diff --git a/.coveragerc b/.coveragerc index 001729ace5bc6c..9fe3e10c8bc27a 100644 --- a/.coveragerc +++ b/.coveragerc @@ -50,6 +50,7 @@ omit = homeassistant/components/asterisk_cdr/mailbox.py homeassistant/components/asterisk_mbox/* homeassistant/components/asuswrt/device_tracker.py + homeassistant/components/atome/* homeassistant/components/august/* homeassistant/components/aurora_abb_powerone/sensor.py homeassistant/components/automatic/device_tracker.py @@ -142,6 +143,7 @@ omit = homeassistant/components/dlna_dmr/media_player.py homeassistant/components/dnsip/sensor.py homeassistant/components/dominos/* + homeassistant/components/doods/* homeassistant/components/doorbird/* homeassistant/components/dovado/* homeassistant/components/downloader/* @@ -196,7 +198,6 @@ omit = homeassistant/components/evohome/* homeassistant/components/familyhub/camera.py homeassistant/components/fastdotcom/* - homeassistant/components/fedex/sensor.py homeassistant/components/ffmpeg/camera.py homeassistant/components/fibaro/* homeassistant/components/filesize/sensor.py @@ -248,6 +249,7 @@ omit = homeassistant/components/greeneye_monitor/sensor.py homeassistant/components/greenwave/light.py homeassistant/components/group/notify.py + homeassistant/components/growatt_server/sensor.py homeassistant/components/gstreamer/media_player.py homeassistant/components/gtfs/sensor.py homeassistant/components/gtt/sensor.py @@ -286,7 +288,15 @@ omit = homeassistant/components/hydrawise/* homeassistant/components/hyperion/light.py homeassistant/components/ialarm/alarm_control_panel.py + homeassistant/components/iaqualink/binary_sensor.py + homeassistant/components/iaqualink/climate.py + homeassistant/components/iaqualink/light.py + homeassistant/components/iaqualink/sensor.py + homeassistant/components/iaqualink/switch.py homeassistant/components/icloud/device_tracker.py + homeassistant/components/izone/climate.py + homeassistant/components/izone/discovery.py + homeassistant/components/izone/__init__.py homeassistant/components/idteck_prox/* homeassistant/components/ifttt/* homeassistant/components/iglo/light.py @@ -307,6 +317,7 @@ omit = homeassistant/components/itunes/media_player.py homeassistant/components/joaoapps_join/* homeassistant/components/juicenet/* + homeassistant/components/kaiterra/* homeassistant/components/kankun/switch.py homeassistant/components/keba/* homeassistant/components/keenetic_ndms2/device_tracker.py @@ -428,11 +439,14 @@ omit = homeassistant/components/nuki/lock.py homeassistant/components/nut/sensor.py homeassistant/components/nx584/alarm_control_panel.py + homeassistant/components/nzbget/__init__.py homeassistant/components/nzbget/sensor.py + homeassistant/components/obihai/* homeassistant/components/octoprint/* homeassistant/components/oem/climate.py homeassistant/components/oasa_telematics/sensor.py homeassistant/components/ohmconnect/sensor.py + homeassistant/components/ombi/* homeassistant/components/onewire/sensor.py homeassistant/components/onkyo/media_player.py homeassistant/components/onvif/camera.py @@ -469,8 +483,10 @@ omit = homeassistant/components/pioneer/media_player.py homeassistant/components/pjlink/media_player.py homeassistant/components/plaato/* + homeassistant/components/plex/__init__.py homeassistant/components/plex/media_player.py homeassistant/components/plex/sensor.py + homeassistant/components/plex/server.py homeassistant/components/plugwise/* homeassistant/components/plum_lightpad/* homeassistant/components/pocketcasts/sensor.py @@ -575,6 +591,7 @@ omit = homeassistant/components/snmp/* homeassistant/components/sochain/sensor.py homeassistant/components/socialblade/sensor.py + homeassistant/components/solaredge/__init__.py homeassistant/components/solaredge/sensor.py homeassistant/components/solaredge_local/sensor.py homeassistant/components/solax/sensor.py @@ -590,7 +607,6 @@ omit = homeassistant/components/spotcrime/sensor.py homeassistant/components/spotify/media_player.py homeassistant/components/squeezebox/media_player.py - homeassistant/components/srp_energy/sensor.py homeassistant/components/starlingbank/sensor.py homeassistant/components/steam_online/sensor.py homeassistant/components/stiebel_eltron/* @@ -611,7 +627,6 @@ omit = homeassistant/components/synologydsm/sensor.py homeassistant/components/syslog/notify.py homeassistant/components/systemmonitor/sensor.py - homeassistant/components/sytadin/sensor.py homeassistant/components/tado/* homeassistant/components/tado/device_tracker.py homeassistant/components/tahoma/* @@ -653,6 +668,7 @@ omit = homeassistant/components/trackr/device_tracker.py homeassistant/components/tradfri/* homeassistant/components/tradfri/light.py + homeassistant/components/tradfri/cover.py homeassistant/components/trafikverket_train/sensor.py homeassistant/components/trafikverket_weatherstation/sensor.py homeassistant/components/transmission/* @@ -670,10 +686,9 @@ omit = homeassistant/components/ue_smart_radio/media_player.py homeassistant/components/upcloud/* homeassistant/components/upnp/* - homeassistant/components/ups/sensor.py + homeassistant/components/upc_connect/* homeassistant/components/uptimerobot/binary_sensor.py homeassistant/components/uscis/sensor.py - homeassistant/components/usps/* homeassistant/components/vallox/* homeassistant/components/vasttrafik/sensor.py homeassistant/components/velbus/__init__.py @@ -692,6 +707,8 @@ omit = homeassistant/components/vesync/const.py homeassistant/components/vesync/switch.py homeassistant/components/viaggiatreno/sensor.py + homeassistant/components/vicare/* + homeassistant/components/vivotek/camera.py homeassistant/components/vizio/media_player.py homeassistant/components/vlc/media_player.py homeassistant/components/vlc_telnet/media_player.py @@ -728,6 +745,7 @@ omit = homeassistant/components/yale_smart_alarm/alarm_control_panel.py homeassistant/components/yamaha/media_player.py homeassistant/components/yamaha_musiccast/media_player.py + homeassistant/components/yandex_transport/* homeassistant/components/yeelight/* homeassistant/components/yeelightsunflower/light.py homeassistant/components/yi/camera.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a025a52e849c1c..e78a8e6851c728 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,7 +3,7 @@ "name": "Home Assistant Dev", "context": "..", "dockerFile": "../Dockerfile.dev", - "postCreateCommand": "pip3 install -e .", + "postCreateCommand": "mkdir -p config && pip3 install -e .", "appPort": 8123, "runArgs": ["-e", "GIT_EDITOR=\"code --wait\""], "extensions": [ diff --git a/.github/stale.yml b/.github/stale.yml index a1a35e9f3b1ada..44cd95e1f5d710 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -13,6 +13,7 @@ onlyLabels: [] # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable exemptLabels: - under investigation + - Help wanted # Set to true to ignore issues in a project (defaults to false) exemptProjects: true diff --git a/.gitignore b/.gitignore index 5389954ca59578..15f0896975da36 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,7 @@ nosetests.xml htmlcov/ test-reports/ test-results.xml +test-output.xml # Translations *.mo diff --git a/.vscode/tasks.json b/.vscode/tasks.json index f57c182809b93e..151868a1663981 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -19,6 +19,7 @@ "label": "Pytest", "type": "shell", "command": "pytest --timeout=10 tests", + "dependsOn": ["Install all Test Requirements"], "group": { "kind": "test", "isDefault": true @@ -85,6 +86,20 @@ "panel": "new" }, "problemMatcher": [] + }, + { + "label": "Install all Test Requirements", + "type": "shell", + "command": "pip3 install -r requirements_test_all.txt -c homeassistant/package_constraints.txt", + "group": { + "kind": "build", + "isDefault": true + }, + "presentation": { + "reveal": "always", + "panel": "new" + }, + "problemMatcher": [] } ] } diff --git a/CODEOWNERS b/CODEOWNERS index 7f60243097e239..7e05cdf0b399e7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -28,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 @@ -109,10 +110,12 @@ homeassistant/components/google_translate/* @awarecan homeassistant/components/google_travel_time/* @robbiet480 homeassistant/components/gpsd/* @fabaff homeassistant/components/group/* @home-assistant/core +homeassistant/components/growatt_server/* @indykoning homeassistant/components/gtfs/* @robbiet480 homeassistant/components/harmony/* @ehendrix23 homeassistant/components/hassio/* @home-assistant/hass-io homeassistant/components/heos/* @andrewsayre +homeassistant/components/here_travel_time/* @eifinger homeassistant/components/hikvision/* @mezz64 homeassistant/components/hikvisioncam/* @fbradyirl homeassistant/components/history/* @home-assistant/core @@ -128,6 +131,7 @@ homeassistant/components/http/* @home-assistant/core homeassistant/components/huawei_lte/* @scop homeassistant/components/huawei_router/* @abmantis homeassistant/components/hue/* @balloob +homeassistant/components/iaqualink/* @flz homeassistant/components/ign_sismologia/* @exxamalte homeassistant/components/incomfort/* @zxdavb homeassistant/components/influxdb/* @fabaff @@ -141,7 +145,9 @@ homeassistant/components/ios/* @robbiet480 homeassistant/components/ipma/* @dgomes homeassistant/components/iqvia/* @bachya homeassistant/components/irish_rail_transport/* @ttroy50 +homeassistant/components/izone/* @Swamp-Ig homeassistant/components/jewish_calendar/* @tsvi +homeassistant/components/kaiterra/* @Michsior14 homeassistant/components/keba/* @dannerph homeassistant/components/knx/* @Julius2342 homeassistant/components/kodi/* @armills @@ -150,9 +156,6 @@ homeassistant/components/lametric/* @robbiet480 homeassistant/components/launch_library/* @ludeeus homeassistant/components/lcn/* @alengwenus homeassistant/components/life360/* @pnbruckner -homeassistant/components/lifx/* @amelchio -homeassistant/components/lifx_cloud/* @amelchio -homeassistant/components/lifx_legacy/* @amelchio homeassistant/components/linky/* @Quentame homeassistant/components/linux_battery/* @fabaff homeassistant/components/liveboxplaytv/* @pschmitt @@ -183,7 +186,6 @@ 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 @@ -192,9 +194,12 @@ homeassistant/components/notify/* @home-assistant/core homeassistant/components/notion/* @bachya homeassistant/components/nsw_fuel_station/* @nickw444 homeassistant/components/nsw_rural_fire_service_feed/* @exxamalte -homeassistant/components/nuki/* @pschmitt +homeassistant/components/nuki/* @pvizeli homeassistant/components/nws/* @MatthewFlamm +homeassistant/components/nzbget/* @chriscla +homeassistant/components/obihai/* @dshokouhi homeassistant/components/ohmconnect/* @robbiet480 +homeassistant/components/ombi/* @larssont homeassistant/components/onboarding/* @home-assistant/core homeassistant/components/opentherm_gw/* @mvn23 homeassistant/components/openuv/* @bachya @@ -205,9 +210,10 @@ homeassistant/components/panel_custom/* @home-assistant/frontend homeassistant/components/panel_iframe/* @home-assistant/frontend homeassistant/components/persistent_notification/* @home-assistant/core homeassistant/components/philips_js/* @elupus -homeassistant/components/pi_hole/* @fabaff +homeassistant/components/pi_hole/* @fabaff @johnluetke homeassistant/components/plaato/* @JohNan homeassistant/components/plant/* @ChristianKuehnel +homeassistant/components/plex/* @jjlawren homeassistant/components/plugwise/* @laetificat @CoMPaTech homeassistant/components/point/* @fredrike homeassistant/components/ps4/* @ktnrg45 @@ -243,11 +249,10 @@ homeassistant/components/smarthab/* @outadoc homeassistant/components/smartthings/* @andrewsayre homeassistant/components/smarty/* @z0mbieprocess homeassistant/components/smtp/* @fabaff -homeassistant/components/solaredge_local/* @drobtravels +homeassistant/components/solaredge_local/* @drobtravels @scheric homeassistant/components/solax/* @squishykid homeassistant/components/somfy/* @tetienne homeassistant/components/songpal/* @rytilahti -homeassistant/components/sonos/* @amelchio homeassistant/components/spaceapi/* @fabaff homeassistant/components/spider/* @peternijssen homeassistant/components/sql/* @dgomes @@ -265,7 +270,6 @@ homeassistant/components/switchmate/* @danielhiversen homeassistant/components/syncthru/* @nielstron homeassistant/components/synology_srm/* @aerialls homeassistant/components/syslog/* @fabaff -homeassistant/components/sytadin/* @gautric homeassistant/components/tahoma/* @philklei homeassistant/components/tautulli/* @ludeeus homeassistant/components/tellduslive/* @fredrike @@ -287,6 +291,7 @@ homeassistant/components/twentemilieu/* @frenck homeassistant/components/twilio_call/* @robbiet480 homeassistant/components/twilio_sms/* @robbiet480 homeassistant/components/unifi/* @kane610 +homeassistant/components/upc_connect/* @pvizeli homeassistant/components/upcloud/* @scop homeassistant/components/updater/* @home-assistant/core homeassistant/components/upnp/* @robbiet480 @@ -297,6 +302,7 @@ homeassistant/components/velbus/* @cereal2nd homeassistant/components/velux/* @Julius2342 homeassistant/components/version/* @fabaff homeassistant/components/vesync/* @markperdue @webdjoe +homeassistant/components/vicare/* @oischinger homeassistant/components/vizio/* @raman325 homeassistant/components/vlc_telnet/* @rodripf homeassistant/components/waqi/* @andrey-git @@ -314,6 +320,7 @@ homeassistant/components/xiaomi_miio/* @rytilahti @syssi homeassistant/components/xiaomi_tv/* @simse homeassistant/components/xmpp/* @fabaff @flowolf homeassistant/components/yamaha_musiccast/* @jalmeroth +homeassistant/components/yandex_transport/* @rishatik92 homeassistant/components/yeelight/* @rytilahti @zewelor homeassistant/components/yeelightsunflower/* @lindsaymarkward homeassistant/components/yessssms/* @flowolf diff --git a/Dockerfile.dev b/Dockerfile.dev index 00f5576bdbb0fc..eb76fe5b16b038 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -23,9 +23,10 @@ RUN git clone --depth 1 https://github.com/home-assistant/hass-release \ WORKDIR /workspaces -# Install Python dependencies from requirements.txt if it exists -COPY requirements_test_all.txt homeassistant/package_constraints.txt /workspaces/ -RUN pip3 install -r requirements_test_all.txt -c package_constraints.txt +# Install Python dependencies from requirements +COPY requirements_test.txt homeassistant/package_constraints.txt ./ +RUN pip3 install -r requirements_test.txt -c package_constraints.txt \ + && rm -f requirements_test.txt package_constraints.txt # Set the default shell to bash instead of sh ENV SHELL /bin/bash diff --git a/azure-pipelines-ci.yml b/azure-pipelines-ci.yml index 0ee272f900daa7..13f0915bc56f12 100644 --- a/azure-pipelines-ci.yml +++ b/azure-pipelines-ci.yml @@ -112,8 +112,10 @@ stages: # Find offending deps with `pipdeptree -r -p typing` pip uninstall -y typing - script: | + set -e + . venv/bin/activate - pytest --timeout=9 --durations=10 --junitxml=test-results.xml -qq -o console_output_style=count -p no:sugar tests + pytest --timeout=9 --durations=10 -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'])) @@ -121,22 +123,11 @@ stages: set -e . 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 + pytest --timeout=9 --durations=10 --cov homeassistant --cov-report html -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 - condition: succeededOrFailed() - inputs: - testResultsFiles: 'test-results.xml' - testRunTitle: 'Publish test results for Python $(python.container)' - - task: PublishCodeCoverageResults@1 - inputs: - codeCoverageTool: cobertura - summaryFileLocation: coverage.xml - displayName: 'publish coverage artifact' - condition: and(succeeded(), eq(variables['python.container'], variables['PythonMain'])) - stage: 'FullCheck' dependsOn: diff --git a/azure-pipelines-release.yml b/azure-pipelines-release.yml index 63ce5b707cf6b2..29e68a5d7acc16 100644 --- a/azure-pipelines-release.yml +++ b/azure-pipelines-release.yml @@ -43,7 +43,7 @@ stages: release="$(Build.SourceBranchName)" created_by="$(curl -s https://api.github.com/repos/home-assistant/home-assistant/releases/tags/${release} | jq --raw-output '.author.login')" - if [[ "${created_by}" =~ ^(balloob|pvizeli|fabaff|robbiet480)$ ]]; then + if [[ "${created_by}" =~ ^(balloob|pvizeli|fabaff|robbiet480|bramkragten)$ ]]; then exit 0 fi @@ -68,8 +68,8 @@ stages: - script: python setup.py sdist bdist_wheel displayName: 'Build package' - script: | - TWINE_USERNAME="$(twineUser)" - TWINE_PASSWORD="$(twinePassword)" + export TWINE_USERNAME="$(twineUser)" + export TWINE_PASSWORD="$(twinePassword)" twine upload dist/* --skip-existing displayName: 'Upload pypi' diff --git a/azure-pipelines-translation.yml b/azure-pipelines-translation.yml index 83ed75da256336..2fd49c056f7fd6 100644 --- a/azure-pipelines-translation.yml +++ b/azure-pipelines-translation.yml @@ -60,6 +60,7 @@ jobs: displayName: 'Download Translation' - script: | git checkout dev + git add homeassistant git commit -am "[ci skip] Translation update" git push displayName: 'Update translation' diff --git a/azure-pipelines-wheels.yml b/azure-pipelines-wheels.yml index eec3f678981b53..42815d8c8ae779 100644 --- a/azure-pipelines-wheels.yml +++ b/azure-pipelines-wheels.yml @@ -45,7 +45,6 @@ jobs: requirement_files="requirements_wheels.txt requirements_diff.txt" for requirement_file in ${requirement_files}; do - sed -i "s|# pytradfri|pytradfri|g" ${requirement_file} sed -i "s|# pybluez|pybluez|g" ${requirement_file} sed -i "s|# bluepy|bluepy|g" ${requirement_file} sed -i "s|# beacontools|beacontools|g" ${requirement_file} @@ -63,9 +62,15 @@ jobs: sed -i "s|# homekit|homekit|g" ${requirement_file} sed -i "s|# decora_wifi|decora_wifi|g" ${requirement_file} sed -i "s|# decora|decora|g" ${requirement_file} + sed -i "s|# avion|avion|g" ${requirement_file} sed -i "s|# PySwitchbot|PySwitchbot|g" ${requirement_file} sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file} sed -i "s|# face_recognition|face_recognition|g" ${requirement_file} sed -i "s|# py_noaa|py_noaa|g" ${requirement_file} + sed -i "s|# bme680|bme680|g" ${requirement_file} + + if [[ "$(buildArch)" =~ arm ]]; then + sed -i "s|# VL53L1X|VL53L1X|g" ${requirement_file} + fi done displayName: 'Prepare requirements files for Hass.io' diff --git a/homeassistant/__main__.py b/homeassistant/__main__.py index 9fe501078c2a45..f7e24d69884975 100644 --- a/homeassistant/__main__.py +++ b/homeassistant/__main__.py @@ -216,7 +216,7 @@ def check_pid(pid_file: str) -> None: try: with open(pid_file, "r") as file: pid = int(file.readline()) - except IOError: + except OSError: # PID File does not exist return @@ -239,7 +239,7 @@ def write_pid(pid_file: str) -> None: try: with open(pid_file, "w") as file: file.write(str(pid)) - except IOError: + except OSError: print(f"Fatal Error: Unable to write pid file {pid_file}") sys.exit(1) @@ -258,7 +258,7 @@ def closefds_osx(min_fd: int, max_fd: int) -> None: val = fcntl(_fd, F_GETFD) if not val & FD_CLOEXEC: fcntl(_fd, F_SETFD, val | FD_CLOEXEC) - except IOError: + except OSError: pass diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py index 26055032422f0c..6889d17a25fe6d 100644 --- a/homeassistant/auth/models.py +++ b/homeassistant/auth/models.py @@ -20,7 +20,7 @@ class Group: """A group.""" - name = attr.ib(type=str) # type: Optional[str] + name = attr.ib(type=Optional[str]) policy = attr.ib(type=perm_mdl.PolicyType) id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) system_generated = attr.ib(type=bool, default=False) @@ -30,22 +30,20 @@ class Group: class User: """A user.""" - name = attr.ib(type=str) # type: Optional[str] + name = attr.ib(type=Optional[str]) perm_lookup = attr.ib(type=perm_mdl.PermissionLookup, cmp=False) id = attr.ib(type=str, factory=lambda: uuid.uuid4().hex) is_owner = attr.ib(type=bool, default=False) is_active = attr.ib(type=bool, default=False) system_generated = attr.ib(type=bool, default=False) - groups = attr.ib(type=List, factory=list, cmp=False) # type: List[Group] + groups = attr.ib(type=List[Group], factory=list, cmp=False) # List of credentials of a user. - credentials = attr.ib(type=list, factory=list, cmp=False) # type: List[Credentials] + credentials = attr.ib(type=List["Credentials"], factory=list, cmp=False) # Tokens associated with a user. - refresh_tokens = attr.ib( - type=dict, factory=dict, cmp=False - ) # type: Dict[str, RefreshToken] + refresh_tokens = attr.ib(type=Dict[str, "RefreshToken"], factory=dict, cmp=False) _permissions = attr.ib( type=Optional[perm_mdl.PolicyPermissions], init=False, cmp=False, default=None diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index ef294491141042..7c4ec731b49b93 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -97,6 +97,17 @@ async def async_from_config_dict( stop = time() _LOGGER.info("Home Assistant initialized in %.2fs", stop - start) + if sys.version_info[:3] < (3, 6, 1): + msg = ( + "Python 3.6.0 support is deprecated and will " + "be removed in the first release after October 2. Please " + "upgrade Python to 3.6.1 or higher." + ) + _LOGGER.warning(msg) + hass.components.persistent_notification.async_create( + msg, "Python version", "python_version" + ) + return hass diff --git a/homeassistant/components/adguard/.translations/es.json b/homeassistant/components/adguard/.translations/es.json index 971d38f9ab2d26..5886d8e5c5b6fc 100644 --- a/homeassistant/components/adguard/.translations/es.json +++ b/homeassistant/components/adguard/.translations/es.json @@ -1,7 +1,30 @@ { "config": { "abort": { - "existing_instance_updated": "Se ha actualizado la configuraci\u00f3n existente." - } + "existing_instance_updated": "Se ha actualizado la configuraci\u00f3n existente.", + "single_instance_allowed": "S\u00f3lo se permite una \u00fanica configuraci\u00f3n de AdGuard Home." + }, + "error": { + "connection_error": "No se conect\u00f3." + }, + "step": { + "hassio_confirm": { + "description": "\u00bfDesea configurar Home Assistant para conectarse al AdGuard Home proporcionado por el complemento Hass.io: {addon} ?", + "title": "AdGuard Home a trav\u00e9s del complemento Hass.io" + }, + "user": { + "data": { + "host": "Host", + "password": "Contrase\u00f1a", + "port": "Puerto", + "ssl": "AdGuard Home utiliza un certificado SSL", + "username": "Nombre de usuario", + "verify_ssl": "AdGuard Home utiliza un certificado apropiado" + }, + "description": "Configure su instancia de AdGuard Home para permitir la supervisi\u00f3n y el control.", + "title": "Enlace su AdGuard Home." + } + }, + "title": "AdGuard Home" } } \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/it.json b/homeassistant/components/adguard/.translations/it.json index 6cd8767334dc9f..57f81dc1d99ad5 100644 --- a/homeassistant/components/adguard/.translations/it.json +++ b/homeassistant/components/adguard/.translations/it.json @@ -1,21 +1,30 @@ { "config": { "abort": { + "existing_instance_updated": "Configurazione esistente aggiornata.", "single_instance_allowed": "\u00c8 consentita solo una singola configurazione di AdGuard Home." }, "error": { "connection_error": "Impossibile connettersi." }, "step": { + "hassio_confirm": { + "description": "Vuoi configurare Home Assistant per connettersi alla AdGuard Home fornita dal componente aggiuntivo di Hass.io: {addon} ?", + "title": "AdGuard Home tramite il componente aggiuntivo di Hass.io" + }, "user": { "data": { "host": "Host", "password": "Password", "port": "Porta", "ssl": "AdGuard Home utilizza un certificato SSL", - "username": "Nome utente" - } + "username": "Nome utente", + "verify_ssl": "AdGuard Home utilizza un certificato appropriato" + }, + "description": "Configura l'istanza di AdGuard Home per consentire il monitoraggio e il controllo.", + "title": "Collega la tua AdGuard Home." } - } + }, + "title": "AdGuard Home" } } \ No newline at end of file diff --git a/homeassistant/components/adguard/.translations/pl.json b/homeassistant/components/adguard/.translations/pl.json index e58c901f3643f4..f8f64d542608fe 100644 --- a/homeassistant/components/adguard/.translations/pl.json +++ b/homeassistant/components/adguard/.translations/pl.json @@ -22,7 +22,7 @@ "verify_ssl": "AdGuard Home u\u017cywa odpowiedniego certyfikatu." }, "description": "Skonfiguruj instancj\u0119 AdGuard Home, aby umo\u017cliwi\u0107 monitorowanie i kontrol\u0119.", - "title": "Po\u0142\u0105cz sw\u00f3j AdGuard Home" + "title": "Po\u0142\u0105cz AdGuard Home" } }, "title": "AdGuard Home" diff --git a/homeassistant/components/alexa/capabilities.py b/homeassistant/components/alexa/capabilities.py index d769f797da1bfa..aeaa0a62c4bdbb 100644 --- a/homeassistant/components/alexa/capabilities.py +++ b/homeassistant/components/alexa/capabilities.py @@ -1,5 +1,4 @@ """Alexa capabilities.""" -from datetime import datetime import logging from homeassistant.const import ( @@ -16,6 +15,7 @@ import homeassistant.components.climate.const as climate from homeassistant.components import light, fan, cover import homeassistant.util.color as color_util +import homeassistant.util.dt as dt_util from .const import ( API_TEMP_UNITS, @@ -109,7 +109,7 @@ def serialize_properties(self): "name": prop_name, "namespace": self.name(), "value": prop_value, - "timeOfSample": datetime.now().strftime(DATE_FORMAT), + "timeOfSample": dt_util.utcnow().strftime(DATE_FORMAT), "uncertaintyInMilliseconds": 0, } diff --git a/homeassistant/components/alexa/flash_briefings.py b/homeassistant/components/alexa/flash_briefings.py index 708d1592e4c0fb..0b5c1243764ce2 100644 --- a/homeassistant/components/alexa/flash_briefings.py +++ b/homeassistant/components/alexa/flash_briefings.py @@ -1,9 +1,9 @@ """Support for Alexa skill service end point.""" import copy -from datetime import datetime import logging import uuid +import homeassistant.util.dt as dt_util from homeassistant.components import http from homeassistant.core import callback from homeassistant.helpers import template @@ -89,7 +89,7 @@ def get(self, request, briefing_id): else: output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL) - output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT) + output[ATTR_UPDATE_DATE] = dt_util.utcnow().strftime(DATE_FORMAT) briefing.append(output) diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 1e636b96ee5205..c72101460c4819 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -1,5 +1,4 @@ """Alexa message handlers.""" -from datetime import datetime import logging import math @@ -28,6 +27,7 @@ TEMP_FAHRENHEIT, ) import homeassistant.util.color as color_util +import homeassistant.util.dt as dt_util from homeassistant.util.decorator import Registry from homeassistant.util.temperature import convert as convert_temperature @@ -275,7 +275,7 @@ async def async_api_activate(hass, config, directive, context): payload = { "cause": {"type": Cause.VOICE_INTERACTION}, - "timestamp": "%sZ" % (datetime.utcnow().isoformat(),), + "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", } return directive.response( @@ -299,7 +299,7 @@ async def async_api_deactivate(hass, config, directive, context): payload = { "cause": {"type": Cause.VOICE_INTERACTION}, - "timestamp": "%sZ" % (datetime.utcnow().isoformat(),), + "timestamp": f"{dt_util.utcnow().replace(tzinfo=None).isoformat()}Z", } return directive.response( diff --git a/homeassistant/components/ambiclimate/.translations/it.json b/homeassistant/components/ambiclimate/.translations/it.json index b062eb67c1ffea..a13874b36764d5 100644 --- a/homeassistant/components/ambiclimate/.translations/it.json +++ b/homeassistant/components/ambiclimate/.translations/it.json @@ -1,7 +1,22 @@ { "config": { "abort": { - "already_setup": "L'account Ambiclimate \u00e8 configurato." + "access_token": "Errore sconosciuto durante la generazione di un token di accesso.", + "already_setup": "L'account Ambiclimate \u00e8 configurato.", + "no_config": "\u00c8 necessario configurare Ambiclimate prima di poter eseguire l'autenticazione con esso. [Leggere le istruzioni] (https://www.home-assistant.io/components/ambiclimate/)." + }, + "create_entry": { + "default": "Autenticato con successo con Ambiclimate" + }, + "error": { + "follow_link": "Si prega di seguire il link e di autenticarsi prima di premere Invia", + "no_token": "Non autenticato con Ambiclimate" + }, + "step": { + "auth": { + "description": "Segui questo [link]({authorization_url}) e Consenti accesso al tuo account Ambiclimate, quindi torna indietro e premi Invia qui sotto. \n (Assicurati che l'URL di richiamata specificato sia {cb_url})", + "title": "Autenticare Ambiclimate" + } }, "title": "Ambiclimate" } diff --git a/homeassistant/components/ambiclimate/.translations/no.json b/homeassistant/components/ambiclimate/.translations/no.json index 567d0b95ff38cb..7bb124ae5433e1 100644 --- a/homeassistant/components/ambiclimate/.translations/no.json +++ b/homeassistant/components/ambiclimate/.translations/no.json @@ -14,7 +14,7 @@ }, "step": { "auth": { - "description": "Vennligst f\u00f8lg denne [linken]({authorization_url}) og Tillat tilgang til din Ambiclimate konto, og kom s\u00e5 tilbake og trykk Send nedenfor.\n(Kontroller at den angitte URL-adressen for tilbakeringing er {cb_url})", + "description": "Vennligst f\u00f8lg denne [linken]({authorization_url}) og Tillat tilgang til din Ambiclimate konto, kom deretter tilbake og trykk Send nedenfor.\n(Kontroller at den angitte URL-adressen for tilbakeringing er {cb_url})", "title": "Autensiere Ambiclimate" } }, diff --git a/homeassistant/components/ambiclimate/.translations/pl.json b/homeassistant/components/ambiclimate/.translations/pl.json index 47e9c9f35b2897..7ba95b007c995a 100644 --- a/homeassistant/components/ambiclimate/.translations/pl.json +++ b/homeassistant/components/ambiclimate/.translations/pl.json @@ -14,7 +14,7 @@ }, "step": { "auth": { - "description": "Kliknij poni\u017cszy [link]({authorization_url}) i Zezw\u00f3l na dost\u0119p do swojego konta Ambiclimate, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Prze\u015blij poni\u017cej. \n(Upewnij si\u0119, \u017ce podany adres URL to {cb_url})", + "description": "Kliknij poni\u017cszy [link]({authorization_url}) i Zezw\u00f3l na dost\u0119p do konta Ambiclimate, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Prze\u015blij poni\u017cej. \n(Upewnij si\u0119, \u017ce podany adres URL to {cb_url})", "title": "Uwierzytelnienie Ambiclimate" } }, diff --git a/homeassistant/components/ambiclimate/.translations/ru.json b/homeassistant/components/ambiclimate/.translations/ru.json index 129579315a29de..a4300e1e5306c5 100644 --- a/homeassistant/components/ambiclimate/.translations/ru.json +++ b/homeassistant/components/ambiclimate/.translations/ru.json @@ -3,7 +3,7 @@ "abort": { "access_token": "\u041f\u0440\u0438 \u0441\u043e\u0437\u0434\u0430\u043d\u0438\u0438 \u0442\u043e\u043a\u0435\u043d\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u043f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043e\u0448\u0438\u0431\u043a\u0430.", "already_setup": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c Ambi Climate \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430.", - "no_config": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Ambi Climate \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. [\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/ambiclimate/)." + "no_config": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 Ambi Climate \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/ambiclimate/)." }, "create_entry": { "default": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." diff --git a/homeassistant/components/ambient_station/.translations/it.json b/homeassistant/components/ambient_station/.translations/it.json index f87c987a79fba4..b468ba3673cd5d 100644 --- a/homeassistant/components/ambient_station/.translations/it.json +++ b/homeassistant/components/ambient_station/.translations/it.json @@ -13,6 +13,7 @@ }, "title": "Inserisci i tuoi dati" } - } + }, + "title": "PWS ambientale" } } \ No newline at end of file diff --git a/homeassistant/components/ambient_station/.translations/pl.json b/homeassistant/components/ambient_station/.translations/pl.json index 2140b4e29fe27c..6ebd0848a632fe 100644 --- a/homeassistant/components/ambient_station/.translations/pl.json +++ b/homeassistant/components/ambient_station/.translations/pl.json @@ -11,7 +11,7 @@ "api_key": "Klucz API", "app_key": "Klucz aplikacji" }, - "title": "Wprowad\u017a swoje dane" + "title": "Wprowad\u017a dane" } }, "title": "Ambient PWS" diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index 2c8fc98e24865a..6643faa85bdeb3 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.26" + "androidtv==0.0.27" ], "dependencies": [], "codeowners": ["@JeffLIrion"] diff --git a/homeassistant/components/arcam_fmj/.translations/de.json b/homeassistant/components/arcam_fmj/.translations/de.json new file mode 100644 index 00000000000000..b0ad4660d0fef1 --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/de.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/es.json b/homeassistant/components/arcam_fmj/.translations/es.json new file mode 100644 index 00000000000000..b0ad4660d0fef1 --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/es.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/fr.json b/homeassistant/components/arcam_fmj/.translations/fr.json new file mode 100644 index 00000000000000..b0ad4660d0fef1 --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/fr.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/it.json b/homeassistant/components/arcam_fmj/.translations/it.json new file mode 100644 index 00000000000000..b0ad4660d0fef1 --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/it.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/ko.json b/homeassistant/components/arcam_fmj/.translations/ko.json new file mode 100644 index 00000000000000..b0ad4660d0fef1 --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/ko.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/lb.json b/homeassistant/components/arcam_fmj/.translations/lb.json new file mode 100644 index 00000000000000..b0ad4660d0fef1 --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/lb.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/arcam_fmj/.translations/sl.json b/homeassistant/components/arcam_fmj/.translations/sl.json new file mode 100644 index 00000000000000..b0ad4660d0fef1 --- /dev/null +++ b/homeassistant/components/arcam_fmj/.translations/sl.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Arcam FMJ" + } +} \ No newline at end of file diff --git a/homeassistant/components/atome/__init__.py b/homeassistant/components/atome/__init__.py new file mode 100644 index 00000000000000..6f524606a817bf --- /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 00000000000000..621faba4fc0a33 --- /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.1.1"] +} diff --git a/homeassistant/components/atome/sensor.py b/homeassistant/components/atome/sensor.py new file mode 100644 index 00000000000000..c98b634bb2111d --- /dev/null +++ b/homeassistant/components/atome/sensor.py @@ -0,0 +1,279 @@ +"""Linky Atome.""" +import logging +from datetime import timedelta + +import voluptuous as vol +from pyatome.client import AtomeClient +from pyatome.client import PyAtomeError + +from homeassistant.const import ( + CONF_PASSWORD, + CONF_USERNAME, + CONF_NAME, + DEVICE_CLASS_POWER, + POWER_WATT, + ENERGY_KILO_WATT_HOUR, +) +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" + +LIVE_SCAN_INTERVAL = timedelta(seconds=30) +DAILY_SCAN_INTERVAL = timedelta(seconds=150) +WEEKLY_SCAN_INTERVAL = timedelta(hours=1) +MONTHLY_SCAN_INTERVAL = timedelta(hours=1) +YEARLY_SCAN_INTERVAL = timedelta(days=1) + +LIVE_NAME = "Atome Live Power" +DAILY_NAME = "Atome Daily" +WEEKLY_NAME = "Atome Weekly" +MONTHLY_NAME = "Atome Monthly" +YEARLY_NAME = "Atome Yearly" + +LIVE_TYPE = "live" +DAILY_TYPE = "day" +WEEKLY_TYPE = "week" +MONTHLY_TYPE = "month" +YEARLY_TYPE = "year" + +ICON = "mdi:flash" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Atome sensor.""" + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + try: + atome_client = AtomeClient(username, password) + atome_client.login() + except PyAtomeError as exp: + _LOGGER.error(exp) + return + + data = AtomeData(atome_client) + + sensors = [] + sensors.append(AtomeSensor(data, LIVE_NAME, LIVE_TYPE)) + sensors.append(AtomeSensor(data, DAILY_NAME, DAILY_TYPE)) + sensors.append(AtomeSensor(data, WEEKLY_NAME, WEEKLY_TYPE)) + sensors.append(AtomeSensor(data, MONTHLY_NAME, MONTHLY_TYPE)) + sensors.append(AtomeSensor(data, YEARLY_NAME, YEARLY_TYPE)) + + add_entities(sensors, True) + + +class AtomeData: + """Stores data retrieved from Neurio sensor.""" + + def __init__(self, client: AtomeClient): + """Initialize the data.""" + self.atome_client = client + self._live_power = None + self._subscribed_power = None + self._is_connected = None + self._day_usage = None + self._day_price = None + self._week_usage = None + self._week_price = None + self._month_usage = None + self._month_price = None + self._year_usage = None + self._year_price = None + + @property + def live_power(self): + """Return latest active power value.""" + return self._live_power + + @property + def subscribed_power(self): + """Return latest active power value.""" + return self._subscribed_power + + @property + def is_connected(self): + """Return latest active power value.""" + return self._is_connected + + @Throttle(LIVE_SCAN_INTERVAL) + def update_live_usage(self): + """Return current power value.""" + try: + values = self.atome_client.get_live() + self._live_power = values["last"] + self._subscribed_power = values["subscribed"] + self._is_connected = values["isConnected"] + _LOGGER.debug( + "Updating Atome live data. Got: %d, isConnected: %s, subscribed: %d", + self._live_power, + self._is_connected, + self._subscribed_power, + ) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + @property + def day_usage(self): + """Return latest daily usage value.""" + return self._day_usage + + @property + def day_price(self): + """Return latest daily usage value.""" + return self._day_price + + @Throttle(DAILY_SCAN_INTERVAL) + def update_day_usage(self): + """Return current daily power usage.""" + try: + values = self.atome_client.get_consumption(DAILY_TYPE) + self._day_usage = values["total"] / 1000 + self._day_price = values["price"] + _LOGGER.debug("Updating Atome daily data. Got: %d.", self._day_usage) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + @property + def week_usage(self): + """Return latest weekly usage value.""" + return self._week_usage + + @property + def week_price(self): + """Return latest weekly usage value.""" + return self._week_price + + @Throttle(WEEKLY_SCAN_INTERVAL) + def update_week_usage(self): + """Return current weekly power usage.""" + try: + values = self.atome_client.get_consumption(WEEKLY_TYPE) + self._week_usage = values["total"] / 1000 + self._week_price = values["price"] + _LOGGER.debug("Updating Atome weekly data. Got: %d.", self._week_usage) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + @property + def month_usage(self): + """Return latest monthly usage value.""" + return self._month_usage + + @property + def month_price(self): + """Return latest monthly usage value.""" + return self._month_price + + @Throttle(MONTHLY_SCAN_INTERVAL) + def update_month_usage(self): + """Return current monthly power usage.""" + try: + values = self.atome_client.get_consumption(MONTHLY_TYPE) + self._month_usage = values["total"] / 1000 + self._month_price = values["price"] + _LOGGER.debug("Updating Atome monthly data. Got: %d.", self._month_usage) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + @property + def year_usage(self): + """Return latest yearly usage value.""" + return self._year_usage + + @property + def year_price(self): + """Return latest yearly usage value.""" + return self._year_price + + @Throttle(YEARLY_SCAN_INTERVAL) + def update_year_usage(self): + """Return current yearly power usage.""" + try: + values = self.atome_client.get_consumption(YEARLY_TYPE) + self._year_usage = values["total"] / 1000 + self._year_price = values["price"] + _LOGGER.debug("Updating Atome yearly data. Got: %d.", self._year_usage) + + except KeyError as error: + _LOGGER.error("Missing last value in values: %s: %s", values, error) + + +class AtomeSensor(Entity): + """Representation of a sensor entity for Atome.""" + + def __init__(self, data, name, sensor_type): + """Initialize the sensor.""" + self._name = name + self._data = data + self._state = None + self._attributes = {} + + self._sensor_type = sensor_type + + if sensor_type == LIVE_TYPE: + self._unit_of_measurement = POWER_WATT + else: + self._unit_of_measurement = ENERGY_KILO_WATT_HOUR + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_POWER + + def update(self): + """Update device state.""" + update_function = getattr(self._data, f"update_{self._sensor_type}_usage") + update_function() + + if self._sensor_type == LIVE_TYPE: + self._state = self._data.live_power + self._attributes["subscribed_power"] = self._data.subscribed_power + self._attributes["is_connected"] = self._data.is_connected + else: + self._state = getattr(self._data, f"{self._sensor_type}_usage") + self._attributes["price"] = getattr( + self._data, f"{self._sensor_type}_price" + ) diff --git a/homeassistant/components/auth/.translations/it.json b/homeassistant/components/auth/.translations/it.json index be06f0209c409d..dbfe4acd6156ab 100644 --- a/homeassistant/components/auth/.translations/it.json +++ b/homeassistant/components/auth/.translations/it.json @@ -10,7 +10,7 @@ "step": { "init": { "description": "Selezionare uno dei servizi di notifica:", - "title": "Imposta la password one-time fornita dal componente di notifica" + "title": "Imposta la password monouso fornita dal componente di notifica" }, "setup": { "description": "\u00c8 stata inviata una password monouso tramite **notify.{notify_service}**. Per favore, inseriscila qui sotto:", @@ -25,7 +25,7 @@ }, "step": { "init": { - "description": "Per attivare l'autenticazione a due fattori utilizzando password monouso basate sul tempo, eseguire la scansione del codice QR con l'app di autenticazione. Se non ne hai uno, ti consigliamo [Google Authenticator] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \n Dopo aver scansionato il codice, inserisci il codice a sei cifre dalla tua app per verificare la configurazione. Se riscontri problemi con la scansione del codice QR, esegui una configurazione manuale con codice ** ` {code} ` **.", + "description": "Per attivare l'autenticazione a due fattori utilizzando le password monouso basate sul tempo, eseguire la scansione del codice QR con l'app di autenticazione. Se non ne hai uno, ti consigliamo [Google Authenticator] (https://support.google.com/accounts/answer/1066447) o [Authy] (https://authy.com/). \n\n {qr_code} \n \nDopo la scansione, inserisci il codice a sei cifre dalla tua app per verificare la configurazione. Se riscontri problemi con la scansione del codice QR, esegui una configurazione manuale con il codice ** ` {code} ` **.", "title": "Imposta l'autenticazione a due fattori usando TOTP" } }, diff --git a/homeassistant/components/auth/.translations/ko.json b/homeassistant/components/auth/.translations/ko.json index 6c2e8988d83c58..1cb70519b20f45 100644 --- a/homeassistant/components/auth/.translations/ko.json +++ b/homeassistant/components/auth/.translations/ko.json @@ -25,7 +25,7 @@ }, "step": { "init": { - "description": "\uc2dc\uac04 \uae30\ubc18\uc758 \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638\ub97c \uc0ac\uc6a9\ud558\ub294 2\ub2e8\uacc4 \uc778\uc99d\uc744 \ud558\ub824\uba74 \uc778\uc99d\uc6a9 \uc571\uc744 \uc774\uc6a9\ud574\uc11c QR \ucf54\ub4dc\ub97c \uc2a4\uce94\ud574\uc8fc\uc138\uc694. \uc778\uc99d\uc6a9 \uc571\uc740 [Google OTP](https://support.google.com/accounts/answer/1066447) \ub610\ub294 [Authy](https://authy.com/) \ub97c \ucd94\ucc9c\ub4dc\ub9bd\ub2c8\ub2e4.\n\n{qr_code}\n\n\uc2a4\uce94 \ud6c4\uc5d0 \uc0dd\uc131\ub41c 6\uc790\ub9ac \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc11c \uc124\uc815\uc744 \ud655\uc778\ud558\uc138\uc694. QR \ucf54\ub4dc \uc2a4\uce94\uc5d0 \ubb38\uc81c\uac00 \uc788\ub2e4\uba74, **`{code}`** \ucf54\ub4dc\ub85c \uc9c1\uc811 \uc124\uc815\ud574\ubcf4\uc138\uc694.", + "description": "\uc2dc\uac04 \uae30\ubc18\uc758 \uc77c\ud68c\uc6a9 \ube44\ubc00\ubc88\ud638\ub97c \uc0ac\uc6a9\ud558\ub294 2\ub2e8\uacc4 \uc778\uc99d\uc744 \ud558\ub824\uba74 \uc778\uc99d\uc6a9 \uc571\uc744 \uc774\uc6a9\ud574\uc11c QR \ucf54\ub4dc\ub97c \uc2a4\uce94\ud574\uc8fc\uc138\uc694. \uc778\uc99d\uc6a9 \uc571\uc740 [\uad6c\uae00 OTP](https://support.google.com/accounts/answer/1066447) \ub610\ub294 [Authy](https://authy.com/) \ub97c \ucd94\ucc9c\ub4dc\ub9bd\ub2c8\ub2e4.\n\n{qr_code}\n\n\uc2a4\uce94 \ud6c4\uc5d0 \uc0dd\uc131\ub41c 6\uc790\ub9ac \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc11c \uc124\uc815\uc744 \ud655\uc778\ud558\uc138\uc694. QR \ucf54\ub4dc \uc2a4\uce94\uc5d0 \ubb38\uc81c\uac00 \uc788\ub2e4\uba74, **`{code}`** \ucf54\ub4dc\ub85c \uc9c1\uc811 \uc124\uc815\ud574\ubcf4\uc138\uc694.", "title": "TOTP 2\ub2e8\uacc4 \uc778\uc99d \uad6c\uc131" } }, diff --git a/homeassistant/components/auth/.translations/pl.json b/homeassistant/components/auth/.translations/pl.json index f0e9f7b71ea449..78610a5324fe39 100644 --- a/homeassistant/components/auth/.translations/pl.json +++ b/homeassistant/components/auth/.translations/pl.json @@ -13,7 +13,7 @@ "title": "Skonfiguruj has\u0142o jednorazowe dostarczone przez komponent powiadomie\u0144" }, "setup": { - "description": "Has\u0142o jednorazowe zosta\u0142o wys\u0142ane przez **notify.{notify_service}**. Wpisz je poni\u017cej:", + "description": "Has\u0142o jednorazowe zosta\u0142o wys\u0142ane przez **notify.{notify_service}**. Wprowad\u017a je poni\u017cej:", "title": "Sprawd\u017a konfiguracj\u0119" } }, diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 1cffd361b19210..f0529f126f1e73 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -3,9 +3,13 @@ from functools import partial import importlib import logging +from typing import Any import voluptuous as vol +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, @@ -31,7 +35,7 @@ from homeassistant.util.dt import parse_datetime, utcnow -# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs +# mypy: allow-untyped-calls, allow-untyped-defs # mypy: no-check-untyped-defs, no-warn-return-any DOMAIN = "automation" @@ -40,6 +44,7 @@ GROUP_NAME_ALL_AUTOMATIONS = "all automations" CONF_ALIAS = "alias" +CONF_DESCRIPTION = "description" CONF_HIDE_ENTITY = "hide_entity" CONF_CONDITION = "condition" @@ -92,6 +97,7 @@ def _platform_validator(config): # str on purpose CONF_ID: str, CONF_ALIAS: cv.string, + vol.Optional(CONF_DESCRIPTION): cv.string, vol.Optional(CONF_INITIAL_STATE): cv.boolean, vol.Optional(CONF_HIDE_ENTITY, default=DEFAULT_HIDE_ENTITY): cv.boolean, vol.Required(CONF_TRIGGER): _TRIGGER_SCHEMA, @@ -276,11 +282,11 @@ async def async_added_to_hass(self) -> None: if enable_automation: await self.async_enable() - async def async_turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on and update the state.""" await self.async_enable() - async def async_turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" await self.async_disable() @@ -386,7 +392,7 @@ async def _async_process_config(hass, config, component): action = _async_get_action(hass, config_block.get(CONF_ACTION, {}), name) if CONF_CONDITION in config_block: - cond_func = _async_process_if(hass, config, config_block) + cond_func = await _async_process_if(hass, config, config_block) if cond_func is None: continue @@ -437,14 +443,14 @@ async def action(entity_id, variables, context): return action -def _async_process_if(hass, config, p_config): +async def _async_process_if(hass, config, p_config): """Process if checks.""" if_configs = p_config.get(CONF_CONDITION) checks = [] for if_config in if_configs: try: - checks.append(condition.async_from_config(if_config, False)) + checks.append(await condition.async_from_config(hass, if_config, False)) except HomeAssistantError as ex: _LOGGER.warning("Invalid condition: %s", ex) return None @@ -467,7 +473,10 @@ async def _async_process_trigger(hass, config, trigger_configs, name, action): for conf in trigger_configs: platform = importlib.import_module(".{}".format(conf[CONF_PLATFORM]), __name__) - remove = await platform.async_trigger(hass, conf, action, info) + try: + remove = await platform.async_trigger(hass, conf, action, info) + except InvalidDeviceAutomationConfig: + remove = False if not remove: _LOGGER.error("Error setting up trigger %s", name) diff --git a/homeassistant/components/axis/.translations/es.json b/homeassistant/components/axis/.translations/es.json index 817737eee04d88..d29481a3be96f8 100644 --- a/homeassistant/components/axis/.translations/es.json +++ b/homeassistant/components/axis/.translations/es.json @@ -8,6 +8,7 @@ }, "error": { "already_configured": "El dispositivo ya est\u00e1 configurado", + "already_in_progress": "El flujo de configuraci\u00f3n del dispositivo ya est\u00e1 en curso.", "device_unavailable": "El dispositivo no est\u00e1 disponible", "faulty_credentials": "Credenciales de usuario incorrectas" }, diff --git a/homeassistant/components/axis/.translations/it.json b/homeassistant/components/axis/.translations/it.json index 2498c28ec33aca..e979af0883656f 100644 --- a/homeassistant/components/axis/.translations/it.json +++ b/homeassistant/components/axis/.translations/it.json @@ -3,6 +3,7 @@ "abort": { "already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato", "bad_config_file": "Dati errati dal file di configurazione", + "link_local_address": "Gli indirizzi locali di collegamento non sono supportati", "not_axis_device": "Il dispositivo rilevato non \u00e8 un dispositivo Axis" }, "error": { diff --git a/homeassistant/components/bbox/device_tracker.py b/homeassistant/components/bbox/device_tracker.py index b565d05685f979..89449aeab4592f 100644 --- a/homeassistant/components/bbox/device_tracker.py +++ b/homeassistant/components/bbox/device_tracker.py @@ -2,6 +2,7 @@ from collections import namedtuple from datetime import timedelta import logging +from typing import List import voluptuous as vol @@ -41,12 +42,11 @@ class BboxDeviceScanner(DeviceScanner): def __init__(self, config): """Get host from config.""" - from typing import List # noqa: pylint: disable=unused-import self.host = config[CONF_HOST] """Initialize the scanner.""" - self.last_results = [] # type: List[Device] + self.last_results: List[Device] = [] self.success_init = self._update_info() _LOGGER.info("Scanner initialized") diff --git a/homeassistant/components/bbox/sensor.py b/homeassistant/components/bbox/sensor.py index b59b166e41f145..ba38f8d2607dc3 100644 --- a/homeassistant/components/bbox/sensor.py +++ b/homeassistant/components/bbox/sensor.py @@ -13,7 +13,7 @@ _LOGGER = logging.getLogger(__name__) -BANDWIDTH_MEGABITS_SECONDS = "Mb/s" # type: str +BANDWIDTH_MEGABITS_SECONDS = "Mb/s" ATTRIBUTION = "Powered by Bouygues Telecom" diff --git a/homeassistant/components/binary_sensor/.translations/en.json b/homeassistant/components/binary_sensor/.translations/en.json new file mode 100644 index 00000000000000..6379df936b898d --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/en.json @@ -0,0 +1,92 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} battery is low", + "is_cold": "{entity_name} is cold", + "is_connected": "{entity_name} is connected", + "is_gas": "{entity_name} is detecting gas", + "is_hot": "{entity_name} is hot", + "is_light": "{entity_name} is detecting light", + "is_locked": "{entity_name} is locked", + "is_moist": "{entity_name} is moist", + "is_motion": "{entity_name} is detecting motion", + "is_moving": "{entity_name} is moving", + "is_no_gas": "{entity_name} is not detecting gas", + "is_no_light": "{entity_name} is not detecting light", + "is_no_motion": "{entity_name} is not detecting motion", + "is_no_problem": "{entity_name} is not detecting problem", + "is_no_smoke": "{entity_name} is not detecting smoke", + "is_no_sound": "{entity_name} is not detecting sound", + "is_no_vibration": "{entity_name} is not detecting vibration", + "is_not_bat_low": "{entity_name} battery is normal", + "is_not_cold": "{entity_name} is not cold", + "is_not_connected": "{entity_name} is disconnected", + "is_not_hot": "{entity_name} is not hot", + "is_not_locked": "{entity_name} is unlocked", + "is_not_moist": "{entity_name} is dry", + "is_not_moving": "{entity_name} is not moving", + "is_not_occupied": "{entity_name} is not occupied", + "is_not_open": "{entity_name} is closed", + "is_not_plugged_in": "{entity_name} is unplugged", + "is_not_powered": "{entity_name} is not powered", + "is_not_present": "{entity_name} is not present", + "is_not_unsafe": "{entity_name} is safe", + "is_occupied": "{entity_name} is occupied", + "is_off": "{entity_name} is off", + "is_on": "{entity_name} is on", + "is_open": "{entity_name} is open", + "is_plugged_in": "{entity_name} is plugged in", + "is_powered": "{entity_name} is powered", + "is_present": "{entity_name} is present", + "is_problem": "{entity_name} is detecting problem", + "is_smoke": "{entity_name} is detecting smoke", + "is_sound": "{entity_name} is detecting sound", + "is_unsafe": "{entity_name} is unsafe", + "is_vibration": "{entity_name} is detecting vibration" + }, + "trigger_type": { + "bat_low": "{entity_name} battery low", + "closed": "{entity_name} closed", + "cold": "{entity_name} became cold", + "connected": "{entity_name} connected", + "gas": "{entity_name} started detecting gas", + "hot": "{entity_name} became hot", + "light": "{entity_name} started detecting light", + "locked": "{entity_name} locked", + "moist\u00a7": "{entity_name} became moist", + "motion": "{entity_name} started detecting motion", + "moving": "{entity_name} started moving", + "no_gas": "{entity_name} stopped detecting gas", + "no_light": "{entity_name} stopped detecting light", + "no_motion": "{entity_name} stopped detecting motion", + "no_problem": "{entity_name} stopped detecting problem", + "no_smoke": "{entity_name} stopped detecting smoke", + "no_sound": "{entity_name} stopped detecting sound", + "no_vibration": "{entity_name} stopped detecting vibration", + "not_bat_low": "{entity_name} battery normal", + "not_cold": "{entity_name} became not cold", + "not_connected": "{entity_name} disconnected", + "not_hot": "{entity_name} became not hot", + "not_locked": "{entity_name} unlocked", + "not_moist": "{entity_name} became dry", + "not_moving": "{entity_name} stopped moving", + "not_occupied": "{entity_name} became not occupied", + "not_plugged_in": "{entity_name} unplugged", + "not_powered": "{entity_name} not powered", + "not_present": "{entity_name} not present", + "not_unsafe": "{entity_name} became safe", + "occupied": "{entity_name} became occupied", + "opened": "{entity_name} opened", + "plugged_in": "{entity_name} plugged in", + "powered": "{entity_name} powered", + "present": "{entity_name} present", + "problem": "{entity_name} started detecting problem", + "smoke": "{entity_name} started detecting smoke", + "sound": "{entity_name} started detecting sound", + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on", + "unsafe": "{entity_name} became unsafe", + "vibration": "{entity_name} started detecting vibration" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/.translations/no.json b/homeassistant/components/binary_sensor/.translations/no.json new file mode 100644 index 00000000000000..5a1916bce59c33 --- /dev/null +++ b/homeassistant/components/binary_sensor/.translations/no.json @@ -0,0 +1,92 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} batteriniv\u00e5et er lavt", + "is_cold": "{entity_name} er kald", + "is_connected": "{entity_name} er tilkoblet", + "is_gas": "{entity_name} registrerer gass", + "is_hot": "{entity_name} er varm", + "is_light": "{entity_name} registrerer lys", + "is_locked": "{entity_name} er l\u00e5st", + "is_moist": "{entity_name} er fuktig", + "is_motion": "{entity_name} registrerer bevegelse", + "is_moving": "{entity_name} er i bevegelse", + "is_no_gas": "{entity_name} registrerer ikke gass", + "is_no_light": "{entity_name} registrerer ikke lys", + "is_no_motion": "{entity_name} registrerer ikke bevegelse", + "is_no_problem": "{entity_name} registrerer ikke et problem", + "is_no_smoke": "{entity_name} registrerer ikke r\u00f8yk", + "is_no_sound": "{entity_name} registrerer ikke lyd", + "is_no_vibration": "{entity_name} registrerer ikke bevegelse", + "is_not_bat_low": "{entity_name} batteri er normalt", + "is_not_cold": "{entity_name} er ikke kald", + "is_not_connected": "{entity_name} er frakoblet", + "is_not_hot": "{entity_name} er ikke varm", + "is_not_locked": "{entity_name} er ul\u00e5st", + "is_not_moist": "{entity_name} er t\u00f8rr", + "is_not_moving": "{entity_name} er ikke i bevegelse", + "is_not_occupied": "{entity_name} er ledig", + "is_not_open": "{entity_name} er lukket", + "is_not_plugged_in": "{entity_name} er koblet fra", + "is_not_powered": "{entity_name} er spenningsl\u00f8s", + "is_not_present": "{entity_name} er ikke tilstede", + "is_not_unsafe": "{entity_name} er trygg", + "is_occupied": "{entity_name} er opptatt", + "is_off": "{entity_name} er sl\u00e5tt av", + "is_on": "{entity_name} er sl\u00e5tt p\u00e5", + "is_open": "{entity_name} er \u00e5pen", + "is_plugged_in": "{entity_name} er koblet til", + "is_powered": "{entity_name} er spenningssatt", + "is_present": "{entity_name} er tilstede", + "is_problem": "{entity_name} registrerer et problem", + "is_smoke": "{entity_name} registrerer r\u00f8yk", + "is_sound": "{entity_name} registrerer lyd", + "is_unsafe": "{entity_name} er utrygg", + "is_vibration": "{entity_name} registrerer vibrasjon" + }, + "trigger_type": { + "bat_low": "{entity_name} lavt batteri", + "closed": "{entity_name} stengt", + "cold": "{entity_name} ble kald", + "connected": "{entity_name} tilkoblet", + "gas": "{entity_name} begynte \u00e5 registrere gass", + "hot": "{entity_name} ble varm", + "light": "{entity_name} begynte \u00e5 registrere lys", + "locked": "{entity_name} l\u00e5st", + "moist\u00a7": "{entity_name} ble fuktig", + "motion": "{entity_name} begynte \u00e5 registrere bevegelse", + "moving": "{entity_name} begynte \u00e5 bevege seg", + "no_gas": "{entity_name} sluttet \u00e5 registrere gass", + "no_light": "{entity_name} sluttet \u00e5 registrere lys", + "no_motion": "{entity_name} sluttet \u00e5 registrere bevegelse", + "no_problem": "{entity_name} sluttet \u00e5 registrere problem", + "no_smoke": "{entity_name} sluttet \u00e5 registrere r\u00f8yk", + "no_sound": "{entity_name} sluttet \u00e5 registrere lyd", + "no_vibration": "{entity_name} sluttet \u00e5 registrere vibrasjon", + "not_bat_low": "{entity_name} batteri normalt", + "not_cold": "{entity_name} ble ikke lenger kald", + "not_connected": "{entity_name} koblet fra", + "not_hot": "{entity_name} ble ikke lenger varm", + "not_locked": "{entity_name} l\u00e5st opp", + "not_moist": "{entity_name} ble t\u00f8rr", + "not_moving": "{entity_name} sluttet \u00e5 bevege seg", + "not_occupied": "{entity_name} ble ledig", + "not_plugged_in": "{entity_name} koblet fra", + "not_powered": "{entity_name} spenningsl\u00f8s", + "not_present": "{entity_name} ikke til stede", + "not_unsafe": "{entity_name} ble trygg", + "occupied": "{entity_name} ble opptatt", + "opened": "{entity_name} \u00e5pnet", + "plugged_in": "{entity_name} koblet til", + "powered": "{entity_name} spenningssatt", + "present": "{entity_name} tilstede", + "problem": "{entity_name} begynte \u00e5 registrere et problem", + "smoke": "{entity_name} begynte \u00e5 registrere r\u00f8yk", + "sound": "{entity_name} begynte \u00e5 registrere lyd", + "turned_off": "{entity_name} sl\u00e5tt av", + "turned_on": "{entity_name} sl\u00e5tt p\u00e5", + "unsafe": "{entity_name} ble usikker", + "vibration": "{entity_name} begynte \u00e5 oppdage vibrasjon" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/binary_sensor/device_automation.py b/homeassistant/components/binary_sensor/device_automation.py new file mode 100644 index 00000000000000..c609c2eb5da4c8 --- /dev/null +++ b/homeassistant/components/binary_sensor/device_automation.py @@ -0,0 +1,423 @@ +"""Provides device automations for lights.""" +import voluptuous as vol + +import homeassistant.components.automation.state as state +from homeassistant.components.device_automation.const import ( + CONF_IS_OFF, + CONF_IS_ON, + CONF_TURNED_OFF, + CONF_TURNED_ON, +) +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.core import split_entity_id +from homeassistant.helpers.entity_registry import async_entries_for_device +from homeassistant.helpers import condition, config_validation as cv + +from . import ( + DOMAIN, + DEVICE_CLASS_BATTERY, + DEVICE_CLASS_COLD, + DEVICE_CLASS_CONNECTIVITY, + DEVICE_CLASS_DOOR, + DEVICE_CLASS_GARAGE_DOOR, + DEVICE_CLASS_GAS, + DEVICE_CLASS_HEAT, + DEVICE_CLASS_LIGHT, + DEVICE_CLASS_LOCK, + DEVICE_CLASS_MOISTURE, + DEVICE_CLASS_MOTION, + DEVICE_CLASS_MOVING, + DEVICE_CLASS_OCCUPANCY, + DEVICE_CLASS_OPENING, + DEVICE_CLASS_PLUG, + DEVICE_CLASS_POWER, + DEVICE_CLASS_PRESENCE, + DEVICE_CLASS_PROBLEM, + DEVICE_CLASS_SAFETY, + DEVICE_CLASS_SMOKE, + DEVICE_CLASS_SOUND, + DEVICE_CLASS_VIBRATION, + DEVICE_CLASS_WINDOW, +) + + +# mypy: allow-untyped-defs, no-check-untyped-defs + +DEVICE_CLASS_NONE = "none" + +CONF_IS_BAT_LOW = "is_bat_low" +CONF_IS_NOT_BAT_LOW = "is_not_bat_low" +CONF_IS_COLD = "is_cold" +CONF_IS_NOT_COLD = "is_not_cold" +CONF_IS_CONNECTED = "is_connected" +CONF_IS_NOT_CONNECTED = "is_not_connected" +CONF_IS_GAS = "is_gas" +CONF_IS_NO_GAS = "is_no_gas" +CONF_IS_HOT = "is_hot" +CONF_IS_NOT_HOT = "is_not_hot" +CONF_IS_LIGHT = "is_light" +CONF_IS_NO_LIGHT = "is_no_light" +CONF_IS_LOCKED = "is_locked" +CONF_IS_NOT_LOCKED = "is_not_locked" +CONF_IS_MOIST = "is_moist" +CONF_IS_NOT_MOIST = "is_not_moist" +CONF_IS_MOTION = "is_motion" +CONF_IS_NO_MOTION = "is_no_motion" +CONF_IS_MOVING = "is_moving" +CONF_IS_NOT_MOVING = "is_not_moving" +CONF_IS_OCCUPIED = "is_occupied" +CONF_IS_NOT_OCCUPIED = "is_not_occupied" +CONF_IS_PLUGGED_IN = "is_plugged_in" +CONF_IS_NOT_PLUGGED_IN = "is_not_plugged_in" +CONF_IS_POWERED = "is_powered" +CONF_IS_NOT_POWERED = "is_not_powered" +CONF_IS_PRESENT = "is_present" +CONF_IS_NOT_PRESENT = "is_not_present" +CONF_IS_PROBLEM = "is_problem" +CONF_IS_NO_PROBLEM = "is_no_problem" +CONF_IS_UNSAFE = "is_unsafe" +CONF_IS_NOT_UNSAFE = "is_not_unsafe" +CONF_IS_SMOKE = "is_smoke" +CONF_IS_NO_SMOKE = "is_no_smoke" +CONF_IS_SOUND = "is_sound" +CONF_IS_NO_SOUND = "is_no_sound" +CONF_IS_VIBRATION = "is_vibration" +CONF_IS_NO_VIBRATION = "is_no_vibration" +CONF_IS_OPEN = "is_open" +CONF_IS_NOT_OPEN = "is_not_open" + +CONF_BAT_LOW = "bat_low" +CONF_NOT_BAT_LOW = "not_bat_low" +CONF_COLD = "cold" +CONF_NOT_COLD = "not_cold" +CONF_CONNECTED = "connected" +CONF_NOT_CONNECTED = "not_connected" +CONF_GAS = "gas" +CONF_NO_GAS = "no_gas" +CONF_HOT = "hot" +CONF_NOT_HOT = "not_hot" +CONF_LIGHT = "light" +CONF_NO_LIGHT = "no_light" +CONF_LOCKED = "locked" +CONF_NOT_LOCKED = "not_locked" +CONF_MOIST = "moist" +CONF_NOT_MOIST = "not_moist" +CONF_MOTION = "motion" +CONF_NO_MOTION = "no_motion" +CONF_MOVING = "moving" +CONF_NOT_MOVING = "not_moving" +CONF_OCCUPIED = "occupied" +CONF_NOT_OCCUPIED = "not_occupied" +CONF_PLUGGED_IN = "plugged_in" +CONF_NOT_PLUGGED_IN = "not_plugged_in" +CONF_POWERED = "powered" +CONF_NOT_POWERED = "not_powered" +CONF_PRESENT = "present" +CONF_NOT_PRESENT = "not_present" +CONF_PROBLEM = "problem" +CONF_NO_PROBLEM = "no_problem" +CONF_UNSAFE = "unsafe" +CONF_NOT_UNSAFE = "not_unsafe" +CONF_SMOKE = "smoke" +CONF_NO_SMOKE = "no_smoke" +CONF_SOUND = "sound" +CONF_NO_SOUND = "no_sound" +CONF_VIBRATION = "vibration" +CONF_NO_VIBRATION = "no_vibration" +CONF_OPEN = "open" +CONF_NOT_OPEN = "not_open" + +IS_ON = [ + CONF_IS_BAT_LOW, + CONF_IS_COLD, + CONF_IS_CONNECTED, + CONF_IS_GAS, + CONF_IS_HOT, + CONF_IS_LIGHT, + CONF_IS_LOCKED, + CONF_IS_MOIST, + CONF_IS_MOTION, + CONF_IS_MOVING, + CONF_IS_OCCUPIED, + CONF_IS_OPEN, + CONF_IS_PLUGGED_IN, + CONF_IS_POWERED, + CONF_IS_PRESENT, + CONF_IS_PROBLEM, + CONF_IS_SMOKE, + CONF_IS_SOUND, + CONF_IS_UNSAFE, + CONF_IS_VIBRATION, + CONF_IS_ON, +] + +IS_OFF = [ + CONF_IS_NOT_BAT_LOW, + CONF_IS_NOT_COLD, + CONF_IS_NOT_CONNECTED, + CONF_IS_NOT_HOT, + CONF_IS_NOT_LOCKED, + CONF_IS_NOT_MOIST, + CONF_IS_NOT_MOVING, + CONF_IS_NOT_OCCUPIED, + CONF_IS_NOT_OPEN, + CONF_IS_NOT_PLUGGED_IN, + CONF_IS_NOT_POWERED, + CONF_IS_NOT_PRESENT, + CONF_IS_NOT_UNSAFE, + CONF_IS_NO_GAS, + CONF_IS_NO_LIGHT, + CONF_IS_NO_MOTION, + CONF_IS_NO_PROBLEM, + CONF_IS_NO_SMOKE, + CONF_IS_NO_SOUND, + CONF_IS_NO_VIBRATION, + CONF_IS_OFF, +] + +TURNED_ON = [ + CONF_BAT_LOW, + CONF_COLD, + CONF_CONNECTED, + CONF_GAS, + CONF_HOT, + CONF_LIGHT, + CONF_LOCKED, + CONF_MOIST, + CONF_MOTION, + CONF_MOVING, + CONF_OCCUPIED, + CONF_OPEN, + CONF_PLUGGED_IN, + CONF_POWERED, + CONF_PRESENT, + CONF_PROBLEM, + CONF_SMOKE, + CONF_SOUND, + CONF_UNSAFE, + CONF_VIBRATION, + CONF_TURNED_ON, +] + +TURNED_OFF = [ + CONF_NOT_BAT_LOW, + CONF_NOT_COLD, + CONF_NOT_CONNECTED, + CONF_NOT_HOT, + CONF_NOT_LOCKED, + CONF_NOT_MOIST, + CONF_NOT_MOVING, + CONF_NOT_OCCUPIED, + CONF_NOT_OPEN, + CONF_NOT_PLUGGED_IN, + CONF_NOT_POWERED, + CONF_NOT_PRESENT, + CONF_NOT_UNSAFE, + CONF_NO_GAS, + CONF_NO_LIGHT, + CONF_NO_MOTION, + CONF_NO_PROBLEM, + CONF_NO_SMOKE, + CONF_NO_SOUND, + CONF_NO_VIBRATION, + CONF_TURNED_OFF, +] + +ENTITY_CONDITIONS = { + DEVICE_CLASS_BATTERY: [ + {CONF_TYPE: CONF_IS_BAT_LOW}, + {CONF_TYPE: CONF_IS_NOT_BAT_LOW}, + ], + DEVICE_CLASS_COLD: [{CONF_TYPE: CONF_IS_COLD}, {CONF_TYPE: CONF_IS_NOT_COLD}], + DEVICE_CLASS_CONNECTIVITY: [ + {CONF_TYPE: CONF_IS_CONNECTED}, + {CONF_TYPE: CONF_IS_NOT_CONNECTED}, + ], + DEVICE_CLASS_DOOR: [{CONF_TYPE: CONF_IS_OPEN}, {CONF_TYPE: CONF_IS_NOT_OPEN}], + DEVICE_CLASS_GARAGE_DOOR: [ + {CONF_TYPE: CONF_IS_OPEN}, + {CONF_TYPE: CONF_IS_NOT_OPEN}, + ], + DEVICE_CLASS_GAS: [{CONF_TYPE: CONF_IS_GAS}, {CONF_TYPE: CONF_IS_NO_GAS}], + DEVICE_CLASS_HEAT: [{CONF_TYPE: CONF_IS_HOT}, {CONF_TYPE: CONF_IS_NOT_HOT}], + DEVICE_CLASS_LIGHT: [{CONF_TYPE: CONF_IS_LIGHT}, {CONF_TYPE: CONF_IS_NO_LIGHT}], + DEVICE_CLASS_LOCK: [{CONF_TYPE: CONF_IS_LOCKED}, {CONF_TYPE: CONF_IS_NOT_LOCKED}], + DEVICE_CLASS_MOISTURE: [{CONF_TYPE: CONF_IS_MOIST}, {CONF_TYPE: CONF_IS_NOT_MOIST}], + DEVICE_CLASS_MOTION: [{CONF_TYPE: CONF_IS_MOTION}, {CONF_TYPE: CONF_IS_NO_MOTION}], + DEVICE_CLASS_MOVING: [{CONF_TYPE: CONF_IS_MOVING}, {CONF_TYPE: CONF_IS_NOT_MOVING}], + DEVICE_CLASS_OCCUPANCY: [ + {CONF_TYPE: CONF_IS_OCCUPIED}, + {CONF_TYPE: CONF_IS_NOT_OCCUPIED}, + ], + DEVICE_CLASS_OPENING: [{CONF_TYPE: CONF_IS_OPEN}, {CONF_TYPE: CONF_IS_NOT_OPEN}], + DEVICE_CLASS_PLUG: [ + {CONF_TYPE: CONF_IS_PLUGGED_IN}, + {CONF_TYPE: CONF_IS_NOT_PLUGGED_IN}, + ], + DEVICE_CLASS_POWER: [ + {CONF_TYPE: CONF_IS_POWERED}, + {CONF_TYPE: CONF_IS_NOT_POWERED}, + ], + DEVICE_CLASS_PRESENCE: [ + {CONF_TYPE: CONF_IS_PRESENT}, + {CONF_TYPE: CONF_IS_NOT_PRESENT}, + ], + DEVICE_CLASS_PROBLEM: [ + {CONF_TYPE: CONF_IS_PROBLEM}, + {CONF_TYPE: CONF_IS_NO_PROBLEM}, + ], + DEVICE_CLASS_SAFETY: [{CONF_TYPE: CONF_IS_UNSAFE}, {CONF_TYPE: CONF_IS_NOT_UNSAFE}], + DEVICE_CLASS_SMOKE: [{CONF_TYPE: CONF_IS_SMOKE}, {CONF_TYPE: CONF_IS_NO_SMOKE}], + DEVICE_CLASS_SOUND: [{CONF_TYPE: CONF_IS_SOUND}, {CONF_TYPE: CONF_IS_NO_SOUND}], + DEVICE_CLASS_VIBRATION: [ + {CONF_TYPE: CONF_IS_VIBRATION}, + {CONF_TYPE: CONF_IS_NO_VIBRATION}, + ], + DEVICE_CLASS_WINDOW: [{CONF_TYPE: CONF_IS_OPEN}, {CONF_TYPE: CONF_IS_NOT_OPEN}], + DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_IS_ON}, {CONF_TYPE: CONF_IS_OFF}], +} + +ENTITY_TRIGGERS = { + DEVICE_CLASS_BATTERY: [{CONF_TYPE: CONF_BAT_LOW}, {CONF_TYPE: CONF_NOT_BAT_LOW}], + DEVICE_CLASS_COLD: [{CONF_TYPE: CONF_COLD}, {CONF_TYPE: CONF_NOT_COLD}], + DEVICE_CLASS_CONNECTIVITY: [ + {CONF_TYPE: CONF_CONNECTED}, + {CONF_TYPE: CONF_NOT_CONNECTED}, + ], + DEVICE_CLASS_DOOR: [{CONF_TYPE: CONF_OPEN}, {CONF_TYPE: CONF_NOT_OPEN}], + DEVICE_CLASS_GARAGE_DOOR: [{CONF_TYPE: CONF_OPEN}, {CONF_TYPE: CONF_NOT_OPEN}], + DEVICE_CLASS_GAS: [{CONF_TYPE: CONF_GAS}, {CONF_TYPE: CONF_NO_GAS}], + DEVICE_CLASS_HEAT: [{CONF_TYPE: CONF_HOT}, {CONF_TYPE: CONF_NOT_HOT}], + DEVICE_CLASS_LIGHT: [{CONF_TYPE: CONF_LIGHT}, {CONF_TYPE: CONF_NO_LIGHT}], + DEVICE_CLASS_LOCK: [{CONF_TYPE: CONF_LOCKED}, {CONF_TYPE: CONF_NOT_LOCKED}], + DEVICE_CLASS_MOISTURE: [{CONF_TYPE: CONF_MOIST}, {CONF_TYPE: CONF_NOT_MOIST}], + DEVICE_CLASS_MOTION: [{CONF_TYPE: CONF_MOTION}, {CONF_TYPE: CONF_NO_MOTION}], + DEVICE_CLASS_MOVING: [{CONF_TYPE: CONF_MOVING}, {CONF_TYPE: CONF_NOT_MOVING}], + DEVICE_CLASS_OCCUPANCY: [ + {CONF_TYPE: CONF_OCCUPIED}, + {CONF_TYPE: CONF_NOT_OCCUPIED}, + ], + DEVICE_CLASS_OPENING: [{CONF_TYPE: CONF_OPEN}, {CONF_TYPE: CONF_NOT_OPEN}], + DEVICE_CLASS_PLUG: [{CONF_TYPE: CONF_PLUGGED_IN}, {CONF_TYPE: CONF_NOT_PLUGGED_IN}], + DEVICE_CLASS_POWER: [{CONF_TYPE: CONF_POWERED}, {CONF_TYPE: CONF_NOT_POWERED}], + DEVICE_CLASS_PRESENCE: [{CONF_TYPE: CONF_PRESENT}, {CONF_TYPE: CONF_NOT_PRESENT}], + DEVICE_CLASS_PROBLEM: [{CONF_TYPE: CONF_PROBLEM}, {CONF_TYPE: CONF_NO_PROBLEM}], + DEVICE_CLASS_SAFETY: [{CONF_TYPE: CONF_UNSAFE}, {CONF_TYPE: CONF_NOT_UNSAFE}], + DEVICE_CLASS_SMOKE: [{CONF_TYPE: CONF_SMOKE}, {CONF_TYPE: CONF_NO_SMOKE}], + DEVICE_CLASS_SOUND: [{CONF_TYPE: CONF_SOUND}, {CONF_TYPE: CONF_NO_SOUND}], + DEVICE_CLASS_VIBRATION: [ + {CONF_TYPE: CONF_VIBRATION}, + {CONF_TYPE: CONF_NO_VIBRATION}, + ], + DEVICE_CLASS_WINDOW: [{CONF_TYPE: CONF_OPEN}, {CONF_TYPE: CONF_NOT_OPEN}], + DEVICE_CLASS_NONE: [{CONF_TYPE: CONF_TURNED_ON}, {CONF_TYPE: CONF_TURNED_OFF}], +} + +CONDITION_SCHEMA = vol.Schema( + { + vol.Required(CONF_CONDITION): "device", + vol.Required(CONF_DEVICE_ID): str, + vol.Required(CONF_DOMAIN): DOMAIN, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(IS_OFF + IS_ON), + } +) + +TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "device", + vol.Required(CONF_DEVICE_ID): str, + vol.Required(CONF_DOMAIN): DOMAIN, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In(TURNED_OFF + TURNED_ON), + } +) + + +def async_condition_from_config(config, config_validation): + """Evaluate state based on configuration.""" + config = CONDITION_SCHEMA(config) + condition_type = config[CONF_TYPE] + if condition_type in IS_ON: + stat = "on" + else: + stat = "off" + state_config = { + condition.CONF_CONDITION: "state", + condition.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + condition.CONF_STATE: stat, + } + + return condition.state_from_config(state_config, config_validation) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + config = TRIGGER_SCHEMA(config) + trigger_type = config[CONF_TYPE] + if trigger_type in TURNED_ON: + from_state = "off" + to_state = "on" + else: + from_state = "on" + to_state = "off" + state_config = { + state.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state.CONF_FROM: from_state, + state.CONF_TO: to_state, + } + + return await state.async_trigger(hass, state_config, action, automation_info) + + +def _is_domain(entity, domain): + return split_entity_id(entity.entity_id)[0] == domain + + +async def _async_get_automations(hass, device_id, automation_templates, domain): + """List device automations.""" + automations = [] + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + entities = async_entries_for_device(entity_registry, device_id) + domain_entities = [x for x in entities if _is_domain(x, domain)] + for entity in domain_entities: + device_class = DEVICE_CLASS_NONE + entity_id = entity.entity_id + entity = hass.states.get(entity_id) + if entity and ATTR_DEVICE_CLASS in entity.attributes: + device_class = entity.attributes[ATTR_DEVICE_CLASS] + automation_template = automation_templates[device_class] + + for automation in automation_template: + automation = dict(automation) + automation.update(device_id=device_id, entity_id=entity_id, domain=domain) + automations.append(automation) + + return automations + + +async def async_get_conditions(hass, device_id): + """List device conditions.""" + automations = await _async_get_automations( + hass, device_id, ENTITY_CONDITIONS, DOMAIN + ) + for automation in automations: + automation.update(condition="device") + return automations + + +async def async_get_triggers(hass, device_id): + """List device triggers.""" + automations = await _async_get_automations(hass, device_id, ENTITY_TRIGGERS, DOMAIN) + for automation in automations: + automation.update(platform="device") + return automations diff --git a/homeassistant/components/binary_sensor/strings.json b/homeassistant/components/binary_sensor/strings.json new file mode 100644 index 00000000000000..109a2b1fd45f61 --- /dev/null +++ b/homeassistant/components/binary_sensor/strings.json @@ -0,0 +1,93 @@ +{ + "device_automation": { + "condition_type": { + "is_bat_low": "{entity_name} battery is low", + "is_not_bat_low": "{entity_name} battery is normal", + "is_cold": "{entity_name} is cold", + "is_not_cold": "{entity_name} is not cold", + "is_connected": "{entity_name} is connected", + "is_not_connected": "{entity_name} is disconnected", + "is_gas": "{entity_name} is detecting gas", + "is_no_gas": "{entity_name} is not detecting gas", + "is_hot": "{entity_name} is hot", + "is_not_hot": "{entity_name} is not hot", + "is_light": "{entity_name} is detecting light", + "is_no_light": "{entity_name} is not detecting light", + "is_locked": "{entity_name} is locked", + "is_not_locked": "{entity_name} is unlocked", + "is_moist": "{entity_name} is moist", + "is_not_moist": "{entity_name} is dry", + "is_motion": "{entity_name} is detecting motion", + "is_no_motion": "{entity_name} is not detecting motion", + "is_moving": "{entity_name} is moving", + "is_not_moving": "{entity_name} is not moving", + "is_occupied": "{entity_name} is occupied", + "is_not_occupied": "{entity_name} is not occupied", + "is_plugged_in": "{entity_name} is plugged in", + "is_not_plugged_in": "{entity_name} is unplugged", + "is_powered": "{entity_name} is powered", + "is_not_powered": "{entity_name} is not powered", + "is_present": "{entity_name} is present", + "is_not_present": "{entity_name} is not present", + "is_problem": "{entity_name} is detecting problem", + "is_no_problem": "{entity_name} is not detecting problem", + "is_unsafe": "{entity_name} is unsafe", + "is_not_unsafe": "{entity_name} is safe", + "is_smoke": "{entity_name} is detecting smoke", + "is_no_smoke": "{entity_name} is not detecting smoke", + "is_sound": "{entity_name} is detecting sound", + "is_no_sound": "{entity_name} is not detecting sound", + "is_vibration": "{entity_name} is detecting vibration", + "is_no_vibration": "{entity_name} is not detecting vibration", + "is_open": "{entity_name} is open", + "is_not_open": "{entity_name} is closed", + "is_on": "{entity_name} is on", + "is_off": "{entity_name} is off" + }, + "trigger_type": { + "bat_low": "{entity_name} battery low", + "not_bat_low": "{entity_name} battery normal", + "cold": "{entity_name} became cold", + "not_cold": "{entity_name} became not cold", + "connected": "{entity_name} connected", + "not_connected": "{entity_name} disconnected", + "gas": "{entity_name} started detecting gas", + "no_gas": "{entity_name} stopped detecting gas", + "hot": "{entity_name} became hot", + "not_hot": "{entity_name} became not hot", + "light": "{entity_name} started detecting light", + "no_light": "{entity_name} stopped detecting light", + "locked": "{entity_name} locked", + "not_locked": "{entity_name} unlocked", + "moist§": "{entity_name} became moist", + "not_moist": "{entity_name} became dry", + "motion": "{entity_name} started detecting motion", + "no_motion": "{entity_name} stopped detecting motion", + "moving": "{entity_name} started moving", + "not_moving": "{entity_name} stopped moving", + "occupied": "{entity_name} became occupied", + "not_occupied": "{entity_name} became not occupied", + "plugged_in": "{entity_name} plugged in", + "not_plugged_in": "{entity_name} unplugged", + "powered": "{entity_name} powered", + "not_powered": "{entity_name} not powered", + "present": "{entity_name} present", + "not_present": "{entity_name} not present", + "problem": "{entity_name} started detecting problem", + "no_problem": "{entity_name} stopped detecting problem", + "unsafe": "{entity_name} became unsafe", + "not_unsafe": "{entity_name} became safe", + "smoke": "{entity_name} started detecting smoke", + "no_smoke": "{entity_name} stopped detecting smoke", + "sound": "{entity_name} started detecting sound", + "no_sound": "{entity_name} stopped detecting sound", + "vibration": "{entity_name} started detecting vibration", + "no_vibration": "{entity_name} stopped detecting vibration", + "opened": "{entity_name} opened", + "closed": "{entity_name} closed", + "turned_on": "{entity_name} turned on", + "turned_off": "{entity_name} turned off" + + } + } +} diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index e760f91070a163..6a26775b0a8ac7 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -1,25 +1,30 @@ """Tracking for bluetooth devices.""" +import asyncio import logging +from typing import List, Set, Tuple, Optional +# pylint: disable=import-error +import bluetooth +from bt_proximity import BluetoothRSSI import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.event import track_point_in_utc_time from homeassistant.components.device_tracker import PLATFORM_SCHEMA -from homeassistant.components.device_tracker.legacy import ( - YAML_DEVICES, - async_load_config, -) from homeassistant.components.device_tracker.const import ( - CONF_TRACK_NEW, CONF_SCAN_INTERVAL, - SCAN_INTERVAL, + CONF_TRACK_NEW, DEFAULT_TRACK_NEW, - SOURCE_TYPE_BLUETOOTH, DOMAIN, + SCAN_INTERVAL, + SOURCE_TYPE_BLUETOOTH, +) +from homeassistant.components.device_tracker.legacy import ( + YAML_DEVICES, + async_load_config, ) -import homeassistant.util.dt as dt_util -from homeassistant.util.async_ import run_coroutine_threadsafe +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.typing import HomeAssistantType + _LOGGER = logging.getLogger(__name__) @@ -42,100 +47,145 @@ ) -def setup_scanner(hass, config, see, discovery_info=None): +def is_bluetooth_device(device) -> bool: + """Check whether a device is a bluetooth device by its mac.""" + return device.mac and device.mac[:3].upper() == BT_PREFIX + + +def discover_devices(device_id: int) -> List[Tuple[str, str]]: + """Discover Bluetooth devices.""" + result = bluetooth.discover_devices( + duration=8, + lookup_names=True, + flush_cache=True, + lookup_class=False, + device_id=device_id, + ) + _LOGGER.debug("Bluetooth devices discovered = %d", len(result)) + return result + + +async def see_device( + hass: HomeAssistantType, async_see, mac: str, device_name: str, rssi=None +) -> None: + """Mark a device as seen.""" + attributes = {} + if rssi is not None: + attributes["rssi"] = rssi + + await async_see( + mac=f"{BT_PREFIX}{mac}", + host_name=device_name, + attributes=attributes, + source_type=SOURCE_TYPE_BLUETOOTH, + ) + + +async def get_tracking_devices(hass: HomeAssistantType) -> Tuple[Set[str], Set[str]]: + """ + Load all known devices. + + We just need the devices so set consider_home and home range to 0 + """ + yaml_path: str = hass.config.path(YAML_DEVICES) + + devices = await async_load_config(yaml_path, hass, 0) + bluetooth_devices = [device for device in devices if is_bluetooth_device(device)] + + devices_to_track: Set[str] = { + device.mac[3:] for device in bluetooth_devices if device.track + } + devices_to_not_track: Set[str] = { + device.mac[3:] for device in bluetooth_devices if not device.track + } + + return devices_to_track, devices_to_not_track + + +def lookup_name(mac: str) -> Optional[str]: + """Lookup a Bluetooth device name.""" + _LOGGER.debug("Scanning %s", mac) + return bluetooth.lookup_name(mac, timeout=5) + + +async def async_setup_scanner( + hass: HomeAssistantType, config: dict, async_see, discovery_info=None +): """Set up the Bluetooth Scanner.""" - # pylint: disable=import-error - import bluetooth - from bt_proximity import BluetoothRSSI - - def see_device(mac, name, rssi=None): - """Mark a device as seen.""" - attributes = {} - if rssi is not None: - attributes["rssi"] = rssi - see( - mac=f"{BT_PREFIX}{mac}", - host_name=name, - attributes=attributes, - source_type=SOURCE_TYPE_BLUETOOTH, - ) - - device_id = config.get(CONF_DEVICE_ID) - - def discover_devices(): - """Discover Bluetooth devices.""" - result = bluetooth.discover_devices( - duration=8, - lookup_names=True, - flush_cache=True, - lookup_class=False, - device_id=device_id, - ) - _LOGGER.debug("Bluetooth devices discovered = %d", len(result)) - return result - - yaml_path = hass.config.path(YAML_DEVICES) - devs_to_track = [] - devs_donot_track = [] - - # Load all known devices. - # We just need the devices so set consider_home and home range - # to 0 - for device in run_coroutine_threadsafe( - async_load_config(yaml_path, hass, 0), hass.loop - ).result(): - # Check if device is a valid bluetooth device - if device.mac and device.mac[:3].upper() == BT_PREFIX: - if device.track: - devs_to_track.append(device.mac[3:]) - else: - devs_donot_track.append(device.mac[3:]) + device_id: int = config.get(CONF_DEVICE_ID) + interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + request_rssi = config.get(CONF_REQUEST_RSSI, False) + update_bluetooth_lock = asyncio.Lock() # If track new devices is true discover new devices on startup. - track_new = config.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) - if track_new: - for dev in discover_devices(): - if dev[0] not in devs_to_track and dev[0] not in devs_donot_track: - devs_to_track.append(dev[0]) - see_device(dev[0], dev[1]) + track_new: bool = config.get(CONF_TRACK_NEW, DEFAULT_TRACK_NEW) + _LOGGER.debug("Tracking new devices is set to %s", track_new) - interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + devices_to_track, devices_to_not_track = await get_tracking_devices(hass) - request_rssi = config.get(CONF_REQUEST_RSSI, False) + if not devices_to_track and not track_new: + _LOGGER.debug("No Bluetooth devices to track and not tracking new devices") + + if request_rssi: + _LOGGER.debug("Detecting RSSI for devices") - def update_bluetooth(_): - """Update Bluetooth and set timer for the next update.""" - update_bluetooth_once() - track_point_in_utc_time(hass, update_bluetooth, dt_util.utcnow() + interval) + async def perform_bluetooth_update(): + """Discover Bluetooth devices and update status.""" + + _LOGGER.debug("Performing Bluetooth devices discovery and update") + tasks = [] - def update_bluetooth_once(): - """Lookup Bluetooth device and update status.""" try: if track_new: - for dev in discover_devices(): - if dev[0] not in devs_to_track and dev[0] not in devs_donot_track: - devs_to_track.append(dev[0]) - for mac in devs_to_track: - _LOGGER.debug("Scanning %s", mac) - result = bluetooth.lookup_name(mac, timeout=5) + devices = await hass.async_add_executor_job(discover_devices, device_id) + for mac, device_name in devices: + if mac not in devices_to_track and mac not in devices_to_not_track: + devices_to_track.add(mac) + + for mac in devices_to_track: + device_name = await hass.async_add_executor_job(lookup_name, mac) + if device_name is None: + # Could not lookup device name + continue + rssi = None if request_rssi: client = BluetoothRSSI(mac) - rssi = client.request_rssi() + rssi = await hass.async_add_executor_job(client.request_rssi) client.close() - if result is None: - # Could not lookup device name - continue - see_device(mac, result, rssi) + + tasks.append(see_device(hass, async_see, mac, device_name, rssi)) + + if tasks: + await asyncio.wait(tasks) + except bluetooth.BluetoothError: _LOGGER.exception("Error looking up Bluetooth device") - def handle_update_bluetooth(call): + async def update_bluetooth(now=None): + """Lookup Bluetooth devices and update status.""" + + # If an update is in progress, we don't do anything + if update_bluetooth_lock.locked(): + _LOGGER.debug( + "Previous execution of update_bluetooth is taking longer than the scheduled update of interval %s", + interval, + ) + return + + async with update_bluetooth_lock: + await perform_bluetooth_update() + + async def handle_manual_update_bluetooth(call): """Update bluetooth devices on demand.""" - update_bluetooth_once() - update_bluetooth(dt_util.utcnow()) + await update_bluetooth() + + hass.async_create_task(update_bluetooth()) + async_track_time_interval(hass, update_bluetooth, interval) - hass.services.register(DOMAIN, "bluetooth_tracker_update", handle_update_bluetooth) + hass.services.async_register( + DOMAIN, "bluetooth_tracker_update", handle_manual_update_bluetooth + ) return True diff --git a/homeassistant/components/bme680/sensor.py b/homeassistant/components/bme680/sensor.py index 20fdfc9ee79f6f..a36b35ea9d4be2 100644 --- a/homeassistant/components/bme680/sensor.py +++ b/homeassistant/components/bme680/sensor.py @@ -171,7 +171,7 @@ def _setup_bme680(config): sensor.select_gas_heater_profile(0) else: sensor.set_gas_status(bme680.DISABLE_GAS_MEAS) - except (RuntimeError, IOError): + except (RuntimeError, OSError): _LOGGER.error("BME680 sensor not detected at 0x%02x", i2c_address) return None diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index c257470bb2d0e8..8e67da86dc3014 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -1,5 +1,4 @@ """Reads vehicle status from BMW connected drive portal.""" -import datetime import logging import voluptuous as vol @@ -8,6 +7,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.event import track_utc_time_change import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -100,7 +100,7 @@ def execute_service(call): # update every UPDATE_INTERVAL minutes, starting now # this should even out the load on the servers - now = datetime.datetime.now() + now = dt_util.utcnow() track_utc_time_change( hass, cd_account.update, @@ -142,7 +142,7 @@ def update(self, *_): self.account.update_vehicle_states() for listener in self._update_listeners: listener() - except IOError as exception: + except OSError as exception: _LOGGER.error( "Could not connect to the BMW Connected Drive portal. " "The vehicle state could not be updated." diff --git a/homeassistant/components/bmw_connected_drive/binary_sensor.py b/homeassistant/components/bmw_connected_drive/binary_sensor.py index c9cc9b2d33373f..c13de4559847ef 100644 --- a/homeassistant/components/bmw_connected_drive/binary_sensor.py +++ b/homeassistant/components/bmw_connected_drive/binary_sensor.py @@ -9,7 +9,7 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = { - "lids": ["Doors", "opening", "mdi:car-door"], + "lids": ["Doors", "opening", "mdi:car-door-lock"], "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"], @@ -122,8 +122,9 @@ 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.has_check_control_messages - if check_control_messages: + check_control_messages = vehicle_state.check_control_messages + has_check_control_messages = vehicle_state.has_check_control_messages + if has_check_control_messages: cbs_list = [] for message in check_control_messages: cbs_list.append(message["ccmDescriptionShort"]) @@ -184,9 +185,9 @@ def _format_cbs_report(self, report): distance = round( self.hass.config.units.length(report.due_distance, LENGTH_KILOMETERS) ) - result[f"{service_type} distance"] = "{} {}".format( - distance, self.hass.config.units.length_unit - ) + result[ + f"{service_type} distance" + ] = f"{distance} {self.hass.config.units.length_unit}" return result def update_callback(self): diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 011908d54585e4..96d541b1955337 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -17,10 +17,10 @@ ATTR_TO_HA_METRIC = { "mileage": ["mdi:speedometer", LENGTH_KILOMETERS], - "remaining_range_total": ["mdi:ruler", LENGTH_KILOMETERS], - "remaining_range_electric": ["mdi:ruler", LENGTH_KILOMETERS], - "remaining_range_fuel": ["mdi:ruler", LENGTH_KILOMETERS], - "max_range_electric": ["mdi:ruler", LENGTH_KILOMETERS], + "remaining_range_total": ["mdi:map-marker-distance", LENGTH_KILOMETERS], + "remaining_range_electric": ["mdi:map-marker-distance", LENGTH_KILOMETERS], + "remaining_range_fuel": ["mdi:map-marker-distance", LENGTH_KILOMETERS], + "max_range_electric": ["mdi:map-marker-distance", LENGTH_KILOMETERS], "remaining_fuel": ["mdi:gas-station", VOLUME_LITERS], "charging_time_remaining": ["mdi:update", "h"], "charging_status": ["mdi:battery-charging", None], @@ -28,10 +28,10 @@ ATTR_TO_HA_IMPERIAL = { "mileage": ["mdi:speedometer", LENGTH_MILES], - "remaining_range_total": ["mdi:ruler", LENGTH_MILES], - "remaining_range_electric": ["mdi:ruler", LENGTH_MILES], - "remaining_range_fuel": ["mdi:ruler", LENGTH_MILES], - "max_range_electric": ["mdi:ruler", LENGTH_MILES], + "remaining_range_total": ["mdi:map-marker-distance", LENGTH_MILES], + "remaining_range_electric": ["mdi:map-marker-distance", LENGTH_MILES], + "remaining_range_fuel": ["mdi:map-marker-distance", LENGTH_MILES], + "max_range_electric": ["mdi:map-marker-distance", LENGTH_MILES], "remaining_fuel": ["mdi:gas-station", VOLUME_GALLONS], "charging_time_remaining": ["mdi:update", "h"], "charging_status": ["mdi:battery-charging", None], diff --git a/homeassistant/components/bom/sensor.py b/homeassistant/components/bom/sensor.py index 33444f1099652a..ed22be003ad4fa 100644 --- a/homeassistant/components/bom/sensor.py +++ b/homeassistant/components/bom/sensor.py @@ -13,6 +13,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_MONITORED_CONDITIONS, @@ -240,7 +241,7 @@ def should_update(self): # Never updated before, therefore an update should occur. return True - now = datetime.datetime.now() + now = dt_util.utcnow() update_due_at = self.last_updated + datetime.timedelta(minutes=35) return now > update_due_at @@ -251,8 +252,8 @@ def update(self): _LOGGER.debug( "BOM was updated %s minutes ago, skipping update as" " < 35 minutes, Now: %s, LastUpdate: %s", - (datetime.datetime.now() - self.last_updated), - datetime.datetime.now(), + (dt_util.utcnow() - self.last_updated), + dt_util.utcnow(), self.last_updated, ) return @@ -263,8 +264,10 @@ def update(self): # set lastupdate using self._data[0] as the first element in the # array is the latest date in the json - self.last_updated = datetime.datetime.strptime( - str(self._data[0]["local_date_time_full"]), "%Y%m%d%H%M%S" + self.last_updated = dt_util.as_utc( + datetime.datetime.strptime( + str(self._data[0]["local_date_time_full"]), "%Y%m%d%H%M%S" + ) ) return diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 1db9e2beaf9376..cdf202bbafd685 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -81,13 +81,13 @@ def __init__(self, name: str, dimension: int, delta: float): # invariant: this condition is private to and owned by this instance. self._condition = asyncio.Condition() - self._last_image = None # type: Optional[bytes] + self._last_image: Optional[bytes] = None # value of the last seen last modified header - self._last_modified = None # type: Optional[str] + self._last_modified: Optional[str] = None # loading status self._loading = False # deadline for image refresh - self.delta after last successful load - self._deadline = None # type: Optional[datetime] + self._deadline: Optional[datetime] = None @property def name(self) -> str: diff --git a/homeassistant/components/cast/.translations/ko.json b/homeassistant/components/cast/.translations/ko.json index 32c744c8f20b0e..71dee3afec5ec1 100644 --- a/homeassistant/components/cast/.translations/ko.json +++ b/homeassistant/components/cast/.translations/ko.json @@ -1,15 +1,15 @@ { "config": { "abort": { - "no_devices_found": "Googgle Cast \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", - "single_instance_allowed": "\ud558\ub098\uc758 Google Cast \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + "no_devices_found": "\uad6c\uae00 \uce90\uc2a4\ud2b8 \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "single_instance_allowed": "\ud558\ub098\uc758 \uad6c\uae00 \uce90\uc2a4\ud2b8\ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." }, "step": { "confirm": { - "description": "Google Cast\ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", - "title": "Google Cast" + "description": "\uad6c\uae00 \uce90\uc2a4\ud2b8\ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "\uad6c\uae00 \uce90\uc2a4\ud2b8" } }, - "title": "Google Cast" + "title": "\uad6c\uae00 \uce90\uc2a4\ud2b8" } } \ No newline at end of file diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index cc112984f888ae..4dfb58ef3b7d6e 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -1,6 +1,7 @@ """Component to embed Google Cast.""" from homeassistant import config_entries +from . import home_assistant_cast from .const import DOMAIN @@ -20,8 +21,10 @@ async def async_setup(hass, config): return True -async def async_setup_entry(hass, entry): +async def async_setup_entry(hass, entry: config_entries.ConfigEntry): """Set up Cast from a config entry.""" + await home_assistant_cast.async_setup_ha_cast(hass, entry) + hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, "media_player") ) diff --git a/homeassistant/components/cast/const.py b/homeassistant/components/cast/const.py index e9f9ba4c39deac..c6164484dbbb14 100644 --- a/homeassistant/components/cast/const.py +++ b/homeassistant/components/cast/const.py @@ -1,3 +1,26 @@ """Consts for Cast integration.""" DOMAIN = "cast" +DEFAULT_PORT = 8009 + +# Stores a threading.Lock that is held by the internal pychromecast discovery. +INTERNAL_DISCOVERY_RUNNING_KEY = "cast_discovery_running" +# Stores all ChromecastInfo we encountered through discovery or config as a set +# If we find a chromecast with a new host, the old one will be removed again. +KNOWN_CHROMECAST_INFO_KEY = "cast_known_chromecasts" +# Stores UUIDs of cast devices that were added as entities. Doesn't store +# None UUIDs. +ADDED_CAST_DEVICES_KEY = "cast_added_cast_devices" +# Stores an audio group manager. +CAST_MULTIZONE_MANAGER_KEY = "cast_multizone_manager" + +# Dispatcher signal fired with a ChromecastInfo every time we discover a new +# Chromecast or receive it through configuration +SIGNAL_CAST_DISCOVERED = "cast_discovered" + +# Dispatcher signal fired with a ChromecastInfo every time a Chromecast is +# removed +SIGNAL_CAST_REMOVED = "cast_removed" + +# Dispatcher signal fired when a Chromecast should show a Home Assistant Cast view. +SIGNAL_HASS_CAST_SHOW_VIEW = "cast_show_view" diff --git a/homeassistant/components/cast/discovery.py b/homeassistant/components/cast/discovery.py new file mode 100644 index 00000000000000..d3097b3cc29fb3 --- /dev/null +++ b/homeassistant/components/cast/discovery.py @@ -0,0 +1,99 @@ +"""Deal with Cast discovery.""" +import logging +import threading + +import pychromecast + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import dispatcher_send + +from .const import ( + KNOWN_CHROMECAST_INFO_KEY, + SIGNAL_CAST_DISCOVERED, + INTERNAL_DISCOVERY_RUNNING_KEY, + SIGNAL_CAST_REMOVED, +) +from .helpers import ChromecastInfo, ChromeCastZeroconf + +_LOGGER = logging.getLogger(__name__) + + +def discover_chromecast(hass: HomeAssistant, info: ChromecastInfo): + """Discover a Chromecast.""" + if info in hass.data[KNOWN_CHROMECAST_INFO_KEY]: + _LOGGER.debug("Discovered previous chromecast %s", info) + + # Either discovered completely new chromecast or a "moved" one. + info = info.fill_out_missing_chromecast_info() + _LOGGER.debug("Discovered chromecast %s", info) + + if info.uuid is not None: + # Remove previous cast infos with same uuid from known chromecasts. + same_uuid = set( + x for x in hass.data[KNOWN_CHROMECAST_INFO_KEY] if info.uuid == x.uuid + ) + hass.data[KNOWN_CHROMECAST_INFO_KEY] -= same_uuid + + hass.data[KNOWN_CHROMECAST_INFO_KEY].add(info) + dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info) + + +def _remove_chromecast(hass: HomeAssistant, info: ChromecastInfo): + # Removed chromecast + _LOGGER.debug("Removed chromecast %s", info) + + dispatcher_send(hass, SIGNAL_CAST_REMOVED, info) + + +def setup_internal_discovery(hass: HomeAssistant) -> None: + """Set up the pychromecast internal discovery.""" + if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data: + hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock() + + if not hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].acquire(blocking=False): + # Internal discovery is already running + return + + def internal_add_callback(name): + """Handle zeroconf discovery of a new chromecast.""" + mdns = listener.services[name] + discover_chromecast( + hass, + ChromecastInfo( + service=name, + host=mdns[0], + port=mdns[1], + uuid=mdns[2], + model_name=mdns[3], + friendly_name=mdns[4], + ), + ) + + def internal_remove_callback(name, mdns): + """Handle zeroconf discovery of a removed chromecast.""" + _remove_chromecast( + hass, + ChromecastInfo( + service=name, + host=mdns[0], + port=mdns[1], + uuid=mdns[2], + model_name=mdns[3], + friendly_name=mdns[4], + ), + ) + + _LOGGER.debug("Starting internal pychromecast discovery.") + listener, browser = pychromecast.start_discovery( + internal_add_callback, internal_remove_callback + ) + ChromeCastZeroconf.set_zeroconf(browser.zc) + + def stop_discovery(event): + """Stop discovery of new chromecasts.""" + _LOGGER.debug("Stopping internal pychromecast discovery.") + pychromecast.stop_discovery(browser) + hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].release() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_discovery) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py new file mode 100644 index 00000000000000..ea5c77ebc1afa3 --- /dev/null +++ b/homeassistant/components/cast/helpers.py @@ -0,0 +1,246 @@ +"""Helpers to deal with Cast devices.""" +from typing import Optional, Tuple + +import attr +from pychromecast import dial + +from .const import DEFAULT_PORT + + +@attr.s(slots=True, frozen=True) +class ChromecastInfo: + """Class to hold all data about a chromecast for creating connections. + + This also has the same attributes as the mDNS fields by zeroconf. + """ + + host = attr.ib(type=str) + port = attr.ib(type=int) + service = attr.ib(type=Optional[str], default=None) + uuid = attr.ib( + type=Optional[str], converter=attr.converters.optional(str), default=None + ) # always convert UUID to string if not None + manufacturer = attr.ib(type=str, default="") + model_name = attr.ib(type=str, default="") + friendly_name = attr.ib(type=Optional[str], default=None) + is_dynamic_group = attr.ib(type=Optional[bool], default=None) + + @property + def is_audio_group(self) -> bool: + """Return if this is an audio group.""" + return self.port != DEFAULT_PORT + + @property + def is_information_complete(self) -> bool: + """Return if all information is filled out.""" + want_dynamic_group = self.is_audio_group + have_dynamic_group = self.is_dynamic_group is not None + have_all_except_dynamic_group = all( + attr.astuple( + self, + filter=attr.filters.exclude( + attr.fields(ChromecastInfo).is_dynamic_group + ), + ) + ) + return have_all_except_dynamic_group and ( + not want_dynamic_group or have_dynamic_group + ) + + @property + def host_port(self) -> Tuple[str, int]: + """Return the host+port tuple.""" + return self.host, self.port + + def fill_out_missing_chromecast_info(self) -> "ChromecastInfo": + """Return a new ChromecastInfo object with missing attributes filled in. + + Uses blocking HTTP. + """ + if self.is_information_complete: + # We have all information, no need to check HTTP API. Or this is an + # audio group, so checking via HTTP won't give us any new information. + return self + + # Fill out missing information via HTTP dial. + if self.is_audio_group: + is_dynamic_group = False + http_group_status = None + dynamic_groups = [] + if self.uuid: + http_group_status = dial.get_multizone_status( + self.host, + services=[self.service], + zconf=ChromeCastZeroconf.get_zeroconf(), + ) + if http_group_status is not None: + dynamic_groups = [ + str(g.uuid) for g in http_group_status.dynamic_groups + ] + is_dynamic_group = self.uuid in dynamic_groups + + return ChromecastInfo( + service=self.service, + host=self.host, + port=self.port, + uuid=self.uuid, + friendly_name=self.friendly_name, + manufacturer=self.manufacturer, + model_name=self.model_name, + is_dynamic_group=is_dynamic_group, + ) + + http_device_status = dial.get_device_status( + self.host, services=[self.service], zconf=ChromeCastZeroconf.get_zeroconf() + ) + if http_device_status is None: + # HTTP dial didn't give us any new information. + return self + + return ChromecastInfo( + service=self.service, + host=self.host, + port=self.port, + uuid=(self.uuid or http_device_status.uuid), + friendly_name=(self.friendly_name or http_device_status.friendly_name), + manufacturer=(self.manufacturer or http_device_status.manufacturer), + model_name=(self.model_name or http_device_status.model_name), + ) + + def same_dynamic_group(self, other: "ChromecastInfo") -> bool: + """Test chromecast info is same dynamic group.""" + return ( + self.is_audio_group + and other.is_dynamic_group + and self.friendly_name == other.friendly_name + ) + + +class ChromeCastZeroconf: + """Class to hold a zeroconf instance.""" + + __zconf = None + + @classmethod + def set_zeroconf(cls, zconf): + """Set zeroconf.""" + cls.__zconf = zconf + + @classmethod + def get_zeroconf(cls): + """Get zeroconf.""" + return cls.__zconf + + +class CastStatusListener: + """Helper class to handle pychromecast status callbacks. + + Necessary because a CastDevice entity can create a new socket client + and therefore callbacks from multiple chromecast connections can + potentially arrive. This class allows invalidating past chromecast objects. + """ + + def __init__(self, cast_device, chromecast, mz_mgr): + """Initialize the status listener.""" + self._cast_device = cast_device + self._uuid = chromecast.uuid + self._valid = True + self._mz_mgr = mz_mgr + + chromecast.register_status_listener(self) + chromecast.socket_client.media_controller.register_status_listener(self) + chromecast.register_connection_listener(self) + # pylint: disable=protected-access + if cast_device._cast_info.is_audio_group: + self._mz_mgr.add_multizone(chromecast) + else: + self._mz_mgr.register_listener(chromecast.uuid, self) + + def new_cast_status(self, cast_status): + """Handle reception of a new CastStatus.""" + if self._valid: + self._cast_device.new_cast_status(cast_status) + + def new_media_status(self, media_status): + """Handle reception of a new MediaStatus.""" + if self._valid: + self._cast_device.new_media_status(media_status) + + def new_connection_status(self, connection_status): + """Handle reception of a new ConnectionStatus.""" + if self._valid: + self._cast_device.new_connection_status(connection_status) + + @staticmethod + def added_to_multizone(group_uuid): + """Handle the cast added to a group.""" + pass + + def removed_from_multizone(self, group_uuid): + """Handle the cast removed from a group.""" + if self._valid: + self._cast_device.multizone_new_media_status(group_uuid, None) + + def multizone_new_cast_status(self, group_uuid, cast_status): + """Handle reception of a new CastStatus for a group.""" + pass + + def multizone_new_media_status(self, group_uuid, media_status): + """Handle reception of a new MediaStatus for a group.""" + if self._valid: + self._cast_device.multizone_new_media_status(group_uuid, media_status) + + def invalidate(self): + """Invalidate this status listener. + + All following callbacks won't be forwarded. + """ + # pylint: disable=protected-access + if self._cast_device._cast_info.is_audio_group: + self._mz_mgr.remove_multizone(self._uuid) + else: + self._mz_mgr.deregister_listener(self._uuid, self) + self._valid = False + + +class DynamicGroupCastStatusListener: + """Helper class to handle pychromecast status callbacks. + + Necessary because a CastDevice entity can create a new socket client + and therefore callbacks from multiple chromecast connections can + potentially arrive. This class allows invalidating past chromecast objects. + """ + + def __init__(self, cast_device, chromecast, mz_mgr): + """Initialize the status listener.""" + self._cast_device = cast_device + self._uuid = chromecast.uuid + self._valid = True + self._mz_mgr = mz_mgr + + chromecast.register_status_listener(self) + chromecast.socket_client.media_controller.register_status_listener(self) + chromecast.register_connection_listener(self) + self._mz_mgr.add_multizone(chromecast) + + def new_cast_status(self, cast_status): + """Handle reception of a new CastStatus.""" + pass + + def new_media_status(self, media_status): + """Handle reception of a new MediaStatus.""" + if self._valid: + self._cast_device.new_dynamic_group_media_status(media_status) + + def new_connection_status(self, connection_status): + """Handle reception of a new ConnectionStatus.""" + if self._valid: + self._cast_device.new_dynamic_group_connection_status(connection_status) + + def invalidate(self): + """Invalidate this status listener. + + All following callbacks won't be forwarded. + """ + self._mz_mgr.remove_multizone(self._uuid) + self._valid = False diff --git a/homeassistant/components/cast/home_assistant_cast.py b/homeassistant/components/cast/home_assistant_cast.py new file mode 100644 index 00000000000000..d5d35ba7c9f933 --- /dev/null +++ b/homeassistant/components/cast/home_assistant_cast.py @@ -0,0 +1,74 @@ +"""Home Assistant Cast integration for Cast.""" +from typing import Optional + +import voluptuous as vol + +from pychromecast.controllers.homeassistant import HomeAssistantController + +from homeassistant import auth, config_entries, core +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.helpers import config_validation as cv, dispatcher + +from .const import DOMAIN, SIGNAL_HASS_CAST_SHOW_VIEW + +SERVICE_SHOW_VIEW = "show_lovelace_view" +ATTR_VIEW_PATH = "view_path" + + +async def async_setup_ha_cast( + hass: core.HomeAssistant, entry: config_entries.ConfigEntry +): + """Set up Home Assistant Cast.""" + user_id: Optional[str] = entry.data.get("user_id") + user: Optional[auth.models.User] = None + + if user_id is not None: + user = await hass.auth.async_get_user(user_id) + + if user is None: + user = await hass.auth.async_create_system_user( + "Home Assistant Cast", [auth.GROUP_ID_ADMIN] + ) + hass.config_entries.async_update_entry( + entry, data={**entry.data, "user_id": user.id} + ) + + if user.refresh_tokens: + refresh_token: auth.models.RefreshToken = list(user.refresh_tokens.values())[0] + else: + refresh_token = await hass.auth.async_create_refresh_token(user) + + async def handle_show_view(call: core.ServiceCall): + """Handle a Show View service call.""" + hass_url = hass.config.api.base_url + + # Home Assistant Cast only works with https urls. If user has no configured + # base url, use their remote url. + if not hass_url.lower().startswith("https://"): + try: + hass_url = hass.components.cloud.async_remote_ui_url() + except hass.components.cloud.CloudNotAvailable: + pass + + controller = HomeAssistantController( + # If you are developing Home Assistant Cast, uncomment and set to your dev app id. + # app_id="5FE44367", + hass_url=hass_url, + client_id=None, + refresh_token=refresh_token.token, + ) + + dispatcher.async_dispatcher_send( + hass, + SIGNAL_HASS_CAST_SHOW_VIEW, + controller, + call.data[ATTR_ENTITY_ID], + call.data[ATTR_VIEW_PATH], + ) + + hass.helpers.service.async_register_admin_service( + DOMAIN, + SERVICE_SHOW_VIEW, + handle_show_view, + vol.Schema({ATTR_ENTITY_ID: cv.entity_id, ATTR_VIEW_PATH: str}), + ) diff --git a/homeassistant/components/cast/manifest.json b/homeassistant/components/cast/manifest.json index ff9e8907ec5aeb..84a6a6e2934fd6 100644 --- a/homeassistant/components/cast/manifest.json +++ b/homeassistant/components/cast/manifest.json @@ -3,9 +3,7 @@ "name": "Cast", "config_flow": true, "documentation": "https://www.home-assistant.io/components/cast", - "requirements": [ - "pychromecast==3.2.2" - ], + "requirements": ["pychromecast==4.0.1"], "dependencies": [], "zeroconf": ["_googlecast._tcp.local."], "codeowners": [] diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index af9f39f8ed446f..c2d847fd09bb2b 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -1,10 +1,15 @@ """Provide functionality to interact with Cast devices on the network.""" import asyncio import logging -import threading -from typing import Optional, Tuple +from typing import Optional -import attr +import pychromecast +from pychromecast.socket_client import ( + CONNECTION_STATUS_CONNECTED, + CONNECTION_STATUS_DISCONNECTED, +) +from pychromecast.controllers.multizone import MultizoneManager +from pychromecast.controllers.homeassistant import HomeAssistantController import voluptuous as vol from homeassistant.components.media_player import PLATFORM_SCHEMA, MediaPlayerDevice @@ -35,22 +40,34 @@ from homeassistant.core import callback from homeassistant.exceptions import PlatformNotReady import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import async_dispatcher_connect, dispatcher_send +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import ConfigType, HomeAssistantType import homeassistant.util.dt as dt_util from homeassistant.util.logging import async_create_catching_coro -from . import DOMAIN as CAST_DOMAIN - -DEPENDENCIES = ("cast",) +from .const import ( + DOMAIN as CAST_DOMAIN, + ADDED_CAST_DEVICES_KEY, + SIGNAL_CAST_DISCOVERED, + KNOWN_CHROMECAST_INFO_KEY, + CAST_MULTIZONE_MANAGER_KEY, + DEFAULT_PORT, + SIGNAL_CAST_REMOVED, + SIGNAL_HASS_CAST_SHOW_VIEW, +) +from .helpers import ( + ChromecastInfo, + CastStatusListener, + DynamicGroupCastStatusListener, + ChromeCastZeroconf, +) +from .discovery import setup_internal_discovery, discover_chromecast _LOGGER = logging.getLogger(__name__) CONF_IGNORE_CEC = "ignore_cec" CAST_SPLASH = "https://home-assistant.io/images/cast/splash.png" -DEFAULT_PORT = 8009 - SUPPORT_CAST = ( SUPPORT_PAUSE | SUPPORT_PLAY @@ -62,24 +79,6 @@ | SUPPORT_VOLUME_SET ) -# Stores a threading.Lock that is held by the internal pychromecast discovery. -INTERNAL_DISCOVERY_RUNNING_KEY = "cast_discovery_running" -# Stores all ChromecastInfo we encountered through discovery or config as a set -# If we find a chromecast with a new host, the old one will be removed again. -KNOWN_CHROMECAST_INFO_KEY = "cast_known_chromecasts" -# Stores UUIDs of cast devices that were added as entities. Doesn't store -# None UUIDs. -ADDED_CAST_DEVICES_KEY = "cast_added_cast_devices" -# Stores an audio group manager. -CAST_MULTIZONE_MANAGER_KEY = "cast_multizone_manager" - -# Dispatcher signal fired with a ChromecastInfo every time we discover a new -# Chromecast or receive it through configuration -SIGNAL_CAST_DISCOVERED = "cast_discovered" - -# Dispatcher signal fired with a ChromecastInfo every time a Chromecast is -# removed -SIGNAL_CAST_REMOVED = "cast_removed" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -89,212 +88,6 @@ ) -@attr.s(slots=True, frozen=True) -class ChromecastInfo: - """Class to hold all data about a chromecast for creating connections. - - This also has the same attributes as the mDNS fields by zeroconf. - """ - - host = attr.ib(type=str) - port = attr.ib(type=int) - service = attr.ib(type=Optional[str], default=None) - uuid = attr.ib( - type=Optional[str], converter=attr.converters.optional(str), default=None - ) # always convert UUID to string if not None - manufacturer = attr.ib(type=str, default="") - model_name = attr.ib(type=str, default="") - friendly_name = attr.ib(type=Optional[str], default=None) - is_dynamic_group = attr.ib(type=Optional[bool], default=None) - - @property - def is_audio_group(self) -> bool: - """Return if this is an audio group.""" - return self.port != DEFAULT_PORT - - @property - def is_information_complete(self) -> bool: - """Return if all information is filled out.""" - want_dynamic_group = self.is_audio_group - have_dynamic_group = self.is_dynamic_group is not None - have_all_except_dynamic_group = all( - attr.astuple( - self, - filter=attr.filters.exclude( - attr.fields(ChromecastInfo).is_dynamic_group - ), - ) - ) - return have_all_except_dynamic_group and ( - not want_dynamic_group or have_dynamic_group - ) - - @property - def host_port(self) -> Tuple[str, int]: - """Return the host+port tuple.""" - return self.host, self.port - - -def _is_matching_dynamic_group( - our_info: ChromecastInfo, new_info: ChromecastInfo -) -> bool: - return ( - our_info.is_audio_group - and new_info.is_dynamic_group - and our_info.friendly_name == new_info.friendly_name - ) - - -def _fill_out_missing_chromecast_info(info: ChromecastInfo) -> ChromecastInfo: - """Fill out missing attributes of ChromecastInfo using blocking HTTP.""" - if info.is_information_complete: - # We have all information, no need to check HTTP API. Or this is an - # audio group, so checking via HTTP won't give us any new information. - return info - - # Fill out missing information via HTTP dial. - from pychromecast import dial - - if info.is_audio_group: - is_dynamic_group = False - http_group_status = None - dynamic_groups = [] - if info.uuid: - http_group_status = dial.get_multizone_status( - info.host, - services=[info.service], - zconf=ChromeCastZeroconf.get_zeroconf(), - ) - if http_group_status is not None: - dynamic_groups = [str(g.uuid) for g in http_group_status.dynamic_groups] - is_dynamic_group = info.uuid in dynamic_groups - - return ChromecastInfo( - service=info.service, - host=info.host, - port=info.port, - uuid=info.uuid, - friendly_name=info.friendly_name, - manufacturer=info.manufacturer, - model_name=info.model_name, - is_dynamic_group=is_dynamic_group, - ) - - http_device_status = dial.get_device_status( - info.host, services=[info.service], zconf=ChromeCastZeroconf.get_zeroconf() - ) - if http_device_status is None: - # HTTP dial didn't give us any new information. - return info - - return ChromecastInfo( - service=info.service, - host=info.host, - port=info.port, - uuid=(info.uuid or http_device_status.uuid), - friendly_name=(info.friendly_name or http_device_status.friendly_name), - manufacturer=(info.manufacturer or http_device_status.manufacturer), - model_name=(info.model_name or http_device_status.model_name), - ) - - -def _discover_chromecast(hass: HomeAssistantType, info: ChromecastInfo): - if info in hass.data[KNOWN_CHROMECAST_INFO_KEY]: - _LOGGER.debug("Discovered previous chromecast %s", info) - - # Either discovered completely new chromecast or a "moved" one. - info = _fill_out_missing_chromecast_info(info) - _LOGGER.debug("Discovered chromecast %s", info) - - if info.uuid is not None: - # Remove previous cast infos with same uuid from known chromecasts. - same_uuid = set( - x for x in hass.data[KNOWN_CHROMECAST_INFO_KEY] if info.uuid == x.uuid - ) - hass.data[KNOWN_CHROMECAST_INFO_KEY] -= same_uuid - - hass.data[KNOWN_CHROMECAST_INFO_KEY].add(info) - dispatcher_send(hass, SIGNAL_CAST_DISCOVERED, info) - - -def _remove_chromecast(hass: HomeAssistantType, info: ChromecastInfo): - # Removed chromecast - _LOGGER.debug("Removed chromecast %s", info) - - dispatcher_send(hass, SIGNAL_CAST_REMOVED, info) - - -class ChromeCastZeroconf: - """Class to hold a zeroconf instance.""" - - __zconf = None - - @classmethod - def set_zeroconf(cls, zconf): - """Set zeroconf.""" - cls.__zconf = zconf - - @classmethod - def get_zeroconf(cls): - """Get zeroconf.""" - return cls.__zconf - - -def _setup_internal_discovery(hass: HomeAssistantType) -> None: - """Set up the pychromecast internal discovery.""" - if INTERNAL_DISCOVERY_RUNNING_KEY not in hass.data: - hass.data[INTERNAL_DISCOVERY_RUNNING_KEY] = threading.Lock() - - if not hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].acquire(blocking=False): - # Internal discovery is already running - return - - import pychromecast - - def internal_add_callback(name): - """Handle zeroconf discovery of a new chromecast.""" - mdns = listener.services[name] - _discover_chromecast( - hass, - ChromecastInfo( - service=name, - host=mdns[0], - port=mdns[1], - uuid=mdns[2], - model_name=mdns[3], - friendly_name=mdns[4], - ), - ) - - def internal_remove_callback(name, mdns): - """Handle zeroconf discovery of a removed chromecast.""" - _remove_chromecast( - hass, - ChromecastInfo( - service=name, - host=mdns[0], - port=mdns[1], - uuid=mdns[2], - model_name=mdns[3], - friendly_name=mdns[4], - ), - ) - - _LOGGER.debug("Starting internal pychromecast discovery.") - listener, browser = pychromecast.start_discovery( - internal_add_callback, internal_remove_callback - ) - ChromeCastZeroconf.set_zeroconf(browser.zc) - - def stop_discovery(event): - """Stop discovery of new chromecasts.""" - _LOGGER.debug("Stopping internal pychromecast discovery.") - pychromecast.stop_discovery(browser) - hass.data[INTERNAL_DISCOVERY_RUNNING_KEY].release() - - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_discovery) - - @callback def _async_create_cast_device(hass: HomeAssistantType, info: ChromecastInfo): """Create a CastDevice Entity from the chromecast object. @@ -357,8 +150,6 @@ async def _async_setup_platform( hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info ): """Set up the cast platform.""" - import pychromecast - # Import CEC IGNORE attributes pychromecast.IGNORE_CEC += config.get(CONF_IGNORE_CEC, []) hass.data.setdefault(ADDED_CAST_DEVICES_KEY, set()) @@ -390,9 +181,9 @@ def async_cast_discovered(discover: ChromecastInfo) -> None: if info is None or info.is_audio_group: # If we were a) explicitly told to enable discovery or # b) have an audio group cast device, we need internal discovery. - hass.async_add_job(_setup_internal_discovery, hass) + hass.async_add_executor_job(setup_internal_discovery, hass) else: - info = await hass.async_add_job(_fill_out_missing_chromecast_info, info) + info = await hass.async_add_executor_job(info.fill_out_missing_chromecast_info) if info.friendly_name is None: _LOGGER.debug( "Cannot retrieve detail information for chromecast" @@ -400,121 +191,7 @@ def async_cast_discovered(discover: ChromecastInfo) -> None: info, ) - hass.async_add_job(_discover_chromecast, hass, info) - - -class CastStatusListener: - """Helper class to handle pychromecast status callbacks. - - Necessary because a CastDevice entity can create a new socket client - and therefore callbacks from multiple chromecast connections can - potentially arrive. This class allows invalidating past chromecast objects. - """ - - def __init__(self, cast_device, chromecast, mz_mgr): - """Initialize the status listener.""" - self._cast_device = cast_device - self._uuid = chromecast.uuid - self._valid = True - self._mz_mgr = mz_mgr - - chromecast.register_status_listener(self) - chromecast.socket_client.media_controller.register_status_listener(self) - chromecast.register_connection_listener(self) - # pylint: disable=protected-access - if cast_device._cast_info.is_audio_group: - self._mz_mgr.add_multizone(chromecast) - else: - self._mz_mgr.register_listener(chromecast.uuid, self) - - def new_cast_status(self, cast_status): - """Handle reception of a new CastStatus.""" - if self._valid: - self._cast_device.new_cast_status(cast_status) - - def new_media_status(self, media_status): - """Handle reception of a new MediaStatus.""" - if self._valid: - self._cast_device.new_media_status(media_status) - - def new_connection_status(self, connection_status): - """Handle reception of a new ConnectionStatus.""" - if self._valid: - self._cast_device.new_connection_status(connection_status) - - @staticmethod - def added_to_multizone(group_uuid): - """Handle the cast added to a group.""" - pass - - def removed_from_multizone(self, group_uuid): - """Handle the cast removed from a group.""" - if self._valid: - self._cast_device.multizone_new_media_status(group_uuid, None) - - def multizone_new_cast_status(self, group_uuid, cast_status): - """Handle reception of a new CastStatus for a group.""" - pass - - def multizone_new_media_status(self, group_uuid, media_status): - """Handle reception of a new MediaStatus for a group.""" - if self._valid: - self._cast_device.multizone_new_media_status(group_uuid, media_status) - - def invalidate(self): - """Invalidate this status listener. - - All following callbacks won't be forwarded. - """ - # pylint: disable=protected-access - if self._cast_device._cast_info.is_audio_group: - self._mz_mgr.remove_multizone(self._uuid) - else: - self._mz_mgr.deregister_listener(self._uuid, self) - self._valid = False - - -class DynamicGroupCastStatusListener: - """Helper class to handle pychromecast status callbacks. - - Necessary because a CastDevice entity can create a new socket client - and therefore callbacks from multiple chromecast connections can - potentially arrive. This class allows invalidating past chromecast objects. - """ - - def __init__(self, cast_device, chromecast, mz_mgr): - """Initialize the status listener.""" - self._cast_device = cast_device - self._uuid = chromecast.uuid - self._valid = True - self._mz_mgr = mz_mgr - - chromecast.register_status_listener(self) - chromecast.socket_client.media_controller.register_status_listener(self) - chromecast.register_connection_listener(self) - self._mz_mgr.add_multizone(chromecast) - - def new_cast_status(self, cast_status): - """Handle reception of a new CastStatus.""" - pass - - def new_media_status(self, media_status): - """Handle reception of a new MediaStatus.""" - if self._valid: - self._cast_device.new_dynamic_group_media_status(media_status) - - def new_connection_status(self, connection_status): - """Handle reception of a new ConnectionStatus.""" - if self._valid: - self._cast_device.new_dynamic_group_connection_status(connection_status) - - def invalidate(self): - """Invalidate this status listener. - - All following callbacks won't be forwarded. - """ - self._mz_mgr.remove_multizone(self._uuid) - self._valid = False + hass.async_add_executor_job(discover_chromecast, hass, info) class CastDevice(MediaPlayerDevice): @@ -525,106 +202,51 @@ class CastDevice(MediaPlayerDevice): "elected leader" itself. """ - def __init__(self, cast_info): + def __init__(self, cast_info: ChromecastInfo): """Initialize the cast device.""" - import pychromecast # noqa: pylint: disable=unused-import - self._cast_info = cast_info # type: ChromecastInfo + self._cast_info = cast_info self.services = None if cast_info.service: self.services = set() self.services.add(cast_info.service) - self._chromecast = None # type: Optional[pychromecast.Chromecast] + self._chromecast: Optional[pychromecast.Chromecast] = None self.cast_status = None self.media_status = None self.media_status_received = None - self._dynamic_group_cast_info = None # type: ChromecastInfo - self._dynamic_group_cast = None # type: Optional[pychromecast.Chromecast] + self._dynamic_group_cast_info: ChromecastInfo = None + self._dynamic_group_cast: Optional[pychromecast.Chromecast] = None self.dynamic_group_media_status = None self.dynamic_group_media_status_received = None self.mz_media_status = {} self.mz_media_status_received = {} self.mz_mgr = None - self._available = False # type: bool - self._dynamic_group_available = False # type: bool - self._status_listener = None # type: Optional[CastStatusListener] - self._dynamic_group_status_listener = ( - None - ) # type: Optional[DynamicGroupCastStatusListener] + self._available = False + self._dynamic_group_available = False + self._status_listener: Optional[CastStatusListener] = None + self._dynamic_group_status_listener: Optional[ + DynamicGroupCastStatusListener + ] = None + self._hass_cast_controller: Optional[HomeAssistantController] = None + self._add_remove_handler = None self._del_remove_handler = None + self._cast_view_remove_handler = None async def async_added_to_hass(self): """Create chromecast object when added to hass.""" - - @callback - def async_cast_discovered(discover: ChromecastInfo): - """Handle discovery of new Chromecast.""" - if self._cast_info.uuid is None: - # We can't handle empty UUIDs - return - if _is_matching_dynamic_group(self._cast_info, discover): - _LOGGER.debug("Discovered matching dynamic group: %s", discover) - self.hass.async_create_task( - async_create_catching_coro(self.async_set_dynamic_group(discover)) - ) - return - - if self._cast_info.uuid != discover.uuid: - # Discovered is not our device. - return - if self.services is None: - _LOGGER.warning( - "[%s %s (%s:%s)] Received update for manually added Cast", - self.entity_id, - self._cast_info.friendly_name, - self._cast_info.host, - self._cast_info.port, - ) - return - _LOGGER.debug("Discovered chromecast with same UUID: %s", discover) - self.hass.async_create_task( - async_create_catching_coro(self.async_set_cast_info(discover)) - ) - - def async_cast_removed(discover: ChromecastInfo): - """Handle removal of Chromecast.""" - if self._cast_info.uuid is None: - # We can't handle empty UUIDs - return - if ( - self._dynamic_group_cast_info is not None - and self._dynamic_group_cast_info.uuid == discover.uuid - ): - _LOGGER.debug("Removed matching dynamic group: %s", discover) - self.hass.async_create_task( - async_create_catching_coro(self.async_del_dynamic_group()) - ) - return - if self._cast_info.uuid != discover.uuid: - # Removed is not our device. - return - _LOGGER.debug("Removed chromecast with same UUID: %s", discover) - self.hass.async_create_task( - async_create_catching_coro(self.async_del_cast_info(discover)) - ) - - async def async_stop(event): - """Disconnect socket on Home Assistant stop.""" - await self._async_disconnect() - self._add_remove_handler = async_dispatcher_connect( - self.hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered + self.hass, SIGNAL_CAST_DISCOVERED, self._async_cast_discovered ) self._del_remove_handler = async_dispatcher_connect( - self.hass, SIGNAL_CAST_REMOVED, async_cast_removed + self.hass, SIGNAL_CAST_REMOVED, self._async_cast_removed ) - self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_stop) self.hass.async_create_task( async_create_catching_coro(self.async_set_cast_info(self._cast_info)) ) for info in self.hass.data[KNOWN_CHROMECAST_INFO_KEY]: - if _is_matching_dynamic_group(self._cast_info, info): + if self._cast_info.same_dynamic_group(info): _LOGGER.debug( "[%s %s (%s:%s)] Found dynamic group: %s", self.entity_id, @@ -638,6 +260,10 @@ async def async_stop(event): ) break + self._cast_view_remove_handler = async_dispatcher_connect( + self.hass, SIGNAL_HASS_CAST_SHOW_VIEW, self._handle_signal_show_view + ) + async def async_will_remove_from_hass(self) -> None: """Disconnect Chromecast object when removed.""" await self._async_disconnect() @@ -647,12 +273,16 @@ async def async_will_remove_from_hass(self) -> None: self.hass.data[ADDED_CAST_DEVICES_KEY].remove(self._cast_info.uuid) if self._add_remove_handler: self._add_remove_handler() + self._add_remove_handler = None if self._del_remove_handler: self._del_remove_handler() + self._del_remove_handler = None + if self._cast_view_remove_handler: + self._cast_view_remove_handler() + self._cast_view_remove_handler = None async def async_set_cast_info(self, cast_info): """Set the cast information and set up the chromecast object.""" - import pychromecast self._cast_info = cast_info @@ -717,9 +347,8 @@ async def async_set_cast_info(self, cast_info): self._chromecast = chromecast if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data: - from pychromecast.controllers.multizone import MultizoneManager - self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager() + self.mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY] self._status_listener = CastStatusListener(self, chromecast, self.mz_mgr) @@ -744,7 +373,6 @@ async def async_del_cast_info(self, cast_info): async def async_set_dynamic_group(self, cast_info): """Set the cast information and set up the chromecast object.""" - import pychromecast _LOGGER.debug( "[%s %s (%s:%s)] Connecting to dynamic group by host %s", @@ -773,9 +401,8 @@ async def async_set_dynamic_group(self, cast_info): self._dynamic_group_cast = chromecast if CAST_MULTIZONE_MANAGER_KEY not in self.hass.data: - from pychromecast.controllers.multizone import MultizoneManager - self.hass.data[CAST_MULTIZONE_MANAGER_KEY] = MultizoneManager() + mz_mgr = self.hass.data[CAST_MULTIZONE_MANAGER_KEY] self._dynamic_group_status_listener = DynamicGroupCastStatusListener( @@ -839,6 +466,7 @@ def _invalidate(self): self.mz_media_status = {} self.mz_media_status_received = {} self.mz_mgr = None + self._hass_cast_controller = None if self._status_listener is not None: self._status_listener.invalidate() self._status_listener = None @@ -866,11 +494,6 @@ def new_media_status(self, media_status): def new_connection_status(self, connection_status): """Handle updates of connection status.""" - from pychromecast.socket_client import ( - CONNECTION_STATUS_CONNECTED, - CONNECTION_STATUS_DISCONNECTED, - ) - _LOGGER.debug( "[%s %s (%s:%s)] Received cast device connection status: %s", self.entity_id, @@ -901,7 +524,7 @@ def new_connection_status(self, connection_status): info = self._cast_info if info.friendly_name is None and not info.is_audio_group: # We couldn't find friendly_name when the cast was added, retry - self._cast_info = _fill_out_missing_chromecast_info(info) + self._cast_info = info.fill_out_missing_chromecast_info() self._available = new_available self.schedule_update_ha_state() @@ -913,11 +536,6 @@ def new_dynamic_group_media_status(self, media_status): def new_dynamic_group_connection_status(self, connection_status): """Handle updates of connection status.""" - from pychromecast.socket_client import ( - CONNECTION_STATUS_CONNECTED, - CONNECTION_STATUS_DISCONNECTED, - ) - _LOGGER.debug( "[%s %s (%s:%s)] Received dynamic group connection status: %s", self.entity_id, @@ -991,7 +609,6 @@ def _media_controller(self): def turn_on(self): """Turn on the cast device.""" - import pychromecast if not self._chromecast.is_idle: # Already turned on @@ -1276,3 +893,69 @@ def media_position_updated_at(self): def unique_id(self) -> Optional[str]: """Return a unique ID.""" return self._cast_info.uuid + + async def _async_cast_discovered(self, discover: ChromecastInfo): + """Handle discovery of new Chromecast.""" + if self._cast_info.uuid is None: + # We can't handle empty UUIDs + return + + if self._cast_info.same_dynamic_group(discover): + _LOGGER.debug("Discovered matching dynamic group: %s", discover) + await self.async_set_dynamic_group(discover) + return + + if self._cast_info.uuid != discover.uuid: + # Discovered is not our device. + return + + if self.services is None: + _LOGGER.warning( + "[%s %s (%s:%s)] Received update for manually added Cast", + self.entity_id, + self._cast_info.friendly_name, + self._cast_info.host, + self._cast_info.port, + ) + return + + _LOGGER.debug("Discovered chromecast with same UUID: %s", discover) + await self.async_set_cast_info(discover) + + async def _async_cast_removed(self, discover: ChromecastInfo): + """Handle removal of Chromecast.""" + if self._cast_info.uuid is None: + # We can't handle empty UUIDs + return + + if ( + self._dynamic_group_cast_info is not None + and self._dynamic_group_cast_info.uuid == discover.uuid + ): + _LOGGER.debug("Removed matching dynamic group: %s", discover) + await self.async_del_dynamic_group() + return + + if self._cast_info.uuid != discover.uuid: + # Removed is not our device. + return + + _LOGGER.debug("Removed chromecast with same UUID: %s", discover) + await self.async_del_cast_info(discover) + + async def _async_stop(self, event): + """Disconnect socket on Home Assistant stop.""" + await self._async_disconnect() + + def _handle_signal_show_view( + self, controller: HomeAssistantController, entity_id: str, view_path: str + ): + """Handle a show view signal.""" + if entity_id != self.entity_id: + return + + if self._hass_cast_controller is None: + self._hass_cast_controller = controller + self._chromecast.register_handler(controller) + + self._hass_cast_controller.show_lovelace_view(view_path) diff --git a/homeassistant/components/cast/services.yaml b/homeassistant/components/cast/services.yaml new file mode 100644 index 00000000000000..24bc7b16a903c7 --- /dev/null +++ b/homeassistant/components/cast/services.yaml @@ -0,0 +1,9 @@ +show_lovelace_view: + description: Show a Lovelace view on a Chromecast. + fields: + entity_id: + description: Media Player entity to show the Lovelace view on. + example: "media_player.kitchen" + view_path: + description: The path of the Lovelace view to show. + example: downstairs diff --git a/homeassistant/components/cert_expiry/.translations/de.json b/homeassistant/components/cert_expiry/.translations/de.json new file mode 100644 index 00000000000000..344abe13067c42 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/de.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "host_port_exists": "Diese Kombination aus Host und Port ist bereits konfiguriert." + }, + "error": { + "certificate_fetch_failed": "Zertifikat kann von dieser Kombination aus Host und Port nicht abgerufen werden", + "connection_timeout": "Zeit\u00fcberschreitung beim Herstellen einer Verbindung mit diesem Host", + "host_port_exists": "Diese Kombination aus Host und Port ist bereits konfiguriert.", + "resolve_failed": "Dieser Host kann nicht aufgel\u00f6st werden" + }, + "step": { + "user": { + "data": { + "host": "Der Hostname des Zertifikats", + "name": "Der Name des Zertifikats", + "port": "Der Port des Zertifikats" + }, + "title": "Definieren Sie das zu testende Zertifikat" + } + }, + "title": "Zertifikatsablauf" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/en.json b/homeassistant/components/cert_expiry/.translations/en.json index b6aa1cefb02aed..873dfee9a92bb9 100644 --- a/homeassistant/components/cert_expiry/.translations/en.json +++ b/homeassistant/components/cert_expiry/.translations/en.json @@ -5,7 +5,7 @@ }, "error": { "certificate_fetch_failed": "Can not fetch certificate from this host and port combination", - "connection_timeout": "Timeout whemn connecting to this host", + "connection_timeout": "Timeout when connecting to this host", "host_port_exists": "This host and port combination is already configured", "resolve_failed": "This host can not be resolved" }, diff --git a/homeassistant/components/cert_expiry/.translations/es.json b/homeassistant/components/cert_expiry/.translations/es.json new file mode 100644 index 00000000000000..b10518646ac907 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/es.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "host_port_exists": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada" + }, + "error": { + "certificate_fetch_failed": "No se puede obtener el certificado de esta combinaci\u00f3n de host y puerto", + "connection_timeout": "Tiempo de espera agotado al conectar a este host", + "host_port_exists": "Esta combinaci\u00f3n de host y puerto ya est\u00e1 configurada", + "resolve_failed": "Este host no se puede resolver" + }, + "step": { + "user": { + "data": { + "host": "El nombre de host del certificado", + "name": "El nombre del certificado", + "port": "El puerto del certificado" + }, + "title": "Defina el certificado para probar" + } + }, + "title": "Caducidad del certificado" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/fr.json b/homeassistant/components/cert_expiry/.translations/fr.json new file mode 100644 index 00000000000000..a3536902c76d26 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/fr.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "host_port_exists": "Cette combinaison h\u00f4te / port est d\u00e9j\u00e0 configur\u00e9e" + }, + "error": { + "certificate_fetch_failed": "Impossible de r\u00e9cup\u00e9rer le certificat de cette combinaison h\u00f4te / port", + "connection_timeout": "D\u00e9lai d'attente lors de la connexion \u00e0 cet h\u00f4te", + "host_port_exists": "Cette combinaison h\u00f4te / port est d\u00e9j\u00e0 configur\u00e9e", + "resolve_failed": "Cet h\u00f4te ne peut pas \u00eatre r\u00e9solu" + }, + "step": { + "user": { + "data": { + "host": "Le nom d'h\u00f4te du certificat", + "name": "Le nom du certificat", + "port": "Le port du certificat" + }, + "title": "D\u00e9finir le certificat \u00e0 tester" + } + }, + "title": "Expiration du certificat" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/it.json b/homeassistant/components/cert_expiry/.translations/it.json new file mode 100644 index 00000000000000..73749382dd9bca --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/it.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "host_port_exists": "Questa combinazione di host e porta \u00e8 gi\u00e0 configurata" + }, + "error": { + "certificate_fetch_failed": "Non \u00e8 possibile recuperare il certificato da questa combinazione di host e porta", + "connection_timeout": "Tempo scaduto collegandosi a questo host", + "host_port_exists": "Questa combinazione di host e porta \u00e8 gi\u00e0 configurata", + "resolve_failed": "Questo host non pu\u00f2 essere risolto" + }, + "step": { + "user": { + "data": { + "host": "L'hostname del certificato", + "name": "Il nome del certificato", + "port": "La porta del certificato" + }, + "title": "Definire il certificato da testare" + } + }, + "title": "Scadenza certificato" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/ko.json b/homeassistant/components/cert_expiry/.translations/ko.json new file mode 100644 index 00000000000000..a807d32a6fbaaa --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/ko.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "host_port_exists": "\ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "certificate_fetch_failed": "\ud574\ub2f9 \ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\uc5d0\uc11c \uc778\uc99d\uc11c\ub97c \uac00\uc838 \uc62c \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "connection_timeout": "\ud638\uc2a4\ud2b8 \uc5f0\uacb0 \uc2dc\uac04\uc774 \ucd08\uacfc\ud588\uc2b5\ub2c8\ub2e4", + "host_port_exists": "\ud638\uc2a4\ud2b8\uc640 \ud3ec\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "resolve_failed": "\ud638\uc2a4\ud2b8\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "host": "\uc778\uc99d\uc11c\uc758 \ud638\uc2a4\ud2b8 \uc774\ub984", + "name": "\uc778\uc99d\uc11c\uc758 \uc774\ub984", + "port": "\uc778\uc99d\uc11c\uc758 \ud3ec\ud2b8" + }, + "title": "\uc778\uc99d\uc11c \uc815\uc758 \ud14c\uc2a4\ud2b8 \ub300\uc0c1" + } + }, + "title": "\uc778\uc99d\uc11c \ub9cc\ub8cc" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/lb.json b/homeassistant/components/cert_expiry/.translations/lb.json new file mode 100644 index 00000000000000..9620526e363d9d --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/lb.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "host_port_exists": "D\u00ebsen Host an Port sinn scho konfigur\u00e9iert" + }, + "error": { + "certificate_fetch_failed": "Kann keen Zertifikat vun d\u00ebsen Host a Port recuper\u00e9ieren", + "connection_timeout": "Z\u00e4it Iwwerschreidung beim verbannen.", + "host_port_exists": "D\u00ebsen Host an Port sinn scho konfigur\u00e9iert", + "resolve_failed": "D\u00ebsen Host kann net opgel\u00e9ist ginn" + }, + "step": { + "user": { + "data": { + "host": "Den Hostnumm vum Zertifikat", + "name": "De Numm vum Zertifikat", + "port": "De Port vum Zertifikat" + }, + "title": "W\u00e9ieen Zertifikat soll getest ginn" + } + }, + "title": "Zertifikat Verfallsdatum" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/no.json b/homeassistant/components/cert_expiry/.translations/no.json index e095cc360a0f4e..73e899106c1063 100644 --- a/homeassistant/components/cert_expiry/.translations/no.json +++ b/homeassistant/components/cert_expiry/.translations/no.json @@ -5,7 +5,7 @@ }, "error": { "certificate_fetch_failed": "Kan ikke hente sertifikat fra denne verts- og portkombinasjonen", - "connection_timeout": "Timeout n\u00e5r det kobles til denne verten", + "connection_timeout": "Tidsavbrudd n\u00e5r du kobler til denne verten", "host_port_exists": "Denne verts- og portkombinasjonen er allerede konfigurert", "resolve_failed": "Denne verten kan ikke l\u00f8ses" }, diff --git a/homeassistant/components/cert_expiry/.translations/sl.json b/homeassistant/components/cert_expiry/.translations/sl.json new file mode 100644 index 00000000000000..3774956330a52a --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/sl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "host_port_exists": "Ta kombinacija gostitelja in vrat je \u017ee konfigurirana" + }, + "error": { + "certificate_fetch_failed": "Iz te kombinacije gostitelja in vrat ni mogo\u010de pridobiti potrdila", + "connection_timeout": "\u010casovna omejitev za povezavo s tem gostiteljem je potekla", + "host_port_exists": "Ta kombinacija gostitelja in vrat je \u017ee konfigurirana", + "resolve_failed": "Tega gostitelja ni mogo\u010de razre\u0161iti" + }, + "step": { + "user": { + "data": { + "host": "Ime gostitelja potrdila", + "name": "Ime potrdila", + "port": "Vrata potrdila" + }, + "title": "Dolo\u010dite potrdilo za testiranje" + } + }, + "title": "Veljavnost certifikata" + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/.translations/zh-Hans.json b/homeassistant/components/cert_expiry/.translations/zh-Hans.json new file mode 100644 index 00000000000000..07affc990a8173 --- /dev/null +++ b/homeassistant/components/cert_expiry/.translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "error": { + "connection_timeout": "\u8fde\u63a5\u5230\u6b64\u4e3b\u673a\u65f6\u7684\u8d85\u65f6" + }, + "step": { + "user": { + "data": { + "host": "\u8bc1\u4e66\u7684\u4e3b\u673a\u540d", + "name": "\u8bc1\u4e66\u7684\u540d\u79f0", + "port": "\u8bc1\u4e66\u7684\u7aef\u53e3" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index ab68d5ba08bc43..7c7efea7333120 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -1,7 +1,5 @@ """The cert_expiry component.""" from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.core import callback from homeassistant.helpers.typing import HomeAssistantType @@ -13,13 +11,7 @@ async def async_setup(hass, config): async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Load the saved entities.""" - @callback - def async_start(_): - """Load the entry after the start event.""" - hass.async_create_task( - hass.config_entries.async_forward_entry_setup(entry, "sensor") - ) - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, async_start) - + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) return True diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index dd3463fff95c4e..d73762ce882647 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -38,10 +38,12 @@ def _prt_in_configuration_exists(self, user_input) -> bool: return True return False - def _test_connection(self, user_input=None): + async def _test_connection(self, user_input=None): """Test connection to the server and try to get the certtificate.""" try: - get_cert(user_input[CONF_HOST], user_input.get(CONF_PORT, DEFAULT_PORT)) + await self.hass.async_add_executor_job( + get_cert, user_input[CONF_HOST], user_input.get(CONF_PORT, DEFAULT_PORT) + ) return True except socket.gaierror: self._errors[CONF_HOST] = "resolve_failed" @@ -59,7 +61,7 @@ async def async_step_user(self, user_input=None): if self._prt_in_configuration_exists(user_input): self._errors[CONF_HOST] = "host_port_exists" else: - if self._test_connection(user_input): + if await self._test_connection(user_input): host = user_input[CONF_HOST] name = slugify(user_input.get(CONF_NAME, DEFAULT_NAME)) prt = user_input.get(CONF_PORT, DEFAULT_PORT) diff --git a/homeassistant/components/cert_expiry/sensor.py b/homeassistant/components/cert_expiry/sensor.py index fccfb295c0fff2..b564cff7338584 100644 --- a/homeassistant/components/cert_expiry/sensor.py +++ b/homeassistant/components/cert_expiry/sensor.py @@ -9,7 +9,12 @@ import homeassistant.helpers.config_validation as cv from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, CONF_HOST, CONF_PORT +from homeassistant.const import ( + CONF_NAME, + CONF_HOST, + CONF_PORT, + EVENT_HOMEASSISTANT_START, +) from homeassistant.helpers.entity import Entity from .const import DOMAIN, DEFAULT_NAME, DEFAULT_PORT @@ -82,6 +87,15 @@ def available(self): """Icon to use in the frontend, if any.""" return self._available + async def async_added_to_hass(self): + """Once the entity is added we should update to get the initial data loaded.""" + + def do_update(_): + """Run the update method when the start event was fired.""" + self.update() + + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, do_update) + def update(self): """Fetch the certificate information.""" try: diff --git a/homeassistant/components/cert_expiry/strings.json b/homeassistant/components/cert_expiry/strings.json index 8943643e8b392e..3e2fea2342e260 100644 --- a/homeassistant/components/cert_expiry/strings.json +++ b/homeassistant/components/cert_expiry/strings.json @@ -14,7 +14,7 @@ "error": { "host_port_exists": "This host and port combination is already configured", "resolve_failed": "This host can not be resolved", - "connection_timeout": "Timeout whemn connecting to this host", + "connection_timeout": "Timeout when connecting to this host", "certificate_fetch_failed": "Can not fetch certificate from this host and port combination" }, "abort": { diff --git a/homeassistant/components/concord232/alarm_control_panel.py b/homeassistant/components/concord232/alarm_control_panel.py index 68f0d77e307a82..e86ec02040e3b3 100644 --- a/homeassistant/components/concord232/alarm_control_panel.py +++ b/homeassistant/components/concord232/alarm_control_panel.py @@ -69,7 +69,6 @@ def __init__(self, url, name, code, mode): self._url = url self._alarm = concord232_client.Client(self._url) self._alarm.partitions = self._alarm.list_partitions() - self._alarm.last_partition_update = datetime.datetime.now() @property def name(self): diff --git a/homeassistant/components/concord232/binary_sensor.py b/homeassistant/components/concord232/binary_sensor.py index 10643f134d70e8..1a406d743b7cfa 100644 --- a/homeassistant/components/concord232/binary_sensor.py +++ b/homeassistant/components/concord232/binary_sensor.py @@ -12,6 +12,7 @@ ) from homeassistant.const import CONF_HOST, CONF_PORT import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -53,7 +54,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.debug("Initializing client") client = concord232_client.Client(f"http://{host}:{port}") client.zones = client.list_zones() - client.last_zone_update = datetime.datetime.now() + client.last_zone_update = dt_util.utcnow() except requests.exceptions.ConnectionError as ex: _LOGGER.error("Unable to connect to Concord232: %s", str(ex)) @@ -128,11 +129,11 @@ def is_on(self): def update(self): """Get updated stats from API.""" - last_update = datetime.datetime.now() - self._client.last_zone_update + last_update = dt_util.utcnow() - self._client.last_zone_update _LOGGER.debug("Zone: %s ", self._zone) if last_update > datetime.timedelta(seconds=1): self._client.zones = self._client.list_zones() - self._client.last_zone_update = datetime.datetime.now() + self._client.last_zone_update = dt_util.utcnow() _LOGGER.debug("Updated from zone: %s", self._zone["name"]) if hasattr(self._client, "zones"): diff --git a/homeassistant/components/config/automation.py b/homeassistant/components/config/automation.py index 7ca71fc4f93dfb..17efdba3fb573f 100644 --- a/homeassistant/components/config/automation.py +++ b/homeassistant/components/config/automation.py @@ -53,7 +53,7 @@ def _write_value(self, hass, data, config_key, new_value): # Iterate through some keys that we want to have ordered in the output updated_value = OrderedDict() - for key in ("id", "alias", "trigger", "condition", "action"): + for key in ("id", "alias", "description", "trigger", "condition", "action"): if key in cur_value: updated_value[key] = cur_value[key] if key in new_value: diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index d491765bb00f57..8d2b4430fe110c 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -2,6 +2,7 @@ from datetime import timedelta import functools as ft import logging +from typing import Any import voluptuous as vol @@ -33,7 +34,7 @@ ) -# mypy: allow-untyped-calls, allow-incomplete-defs, allow-untyped-defs +# mypy: allow-untyped-calls, allow-untyped-defs _LOGGER = logging.getLogger(__name__) @@ -263,7 +264,7 @@ def is_closed(self): """Return if the cover is closed or not.""" raise NotImplementedError() - def open_cover(self, **kwargs): + def open_cover(self, **kwargs: Any) -> None: """Open the cover.""" raise NotImplementedError() @@ -274,7 +275,7 @@ def async_open_cover(self, **kwargs): """ return self.hass.async_add_job(ft.partial(self.open_cover, **kwargs)) - def close_cover(self, **kwargs): + def close_cover(self, **kwargs: Any) -> None: """Close cover.""" raise NotImplementedError() @@ -285,7 +286,7 @@ def async_close_cover(self, **kwargs): """ return self.hass.async_add_job(ft.partial(self.close_cover, **kwargs)) - def toggle(self, **kwargs) -> None: + def toggle(self, **kwargs: Any) -> None: """Toggle the entity.""" if self.is_closed: self.open_cover(**kwargs) @@ -323,7 +324,7 @@ def async_stop_cover(self, **kwargs): """ return self.hass.async_add_job(ft.partial(self.stop_cover, **kwargs)) - def open_cover_tilt(self, **kwargs): + def open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" pass @@ -334,7 +335,7 @@ def async_open_cover_tilt(self, **kwargs): """ return self.hass.async_add_job(ft.partial(self.open_cover_tilt, **kwargs)) - def close_cover_tilt(self, **kwargs): + def close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" pass @@ -369,7 +370,7 @@ def async_stop_cover_tilt(self, **kwargs): """ return self.hass.async_add_job(ft.partial(self.stop_cover_tilt, **kwargs)) - def toggle_tilt(self, **kwargs) -> None: + def toggle_tilt(self, **kwargs: Any) -> None: """Toggle the entity.""" if self.current_cover_tilt_position == 0: self.open_cover_tilt(**kwargs) diff --git a/homeassistant/components/daikin/.translations/ko.json b/homeassistant/components/daikin/.translations/ko.json index 2291d46800d849..4b1d1bd86e5b8a 100644 --- a/homeassistant/components/daikin/.translations/ko.json +++ b/homeassistant/components/daikin/.translations/ko.json @@ -10,10 +10,10 @@ "data": { "host": "\ud638\uc2a4\ud2b8" }, - "description": "Daikin AC \uc758 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", - "title": "Daikin AC \uad6c\uc131" + "description": "\ub2e4\uc774\ud0a8 \uc5d0\uc5b4\ucee8\uc758 IP \uc8fc\uc18c\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "title": "\ub2e4\uc774\ud0a8 \uc5d0\uc5b4\ucee8 \uad6c\uc131" } }, - "title": "Daikin AC" + "title": "\ub2e4\uc774\ud0a8 \uc5d0\uc5b4\ucee8" } } \ No newline at end of file diff --git a/homeassistant/components/darksky/sensor.py b/homeassistant/components/darksky/sensor.py index 4f000253245f1f..d4e7e7ec63a97c 100644 --- a/homeassistant/components/darksky/sensor.py +++ b/homeassistant/components/darksky/sensor.py @@ -371,7 +371,7 @@ CONDITION_PICTURES = { "clear-day": ["/static/images/darksky/weather-sunny.svg", "mdi:weather-sunny"], - "clear-night": ["/static/images/darksky/weather-night.svg", "mdi:weather-sunny"], + "clear-night": ["/static/images/darksky/weather-night.svg", "mdi:weather-night"], "rain": ["/static/images/darksky/weather-pouring.svg", "mdi:weather-pouring"], "snow": ["/static/images/darksky/weather-snowy.svg", "mdi:weather-snowy"], "sleet": ["/static/images/darksky/weather-hail.svg", "mdi:weather-snowy-rainy"], diff --git a/homeassistant/components/deconz/.translations/bg.json b/homeassistant/components/deconz/.translations/bg.json index a02a6ff422338f..f3eead4aae023a 100644 --- a/homeassistant/components/deconz/.translations/bg.json +++ b/homeassistant/components/deconz/.translations/bg.json @@ -40,5 +40,34 @@ } }, "title": "deCONZ" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\u0418 \u0434\u0432\u0430\u0442\u0430 \u0431\u0443\u0442\u043e\u043d\u0430", + "button_1": "\u041f\u044a\u0440\u0432\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_2": "\u0412\u0442\u043e\u0440\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_3": "\u0422\u0440\u0435\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", + "button_4": "\u0427\u0435\u0442\u0432\u044a\u0440\u0442\u0438 \u0431\u0443\u0442\u043e\u043d", + "close": "\u0417\u0430\u0442\u0432\u0430\u0440\u044f\u043d\u0435", + "dim_down": "\u0417\u0430\u0442\u044a\u043c\u043d\u044f\u0432\u0430\u043d\u0435", + "dim_up": "\u041e\u0441\u0432\u0435\u0442\u044f\u0432\u0430\u043d\u0435", + "left": "\u041b\u044f\u0432\u043e", + "open": "\u041e\u0442\u0432\u0430\u0440\u044f\u043d\u0435", + "right": "\u0414\u044f\u0441\u043d\u043e", + "turn_off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0438", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438" + }, + "trigger_type": { + "remote_button_double_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u0434\u0432\u0443\u043a\u0440\u0430\u0442\u043d\u043e", + "remote_button_long_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u043f\u0440\u043e\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e", + "remote_button_long_release": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043e\u0442\u043f\u0443\u0441\u043d\u0430\u0442 \u0441\u043b\u0435\u0434 \u043f\u0440\u043e\u0434\u044a\u043b\u0436\u0438\u0442\u0435\u043b\u043d\u043e \u043d\u0430\u0442\u0438\u0441\u043a\u0430\u043d\u0435", + "remote_button_quadruple_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u0447\u0435\u0442\u0438\u0440\u0438\u043a\u0440\u0430\u0442\u043d\u043e", + "remote_button_quintuple_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u043f\u0435\u0442\u043a\u0440\u0430\u0442\u043d\u043e", + "remote_button_rotated": "\u0417\u0430\u0432\u044a\u0440\u0442\u044f\u043d \u0431\u0443\u0442\u043e\u043d \"{subtype}\"", + "remote_button_short_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442", + "remote_button_short_release": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043e\u0442\u043f\u0443\u0441\u043d\u0430\u0442", + "remote_button_triple_press": "\"{subtype}\" \u0431\u0443\u0442\u043e\u043d\u044a\u0442 \u0431\u0435\u0448\u0435 \u043d\u0430\u0442\u0438\u0441\u043d\u0430\u0442 \u0442\u0440\u0438\u043a\u0440\u0430\u0442\u043d\u043e", + "remote_gyro_activated": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u0442\u043e \u0435 \u0440\u0430\u0437\u043a\u043b\u0430\u0442\u0435\u043d\u043e" + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/ca.json b/homeassistant/components/deconz/.translations/ca.json index 56ae59c78ba031..d36de4acc1e96c 100644 --- a/homeassistant/components/deconz/.translations/ca.json +++ b/homeassistant/components/deconz/.translations/ca.json @@ -41,6 +41,35 @@ }, "title": "Passarel\u00b7la d'enlla\u00e7 deCONZ Zigbee" }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Ambd\u00f3s botons", + "button_1": "Primer bot\u00f3", + "button_2": "Segon bot\u00f3", + "button_3": "Tercer bot\u00f3", + "button_4": "Quart bot\u00f3", + "close": "Tanca", + "dim_down": "Atenua la brillantor", + "dim_up": "Augmenta la brillantor", + "left": "Esquerra", + "open": "Obert", + "right": "Dreta", + "turn_off": "Desactiva", + "turn_on": "Activa" + }, + "trigger_type": { + "remote_button_double_press": "Bot\u00f3 \"{subtype}\" clicat dues vegades consecutives", + "remote_button_long_press": "Bot\u00f3 \"{subtype}\" premut continuament", + "remote_button_long_release": "Bot\u00f3 \"{subtype}\" alliberat despr\u00e9s d'una estona premut", + "remote_button_quadruple_press": "Bot\u00f3 \"{subtype}\" clicat quatre vegades consecutives", + "remote_button_quintuple_press": "Bot\u00f3 \"{subtype}\" clicat cinc vegades consecutives", + "remote_button_rotated": "Bot\u00f3 \"{subtype}\" girat", + "remote_button_short_press": "Bot\u00f3 \"{subtype}\" premut", + "remote_button_short_release": "Bot\u00f3 \"{subtype}\" alliberat", + "remote_button_triple_press": "Bot\u00f3 \"{subtype}\" clicat tres vegades consecutives", + "remote_gyro_activated": "Dispositiu sacsejat" + } + }, "options": { "step": { "async_step_deconz_devices": { @@ -48,7 +77,14 @@ "allow_clip_sensor": "Permet sensors deCONZ CLIP", "allow_deconz_groups": "Permet grups de llums deCONZ" }, - "description": "Configura la visibilitat dels tipus de dispositius deCONZ" + "description": "Configura la visibilitat dels tipus dels dispositius deCONZ" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Permet sensors deCONZ CLIP", + "allow_deconz_groups": "Permet grups de llums deCONZ" + }, + "description": "Configura la visibilitat dels tipus dels dispositius deCONZ" } } } diff --git a/homeassistant/components/deconz/.translations/da.json b/homeassistant/components/deconz/.translations/da.json index 1b595924106cfd..6b74c09107a098 100644 --- a/homeassistant/components/deconz/.translations/da.json +++ b/homeassistant/components/deconz/.translations/da.json @@ -41,6 +41,24 @@ }, "title": "deCONZ Zigbee gateway" }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Begge knapper", + "button_1": "F\u00f8rste knap", + "button_2": "Anden knap", + "button_3": "Tredje knap", + "button_4": "Fjerde knap", + "close": "Luk", + "dim_down": "D\u00e6mp ned", + "dim_up": "D\u00e6mp op", + "left": "Venstre", + "open": "\u00c5ben", + "right": "H\u00f8jre" + }, + "trigger_type": { + "remote_gyro_activated": "Enhed rystet" + } + }, "options": { "step": { "async_step_deconz_devices": { diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json index 5902d2a3bf3619..97e25e28965c5f 100644 --- a/homeassistant/components/deconz/.translations/de.json +++ b/homeassistant/components/deconz/.translations/de.json @@ -49,6 +49,13 @@ "allow_deconz_groups": "deCONZ-Lichtgruppen zulassen" }, "description": "Konfigurieren der Sichtbarkeit von deCONZ-Ger\u00e4tetypen" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ CLIP-Sensoren zulassen", + "allow_deconz_groups": "deCONZ-Lichtgruppen zulassen" + }, + "description": "Sichtbarkeit der deCONZ-Ger\u00e4tetypen konfigurieren" } } } diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json index 272a6f5d1be2ed..ead71db8c27d89 100644 --- a/homeassistant/components/deconz/.translations/en.json +++ b/homeassistant/components/deconz/.translations/en.json @@ -41,6 +41,35 @@ }, "title": "deCONZ Zigbee gateway" }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Both buttons", + "button_1": "First button", + "button_2": "Second button", + "button_3": "Third button", + "button_4": "Fourth button", + "close": "Close", + "dim_down": "Dim down", + "dim_up": "Dim up", + "left": "Left", + "open": "Open", + "right": "Right", + "turn_off": "Turn off", + "turn_on": "Turn on" + }, + "trigger_type": { + "remote_button_double_press": "\"{subtype}\" button double clicked", + "remote_button_long_press": "\"{subtype}\" button continuously pressed", + "remote_button_long_release": "\"{subtype}\" button released after long press", + "remote_button_quadruple_press": "\"{subtype}\" button quadruple clicked", + "remote_button_quintuple_press": "\"{subtype}\" button quintuple clicked", + "remote_button_rotated": "Button rotated \"{subtype}\"", + "remote_button_short_press": "\"{subtype}\" button pressed", + "remote_button_short_release": "\"{subtype}\" button released", + "remote_button_triple_press": "\"{subtype}\" button triple clicked", + "remote_gyro_activated": "Device shaken" + } + }, "options": { "step": { "async_step_deconz_devices": { diff --git a/homeassistant/components/deconz/.translations/es.json b/homeassistant/components/deconz/.translations/es.json index 8bcf03914cee84..1bc6c8211a268d 100644 --- a/homeassistant/components/deconz/.translations/es.json +++ b/homeassistant/components/deconz/.translations/es.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "El puente ya esta configurado", + "already_in_progress": "El flujo de configuraci\u00f3n para el puente ya est\u00e1 en curso.", "no_bridges": "No se han descubierto puentes deCONZ", + "not_deconz_bridge": "No es un puente deCONZ", "one_instance_only": "El componente solo admite una instancia de deCONZ", "updated_instance": "Instancia deCONZ actualizada con nueva direcci\u00f3n de host" }, @@ -39,12 +41,43 @@ }, "title": "Pasarela Zigbee deCONZ" }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Ambos botones", + "button_1": "Primer bot\u00f3n", + "button_2": "Segundo bot\u00f3n", + "button_3": "Tercer bot\u00f3n", + "button_4": "Cuarto bot\u00f3n", + "close": "Cerrar", + "dim_down": "Bajar la intensidad", + "dim_up": "Subir la intensidad", + "left": "Izquierda", + "open": "Abierto", + "right": "Derecha", + "turn_off": "Apagar", + "turn_on": "Encender" + }, + "trigger_type": { + "remote_button_double_press": "Bot\u00f3n \"{subtype}\" pulsado dos veces consecutivas", + "remote_button_long_press": "bot\u00f3n \"{subtype}\" pulsado continuamente", + "remote_button_long_release": "Bot\u00f3n \"{subtype}\" liberado despu\u00e9s de un rato pulsado", + "remote_button_quadruple_press": "Bot\u00f3n \"{subtype}\" pulsado cuatro veces consecutivas", + "remote_button_quintuple_press": "Bot\u00f3n \"{subtype}\" pulsado cinco veces consecutivas", + "remote_button_rotated": "Bot\u00f3n \"{subtype}\" girado", + "remote_button_short_press": "bot\u00f3n \"{subtype}\" pulsado", + "remote_button_short_release": "bot\u00f3n \"{subtype}\" liberado", + "remote_button_triple_press": "Bot\u00f3n \"{subtype}\" pulsado cuatro veces consecutivas", + "remote_gyro_activated": "Dispositivo sacudido" + } + }, "options": { "step": { "async_step_deconz_devices": { "data": { + "allow_clip_sensor": "Permitir sensores deCONZ CLIP", "allow_deconz_groups": "Permitir grupos de luz deCONZ" - } + }, + "description": "Configurar la visibilidad de los tipos de dispositivos deCONZ" }, "deconz_devices": { "data": { diff --git a/homeassistant/components/deconz/.translations/fr.json b/homeassistant/components/deconz/.translations/fr.json index 9b98914314a749..cc6d22945dcb79 100644 --- a/homeassistant/components/deconz/.translations/fr.json +++ b/homeassistant/components/deconz/.translations/fr.json @@ -40,5 +40,52 @@ } }, "title": "Passerelle deCONZ Zigbee" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Les deux boutons", + "button_1": "Premier bouton", + "button_2": "Deuxi\u00e8me bouton", + "button_3": "Troisi\u00e8me bouton", + "button_4": "Quatri\u00e8me bouton", + "close": "Ferm\u00e9", + "dim_down": "Assombrir", + "dim_up": "\u00c9claircir", + "left": "Gauche", + "open": "Ouvert", + "right": "Droite", + "turn_off": "\u00c9teint", + "turn_on": "Allum\u00e9" + }, + "trigger_type": { + "remote_button_double_press": "Bouton \"{subtype}\" double cliqu\u00e9", + "remote_button_long_press": "Bouton \"{subtype}\" appuy\u00e9 continuellement", + "remote_button_long_release": "Bouton \"{subtype}\" rel\u00e2ch\u00e9 apr\u00e8s appui long", + "remote_button_quadruple_press": "Bouton \"{subtype}\" quadruple cliqu\u00e9", + "remote_button_quintuple_press": "Bouton \"{subtype}\" quintuple cliqu\u00e9", + "remote_button_rotated": "Bouton \"{subtype}\" tourn\u00e9", + "remote_button_short_press": "Bouton \"{subtype}\" appuy\u00e9", + "remote_button_short_release": "Bouton \"{subtype}\" rel\u00e2ch\u00e9", + "remote_button_triple_press": "Bouton \"{subtype}\" triple cliqu\u00e9", + "remote_gyro_activated": "Appareil secou\u00e9" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Autoriser les capteurs deCONZ CLIP", + "allow_deconz_groups": "Autoriser les groupes de lumi\u00e8res deCONZ" + }, + "description": "Configurer la visibilit\u00e9 des appareils de type deCONZ" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Autoriser les capteurs deCONZ CLIP", + "allow_deconz_groups": "Autoriser les groupes de lumi\u00e8res deCONZ" + }, + "description": "Configurer la visibilit\u00e9 des appareils de type deCONZ" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/it.json b/homeassistant/components/deconz/.translations/it.json index f15a2ddf26536a..7a2b8832864e2b 100644 --- a/homeassistant/components/deconz/.translations/it.json +++ b/homeassistant/components/deconz/.translations/it.json @@ -2,7 +2,9 @@ "config": { "abort": { "already_configured": "Il Bridge \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione per bridge \u00e8 gi\u00e0 in corso.", "no_bridges": "Nessun bridge deCONZ rilevato", + "not_deconz_bridge": "Non \u00e8 un bridge deCONZ", "one_instance_only": "Il componente supporto solo un'istanza di deCONZ", "updated_instance": "Istanza deCONZ aggiornata con nuovo indirizzo host" }, @@ -21,12 +23,12 @@ "init": { "data": { "host": "Host", - "port": "Porta (valore di default: '80')" + "port": "Porta" }, "title": "Definisci il gateway deCONZ" }, "link": { - "description": "Sblocca il tuo gateway deCONZ per registrarlo in Home Assistant.\n\n1. Vai nelle impostazioni di sistema di deCONZ\n2. Premi il bottone \"Unlock Gateway\"", + "description": "Sblocca il tuo gateway deCONZ per registrarti con Home Assistant.\n\n1. Vai a Impostazioni deCONZ -> Gateway -> Avanzate\n2. Premere il pulsante \"Autentica app\"", "title": "Collega con deCONZ" }, "options": { @@ -38,5 +40,52 @@ } }, "title": "Gateway Zigbee deCONZ" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Entrambi", + "button_1": "Primo", + "button_2": "Secondo pulsante", + "button_3": "Terzo pulsante", + "button_4": "Quarto pulsante", + "close": "Chiudere", + "dim_down": "Diminuire luminosit\u00e0", + "dim_up": "Aumentare luminosit\u00e0", + "left": "Sinistra", + "open": "Aperto", + "right": "Destra", + "turn_off": "Spegnere", + "turn_on": "Accendere" + }, + "trigger_type": { + "remote_button_double_press": "Pulsante \"{subtype}\" cliccato due volte", + "remote_button_long_press": "Pulsante \"{subtype}\" premuto continuamente", + "remote_button_long_release": "Pulsante \"{subtype}\" rilasciato dopo una lunga pressione", + "remote_button_quadruple_press": "Pulsante \"{subtype}\" cliccato quattro volte", + "remote_button_quintuple_press": "Pulsante \"{subtype}\" cliccato cinque volte", + "remote_button_rotated": "Pulsante ruotato \"{subtype}\"", + "remote_button_short_press": "Pulsante \"{subtype}\" premuto", + "remote_button_short_release": "Pulsante \"{subtype}\" rilasciato", + "remote_button_triple_press": "Pulsante \"{subtype}\" cliccato tre volte", + "remote_gyro_activated": "Dispositivo in vibrazione" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "Consentire sensori CLIP deCONZ", + "allow_deconz_groups": "Consentire gruppi luce deCONZ" + }, + "description": "Configurare la visibilit\u00e0 dei tipi di dispositivi deCONZ" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Consentire sensori CLIP deCONZ", + "allow_deconz_groups": "Consentire gruppi luce deCONZ" + }, + "description": "Configurare la visibilit\u00e0 dei tipi di dispositivi deCONZ" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/ko.json b/homeassistant/components/deconz/.translations/ko.json index 4bf845d50e5fd2..923a2beb2ffba2 100644 --- a/homeassistant/components/deconz/.translations/ko.json +++ b/homeassistant/components/deconz/.translations/ko.json @@ -40,5 +40,52 @@ } }, "title": "deCONZ Zigbee \uac8c\uc774\ud2b8\uc6e8\uc774" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\ub450 \uac1c", + "button_1": "\uccab \ubc88\uc9f8", + "button_2": "\ub450 \ubc88\uc9f8", + "button_3": "\uc138 \ubc88\uc9f8", + "button_4": "\ub124 \ubc88\uc9f8", + "close": "\ub2eb\uae30", + "dim_down": "\uc5b4\ub461\uac8c \ud558\uae30", + "dim_up": "\ubc1d\uac8c \ud558\uae30", + "left": "\uc67c\ucabd", + "open": "\uc5f4\uae30", + "right": "\uc624\ub978\ucabd", + "turn_off": "\ub044\uae30", + "turn_on": "\ucf1c\uae30" + }, + "trigger_type": { + "remote_button_double_press": "\"{subtype}\" \ubc84\ud2bc\uc744 \ub450 \ubc88 \ub204\ub984", + "remote_button_long_press": "\"{subtype}\" \ubc84\ud2bc\uc744 \uacc4\uc18d \ub204\ub984", + "remote_button_long_release": "\"{subtype}\" \ubc84\ud2bc\uc744 \uae38\uac8c \ub20c\ub800\ub2e4\uac00 \ub5cc", + "remote_button_quadruple_press": "\"{subtype}\" \ubc84\ud2bc\uc744 \ub124 \ubc88 \ub204\ub984", + "remote_button_quintuple_press": "\"{subtype}\" \ubc84\ud2bc\uc744 \ub2e4\uc12f \ubc88 \ub204\ub984", + "remote_button_rotated": "\"{subtype}\" \ubc84\ud2bc\uc744 \ud68c\uc804", + "remote_button_short_press": "\"{subtype}\" \ubc84\ud2bc\uc744 \ub204\ub984", + "remote_button_short_release": "\"{subtype}\" \ubc84\ud2bc\uc744 \ub5cc", + "remote_button_triple_press": "\"{subtype}\" \ubc84\ud2bc\uc744 \uc138 \ubc88 \ub204\ub984", + "remote_gyro_activated": "\uae30\uae30 \ud754\ub4e6" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ CLIP \uc13c\uc11c \ud5c8\uc6a9", + "allow_deconz_groups": "deCONZ \ub77c\uc774\ud2b8 \uadf8\ub8f9 \ud5c8\uc6a9" + }, + "description": "deCONZ \uae30\uae30 \uc720\ud615\uc758 \ud45c\uc2dc \uc5ec\ubd80 \uad6c\uc131" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ CLIP \uc13c\uc11c \ud5c8\uc6a9", + "allow_deconz_groups": "deCONZ \ub77c\uc774\ud2b8 \uadf8\ub8f9 \ud5c8\uc6a9" + }, + "description": "deCONZ \uae30\uae30 \uc720\ud615\uc758 \ud45c\uc2dc \uc5ec\ubd80 \uad6c\uc131" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/lb.json b/homeassistant/components/deconz/.translations/lb.json index 60a27304d78437..1a03143f11edfd 100644 --- a/homeassistant/components/deconz/.translations/lb.json +++ b/homeassistant/components/deconz/.translations/lb.json @@ -23,7 +23,7 @@ "init": { "data": { "host": "Host", - "port": "Port (Standard Wert: '80')" + "port": "Port" }, "title": "deCONZ gateway d\u00e9fin\u00e9ieren" }, @@ -40,5 +40,52 @@ } }, "title": "deCONZ Zigbee gateway" + }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "B\u00e9id Kn\u00e4ppchen", + "button_1": "\u00c9ischte Kn\u00e4ppchen", + "button_2": "Zweete Kn\u00e4ppchen", + "button_3": "Dr\u00ebtte Kn\u00e4ppchen", + "button_4": "V\u00e9ierte Kn\u00e4ppchen", + "close": "Zoumaachen", + "dim_down": "Erhellen", + "dim_up": "Verd\u00e4ischteren", + "left": "L\u00e9nks", + "open": "Op", + "right": "Riets", + "turn_off": "Ausschalten", + "turn_on": "Uschalten" + }, + "trigger_type": { + "remote_button_double_press": "\"{subtype}\" Kn\u00e4ppche zwee mol gedr\u00e9ckt", + "remote_button_long_press": "\"{subtype}\" Kn\u00e4ppche permanent gedr\u00e9ckt", + "remote_button_long_release": "\"{subtype}\" Kn\u00e4ppche no laangem unhalen lassgelooss", + "remote_button_quadruple_press": "\"{subtype}\" Kn\u00e4ppche v\u00e9ier mol gedr\u00e9ckt", + "remote_button_quintuple_press": "\"{subtype}\" Kn\u00e4ppche f\u00ebnnef mol gedr\u00e9ckt", + "remote_button_rotated": "Kn\u00e4ppche gedr\u00e9int \"{subtype}\"", + "remote_button_short_press": "\"{subtype}\" Kn\u00e4ppche gedr\u00e9ckt", + "remote_button_short_release": "\"{subtype}\" Kn\u00e4ppche lassgelooss", + "remote_button_triple_press": "\"{subtype}\" Kn\u00e4ppche dr\u00e4imol gedr\u00e9ckt", + "remote_gyro_activated": "Apparat ger\u00ebselt" + } + }, + "options": { + "step": { + "async_step_deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ Clip Sensoren erlaben", + "allow_deconz_groups": "deCONZ Luucht Gruppen erlaben" + }, + "description": "Visibilit\u00e9it vun deCONZ Apparater konfigur\u00e9ieren" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "deCONZ Clip Sensoren erlaben", + "allow_deconz_groups": "deCONZ Luucht Gruppen erlaben" + }, + "description": "Visibilit\u00e9it vun deCONZ Apparater konfigur\u00e9ieren" + } + } } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/nl.json b/homeassistant/components/deconz/.translations/nl.json index 785fba4ffc0e02..116f6254b37213 100644 --- a/homeassistant/components/deconz/.translations/nl.json +++ b/homeassistant/components/deconz/.translations/nl.json @@ -41,6 +41,35 @@ }, "title": "deCONZ Zigbee gateway" }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Beide knoppen", + "button_1": "Eerste knop", + "button_2": "Tweede knop", + "button_3": "Derde knop", + "button_4": "Vierde knop", + "close": "Sluiten", + "dim_down": "Dim omlaag", + "dim_up": "Dim omhoog", + "left": "Links", + "open": "Open", + "right": "Rechts", + "turn_off": "Uitschakelen", + "turn_on": "Inschakelen" + }, + "trigger_type": { + "remote_button_double_press": "\"{subtype}\" knop dubbel geklikt", + "remote_button_long_press": "\" {subtype} \" knop continu ingedrukt", + "remote_button_long_release": "\"{subtype}\" knop losgelaten na lang indrukken van de knop", + "remote_button_quadruple_press": "\" {subtype} \" knop viervoudig aangeklikt", + "remote_button_quintuple_press": "\" {subtype} \" knop vijf keer aangeklikt", + "remote_button_rotated": "Knop gedraaid \" {subtype} \"", + "remote_button_short_press": "\" {subtype} \" knop ingedrukt", + "remote_button_short_release": "\"{subtype}\" knop losgelaten", + "remote_button_triple_press": "\" {subtype} \" knop driemaal geklikt", + "remote_gyro_activated": "Apparaat geschud" + } + }, "options": { "step": { "async_step_deconz_devices": { @@ -49,6 +78,13 @@ "allow_deconz_groups": "DeCONZ-lichtgroepen toestaan" }, "description": "De zichtbaarheid van deCONZ-apparaattypen configureren" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "DeCONZ CLIP sensoren toestaan", + "allow_deconz_groups": "Sta deCONZ-lichtgroepen toe" + }, + "description": "Configureer de zichtbaarheid van deCONZ-apparaattypen" } } } diff --git a/homeassistant/components/deconz/.translations/no.json b/homeassistant/components/deconz/.translations/no.json index 8798248224a582..3968c1f00c58d2 100644 --- a/homeassistant/components/deconz/.translations/no.json +++ b/homeassistant/components/deconz/.translations/no.json @@ -41,6 +41,35 @@ }, "title": "deCONZ Zigbee gateway" }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Begge knappene", + "button_1": "F\u00f8rste knapp", + "button_2": "Andre knapp", + "button_3": "Tredje knapp", + "button_4": "Fjerde knapp", + "close": "Lukk", + "dim_down": "Dimm ned", + "dim_up": "Dimm opp", + "left": "Venstre", + "open": "\u00c5pen", + "right": "H\u00f8yre", + "turn_off": "Skru av", + "turn_on": "Sl\u00e5 p\u00e5" + }, + "trigger_type": { + "remote_button_double_press": "\" {subtype} \"-knappen ble dobbeltklikket", + "remote_button_long_press": "\" {subtype} \" - knappen ble kontinuerlig trykket", + "remote_button_long_release": "\" {subtype} \" -knappen sluppet etter langt trykk", + "remote_button_quadruple_press": "\" {subtype} \" -knappen ble firedoblet klikket", + "remote_button_quintuple_press": "\" {subtype} \" - knappen femdobbelt klikket", + "remote_button_rotated": "Knappen roterte \" {subtype} \"", + "remote_button_short_press": "\" {subtype} \" -knappen ble trykket", + "remote_button_short_release": "\" {subtype} \" -knappen ble utgitt", + "remote_button_triple_press": "\" {subtype} \"-knappen trippel klikket", + "remote_gyro_activated": "Enhet er ristet" + } + }, "options": { "step": { "async_step_deconz_devices": { @@ -49,6 +78,13 @@ "allow_deconz_groups": "Tillat deCONZ lys grupper" }, "description": "Konfigurere synlighet av deCONZ enhetstyper" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Tillat deCONZ CLIP-sensorer", + "allow_deconz_groups": "Tillat deCONZ lys grupper" + }, + "description": "Konfigurere synlighet av deCONZ enhetstyper" } } } diff --git a/homeassistant/components/deconz/.translations/pl.json b/homeassistant/components/deconz/.translations/pl.json index 0f2009a46b687b..70c33cf3c02f4f 100644 --- a/homeassistant/components/deconz/.translations/pl.json +++ b/homeassistant/components/deconz/.translations/pl.json @@ -28,7 +28,7 @@ "title": "Zdefiniuj bramk\u0119 deCONZ" }, "link": { - "description": "Odblokuj bramk\u0119 deCONZ, aby zarejestrowa\u0107 j\u0105 w Home Assistant. \n\n 1. Przejd\u017a do ustawie\u0144 systemu deCONZ \n 2. Naci\u015bnij przycisk \"Odblokuj bramk\u0119\"", + "description": "Odblokuj bramk\u0119 deCONZ, aby zarejestrowa\u0107 j\u0105 w Home Assistant. \n\n 1. Przejd\u017a do ustawienia deCONZ > bramka > Zaawansowane\n 2. Naci\u015bnij przycisk \"Uwierzytelnij aplikacj\u0119\"", "title": "Po\u0142\u0105cz z deCONZ" }, "options": { @@ -41,6 +41,35 @@ }, "title": "Brama deCONZ Zigbee" }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Oba przyciski", + "button_1": "Pierwszy przycisk", + "button_2": "Drugi przycisk", + "button_3": "Trzeci przycisk", + "button_4": "Czwarty przycisk", + "close": "Zamknij", + "dim_down": "Przyciemnienie", + "dim_up": "Przyciemnienie", + "left": "Lewo", + "open": "Otw\u00f3rz", + "right": "Prawo", + "turn_off": "Wy\u0142\u0105cz", + "turn_on": "W\u0142\u0105cz" + }, + "trigger_type": { + "remote_button_double_press": "Przycisk \"{subtype}\" podw\u00f3jnie naci\u015bni\u0119ty", + "remote_button_long_press": "Przycisk \"{subtype}\" naci\u015bni\u0119ty w spos\u00f3b ci\u0105g\u0142y", + "remote_button_long_release": "Przycisk \"{subtype}\" zwolniony po d\u0142ugim naci\u015bni\u0119ciu", + "remote_button_quadruple_press": "Przycisk \"{subtype}\" czterokrotnie naci\u015bni\u0119ty", + "remote_button_quintuple_press": "Przycisk \"{subtype}\" pi\u0119ciokrotnie naci\u015bni\u0119ty", + "remote_button_rotated": "Przycisk obr\u00f3cony \"{subtype}\"", + "remote_button_short_press": "Przycisk \"{subtype}\" naci\u015bni\u0119ty", + "remote_button_short_release": "Przycisk \"{subtype}\" zwolniony", + "remote_button_triple_press": "Przycisk \"{subtype}\" trzykrotnie naci\u015bni\u0119ty", + "remote_gyro_activated": "Urz\u0105dzenie potrz\u0105\u015bni\u0119te" + } + }, "options": { "step": { "async_step_deconz_devices": { @@ -49,6 +78,13 @@ "allow_deconz_groups": "Zezwalaj na grupy \u015bwiate\u0142 deCONZ" }, "description": "Skonfiguruj widoczno\u015b\u0107 urz\u0105dze\u0144 deCONZ" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Zezwalaj na czujniki deCONZ CLIP", + "allow_deconz_groups": "Zezwalaj na grupy \u015bwiate\u0142 deCONZ" + }, + "description": "Skonfiguruj widoczno\u015b\u0107 typ\u00f3w urz\u0105dze\u0144 deCONZ" } } } diff --git a/homeassistant/components/deconz/.translations/ru.json b/homeassistant/components/deconz/.translations/ru.json index ee7208cdf1731a..558fd9e5897e7f 100644 --- a/homeassistant/components/deconz/.translations/ru.json +++ b/homeassistant/components/deconz/.translations/ru.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430", + "already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", "already_in_progress": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0448\u043b\u044e\u0437\u0430 \u0443\u0436\u0435 \u043d\u0430\u0447\u0430\u0442\u0430.", "no_bridges": "\u0428\u043b\u044e\u0437\u044b deCONZ \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b", "not_deconz_bridge": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0448\u043b\u044e\u0437\u043e\u043c deCONZ", @@ -25,7 +25,7 @@ "host": "\u0425\u043e\u0441\u0442", "port": "\u041f\u043e\u0440\u0442" }, - "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0438\u0442\u044c \u0448\u043b\u044e\u0437 deCONZ" + "title": "deCONZ" }, "link": { "description": "\u0420\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0439\u0442\u0435 \u0448\u043b\u044e\u0437 deCONZ \u0434\u043b\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0432 Home Assistant:\n\n1. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043a \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u043c \u0441\u0438\u0441\u0442\u0435\u043c\u044b deCONZ -> Gateway -> Advanced\n2. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u00abAuthenticate app\u00bb", @@ -41,6 +41,35 @@ }, "title": "deCONZ" }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\u041e\u0431\u0435 \u043a\u043d\u043e\u043f\u043a\u0438", + "button_1": "\u041f\u0435\u0440\u0432\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_2": "\u0412\u0442\u043e\u0440\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_3": "\u0422\u0440\u0435\u0442\u044c\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "button_4": "\u0427\u0435\u0442\u0432\u0435\u0440\u0442\u0430\u044f \u043a\u043d\u043e\u043f\u043a\u0430", + "close": "\u0417\u0430\u043a\u0440\u044b\u0442\u043e", + "dim_down": "\u0423\u0431\u0430\u0432\u0438\u0442\u044c \u044f\u0440\u043a\u043e\u0441\u0442\u044c", + "dim_up": "\u0414\u043e\u0431\u0430\u0432\u0438\u0442\u044c \u044f\u0440\u043a\u043e\u0441\u0442\u044c", + "left": "\u041d\u0430\u043b\u0435\u0432\u043e", + "open": "\u041e\u0442\u043a\u0440\u044b\u0442\u043e", + "right": "\u041d\u0430\u043f\u0440\u0430\u0432\u043e", + "turn_off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + }, + "trigger_type": { + "remote_button_double_press": "\u041a\u043d\u043e\u043f\u043a\u0430 \"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0434\u0432\u0430\u0436\u0434\u044b", + "remote_button_long_press": "\u041a\u043d\u043e\u043f\u043a\u0430 \"{subtype}\" \u043d\u0435\u043f\u0440\u0435\u0440\u044b\u0432\u043d\u043e \u043d\u0430\u0436\u0430\u0442\u0430", + "remote_button_long_release": "\u041a\u043d\u043e\u043f\u043a\u0430 \"{subtype}\" \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430 \u043f\u043e\u0441\u043b\u0435 \u0434\u043b\u0438\u0442\u0435\u043b\u044c\u043d\u043e\u0433\u043e \u043d\u0430\u0436\u0430\u0442\u0438\u044f", + "remote_button_quadruple_press": "\u041a\u043d\u043e\u043f\u043a\u0430 \"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0447\u0435\u0442\u044b\u0440\u0435 \u0440\u0430\u0437\u0430", + "remote_button_quintuple_press": "\u041a\u043d\u043e\u043f\u043a\u0430 \"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u043f\u044f\u0442\u044c \u0440\u0430\u0437", + "remote_button_rotated": "\u041a\u043d\u043e\u043f\u043a\u0430 \"{subtype}\" \u043f\u043e\u0432\u0451\u0440\u043d\u0443\u0442\u0430", + "remote_button_short_press": "\u041a\u043d\u043e\u043f\u043a\u0430 \"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430", + "remote_button_short_release": "\u041a\u043d\u043e\u043f\u043a\u0430 \"{subtype}\" \u043e\u0442\u043f\u0443\u0449\u0435\u043d\u0430", + "remote_button_triple_press": "\u041a\u043d\u043e\u043f\u043a\u0430 \"{subtype}\" \u043d\u0430\u0436\u0430\u0442\u0430 \u0442\u0440\u0438\u0436\u0434\u044b", + "remote_gyro_activated": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043f\u043e\u0442\u0440\u044f\u0441\u043b\u0438" + } + }, "options": { "step": { "async_step_deconz_devices": { @@ -49,6 +78,13 @@ "allow_deconz_groups": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0441\u0432\u0435\u0449\u0435\u043d\u0438\u044f deCONZ" }, "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0442\u0438\u043f\u043e\u0432 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 deCONZ" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0434\u0430\u0442\u0447\u0438\u043a\u0438 deCONZ CLIP", + "allow_deconz_groups": "\u041e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0442\u044c \u0433\u0440\u0443\u043f\u043f\u044b \u043e\u0441\u0432\u0435\u0449\u0435\u043d\u0438\u044f deCONZ" + }, + "description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 \u0432\u0438\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0442\u0438\u043f\u043e\u0432 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432 deCONZ" } } } diff --git a/homeassistant/components/deconz/.translations/sl.json b/homeassistant/components/deconz/.translations/sl.json index 4a30e9d34d113a..9aebb2a556f6cd 100644 --- a/homeassistant/components/deconz/.translations/sl.json +++ b/homeassistant/components/deconz/.translations/sl.json @@ -41,6 +41,35 @@ }, "title": "deCONZ Zigbee prehod" }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "Oba gumba", + "button_1": "Prvi gumb", + "button_2": "Drugi gumb", + "button_3": "Tretji gumb", + "button_4": "\u010cetrti gumb", + "close": "Zapri", + "dim_down": "Zatemnite", + "dim_up": "pove\u010dajte mo\u010d", + "left": "Levo", + "open": "Odprto", + "right": "Desno", + "turn_off": "Ugasni", + "turn_on": "Pri\u017egi" + }, + "trigger_type": { + "remote_button_double_press": "Dvakrat kliknete gumb \"{subtype}\"", + "remote_button_long_press": "\"{subtype}\" gumb neprekinjeno pritisnjen", + "remote_button_long_release": "\"{subtype}\" gumb spro\u0161\u010den po dolgem pritisku", + "remote_button_quadruple_press": "\"{subtype}\" gumb \u0161tirikrat kliknjen", + "remote_button_quintuple_press": "\"{subtype}\" gumb petkrat kliknjen", + "remote_button_rotated": "Gumb \"{subtype}\" zasukan", + "remote_button_short_press": "Pritisnjen \"{subtype}\" gumb", + "remote_button_short_release": "Gumb \"{subtype}\" spro\u0161\u010den", + "remote_button_triple_press": "Gumb \"{subtype}\" trikrat kliknjen", + "remote_gyro_activated": "Naprava se je pretresla" + } + }, "options": { "step": { "async_step_deconz_devices": { @@ -49,6 +78,13 @@ "allow_deconz_groups": "Dovolite deCONZ skupine lu\u010di" }, "description": "Konfiguracija vidnosti tipov naprav deCONZ" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "Dovoli deCONZ CLIP senzorje", + "allow_deconz_groups": "Dovolite deCONZ skupine lu\u010di" + }, + "description": "Konfiguracija vidnosti tipov naprav deCONZ" } } } diff --git a/homeassistant/components/deconz/.translations/zh-Hant.json b/homeassistant/components/deconz/.translations/zh-Hant.json index 53d6f76a60161a..f024386aa0f873 100644 --- a/homeassistant/components/deconz/.translations/zh-Hant.json +++ b/homeassistant/components/deconz/.translations/zh-Hant.json @@ -41,6 +41,35 @@ }, "title": "deCONZ Zigbee \u9598\u9053\u5668" }, + "device_automation": { + "trigger_subtype": { + "both_buttons": "\u5169\u500b\u6309\u9215", + "button_1": "\u7b2c\u4e00\u500b\u6309\u9215", + "button_2": "\u7b2c\u4e8c\u500b\u6309\u9215", + "button_3": "\u7b2c\u4e09\u500b\u6309\u9215", + "button_4": "\u7b2c\u56db\u500b\u6309\u9215", + "close": "\u95dc\u9589", + "dim_down": "\u8abf\u6697", + "dim_up": "\u8abf\u4eae", + "left": "\u5de6", + "open": "\u958b\u555f", + "right": "\u53f3", + "turn_off": "\u95dc\u9589", + "turn_on": "\u958b\u555f" + }, + "trigger_type": { + "remote_button_double_press": "\"{subtype}\" \u6309\u9215\u96d9\u64ca", + "remote_button_long_press": "\"{subtype}\" \u6309\u9215\u6301\u7e8c\u6309\u4e0b", + "remote_button_long_release": "\u9577\u6309\u5f8c\u91cb\u653e \"{subtype}\" \u6309\u9215", + "remote_button_quadruple_press": "\"{subtype}\" \u6309\u9215\u56db\u9023\u9ede\u64ca", + "remote_button_quintuple_press": "\"{subtype}\" \u6309\u9215\u4e94\u9023\u9ede\u64ca", + "remote_button_rotated": "\u65cb\u8f49 \"{subtype}\" \u6309\u9215", + "remote_button_short_press": "\"{subtype}\" \u6309\u9215\u5df2\u6309\u4e0b", + "remote_button_short_release": "\"{subtype}\" \u6309\u9215\u5df2\u91cb\u653e", + "remote_button_triple_press": "\"{subtype}\" \u6309\u9215\u4e09\u9023\u9ede\u64ca", + "remote_gyro_activated": "\u8a2d\u5099\u6416\u6643" + } + }, "options": { "step": { "async_step_deconz_devices": { @@ -49,6 +78,13 @@ "allow_deconz_groups": "\u5141\u8a31 deCONZ \u71c8\u5149\u7fa4\u7d44" }, "description": "\u8a2d\u5b9a deCONZ \u53ef\u8996\u88dd\u7f6e\u985e\u578b" + }, + "deconz_devices": { + "data": { + "allow_clip_sensor": "\u5141\u8a31 deCONZ CLIP \u611f\u61c9\u5668", + "allow_deconz_groups": "\u5141\u8a31 deCONZ \u71c8\u5149\u7fa4\u7d44" + }, + "description": "\u8a2d\u5b9a deCONZ \u53ef\u8996\u88dd\u7f6e\u985e\u578b" } } } diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 68974d12253f6e..558b0fe4205147 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -1,78 +1,20 @@ """Support for deCONZ devices.""" import voluptuous as vol -from homeassistant import config_entries -from homeassistant.const import ( - CONF_API_KEY, - CONF_HOST, - CONF_PORT, - EVENT_HOMEASSISTANT_STOP, -) -from homeassistant.helpers import config_validation as cv +from homeassistant.const import EVENT_HOMEASSISTANT_STOP -# Loading the config flow file will register the flow from .config_flow import get_master_gateway -from .const import ( - CONF_ALLOW_CLIP_SENSOR, - CONF_ALLOW_DECONZ_GROUPS, - CONF_BRIDGEID, - CONF_MASTER_GATEWAY, - DEFAULT_PORT, - DOMAIN, - _LOGGER, -) +from .const import CONF_BRIDGEID, CONF_MASTER_GATEWAY, DOMAIN from .gateway import DeconzGateway +from .services import async_setup_services, async_unload_services CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Optional(CONF_API_KEY): cv.string, - vol.Optional(CONF_HOST): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - -SERVICE_DECONZ = "configure" - -SERVICE_FIELD = "field" -SERVICE_ENTITY = "entity" -SERVICE_DATA = "data" - -SERVICE_SCHEMA = vol.All( - vol.Schema( - { - vol.Optional(SERVICE_ENTITY): cv.entity_id, - vol.Optional(SERVICE_FIELD): cv.matches_regex("/.*"), - vol.Required(SERVICE_DATA): dict, - vol.Optional(CONF_BRIDGEID): str, - } - ), - cv.has_at_least_one_key(SERVICE_ENTITY, SERVICE_FIELD), + {DOMAIN: vol.Schema({}, extra=vol.ALLOW_EXTRA)}, extra=vol.ALLOW_EXTRA ) -SERVICE_DEVICE_REFRESH = "device_refresh" - -SERVICE_DEVICE_REFRESCH_SCHEMA = vol.All(vol.Schema({vol.Optional(CONF_BRIDGEID): str})) - async def async_setup(hass, config): - """Load configuration for deCONZ component. - - Discovery has loaded the component if DOMAIN is not present in config. - """ - if not hass.config_entries.async_entries(DOMAIN) and DOMAIN in config: - deconz_config = config[DOMAIN] - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=deconz_config, - ) - ) + """Old way of setting up deCONZ integrations.""" return True @@ -86,7 +28,7 @@ async def async_setup_entry(hass, config_entry): hass.data[DOMAIN] = {} if not config_entry.options: - await async_populate_options(hass, config_entry) + await async_update_master_gateway(hass, config_entry) gateway = DeconzGateway(hass, config_entry) @@ -97,100 +39,10 @@ async def async_setup_entry(hass, config_entry): await gateway.async_update_device_registry() - async def async_configure(call): - """Set attribute of device in deCONZ. - - Entity is used to resolve to a device path (e.g. '/lights/1'). - Field is a string representing either a full path - (e.g. '/lights/1/state') when entity is not specified, or a - subpath (e.g. '/state') when used together with entity. - Data is a json object with what data you want to alter - e.g. data={'on': true}. - { - "field": "/lights/1/state", - "data": {"on": true} - } - See Dresden Elektroniks REST API documentation for details: - http://dresden-elektronik.github.io/deconz-rest-doc/rest/ - """ - field = call.data.get(SERVICE_FIELD, "") - entity_id = call.data.get(SERVICE_ENTITY) - data = call.data[SERVICE_DATA] - - gateway = get_master_gateway(hass) - if CONF_BRIDGEID in call.data: - gateway = hass.data[DOMAIN][call.data[CONF_BRIDGEID]] - - if entity_id: - try: - field = gateway.deconz_ids[entity_id] + field - except KeyError: - _LOGGER.error("Could not find the entity %s", entity_id) - return - - await gateway.api.async_put_state(field, data) - - hass.services.async_register( - DOMAIN, SERVICE_DECONZ, async_configure, schema=SERVICE_SCHEMA - ) - - async def async_refresh_devices(call): - """Refresh available devices from deCONZ.""" - gateway = get_master_gateway(hass) - if CONF_BRIDGEID in call.data: - gateway = hass.data[DOMAIN][call.data[CONF_BRIDGEID]] - - groups = set(gateway.api.groups.keys()) - lights = set(gateway.api.lights.keys()) - scenes = set(gateway.api.scenes.keys()) - sensors = set(gateway.api.sensors.keys()) - - await gateway.api.async_load_parameters() - - gateway.async_add_device_callback( - "group", - [ - group - for group_id, group in gateway.api.groups.items() - if group_id not in groups - ], - ) - - gateway.async_add_device_callback( - "light", - [ - light - for light_id, light in gateway.api.lights.items() - if light_id not in lights - ], - ) - - gateway.async_add_device_callback( - "scene", - [ - scene - for scene_id, scene in gateway.api.scenes.items() - if scene_id not in scenes - ], - ) - - gateway.async_add_device_callback( - "sensor", - [ - sensor - for sensor_id, sensor in gateway.api.sensors.items() - if sensor_id not in sensors - ], - ) - - hass.services.async_register( - DOMAIN, - SERVICE_DEVICE_REFRESH, - async_refresh_devices, - schema=SERVICE_DEVICE_REFRESCH_SCHEMA, - ) + await async_setup_services(hass) hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gateway.shutdown) + return True @@ -199,29 +51,28 @@ async def async_unload_entry(hass, config_entry): gateway = hass.data[DOMAIN].pop(config_entry.data[CONF_BRIDGEID]) if not hass.data[DOMAIN]: - hass.services.async_remove(DOMAIN, SERVICE_DECONZ) - hass.services.async_remove(DOMAIN, SERVICE_DEVICE_REFRESH) + await async_unload_services(hass) elif gateway.master: - await async_populate_options(hass, config_entry) + await async_update_master_gateway(hass, config_entry) new_master_gateway = next(iter(hass.data[DOMAIN].values())) - await async_populate_options(hass, new_master_gateway.config_entry) + await async_update_master_gateway(hass, new_master_gateway.config_entry) return await gateway.async_reset() -async def async_populate_options(hass, config_entry): - """Populate default options for gateway. +async def async_update_master_gateway(hass, config_entry): + """Update master gateway boolean. Called by setup_entry and unload_entry. Makes sure there is always one master available. """ master = not get_master_gateway(hass) - options = { - CONF_MASTER_GATEWAY: master, - CONF_ALLOW_CLIP_SENSOR: config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, False), - CONF_ALLOW_DECONZ_GROUPS: config_entry.data.get(CONF_ALLOW_DECONZ_GROUPS, True), - } + old_options = dict(config_entry.options) + + new_options = {CONF_MASTER_GATEWAY: master} + + options = {**old_options, **new_options} hass.config_entries.async_update_entry(config_entry, options=options) diff --git a/homeassistant/components/deconz/binary_sensor.py b/homeassistant/components/deconz/binary_sensor.py index 0b5d3173812ba7..b81ecdc5164a75 100644 --- a/homeassistant/components/deconz/binary_sensor.py +++ b/homeassistant/components/deconz/binary_sensor.py @@ -2,13 +2,13 @@ from pydeconz.sensor import Presence, Vibration from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE +from homeassistant.const import ATTR_TEMPERATURE from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import get_gateway_from_config_entry, DeconzEntityHandler ATTR_ORIENTATION = "orientation" ATTR_TILTANGLE = "tiltangle" @@ -17,13 +17,14 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Old way of setting up deCONZ platforms.""" - pass async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ binary sensor.""" gateway = get_gateway_from_config_entry(hass, config_entry) + entity_handler = DeconzEntityHandler(gateway) + @callback def async_add_sensor(sensors): """Add binary sensor from deCONZ.""" @@ -31,17 +32,16 @@ def async_add_sensor(sensors): for sensor in sensors: - if sensor.BINARY and not ( - not gateway.allow_clip_sensor and sensor.type.startswith("CLIP") - ): - - entities.append(DeconzBinarySensor(sensor, gateway)) + if sensor.BINARY: + new_sensor = DeconzBinarySensor(sensor, gateway) + entity_handler.add_entity(new_sensor) + entities.append(new_sensor) async_add_entities(entities, True) gateway.listeners.append( async_dispatcher_connect( - hass, gateway.async_event_new_device(NEW_SENSOR), async_add_sensor + hass, gateway.async_signal_new_device(NEW_SENSOR), async_add_sensor ) ) @@ -55,7 +55,7 @@ class DeconzBinarySensor(DeconzDevice, BinarySensorDevice): def async_update_callback(self, force_update=False): """Update the sensor's state.""" changed = set(self._device.changed_keys) - keys = {"battery", "on", "reachable", "state"} + keys = {"on", "reachable", "state"} if force_update or any(key in changed for key in keys): self.async_schedule_update_ha_state() @@ -78,8 +78,6 @@ def icon(self): def device_state_attributes(self): """Return the state attributes of the sensor.""" attr = {} - if self._device.battery: - attr[ATTR_BATTERY_LEVEL] = self._device.battery if self._device.on is not None: attr[ATTR_ON] = self._device.on diff --git a/homeassistant/components/deconz/climate.py b/homeassistant/components/deconz/climate.py index a72c29019598e7..b7a1ebce22ad48 100644 --- a/homeassistant/components/deconz/climate.py +++ b/homeassistant/components/deconz/climate.py @@ -8,7 +8,7 @@ HVAC_MODE_OFF, SUPPORT_TARGET_TEMPERATURE, ) -from homeassistant.const import ATTR_BATTERY_LEVEL, ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -21,7 +21,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Old way of setting up deCONZ platforms.""" - pass async def async_setup_entry(hass, config_entry, async_add_entities): @@ -38,17 +37,14 @@ def async_add_climate(sensors): for sensor in sensors: - if sensor.type in Thermostat.ZHATYPE and not ( - not gateway.allow_clip_sensor and sensor.type.startswith("CLIP") - ): - + if sensor.type in Thermostat.ZHATYPE: entities.append(DeconzThermostat(sensor, gateway)) async_add_entities(entities, True) gateway.listeners.append( async_dispatcher_connect( - hass, gateway.async_event_new_device(NEW_SENSOR), async_add_climate + hass, gateway.async_signal_new_device(NEW_SENSOR), async_add_climate ) ) @@ -123,9 +119,6 @@ def device_state_attributes(self): """Return the state attributes of the thermostat.""" attr = {} - if self._device.battery: - attr[ATTR_BATTERY_LEVEL] = self._device.battery - if self._device.offset: attr[ATTR_OFFSET] = self._device.offset diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 60d47a0a4e22b6..c63b1721393222 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -1,6 +1,5 @@ """Config flow to configure deCONZ component.""" import asyncio -from copy import copy import async_timeout import voluptuous as vol @@ -17,6 +16,8 @@ CONF_ALLOW_CLIP_SENSOR, CONF_ALLOW_DECONZ_GROUPS, CONF_BRIDGEID, + DEFAULT_ALLOW_CLIP_SENSOR, + DEFAULT_ALLOW_DECONZ_GROUPS, DEFAULT_PORT, DOMAIN, ) @@ -190,31 +191,12 @@ async def async_step_ssdp(self, discovery_info): # pylint: disable=unsupported-assignment-operation self.context[CONF_BRIDGEID] = bridgeid - deconz_config = { + self.deconz_config = { CONF_HOST: discovery_info[CONF_HOST], CONF_PORT: discovery_info[CONF_PORT], } - return await self.async_step_import(deconz_config) - - async def async_step_import(self, import_config): - """Import a deCONZ bridge as a config entry. - - This flow is triggered by `async_setup` for configured bridges. - This flow is also triggered by `async_step_discovery`. - - This will execute for any bridge that does not have a - config entry yet (based on host). - - If an API key is provided, we will create an entry. - Otherwise we will delegate to `link` step which - will ask user to link the bridge. - """ - self.deconz_config = import_config - if CONF_API_KEY not in import_config: - return await self.async_step_link() - - return await self._create_entry() + return await self.async_step_link() async def async_step_hassio(self, user_input=None): """Prepare configuration for a Hass.io deCONZ bridge. @@ -256,7 +238,7 @@ class DeconzOptionsFlowHandler(config_entries.OptionsFlow): def __init__(self, config_entry): """Initialize deCONZ options flow.""" self.config_entry = config_entry - self.options = copy(config_entry.options) + self.options = dict(config_entry.options) async def async_step_init(self, user_input=None): """Manage the deCONZ options.""" @@ -277,11 +259,15 @@ async def async_step_deconz_devices(self, user_input=None): { vol.Optional( CONF_ALLOW_CLIP_SENSOR, - default=self.config_entry.options[CONF_ALLOW_CLIP_SENSOR], + default=self.config_entry.options.get( + CONF_ALLOW_CLIP_SENSOR, DEFAULT_ALLOW_CLIP_SENSOR + ), ): bool, vol.Optional( CONF_ALLOW_DECONZ_GROUPS, - default=self.config_entry.options[CONF_ALLOW_DECONZ_GROUPS], + default=self.config_entry.options.get( + CONF_ALLOW_DECONZ_GROUPS, DEFAULT_ALLOW_DECONZ_GROUPS + ), ): bool, } ), diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index ef152aa2b708f0..62879a82724b03 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -7,7 +7,7 @@ DEFAULT_PORT = 80 DEFAULT_ALLOW_CLIP_SENSOR = False -DEFAULT_ALLOW_DECONZ_GROUPS = False +DEFAULT_ALLOW_DECONZ_GROUPS = True CONF_ALLOW_CLIP_SENSOR = "allow_clip_sensor" CONF_ALLOW_DECONZ_GROUPS = "allow_deconz_groups" diff --git a/homeassistant/components/deconz/cover.py b/homeassistant/components/deconz/cover.py index be4088a5c86592..bcd408c25a7591 100644 --- a/homeassistant/components/deconz/cover.py +++ b/homeassistant/components/deconz/cover.py @@ -17,7 +17,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Old way of setting up deCONZ platforms.""" - pass async def async_setup_entry(hass, config_entry, async_add_entities): @@ -40,7 +39,7 @@ def async_add_cover(lights): gateway.listeners.append( async_dispatcher_connect( - hass, gateway.async_event_new_device(NEW_LIGHT), async_add_cover + hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_cover ) ) diff --git a/homeassistant/components/deconz/deconz_device.py b/homeassistant/components/deconz/deconz_device.py index 389ed11e437126..68daee6cf260ca 100644 --- a/homeassistant/components/deconz/deconz_device.py +++ b/homeassistant/components/deconz/deconz_device.py @@ -7,69 +7,105 @@ from .const import DOMAIN as DECONZ_DOMAIN -class DeconzDevice(Entity): - """Representation of a deCONZ device.""" +class DeconzBase: + """Common base for deconz entities and events.""" def __init__(self, device, gateway): """Set up device and add update callback to get data from websocket.""" self._device = device self.gateway = gateway + self.listeners = [] + + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return self._device.uniqueid + + @property + def serial(self): + """Return a serial number for this device.""" + if self._device.uniqueid is None or self._device.uniqueid.count(":") != 7: + return None + + return self._device.uniqueid.split("-", 1)[0] + + @property + def device_info(self): + """Return a device description for device registry.""" + if self.serial is None: + return None + + bridgeid = self.gateway.api.config.bridgeid + + return { + "connections": {(CONNECTION_ZIGBEE, self.serial)}, + "identifiers": {(DECONZ_DOMAIN, self.serial)}, + "manufacturer": self._device.manufacturer, + "model": self._device.modelid, + "name": self._device.name, + "sw_version": self._device.swversion, + "via_device": (DECONZ_DOMAIN, bridgeid), + } + + +class DeconzDevice(DeconzBase, Entity): + """Representation of a deCONZ device.""" + + def __init__(self, device, gateway): + """Set up device and add update callback to get data from websocket.""" + super().__init__(device, gateway) + self.unsub_dispatcher = None + @property + def entity_registry_enabled_default(self): + """Return if the entity should be enabled when first added to the entity registry.""" + if not self.gateway.option_allow_clip_sensor and self._device.type.startswith( + "CLIP" + ): + return False + + if ( + not self.gateway.option_allow_deconz_groups + and self._device.type == "LightGroup" + ): + return False + + return True + async def async_added_to_hass(self): """Subscribe to device events.""" self._device.register_async_callback(self.async_update_callback) self.gateway.deconz_ids[self.entity_id] = self._device.deconz_id - self.unsub_dispatcher = async_dispatcher_connect( - self.hass, self.gateway.event_reachable, self.async_update_callback + self.listeners.append( + async_dispatcher_connect( + self.hass, self.gateway.signal_reachable, self.async_update_callback + ) ) async def async_will_remove_from_hass(self) -> None: """Disconnect device object when removed.""" self._device.remove_callback(self.async_update_callback) del self.gateway.deconz_ids[self.entity_id] - self.unsub_dispatcher() + for unsub_dispatcher in self.listeners: + unsub_dispatcher() @callback def async_update_callback(self, force_update=False): """Update the device's state.""" self.async_schedule_update_ha_state() - @property - def name(self): - """Return the name of the device.""" - return self._device.name - - @property - def unique_id(self): - """Return a unique identifier for this device.""" - return self._device.uniqueid - @property def available(self): """Return True if device is available.""" return self.gateway.available and self._device.reachable + @property + def name(self): + """Return the name of the device.""" + return self._device.name + @property def should_poll(self): """No polling needed.""" return False - - @property - def device_info(self): - """Return a device description for device registry.""" - if self._device.uniqueid is None or self._device.uniqueid.count(":") != 7: - return None - - serial = self._device.uniqueid.split("-", 1)[0] - bridgeid = self.gateway.api.config.bridgeid - - return { - "connections": {(CONNECTION_ZIGBEE, serial)}, - "identifiers": {(DECONZ_DOMAIN, serial)}, - "manufacturer": self._device.manufacturer, - "model": self._device.modelid, - "name": self._device.name, - "sw_version": self._device.swversion, - "via_device": (DECONZ_DOMAIN, bridgeid), - } diff --git a/homeassistant/components/deconz/deconz_event.py b/homeassistant/components/deconz/deconz_event.py new file mode 100644 index 00000000000000..31588db1f23833 --- /dev/null +++ b/homeassistant/components/deconz/deconz_event.py @@ -0,0 +1,61 @@ +"""Representation of a deCONZ remote.""" +from homeassistant.const import CONF_EVENT, CONF_ID +from homeassistant.core import callback +from homeassistant.util import slugify + +from .const import _LOGGER +from .deconz_device import DeconzBase + +CONF_DECONZ_EVENT = "deconz_event" +CONF_UNIQUE_ID = "unique_id" + + +class DeconzEvent(DeconzBase): + """When you want signals instead of entities. + + Stateless sensors such as remotes are expected to generate an event + instead of a sensor entity in hass. + """ + + def __init__(self, device, gateway): + """Register callback that will be used for signals.""" + super().__init__(device, gateway) + + self._device.register_async_callback(self.async_update_callback) + + self.device_id = None + self.event_id = slugify(self._device.name) + _LOGGER.debug("deCONZ event created: %s", self.event_id) + + @property + def device(self): + """Return Event device.""" + return self._device + + @callback + def async_will_remove_from_hass(self) -> None: + """Disconnect event object when removed.""" + self._device.remove_callback(self.async_update_callback) + self._device = None + + @callback + def async_update_callback(self, force_update=False): + """Fire the event if reason is that state is updated.""" + if "state" in self._device.changed_keys: + data = { + CONF_ID: self.event_id, + CONF_UNIQUE_ID: self.serial, + CONF_EVENT: self._device.state, + } + self.gateway.hass.bus.async_fire(CONF_DECONZ_EVENT, data) + + async def async_update_device_registry(self): + """Update device registry.""" + device_registry = ( + await self.gateway.hass.helpers.device_registry.async_get_registry() + ) + + entry = device_registry.async_get_or_create( + config_entry_id=self.gateway.config_entry.entry_id, **self.device_info + ) + self.device_id = entry.id diff --git a/homeassistant/components/deconz/device_automation.py b/homeassistant/components/deconz/device_automation.py new file mode 100644 index 00000000000000..28f36b8f431ea8 --- /dev/null +++ b/homeassistant/components/deconz/device_automation.py @@ -0,0 +1,254 @@ +"""Provides device automations for deconz events.""" +import voluptuous as vol + +import homeassistant.components.automation.event as event + +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) +from homeassistant.const import ( + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_EVENT, + CONF_PLATFORM, + CONF_TYPE, +) + +from . import DOMAIN +from .config_flow import configured_gateways +from .deconz_event import CONF_DECONZ_EVENT, CONF_UNIQUE_ID +from .gateway import get_gateway_from_config_entry + +CONF_SUBTYPE = "subtype" + +CONF_SHORT_PRESS = "remote_button_short_press" +CONF_SHORT_RELEASE = "remote_button_short_release" +CONF_LONG_PRESS = "remote_button_long_press" +CONF_LONG_RELEASE = "remote_button_long_release" +CONF_DOUBLE_PRESS = "remote_button_double_press" +CONF_TRIPLE_PRESS = "remote_button_triple_press" +CONF_QUADRUPLE_PRESS = "remote_button_quadruple_press" +CONF_QUINTUPLE_PRESS = "remote_button_quintuple_press" +CONF_ROTATED = "remote_button_rotated" +CONF_SHAKE = "remote_gyro_activated" + +CONF_TURN_ON = "turn_on" +CONF_TURN_OFF = "turn_off" +CONF_DIM_UP = "dim_up" +CONF_DIM_DOWN = "dim_down" +CONF_LEFT = "left" +CONF_RIGHT = "right" +CONF_OPEN = "open" +CONF_CLOSE = "close" +CONF_BOTH_BUTTONS = "both_buttons" +CONF_BUTTON_1 = "button_1" +CONF_BUTTON_2 = "button_2" +CONF_BUTTON_3 = "button_3" +CONF_BUTTON_4 = "button_4" + +HUE_DIMMER_REMOTE_MODEL = "RWL021" +HUE_DIMMER_REMOTE = { + (CONF_SHORT_PRESS, CONF_TURN_ON): 1000, + (CONF_SHORT_RELEASE, CONF_TURN_ON): 1002, + (CONF_LONG_PRESS, CONF_TURN_ON): 1001, + (CONF_LONG_RELEASE, CONF_TURN_ON): 1003, + (CONF_SHORT_PRESS, CONF_DIM_UP): 2000, + (CONF_SHORT_RELEASE, CONF_DIM_UP): 2002, + (CONF_LONG_PRESS, CONF_DIM_UP): 2001, + (CONF_LONG_RELEASE, CONF_DIM_UP): 2003, + (CONF_SHORT_PRESS, CONF_DIM_DOWN): 3000, + (CONF_SHORT_RELEASE, CONF_DIM_DOWN): 3002, + (CONF_LONG_PRESS, CONF_DIM_DOWN): 3001, + (CONF_LONG_RELEASE, CONF_DIM_DOWN): 3003, + (CONF_SHORT_PRESS, CONF_TURN_OFF): 4000, + (CONF_SHORT_RELEASE, CONF_TURN_OFF): 4002, + (CONF_LONG_PRESS, CONF_TURN_OFF): 4001, + (CONF_LONG_RELEASE, CONF_TURN_OFF): 4003, +} + +HUE_TAP_REMOTE_MODEL = "ZGPSWITCH" +HUE_TAP_REMOTE = { + (CONF_SHORT_PRESS, CONF_BUTTON_1): 34, + (CONF_SHORT_PRESS, CONF_BUTTON_2): 16, + (CONF_SHORT_PRESS, CONF_BUTTON_3): 17, + (CONF_SHORT_PRESS, CONF_BUTTON_4): 18, +} + +TRADFRI_ON_OFF_SWITCH_MODEL = "TRADFRI on/off switch" +TRADFRI_ON_OFF_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): 1002, + (CONF_LONG_PRESS, CONF_TURN_ON): 1001, + (CONF_LONG_RELEASE, CONF_TURN_ON): 1003, + (CONF_SHORT_PRESS, CONF_TURN_OFF): 2002, + (CONF_LONG_PRESS, CONF_TURN_OFF): 2001, + (CONF_LONG_RELEASE, CONF_TURN_OFF): 2003, +} + +TRADFRI_OPEN_CLOSE_REMOTE_MODEL = "TRADFRI open/close remote" +TRADFRI_OPEN_CLOSE_REMOTE = { + (CONF_SHORT_PRESS, CONF_OPEN): 1002, + (CONF_LONG_PRESS, CONF_OPEN): 1003, + (CONF_SHORT_PRESS, CONF_CLOSE): 2002, + (CONF_LONG_PRESS, CONF_CLOSE): 2003, +} + +TRADFRI_REMOTE_MODEL = "TRADFRI remote control" +TRADFRI_REMOTE = { + (CONF_SHORT_PRESS, CONF_TURN_ON): 1002, + (CONF_LONG_PRESS, CONF_TURN_ON): 1001, + (CONF_SHORT_PRESS, CONF_DIM_UP): 2002, + (CONF_LONG_PRESS, CONF_DIM_UP): 2001, + (CONF_LONG_RELEASE, CONF_DIM_UP): 2003, + (CONF_SHORT_PRESS, CONF_DIM_DOWN): 3002, + (CONF_LONG_PRESS, CONF_DIM_DOWN): 3001, + (CONF_LONG_RELEASE, CONF_DIM_DOWN): 3003, + (CONF_SHORT_PRESS, CONF_LEFT): 4002, + (CONF_LONG_PRESS, CONF_LEFT): 4001, + (CONF_LONG_RELEASE, CONF_LEFT): 4003, + (CONF_SHORT_PRESS, CONF_RIGHT): 5002, + (CONF_LONG_PRESS, CONF_RIGHT): 5001, + (CONF_LONG_RELEASE, CONF_RIGHT): 5003, +} + +TRADFRI_WIRELESS_DIMMER_MODEL = "TRADFRI wireless dimmer" +TRADFRI_WIRELESS_DIMMER = { + (CONF_ROTATED, CONF_LEFT): 3002, + (CONF_ROTATED, CONF_RIGHT): 2002, +} + +AQARA_DOUBLE_WALL_SWITCH_MODEL = "lumi.remote.b286acn01" +AQARA_DOUBLE_WALL_SWITCH = { + (CONF_SHORT_PRESS, CONF_LEFT): 1002, + (CONF_LONG_PRESS, CONF_LEFT): 1001, + (CONF_DOUBLE_PRESS, CONF_LEFT): 1004, + (CONF_SHORT_PRESS, CONF_RIGHT): 2002, + (CONF_LONG_PRESS, CONF_RIGHT): 2001, + (CONF_DOUBLE_PRESS, CONF_RIGHT): 2004, + (CONF_SHORT_PRESS, CONF_BOTH_BUTTONS): 3002, + (CONF_LONG_PRESS, CONF_BOTH_BUTTONS): 3001, + (CONF_DOUBLE_PRESS, CONF_BOTH_BUTTONS): 3004, +} + +AQARA_MINI_SWITCH_MODEL = "lumi.remote.b1acn01" +AQARA_MINI_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): 1002, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): 1004, + (CONF_LONG_PRESS, CONF_TURN_ON): 1001, + (CONF_LONG_RELEASE, CONF_TURN_ON): 1003, +} + +AQARA_ROUND_SWITCH_MODEL = "lumi.sensor_switch" +AQARA_ROUND_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): 1000, + (CONF_SHORT_RELEASE, CONF_TURN_ON): 1002, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): 1004, + (CONF_TRIPLE_PRESS, CONF_TURN_ON): 1005, + (CONF_QUADRUPLE_PRESS, CONF_TURN_ON): 1006, + (CONF_QUINTUPLE_PRESS, CONF_TURN_ON): 1010, + (CONF_LONG_PRESS, CONF_TURN_ON): 1001, + (CONF_LONG_RELEASE, CONF_TURN_ON): 1003, +} + +AQARA_SQUARE_SWITCH_MODEL = "lumi.sensor_switch.aq3" +AQARA_SQUARE_SWITCH = { + (CONF_SHORT_PRESS, CONF_TURN_ON): 1002, + (CONF_DOUBLE_PRESS, CONF_TURN_ON): 1004, + (CONF_LONG_PRESS, CONF_TURN_ON): 1001, + (CONF_LONG_RELEASE, CONF_TURN_ON): 1003, + (CONF_SHAKE, ""): 1007, +} + +REMOTES = { + HUE_DIMMER_REMOTE_MODEL: HUE_DIMMER_REMOTE, + HUE_TAP_REMOTE_MODEL: HUE_TAP_REMOTE, + TRADFRI_ON_OFF_SWITCH_MODEL: TRADFRI_ON_OFF_SWITCH, + TRADFRI_OPEN_CLOSE_REMOTE_MODEL: TRADFRI_OPEN_CLOSE_REMOTE, + TRADFRI_REMOTE_MODEL: TRADFRI_REMOTE, + TRADFRI_WIRELESS_DIMMER_MODEL: TRADFRI_WIRELESS_DIMMER, + AQARA_DOUBLE_WALL_SWITCH_MODEL: AQARA_DOUBLE_WALL_SWITCH, + AQARA_MINI_SWITCH_MODEL: AQARA_MINI_SWITCH, + AQARA_ROUND_SWITCH_MODEL: AQARA_ROUND_SWITCH, + AQARA_SQUARE_SWITCH_MODEL: AQARA_SQUARE_SWITCH, +} + +TRIGGER_SCHEMA = vol.All( + vol.Schema( + { + vol.Required(CONF_DEVICE_ID): str, + vol.Required(CONF_DOMAIN): DOMAIN, + vol.Required(CONF_PLATFORM): "device", + vol.Required(CONF_TYPE): str, + vol.Required(CONF_SUBTYPE): str, + } + ) +) + + +def _get_deconz_event_from_device_id(hass, device_id): + """Resolve deconz event from device id.""" + deconz_config_entries = configured_gateways(hass) + for config_entry in deconz_config_entries.values(): + + gateway = get_gateway_from_config_entry(hass, config_entry) + for deconz_event in gateway.events: + + if device_id == deconz_event.device_id: + return deconz_event + + return None + + +async def async_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + config = TRIGGER_SCHEMA(config) + + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get(config[CONF_DEVICE_ID]) + + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) + + if device.model not in REMOTES and trigger not in REMOTES[device.model]: + raise InvalidDeviceAutomationConfig + + trigger = REMOTES[device.model][trigger] + + deconz_event = _get_deconz_event_from_device_id(hass, device.id) + if deconz_event is None: + raise InvalidDeviceAutomationConfig + + event_id = deconz_event.serial + + state_config = { + event.CONF_EVENT_TYPE: CONF_DECONZ_EVENT, + event.CONF_EVENT_DATA: {CONF_UNIQUE_ID: event_id, CONF_EVENT: trigger}, + } + + return await event.async_trigger(hass, state_config, action, automation_info) + + +async def async_get_triggers(hass, device_id): + """List device triggers. + + Make sure device is a supported remote model. + Retrieve the deconz event object matching device entry. + Generate device trigger list. + """ + device_registry = await hass.helpers.device_registry.async_get_registry() + device = device_registry.async_get(device_id) + + if device.model not in REMOTES: + return + + triggers = [] + for trigger, subtype in REMOTES[device.model].keys(): + triggers.append( + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: DOMAIN, + CONF_PLATFORM: "device", + CONF_TYPE: trigger, + CONF_SUBTYPE: subtype, + } + ) + + return triggers diff --git a/homeassistant/components/deconz/gateway.py b/homeassistant/components/deconz/gateway.py index 2117f8dc6bb3cf..75898b0fdab819 100644 --- a/homeassistant/components/deconz/gateway.py +++ b/homeassistant/components/deconz/gateway.py @@ -3,18 +3,20 @@ import async_timeout from pydeconz import DeconzSession, errors -from pydeconz.sensor import Switch from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.const import CONF_EVENT, CONF_HOST, CONF_ID -from homeassistant.core import EventOrigin, callback +from homeassistant.const import CONF_HOST +from homeassistant.core import callback from homeassistant.helpers import aiohttp_client from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, ) -from homeassistant.util import slugify +from homeassistant.helpers.entity_registry import ( + async_get_registry, + DISABLED_CONFIG_ENTRY, +) from .const import ( _LOGGER, @@ -22,11 +24,13 @@ CONF_ALLOW_DECONZ_GROUPS, CONF_BRIDGEID, CONF_MASTER_GATEWAY, + DEFAULT_ALLOW_CLIP_SENSOR, + DEFAULT_ALLOW_DECONZ_GROUPS, DOMAIN, NEW_DEVICE, - NEW_SENSOR, SUPPORTED_PLATFORMS, ) + from .errors import AuthenticationRequired, CannotConnect @@ -61,14 +65,18 @@ def master(self) -> bool: return self.config_entry.options[CONF_MASTER_GATEWAY] @property - def allow_clip_sensor(self) -> bool: + def option_allow_clip_sensor(self) -> bool: """Allow loading clip sensor from gateway.""" - return self.config_entry.options.get(CONF_ALLOW_CLIP_SENSOR, True) + return self.config_entry.options.get( + CONF_ALLOW_CLIP_SENSOR, DEFAULT_ALLOW_CLIP_SENSOR + ) @property - def allow_deconz_groups(self) -> bool: + def option_allow_deconz_groups(self) -> bool: """Allow loading deCONZ groups from gateway.""" - return self.config_entry.options.get(CONF_ALLOW_DECONZ_GROUPS, True) + return self.config_entry.options.get( + CONF_ALLOW_DECONZ_GROUPS, DEFAULT_ALLOW_DECONZ_GROUPS + ) async def async_update_device_registry(self): """Update device registry.""" @@ -109,45 +117,52 @@ async def async_setup(self): ) ) - self.listeners.append( - async_dispatcher_connect( - hass, self.async_event_new_device(NEW_SENSOR), self.async_add_remote - ) - ) - - self.async_add_remote(self.api.sensors.values()) - self.api.start() - self.config_entry.add_update_listener(self.async_new_address_callback) + self.config_entry.add_update_listener(self.async_new_address) + self.config_entry.add_update_listener(self.async_options_updated) return True @staticmethod - async def async_new_address_callback(hass, entry): + async def async_new_address(hass, entry): """Handle signals of gateway getting new address. This is a static method because a class method (bound method), can not be used with weak references. """ - gateway = hass.data[DOMAIN][entry.data[CONF_BRIDGEID]] - gateway.api.close() - gateway.api.host = entry.data[CONF_HOST] - gateway.api.start() + gateway = get_gateway_from_config_entry(hass, entry) + if gateway.api.host != entry.data[CONF_HOST]: + gateway.api.close() + gateway.api.host = entry.data[CONF_HOST] + gateway.api.start() @property - def event_reachable(self): + def signal_reachable(self): """Gateway specific event to signal a change in connection status.""" - return f"deconz_reachable_{self.bridgeid}" + return f"deconz-reachable-{self.bridgeid}" @callback def async_connection_status_callback(self, available): """Handle signals of gateway connection status.""" self.available = available - async_dispatcher_send(self.hass, self.event_reachable, True) + async_dispatcher_send(self.hass, self.signal_reachable, True) + + @property + def signal_options_update(self): + """Event specific per deCONZ entry to signal new options.""" + return f"deconz-options-{self.bridgeid}" + + @staticmethod + async def async_options_updated(hass, entry): + """Triggered by config entry options updates.""" + gateway = get_gateway_from_config_entry(hass, entry) + + registry = await async_get_registry(hass) + async_dispatcher_send(hass, gateway.signal_options_update, registry) @callback - def async_event_new_device(self, device_type): + def async_signal_new_device(self, device_type): """Gateway specific event to signal new device.""" return NEW_DEVICE[device_type].format(self.bridgeid) @@ -157,18 +172,9 @@ def async_add_device_callback(self, device_type, device): if not isinstance(device, list): device = [device] async_dispatcher_send( - self.hass, self.async_event_new_device(device_type), device + self.hass, self.async_signal_new_device(device_type), device ) - @callback - def async_add_remote(self, sensors): - """Set up remote from deCONZ.""" - for sensor in sensors: - if sensor.type in Switch.ZHATYPE and not ( - not self.allow_clip_sensor and sensor.type.startswith("CLIP") - ): - self.events.append(DeconzEvent(self.hass, sensor)) - @callback def shutdown(self, event): """Wrap the call to deconz.close. @@ -178,11 +184,8 @@ def shutdown(self, event): self.api.close() async def async_reset(self): - """Reset this gateway to default state. - - Will cancel any scheduled setup retry and will unload - the config entry. - """ + """Reset this gateway to default state.""" + self.api.async_connection_status_callback = None self.api.close() for component in SUPPORTED_PLATFORMS: @@ -196,7 +199,7 @@ async def async_reset(self): for event in self.events: event.async_will_remove_from_hass() - self.events.remove(event) + self.events.clear() self.deconz_ids = {} return True @@ -229,31 +232,36 @@ async def get_gateway( raise CannotConnect -class DeconzEvent: - """When you want signals instead of entities. +class DeconzEntityHandler: + """Platform entity handler to help with updating disabled by.""" - Stateless sensors such as remotes are expected to generate an event - instead of a sensor entity in hass. - """ + def __init__(self, gateway): + """Create an entity handler.""" + self.gateway = gateway + self._entities = [] - def __init__(self, hass, device): - """Register callback that will be used for signals.""" - self._hass = hass - self._device = device - self._device.register_async_callback(self.async_update_callback) - self._event = f"deconz_{CONF_EVENT}" - self._id = slugify(self._device.name) - _LOGGER.debug("deCONZ event created: %s", self._id) + gateway.listeners.append( + async_dispatcher_connect( + gateway.hass, gateway.signal_options_update, self.update_entity_registry + ) + ) @callback - def async_will_remove_from_hass(self) -> None: - """Disconnect event object when removed.""" - self._device.remove_callback(self.async_update_callback) - self._device = None + def add_entity(self, entity): + """Add a new entity to handler.""" + self._entities.append(entity) @callback - def async_update_callback(self, force_update=False): - """Fire the event if reason is that state is updated.""" - if "state" in self._device.changed_keys: - data = {CONF_ID: self._id, CONF_EVENT: self._device.state} - self._hass.bus.async_fire(self._event, data, EventOrigin.remote) + def update_entity_registry(self, entity_registry): + """Update entity registry disabled by status.""" + for entity in self._entities: + + if entity.entity_registry_enabled_default != entity.enabled: + disabled_by = None + + if entity.enabled: + disabled_by = DISABLED_CONFIG_ENTRY + + entity_registry.async_update_entity( + entity.registry_entry.entity_id, disabled_by=disabled_by + ) diff --git a/homeassistant/components/deconz/light.py b/homeassistant/components/deconz/light.py index b68aa6f07796d6..bf4b05089a84c0 100644 --- a/homeassistant/components/deconz/light.py +++ b/homeassistant/components/deconz/light.py @@ -29,18 +29,19 @@ SWITCH_TYPES, ) from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .gateway import get_gateway_from_config_entry, DeconzEntityHandler async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Old way of setting up deCONZ platforms.""" - pass async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ lights and groups from a config entry.""" gateway = get_gateway_from_config_entry(hass, config_entry) + entity_handler = DeconzEntityHandler(gateway) + @callback def async_add_light(lights): """Add light from deCONZ.""" @@ -54,7 +55,7 @@ def async_add_light(lights): gateway.listeners.append( async_dispatcher_connect( - hass, gateway.async_event_new_device(NEW_LIGHT), async_add_light + hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_light ) ) @@ -64,14 +65,16 @@ def async_add_group(groups): entities = [] for group in groups: - if group.lights and gateway.allow_deconz_groups: - entities.append(DeconzGroup(group, gateway)) + if group.lights: + new_group = DeconzGroup(group, gateway) + entity_handler.add_entity(new_group) + entities.append(new_group) async_add_entities(entities, True) gateway.listeners.append( async_dispatcher_connect( - hass, gateway.async_event_new_device(NEW_GROUP), async_add_group + hass, gateway.async_signal_new_device(NEW_GROUP), async_add_group ) ) @@ -190,9 +193,6 @@ def device_state_attributes(self): attributes = {} attributes["is_deconz_group"] = self._device.type == "LightGroup" - if self._device.type == "LightGroup": - attributes["all_on"] = self._device.all_on - return attributes @@ -203,9 +203,7 @@ def __init__(self, device, gateway): """Set up group and create an unique id.""" super().__init__(device, gateway) - self._unique_id = "{}-{}".format( - self.gateway.api.config.bridgeid, self._device.deconz_id - ) + self._unique_id = f"{self.gateway.api.config.bridgeid}-{self._device.deconz_id}" @property def unique_id(self): @@ -224,3 +222,11 @@ def device_info(self): "name": self._device.name, "via_device": (DECONZ_DOMAIN, bridgeid), } + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = dict(super().device_state_attributes) + attributes["all_on"] = self._device.all_on + + return attributes diff --git a/homeassistant/components/deconz/scene.py b/homeassistant/components/deconz/scene.py index ede60e3ef453ed..a84e799d44d47b 100644 --- a/homeassistant/components/deconz/scene.py +++ b/homeassistant/components/deconz/scene.py @@ -9,7 +9,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Old way of setting up deCONZ platforms.""" - pass async def async_setup_entry(hass, config_entry, async_add_entities): @@ -28,7 +27,7 @@ def async_add_scene(scenes): gateway.listeners.append( async_dispatcher_connect( - hass, gateway.async_event_new_device(NEW_SCENE), async_add_scene + hass, gateway.async_signal_new_device(NEW_SCENE), async_add_scene ) ) @@ -49,6 +48,7 @@ async def async_added_to_hass(self): async def async_will_remove_from_hass(self) -> None: """Disconnect scene object when removed.""" + del self.gateway.deconz_ids[self.entity_id] self._scene = None async def async_activate(self): diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index dad3c25cc38d0a..cc3f3de3170c66 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -1,19 +1,14 @@ """Support for deCONZ sensors.""" -from pydeconz.sensor import Consumption, Daylight, LightLevel, Power, Switch - -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, - ATTR_TEMPERATURE, - ATTR_VOLTAGE, - DEVICE_CLASS_BATTERY, -) +from pydeconz.sensor import Consumption, Daylight, LightLevel, Power, Switch, Thermostat + +from homeassistant.const import ATTR_TEMPERATURE, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.util import slugify from .const import ATTR_DARK, ATTR_ON, NEW_SENSOR from .deconz_device import DeconzDevice -from .gateway import get_gateway_from_config_entry +from .deconz_event import DeconzEvent +from .gateway import get_gateway_from_config_entry, DeconzEntityHandler ATTR_CURRENT = "current" ATTR_POWER = "power" @@ -23,36 +18,53 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Old way of setting up deCONZ platforms.""" - pass async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the deCONZ sensors.""" gateway = get_gateway_from_config_entry(hass, config_entry) + batteries = set() + entity_handler = DeconzEntityHandler(gateway) + @callback def async_add_sensor(sensors): - """Add sensors from deCONZ.""" + """Add sensors from deCONZ. + + Create DeconzEvent if part of ZHAType list. + Create DeconzSensor if not a ZHAType and not a binary sensor. + Create DeconzBattery if sensor has a battery attribute. + """ entities = [] for sensor in sensors: - if not sensor.BINARY and not ( - not gateway.allow_clip_sensor and sensor.type.startswith("CLIP") - ): + if sensor.type in Switch.ZHATYPE: - if sensor.type in Switch.ZHATYPE: - if sensor.battery: - entities.append(DeconzBattery(sensor, gateway)) + if gateway.option_allow_clip_sensor or not sensor.type.startswith( + "CLIP" + ): + new_event = DeconzEvent(sensor, gateway) + hass.async_create_task(new_event.async_update_device_registry()) + gateway.events.append(new_event) - else: - entities.append(DeconzSensor(sensor, gateway)) + elif not sensor.BINARY and sensor.type not in Thermostat.ZHATYPE: + + new_sensor = DeconzSensor(sensor, gateway) + entity_handler.add_entity(new_sensor) + entities.append(new_sensor) + + if sensor.battery: + new_battery = DeconzBattery(sensor, gateway) + if new_battery.unique_id not in batteries: + batteries.add(new_battery.unique_id) + entities.append(new_battery) async_add_entities(entities, True) gateway.listeners.append( async_dispatcher_connect( - hass, gateway.async_event_new_device(NEW_SENSOR), async_add_sensor + hass, gateway.async_signal_new_device(NEW_SENSOR), async_add_sensor ) ) @@ -66,7 +78,7 @@ class DeconzSensor(DeconzDevice): def async_update_callback(self, force_update=False): """Update the sensor's state.""" changed = set(self._device.changed_keys) - keys = {"battery", "on", "reachable", "state"} + keys = {"on", "reachable", "state"} if force_update or any(key in changed for key in keys): self.async_schedule_update_ha_state() @@ -94,8 +106,6 @@ def unit_of_measurement(self): def device_state_attributes(self): """Return the state attributes of the sensor.""" attr = {} - if self._device.battery: - attr[ATTR_BATTERY_LEVEL] = self._device.battery if self._device.on is not None: attr[ATTR_ON] = self._device.on @@ -122,13 +132,6 @@ def device_state_attributes(self): class DeconzBattery(DeconzDevice): """Battery class for when a device is only represented as an event.""" - def __init__(self, device, gateway): - """Register dispatcher callback for update of battery state.""" - super().__init__(device, gateway) - - self._name = "{} {}".format(self._device.name, "Battery Level") - self._unit_of_measurement = "%" - @callback def async_update_callback(self, force_update=False): """Update the battery's state, if needed.""" @@ -137,6 +140,11 @@ def async_update_callback(self, force_update=False): if force_update or any(key in changed for key in keys): self.async_schedule_update_ha_state() + @property + def unique_id(self): + """Return a unique identifier for this device.""" + return f"{self.serial}-battery" + @property def state(self): """Return the state of the battery.""" @@ -145,7 +153,7 @@ def state(self): @property def name(self): """Return the name of the battery.""" - return self._name + return f"{self._device.name} Battery Level" @property def device_class(self): @@ -155,10 +163,16 @@ def device_class(self): @property def unit_of_measurement(self): """Return the unit of measurement of this entity.""" - return self._unit_of_measurement + return "%" @property def device_state_attributes(self): """Return the state attributes of the battery.""" - attr = {ATTR_EVENT_ID: slugify(self._device.name)} + attr = {} + + if self._device.type in Switch.ZHATYPE: + for event in self.gateway.events: + if self._device == event.device: + attr[ATTR_EVENT_ID] = event.event_id + return attr diff --git a/homeassistant/components/deconz/services.py b/homeassistant/components/deconz/services.py new file mode 100644 index 00000000000000..3498b46d879c8f --- /dev/null +++ b/homeassistant/components/deconz/services.py @@ -0,0 +1,158 @@ +"""deCONZ services.""" +import voluptuous as vol + +from homeassistant.helpers import config_validation as cv + +from .config_flow import get_master_gateway +from .const import CONF_BRIDGEID, DOMAIN, _LOGGER + +DECONZ_SERVICES = "deconz_services" + +SERVICE_FIELD = "field" +SERVICE_ENTITY = "entity" +SERVICE_DATA = "data" + +SERVICE_CONFIGURE_DEVICE = "configure" +SERVICE_CONFIGURE_DEVICE_SCHEMA = vol.All( + vol.Schema( + { + vol.Optional(SERVICE_ENTITY): cv.entity_id, + vol.Optional(SERVICE_FIELD): cv.matches_regex("/.*"), + vol.Required(SERVICE_DATA): dict, + vol.Optional(CONF_BRIDGEID): str, + } + ), + cv.has_at_least_one_key(SERVICE_ENTITY, SERVICE_FIELD), +) + +SERVICE_DEVICE_REFRESH = "device_refresh" +SERVICE_DEVICE_REFRESH_SCHEMA = vol.All(vol.Schema({vol.Optional(CONF_BRIDGEID): str})) + + +async def async_setup_services(hass): + """Set up services for deCONZ integration.""" + if hass.data.get(DECONZ_SERVICES, False): + return + + hass.data[DECONZ_SERVICES] = True + + async def async_call_deconz_service(service_call): + """Call correct deCONZ service.""" + service = service_call.service + service_data = service_call.data + + if service == SERVICE_CONFIGURE_DEVICE: + await async_configure_service(hass, service_data) + + elif service == SERVICE_DEVICE_REFRESH: + await async_refresh_devices_service(hass, service_data) + + hass.services.async_register( + DOMAIN, + SERVICE_CONFIGURE_DEVICE, + async_call_deconz_service, + schema=SERVICE_CONFIGURE_DEVICE_SCHEMA, + ) + + hass.services.async_register( + DOMAIN, + SERVICE_DEVICE_REFRESH, + async_call_deconz_service, + schema=SERVICE_DEVICE_REFRESH_SCHEMA, + ) + + +async def async_unload_services(hass): + """Unload deCONZ services.""" + if not hass.data.get(DECONZ_SERVICES): + return + + hass.data[DECONZ_SERVICES] = False + + hass.services.async_remove(DOMAIN, SERVICE_CONFIGURE_DEVICE) + hass.services.async_remove(DOMAIN, SERVICE_DEVICE_REFRESH) + + +async def async_configure_service(hass, data): + """Set attribute of device in deCONZ. + + Entity is used to resolve to a device path (e.g. '/lights/1'). + Field is a string representing either a full path + (e.g. '/lights/1/state') when entity is not specified, or a + subpath (e.g. '/state') when used together with entity. + Data is a json object with what data you want to alter + e.g. data={'on': true}. + { + "field": "/lights/1/state", + "data": {"on": true} + } + See Dresden Elektroniks REST API documentation for details: + http://dresden-elektronik.github.io/deconz-rest-doc/rest/ + """ + bridgeid = data.get(CONF_BRIDGEID) + field = data.get(SERVICE_FIELD, "") + entity_id = data.get(SERVICE_ENTITY) + data = data[SERVICE_DATA] + + gateway = get_master_gateway(hass) + if bridgeid: + gateway = hass.data[DOMAIN][bridgeid] + + if entity_id: + try: + field = gateway.deconz_ids[entity_id] + field + except KeyError: + _LOGGER.error("Could not find the entity %s", entity_id) + return + + await gateway.api.async_put_state(field, data) + + +async def async_refresh_devices_service(hass, data): + """Refresh available devices from deCONZ.""" + gateway = get_master_gateway(hass) + if CONF_BRIDGEID in data: + gateway = hass.data[DOMAIN][data[CONF_BRIDGEID]] + + groups = set(gateway.api.groups.keys()) + lights = set(gateway.api.lights.keys()) + scenes = set(gateway.api.scenes.keys()) + sensors = set(gateway.api.sensors.keys()) + + await gateway.api.async_load_parameters() + + gateway.async_add_device_callback( + "group", + [ + group + for group_id, group in gateway.api.groups.items() + if group_id not in groups + ], + ) + + gateway.async_add_device_callback( + "light", + [ + light + for light_id, light in gateway.api.lights.items() + if light_id not in lights + ], + ) + + gateway.async_add_device_callback( + "scene", + [ + scene + for scene_id, scene in gateway.api.scenes.items() + if scene_id not in scenes + ], + ) + + gateway.async_add_device_callback( + "sensor", + [ + sensor + for sensor_id, sensor in gateway.api.sensors.items() + if sensor_id not in sensors + ], + ) diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 7081f816e6ae08..00aa463349cf68 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -51,5 +51,34 @@ } } } + }, + "device_automation": { + "trigger_type": { + "remote_button_short_press": "\"{subtype}\" button pressed", + "remote_button_short_release": "\"{subtype}\" button released", + "remote_button_long_press": "\"{subtype}\" button continuously pressed", + "remote_button_long_release": "\"{subtype}\" button released after long press", + "remote_button_double_press": "\"{subtype}\" button double clicked", + "remote_button_triple_press": "\"{subtype}\" button triple clicked", + "remote_button_quadruple_press": "\"{subtype}\" button quadruple clicked", + "remote_button_quintuple_press": "\"{subtype}\" button quintuple clicked", + "remote_button_rotated": "Button rotated \"{subtype}\"", + "remote_gyro_activated": "Device shaken" + }, + "trigger_subtype": { + "turn_on": "Turn on", + "turn_off": "Turn off", + "dim_up": "Dim up", + "dim_down": "Dim down", + "left": "Left", + "right": "Right", + "open": "Open", + "close": "Close", + "both_buttons": "Both buttons", + "button_1": "First button", + "button_2": "Second button", + "button_3": "Third button", + "button_4": "Fourth button" + } } } diff --git a/homeassistant/components/deconz/switch.py b/homeassistant/components/deconz/switch.py index 7ce40789802f3d..1b51256580aeb7 100644 --- a/homeassistant/components/deconz/switch.py +++ b/homeassistant/components/deconz/switch.py @@ -10,7 +10,6 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Old way of setting up deCONZ platforms.""" - pass async def async_setup_entry(hass, config_entry, async_add_entities): @@ -37,7 +36,7 @@ def async_add_switch(lights): gateway.listeners.append( async_dispatcher_connect( - hass, gateway.async_event_new_device(NEW_LIGHT), async_add_switch + hass, gateway.async_signal_new_device(NEW_LIGHT), async_add_switch ) ) diff --git a/homeassistant/components/demo/weather.py b/homeassistant/components/demo/weather.py index b81a2193bb54ab..2253f261ad2cef 100644 --- a/homeassistant/components/demo/weather.py +++ b/homeassistant/components/demo/weather.py @@ -1,5 +1,5 @@ """Demo platform that offers fake meteorological data.""" -from datetime import datetime, timedelta +from datetime import timedelta from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, @@ -10,6 +10,7 @@ WeatherEntity, ) from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +import homeassistant.util.dt as dt_util CONDITION_CLASSES = { "cloudy": [], @@ -147,7 +148,7 @@ def attribution(self): @property def forecast(self): """Return the forecast.""" - reftime = datetime.now().replace(hour=16, minute=00) + reftime = dt_util.now().replace(hour=16, minute=00) forecast_data = [] for entry in self._forecast: diff --git a/homeassistant/components/device_automation/__init__.py b/homeassistant/components/device_automation/__init__.py index 018e1286d8bc53..9508dd9c849a93 100644 --- a/homeassistant/components/device_automation/__init__.py +++ b/homeassistant/components/device_automation/__init__.py @@ -1,12 +1,16 @@ """Helpers for device automations.""" import asyncio import logging +from typing import Callable, cast import voluptuous as vol from homeassistant.components import websocket_api -from homeassistant.core import split_entity_id +from homeassistant.const import CONF_DOMAIN +from homeassistant.core import split_entity_id, HomeAssistant +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_registry import async_entries_for_device +from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration, IntegrationNotFound DOMAIN = "device_automation" @@ -16,14 +20,34 @@ async def async_setup(hass, config): """Set up device automation.""" + hass.components.websocket_api.async_register_command( + websocket_device_automation_list_actions + ) + hass.components.websocket_api.async_register_command( + websocket_device_automation_list_conditions + ) hass.components.websocket_api.async_register_command( websocket_device_automation_list_triggers ) return True -async def _async_get_device_automation_triggers(hass, domain, device_id): - """List device triggers.""" +async def async_device_condition_from_config( + hass: HomeAssistant, config: ConfigType, config_validation: bool = True +) -> Callable[..., bool]: + """Wrap action method with state based condition.""" + if config_validation: + config = cv.DEVICE_CONDITION_SCHEMA(config) + integration = await async_get_integration(hass, config[CONF_DOMAIN]) + platform = integration.get_platform("device_automation") + return cast( + Callable[..., bool], + platform.async_condition_from_config(config, config_validation), # type: ignore + ) + + +async def _async_get_device_automations_from_domain(hass, domain, fname, device_id): + """List device automations.""" integration = None try: integration = await async_get_integration(hass, domain) @@ -37,19 +61,19 @@ async def _async_get_device_automation_triggers(hass, domain, device_id): # The domain does not have device automations return None - if hasattr(platform, "async_get_triggers"): - return await platform.async_get_triggers(hass, device_id) + if hasattr(platform, fname): + return await getattr(platform, fname)(hass, device_id) -async def async_get_device_automation_triggers(hass, device_id): - """List device triggers.""" +async def _async_get_device_automations(hass, fname, device_id): + """List device automations.""" device_registry, entity_registry = await asyncio.gather( hass.helpers.device_registry.async_get_registry(), hass.helpers.entity_registry.async_get_registry(), ) domains = set() - triggers = [] + automations = [] device = device_registry.async_get(device_id) for entry_id in device.config_entries: config_entry = hass.config_entries.async_get_entry(entry_id) @@ -59,17 +83,47 @@ async def async_get_device_automation_triggers(hass, device_id): for entity in entities: domains.add(split_entity_id(entity.entity_id)[0]) - device_triggers = await asyncio.gather( + device_automations = await asyncio.gather( *( - _async_get_device_automation_triggers(hass, domain, device_id) + _async_get_device_automations_from_domain(hass, domain, fname, device_id) for domain in domains ) ) - for device_trigger in device_triggers: - if device_trigger is not None: - triggers.extend(device_trigger) + for device_automation in device_automations: + if device_automation is not None: + automations.extend(device_automation) - return triggers + return automations + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/action/list", + vol.Required("device_id"): str, + } +) +async def websocket_device_automation_list_actions(hass, connection, msg): + """Handle request for device actions.""" + device_id = msg["device_id"] + actions = await _async_get_device_automations(hass, "async_get_actions", device_id) + connection.send_result(msg["id"], actions) + + +@websocket_api.async_response +@websocket_api.websocket_command( + { + vol.Required("type"): "device_automation/condition/list", + vol.Required("device_id"): str, + } +) +async def websocket_device_automation_list_conditions(hass, connection, msg): + """Handle request for device conditions.""" + device_id = msg["device_id"] + conditions = await _async_get_device_automations( + hass, "async_get_conditions", device_id + ) + connection.send_result(msg["id"], conditions) @websocket_api.async_response @@ -82,5 +136,7 @@ async def async_get_device_automation_triggers(hass, device_id): async def websocket_device_automation_list_triggers(hass, connection, msg): """Handle request for device triggers.""" device_id = msg["device_id"] - triggers = await async_get_device_automation_triggers(hass, device_id) - connection.send_result(msg["id"], {"triggers": triggers}) + triggers = await _async_get_device_automations( + hass, "async_get_triggers", device_id + ) + connection.send_result(msg["id"], triggers) diff --git a/homeassistant/components/device_automation/const.py b/homeassistant/components/device_automation/const.py new file mode 100644 index 00000000000000..40bfc4ca0a13e4 --- /dev/null +++ b/homeassistant/components/device_automation/const.py @@ -0,0 +1,8 @@ +"""Constants for device automations.""" +CONF_IS_OFF = "is_off" +CONF_IS_ON = "is_on" +CONF_TOGGLE = "toggle" +CONF_TURN_OFF = "turn_off" +CONF_TURN_ON = "turn_on" +CONF_TURNED_OFF = "turned_off" +CONF_TURNED_ON = "turned_on" diff --git a/homeassistant/components/device_automation/exceptions.py b/homeassistant/components/device_automation/exceptions.py new file mode 100644 index 00000000000000..2f7c0df01876f7 --- /dev/null +++ b/homeassistant/components/device_automation/exceptions.py @@ -0,0 +1,6 @@ +"""Device automation exceptions.""" +from homeassistant.exceptions import HomeAssistantError + + +class InvalidDeviceAutomationConfig(HomeAssistantError): + """When device automation config is invalid.""" diff --git a/homeassistant/components/device_automation/toggle_entity.py b/homeassistant/components/device_automation/toggle_entity.py new file mode 100644 index 00000000000000..1593e70771aea3 --- /dev/null +++ b/homeassistant/components/device_automation/toggle_entity.py @@ -0,0 +1,186 @@ +"""Device automation helpers for toggle entity.""" +import voluptuous as vol + +import homeassistant.components.automation.state as state +from homeassistant.components.device_automation.const import ( + CONF_IS_OFF, + CONF_IS_ON, + CONF_TOGGLE, + CONF_TURN_OFF, + CONF_TURN_ON, + CONF_TURNED_OFF, + CONF_TURNED_ON, +) +from homeassistant.core import split_entity_id +from homeassistant.const import ( + CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_ENTITY_ID, + CONF_PLATFORM, + CONF_TYPE, +) +from homeassistant.helpers.entity_registry import async_entries_for_device +from homeassistant.helpers import condition, config_validation as cv, service + +ENTITY_ACTIONS = [ + { + # Turn entity off + CONF_TYPE: CONF_TURN_OFF + }, + { + # Turn entity on + CONF_TYPE: CONF_TURN_ON + }, + { + # Toggle entity + CONF_TYPE: CONF_TOGGLE + }, +] + +ENTITY_CONDITIONS = [ + { + # True when entity is turned off + CONF_CONDITION: "device", + CONF_TYPE: CONF_IS_OFF, + }, + { + # True when entity is turned on + CONF_CONDITION: "device", + CONF_TYPE: CONF_IS_ON, + }, +] + +ENTITY_TRIGGERS = [ + { + # Trigger when entity is turned off + CONF_PLATFORM: "device", + CONF_TYPE: CONF_TURNED_OFF, + }, + { + # Trigger when entity is turned on + CONF_PLATFORM: "device", + CONF_TYPE: CONF_TURNED_ON, + }, +] + +ACTION_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_ID): str, + vol.Required(CONF_DOMAIN): str, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In([CONF_TOGGLE, CONF_TURN_OFF, CONF_TURN_ON]), + } +) + +CONDITION_SCHEMA = vol.Schema( + { + vol.Required(CONF_CONDITION): "device", + vol.Required(CONF_DEVICE_ID): str, + vol.Required(CONF_DOMAIN): str, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In([CONF_IS_OFF, CONF_IS_ON]), + } +) + +TRIGGER_SCHEMA = vol.Schema( + { + vol.Required(CONF_PLATFORM): "device", + vol.Required(CONF_DEVICE_ID): str, + vol.Required(CONF_DOMAIN): str, + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Required(CONF_TYPE): vol.In([CONF_TURNED_OFF, CONF_TURNED_ON]), + } +) + + +def _is_domain(entity, domain): + return split_entity_id(entity.entity_id)[0] == domain + + +async def async_call_action_from_config(hass, config, variables, context, domain): + """Change state based on configuration.""" + config = ACTION_SCHEMA(config) + action_type = config[CONF_TYPE] + if action_type == CONF_TURN_ON: + action = "turn_on" + elif action_type == CONF_TURN_OFF: + action = "turn_off" + else: + action = "toggle" + + service_action = { + service.CONF_SERVICE: "{}.{}".format(domain, action), + CONF_ENTITY_ID: config[CONF_ENTITY_ID], + } + + await service.async_call_from_config( + hass, service_action, blocking=True, variables=variables, context=context + ) + + +def async_condition_from_config(config, config_validation): + """Evaluate state based on configuration.""" + condition_type = config[CONF_TYPE] + if condition_type == CONF_IS_ON: + stat = "on" + else: + stat = "off" + state_config = { + condition.CONF_CONDITION: "state", + condition.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + condition.CONF_STATE: stat, + } + + return condition.state_from_config(state_config, config_validation) + + +async def async_attach_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + trigger_type = config[CONF_TYPE] + if trigger_type == CONF_TURNED_ON: + from_state = "off" + to_state = "on" + else: + from_state = "on" + to_state = "off" + state_config = { + state.CONF_ENTITY_ID: config[CONF_ENTITY_ID], + state.CONF_FROM: from_state, + state.CONF_TO: to_state, + } + + return await state.async_trigger(hass, state_config, action, automation_info) + + +async def _async_get_automations(hass, device_id, automation_templates, domain): + """List device automations.""" + automations = [] + entity_registry = await hass.helpers.entity_registry.async_get_registry() + + entities = async_entries_for_device(entity_registry, device_id) + domain_entities = [x for x in entities if _is_domain(x, domain)] + for entity in domain_entities: + for automation in automation_templates: + automation = dict(automation) + automation.update( + device_id=device_id, entity_id=entity.entity_id, domain=domain + ) + automations.append(automation) + + return automations + + +async def async_get_actions(hass, device_id, domain): + """List device actions.""" + return await _async_get_automations(hass, device_id, ENTITY_ACTIONS, domain) + + +async def async_get_conditions(hass, device_id, domain): + """List device conditions.""" + return await _async_get_automations(hass, device_id, ENTITY_CONDITIONS, domain) + + +async def async_get_triggers(hass, device_id, domain): + """List device triggers.""" + return await _async_get_automations(hass, device_id, ENTITY_TRIGGERS, domain) diff --git a/homeassistant/components/device_sun_light_trigger/__init__.py b/homeassistant/components/device_sun_light_trigger/__init__.py index 1b71b44369da14..9a058cfacc10bb 100644 --- a/homeassistant/components/device_sun_light_trigger/__init__.py +++ b/homeassistant/components/device_sun_light_trigger/__init__.py @@ -63,12 +63,14 @@ async def async_setup(hass, config): device_tracker = hass.components.device_tracker group = hass.components.group light = hass.components.light + person = hass.components.person conf = config[DOMAIN] disable_turn_off = conf.get(CONF_DISABLE_TURN_OFF) light_group = conf.get(CONF_LIGHT_GROUP, light.ENTITY_ID_ALL_LIGHTS) light_profile = conf.get(CONF_LIGHT_PROFILE) device_group = conf.get(CONF_DEVICE_GROUP, device_tracker.ENTITY_ID_ALL_DEVICES) device_entity_ids = group.get_entity_ids(device_group, device_tracker.DOMAIN) + device_entity_ids.extend(group.get_entity_ids(device_group, person.DOMAIN)) if not device_entity_ids: logger.error("No devices found to track") diff --git a/homeassistant/components/device_sun_light_trigger/manifest.json b/homeassistant/components/device_sun_light_trigger/manifest.json index abe5a1d500cb81..40ab85bc1e5fbb 100644 --- a/homeassistant/components/device_sun_light_trigger/manifest.json +++ b/homeassistant/components/device_sun_light_trigger/manifest.json @@ -6,7 +6,8 @@ "dependencies": [ "device_tracker", "group", - "light" + "light", + "person" ], "codeowners": [] } diff --git a/homeassistant/components/device_tracker/config_entry.py b/homeassistant/components/device_tracker/config_entry.py index 460f11984096ca..9e53c2e0cea24e 100644 --- a/homeassistant/components/device_tracker/config_entry.py +++ b/homeassistant/components/device_tracker/config_entry.py @@ -18,7 +18,7 @@ async def async_setup_entry(hass, entry): """Set up an entry.""" - component = hass.data.get(DOMAIN) # type: Optional[EntityComponent] + component: Optional[EntityComponent] = hass.data.get(DOMAIN) if component is None: component = hass.data[DOMAIN] = EntityComponent(LOGGER, DOMAIN, hass) diff --git a/homeassistant/components/device_tracker/legacy.py b/homeassistant/components/device_tracker/legacy.py index 2bfd0c41a47bfd..5c186cc12a1182 100644 --- a/homeassistant/components/device_tracker/legacy.py +++ b/homeassistant/components/device_tracker/legacy.py @@ -327,15 +327,15 @@ async def async_init_single_device(dev): class Device(RestoreEntity): """Represent a tracked device.""" - host_name = None # type: str - location_name = None # type: str - gps = None # type: GPSType - gps_accuracy = 0 # type: int - last_seen = None # type: dt_util.dt.datetime - consider_home = None # type: dt_util.dt.timedelta - battery = None # type: int - attributes = None # type: dict - icon = None # type: str + host_name: str = None + location_name: str = None + gps: GPSType = None + gps_accuracy: int = 0 + last_seen: dt_util.dt.datetime = None + consider_home: dt_util.dt.timedelta = None + battery: int = None + attributes: dict = None + icon: str = None # Track if the last update of this device was HOME. last_update_home = False diff --git a/homeassistant/components/device_tracker/setup.py b/homeassistant/components/device_tracker/setup.py index e6edb5f63ac6a0..6c9f05dead7349 100644 --- a/homeassistant/components/device_tracker/setup.py +++ b/homeassistant/components/device_tracker/setup.py @@ -147,7 +147,7 @@ def async_setup_scanner_platform( scanner.hass = hass # Initial scan of each mac we also tell about host name for config - seen = set() # type: Any + seen: Any = set() async def async_device_tracker_scan(now: dt_util.dt.datetime): """Handle interval matches.""" diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 5f1fd335d45d89..15fcfc15338da2 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -36,6 +36,7 @@ SERVICE_MOBILE_APP = "hass_mobile_app" SERVICE_NETGEAR = "netgear_router" SERVICE_OCTOPRINT = "octoprint" +SERVICE_PLEX = "plex_mediaserver" SERVICE_ROKU = "roku" SERVICE_SABNZBD = "sabnzbd" SERVICE_SAMSUNG_PRINTER = "samsung_printer" @@ -49,6 +50,7 @@ SERVICE_DAIKIN: "daikin", SERVICE_TELLDUSLIVE: "tellduslive", SERVICE_IGD: "upnp", + SERVICE_PLEX: "plex", } SERVICE_HANDLERS = { @@ -68,7 +70,6 @@ SERVICE_FREEBOX: ("freebox", None), SERVICE_YEELIGHT: ("yeelight", None), "panasonic_viera": ("media_player", "panasonic_viera"), - "plex_mediaserver": ("media_player", "plex"), "yamaha": ("media_player", "yamaha"), "logitech_mediaserver": ("media_player", "squeezebox"), "directv": ("media_player", "directv"), diff --git a/homeassistant/components/dlna_dmr/manifest.json b/homeassistant/components/dlna_dmr/manifest.json index 4e7b11767beb5b..bf05d5c7f63fdd 100644 --- a/homeassistant/components/dlna_dmr/manifest.json +++ b/homeassistant/components/dlna_dmr/manifest.json @@ -3,7 +3,7 @@ "name": "Dlna dmr", "documentation": "https://www.home-assistant.io/components/dlna_dmr", "requirements": [ - "async-upnp-client==0.14.10" + "async-upnp-client==0.14.11" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index c7c488950cc747..5dd7ab7a88a74d 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -1,6 +1,5 @@ """Support for DLNA DMR (Device Media Renderer).""" import asyncio -from datetime import datetime from datetime import timedelta import functools import logging @@ -43,6 +42,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import HomeAssistantType import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util from homeassistant.util import get_local_ip _LOGGER = logging.getLogger(__name__) @@ -241,14 +241,14 @@ async def async_update(self): return # do we need to (re-)subscribe? - now = datetime.now() + now = dt_util.utcnow() should_renew = ( self._subscription_renew_time and now >= self._subscription_renew_time ) if should_renew or not was_available and self._available: try: timeout = await self._device.async_subscribe_services() - self._subscription_renew_time = datetime.now() + timeout / 2 + self._subscription_renew_time = dt_util.utcnow() + timeout / 2 except (asyncio.TimeoutError, aiohttp.ClientError): self._available = False _LOGGER.debug("Could not (re)subscribe") diff --git a/homeassistant/components/doods/__init__.py b/homeassistant/components/doods/__init__.py new file mode 100644 index 00000000000000..b6edb9be87bda0 --- /dev/null +++ b/homeassistant/components/doods/__init__.py @@ -0,0 +1 @@ +"""The doods component.""" diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py new file mode 100644 index 00000000000000..850eae76040f2a --- /dev/null +++ b/homeassistant/components/doods/image_processing.py @@ -0,0 +1,363 @@ +"""Support for the DOODS service.""" +import io +import logging +import time + +import voluptuous as vol +from PIL import Image, ImageDraw +from pydoods import PyDOODS + +from homeassistant.components.image_processing import ( + CONF_CONFIDENCE, + CONF_ENTITY_ID, + CONF_NAME, + CONF_SOURCE, + PLATFORM_SCHEMA, + ImageProcessingEntity, +) +from homeassistant.core import split_entity_id +from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +ATTR_MATCHES = "matches" +ATTR_SUMMARY = "summary" +ATTR_TOTAL_MATCHES = "total_matches" + +CONF_URL = "url" +CONF_AUTH_KEY = "auth_key" +CONF_DETECTOR = "detector" +CONF_LABELS = "labels" +CONF_AREA = "area" +CONF_TOP = "top" +CONF_BOTTOM = "bottom" +CONF_RIGHT = "right" +CONF_LEFT = "left" +CONF_FILE_OUT = "file_out" + +AREA_SCHEMA = vol.Schema( + { + vol.Optional(CONF_BOTTOM, default=1): cv.small_float, + vol.Optional(CONF_LEFT, default=0): cv.small_float, + vol.Optional(CONF_RIGHT, default=1): cv.small_float, + vol.Optional(CONF_TOP, default=0): cv.small_float, + } +) + +LABEL_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_AREA): AREA_SCHEMA, + vol.Optional(CONF_CONFIDENCE, default=0.0): vol.Range(min=0, max=100), + } +) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_URL): cv.string, + vol.Required(CONF_DETECTOR): cv.string, + vol.Optional(CONF_AUTH_KEY, default=""): cv.string, + vol.Optional(CONF_FILE_OUT, default=[]): vol.All(cv.ensure_list, [cv.template]), + vol.Optional(CONF_CONFIDENCE, default=0.0): vol.Range(min=0, max=100), + vol.Optional(CONF_LABELS, default=[]): vol.All( + cv.ensure_list, [vol.Any(cv.string, LABEL_SCHEMA)] + ), + vol.Optional(CONF_AREA): AREA_SCHEMA, + } +) + + +def draw_box(draw, box, img_width, img_height, text="", color=(255, 255, 0)): + """Draw bounding box on image.""" + ymin, xmin, ymax, xmax = box + (left, right, top, bottom) = ( + xmin * img_width, + xmax * img_width, + ymin * img_height, + ymax * img_height, + ) + draw.line( + [(left, top), (left, bottom), (right, bottom), (right, top), (left, top)], + width=5, + fill=color, + ) + if text: + draw.text((left, abs(top - 15)), text, fill=color) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Doods client.""" + url = config[CONF_URL] + auth_key = config[CONF_AUTH_KEY] + detector_name = config[CONF_DETECTOR] + + doods = PyDOODS(url, auth_key) + response = doods.get_detectors() + if not isinstance(response, dict): + _LOGGER.warning("Could not connect to doods server: %s", url) + return + + detector = {} + for server_detector in response["detectors"]: + if server_detector["name"] == detector_name: + detector = server_detector + break + + if not detector: + _LOGGER.warning( + "Detector %s is not supported by doods server %s", detector_name, url + ) + return + + entities = [] + for camera in config[CONF_SOURCE]: + entities.append( + Doods( + hass, + camera[CONF_ENTITY_ID], + camera.get(CONF_NAME), + doods, + detector, + config, + ) + ) + add_entities(entities) + + +class Doods(ImageProcessingEntity): + """Doods image processing service client.""" + + def __init__(self, hass, camera_entity, name, doods, detector, config): + """Initialize the DOODS entity.""" + self.hass = hass + self._camera_entity = camera_entity + if name: + self._name = name + else: + name = split_entity_id(camera_entity)[1] + self._name = f"Doods {name}" + self._doods = doods + self._file_out = config[CONF_FILE_OUT] + self._detector_name = detector["name"] + + # detector config and aspect ratio + self._width = None + self._height = None + self._aspect = None + if detector["width"] and detector["height"]: + self._width = detector["width"] + self._height = detector["height"] + self._aspect = self._width / self._height + + # the base confidence + dconfig = {} + confidence = config[CONF_CONFIDENCE] + + # handle labels and specific detection areas + labels = config[CONF_LABELS] + self._label_areas = {} + for label in labels: + if isinstance(label, dict): + label_name = label[CONF_NAME] + if label_name not in detector["labels"] and label_name != "*": + _LOGGER.warning("Detector does not support label %s", label_name) + continue + + # Label Confidence + label_confidence = label[CONF_CONFIDENCE] + if label_name not in dconfig or dconfig[label_name] > label_confidence: + dconfig[label_name] = label_confidence + + # Label area + label_area = label.get(CONF_AREA) + self._label_areas[label_name] = [0, 0, 1, 1] + if label_area: + self._label_areas[label_name] = [ + label_area[CONF_TOP], + label_area[CONF_LEFT], + label_area[CONF_BOTTOM], + label_area[CONF_RIGHT], + ] + else: + if label not in detector["labels"] and label != "*": + _LOGGER.warning("Detector does not support label %s", label) + continue + self._label_areas[label] = [0, 0, 1, 1] + if label not in dconfig or dconfig[label] > confidence: + dconfig[label] = confidence + + if not dconfig: + dconfig["*"] = confidence + + # Handle global detection area + self._area = [0, 0, 1, 1] + area_config = config.get(CONF_AREA) + if area_config: + self._area = [ + area_config[CONF_TOP], + area_config[CONF_LEFT], + area_config[CONF_BOTTOM], + area_config[CONF_RIGHT], + ] + + template.attach(hass, self._file_out) + + self._dconfig = dconfig + self._matches = {} + self._total_matches = 0 + self._last_image = None + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera_entity + + @property + def name(self): + """Return the name of the image processor.""" + return self._name + + @property + def state(self): + """Return the state of the entity.""" + return self._total_matches + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + return { + ATTR_MATCHES: self._matches, + ATTR_SUMMARY: { + label: len(values) for label, values in self._matches.items() + }, + ATTR_TOTAL_MATCHES: self._total_matches, + } + + def _save_image(self, image, matches, paths): + img = Image.open(io.BytesIO(bytearray(image))).convert("RGB") + img_width, img_height = img.size + draw = ImageDraw.Draw(img) + + # Draw custom global region/area + if self._area != [0, 0, 1, 1]: + draw_box( + draw, self._area, img_width, img_height, "Detection Area", (0, 255, 255) + ) + + for label, values in matches.items(): + + # Draw custom label regions/areas + if label in self._label_areas and self._label_areas[label] != [0, 0, 1, 1]: + box_label = f"{label.capitalize()} Detection Area" + draw_box( + draw, + self._label_areas[label], + img_width, + img_height, + box_label, + (0, 255, 0), + ) + + # Draw detected objects + for instance in values: + box_label = f'{label} {instance["score"]:.1f}%' + # Already scaled, use 1 for width and height + draw_box( + draw, + instance["box"], + img_width, + img_height, + box_label, + (255, 255, 0), + ) + + for path in paths: + _LOGGER.info("Saving results image to %s", path) + img.save(path) + + def process_image(self, image): + """Process the image.""" + img = Image.open(io.BytesIO(bytearray(image))) + img_width, img_height = img.size + + if self._aspect and abs((img_width / img_height) - self._aspect) > 0.1: + _LOGGER.debug( + "The image aspect: %s and the detector aspect: %s differ by more than 0.1", + (img_width / img_height), + self._aspect, + ) + + # Run detection + start = time.time() + response = self._doods.detect( + image, dconfig=self._dconfig, detector_name=self._detector_name + ) + _LOGGER.debug( + "doods detect: %s response: %s duration: %s", + self._dconfig, + response, + time.time() - start, + ) + + matches = {} + total_matches = 0 + + if not response or "error" in response: + if "error" in response: + _LOGGER.error(response["error"]) + self._matches = matches + self._total_matches = total_matches + return + + for detection in response["detections"]: + score = detection["confidence"] + boxes = [ + detection["top"], + detection["left"], + detection["bottom"], + detection["right"], + ] + label = detection["label"] + + # Exclude unlisted labels + if "*" not in self._dconfig and label not in self._dconfig: + continue + + # Exclude matches outside global area definition + if ( + boxes[0] < self._area[0] + or boxes[1] < self._area[1] + or boxes[2] > self._area[2] + or boxes[3] > self._area[3] + ): + continue + + # Exclude matches outside label specific area definition + if self._label_areas and ( + boxes[0] < self._label_areas[label][0] + or boxes[1] < self._label_areas[label][1] + or boxes[2] > self._label_areas[label][2] + or boxes[3] > self._label_areas[label][3] + ): + continue + + if label not in matches: + matches[label] = [] + matches[label].append({"score": float(score), "box": boxes}) + total_matches += 1 + + # Save Images + if total_matches and self._file_out: + paths = [] + for path_template in self._file_out: + if isinstance(path_template, template.Template): + paths.append( + path_template.render(camera_entity=self._camera_entity) + ) + else: + paths.append(path_template) + self._save_image(image, matches, paths) + + self._matches = matches + self._total_matches = total_matches diff --git a/homeassistant/components/doods/manifest.json b/homeassistant/components/doods/manifest.json new file mode 100644 index 00000000000000..75c1bd3dcd3814 --- /dev/null +++ b/homeassistant/components/doods/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "doods", + "name": "DOODS - Distributed Outside Object Detection Service", + "documentation": "https://www.home-assistant.io/components/doods", + "requirements": [ + "pydoods==1.0.2" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/doorbird/camera.py b/homeassistant/components/doorbird/camera.py index eaae3f1923636d..457c319d9e1654 100644 --- a/homeassistant/components/doorbird/camera.py +++ b/homeassistant/components/doorbird/camera.py @@ -8,6 +8,7 @@ from homeassistant.components.camera import Camera, SUPPORT_STREAM from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.util.dt as dt_util from . import DOMAIN as DOORBIRD_DOMAIN @@ -77,7 +78,7 @@ def name(self): async def async_camera_image(self): """Pull a still image from the camera.""" - now = datetime.datetime.now() + now = dt_util.utcnow() if self._last_image and now - self._last_update < self._interval: return self._last_image diff --git a/homeassistant/components/doorbird/switch.py b/homeassistant/components/doorbird/switch.py index 643e006dfef0ed..7a0dfa82e76eef 100644 --- a/homeassistant/components/doorbird/switch.py +++ b/homeassistant/components/doorbird/switch.py @@ -3,6 +3,7 @@ import logging from homeassistant.components.switch import SwitchDevice +import homeassistant.util.dt as dt_util from . import DOMAIN as DOORBIRD_DOMAIN @@ -66,7 +67,7 @@ def turn_on(self, **kwargs): else: self._state = self._doorstation.device.energize_relay(self._relay) - now = datetime.datetime.now() + now = dt_util.utcnow() self._assume_off = now + self._time def turn_off(self, **kwargs): @@ -75,6 +76,6 @@ def turn_off(self, **kwargs): def update(self): """Wait for the correct amount of assumed time to pass.""" - if self._state and self._assume_off <= datetime.datetime.now(): + if self._state and self._assume_off <= dt_util.utcnow(): self._state = False self._assume_off = datetime.datetime.min diff --git a/homeassistant/components/ebox/sensor.py b/homeassistant/components/ebox/sensor.py index 66c58b828826cb..95c5513ecaf94e 100644 --- a/homeassistant/components/ebox/sensor.py +++ b/homeassistant/components/ebox/sensor.py @@ -26,10 +26,10 @@ _LOGGER = logging.getLogger(__name__) -GIGABITS = "Gb" # type: str -PRICE = "CAD" # type: str -DAYS = "days" # type: str -PERCENT = "%" # type: str +GIGABITS = "Gb" +PRICE = "CAD" +DAYS = "days" +PERCENT = "%" DEFAULT_NAME = "EBox" diff --git a/homeassistant/components/ebusd/sensor.py b/homeassistant/components/ebusd/sensor.py index ac156e040d7332..4bc79e7bd39c19 100644 --- a/homeassistant/components/ebusd/sensor.py +++ b/homeassistant/components/ebusd/sensor.py @@ -3,6 +3,7 @@ import datetime from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util from .const import DOMAIN @@ -68,9 +69,7 @@ def device_state_attributes(self): if index < len(time_frame): parsed = datetime.datetime.strptime(time_frame[index], "%H:%M") parsed = parsed.replace( - datetime.datetime.now().year, - datetime.datetime.now().month, - datetime.datetime.now().day, + dt_util.now().year, dt_util.now().month, dt_util.now().day ) schedule[item[0]] = parsed.isoformat() return schedule diff --git a/homeassistant/components/egardia/__init__.py b/homeassistant/components/egardia/__init__.py index e17ea8f065d10a..9e11f522dd53d0 100644 --- a/homeassistant/components/egardia/__init__.py +++ b/homeassistant/components/egardia/__init__.py @@ -110,7 +110,7 @@ def setup(hass, config): server = egardiaserver.EgardiaServer("", rs_port) bound = server.bind() if not bound: - raise IOError( + raise OSError( "Binding error occurred while " + "starting EgardiaServer." ) hass.data[EGARDIA_SERVER] = server @@ -123,7 +123,7 @@ def handle_stop_event(event): # listen to home assistant stop event hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop_event) - except IOError: + except OSError: _LOGGER.error("Binding error occurred while starting EgardiaServer") return False diff --git a/homeassistant/components/emulated_roku/.translations/it.json b/homeassistant/components/emulated_roku/.translations/it.json index cba89add799481..8f39309264a6ca 100644 --- a/homeassistant/components/emulated_roku/.translations/it.json +++ b/homeassistant/components/emulated_roku/.translations/it.json @@ -6,8 +6,12 @@ "step": { "user": { "data": { + "advertise_ip": "Pubblicizza IP", + "advertise_port": "Pubblicizza porta", "host_ip": "Indirizzo IP dell'host", - "name": "Nome" + "listen_port": "Porta di ascolto", + "name": "Nome", + "upnp_bind_multicast": "Associa multicast (Vero / Falso)" }, "title": "Definisci la configurazione del server" } diff --git a/homeassistant/components/enphase_envoy/sensor.py b/homeassistant/components/enphase_envoy/sensor.py index 2cc46632ddaf58..13784e24d77fc9 100644 --- a/homeassistant/components/enphase_envoy/sensor.py +++ b/homeassistant/components/enphase_envoy/sensor.py @@ -9,6 +9,7 @@ from homeassistant.const import ( CONF_IP_ADDRESS, CONF_MONITORED_CONDITIONS, + CONF_NAME, POWER_WATT, ENERGY_WATT_HOUR, ) @@ -44,6 +45,7 @@ vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): vol.All( cv.ensure_list, [vol.In(list(SENSORS))] ), + vol.Optional(CONF_NAME, default=""): cv.string, } ) @@ -54,6 +56,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= ip_address = config[CONF_IP_ADDRESS] monitored_conditions = config[CONF_MONITORED_CONDITIONS] + name = config[CONF_NAME] entities = [] # Iterate through the list of sensors @@ -66,14 +69,17 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= Envoy( ip_address, condition, - "{} {}".format(SENSORS[condition][0], inverter), + f"{name}{SENSORS[condition][0]} {inverter}", SENSORS[condition][1], ) ) else: entities.append( Envoy( - ip_address, condition, SENSORS[condition][0], SENSORS[condition][1] + ip_address, + condition, + f"{name}{SENSORS[condition][0]}", + SENSORS[condition][1], ) ) async_add_entities(entities) diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index 0625fd4c27f6d9..2ae2006512b03a 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -3,7 +3,7 @@ "name": "Environment Canada", "documentation": "https://www.home-assistant.io/components/environment_canada", "requirements": [ - "env_canada==0.0.24" + "env_canada==0.0.25" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/environment_canada/sensor.py b/homeassistant/components/environment_canada/sensor.py index 2413edaebce060..244fda61656f88 100644 --- a/homeassistant/components/environment_canada/sensor.py +++ b/homeassistant/components/environment_canada/sensor.py @@ -68,7 +68,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ec_data = ECData(coordinates=(lat, lon), language=config.get(CONF_LANGUAGE)) sensor_list = list(ec_data.conditions.keys()) + list(ec_data.alerts.keys()) - sensor_list.remove("icon_code") add_entities([ECSensor(sensor_type, ec_data) for sensor_type in sensor_list], True) diff --git a/homeassistant/components/esphome/.translations/es.json b/homeassistant/components/esphome/.translations/es.json index 88730a18554e9a..70d766cf4c0544 100644 --- a/homeassistant/components/esphome/.translations/es.json +++ b/homeassistant/components/esphome/.translations/es.json @@ -8,6 +8,7 @@ "invalid_password": "\u00a1Contrase\u00f1a incorrecta!", "resolve_error": "No se puede resolver la direcci\u00f3n de ESP. Si el error persiste, configura una direcci\u00f3n IP est\u00e1tica: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, + "flow_title": "Desplom\u00e9: {name}", "step": { "authenticate": { "data": { diff --git a/homeassistant/components/esphome/.translations/it.json b/homeassistant/components/esphome/.translations/it.json index b9088c2eadc34e..bb77e87f6a1c38 100644 --- a/homeassistant/components/esphome/.translations/it.json +++ b/homeassistant/components/esphome/.translations/it.json @@ -18,7 +18,7 @@ "title": "Inserisci la password" }, "discovery_confirm": { - "description": "Vuoi aggiungere il nodo ESPHome ` {name} ` a Home Assistant?", + "description": "Vuoi aggiungere il nodo ESPHome `{name}` a Home Assistant?", "title": "Trovato nodo ESPHome" }, "user": { diff --git a/homeassistant/components/esphome/.translations/lb.json b/homeassistant/components/esphome/.translations/lb.json index 955b050bc5b19d..882b67823ba288 100644 --- a/homeassistant/components/esphome/.translations/lb.json +++ b/homeassistant/components/esphome/.translations/lb.json @@ -14,7 +14,7 @@ "data": { "password": "Passwuert" }, - "description": "Gitt d'Passwuert vun \u00e4rer Konfiguratioun an.", + "description": "Gitt d'Passwuert vun \u00e4rer Konfiguratioun an fir {name}.", "title": "Passwuert aginn" }, "discovery_confirm": { diff --git a/homeassistant/components/esphome/.translations/pl.json b/homeassistant/components/esphome/.translations/pl.json index c8e6012ea94582..9394b5af543cb2 100644 --- a/homeassistant/components/esphome/.translations/pl.json +++ b/homeassistant/components/esphome/.translations/pl.json @@ -26,7 +26,7 @@ "host": "Host", "port": "Port" }, - "description": "Wprowad\u017a ustawienia po\u0142\u0105czenia swojego [ESPHome](https://esphomelib.com/) w\u0119z\u0142a.", + "description": "Wprowad\u017a ustawienia po\u0142\u0105czenia [ESPHome](https://esphomelib.com/) w\u0119z\u0142a.", "title": "ESPHome" } }, diff --git a/homeassistant/components/esphome/__init__.py b/homeassistant/components/esphome/__init__.py index 182d4003e30664..bc06aba94ead87 100644 --- a/homeassistant/components/esphome/__init__.py +++ b/homeassistant/components/esphome/__init__.py @@ -203,7 +203,7 @@ async def try_connect(tries: int = 0, is_disconnect: bool = True) -> None: # When removing/disconnecting manually return - data = hass.data[DOMAIN][entry.entry_id] # type: RuntimeEntryData + data: RuntimeEntryData = hass.data[DOMAIN][entry.entry_id] for disconnect_cb in data.disconnect_callbacks: disconnect_cb() data.disconnect_callbacks = [] @@ -326,7 +326,7 @@ async def _cleanup_instance( hass: HomeAssistantType, entry: ConfigEntry ) -> RuntimeEntryData: """Cleanup the esphome client if it exists.""" - data = hass.data[DATA_KEY].pop(entry.entry_id) # type: RuntimeEntryData + data: RuntimeEntryData = hass.data[DATA_KEY].pop(entry.entry_id) if data.reconnect_task is not None: data.reconnect_task.cancel() for disconnect_cb in data.disconnect_callbacks: @@ -363,7 +363,7 @@ async def platform_async_setup_entry( This method is in charge of receiving, distributing and storing info and state updates. """ - entry_data = hass.data[DOMAIN][entry.entry_id] # type: RuntimeEntryData + entry_data: RuntimeEntryData = hass.data[DOMAIN][entry.entry_id] entry_data.info[component_key] = {} entry_data.state[component_key] = {} @@ -468,7 +468,7 @@ def __init__(self, entry_id: str, component_key: str, key: int): self._entry_id = entry_id self._component_key = component_key self._key = key - self._remove_callbacks = [] # type: List[Callable[[], None]] + self._remove_callbacks: List[Callable[[], None]] = [] async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/esphome/config_flow.py b/homeassistant/components/esphome/config_flow.py index 35389d055d6708..9680ed46acdf5d 100644 --- a/homeassistant/components/esphome/config_flow.py +++ b/homeassistant/components/esphome/config_flow.py @@ -19,9 +19,9 @@ class EsphomeFlowHandler(config_entries.ConfigFlow): def __init__(self): """Initialize flow.""" - self._host = None # type: Optional[str] - self._port = None # type: Optional[int] - self._password = None # type: Optional[str] + self._host: Optional[str] = None + self._port: Optional[int] = None + self._password: Optional[str] = None async def async_step_user( self, user_input: Optional[ConfigType] = None, error: Optional[str] = None @@ -94,9 +94,7 @@ async def async_step_zeroconf(self, user_input: ConfigType): already_configured = True elif entry.entry_id in self.hass.data.get(DATA_KEY, {}): # Does a config entry with this name already exist? - data = self.hass.data[DATA_KEY][ - entry.entry_id - ] # type: RuntimeEntryData + data: RuntimeEntryData = self.hass.data[DATA_KEY][entry.entry_id] # Node names are unique in the network if data.device_info is not None: already_configured = data.device_info.name == node_name diff --git a/homeassistant/components/esphome/light.py b/homeassistant/components/esphome/light.py index e455d5581d1e3d..1205521706eb21 100644 --- a/homeassistant/components/esphome/light.py +++ b/homeassistant/components/esphome/light.py @@ -74,7 +74,7 @@ async def async_turn_on(self, **kwargs) -> None: red, green, blue = color_util.color_hsv_to_RGB(hue, sat, 100) data["rgb"] = (red / 255, green / 255, blue / 255) if ATTR_FLASH in kwargs: - data["flash"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] + data["flash_length"] = FLASH_LENGTHS[kwargs[ATTR_FLASH]] if ATTR_TRANSITION in kwargs: data["transition_length"] = kwargs[ATTR_TRANSITION] if ATTR_BRIGHTNESS in kwargs: diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 50d698f733656f..82f4d37938c7d8 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -55,23 +55,21 @@ "speed_list": ATTR_SPEED_LIST, "oscillating": ATTR_OSCILLATING, "current_direction": ATTR_DIRECTION, -} # type: dict +} FAN_SET_SPEED_SCHEMA = ENTITY_SERVICE_SCHEMA.extend( {vol.Required(ATTR_SPEED): cv.string} -) # type: dict +) -FAN_TURN_ON_SCHEMA = ENTITY_SERVICE_SCHEMA.extend( - {vol.Optional(ATTR_SPEED): cv.string} -) # type: dict +FAN_TURN_ON_SCHEMA = ENTITY_SERVICE_SCHEMA.extend({vol.Optional(ATTR_SPEED): cv.string}) FAN_OSCILLATE_SCHEMA = ENTITY_SERVICE_SCHEMA.extend( {vol.Required(ATTR_OSCILLATING): cv.boolean} -) # type: dict +) FAN_SET_DIRECTION_SCHEMA = ENTITY_SERVICE_SCHEMA.extend( {vol.Optional(ATTR_DIRECTION): cv.string} -) # type: dict +) @bind_hass @@ -198,7 +196,7 @@ def current_direction(self) -> Optional[str]: @property def state_attributes(self) -> dict: """Return optional state attributes.""" - data = {} # type: dict + data = {} for prop, attr in PROP_TO_ATTR.items(): if not hasattr(self, prop): diff --git a/homeassistant/components/fedex/__init__.py b/homeassistant/components/fedex/__init__.py deleted file mode 100644 index d685ab50372de5..00000000000000 --- a/homeassistant/components/fedex/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The fedex component.""" diff --git a/homeassistant/components/fedex/manifest.json b/homeassistant/components/fedex/manifest.json deleted file mode 100644 index b34a8b8383ef85..00000000000000 --- a/homeassistant/components/fedex/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "fedex", - "name": "Fedex", - "documentation": "https://www.home-assistant.io/components/fedex", - "requirements": [ - "fedexdeliverymanager==1.0.6" - ], - "dependencies": [], - "codeowners": [] -} diff --git a/homeassistant/components/fedex/sensor.py b/homeassistant/components/fedex/sensor.py deleted file mode 100644 index 2f499e52e234e5..00000000000000 --- a/homeassistant/components/fedex/sensor.py +++ /dev/null @@ -1,120 +0,0 @@ -"""Sensor for Fedex packages.""" -import logging -from collections import defaultdict -from datetime import timedelta - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_NAME, - CONF_USERNAME, - CONF_PASSWORD, - ATTR_ATTRIBUTION, - CONF_SCAN_INTERVAL, -) -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle -from homeassistant.util import slugify -from homeassistant.util.dt import now, parse_date - -_LOGGER = logging.getLogger(__name__) - -COOKIE = "fedexdeliverymanager_cookies.pickle" - -DOMAIN = "fedex" - -ICON = "mdi:package-variant-closed" - -STATUS_DELIVERED = "delivered" - -SCAN_INTERVAL = timedelta(seconds=1800) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME): cv.string, - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Fedex platform.""" - import fedexdeliverymanager - - _LOGGER.warning( - "The fedex integration is deprecated and will be removed " - "in Home Assistant 0.100.0. For more information see ADR-0004:" - "https://github.com/home-assistant/architecture/blob/master/adr/0004-webscraping.md" - ) - - name = config.get(CONF_NAME) - update_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) - - try: - cookie = hass.config.path(COOKIE) - session = fedexdeliverymanager.get_session( - config.get(CONF_USERNAME), config.get(CONF_PASSWORD), cookie_path=cookie - ) - except fedexdeliverymanager.FedexError: - _LOGGER.exception("Could not connect to Fedex Delivery Manager") - return False - - add_entities([FedexSensor(session, name, update_interval)], True) - - -class FedexSensor(Entity): - """Fedex Sensor.""" - - def __init__(self, session, name, interval): - """Initialize the sensor.""" - self._session = session - self._name = name - self._attributes = None - self._state = None - self.update = Throttle(interval)(self._update) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name or DOMAIN - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return "packages" - - def _update(self): - """Update device state.""" - import fedexdeliverymanager - - status_counts = defaultdict(int) - for package in fedexdeliverymanager.get_packages(self._session): - status = slugify(package["primary_status"]) - skip = ( - status == STATUS_DELIVERED - and parse_date(package["delivery_date"]) < now().date() - ) - if skip: - continue - status_counts[status] += 1 - self._attributes = {ATTR_ATTRIBUTION: fedexdeliverymanager.ATTRIBUTION} - self._attributes.update(status_counts) - self._state = sum(status_counts.values()) - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self._attributes - - @property - def icon(self): - """Icon to use in the frontend.""" - return ICON diff --git a/homeassistant/components/fido/sensor.py b/homeassistant/components/fido/sensor.py index e85b45db4d3aa0..8814a2406c52a9 100644 --- a/homeassistant/components/fido/sensor.py +++ b/homeassistant/components/fido/sensor.py @@ -25,10 +25,10 @@ _LOGGER = logging.getLogger(__name__) -KILOBITS = "Kb" # type: str -PRICE = "CAD" # type: str -MESSAGES = "messages" # type: str -MINUTES = "minutes" # type: str +KILOBITS = "Kb" +PRICE = "CAD" +MESSAGES = "messages" +MINUTES = "minutes" DEFAULT_NAME = "Fido" diff --git a/homeassistant/components/fints/sensor.py b/homeassistant/components/fints/sensor.py index 008337f88eb077..376ea2c0f9d4fc 100644 --- a/homeassistant/components/fints/sensor.py +++ b/homeassistant/components/fints/sensor.py @@ -162,11 +162,11 @@ class FinTsAccount(Entity): def __init__(self, client: FinTsClient, account, name: str) -> None: """Initialize a FinTs balance account.""" - self._client = client # type: FinTsClient + self._client = client self._account = account - self._name = name # type: str - self._balance = None # type: float - self._currency = None # type: str + self._name = name + self._balance: float = None + self._currency: str = None @property def should_poll(self) -> bool: @@ -222,11 +222,11 @@ class FinTsHoldingsAccount(Entity): def __init__(self, client: FinTsClient, account, name: str) -> None: """Initialize a FinTs holdings account.""" - self._client = client # type: FinTsClient - self._name = name # type: str + self._client = client + self._name = name self._account = account self._holdings = [] - self._total = None # type: float + self._total: float = None @property def should_poll(self) -> bool: diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 7298ce8c1d086a..8ef662ec878f90 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -4,6 +4,7 @@ import mimetypes import os import pathlib +from typing import Optional, Set, Tuple from aiohttp import web, web_urldispatcher, hdrs import voluptuous as vol @@ -22,7 +23,7 @@ from .storage import async_setup_frontend_storage -# mypy: allow-incomplete-defs, allow-untyped-defs, no-check-untyped-defs +# mypy: allow-untyped-defs, no-check-untyped-defs # Fix mimetypes for borked Windows machines # https://github.com/home-assistant/home-assistant-polymer/issues/3336 @@ -400,7 +401,9 @@ def url_for(self, **kwargs: str) -> URL: """Construct url for resource with additional params.""" return URL("/") - async def resolve(self, request: web.Request): + async def resolve( + self, request: web.Request + ) -> Tuple[Optional[web_urldispatcher.UrlMappingMatchInfo], Set[str]]: """Resolve resource. Return (UrlMappingMatchInfo, allowed_methods) pair. @@ -447,7 +450,7 @@ def get_template(self): return tpl - async def get(self, request: web.Request): + async def get(self, request: web.Request) -> web.Response: """Serve the index page for panel pages.""" hass = request.app["hass"] diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 2b17091ba5cb94..896867fcb172eb 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -2,9 +2,7 @@ "domain": "frontend", "name": "Home Assistant Frontend", "documentation": "https://www.home-assistant.io/components/frontend", - "requirements": [ - "home-assistant-frontend==20190901.0" - ], + "requirements": ["home-assistant-frontend==20190919.0"], "dependencies": [ "api", "auth", @@ -14,7 +12,5 @@ "system_log", "websocket_api" ], - "codeowners": [ - "@home-assistant/frontend" - ] + "codeowners": ["@home-assistant/frontend"] } diff --git a/homeassistant/components/geniushub/manifest.json b/homeassistant/components/geniushub/manifest.json index 12f7c266840bf6..f2110ffb2f0220 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.6.5" + "geniushub-client==0.6.13" ], "dependencies": [], "codeowners": ["@zxdavb"] diff --git a/homeassistant/components/geonetnz_quakes/.translations/de.json b/homeassistant/components/geonetnz_quakes/.translations/de.json new file mode 100644 index 00000000000000..7c6fd08af96c8d --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Standort bereits registriert" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "F\u00fcllen Sie Ihre Filterdaten aus." + } + }, + "title": "GeoNet NZ Erdbeben" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/es.json b/homeassistant/components/geonetnz_quakes/.translations/es.json new file mode 100644 index 00000000000000..f6f592675ab33e --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Ubicaci\u00f3n ya registrada" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radio" + }, + "title": "Complete todos los campos requeridos" + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/fr.json b/homeassistant/components/geonetnz_quakes/.translations/fr.json new file mode 100644 index 00000000000000..74ae5541754ef7 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/fr.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Emplacement d\u00e9j\u00e0 enregistr\u00e9" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Rayon" + }, + "title": "Remplissez les d\u00e9tails de votre filtre." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/it.json b/homeassistant/components/geonetnz_quakes/.translations/it.json new file mode 100644 index 00000000000000..2a019aa39d94ae --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/it.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Localit\u00e0 gi\u00e0 registrata" + }, + "step": { + "user": { + "data": { + "mmi": "Intensit\u00e0 in Scala Mercalli Modificata", + "radius": "Raggio" + }, + "title": "Inserisci i tuoi dettagli del filtro." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/ko.json b/homeassistant/components/geonetnz_quakes/.translations/ko.json new file mode 100644 index 00000000000000..26caa2ebe54cad --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/ko.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "\uc704\uce58\uac00 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "\ubc18\uacbd" + }, + "title": "\ud544\ud130 \uc138\ubd80 \uc0ac\ud56d\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694" + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/lb.json b/homeassistant/components/geonetnz_quakes/.translations/lb.json new file mode 100644 index 00000000000000..2499befecbb822 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/lb.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Standuert ass scho registr\u00e9iert" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "F\u00ebllt \u00e4r Filter D\u00e9tailer aus." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/no.json b/homeassistant/components/geonetnz_quakes/.translations/no.json new file mode 100644 index 00000000000000..40b695d6f51488 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/no.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Beliggenhet allerede er registrert" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radius" + }, + "title": "Fyll ut filterdetaljene." + } + }, + "title": "GeoNet NZ Quakes" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/sl.json b/homeassistant/components/geonetnz_quakes/.translations/sl.json new file mode 100644 index 00000000000000..bdd05d339535b2 --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/sl.json @@ -0,0 +1,17 @@ +{ + "config": { + "error": { + "identifier_exists": "Lokacija je \u017ee registrirana" + }, + "step": { + "user": { + "data": { + "mmi": "MMI", + "radius": "Radij" + }, + "title": "Izpolnite podrobnosti filtra." + } + }, + "title": "GeoNet NZ Potresi" + } +} \ No newline at end of file diff --git a/homeassistant/components/geonetnz_quakes/.translations/zh-Hans.json b/homeassistant/components/geonetnz_quakes/.translations/zh-Hans.json new file mode 100644 index 00000000000000..3786b03f41fc3e --- /dev/null +++ b/homeassistant/components/geonetnz_quakes/.translations/zh-Hans.json @@ -0,0 +1,9 @@ +{ + "config": { + "step": { + "user": { + "title": "\u586b\u5199\u60a8\u7684filter\u8be6\u7ec6\u4fe1\u606f\u3002" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index 1385d5e59a768b..90b4b386f37e52 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -197,18 +197,20 @@ async def async_update(self): elif self.type == "cpu_temp": for sensor in value["sensors"]: if sensor["label"] in [ - "CPU", + "amdgpu 1", + "aml_thermal", + "Core 0", + "Core 1", "CPU Temperature", - "Package id 0", - "Physical id 0", - "cpu_thermal 1", + "CPU", "cpu-thermal 1", + "cpu_thermal 1", "exynos-therm 1", - "soc_thermal 1", + "Package id 0", + "Physical id 0", + "radeon 1", "soc-thermal 1", - "aml_thermal", - "Core 0", - "Core 1", + "soc_thermal 1", ]: self._state = sensor["value"] elif self.type == "docker_active": diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 24502462512fba..d68650fb6384c3 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -93,7 +93,7 @@ def __init__(self, config): async def post(self, request: Request) -> Response: """Handle Google Assistant requests.""" - message = await request.json() # type: dict + message: dict = await request.json() result = await async_handle_message( request.app["hass"], self.config, request["hass_user"].id, message ) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 2cb440f9181fea..6ab6d937b51e84 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -24,7 +24,7 @@ async def async_handle_message(hass, config, user_id, message): """Handle incoming API messages.""" - request_id = message.get("requestId") # type: str + request_id: str = message.get("requestId") data = RequestData(config, user_id, request_id) @@ -38,7 +38,7 @@ async def async_handle_message(hass, config, user_id, message): async def _process(hass, data, message): """Process a message.""" - inputs = message.get("inputs") # type: list + inputs: list = message.get("inputs") if len(inputs) != 1: return { diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 5fa7d49b885b1c..2afa18af32e6a7 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -308,7 +308,7 @@ def sync_attributes(self): if features & light.SUPPORT_COLOR_TEMP: # Max Kelvin is Min Mireds K = 1000000 / mireds - # Min Kevin is Max Mireds K = 1000000 / mireds + # Min Kelvin is Max Mireds K = 1000000 / mireds response["colorTemperatureRange"] = { "temperatureMaxK": color_util.color_temperature_mired_to_kelvin( attrs.get(light.ATTR_MIN_MIREDS) diff --git a/homeassistant/components/group/light.py b/homeassistant/components/group/light.py index f0d29d923c8ed1..0b1291d4045f42 100644 --- a/homeassistant/components/group/light.py +++ b/homeassistant/components/group/light.py @@ -1,4 +1,5 @@ """This platform allows several lights to be grouped into one light.""" +import asyncio from collections import Counter import itertools import logging @@ -19,6 +20,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.util import color as color_util from homeassistant.components.light import ( ATTR_BRIGHTNESS, @@ -75,19 +77,19 @@ class LightGroup(light.Light): def __init__(self, name: str, entity_ids: List[str]) -> None: """Initialize a light group.""" - self._name = name # type: str - self._entity_ids = entity_ids # type: List[str] - self._is_on = False # type: bool - self._available = False # type: bool - self._brightness = None # type: Optional[int] - self._hs_color = None # type: Optional[Tuple[float, float]] - self._color_temp = None # type: Optional[int] - self._min_mireds = 154 # type: Optional[int] - self._max_mireds = 500 # type: Optional[int] - self._white_value = None # type: Optional[int] - self._effect_list = None # type: Optional[List[str]] - self._effect = None # type: Optional[str] - self._supported_features = 0 # type: int + self._name = name + self._entity_ids = entity_ids + self._is_on = False + self._available = False + self._brightness: Optional[int] = None + self._hs_color: Optional[Tuple[float, float]] = None + self._color_temp: Optional[int] = None + self._min_mireds: Optional[int] = 154 + self._max_mireds: Optional[int] = 500 + self._white_value: Optional[int] = None + self._effect_list: Optional[List[str]] = None + self._effect: Optional[str] = None + self._supported_features: int = 0 self._async_unsub_state_changed = None async def async_added_to_hass(self) -> None: @@ -179,6 +181,7 @@ def should_poll(self) -> bool: async def async_turn_on(self, **kwargs): """Forward the turn_on command to all lights in the light group.""" data = {ATTR_ENTITY_ID: self._entity_ids} + emulate_color_temp_entity_ids = [] if ATTR_BRIGHTNESS in kwargs: data[ATTR_BRIGHTNESS] = kwargs[ATTR_BRIGHTNESS] @@ -189,6 +192,23 @@ async def async_turn_on(self, **kwargs): if ATTR_COLOR_TEMP in kwargs: data[ATTR_COLOR_TEMP] = kwargs[ATTR_COLOR_TEMP] + # Create a new entity list to mutate + updated_entities = list(self._entity_ids) + + # Walk through initial entity ids, split entity lists by support + for entity_id in self._entity_ids: + state = self.hass.states.get(entity_id) + if not state: + continue + support = state.attributes.get(ATTR_SUPPORTED_FEATURES) + # Only pass color temperature to supported entity_ids + if bool(support & SUPPORT_COLOR) and not bool( + support & SUPPORT_COLOR_TEMP + ): + emulate_color_temp_entity_ids.append(entity_id) + updated_entities.remove(entity_id) + data[ATTR_ENTITY_ID] = updated_entities + if ATTR_WHITE_VALUE in kwargs: data[ATTR_WHITE_VALUE] = kwargs[ATTR_WHITE_VALUE] @@ -201,8 +221,32 @@ async def async_turn_on(self, **kwargs): if ATTR_FLASH in kwargs: data[ATTR_FLASH] = kwargs[ATTR_FLASH] - await self.hass.services.async_call( - light.DOMAIN, light.SERVICE_TURN_ON, data, blocking=True + if not emulate_color_temp_entity_ids: + await self.hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_ON, data, blocking=True + ) + return + + emulate_color_temp_data = data.copy() + temp_k = color_util.color_temperature_mired_to_kelvin( + emulate_color_temp_data[ATTR_COLOR_TEMP] + ) + hs_color = color_util.color_temperature_to_hs(temp_k) + emulate_color_temp_data[ATTR_HS_COLOR] = hs_color + del emulate_color_temp_data[ATTR_COLOR_TEMP] + + emulate_color_temp_data[ATTR_ENTITY_ID] = emulate_color_temp_entity_ids + + await asyncio.gather( + self.hass.services.async_call( + light.DOMAIN, light.SERVICE_TURN_ON, data, blocking=True + ), + self.hass.services.async_call( + light.DOMAIN, + light.SERVICE_TURN_ON, + emulate_color_temp_data, + blocking=True, + ), ) async def async_turn_off(self, **kwargs): diff --git a/homeassistant/components/growatt_server/__init__.py b/homeassistant/components/growatt_server/__init__.py new file mode 100644 index 00000000000000..14205e8d9ba3ce --- /dev/null +++ b/homeassistant/components/growatt_server/__init__.py @@ -0,0 +1 @@ +"""The Growatt server PV inverter sensor integration.""" diff --git a/homeassistant/components/growatt_server/manifest.json b/homeassistant/components/growatt_server/manifest.json new file mode 100644 index 00000000000000..a6a1d2b8aebb7a --- /dev/null +++ b/homeassistant/components/growatt_server/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "growatt_server", + "name": "Growatt Server", + "documentation": "https://www.home-assistant.io/components/growatt_server/", + "requirements": [ + "growattServer==0.0.1" + ], + "dependencies": [], + "codeowners": [ + "@indykoning" + ] +} diff --git a/homeassistant/components/growatt_server/sensor.py b/homeassistant/components/growatt_server/sensor.py new file mode 100644 index 00000000000000..3b7109222a4abb --- /dev/null +++ b/homeassistant/components/growatt_server/sensor.py @@ -0,0 +1,189 @@ +"""Read status of growatt inverters.""" +import re +import json +import logging +import datetime + +import growattServer +import voluptuous as vol + +from homeassistant.util import Throttle +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, CONF_USERNAME, CONF_PASSWORD + +_LOGGER = logging.getLogger(__name__) + +CONF_PLANT_ID = "plant_id" +DEFAULT_PLANT_ID = "0" +DEFAULT_NAME = "Growatt" +SCAN_INTERVAL = datetime.timedelta(minutes=5) + +TOTAL_SENSOR_TYPES = { + "total_money_today": ("Total money today", "€", "plantMoneyText", None), + "total_money_total": ("Money lifetime", "€", "totalMoneyText", None), + "total_energy_today": ("Energy Today", "kWh", "todayEnergy", "power"), + "total_output_power": ("Output Power", "W", "invTodayPpv", "power"), + "total_energy_output": ("Lifetime energy output", "kWh", "totalEnergy", "power"), + "total_maximum_output": ("Maximum power", "W", "nominalPower", "power"), +} + +INVERTER_SENSOR_TYPES = { + "inverter_energy_today": ("Energy today", "kWh", "e_today", "power"), + "inverter_energy_total": ("Lifetime energy output", "kWh", "e_total", "power"), + "inverter_voltage_input_1": ("Input 1 voltage", "V", "vpv1", None), + "inverter_amperage_input_1": ("Input 1 Amperage", "A", "ipv1", None), + "inverter_wattage_input_1": ("Input 1 Wattage", "W", "ppv1", "power"), + "inverter_voltage_input_2": ("Input 2 voltage", "V", "vpv2", None), + "inverter_amperage_input_2": ("Input 2 Amperage", "A", "ipv2", None), + "inverter_wattage_input_2": ("Input 2 Wattage", "W", "ppv2", "power"), + "inverter_voltage_input_3": ("Input 3 voltage", "V", "vpv3", None), + "inverter_amperage_input_3": ("Input 3 Amperage", "A", "ipv3", None), + "inverter_wattage_input_3": ("Input 3 Wattage", "W", "ppv3", "power"), + "inverter_internal_wattage": ("Internal wattage", "W", "ppv", "power"), + "inverter_reactive_voltage": ("Reactive voltage", "V", "vacr", None), + "inverter_inverter_reactive_amperage": ("Reactive amperage", "A", "iacr", None), + "inverter_frequency": ("AC frequency", "Hz", "fac", None), + "inverter_current_wattage": ("Output power", "W", "pac", "power"), + "inverter_current_reactive_wattage": ("Reactive wattage", "W", "pacr", "power"), +} + +SENSOR_TYPES = {**TOTAL_SENSOR_TYPES, **INVERTER_SENSOR_TYPES} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PLANT_ID, default=DEFAULT_PLANT_ID): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Growatt sensor.""" + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + plant_id = config[CONF_PLANT_ID] + name = config[CONF_NAME] + + api = growattServer.GrowattApi() + + # Log in to api and fetch first plant if no plant id is defined. + login_response = api.login(username, password) + if not login_response["success"] and login_response["errCode"] == "102": + _LOGGER.error("Username or Password may be incorrect!") + return + user_id = login_response["userId"] + if plant_id == DEFAULT_PLANT_ID: + plant_info = api.plant_list(user_id) + plant_id = plant_info["data"][0]["plantId"] + + # Get a list of inverters for specified plant to add sensors for. + inverters = api.inverter_list(plant_id) + entities = [] + probe = GrowattData(api, username, password, plant_id, True) + for sensor in TOTAL_SENSOR_TYPES: + entities.append( + GrowattInverter(probe, f"{name} Total", sensor, f"{plant_id}-{sensor}") + ) + + # Add sensors for each inverter in the specified plant. + for inverter in inverters: + probe = GrowattData(api, username, password, inverter["deviceSn"], False) + for sensor in INVERTER_SENSOR_TYPES: + entities.append( + GrowattInverter( + probe, + f"{inverter['deviceAilas']}", + sensor, + f"{inverter['deviceSn']}-{sensor}", + ) + ) + + add_entities(entities, True) + + +class GrowattInverter(Entity): + """Representation of a Growatt Sensor.""" + + def __init__(self, probe, name, sensor, unique_id): + """Initialize a PVOutput sensor.""" + self.sensor = sensor + self.probe = probe + self._name = name + self._state = None + self._unique_id = unique_id + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._name} {SENSOR_TYPES[self.sensor][0]}" + + @property + def unique_id(self): + """Return the unique id of the sensor.""" + return self._unique_id + + @property + def icon(self): + """Return the icon of the sensor.""" + return "mdi:solar-power" + + @property + def state(self): + """Return the state of the sensor.""" + return self.probe.get_data(SENSOR_TYPES[self.sensor][2]) + + @property + def device_class(self): + """Return the device class of the sensor.""" + return SENSOR_TYPES[self.sensor][3] + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return SENSOR_TYPES[self.sensor][1] + + def update(self): + """Get the latest data from the Growat API and updates the state.""" + self.probe.update() + + +class GrowattData: + """The class for handling data retrieval.""" + + def __init__(self, api, username, password, inverter_id, is_total=False): + """Initialize the probe.""" + + self.is_total = is_total + self.api = api + self.inverter_id = inverter_id + self.data = {} + self.username = username + self.password = password + + @Throttle(SCAN_INTERVAL) + def update(self): + """Update probe data.""" + self.api.login(self.username, self.password) + _LOGGER.debug("Updating data for %s", self.inverter_id) + try: + if self.is_total: + total_info = self.api.plant_info(self.inverter_id) + del total_info["deviceList"] + # PlantMoneyText comes in as "3.1/€" remove anything that isn't part of the number + total_info["plantMoneyText"] = re.sub( + r"[^\d.,]", "", total_info["plantMoneyText"] + ) + self.data = total_info + else: + inverter_info = self.api.inverter_detail(self.inverter_id) + self.data = inverter_info["data"] + except json.decoder.JSONDecodeError: + _LOGGER.error("Unable to fetch data from Growatt server") + + def get_data(self, variable): + """Get the data.""" + return self.data.get(variable) diff --git a/homeassistant/components/gtfs/sensor.py b/homeassistant/components/gtfs/sensor.py index d70e1016f07e84..086545f0c761d5 100644 --- a/homeassistant/components/gtfs/sensor.py +++ b/homeassistant/components/gtfs/sensor.py @@ -122,7 +122,7 @@ def get_next_departure( include_tomorrow: bool = False, ) -> dict: """Get the next departure for the given schedule.""" - now = datetime.datetime.now() + offset + now = dt_util.now().replace(tzinfo=None) + offset now_date = now.strftime(dt_util.DATE_STR_FORMAT) yesterday = now - datetime.timedelta(days=1) yesterday_date = yesterday.strftime(dt_util.DATE_STR_FORMAT) @@ -256,7 +256,7 @@ def get_next_departure( _LOGGER.debug("Timetable: %s", sorted(timetable.keys())) - item = {} # type: dict + item = {} for key in sorted(timetable.keys()): if dt_util.parse_datetime(key) > now: item = timetable[key] @@ -393,11 +393,11 @@ def __init__( self._available = False self._icon = ICON self._name = "" - self._state = None # type: Optional[str] - self._attributes = {} # type: dict + self._state: Optional[str] = None + self._attributes = {} self._agency = None - self._departure = {} # type: dict + self._departure = {} self._destination = None self._origin = None self._route = None diff --git a/homeassistant/components/hangouts/.translations/it.json b/homeassistant/components/hangouts/.translations/it.json index ad8dafd17ec74a..ff0a8238d49caa 100644 --- a/homeassistant/components/hangouts/.translations/it.json +++ b/homeassistant/components/hangouts/.translations/it.json @@ -14,14 +14,16 @@ "data": { "2fa": "2FA Pin" }, + "description": "Vuoto", "title": "Autenticazione a due fattori" }, "user": { "data": { "authorization_code": "Codice di autorizzazione (necessario per l'autenticazione manuale)", - "email": "Indirizzo email", + "email": "Indirizzo E-mail", "password": "Password" }, + "description": "Vuoto", "title": "Accesso a Google Hangouts" } }, diff --git a/homeassistant/components/hangouts/.translations/ko.json b/homeassistant/components/hangouts/.translations/ko.json index e045f3359d1542..3b1c755b3588c1 100644 --- a/homeassistant/components/hangouts/.translations/ko.json +++ b/homeassistant/components/hangouts/.translations/ko.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "already_configured": "Google Hangouts \uc740 \uc774\ubbf8 \uc124\uc815\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4", + "already_configured": "\uad6c\uae00 \ud589\uc544\uc6c3\uc740 \uc774\ubbf8 \uc124\uc815\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4", "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" }, "error": { @@ -24,9 +24,9 @@ "password": "\ube44\ubc00\ubc88\ud638" }, "description": "\uc8c4\uc1a1\ud569\ub2c8\ub2e4. \uad00\ub828 \ub0b4\uc6a9\uc774 \uc544\uc9c1 \uc5c5\ub370\uc774\ud2b8 \ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ucd94\ud6c4\uc5d0 \ubc18\uc601\ub420 \uc608\uc815\uc774\ub2c8 \uc870\uae08\ub9cc \uae30\ub2e4\ub824\uc8fc\uc138\uc694.", - "title": "Google Hangouts \ub85c\uadf8\uc778" + "title": "\uad6c\uae00 \ud589\uc544\uc6c3 \ub85c\uadf8\uc778" } }, - "title": "Google Hangouts" + "title": "\uad6c\uae00 \ud589\uc544\uc6c3" } } \ No newline at end of file diff --git a/homeassistant/components/hangouts/hangouts_bot.py b/homeassistant/components/hangouts/hangouts_bot.py index 35f866b3d813aa..9fc3e2fa58e8fb 100644 --- a/homeassistant/components/hangouts/hangouts_bot.py +++ b/homeassistant/components/hangouts/hangouts_bot.py @@ -293,7 +293,7 @@ async def _async_send_message(self, message, targets, data): if self.hass.config.is_allowed_path(uri): try: image_file = open(uri, "rb") - except IOError as error: + except OSError as error: _LOGGER.error( "Image file I/O error(%s): %s", error.errno, error.strerror ) diff --git a/homeassistant/components/heos/.translations/ca.json b/homeassistant/components/heos/.translations/ca.json index 05d95116b10f6b..60bd780547c8cc 100644 --- a/homeassistant/components/heos/.translations/ca.json +++ b/homeassistant/components/heos/.translations/ca.json @@ -4,7 +4,7 @@ "already_setup": "Nom\u00e9s pots configurar una \u00fanica connexi\u00f3 de Heos tot i que aquesta ja pot controlar tots els dispositius de la xarxa." }, "error": { - "connection_failure": "No es pot connectar amb l'amfitri\u00f3 especificat." + "connection_failure": "No s'ha pogut connectar amb l'amfitri\u00f3 especificat." }, "step": { "user": { diff --git a/homeassistant/components/heos/.translations/it.json b/homeassistant/components/heos/.translations/it.json index 20a4060add4ba5..824f7c3fb50d83 100644 --- a/homeassistant/components/heos/.translations/it.json +++ b/homeassistant/components/heos/.translations/it.json @@ -16,6 +16,6 @@ "title": "Connetti a Heos" } }, - "title": "Heos" + "title": "HEOS" } } \ No newline at end of file diff --git a/homeassistant/components/heos/.translations/lb.json b/homeassistant/components/heos/.translations/lb.json index 416f0878de46a3..cfe1d347b0cc20 100644 --- a/homeassistant/components/heos/.translations/lb.json +++ b/homeassistant/components/heos/.translations/lb.json @@ -16,6 +16,6 @@ "title": "Mat Heos verbannen" } }, - "title": "Heos" + "title": "HEOS" } } \ No newline at end of file diff --git a/homeassistant/components/here_travel_time/__init__.py b/homeassistant/components/here_travel_time/__init__.py new file mode 100755 index 00000000000000..9a5c8ec32aca43 --- /dev/null +++ b/homeassistant/components/here_travel_time/__init__.py @@ -0,0 +1 @@ +"""The here_travel_time component.""" diff --git a/homeassistant/components/here_travel_time/manifest.json b/homeassistant/components/here_travel_time/manifest.json new file mode 100755 index 00000000000000..e26e2e1d6ea57c --- /dev/null +++ b/homeassistant/components/here_travel_time/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "here_travel_time", + "name": "HERE travel time", + "documentation": "https://www.home-assistant.io/components/here_travel_time", + "requirements": [ + "herepy==0.6.3.1" + ], + "dependencies": [], + "codeowners": [ + "@eifinger" + ] + } diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py new file mode 100755 index 00000000000000..ba4908fe85c3ac --- /dev/null +++ b/homeassistant/components/here_travel_time/sensor.py @@ -0,0 +1,431 @@ +"""Support for HERE travel time sensors.""" +from datetime import timedelta +import logging +from typing import Callable, Dict, Optional, Union + +import herepy +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_LATITUDE, + ATTR_LONGITUDE, + CONF_MODE, + CONF_NAME, + CONF_UNIT_SYSTEM, + CONF_UNIT_SYSTEM_IMPERIAL, + CONF_UNIT_SYSTEM_METRIC, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import location +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +CONF_DESTINATION_LATITUDE = "destination_latitude" +CONF_DESTINATION_LONGITUDE = "destination_longitude" +CONF_DESTINATION_ENTITY_ID = "destination_entity_id" +CONF_ORIGIN_LATITUDE = "origin_latitude" +CONF_ORIGIN_LONGITUDE = "origin_longitude" +CONF_ORIGIN_ENTITY_ID = "origin_entity_id" +CONF_APP_ID = "app_id" +CONF_APP_CODE = "app_code" +CONF_TRAFFIC_MODE = "traffic_mode" +CONF_ROUTE_MODE = "route_mode" + +DEFAULT_NAME = "HERE Travel Time" + +TRAVEL_MODE_BICYCLE = "bicycle" +TRAVEL_MODE_CAR = "car" +TRAVEL_MODE_PEDESTRIAN = "pedestrian" +TRAVEL_MODE_PUBLIC = "publicTransport" +TRAVEL_MODE_PUBLIC_TIME_TABLE = "publicTransportTimeTable" +TRAVEL_MODE_TRUCK = "truck" +TRAVEL_MODE = [ + TRAVEL_MODE_BICYCLE, + TRAVEL_MODE_CAR, + TRAVEL_MODE_PEDESTRIAN, + TRAVEL_MODE_PUBLIC, + TRAVEL_MODE_PUBLIC_TIME_TABLE, + TRAVEL_MODE_TRUCK, +] + +TRAVEL_MODES_PUBLIC = [TRAVEL_MODE_PUBLIC, TRAVEL_MODE_PUBLIC_TIME_TABLE] +TRAVEL_MODES_VEHICLE = [TRAVEL_MODE_CAR, TRAVEL_MODE_TRUCK] +TRAVEL_MODES_NON_VEHICLE = [TRAVEL_MODE_BICYCLE, TRAVEL_MODE_PEDESTRIAN] + +TRAFFIC_MODE_ENABLED = "traffic_enabled" +TRAFFIC_MODE_DISABLED = "traffic_disabled" + +ROUTE_MODE_FASTEST = "fastest" +ROUTE_MODE_SHORTEST = "shortest" +ROUTE_MODE = [ROUTE_MODE_FASTEST, ROUTE_MODE_SHORTEST] + +ICON_BICYCLE = "mdi:bike" +ICON_CAR = "mdi:car" +ICON_PEDESTRIAN = "mdi:walk" +ICON_PUBLIC = "mdi:bus" +ICON_TRUCK = "mdi:truck" + +UNITS = [CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL] + +ATTR_DURATION = "duration" +ATTR_DISTANCE = "distance" +ATTR_ROUTE = "route" +ATTR_ORIGIN = "origin" +ATTR_DESTINATION = "destination" + +ATTR_MODE = "mode" +ATTR_UNIT_SYSTEM = CONF_UNIT_SYSTEM +ATTR_TRAFFIC_MODE = CONF_TRAFFIC_MODE + +ATTR_DURATION_IN_TRAFFIC = "duration_in_traffic" +ATTR_ORIGIN_NAME = "origin_name" +ATTR_DESTINATION_NAME = "destination_name" + +UNIT_OF_MEASUREMENT = "min" + +SCAN_INTERVAL = timedelta(minutes=5) + +TRACKABLE_DOMAINS = ["device_tracker", "sensor", "zone", "person"] + +NO_ROUTE_ERROR_MESSAGE = "HERE could not find a route based on the input" + +COORDINATE_SCHEMA = vol.Schema( + { + vol.Inclusive(CONF_DESTINATION_LATITUDE, "coordinates"): cv.latitude, + vol.Inclusive(CONF_DESTINATION_LONGITUDE, "coordinates"): cv.longitude, + } +) + +PLATFORM_SCHEMA = vol.All( + cv.has_at_least_one_key(CONF_DESTINATION_LATITUDE, CONF_DESTINATION_ENTITY_ID), + cv.has_at_least_one_key(CONF_ORIGIN_LATITUDE, CONF_ORIGIN_ENTITY_ID), + PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_APP_ID): cv.string, + vol.Required(CONF_APP_CODE): cv.string, + vol.Inclusive( + CONF_DESTINATION_LATITUDE, "destination_coordinates" + ): cv.latitude, + vol.Inclusive( + CONF_DESTINATION_LONGITUDE, "destination_coordinates" + ): cv.longitude, + vol.Exclusive(CONF_DESTINATION_LATITUDE, "destination"): cv.latitude, + vol.Exclusive(CONF_DESTINATION_ENTITY_ID, "destination"): cv.entity_id, + vol.Inclusive(CONF_ORIGIN_LATITUDE, "origin_coordinates"): cv.latitude, + vol.Inclusive(CONF_ORIGIN_LONGITUDE, "origin_coordinates"): cv.longitude, + vol.Exclusive(CONF_ORIGIN_LATITUDE, "origin"): cv.latitude, + vol.Exclusive(CONF_ORIGIN_ENTITY_ID, "origin"): cv.entity_id, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_MODE, default=TRAVEL_MODE_CAR): vol.In(TRAVEL_MODE), + vol.Optional(CONF_ROUTE_MODE, default=ROUTE_MODE_FASTEST): vol.In( + ROUTE_MODE + ), + vol.Optional(CONF_TRAFFIC_MODE, default=False): cv.boolean, + vol.Optional(CONF_UNIT_SYSTEM): vol.In(UNITS), + } + ), +) + + +async def async_setup_platform( + hass: HomeAssistant, + config: Dict[str, Union[str, bool]], + async_add_entities: Callable, + discovery_info: None = None, +) -> None: + """Set up the HERE travel time platform.""" + + app_id = config[CONF_APP_ID] + app_code = config[CONF_APP_CODE] + here_client = herepy.RoutingApi(app_id, app_code) + + if not await hass.async_add_executor_job( + _are_valid_client_credentials, here_client + ): + _LOGGER.error( + "Invalid credentials. This error is returned if the specified token was invalid or no contract could be found for this token." + ) + return + + if config.get(CONF_ORIGIN_LATITUDE) is not None: + origin = f"{config[CONF_ORIGIN_LATITUDE]},{config[CONF_ORIGIN_LONGITUDE]}" + else: + origin = config[CONF_ORIGIN_ENTITY_ID] + + if config.get(CONF_DESTINATION_LATITUDE) is not None: + destination = ( + f"{config[CONF_DESTINATION_LATITUDE]},{config[CONF_DESTINATION_LONGITUDE]}" + ) + else: + destination = config[CONF_DESTINATION_ENTITY_ID] + + travel_mode = config[CONF_MODE] + traffic_mode = config[CONF_TRAFFIC_MODE] + route_mode = config[CONF_ROUTE_MODE] + name = config[CONF_NAME] + units = config.get(CONF_UNIT_SYSTEM, hass.config.units.name) + + here_data = HERETravelTimeData( + here_client, travel_mode, traffic_mode, route_mode, units + ) + + sensor = HERETravelTimeSensor(name, origin, destination, here_data) + + async_add_entities([sensor], True) + + +def _are_valid_client_credentials(here_client: herepy.RoutingApi) -> bool: + """Check if the provided credentials are correct using defaults.""" + known_working_origin = [38.9, -77.04833] + known_working_destination = [39.0, -77.1] + try: + here_client.car_route( + known_working_origin, + known_working_destination, + [ + herepy.RouteMode[ROUTE_MODE_FASTEST], + herepy.RouteMode[TRAVEL_MODE_CAR], + herepy.RouteMode[TRAFFIC_MODE_DISABLED], + ], + ) + except herepy.InvalidCredentialsError: + return False + return True + + +class HERETravelTimeSensor(Entity): + """Representation of a HERE travel time sensor.""" + + def __init__( + self, name: str, origin: str, destination: str, here_data: "HERETravelTimeData" + ) -> None: + """Initialize the sensor.""" + self._name = name + self._here_data = here_data + self._unit_of_measurement = UNIT_OF_MEASUREMENT + self._origin_entity_id = None + self._destination_entity_id = None + self._attrs = { + ATTR_UNIT_SYSTEM: self._here_data.units, + ATTR_MODE: self._here_data.travel_mode, + ATTR_TRAFFIC_MODE: self._here_data.traffic_mode, + } + + # Check if location is a trackable entity + if origin.split(".", 1)[0] in TRACKABLE_DOMAINS: + self._origin_entity_id = origin + else: + self._here_data.origin = origin + + if destination.split(".", 1)[0] in TRACKABLE_DOMAINS: + self._destination_entity_id = destination + else: + self._here_data.destination = destination + + @property + def state(self) -> Optional[str]: + """Return the state of the sensor.""" + if self._here_data.traffic_mode: + if self._here_data.traffic_time is not None: + return str(round(self._here_data.traffic_time / 60)) + if self._here_data.base_time is not None: + return str(round(self._here_data.base_time / 60)) + + return None + + @property + def name(self) -> str: + """Get the name of the sensor.""" + return self._name + + @property + def device_state_attributes( + self + ) -> Optional[Dict[str, Union[None, float, str, bool]]]: + """Return the state attributes.""" + if self._here_data.base_time is None: + return None + + res = self._attrs + if self._here_data.attribution is not None: + res[ATTR_ATTRIBUTION] = self._here_data.attribution + res[ATTR_DURATION] = self._here_data.base_time / 60 + res[ATTR_DISTANCE] = self._here_data.distance + res[ATTR_ROUTE] = self._here_data.route + res[ATTR_DURATION_IN_TRAFFIC] = self._here_data.traffic_time / 60 + res[ATTR_ORIGIN] = self._here_data.origin + res[ATTR_DESTINATION] = self._here_data.destination + res[ATTR_ORIGIN_NAME] = self._here_data.origin_name + res[ATTR_DESTINATION_NAME] = self._here_data.destination_name + return res + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + @property + def icon(self) -> str: + """Icon to use in the frontend depending on travel_mode.""" + if self._here_data.travel_mode == TRAVEL_MODE_BICYCLE: + return ICON_BICYCLE + if self._here_data.travel_mode == TRAVEL_MODE_PEDESTRIAN: + return ICON_PEDESTRIAN + if self._here_data.travel_mode in TRAVEL_MODES_PUBLIC: + return ICON_PUBLIC + if self._here_data.travel_mode == TRAVEL_MODE_TRUCK: + return ICON_TRUCK + return ICON_CAR + + async def async_update(self) -> None: + """Update Sensor Information.""" + # Convert device_trackers to HERE friendly location + if self._origin_entity_id is not None: + self._here_data.origin = await self._get_location_from_entity( + self._origin_entity_id + ) + + if self._destination_entity_id is not None: + self._here_data.destination = await self._get_location_from_entity( + self._destination_entity_id + ) + + await self.hass.async_add_executor_job(self._here_data.update) + + async def _get_location_from_entity(self, entity_id: str) -> Optional[str]: + """Get the location from the entity state or attributes.""" + entity = self.hass.states.get(entity_id) + + if entity is None: + _LOGGER.error("Unable to find entity %s", entity_id) + return None + + # Check if the entity has location attributes + if location.has_location(entity): + return self._get_location_from_attributes(entity) + + # Check if device is in a zone + zone_entity = self.hass.states.get("zone.{}".format(entity.state)) + if location.has_location(zone_entity): + _LOGGER.debug( + "%s is in %s, getting zone location", entity_id, zone_entity.entity_id + ) + return self._get_location_from_attributes(zone_entity) + + # If zone was not found in state then use the state as the location + if entity_id.startswith("sensor."): + return entity.state + + @staticmethod + def _get_location_from_attributes(entity: State) -> str: + """Get the lat/long string from an entities attributes.""" + attr = entity.attributes + return "{},{}".format(attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)) + + +class HERETravelTimeData: + """HERETravelTime data object.""" + + def __init__( + self, + here_client: herepy.RoutingApi, + travel_mode: str, + traffic_mode: bool, + route_mode: str, + units: str, + ) -> None: + """Initialize herepy.""" + self.origin = None + self.destination = None + self.travel_mode = travel_mode + self.traffic_mode = traffic_mode + self.route_mode = route_mode + self.attribution = None + self.traffic_time = None + self.distance = None + self.route = None + self.base_time = None + self.origin_name = None + self.destination_name = None + self.units = units + self._client = here_client + + def update(self) -> None: + """Get the latest data from HERE.""" + if self.traffic_mode: + traffic_mode = TRAFFIC_MODE_ENABLED + else: + traffic_mode = TRAFFIC_MODE_DISABLED + + if self.destination is not None and self.origin is not None: + # Convert location to HERE friendly location + destination = self.destination.split(",") + origin = self.origin.split(",") + + _LOGGER.debug( + "Requesting route for origin: %s, destination: %s, route_mode: %s, mode: %s, traffic_mode: %s", + origin, + destination, + herepy.RouteMode[self.route_mode], + herepy.RouteMode[self.travel_mode], + herepy.RouteMode[traffic_mode], + ) + try: + response = self._client.car_route( + origin, + destination, + [ + herepy.RouteMode[self.route_mode], + herepy.RouteMode[self.travel_mode], + herepy.RouteMode[traffic_mode], + ], + ) + except herepy.NoRouteFoundError: + # Better error message for cryptic no route error codes + _LOGGER.error(NO_ROUTE_ERROR_MESSAGE) + return + + _LOGGER.debug("Raw response is: %s", response.response) + + # pylint: disable=no-member + source_attribution = response.response.get("sourceAttribution") + if source_attribution is not None: + self.attribution = self._build_hass_attribution(source_attribution) + # pylint: disable=no-member + route = response.response["route"] + summary = route[0]["summary"] + waypoint = route[0]["waypoint"] + self.base_time = summary["baseTime"] + if self.travel_mode in TRAVEL_MODES_VEHICLE: + self.traffic_time = summary["trafficTime"] + else: + self.traffic_time = self.base_time + distance = summary["distance"] + if self.units == CONF_UNIT_SYSTEM_IMPERIAL: + # Convert to miles. + self.distance = distance / 1609.344 + else: + # Convert to kilometers + self.distance = distance / 1000 + # pylint: disable=no-member + self.route = response.route_short + self.origin_name = waypoint[0]["mappedRoadName"] + self.destination_name = waypoint[1]["mappedRoadName"] + + @staticmethod + def _build_hass_attribution(source_attribution: Dict) -> Optional[str]: + """Build a hass frontend ready string out of the sourceAttribution.""" + suppliers = source_attribution.get("supplier") + if suppliers is not None: + supplier_titles = [] + for supplier in suppliers: + title = supplier.get("title") + if title is not None: + supplier_titles.append(title) + joined_supplier_titles = ",".join(supplier_titles) + attribution = f"With the support of {joined_supplier_titles}. All information is provided without warranty of any kind." + return attribution diff --git a/homeassistant/components/homekit/manifest.json b/homeassistant/components/homekit/manifest.json index ea3e801ac536f4..ebb0895bd7a100 100644 --- a/homeassistant/components/homekit/manifest.json +++ b/homeassistant/components/homekit/manifest.json @@ -3,7 +3,7 @@ "name": "Homekit", "documentation": "https://www.home-assistant.io/components/homekit", "requirements": [ - "HAP-python==2.5.0" + "HAP-python==2.6.0" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/homekit_controller/.translations/es.json b/homeassistant/components/homekit_controller/.translations/es.json index 642e76fd1dd0d3..67f6daa8469d8e 100644 --- a/homeassistant/components/homekit_controller/.translations/es.json +++ b/homeassistant/components/homekit_controller/.translations/es.json @@ -3,6 +3,7 @@ "abort": { "accessory_not_found_error": "No se puede a\u00f1adir el emparejamiento porque ya no se puede encontrar el dispositivo.", "already_configured": "El accesorio ya est\u00e1 configurado con este controlador.", + "already_in_progress": "El flujo de configuraci\u00f3n del dispositivo ya est\u00e1 en curso.", "already_paired": "Este accesorio ya est\u00e1 emparejado con otro dispositivo. Por favor, reinicia el accesorio e int\u00e9ntalo de nuevo.", "ignored_model": "El soporte de HomeKit para este modelo est\u00e1 bloqueado ya que est\u00e1 disponible una integraci\u00f3n nativa m\u00e1s completa.", "invalid_config_entry": "Este dispositivo se muestra como listo para vincular, pero ya existe una entrada que causa conflicto en Home Assistant y se debe eliminar primero.", @@ -23,7 +24,7 @@ "data": { "pairing_code": "C\u00f3digo de vinculaci\u00f3n" }, - "description": "Introduce tu c\u00f3digo de vinculaci\u00f3n de HomeKit para usar este accesorio", + "description": "Introduce tu c\u00f3digo de vinculaci\u00f3n de HomeKit (en este formato XXX-XX-XXX) para usar este accesorio", "title": "Vincular con accesorio HomeKit" }, "user": { diff --git a/homeassistant/components/homekit_controller/.translations/fr.json b/homeassistant/components/homekit_controller/.translations/fr.json index 15e50a4012701c..7f0566ddd42365 100644 --- a/homeassistant/components/homekit_controller/.translations/fr.json +++ b/homeassistant/components/homekit_controller/.translations/fr.json @@ -24,7 +24,7 @@ "data": { "pairing_code": "Code d\u2019appairage" }, - "description": "Entrez votre code de jumelage HomeKit pour utiliser cet accessoire.", + "description": "Entrez votre code de jumelage HomeKit (au format XXX-XX-XXX) pour utiliser cet accessoire.", "title": "Appairer avec l'accessoire HomeKit" }, "user": { diff --git a/homeassistant/components/homekit_controller/.translations/it.json b/homeassistant/components/homekit_controller/.translations/it.json index a1d460d12dcfa8..7ed026a529c2fd 100644 --- a/homeassistant/components/homekit_controller/.translations/it.json +++ b/homeassistant/components/homekit_controller/.translations/it.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "accessory_not_found_error": "Impossibile aggiungere l'abbinamento in quanto non \u00e8 pi\u00f9 possibile trovare il dispositivo.", "already_configured": "L'accessorio \u00e8 gi\u00e0 configurato con questo controller.", "already_in_progress": "Il flusso di configurazione per il dispositivo \u00e8 gi\u00e0 in corso.", "already_paired": "Questo accessorio \u00e8 gi\u00e0 associato a un altro dispositivo. Si prega di resettare l'accessorio e riprovare.", @@ -10,6 +11,10 @@ }, "error": { "authentication_error": "Codice HomeKit errato. Per favore, controllate e riprovate.", + "busy_error": "Il dispositivo ha rifiutato di aggiungere l'abbinamento in quanto \u00e8 gi\u00e0 associato a un altro controller.", + "max_peers_error": "Il dispositivo ha rifiutato di aggiungere l'abbinamento in quanto non dispone di una memoria libera per esso.", + "max_tries_error": "Il dispositivo ha rifiutato di aggiungere l'abbinamento poich\u00e9 ha ricevuto pi\u00f9 di 100 tentativi di autenticazione non riusciti.", + "pairing_failed": "Si \u00e8 verificato un errore non gestito durante il tentativo di abbinamento con questo dispositivo. Potrebbe trattarsi di un errore temporaneo o il dispositivo potrebbe non essere attualmente supportato.", "unable_to_pair": "Impossibile abbinare, per favore riprova.", "unknown_error": "Il dispositivo ha riportato un errore sconosciuto. L'abbinamento non \u00e8 riuscito." }, @@ -19,7 +24,7 @@ "data": { "pairing_code": "Codice di abbinamento" }, - "description": "Inserisci il codice di abbinamento HomeKit per usare questo accessorio", + "description": "Immettere il codice di abbinamento HomeKit (nel formato XXX-XX-XXX) per utilizzare questo accessorio", "title": "Abbina con accessorio HomeKit" }, "user": { diff --git a/homeassistant/components/homekit_controller/.translations/lb.json b/homeassistant/components/homekit_controller/.translations/lb.json index 97efd428a0469e..ca7bce44508243 100644 --- a/homeassistant/components/homekit_controller/.translations/lb.json +++ b/homeassistant/components/homekit_controller/.translations/lb.json @@ -24,7 +24,7 @@ "data": { "pairing_code": "Pairing Code" }, - "description": "Gitt \u00e4ren HomeKit pairing Code an fir d\u00ebsen Accessoire ze benotzen", + "description": "Gitt \u00e4ren HomeKit pairing Code (am Format XXX-XX-XXX) an fir d\u00ebsen Accessoire ze benotzen", "title": "Mam HomeKit Accessoire verbannen" }, "user": { diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hans.json b/homeassistant/components/homekit_controller/.translations/zh-Hans.json index 8d064622f7e468..d9fdc8f91c2e1f 100644 --- a/homeassistant/components/homekit_controller/.translations/zh-Hans.json +++ b/homeassistant/components/homekit_controller/.translations/zh-Hans.json @@ -23,7 +23,7 @@ "data": { "pairing_code": "\u914d\u5bf9\u4ee3\u7801" }, - "description": "\u8f93\u5165\u60a8\u7684 HomeKit \u914d\u5bf9\u4ee3\u7801\u4ee5\u4f7f\u7528\u6b64\u914d\u4ef6", + "description": "\u8f93\u5165\u60a8\u7684HomeKit\u914d\u5bf9\u4ee3\u7801\uff08\u683c\u5f0f\u4e3aXXX-XX-XXX\uff09\u4ee5\u4f7f\u7528\u6b64\u914d\u4ef6", "title": "\u4e0e HomeKit \u914d\u4ef6\u914d\u5bf9" }, "user": { diff --git a/homeassistant/components/homematicip_cloud/.translations/it.json b/homeassistant/components/homematicip_cloud/.translations/it.json index 6e6d7c8a59fe06..c7f1af21f2270a 100644 --- a/homeassistant/components/homematicip_cloud/.translations/it.json +++ b/homeassistant/components/homematicip_cloud/.translations/it.json @@ -15,7 +15,7 @@ "init": { "data": { "hapid": "ID del punto di accesso (SGTIN)", - "name": "Nome (facoltativo, utilizzato come prefisso del nome per tutti i dispositivi)", + "name": "Nome (opzionale, usato come prefisso del nome per tutti i dispositivi)", "pin": "Codice Pin (opzionale)" }, "title": "Scegli punto di accesso HomematicIP" diff --git a/homeassistant/components/homematicip_cloud/.translations/lb.json b/homeassistant/components/homematicip_cloud/.translations/lb.json index 2cad909a7ee54e..f8ae990d36442a 100644 --- a/homeassistant/components/homematicip_cloud/.translations/lb.json +++ b/homeassistant/components/homematicip_cloud/.translations/lb.json @@ -21,7 +21,7 @@ "title": "HomematicIP Accesspoint auswielen" }, "link": { - "description": "Dr\u00e9ckt de bloen Kn\u00e4ppchen um Accesspoint an den Submit Kn\u00e4ppchen fir d'HomematicIP mam Home Assistant ze registr\u00e9ieren.", + "description": "Dr\u00e9ckt de bloen Kn\u00e4ppchen um Accesspoint an den Submit Kn\u00e4ppchen fir d'HomematicIP mam Home Assistant ze registr\u00e9ieren.\n\n![Standuert vum Kn\u00e4ppchen op der Bridge](/static/images/config_flows/config_homematicip_cloud.png)", "title": "Accesspoint verbannen" } }, diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index d6bc24d21edf18..4ac4614379b905 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -38,19 +38,30 @@ from homeassistant.core import HomeAssistant from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice -from .device import ATTR_GROUP_MEMBER_UNREACHABLE, ATTR_MODEL_TYPE +from .device import ATTR_GROUP_MEMBER_UNREACHABLE, ATTR_IS_GROUP, ATTR_MODEL_TYPE _LOGGER = logging.getLogger(__name__) ATTR_LOW_BATTERY = "low_battery" -ATTR_MOTIONDETECTED = "motion detected" -ATTR_PRESENCEDETECTED = "presence detected" -ATTR_POWERMAINSFAILURE = "power mains failure" -ATTR_WINDOWSTATE = "window state" -ATTR_MOISTUREDETECTED = "moisture detected" -ATTR_WATERLEVELDETECTED = "water level detected" -ATTR_SMOKEDETECTORALARM = "smoke detector alarm" +ATTR_MOISTURE_DETECTED = "moisture_detected" +ATTR_MOTION_DETECTED = "motion_detected" +ATTR_POWER_MAINS_FAILURE = "power_mains_failure" +ATTR_PRESENCE_DETECTED = "presence_detected" +ATTR_SMOKE_DETECTOR_ALARM = "smoke_detector_alarm" ATTR_TODAY_SUNSHINE_DURATION = "today_sunshine_duration_in_minutes" +ATTR_WATER_LEVEL_DETECTED = "water_level_detected" +ATTR_WINDOW_STATE = "window_state" + +GROUP_ATTRIBUTES = { + "lowBat": ATTR_LOW_BATTERY, + "modelType": ATTR_MODEL_TYPE, + "moistureDetected": ATTR_MOISTURE_DETECTED, + "motionDetected": ATTR_MOTION_DETECTED, + "powerMainsFailure": ATTR_POWER_MAINS_FAILURE, + "presenceDetected": ATTR_PRESENCE_DETECTED, + "unreach": ATTR_GROUP_MEMBER_UNREACHABLE, + "waterlevelDetected": ATTR_WATER_LEVEL_DETECTED, +} async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): @@ -118,8 +129,6 @@ def device_class(self) -> str: @property def is_on(self) -> bool: """Return true if the contact interface is on/open.""" - if hasattr(self._device, "sabotage") and self._device.sabotage: - return True if self._device.windowState is None: return None return self._device.windowState != WindowState.CLOSED @@ -136,8 +145,6 @@ def device_class(self) -> str: @property def is_on(self) -> bool: """Return true if the shutter contact is on/open.""" - if hasattr(self._device, "sabotage") and self._device.sabotage: - return True if self._device.windowState is None: return None return self._device.windowState != WindowState.CLOSED @@ -154,8 +161,6 @@ def device_class(self) -> str: @property def is_on(self) -> bool: """Return true if motion is detected.""" - if hasattr(self._device, "sabotage") and self._device.sabotage: - return True return self._device.motionDetected @@ -170,8 +175,6 @@ def device_class(self) -> str: @property def is_on(self) -> bool: """Return true if presence is detected.""" - if hasattr(self._device, "sabotage") and self._device.sabotage: - return True return self._device.presenceDetected @@ -259,13 +262,13 @@ def is_on(self) -> bool: @property def device_state_attributes(self): """Return the state attributes of the illuminance sensor.""" - attr = super().device_state_attributes - if ( - hasattr(self._device, "todaySunshineDuration") - and self._device.todaySunshineDuration - ): - attr[ATTR_TODAY_SUNSHINE_DURATION] = self._device.todaySunshineDuration - return attr + state_attr = super().device_state_attributes + + today_sunshine_duration = getattr(self._device, "todaySunshineDuration", None) + if today_sunshine_duration: + state_attr[ATTR_TODAY_SUNSHINE_DURATION] = today_sunshine_duration + + return state_attr class HomematicipBatterySensor(HomematicipGenericDevice, BinarySensorDevice): @@ -309,21 +312,18 @@ def available(self) -> bool: @property def device_state_attributes(self): """Return the state attributes of the security zone group.""" - attr = {ATTR_MODEL_TYPE: self._device.modelType} + state_attr = {ATTR_MODEL_TYPE: self._device.modelType, ATTR_IS_GROUP: True} - if self._device.motionDetected: - attr[ATTR_MOTIONDETECTED] = True - if self._device.presenceDetected: - attr[ATTR_PRESENCEDETECTED] = True + for attr, attr_key in GROUP_ATTRIBUTES.items(): + attr_value = getattr(self._device, attr, None) + if attr_value: + state_attr[attr_key] = attr_value - if ( - self._device.windowState is not None - and self._device.windowState != WindowState.CLOSED - ): - attr[ATTR_WINDOWSTATE] = str(self._device.windowState) - if self._device.unreach: - attr[ATTR_GROUP_MEMBER_UNREACHABLE] = True - return attr + window_state = getattr(self._device, "windowState", None) + if window_state and window_state != WindowState.CLOSED: + state_attr[ATTR_WINDOW_STATE] = str(window_state) + + return state_attr @property def is_on(self) -> bool: @@ -356,23 +356,13 @@ def __init__(self, home: AsyncHome, device) -> None: @property def device_state_attributes(self): """Return the state attributes of the security group.""" - attr = super().device_state_attributes - - if self._device.powerMainsFailure: - attr[ATTR_POWERMAINSFAILURE] = True - if self._device.moistureDetected: - attr[ATTR_MOISTUREDETECTED] = True - if self._device.waterlevelDetected: - attr[ATTR_WATERLEVELDETECTED] = True - if self._device.lowBat: - attr[ATTR_LOW_BATTERY] = True - if ( - self._device.smokeDetectorAlarmType is not None - and self._device.smokeDetectorAlarmType != SmokeDetectorAlarmType.IDLE_OFF - ): - attr[ATTR_SMOKEDETECTORALARM] = str(self._device.smokeDetectorAlarmType) + state_attr = super().device_state_attributes + + smoke_detector_at = getattr(self._device, "smokeDetectorAlarmType", None) + if smoke_detector_at and smoke_detector_at != SmokeDetectorAlarmType.IDLE_OFF: + state_attr[ATTR_SMOKE_DETECTOR_ALARM] = str(smoke_detector_at) - return attr + return state_attr @property def is_on(self) -> bool: diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py index 5eeb14b635946c..05853d4b260bca 100644 --- a/homeassistant/components/homematicip_cloud/device.py +++ b/homeassistant/components/homematicip_cloud/device.py @@ -12,6 +12,7 @@ ATTR_MODEL_TYPE = "model_type" ATTR_ID = "id" +ATTR_IS_GROUP = "is_group" # RSSI HAP -> Device ATTR_RSSI_DEVICE = "rssi_device" # RSSI Device -> HAP @@ -131,4 +132,6 @@ def device_state_attributes(self): if attr_value: state_attr[attr_key] = attr_value + state_attr[ATTR_IS_GROUP] = False + return state_attr diff --git a/homeassistant/components/homematicip_cloud/light.py b/homeassistant/components/homematicip_cloud/light.py index bc7b12f9653ea5..42ff6d30478fb4 100644 --- a/homeassistant/components/homematicip_cloud/light.py +++ b/homeassistant/components/homematicip_cloud/light.py @@ -93,13 +93,15 @@ class HomematicipLightMeasuring(HomematicipLight): @property def device_state_attributes(self): """Return the state attributes of the generic device.""" - attr = super().device_state_attributes - if self._device.currentPowerConsumption > 0.05: - attr[ATTR_POWER_CONSUMPTION] = round( - self._device.currentPowerConsumption, 2 - ) - attr[ATTR_ENERGY_COUNTER] = round(self._device.energyCounter, 2) - return attr + state_attr = super().device_state_attributes + + current_power_consumption = self._device.currentPowerConsumption + if current_power_consumption > 0.05: + state_attr[ATTR_POWER_CONSUMPTION] = round(current_power_consumption, 2) + + state_attr[ATTR_ENERGY_COUNTER] = round(self._device.energyCounter, 2) + + return state_attr class HomematicipDimmer(HomematicipGenericDevice, Light): @@ -187,15 +189,17 @@ def hs_color(self) -> tuple: @property def device_state_attributes(self): """Return the state attributes of the generic device.""" - attr = super().device_state_attributes + state_attr = super().device_state_attributes + if self.is_on: - attr[ATTR_COLOR_NAME] = self._func_channel.simpleRGBColorState - return attr + state_attr[ATTR_COLOR_NAME] = self._func_channel.simpleRGBColorState + + return state_attr @property def name(self) -> str: """Return the name of the generic device.""" - return "{} {}".format(super().name, "Notification") + return f"{super().name} Notification" @property def supported_features(self) -> int: diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index c15b3121d3a63e..770921288b9341 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -10,6 +10,7 @@ AsyncMotionDetectorIndoor, AsyncMotionDetectorOutdoor, AsyncMotionDetectorPushButton, + AsyncPassageDetector, AsyncPlugableSwitchMeasuring, AsyncPresenceDetectorIndoor, AsyncTemperatureHumiditySensorDisplay, @@ -34,10 +35,12 @@ from homeassistant.core import HomeAssistant from . import DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice -from .device import ATTR_MODEL_TYPE +from .device import ATTR_IS_GROUP, ATTR_MODEL_TYPE _LOGGER = logging.getLogger(__name__) +ATTR_LEFT_COUNTER = "left_counter" +ATTR_RIGHT_COUNTER = "right_counter" ATTR_TEMPERATURE_OFFSET = "temperature_offset" ATTR_WIND_DIRECTION = "wind_direction" ATTR_WIND_DIRECTION_VARIATION = "wind_direction_variation_in_degree" @@ -100,6 +103,8 @@ async def async_setup_entry( devices.append(HomematicipWindspeedSensor(home, device)) if isinstance(device, (AsyncWeatherSensorPlus, AsyncWeatherSensorPro)): devices.append(HomematicipTodayRainSensor(home, device)) + if isinstance(device, AsyncPassageDetector): + devices.append(HomematicipPassageDetectorDeltaCounter(home, device)) if devices: async_add_entities(devices) @@ -145,8 +150,8 @@ def unit_of_measurement(self) -> str: @property def device_state_attributes(self): - """Return the state attributes of the security zone group.""" - return {ATTR_MODEL_TYPE: self._device.modelType} + """Return the state attributes of the access point.""" + return {ATTR_MODEL_TYPE: self._device.modelType, ATTR_IS_GROUP: False} class HomematicipHeatingThermostat(HomematicipGenericDevice): @@ -229,13 +234,13 @@ def unit_of_measurement(self) -> str: @property def device_state_attributes(self): """Return the state attributes of the windspeed sensor.""" - attr = super().device_state_attributes - if ( - hasattr(self._device, "temperatureOffset") - and self._device.temperatureOffset - ): - attr[ATTR_TEMPERATURE_OFFSET] = self._device.temperatureOffset - return attr + state_attr = super().device_state_attributes + + temperature_offset = getattr(self._device, "temperatureOffset", None) + if temperature_offset: + state_attr[ATTR_TEMPERATURE_OFFSET] = temperature_offset + + return state_attr class HomematicipIlluminanceSensor(HomematicipGenericDevice): @@ -307,15 +312,17 @@ def unit_of_measurement(self) -> str: @property def device_state_attributes(self): """Return the state attributes of the wind speed sensor.""" - attr = super().device_state_attributes - if hasattr(self._device, "windDirection") and self._device.windDirection: - attr[ATTR_WIND_DIRECTION] = _get_wind_direction(self._device.windDirection) - if ( - hasattr(self._device, "windDirectionVariation") - and self._device.windDirectionVariation - ): - attr[ATTR_WIND_DIRECTION_VARIATION] = self._device.windDirectionVariation - return attr + state_attr = super().device_state_attributes + + wind_direction = getattr(self._device, "windDirection", None) + if wind_direction: + state_attr[ATTR_WIND_DIRECTION] = _get_wind_direction(wind_direction) + + wind_direction_variation = getattr(self._device, "windDirectionVariation", None) + if wind_direction_variation: + state_attr[ATTR_WIND_DIRECTION_VARIATION] = wind_direction_variation + + return state_attr class HomematicipTodayRainSensor(HomematicipGenericDevice): @@ -336,6 +343,29 @@ def unit_of_measurement(self) -> str: return "mm" +class HomematicipPassageDetectorDeltaCounter(HomematicipGenericDevice): + """Representation of a HomematicIP passage detector delta counter.""" + + def __init__(self, home: AsyncHome, device) -> None: + """Initialize the device.""" + super().__init__(home, device) + + @property + def state(self) -> int: + """Representation of the HomematicIP passage detector delta counter value.""" + return self._device.leftRightCounterDelta + + @property + def device_state_attributes(self): + """Return the state attributes of the delta counter.""" + state_attr = super().device_state_attributes + + state_attr[ATTR_LEFT_COUNTER] = self._device.leftCounter + state_attr[ATTR_RIGHT_COUNTER] = self._device.rightCounter + + return state_attr + + def _get_wind_direction(wind_direction_degree: float) -> str: """Convert wind direction degree to named direction.""" if 11.25 <= wind_direction_degree < 33.75: diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index 6d19087781daef..ababf793f0ce6e 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -19,7 +19,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_IS_GROUP _LOGGER = logging.getLogger(__name__) @@ -113,10 +113,10 @@ def available(self) -> bool: @property def device_state_attributes(self): """Return the state attributes of the switch-group.""" - attr = {} + state_attr = {ATTR_IS_GROUP: True} if self._device.unreach: - attr[ATTR_GROUP_MEMBER_UNREACHABLE] = True - return attr + state_attr[ATTR_GROUP_MEMBER_UNREACHABLE] = True + return state_attr async def async_turn_on(self, **kwargs): """Turn the group on.""" diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index 463e1bfb7410f1..ed9098559a344c 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -7,6 +7,7 @@ AsyncWeatherSensorPro, ) from homematicip.aio.home import AsyncHome +from homematicip.base.enums import WeatherCondition from homeassistant.components.weather import WeatherEntity from homeassistant.config_entries import ConfigEntry @@ -17,6 +18,24 @@ _LOGGER = logging.getLogger(__name__) +HOME_WEATHER_CONDITION = { + WeatherCondition.CLEAR: "sunny", + WeatherCondition.LIGHT_CLOUDY: "partlycloudy", + WeatherCondition.CLOUDY: "cloudy", + WeatherCondition.CLOUDY_WITH_RAIN: "rainy", + WeatherCondition.CLOUDY_WITH_SNOW_RAIN: "snowy-rainy", + WeatherCondition.HEAVILY_CLOUDY: "cloudy", + WeatherCondition.HEAVILY_CLOUDY_WITH_RAIN: "rainy", + WeatherCondition.HEAVILY_CLOUDY_WITH_STRONG_RAIN: "snowy-rainy", + WeatherCondition.HEAVILY_CLOUDY_WITH_SNOW: "snowy", + WeatherCondition.HEAVILY_CLOUDY_WITH_SNOW_RAIN: "snowy-rainy", + WeatherCondition.HEAVILY_CLOUDY_WITH_THUNDER: "lightning", + WeatherCondition.HEAVILY_CLOUDY_WITH_RAIN_AND_THUNDER: "lightning-rainy", + WeatherCondition.FOGGY: "fog", + WeatherCondition.STRONG_WIND: "windy", + WeatherCondition.UNKNOWN: "", +} + async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the HomematicIP Cloud weather sensor.""" @@ -35,6 +54,8 @@ async def async_setup_entry( elif isinstance(device, (AsyncWeatherSensor, AsyncWeatherSensorPlus)): devices.append(HomematicipWeatherSensor(home, device)) + devices.append(HomematicipHomeWeather(home)) + if devices: async_add_entities(devices) @@ -79,7 +100,7 @@ def attribution(self) -> str: @property def condition(self) -> str: """Return the current condition.""" - if hasattr(self._device, "raining") and self._device.raining: + if getattr(self._device, "raining", None): return "rainy" if self._device.storm: return "windy" @@ -95,3 +116,57 @@ class HomematicipWeatherSensorPro(HomematicipWeatherSensor): def wind_bearing(self) -> float: """Return the wind bearing.""" return self._device.windDirection + + +class HomematicipHomeWeather(HomematicipGenericDevice, WeatherEntity): + """representation of a HomematicIP Cloud home weather.""" + + def __init__(self, home: AsyncHome) -> None: + """Initialize the home weather.""" + home.weather.modelType = "HmIP-Home-Weather" + super().__init__(home, home) + + @property + def available(self) -> bool: + """Device available.""" + return self._home.connected + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return f"Weather {self._home.location.city}" + + @property + def temperature(self) -> float: + """Return the platform temperature.""" + return self._device.weather.temperature + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def humidity(self) -> int: + """Return the humidity.""" + return self._device.weather.humidity + + @property + def wind_speed(self) -> float: + """Return the wind speed.""" + return round(self._device.weather.windSpeed, 1) + + @property + def wind_bearing(self) -> float: + """Return the wind bearing.""" + return self._device.weather.windDirection + + @property + def attribution(self) -> str: + """Return the attribution.""" + return "Powered by Homematic IP" + + @property + def condition(self) -> str: + """Return the current condition.""" + return HOME_WEATHER_CONDITION.get(self._device.weather.weatherCondition) diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index d8fa8853c7f18c..7d1e24f369800f 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -18,7 +18,7 @@ from .const import KEY_REAL_IP -# mypy: allow-incomplete-defs, allow-untyped-defs, no-check-untyped-defs +# mypy: allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) @@ -165,7 +165,7 @@ def __init__(self, ip_ban: str, banned_at: Optional[datetime] = None) -> None: self.banned_at = banned_at or datetime.utcnow() -async def async_load_ip_bans_config(hass: HomeAssistant, path: str): +async def async_load_ip_bans_config(hass: HomeAssistant, path: str) -> List[IpBan]: """Load list of banned IPs from config file.""" ip_list: List[IpBan] = [] @@ -188,7 +188,7 @@ async def async_load_ip_bans_config(hass: HomeAssistant, path: str): return ip_list -def update_ip_bans_config(path: str, ip_ban: IpBan): +def update_ip_bans_config(path: str, ip_ban: IpBan) -> None: """Update config file with new banned IP address.""" with open(path, "a") as out: ip_ = { diff --git a/homeassistant/components/hue/.translations/es.json b/homeassistant/components/hue/.translations/es.json index 56e7ed62e9dc8a..3ec9ed871d3dca 100644 --- a/homeassistant/components/hue/.translations/es.json +++ b/homeassistant/components/hue/.translations/es.json @@ -3,9 +3,11 @@ "abort": { "all_configured": "Todos los puentes Philips Hue ya est\u00e1n configurados", "already_configured": "El puente ya esta configurado", + "already_in_progress": "El flujo de configuraci\u00f3n para el puente ya est\u00e1 en curso.", "cannot_connect": "No se puede conectar al puente", "discover_timeout": "No se han descubierto puentes Philips Hue", "no_bridges": "No se han descubierto puentes Philips Hue.", + "not_hue_bridge": "No es un puente Hue", "unknown": "Se produjo un error desconocido" }, "error": { diff --git a/homeassistant/components/hue/.translations/it.json b/homeassistant/components/hue/.translations/it.json index 72b2fd6445bf37..5dd64364c1080f 100644 --- a/homeassistant/components/hue/.translations/it.json +++ b/homeassistant/components/hue/.translations/it.json @@ -1,11 +1,13 @@ { "config": { "abort": { - "all_configured": "Tutti i bridge Philips Hue sono gi\u00e0 configurati", - "already_configured": "Il Bridge \u00e8 gi\u00e0 configurato", + "all_configured": "Tutti i bridge di Philips Hue sono gi\u00e0 configurati", + "already_configured": "Il bridge \u00e8 gi\u00e0 configurato", + "already_in_progress": "Il flusso di configurazione per bridge \u00e8 gi\u00e0 in corso.", "cannot_connect": "Impossibile connettersi al bridge", "discover_timeout": "Impossibile trovare i bridge Hue", - "no_bridges": "Nessun bridge Hue di Philips trovato", + "no_bridges": "Nessun bridge di Philips Hue trovato", + "not_hue_bridge": "Non \u00e8 un bridge Hue", "unknown": "Si \u00e8 verificato un errore" }, "error": { @@ -24,6 +26,6 @@ "title": "Collega Hub" } }, - "title": "Philips Hue Bridge" + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/hydroquebec/sensor.py b/homeassistant/components/hydroquebec/sensor.py index fd713e8b7a7937..c3ad79c1c9866e 100644 --- a/homeassistant/components/hydroquebec/sensor.py +++ b/homeassistant/components/hydroquebec/sensor.py @@ -28,9 +28,9 @@ _LOGGER = logging.getLogger(__name__) KILOWATT_HOUR = ENERGY_KILO_WATT_HOUR -PRICE = "CAD" # type: str -DAYS = "days" # type: str -CONF_CONTRACT = "contract" # type: str +PRICE = "CAD" +DAYS = "days" +CONF_CONTRACT = "contract" DEFAULT_NAME = "HydroQuebec" diff --git a/homeassistant/components/iaqualink/.translations/bg.json b/homeassistant/components/iaqualink/.translations/bg.json new file mode 100644 index 00000000000000..5b37bde3ee3a81 --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "\u041c\u043e\u0436\u0435 \u0434\u0430 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u0435 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u0430 \u0432\u0440\u044a\u0437\u043a\u0430 \u0441 iAqualink." + }, + "error": { + "connection_failure": "\u041d\u0435\u0443\u0441\u043f\u0435\u0448\u043d\u043e \u0441\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 iAqualink. \u041f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e\u0442\u043e \u0438\u043c\u0435 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430\u0442\u0430." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "\u041f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e \u0438\u043c\u0435 / \u0438\u043c\u0435\u0439\u043b \u0430\u0434\u0440\u0435\u0441" + }, + "description": "\u041c\u043e\u043b\u044f \u0432\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u043f\u043e\u0442\u0440\u0435\u0431\u0438\u0442\u0435\u043b\u0441\u043a\u043e\u0442\u043e \u0438\u043c\u0435 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430\u0442\u0430 \u043d\u0430 \u0432\u0430\u0448\u0438\u044f iAqualink \u0430\u043a\u0430\u0443\u043d\u0442.", + "title": "\u0421\u0432\u044a\u0440\u0437\u0432\u0430\u043d\u0435 \u0441 iAqualink" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/ca.json b/homeassistant/components/iaqualink/.translations/ca.json new file mode 100644 index 00000000000000..a5456c7b0cd0a9 --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Nom\u00e9s pots configurar una \u00fanica connexi\u00f3 d'iAqualink." + }, + "error": { + "connection_failure": "No s'ha pogut connectar amb iAqualink. Comprova el nom d'usuari i la contrasenya." + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Nom d'usuari / Correu electr\u00f2nic" + }, + "description": "Introdueix el nom d'usuari i la contrasenya del teu compte d'iAqualink.", + "title": "Connexi\u00f3 amb iAqualink" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/da.json b/homeassistant/components/iaqualink/.translations/da.json new file mode 100644 index 00000000000000..a1e1c20cbc5e5f --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/da.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan kun konfigurere en enkelt iAqualink-forbindelse." + }, + "error": { + "connection_failure": "Kan ikke oprette forbindelse til iAqualink. Kontroller dit brugernavn og din adgangskode." + }, + "step": { + "user": { + "data": { + "password": "Adgangskode", + "username": "Brugernavn / e-mail-adresse" + }, + "description": "Indtast brugernavn og adgangskode til din iAqualink-konto.", + "title": "Opret forbindelse til iAqualink" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/en.json b/homeassistant/components/iaqualink/.translations/en.json new file mode 100644 index 00000000000000..4972c3d3ff7d06 --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure a single iAqualink connection." + }, + "error": { + "connection_failure": "Unable to connect to iAqualink. Check your username and password." + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Username / Email Address" + }, + "description": "Please enter the username and password for your iAqualink account.", + "title": "Connect to iAqualink" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/es.json b/homeassistant/components/iaqualink/.translations/es.json new file mode 100644 index 00000000000000..698be68bd78e9c --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Solo puede configurar una \u00fanica conexi\u00f3n iAqualink." + }, + "error": { + "connection_failure": "No se puede conectar a iAqualink. Verifica tu nombre de usuario y contrase\u00f1a." + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Usuario / correo electr\u00f3nico" + }, + "description": "Por favor, introduzca el nombre de usuario y la contrase\u00f1a de su cuenta de iAqualink.", + "title": "Con\u00e9ctese a iAqualink" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/fr.json b/homeassistant/components/iaqualink/.translations/fr.json new file mode 100644 index 00000000000000..97971b99e9f7ab --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Vous ne pouvez configurer qu'une seule connexion iAqualink." + }, + "error": { + "connection_failure": "Impossible de se connecter \u00e0 iAqualink. V\u00e9rifiez votre nom d'utilisateur et votre mot de passe." + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "username": "Nom d'utilisateur / adresse e-mail" + }, + "description": "Veuillez saisir le nom d'utilisateur et le mot de passe de votre compte iAqualink.", + "title": "Se connecter \u00e0 iAqualink" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/it.json b/homeassistant/components/iaqualink/.translations/it.json new file mode 100644 index 00000000000000..73d840bdbd376c --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "\u00c8 possibile configurare una sola connessione iAqualink." + }, + "error": { + "connection_failure": "Impossibile connettersi a iAqualink. Controllare il nome utente e la password." + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome Utente / Indirizzo E-mail" + }, + "description": "Inserisci il nome utente e la password del tuo account iAqualink.", + "title": "Collegati a iAqualink" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/ko.json b/homeassistant/components/iaqualink/.translations/ko.json new file mode 100644 index 00000000000000..9b2519077e26ce --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "\ud558\ub098\uc758 iAqualink \uc5f0\uacb0\ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "error": { + "connection_failure": "iAqualink \uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc0ac\uc6a9\uc790 \uc774\ub984\uacfc \ube44\ubc00\ubc88\ud638\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694." + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc0ac\uc6a9\uc790 \uc774\ub984 / \uc774\uba54\uc77c \uc8fc\uc18c" + }, + "description": "iAqualink \uacc4\uc815\uc758 \uc0ac\uc6a9\uc790 \uc774\ub984\uacfc \ube44\ubc00\ubc88\ud638\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "title": "iAqualink \uc5f0\uacb0" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/lb.json b/homeassistant/components/iaqualink/.translations/lb.json new file mode 100644 index 00000000000000..4beb11214bc2c8 --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/lb.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Dir k\u00ebnnt n\u00ebmmen eng eenzeg iAqualink Verbindung konfigur\u00e9ieren." + }, + "error": { + "connection_failure": "Kann sech net mat iAqualink verbannen. Iwwerpr\u00e9ift \u00e4ren Benotzernumm an Passwuert" + }, + "step": { + "user": { + "data": { + "password": "Passwuert", + "username": "Benotzernumm / E-Mail Adresse" + }, + "description": "Gitt den Benotznumm an d'Passwuert fir \u00e4ren iAqualink Kont un.", + "title": "Mat iAqualink verbannen" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/nl.json b/homeassistant/components/iaqualink/.translations/nl.json new file mode 100644 index 00000000000000..c0a515bb741e00 --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "U kunt slechts \u00e9\u00e9n iAqualink-verbinding configureren." + }, + "error": { + "connection_failure": "Kan geen verbinding maken met iAqualink. Controleer je gebruikersnaam en wachtwoord." + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "Gebruikersnaam/E-mailadres" + }, + "description": "Voer de gebruikersnaam en het wachtwoord voor uw iAqualink-account in.", + "title": "Verbinding maken met iAqualink" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/no.json b/homeassistant/components/iaqualink/.translations/no.json new file mode 100644 index 00000000000000..9d464a6d516c55 --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan bare konfigurere en enkel iAqualink-tilkobling." + }, + "error": { + "connection_failure": "Kan ikke koble til iAqualink. Sjekk brukernavnet og passordet ditt." + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "Brukernavn / E-postadresse" + }, + "description": "Vennligst skriv inn brukernavn og passord for iAqualink-kontoen din.", + "title": "Koble til iAqualink" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/pl.json b/homeassistant/components/iaqualink/.translations/pl.json new file mode 100644 index 00000000000000..211a65f5ccb4fb --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno po\u0142\u0105czenie iAqualink." + }, + "error": { + "connection_failure": "Nie mo\u017cna po\u0142\u0105czy\u0107 z iAqualink. Sprawd\u017a nazw\u0119 u\u017cytkownika i has\u0142o." + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "username": "Nazwa u\u017cytkownika / adres e-mail" + }, + "description": "Wprowad\u017a nazw\u0119 u\u017cytkownika i has\u0142o do konta iAqualink.", + "title": "Po\u0142\u0105cz z iAqualink" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/ru.json b/homeassistant/components/iaqualink/.translations/ru.json new file mode 100644 index 00000000000000..35444dd422b379 --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "error": { + "connection_failure": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a iAqualink. \u041f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0412\u0430\u0448 \u043b\u043e\u0433\u0438\u043d \u0438 \u043f\u0430\u0440\u043e\u043b\u044c." + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u041b\u043e\u0433\u0438\u043d / \u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043b\u043e\u0433\u0438\u043d \u0438 \u043f\u0430\u0440\u043e\u043b\u044c \u0434\u043b\u044f \u0412\u0430\u0448\u0435\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 iAqualink.", + "title": "Jandy iAqualink" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/sl.json b/homeassistant/components/iaqualink/.translations/sl.json new file mode 100644 index 00000000000000..e2a7f94b3d8a70 --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/sl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "Konfigurirate lahko samo eno povezavo iAqualink." + }, + "error": { + "connection_failure": "Ne morete vzpostaviti povezave z iAqualink. Preverite va\u0161e uporabni\u0161ko ime in geslo." + }, + "step": { + "user": { + "data": { + "password": "Geslo", + "username": "Uporabni\u0161ko ime / e-po\u0161tni naslov" + }, + "description": "Prosimo, vnesite uporabni\u0161ko ime in geslo za iAqualink ra\u010dun.", + "title": "Pove\u017eite se z iAqualink" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/.translations/zh-Hant.json b/homeassistant/components/iaqualink/.translations/zh-Hant.json new file mode 100644 index 00000000000000..146088b4eff9c0 --- /dev/null +++ b/homeassistant/components/iaqualink/.translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 iAqualink \u9023\u7dda\u3002" + }, + "error": { + "connection_failure": "\u7121\u6cd5\u9023\u7dda\u81f3 iAqualink\uff0c\u8acb\u78ba\u8a8d\u60a8\u7684\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc\u3002" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u4f7f\u7528\u8005\u540d\u7a31 / \u96fb\u5b50\u90f5\u4ef6" + }, + "description": "\u8acb\u8f38\u5165 iAqualink \u5e33\u865f\u4f7f\u7528\u8005\u540d\u7a31\u8207\u5bc6\u78bc\u3002", + "title": "\u9023\u7dda\u81f3 iAqualink" + } + }, + "title": "Jandy iAqualink" + } +} \ No newline at end of file diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py new file mode 100644 index 00000000000000..dec91186be2869 --- /dev/null +++ b/homeassistant/components/iaqualink/__init__.py @@ -0,0 +1,212 @@ +"""Component to embed Aqualink devices.""" +import asyncio +from functools import wraps +import logging + +from aiohttp import CookieJar +import voluptuous as vol + +from iaqualink import ( + AqualinkBinarySensor, + AqualinkClient, + AqualinkDevice, + AqualinkLight, + AqualinkLoginException, + AqualinkSensor, + AqualinkThermostat, + AqualinkToggle, +) + +from homeassistant import config_entries +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.climate import DOMAIN as CLIMATE_DOMAIN +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import DOMAIN, UPDATE_INTERVAL + + +_LOGGER = logging.getLogger(__name__) + +ATTR_CONFIG = "config" +PARALLEL_UPDATES = 0 + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> None: + """Set up the Aqualink component.""" + conf = config.get(DOMAIN) + + hass.data[DOMAIN] = {} + + if conf is not None: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, data=conf + ) + ) + + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> None: + """Set up Aqualink from a config entry.""" + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + + # These will contain the initialized devices + binary_sensors = hass.data[DOMAIN][BINARY_SENSOR_DOMAIN] = [] + climates = hass.data[DOMAIN][CLIMATE_DOMAIN] = [] + lights = hass.data[DOMAIN][LIGHT_DOMAIN] = [] + sensors = hass.data[DOMAIN][SENSOR_DOMAIN] = [] + switches = hass.data[DOMAIN][SWITCH_DOMAIN] = [] + + session = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True)) + aqualink = AqualinkClient(username, password, session) + try: + await aqualink.login() + except AqualinkLoginException as login_exception: + _LOGGER.error("Exception raised while attempting to login: %s", login_exception) + return False + + systems = await aqualink.get_systems() + systems = list(systems.values()) + if not systems: + _LOGGER.error("No systems detected or supported") + return False + + # Only supporting the first system for now. + devices = await systems[0].get_devices() + + for dev in devices.values(): + if isinstance(dev, AqualinkThermostat): + climates += [dev] + elif isinstance(dev, AqualinkLight): + lights += [dev] + elif isinstance(dev, AqualinkBinarySensor): + binary_sensors += [dev] + elif isinstance(dev, AqualinkSensor): + sensors += [dev] + elif isinstance(dev, AqualinkToggle): + switches += [dev] + + forward_setup = hass.config_entries.async_forward_entry_setup + if binary_sensors: + _LOGGER.debug("Got %s binary sensors: %s", len(binary_sensors), binary_sensors) + hass.async_create_task(forward_setup(entry, BINARY_SENSOR_DOMAIN)) + if climates: + _LOGGER.debug("Got %s climates: %s", len(climates), climates) + hass.async_create_task(forward_setup(entry, CLIMATE_DOMAIN)) + if lights: + _LOGGER.debug("Got %s lights: %s", len(lights), lights) + hass.async_create_task(forward_setup(entry, LIGHT_DOMAIN)) + if sensors: + _LOGGER.debug("Got %s sensors: %s", len(sensors), sensors) + hass.async_create_task(forward_setup(entry, SENSOR_DOMAIN)) + if switches: + _LOGGER.debug("Got %s switches: %s", len(switches), switches) + hass.async_create_task(forward_setup(entry, SWITCH_DOMAIN)) + + async def _async_systems_update(now): + """Refresh internal state for all systems.""" + await systems[0].update() + async_dispatcher_send(hass, DOMAIN) + + async_track_time_interval(hass, _async_systems_update, UPDATE_INTERVAL) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + forward_unload = hass.config_entries.async_forward_entry_unload + + tasks = [] + + if hass.data[DOMAIN][BINARY_SENSOR_DOMAIN]: + tasks += [forward_unload(entry, BINARY_SENSOR_DOMAIN)] + if hass.data[DOMAIN][CLIMATE_DOMAIN]: + tasks += [forward_unload(entry, CLIMATE_DOMAIN)] + if hass.data[DOMAIN][LIGHT_DOMAIN]: + tasks += [forward_unload(entry, LIGHT_DOMAIN)] + if hass.data[DOMAIN][SENSOR_DOMAIN]: + tasks += [forward_unload(entry, SENSOR_DOMAIN)] + if hass.data[DOMAIN][SWITCH_DOMAIN]: + tasks += [forward_unload(entry, SWITCH_DOMAIN)] + + hass.data[DOMAIN].clear() + + return all(await asyncio.gather(*tasks)) + + +def refresh_system(func): + """Force update all entities after state change.""" + + @wraps(func) + async def wrapper(self, *args, **kwargs): + """Call decorated function and send update signal to all entities.""" + await func(self, *args, **kwargs) + async_dispatcher_send(self.hass, DOMAIN) + + return wrapper + + +class AqualinkEntity(Entity): + """Abstract class for all Aqualink platforms. + + Entity state is updated via the interval timer within the integration. + Any entity state change via the iaqualink library triggers an internal + state refresh which is then propagated to all the entities in the system + via the refresh_system decorator above to the _update_callback in this + class. + """ + + def __init__(self, dev: AqualinkDevice): + """Initialize the entity.""" + self.dev = dev + + async def async_added_to_hass(self) -> None: + """Set up a listener when this entity is added to HA.""" + async_dispatcher_connect(self.hass, DOMAIN, self._update_callback) + + @callback + def _update_callback(self) -> None: + self.async_schedule_update_ha_state() + + @property + def should_poll(self) -> bool: + """Return False as entities shouldn't be polled. + + Entities are checked periodically as the integration runs periodic + updates on a timer. + """ + return False + + @property + def unique_id(self) -> str: + """Return a unique identifier for this entity.""" + return f"{self.dev.system.serial}_{self.dev.name}" diff --git a/homeassistant/components/iaqualink/binary_sensor.py b/homeassistant/components/iaqualink/binary_sensor.py new file mode 100644 index 00000000000000..09c9322a58764b --- /dev/null +++ b/homeassistant/components/iaqualink/binary_sensor.py @@ -0,0 +1,48 @@ +"""Support for Aqualink temperature sensors.""" +import logging + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, + DEVICE_CLASS_COLD, + DOMAIN, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import AqualinkEntity +from .const import DOMAIN as AQUALINK_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up discovered binary sensors.""" + devs = [] + for dev in hass.data[AQUALINK_DOMAIN][DOMAIN]: + devs.append(HassAqualinkBinarySensor(dev)) + async_add_entities(devs, True) + + +class HassAqualinkBinarySensor(AqualinkEntity, BinarySensorDevice): + """Representation of a binary sensor.""" + + @property + def name(self) -> str: + """Return the name of the binary sensor.""" + return self.dev.label + + @property + def is_on(self) -> bool: + """Return whether the binary sensor is on or not.""" + return self.dev.is_on + + @property + def device_class(self) -> str: + """Return the class of the binary sensor.""" + if self.name == "Freeze Protection": + return DEVICE_CLASS_COLD + return None diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py new file mode 100644 index 00000000000000..f41d17837c2f6c --- /dev/null +++ b/homeassistant/components/iaqualink/climate.py @@ -0,0 +1,132 @@ +"""Support for Aqualink Thermostats.""" +import logging +from typing import List, Optional + +from iaqualink import AqualinkHeater, AqualinkPump, AqualinkSensor, AqualinkState +from iaqualink.const import ( + AQUALINK_TEMP_CELSIUS_HIGH, + AQUALINK_TEMP_CELSIUS_LOW, + AQUALINK_TEMP_FAHRENHEIT_HIGH, + AQUALINK_TEMP_FAHRENHEIT_LOW, +) + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + DOMAIN, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.helpers.typing import HomeAssistantType + +from . import AqualinkEntity, refresh_system +from .const import DOMAIN as AQUALINK_DOMAIN, CLIMATE_SUPPORTED_MODES + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up discovered switches.""" + devs = [] + for dev in hass.data[AQUALINK_DOMAIN][DOMAIN]: + devs.append(HassAqualinkThermostat(dev)) + async_add_entities(devs, True) + + +class HassAqualinkThermostat(AqualinkEntity, ClimateDevice): + """Representation of a thermostat.""" + + @property + def name(self) -> str: + """Return the name of the thermostat.""" + return self.dev.label.split(" ")[0] + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + + @property + def hvac_modes(self) -> List[str]: + """Return the list of supported HVAC modes.""" + return CLIMATE_SUPPORTED_MODES + + @property + def pump(self) -> AqualinkPump: + """Return the pump device for the current thermostat.""" + pump = f"{self.name.lower()}_pump" + return self.dev.system.devices[pump] + + @property + def hvac_mode(self) -> str: + """Return the current HVAC mode.""" + state = AqualinkState(self.heater.state) + if state == AqualinkState.ON: + return HVAC_MODE_HEAT + return HVAC_MODE_OFF + + @refresh_system + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Turn the underlying heater switch on or off.""" + if hvac_mode == HVAC_MODE_HEAT: + await self.heater.turn_on() + elif hvac_mode == HVAC_MODE_OFF: + await self.heater.turn_off() + else: + _LOGGER.warning("Unknown operation mode: %s", hvac_mode) + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement.""" + if self.dev.system.temp_unit == "F": + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def min_temp(self) -> int: + """Return the minimum temperature supported by the thermostat.""" + if self.temperature_unit == TEMP_FAHRENHEIT: + return AQUALINK_TEMP_FAHRENHEIT_LOW + return AQUALINK_TEMP_CELSIUS_LOW + + @property + def max_temp(self) -> int: + """Return the minimum temperature supported by the thermostat.""" + if self.temperature_unit == TEMP_FAHRENHEIT: + return AQUALINK_TEMP_FAHRENHEIT_HIGH + return AQUALINK_TEMP_CELSIUS_HIGH + + @property + def target_temperature(self) -> float: + """Return the current target temperature.""" + return float(self.dev.state) + + @refresh_system + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + await self.dev.set_temperature(int(kwargs[ATTR_TEMPERATURE])) + + @property + def sensor(self) -> AqualinkSensor: + """Return the sensor device for the current thermostat.""" + sensor = f"{self.name.lower()}_temp" + return self.dev.system.devices[sensor] + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + if self.sensor.state != "": + return float(self.sensor.state) + return None + + @property + def heater(self) -> AqualinkHeater: + """Return the heater device for the current thermostat.""" + heater = f"{self.name.lower()}_heater" + return self.dev.system.devices[heater] diff --git a/homeassistant/components/iaqualink/config_flow.py b/homeassistant/components/iaqualink/config_flow.py new file mode 100644 index 00000000000000..ec83477d253a04 --- /dev/null +++ b/homeassistant/components/iaqualink/config_flow.py @@ -0,0 +1,52 @@ +"""Config flow to configure zone component.""" +from typing import Optional + +import voluptuous as vol + +from iaqualink import AqualinkClient, AqualinkLoginException + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers import ConfigType + +from .const import DOMAIN + + +@config_entries.HANDLERS.register(DOMAIN) +class AqualinkFlowHandler(config_entries.ConfigFlow): + """Aqualink config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input: Optional[ConfigType] = None): + """Handle a flow start.""" + # Supporting a single account. + entries = self.hass.config_entries.async_entries(DOMAIN) + if entries: + return self.async_abort(reason="already_setup") + + errors = {} + + if user_input is not None: + username = user_input[CONF_USERNAME] + password = user_input[CONF_PASSWORD] + + try: + aqualink = AqualinkClient(username, password) + await aqualink.login() + return self.async_create_entry(title=username, data=user_input) + except AqualinkLoginException: + errors["base"] = "connection_failure" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors, + ) + + async def async_step_import(self, user_input: Optional[ConfigType] = None): + """Occurs when an entry is setup through config.""" + return await self.async_step_user(user_input) diff --git a/homeassistant/components/iaqualink/const.py b/homeassistant/components/iaqualink/const.py new file mode 100644 index 00000000000000..219eb9129944f2 --- /dev/null +++ b/homeassistant/components/iaqualink/const.py @@ -0,0 +1,8 @@ +"""Constants for the the iaqualink component.""" +from datetime import timedelta + +from homeassistant.components.climate.const import HVAC_MODE_HEAT, HVAC_MODE_OFF + +DOMAIN = "iaqualink" +CLIMATE_SUPPORTED_MODES = [HVAC_MODE_HEAT, HVAC_MODE_OFF] +UPDATE_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py new file mode 100644 index 00000000000000..813af7863f1700 --- /dev/null +++ b/homeassistant/components/iaqualink/light.py @@ -0,0 +1,101 @@ +"""Support for Aqualink pool lights.""" +import logging + +from iaqualink import AqualinkLightEffect + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_EFFECT, + DOMAIN, + SUPPORT_BRIGHTNESS, + SUPPORT_EFFECT, + Light, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import AqualinkEntity, refresh_system +from .const import DOMAIN as AQUALINK_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up discovered lights.""" + devs = [] + for dev in hass.data[AQUALINK_DOMAIN][DOMAIN]: + devs.append(HassAqualinkLight(dev)) + async_add_entities(devs, True) + + +class HassAqualinkLight(AqualinkEntity, Light): + """Representation of a light.""" + + @property + def name(self) -> str: + """Return the name of the light.""" + return self.dev.label + + @property + def is_on(self) -> bool: + """Return whether the light is on or off.""" + return self.dev.is_on + + @refresh_system + async def async_turn_on(self, **kwargs) -> None: + """Turn on the light. + + This handles brightness and light effects for lights that do support + them. + """ + brightness = kwargs.get(ATTR_BRIGHTNESS) + effect = kwargs.get(ATTR_EFFECT) + + # For now I'm assuming lights support either effects or brightness. + if effect: + effect = AqualinkLightEffect[effect].value + await self.dev.set_effect(effect) + elif brightness: + # Aqualink supports percentages in 25% increments. + pct = int(round(brightness * 4.0 / 255)) * 25 + await self.dev.set_brightness(pct) + else: + await self.dev.turn_on() + + @refresh_system + async def async_turn_off(self, **kwargs) -> None: + """Turn off the light.""" + await self.dev.turn_off() + + @property + def brightness(self) -> int: + """Return current brightness of the light. + + The scale needs converting between 0-100 and 0-255. + """ + return self.dev.brightness * 255 / 100 + + @property + def effect(self) -> str: + """Return the current light effect if supported.""" + return AqualinkLightEffect(self.dev.effect).name + + @property + def effect_list(self) -> list: + """Return supported light effects.""" + return list(AqualinkLightEffect.__members__) + + @property + def supported_features(self) -> int: + """Return the list of features supported by the light.""" + if self.dev.is_dimmer: + return SUPPORT_BRIGHTNESS + + if self.dev.is_color: + return SUPPORT_EFFECT + + return 0 diff --git a/homeassistant/components/iaqualink/manifest.json b/homeassistant/components/iaqualink/manifest.json new file mode 100644 index 00000000000000..25e02536897915 --- /dev/null +++ b/homeassistant/components/iaqualink/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "iaqualink", + "name": "Jandy iAqualink", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/iaqualink/", + "dependencies": [], + "codeowners": [ + "@flz" + ], + "requirements": [ + "iaqualink==0.2.9" + ] +} diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py new file mode 100644 index 00000000000000..81021d0b4471d4 --- /dev/null +++ b/homeassistant/components/iaqualink/sensor.py @@ -0,0 +1,53 @@ +"""Support for Aqualink temperature sensors.""" +import logging +from typing import Optional + +from homeassistant.components.sensor import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.helpers.typing import HomeAssistantType + +from . import AqualinkEntity +from .const import DOMAIN as AQUALINK_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up discovered sensors.""" + devs = [] + for dev in hass.data[AQUALINK_DOMAIN][DOMAIN]: + devs.append(HassAqualinkSensor(dev)) + async_add_entities(devs, True) + + +class HassAqualinkSensor(AqualinkEntity): + """Representation of a sensor.""" + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self.dev.label + + @property + def unit_of_measurement(self) -> str: + """Return the measurement unit for the sensor.""" + if self.dev.system.temp_unit == "F": + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def state(self) -> str: + """Return the state of the sensor.""" + return int(self.dev.state) if self.dev.state != "" else None + + @property + def device_class(self) -> Optional[str]: + """Return the class of the sensor.""" + if self.dev.name.endswith("_temp"): + return DEVICE_CLASS_TEMPERATURE + return None diff --git a/homeassistant/components/iaqualink/strings.json b/homeassistant/components/iaqualink/strings.json new file mode 100644 index 00000000000000..4c706522198c9f --- /dev/null +++ b/homeassistant/components/iaqualink/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "Jandy iAqualink", + "step": { + "user": { + "title": "Connect to iAqualink", + "description": "Please enter the username and password for your iAqualink account.", + "data": { + "username": "Username / Email Address", + "password": "Password" + } + } + }, + "error": { + "connection_failure": "Unable to connect to iAqualink. Check your username and password." + }, + "abort": { + "already_setup": "You can only configure a single iAqualink connection." + } + } +} diff --git a/homeassistant/components/iaqualink/switch.py b/homeassistant/components/iaqualink/switch.py new file mode 100644 index 00000000000000..8efb473cf54d35 --- /dev/null +++ b/homeassistant/components/iaqualink/switch.py @@ -0,0 +1,59 @@ +"""Support for Aqualink pool feature switches.""" +import logging + +from homeassistant.components.switch import DOMAIN, SwitchDevice +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType + +from . import AqualinkEntity, refresh_system +from .const import DOMAIN as AQUALINK_DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistantType, config_entry: ConfigEntry, async_add_entities +) -> None: + """Set up discovered switches.""" + devs = [] + for dev in hass.data[AQUALINK_DOMAIN][DOMAIN]: + devs.append(HassAqualinkSwitch(dev)) + async_add_entities(devs, True) + + +class HassAqualinkSwitch(AqualinkEntity, SwitchDevice): + """Representation of a switch.""" + + @property + def name(self) -> str: + """Return the name of the switch.""" + return self.dev.label + + @property + def icon(self) -> str: + """Return an icon based on the switch type.""" + if self.name == "Cleaner": + return "mdi:robot-vacuum" + if self.name == "Waterfall" or self.name.endswith("Dscnt"): + return "mdi:fountain" + if self.name.endswith("Pump") or self.name.endswith("Blower"): + return "mdi:fan" + if self.name.endswith("Heater"): + return "mdi:radiator" + + @property + def is_on(self) -> bool: + """Return whether the switch is on or not.""" + return self.dev.is_on + + @refresh_system + async def async_turn_on(self, **kwargs) -> None: + """Turn on the switch.""" + await self.dev.turn_on() + + @refresh_system + async def async_turn_off(self, **kwargs) -> None: + """Turn off the switch.""" + await self.dev.turn_off() diff --git a/homeassistant/components/ifttt/.translations/it.json b/homeassistant/components/ifttt/.translations/it.json index e5dc76b7923cb5..d6faf60d618ed7 100644 --- a/homeassistant/components/ifttt/.translations/it.json +++ b/homeassistant/components/ifttt/.translations/it.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "not_internet_accessible": "Home Assistant deve essere accessibile da internet per ricevere messaggi IFTTT", + "not_internet_accessible": "La tua istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi IFTTT.", "one_instance_allowed": "\u00c8 necessaria una sola istanza." }, "create_entry": { diff --git a/homeassistant/components/influxdb/manifest.json b/homeassistant/components/influxdb/manifest.json index 20652ddd046375..feda5da732cab7 100644 --- a/homeassistant/components/influxdb/manifest.json +++ b/homeassistant/components/influxdb/manifest.json @@ -3,10 +3,10 @@ "name": "Influxdb", "documentation": "https://www.home-assistant.io/components/influxdb", "requirements": [ - "influxdb==5.2.0" + "influxdb==5.2.3" ], "dependencies": [], "codeowners": [ "@fabaff" ] -} +} \ No newline at end of file diff --git a/homeassistant/components/ios/notify.py b/homeassistant/components/ios/notify.py index 83e4c089b9a541..ee74b369629b75 100644 --- a/homeassistant/components/ios/notify.py +++ b/homeassistant/components/ios/notify.py @@ -1,5 +1,4 @@ """Support for iOS push notifications.""" -from datetime import datetime, timezone import logging import requests @@ -25,7 +24,7 @@ def log_rate_limits(hass, target, resp, level=20): """Output rate limit log line at given level.""" rate_limits = resp["rateLimits"] resetsAt = dt_util.parse_datetime(rate_limits["resetsAt"]) - resetsAtTime = resetsAt - datetime.now(timezone.utc) + resetsAtTime = resetsAt - dt_util.utcnow() rate_limit_msg = ( "iOS push notification rate limits for %s: " "%d sent, %d allowed, %d errors, " diff --git a/homeassistant/components/iperf3/manifest.json b/homeassistant/components/iperf3/manifest.json index e35be24fc8089f..0547628b4bfd2c 100644 --- a/homeassistant/components/iperf3/manifest.json +++ b/homeassistant/components/iperf3/manifest.json @@ -3,7 +3,7 @@ "name": "Iperf3", "documentation": "https://www.home-assistant.io/components/iperf3", "requirements": [ - "iperf3==0.1.10" + "iperf3==0.1.11" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/iqvia/.translations/it.json b/homeassistant/components/iqvia/.translations/it.json index 37079cf571dbbf..492654c660c5b0 100644 --- a/homeassistant/components/iqvia/.translations/it.json +++ b/homeassistant/components/iqvia/.translations/it.json @@ -9,6 +9,7 @@ "data": { "zip_code": "CAP" }, + "description": "Compila il tuo CAP americano o canadese.", "title": "IQVIA" } }, diff --git a/homeassistant/components/iqvia/.translations/pl.json b/homeassistant/components/iqvia/.translations/pl.json index 7a6e9a8a915634..b528cdeb70f3b0 100644 --- a/homeassistant/components/iqvia/.translations/pl.json +++ b/homeassistant/components/iqvia/.translations/pl.json @@ -9,7 +9,7 @@ "data": { "zip_code": "Kod pocztowy" }, - "description": "Wprowad\u017a sw\u00f3j ameryka\u0144ski lub kanadyjski kod pocztowy.", + "description": "Wprowad\u017a ameryka\u0144ski lub kanadyjski kod pocztowy.", "title": "IQVIA" } }, diff --git a/homeassistant/components/isy994/__init__.py b/homeassistant/components/isy994/__init__.py index e1d24fa5551041..727ec91dc37cd3 100644 --- a/homeassistant/components/isy994/__init__.py +++ b/homeassistant/components/isy994/__init__.py @@ -459,7 +459,7 @@ class ISYDevice(Entity): """Representation of an ISY994 device.""" _attrs = {} - _name = None # type: str + _name: str = None def __init__(self, node) -> None: """Initialize the insteon device.""" diff --git a/homeassistant/components/izone/.translations/ca.json b/homeassistant/components/izone/.translations/ca.json new file mode 100644 index 00000000000000..b80d9bee4e271a --- /dev/null +++ b/homeassistant/components/izone/.translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No s'han trobat dispositius iZone a la xarxa.", + "single_instance_allowed": "Nom\u00e9s cal una \u00fanica configuraci\u00f3 de iZone." + }, + "step": { + "confirm": { + "description": "Vols configurar iZone?", + "title": "iZone" + } + }, + "title": "iZone" + } +} \ No newline at end of file diff --git a/homeassistant/components/izone/.translations/en.json b/homeassistant/components/izone/.translations/en.json new file mode 100644 index 00000000000000..5293ad2a1fec34 --- /dev/null +++ b/homeassistant/components/izone/.translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No iZone devices found on the network.", + "single_instance_allowed": "Only a single configuration of iZone is necessary." + }, + "step": { + "confirm": { + "description": "Do you want to set up iZone?", + "title": "iZone" + } + }, + "title": "iZone" + } +} \ No newline at end of file diff --git a/homeassistant/components/izone/.translations/fr.json b/homeassistant/components/izone/.translations/fr.json new file mode 100644 index 00000000000000..c90416b0619746 --- /dev/null +++ b/homeassistant/components/izone/.translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Aucun p\u00e9riph\u00e9rique iZone trouv\u00e9 sur le r\u00e9seau.", + "single_instance_allowed": "Une seule configuration d'iZone est n\u00e9cessaire." + }, + "step": { + "confirm": { + "description": "Voulez-vous configurer iZone?", + "title": "iZone" + } + }, + "title": "iZone" + } +} \ No newline at end of file diff --git a/homeassistant/components/izone/.translations/it.json b/homeassistant/components/izone/.translations/it.json new file mode 100644 index 00000000000000..5498624a061ed4 --- /dev/null +++ b/homeassistant/components/izone/.translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo iZone trovato in rete.", + "single_instance_allowed": "\u00c8 necessaria una sola configurazione di iZone." + }, + "step": { + "confirm": { + "description": "Vuoi configurare iZone?", + "title": "iZone" + } + }, + "title": "iZone" + } +} \ No newline at end of file diff --git a/homeassistant/components/izone/.translations/ko.json b/homeassistant/components/izone/.translations/ko.json new file mode 100644 index 00000000000000..69b8ce8a31ea35 --- /dev/null +++ b/homeassistant/components/izone/.translations/ko.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "iZone \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "single_instance_allowed": "\ud558\ub098\uc758 iZone \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "step": { + "confirm": { + "description": "iZone \uc744 \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "iZone" + } + }, + "title": "iZone" + } +} \ No newline at end of file diff --git a/homeassistant/components/izone/.translations/lb.json b/homeassistant/components/izone/.translations/lb.json new file mode 100644 index 00000000000000..c6e075683ad159 --- /dev/null +++ b/homeassistant/components/izone/.translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keng iZone Apparater am Netzwierk fonnt.", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun iZone ass n\u00e9ideg." + }, + "step": { + "confirm": { + "description": "Soll iZone konfigur\u00e9iert ginn?", + "title": "iZone" + } + }, + "title": "iZone" + } +} \ No newline at end of file diff --git a/homeassistant/components/izone/.translations/no.json b/homeassistant/components/izone/.translations/no.json new file mode 100644 index 00000000000000..fcd5c1df019a6b --- /dev/null +++ b/homeassistant/components/izone/.translations/no.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Finner ingen iZone-enheter p\u00e5 nettverket.", + "single_instance_allowed": "Bare en enkelt konfigurasjon av iZone er n\u00f8dvendig." + }, + "step": { + "confirm": { + "description": "Vil du konfigurere iZone?", + "title": "iZone" + } + }, + "title": "iZone" + } +} \ No newline at end of file diff --git a/homeassistant/components/izone/.translations/pl.json b/homeassistant/components/izone/.translations/pl.json new file mode 100644 index 00000000000000..4f90cf71cbcb04 --- /dev/null +++ b/homeassistant/components/izone/.translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 iZone.", + "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja iZone." + }, + "step": { + "confirm": { + "description": "Chcesz skonfigurowa\u0107 iZone?", + "title": "iZone" + } + }, + "title": "iZone" + } +} \ No newline at end of file diff --git a/homeassistant/components/izone/.translations/ru.json b/homeassistant/components/izone/.translations/ru.json new file mode 100644 index 00000000000000..7e632c8dd62119 --- /dev/null +++ b/homeassistant/components/izone/.translations/ru.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 iZone \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b \u0432 \u0441\u0435\u0442\u0438.", + "single_instance_allowed": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430." + }, + "step": { + "confirm": { + "description": "\u0412\u044b \u0443\u0432\u0435\u0440\u0435\u043d\u044b, \u0447\u0442\u043e \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c iZone?", + "title": "iZone" + } + }, + "title": "iZone" + } +} \ No newline at end of file diff --git a/homeassistant/components/izone/.translations/zh-Hant.json b/homeassistant/components/izone/.translations/zh-Hant.json new file mode 100644 index 00000000000000..7448100158e8ee --- /dev/null +++ b/homeassistant/components/izone/.translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 iZone \u8a2d\u5099\u3002", + "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 iZone \u5373\u53ef\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a iZone\uff1f", + "title": "iZone" + } + }, + "title": "iZone" + } +} \ No newline at end of file diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py new file mode 100644 index 00000000000000..7f80fb077cf92c --- /dev/null +++ b/homeassistant/components/izone/__init__.py @@ -0,0 +1,67 @@ +""" +Platform for the iZone AC. + +For more details about this component, please refer to the documentation +https://home-assistant.io/components/izone/ +""" +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EXCLUDE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType, HomeAssistantType + +from .const import IZONE, DATA_CONFIG +from .discovery import async_start_discovery_service, async_stop_discovery_service + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema( + { + IZONE: vol.Schema( + { + vol.Optional(CONF_EXCLUDE, default=[]): vol.All( + cv.ensure_list, [cv.string] + ) + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType): + """Register the iZone component config.""" + conf = config.get(IZONE) + if not conf: + return True + + hass.data[DATA_CONFIG] = conf + + # Explicitly added in the config file, create a config entry. + hass.async_create_task( + hass.config_entries.flow.async_init( + IZONE, context={"source": config_entries.SOURCE_IMPORT} + ) + ) + + return True + + +async def async_setup_entry(hass, entry): + """Set up from a config entry.""" + await async_start_discovery_service(hass) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "climate") + ) + return True + + +async def async_unload_entry(hass, entry): + """Unload the config entry and stop discovery process.""" + await async_stop_discovery_service(hass) + await hass.config_entries.async_forward_entry_unload(entry, "climate") + return True diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py new file mode 100644 index 00000000000000..c932c66627bcae --- /dev/null +++ b/homeassistant/components/izone/climate.py @@ -0,0 +1,546 @@ +"""Support for the iZone HVAC.""" +import logging +from typing import Optional, List + +from pizone import Zone, Controller + +from homeassistant.core import callback +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + HVAC_MODE_HEAT_COOL, + HVAC_MODE_COOL, + HVAC_MODE_DRY, + HVAC_MODE_FAN_ONLY, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + FAN_LOW, + FAN_MEDIUM, + FAN_HIGH, + FAN_AUTO, + PRESET_ECO, + PRESET_NONE, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + PRECISION_HALVES, + TEMP_CELSIUS, + CONF_EXCLUDE, +) +from homeassistant.helpers.temperature import display_temp as show_temp +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + DATA_DISCOVERY_SERVICE, + IZONE, + DISPATCH_CONTROLLER_DISCOVERED, + DISPATCH_CONTROLLER_DISCONNECTED, + DISPATCH_CONTROLLER_RECONNECTED, + DISPATCH_CONTROLLER_UPDATE, + DISPATCH_ZONE_UPDATE, + DATA_CONFIG, +) + +_LOGGER = logging.getLogger(__name__) + +_IZONE_FAN_TO_HA = { + Controller.Fan.LOW: FAN_LOW, + Controller.Fan.MED: FAN_MEDIUM, + Controller.Fan.HIGH: FAN_HIGH, + Controller.Fan.AUTO: FAN_AUTO, +} + + +async def async_setup_entry( + hass: HomeAssistantType, config: ConfigType, async_add_entities +): + """Initialize an IZone Controller.""" + disco = hass.data[DATA_DISCOVERY_SERVICE] + + @callback + def init_controller(ctrl: Controller): + """Register the controller device and the containing zones.""" + conf = hass.data.get(DATA_CONFIG) # type: ConfigType + + # Filter out any entities excluded in the config file + if conf and ctrl.device_uid in conf[CONF_EXCLUDE]: + _LOGGER.info("Controller UID=%s ignored as excluded", ctrl.device_uid) + return + _LOGGER.info("Controller UID=%s discovered", ctrl.device_uid) + + device = ControllerDevice(ctrl) + async_add_entities([device]) + async_add_entities(device.zones.values()) + + # create any components not yet created + for controller in disco.pi_disco.controllers.values(): + init_controller(controller) + + # connect to register any further components + async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, init_controller) + + return True + + +class ControllerDevice(ClimateDevice): + """Representation of iZone Controller.""" + + def __init__(self, controller: Controller) -> None: + """Initialise ControllerDevice.""" + self._controller = controller + + self._supported_features = SUPPORT_FAN_MODE + + if ( + controller.ras_mode == "master" and controller.zone_ctrl == 13 + ) or controller.ras_mode == "RAS": + self._supported_features |= SUPPORT_TARGET_TEMPERATURE + + self._state_to_pizone = { + HVAC_MODE_COOL: Controller.Mode.COOL, + HVAC_MODE_HEAT: Controller.Mode.HEAT, + HVAC_MODE_HEAT_COOL: Controller.Mode.AUTO, + HVAC_MODE_FAN_ONLY: Controller.Mode.VENT, + HVAC_MODE_DRY: Controller.Mode.DRY, + } + if controller.free_air_enabled: + self._supported_features |= SUPPORT_PRESET_MODE + + self._fan_to_pizone = {} + for fan in controller.fan_modes: + self._fan_to_pizone[_IZONE_FAN_TO_HA[fan]] = fan + self._available = True + + self._device_info = { + "identifiers": {(IZONE, self.unique_id)}, + "name": self.name, + "manufacturer": "IZone", + "model": self._controller.sys_type, + } + + # Create the zones + self.zones = {} + for zone in controller.zones: + self.zones[zone] = ZoneDevice(self, zone) + + async def async_added_to_hass(self): + """Call on adding to hass.""" + # Register for connect/disconnect/update events + @callback + def controller_disconnected(ctrl: Controller, ex: Exception) -> None: + """Disconnected from controller.""" + if ctrl is not self._controller: + return + self.set_available(False, ex) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCH_CONTROLLER_DISCONNECTED, controller_disconnected + ) + ) + + @callback + def controller_reconnected(ctrl: Controller) -> None: + """Reconnected to controller.""" + if ctrl is not self._controller: + return + self.set_available(True) + + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCH_CONTROLLER_RECONNECTED, controller_reconnected + ) + ) + + @callback + def controller_update(ctrl: Controller) -> None: + """Handle controller data updates.""" + if ctrl is not self._controller: + return + self.async_schedule_update_ha_state() + + self.async_on_remove( + async_dispatcher_connect( + self.hass, DISPATCH_CONTROLLER_UPDATE, controller_update + ) + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._available + + @callback + def set_available(self, available: bool, ex: Exception = None) -> None: + """ + Set availability for the controller. + + Also sets zone availability as they follow the same availability. + """ + if self.available == available: + return + + if available: + _LOGGER.info("Reconnected controller %s ", self._controller.device_uid) + else: + _LOGGER.info( + "Controller %s disconnected due to exception: %s", + self._controller.device_uid, + ex, + ) + + self._available = available + self.async_schedule_update_ha_state() + for zone in self.zones.values(): + zone.async_schedule_update_ha_state() + + @property + def device_info(self): + """Return the device info for the iZone system.""" + return self._device_info + + @property + def unique_id(self): + """Return the ID of the controller device.""" + return self._controller.device_uid + + @property + def name(self) -> str: + """Return the name of the entity.""" + return f"iZone Controller {self._controller.device_uid}" + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False + + @property + def supported_features(self) -> int: + """Return the list of supported features.""" + return self._supported_features + + @property + def temperature_unit(self) -> str: + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def precision(self) -> float: + """Return the precision of the system.""" + return PRECISION_HALVES + + @property + def device_state_attributes(self): + """Return the optional state attributes.""" + return { + "supply_temperature": show_temp( + self.hass, + self.supply_temperature, + self.temperature_unit, + self.precision, + ), + "temp_setpoint": show_temp( + self.hass, + self._controller.temp_setpoint, + self.temperature_unit, + self.precision, + ), + } + + @property + def hvac_mode(self) -> str: + """Return current operation ie. heat, cool, idle.""" + if not self._controller.is_on: + return HVAC_MODE_OFF + mode = self._controller.mode + for (key, value) in self._state_to_pizone.items(): + if value == mode: + return key + assert False, "Should be unreachable" + + @property + def hvac_modes(self) -> List[str]: + """Return the list of available operation modes.""" + if self._controller.free_air: + return [HVAC_MODE_OFF, HVAC_MODE_FAN_ONLY] + return [HVAC_MODE_OFF, *self._state_to_pizone] + + @property + def preset_mode(self): + """Eco mode is external air.""" + return PRESET_ECO if self._controller.free_air else PRESET_NONE + + @property + def preset_modes(self): + """Available preset modes, normal or eco.""" + if self._controller.free_air_enabled: + return [PRESET_NONE, PRESET_ECO] + return [PRESET_NONE] + + @property + def current_temperature(self) -> Optional[float]: + """Return the current temperature.""" + if self._controller.mode == Controller.Mode.FREE_AIR: + return self._controller.temp_supply + return self._controller.temp_return + + @property + def target_temperature(self) -> Optional[float]: + """Return the temperature we try to reach.""" + if not self._supported_features & SUPPORT_TARGET_TEMPERATURE: + return None + return self._controller.temp_setpoint + + @property + def supply_temperature(self) -> float: + """Return the current supply, or in duct, temperature.""" + return self._controller.temp_supply + + @property + def target_temperature_step(self) -> Optional[float]: + """Return the supported step of target temperature.""" + return 0.5 + + @property + def fan_mode(self) -> Optional[str]: + """Return the fan setting.""" + return _IZONE_FAN_TO_HA[self._controller.fan] + + @property + def fan_modes(self) -> Optional[List[str]]: + """Return the list of available fan modes.""" + return list(self._fan_to_pizone) + + @property + def min_temp(self) -> float: + """Return the minimum temperature.""" + return self._controller.temp_min + + @property + def max_temp(self) -> float: + """Return the maximum temperature.""" + return self._controller.temp_max + + async def wrap_and_catch(self, coro): + """Catch any connection errors and set unavailable.""" + try: + await coro + except ConnectionError as ex: + self.set_available(False, ex) + else: + self.set_available(True) + + async def async_set_temperature(self, **kwargs) -> None: + """Set new target temperature.""" + if not self.supported_features & SUPPORT_TARGET_TEMPERATURE: + self.async_schedule_update_ha_state(True) + return + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is not None: + await self.wrap_and_catch(self._controller.set_temp_setpoint(temp)) + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set new target fan mode.""" + fan = self._fan_to_pizone[fan_mode] + await self.wrap_and_catch(self._controller.set_fan(fan)) + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target operation mode.""" + if hvac_mode == HVAC_MODE_OFF: + await self.wrap_and_catch(self._controller.set_on(False)) + return + if not self._controller.is_on: + await self.wrap_and_catch(self._controller.set_on(True)) + if self._controller.free_air: + return + mode = self._state_to_pizone[hvac_mode] + await self.wrap_and_catch(self._controller.set_mode(mode)) + + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set the preset mode.""" + await self.wrap_and_catch( + self._controller.set_free_air(preset_mode == PRESET_ECO) + ) + + async def async_turn_on(self) -> None: + """Turn the entity on.""" + await self.wrap_and_catch(self._controller.set_on(True)) + + +class ZoneDevice(ClimateDevice): + """Representation of iZone Zone.""" + + def __init__(self, controller: ControllerDevice, zone: Zone) -> None: + """Initialise ZoneDevice.""" + self._controller = controller + self._zone = zone + self._name = zone.name.title() + + self._supported_features = 0 + if zone.type != Zone.Type.AUTO: + self._state_to_pizone = { + HVAC_MODE_OFF: Zone.Mode.CLOSE, + HVAC_MODE_FAN_ONLY: Zone.Mode.OPEN, + } + else: + self._state_to_pizone = { + HVAC_MODE_OFF: Zone.Mode.CLOSE, + HVAC_MODE_FAN_ONLY: Zone.Mode.OPEN, + HVAC_MODE_HEAT_COOL: Zone.Mode.AUTO, + } + self._supported_features |= SUPPORT_TARGET_TEMPERATURE + + self._device_info = { + "identifiers": {(IZONE, controller.unique_id, zone.index)}, + "name": self.name, + "manufacturer": "IZone", + "via_device": (IZONE, controller.unique_id), + "model": zone.type.name.title(), + } + + async def async_added_to_hass(self): + """Call on adding to hass.""" + + @callback + def zone_update(ctrl: Controller, zone: Zone) -> None: + """Handle zone data updates.""" + if zone is not self._zone: + return + self._name = zone.name.title() + self.async_schedule_update_ha_state() + + self.async_on_remove( + async_dispatcher_connect(self.hass, DISPATCH_ZONE_UPDATE, zone_update) + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._controller.available + + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return self._controller.assumed_state + + @property + def device_info(self): + """Return the device info for the iZone system.""" + return self._device_info + + @property + def unique_id(self): + """Return the ID of the controller device.""" + return "{}_z{}".format(self._controller.unique_id, self._zone.index + 1) + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False + + @property + def supported_features(self): + """Return the list of supported features.""" + try: + if self._zone.mode == Zone.Mode.AUTO: + return self._supported_features + return self._supported_features & ~SUPPORT_TARGET_TEMPERATURE + except ConnectionError: + return None + + @property + def temperature_unit(self): + """Return the unit of measurement which this thermostat uses.""" + return TEMP_CELSIUS + + @property + def precision(self): + """Return the precision of the system.""" + return PRECISION_HALVES + + @property + def hvac_mode(self): + """Return current operation ie. heat, cool, idle.""" + mode = self._zone.mode + for (key, value) in self._state_to_pizone.items(): + if value == mode: + return key + return None + + @property + def hvac_modes(self): + """Return the list of available operation modes.""" + return list(self._state_to_pizone.keys()) + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._zone.temp_current + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self._zone.type != Zone.Type.AUTO: + return None + return self._zone.temp_setpoint + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 0.5 + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._controller.min_temp + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._controller.max_temp + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + if self._zone.mode != Zone.Mode.AUTO: + return + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is not None: + await self._controller.wrap_and_catch(self._zone.set_temp_setpoint(temp)) + + async def async_set_hvac_mode(self, hvac_mode: str) -> None: + """Set new target operation mode.""" + mode = self._state_to_pizone[hvac_mode] + await self._controller.wrap_and_catch(self._zone.set_mode(mode)) + self.async_schedule_update_ha_state() + + @property + def is_on(self): + """Return true if on.""" + return self._zone.mode != Zone.Mode.CLOSE + + async def async_turn_on(self): + """Turn device on (open zone).""" + if self._zone.type == Zone.Type.AUTO: + await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.AUTO)) + else: + await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.OPEN)) + self.async_schedule_update_ha_state() + + async def async_turn_off(self): + """Turn device off (close zone).""" + await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.CLOSE)) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py new file mode 100644 index 00000000000000..eb57a36a2bb5f7 --- /dev/null +++ b/homeassistant/components/izone/config_flow.py @@ -0,0 +1,45 @@ +"""Config flow for izone.""" + +import logging +import asyncio + +from async_timeout import timeout + +from homeassistant import config_entries +from homeassistant.helpers import config_entry_flow +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import IZONE, TIMEOUT_DISCOVERY, DISPATCH_CONTROLLER_DISCOVERED + + +_LOGGER = logging.getLogger(__name__) + + +async def _async_has_devices(hass): + from .discovery import async_start_discovery_service, async_stop_discovery_service + + controller_ready = asyncio.Event() + async_dispatcher_connect( + hass, DISPATCH_CONTROLLER_DISCOVERED, lambda x: controller_ready.set() + ) + + disco = await async_start_discovery_service(hass) + + try: + async with timeout(TIMEOUT_DISCOVERY): + await controller_ready.wait() + except asyncio.TimeoutError: + pass + + if not disco.pi_disco.controllers: + await async_stop_discovery_service(hass) + _LOGGER.debug("No controllers found") + return False + + _LOGGER.debug("Controllers %s", disco.pi_disco.controllers) + return True + + +config_entry_flow.register_discovery_flow( + IZONE, "iZone Aircon", _async_has_devices, config_entries.CONN_CLASS_LOCAL_POLL +) diff --git a/homeassistant/components/izone/const.py b/homeassistant/components/izone/const.py new file mode 100644 index 00000000000000..4da7bc9e4afdb5 --- /dev/null +++ b/homeassistant/components/izone/const.py @@ -0,0 +1,14 @@ +"""Constants used by the izone component.""" + +IZONE = "izone" + +DATA_DISCOVERY_SERVICE = "izone_discovery" +DATA_CONFIG = "izone_config" + +DISPATCH_CONTROLLER_DISCOVERED = "izone_controller_discovered" +DISPATCH_CONTROLLER_DISCONNECTED = "izone_controller_disconnected" +DISPATCH_CONTROLLER_RECONNECTED = "izone_controller_disconnected" +DISPATCH_CONTROLLER_UPDATE = "izone_controller_update" +DISPATCH_ZONE_UPDATE = "izone_zone_update" + +TIMEOUT_DISCOVERY = 20 diff --git a/homeassistant/components/izone/discovery.py b/homeassistant/components/izone/discovery.py new file mode 100644 index 00000000000000..3630c28605bb7f --- /dev/null +++ b/homeassistant/components/izone/discovery.py @@ -0,0 +1,87 @@ +"""Internal discovery service for iZone AC.""" + +import logging +import pizone + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import ( + DATA_DISCOVERY_SERVICE, + DISPATCH_CONTROLLER_DISCOVERED, + DISPATCH_CONTROLLER_DISCONNECTED, + DISPATCH_CONTROLLER_RECONNECTED, + DISPATCH_CONTROLLER_UPDATE, + DISPATCH_ZONE_UPDATE, +) + +_LOGGER = logging.getLogger(__name__) + + +class DiscoveryService(pizone.Listener): + """Discovery data and interfacing with pizone library.""" + + def __init__(self, hass): + """Initialise discovery service.""" + super().__init__() + self.hass = hass + self.pi_disco = None + + # Listener interface + def controller_discovered(self, ctrl: pizone.Controller) -> None: + """Handle new controller discoverery.""" + async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_DISCOVERED, ctrl) + + def controller_disconnected(self, ctrl: pizone.Controller, ex: Exception) -> None: + """On disconnect from controller.""" + async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_DISCONNECTED, ctrl, ex) + + def controller_reconnected(self, ctrl: pizone.Controller) -> None: + """On reconnect to controller.""" + async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_RECONNECTED, ctrl) + + def controller_update(self, ctrl: pizone.Controller) -> None: + """System update message is recieved from the controller.""" + async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_UPDATE, ctrl) + + def zone_update(self, ctrl: pizone.Controller, zone: pizone.Zone) -> None: + """Zone update message is recieved from the controller.""" + async_dispatcher_send(self.hass, DISPATCH_ZONE_UPDATE, ctrl, zone) + + +async def async_start_discovery_service(hass: HomeAssistantType): + """Set up the pizone internal discovery.""" + disco = hass.data.get(DATA_DISCOVERY_SERVICE) + if disco: + # Already started + return disco + + # discovery local services + disco = DiscoveryService(hass) + hass.data[DATA_DISCOVERY_SERVICE] = disco + + # Start the pizone discovery service, disco is the listener + session = aiohttp_client.async_get_clientsession(hass) + loop = hass.loop + + disco.pi_disco = pizone.discovery(disco, loop=loop, session=session) + await disco.pi_disco.start_discovery() + + async def shutdown_event(event): + await async_stop_discovery_service(hass) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown_event) + + return disco + + +async def async_stop_discovery_service(hass: HomeAssistantType): + """Stop the discovery service.""" + disco = hass.data.get(DATA_DISCOVERY_SERVICE) + if not disco: + return + + await disco.pi_disco.close() + del hass.data[DATA_DISCOVERY_SERVICE] diff --git a/homeassistant/components/izone/manifest.json b/homeassistant/components/izone/manifest.json new file mode 100644 index 00000000000000..2f6747ab4cc51d --- /dev/null +++ b/homeassistant/components/izone/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "izone", + "name": "izone", + "documentation": "https://www.home-assistant.io/components/izone", + "requirements": [ "python-izone==1.1.1" ], + "dependencies": [], + "codeowners": [ "@Swamp-Ig" ], + "config_flow": true +} diff --git a/homeassistant/components/izone/strings.json b/homeassistant/components/izone/strings.json new file mode 100644 index 00000000000000..7cb14b03c6c593 --- /dev/null +++ b/homeassistant/components/izone/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "title": "iZone", + "step": { + "confirm": { + "title": "iZone", + "description": "Do you want to set up iZone?" + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of iZone is necessary.", + "no_devices_found": "No iZone devices found on the network." + } + } +} diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 93a60e363e1aa2..c7bbbdb2d907a9 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -1 +1,109 @@ """The jewish_calendar component.""" +import logging + +import voluptuous as vol +import hdate + +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME +from homeassistant.helpers.discovery import async_load_platform +import homeassistant.helpers.config_validation as cv + + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "jewish_calendar" + +SENSOR_TYPES = { + "binary": { + "issur_melacha_in_effect": ["Issur Melacha in Effect", "mdi:power-plug-off"] + }, + "data": { + "date": ["Date", "mdi:judaism"], + "weekly_portion": ["Parshat Hashavua", "mdi:book-open-variant"], + "holiday_name": ["Holiday name", "mdi:calendar-star"], + "holiday_type": ["Holiday type", "mdi:counter"], + "omer_count": ["Day of the Omer", "mdi:counter"], + }, + "time": { + "first_light": ["Alot Hashachar", "mdi:weather-sunset-up"], + "gra_end_shma": ['Latest time for Shm"a GR"A', "mdi:calendar-clock"], + "mga_end_shma": ['Latest time for Shm"a MG"A', "mdi:calendar-clock"], + "plag_mincha": ["Plag Hamincha", "mdi:weather-sunset-down"], + "first_stars": ["T'set Hakochavim", "mdi:weather-night"], + "upcoming_shabbat_candle_lighting": [ + "Upcoming Shabbat Candle Lighting", + "mdi:candle", + ], + "upcoming_shabbat_havdalah": ["Upcoming Shabbat Havdalah", "mdi:weather-night"], + "upcoming_candle_lighting": ["Upcoming Candle Lighting", "mdi:candle"], + "upcoming_havdalah": ["Upcoming Havdalah", "mdi:weather-night"], + }, +} + +CONF_DIASPORA = "diaspora" +CONF_LANGUAGE = "language" +CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" +CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset" + +CANDLE_LIGHT_DEFAULT = 18 + +DEFAULT_NAME = "Jewish Calendar" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_DIASPORA, default=False): cv.boolean, + vol.Inclusive(CONF_LATITUDE, "coordinates"): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, "coordinates"): cv.longitude, + vol.Optional(CONF_LANGUAGE, default="english"): vol.In( + ["hebrew", "english"] + ), + vol.Optional( + CONF_CANDLE_LIGHT_MINUTES, default=CANDLE_LIGHT_DEFAULT + ): int, + # Default of 0 means use 8.5 degrees / 'three_stars' time. + vol.Optional(CONF_HAVDALAH_OFFSET_MINUTES, default=0): int, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Set up the Jewish Calendar component.""" + name = config[DOMAIN][CONF_NAME] + language = config[DOMAIN][CONF_LANGUAGE] + + latitude = config[DOMAIN].get(CONF_LATITUDE, hass.config.latitude) + longitude = config[DOMAIN].get(CONF_LONGITUDE, hass.config.longitude) + diaspora = config[DOMAIN][CONF_DIASPORA] + + candle_lighting_offset = config[DOMAIN][CONF_CANDLE_LIGHT_MINUTES] + havdalah_offset = config[DOMAIN][CONF_HAVDALAH_OFFSET_MINUTES] + + location = hdate.Location( + latitude=latitude, + longitude=longitude, + timezone=hass.config.time_zone, + diaspora=diaspora, + ) + + hass.data[DOMAIN] = { + "location": location, + "name": name, + "language": language, + "candle_lighting_offset": candle_lighting_offset, + "havdalah_offset": havdalah_offset, + "diaspora": diaspora, + } + + hass.async_create_task(async_load_platform(hass, "sensor", DOMAIN, {}, config)) + + hass.async_create_task( + async_load_platform(hass, "binary_sensor", DOMAIN, {}, config) + ) + + return True diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py new file mode 100644 index 00000000000000..7362fce3cd0301 --- /dev/null +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -0,0 +1,66 @@ +"""Support for Jewish Calendar binary sensors.""" +import logging + +import hdate + +from homeassistant.components.binary_sensor import BinarySensorDevice +import homeassistant.util.dt as dt_util + +from . import DOMAIN, SENSOR_TYPES + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Jewish Calendar binary sensor devices.""" + if discovery_info is None: + return + + async_add_entities( + [ + JewishCalendarBinarySensor(hass.data[DOMAIN], sensor, sensor_info) + for sensor, sensor_info in SENSOR_TYPES["binary"].items() + ] + ) + + +class JewishCalendarBinarySensor(BinarySensorDevice): + """Representation of an Jewish Calendar binary sensor.""" + + def __init__(self, data, sensor, sensor_info): + """Initialize the binary sensor.""" + self._location = data["location"] + self._type = sensor + self._name = f"{data['name']} {sensor_info[0]}" + self._icon = sensor_info[1] + self._hebrew = data["language"] == "hebrew" + self._candle_lighting_offset = data["candle_lighting_offset"] + self._havdalah_offset = data["havdalah_offset"] + self._state = False + + @property + def icon(self): + """Return the icon of the entity.""" + return self._icon + + @property + def name(self): + """Return the name of the entity.""" + return self._name + + @property + def is_on(self): + """Return true if sensor is on.""" + return self._state + + async def async_update(self): + """Update the state of the sensor.""" + zmanim = hdate.Zmanim( + date=dt_util.now(), + location=self._location, + candle_lighting_offset=self._candle_lighting_offset, + havdalah_offset=self._havdalah_offset, + hebrew=self._hebrew, + ) + + self._state = zmanim.issur_melacha_in_effect diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index d298aee91436b4..405838b1fb10f9 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -1,140 +1,59 @@ """Platform to retrieve Jewish calendar information for Home Assistant.""" import logging -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_LATITUDE, - CONF_LONGITUDE, - CONF_NAME, - SUN_EVENT_SUNSET, -) -import homeassistant.helpers.config_validation as cv +import hdate + +from homeassistant.const import SUN_EVENT_SUNSET from homeassistant.helpers.entity import Entity from homeassistant.helpers.sun import get_astral_event_date import homeassistant.util.dt as dt_util -_LOGGER = logging.getLogger(__name__) +from . import DOMAIN, SENSOR_TYPES -SENSOR_TYPES = { - "date": ["Date", "mdi:judaism"], - "weekly_portion": ["Parshat Hashavua", "mdi:book-open-variant"], - "holiday_name": ["Holiday", "mdi:calendar-star"], - "holyness": ["Holyness", "mdi:counter"], - "first_light": ["Alot Hashachar", "mdi:weather-sunset-up"], - "gra_end_shma": ['Latest time for Shm"a GR"A', "mdi:calendar-clock"], - "mga_end_shma": ['Latest time for Shm"a MG"A', "mdi:calendar-clock"], - "plag_mincha": ["Plag Hamincha", "mdi:weather-sunset-down"], - "first_stars": ["T'set Hakochavim", "mdi:weather-night"], - "upcoming_shabbat_candle_lighting": [ - "Upcoming Shabbat Candle Lighting", - "mdi:candle", - ], - "upcoming_shabbat_havdalah": ["Upcoming Shabbat Havdalah", "mdi:weather-night"], - "upcoming_candle_lighting": ["Upcoming Candle Lighting", "mdi:candle"], - "upcoming_havdalah": ["Upcoming Havdalah", "mdi:weather-night"], - "issur_melacha_in_effect": ["Issur Melacha in Effect", "mdi:power-plug-off"], - "omer_count": ["Day of the Omer", "mdi:counter"], -} - -CONF_DIASPORA = "diaspora" -CONF_LANGUAGE = "language" -CONF_SENSORS = "sensors" -CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" -CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset" - -CANDLE_LIGHT_DEFAULT = 18 - -DEFAULT_NAME = "Jewish Calendar" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_DIASPORA, default=False): cv.boolean, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_LANGUAGE, default="english"): vol.In(["hebrew", "english"]), - vol.Optional(CONF_CANDLE_LIGHT_MINUTES, default=CANDLE_LIGHT_DEFAULT): int, - # Default of 0 means use 8.5 degrees / 'three_stars' time. - vol.Optional(CONF_HAVDALAH_OFFSET_MINUTES, default=0): int, - vol.Optional(CONF_SENSORS, default=["date"]): vol.All( - cv.ensure_list, vol.Length(min=1), [vol.In(SENSOR_TYPES)] - ), - } -) +_LOGGER = logging.getLogger(__name__) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the Jewish calendar sensor platform.""" - language = config.get(CONF_LANGUAGE) - name = config.get(CONF_NAME) - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - diaspora = config.get(CONF_DIASPORA) - candle_lighting_offset = config.get(CONF_CANDLE_LIGHT_MINUTES) - havdalah_offset = config.get(CONF_HAVDALAH_OFFSET_MINUTES) - - if None in (latitude, longitude): - _LOGGER.error("Latitude or longitude not set in Home Assistant config") + if discovery_info is None: return - dev = [] - for sensor_type in config[CONF_SENSORS]: - dev.append( - JewishCalSensor( - name, - language, - sensor_type, - latitude, - longitude, - hass.config.time_zone, - diaspora, - candle_lighting_offset, - havdalah_offset, - ) - ) - async_add_entities(dev, True) + sensors = [ + JewishCalendarSensor(hass.data[DOMAIN], sensor, sensor_info) + for sensor, sensor_info in SENSOR_TYPES["data"].items() + ] + sensors.extend( + JewishCalendarSensor(hass.data[DOMAIN], sensor, sensor_info) + for sensor, sensor_info in SENSOR_TYPES["time"].items() + ) + + async_add_entities(sensors) -class JewishCalSensor(Entity): +class JewishCalendarSensor(Entity): """Representation of an Jewish calendar sensor.""" - def __init__( - self, - name, - language, - sensor_type, - latitude, - longitude, - timezone, - diaspora, - candle_lighting_offset=CANDLE_LIGHT_DEFAULT, - havdalah_offset=0, - ): + def __init__(self, data, sensor, sensor_info): """Initialize the Jewish calendar sensor.""" - self.client_name = name - self._name = SENSOR_TYPES[sensor_type][0] - self.type = sensor_type - self._hebrew = language == "hebrew" + self._location = data["location"] + self._type = sensor + self._name = f"{data['name']} {sensor_info[0]}" + self._icon = sensor_info[1] + self._hebrew = data["language"] == "hebrew" + self._candle_lighting_offset = data["candle_lighting_offset"] + self._havdalah_offset = data["havdalah_offset"] + self._diaspora = data["diaspora"] self._state = None - self.latitude = latitude - self.longitude = longitude - self.timezone = timezone - self.diaspora = diaspora - self.candle_lighting_offset = candle_lighting_offset - self.havdalah_offset = havdalah_offset - _LOGGER.debug("Sensor %s initialized", self.type) @property def name(self): """Return the name of the sensor.""" - return f"{self.client_name} {self._name}" + return self._name @property def icon(self): """Icon to display in the front end.""" - return SENSOR_TYPES[self.type][1] + return self._icon @property def state(self): @@ -143,9 +62,7 @@ def state(self): async def async_update(self): """Update the state of the sensor.""" - import hdate - - now = dt_util.as_local(dt_util.now()) + now = dt_util.now() _LOGGER.debug("Now: %s Timezone = %s", now, now.tzinfo) today = now.date() @@ -155,66 +72,65 @@ async def async_update(self): _LOGGER.debug("Now: %s Sunset: %s", now, sunset) - location = hdate.Location( - latitude=self.latitude, - longitude=self.longitude, - timezone=self.timezone, - diaspora=self.diaspora, - ) - def make_zmanim(date): """Create a Zmanim object.""" return hdate.Zmanim( date=date, - location=location, - candle_lighting_offset=self.candle_lighting_offset, - havdalah_offset=self.havdalah_offset, + location=self._location, + candle_lighting_offset=self._candle_lighting_offset, + havdalah_offset=self._havdalah_offset, hebrew=self._hebrew, ) - date = hdate.HDate(today, diaspora=self.diaspora, hebrew=self._hebrew) - lagging_date = date + date = hdate.HDate(today, diaspora=self._diaspora, hebrew=self._hebrew) - # Advance Hebrew date if sunset has passed. - # Not all sensors should advance immediately when the Hebrew date - # officially changes (i.e. after sunset), hence lagging_date. - if now > sunset: - date = date.next_day + # The Jewish day starts after darkness (called "tzais") and finishes at + # sunset ("shkia"). The time in between is a gray area (aka "Bein + # Hashmashot" - literally: "in between the sun and the moon"). + + # For some sensors, it is more interesting to consider the date to be + # tomorrow based on sunset ("shkia"), for others based on "tzais". + # Hence the following variables. + after_tzais_date = after_shkia_date = date today_times = make_zmanim(today) + + if now > sunset: + after_shkia_date = date.next_day + if today_times.havdalah and now > today_times.havdalah: - lagging_date = lagging_date.next_day + after_tzais_date = date.next_day # Terminology note: by convention in py-libhdate library, "upcoming" # refers to "current" or "upcoming" dates. - if self.type == "date": - self._state = date.hebrew_date - elif self.type == "weekly_portion": + if self._type == "date": + self._state = after_shkia_date.hebrew_date + elif self._type == "weekly_portion": # Compute the weekly portion based on the upcoming shabbat. - self._state = lagging_date.upcoming_shabbat.parasha - elif self.type == "holiday_name": - self._state = date.holiday_description - elif self.type == "holyness": - self._state = date.holiday_type - elif self.type == "upcoming_shabbat_candle_lighting": - times = make_zmanim(lagging_date.upcoming_shabbat.previous_day.gdate) + self._state = after_tzais_date.upcoming_shabbat.parasha + elif self._type == "holiday_name": + self._state = after_shkia_date.holiday_description + elif self._type == "holiday_type": + self._state = after_shkia_date.holiday_type + elif self._type == "upcoming_shabbat_candle_lighting": + times = make_zmanim(after_tzais_date.upcoming_shabbat.previous_day.gdate) self._state = times.candle_lighting - elif self.type == "upcoming_candle_lighting": + elif self._type == "upcoming_candle_lighting": times = make_zmanim( - lagging_date.upcoming_shabbat_or_yom_tov.first_day.previous_day.gdate + after_tzais_date.upcoming_shabbat_or_yom_tov.first_day.previous_day.gdate ) self._state = times.candle_lighting - elif self.type == "upcoming_shabbat_havdalah": - times = make_zmanim(lagging_date.upcoming_shabbat.gdate) + elif self._type == "upcoming_shabbat_havdalah": + times = make_zmanim(after_tzais_date.upcoming_shabbat.gdate) self._state = times.havdalah - elif self.type == "upcoming_havdalah": - times = make_zmanim(lagging_date.upcoming_shabbat_or_yom_tov.last_day.gdate) + elif self._type == "upcoming_havdalah": + times = make_zmanim( + after_tzais_date.upcoming_shabbat_or_yom_tov.last_day.gdate + ) self._state = times.havdalah - elif self.type == "issur_melacha_in_effect": - self._state = make_zmanim(now).issur_melacha_in_effect - elif self.type == "omer_count": - self._state = date.omer_day + elif self._type == "omer_count": + self._state = after_shkia_date.omer_day else: times = make_zmanim(today).zmanim - self._state = times[self.type].time() + self._state = times[self._type].time() _LOGGER.debug("New value: %s", self._state) diff --git a/homeassistant/components/kaiterra/__init__.py b/homeassistant/components/kaiterra/__init__.py new file mode 100644 index 00000000000000..8c61ad5418481a --- /dev/null +++ b/homeassistant/components/kaiterra/__init__.py @@ -0,0 +1,92 @@ +"""Support for Kaiterra devices.""" +import voluptuous as vol + +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.helpers.discovery import async_load_platform +from homeassistant.helpers import config_validation as cv + +from homeassistant.const import ( + CONF_API_KEY, + CONF_DEVICES, + CONF_DEVICE_ID, + CONF_SCAN_INTERVAL, + CONF_TYPE, + CONF_NAME, +) + +from .const import ( + AVAILABLE_AQI_STANDARDS, + AVAILABLE_UNITS, + AVAILABLE_DEVICE_TYPES, + CONF_AQI_STANDARD, + CONF_PREFERRED_UNITS, + DOMAIN, + DEFAULT_AQI_STANDARD, + DEFAULT_PREFERRED_UNIT, + DEFAULT_SCAN_INTERVAL, + KAITERRA_COMPONENTS, +) + +from .api_data import KaiterraApiData + +KAITERRA_DEVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_DEVICE_ID): cv.string, + vol.Required(CONF_TYPE): vol.In(AVAILABLE_DEVICE_TYPES), + vol.Optional(CONF_NAME): cv.string, + } +) + +KAITERRA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [KAITERRA_DEVICE_SCHEMA]), + vol.Optional(CONF_AQI_STANDARD, default=DEFAULT_AQI_STANDARD): vol.In( + AVAILABLE_AQI_STANDARDS + ), + vol.Optional(CONF_PREFERRED_UNITS, default=DEFAULT_PREFERRED_UNIT): vol.All( + cv.ensure_list, [vol.In(AVAILABLE_UNITS)] + ), + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period, + } +) + +CONFIG_SCHEMA = vol.Schema({DOMAIN: KAITERRA_SCHEMA}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the Kaiterra components.""" + + conf = config[DOMAIN] + scan_interval = conf[CONF_SCAN_INTERVAL] + devices = conf[CONF_DEVICES] + session = async_get_clientsession(hass) + api = hass.data[DOMAIN] = KaiterraApiData(hass, conf, session) + + await api.async_update() + + async def _update(now=None): + """Periodic update.""" + await api.async_update() + + async_track_time_interval(hass, _update, scan_interval) + + # Load platforms for each device + for device in devices: + device_name, device_id = ( + device.get(CONF_NAME) or device[CONF_TYPE], + device[CONF_DEVICE_ID], + ) + for component in KAITERRA_COMPONENTS: + hass.async_create_task( + async_load_platform( + hass, + component, + DOMAIN, + {CONF_NAME: device_name, CONF_DEVICE_ID: device_id}, + config, + ) + ) + + return True diff --git a/homeassistant/components/kaiterra/air_quality.py b/homeassistant/components/kaiterra/air_quality.py new file mode 100644 index 00000000000000..4dfe04f9c2e12d --- /dev/null +++ b/homeassistant/components/kaiterra/air_quality.py @@ -0,0 +1,115 @@ +"""Support for Kaiterra Air Quality Sensors.""" +from homeassistant.components.air_quality import AirQualityEntity + +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from homeassistant.const import CONF_DEVICE_ID, CONF_NAME + +from .const import ( + DOMAIN, + ATTR_VOC, + ATTR_AQI_LEVEL, + ATTR_AQI_POLLUTANT, + DISPATCHER_KAITERRA, +) + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the air_quality kaiterra sensor.""" + if discovery_info is None: + return + + api = hass.data[DOMAIN] + name = discovery_info[CONF_NAME] + device_id = discovery_info[CONF_DEVICE_ID] + + async_add_entities([KaiterraAirQuality(api, name, device_id)]) + + +class KaiterraAirQuality(AirQualityEntity): + """Implementation of a Kaittera air quality sensor.""" + + def __init__(self, api, name, device_id): + """Initialize the sensor.""" + self._api = api + self._name = f"{name} Air Quality" + self._device_id = device_id + + def _data(self, key): + return self._device.get(key, {}).get("value") + + @property + def _device(self): + return self._api.data.get(self._device_id, {}) + + @property + def should_poll(self): + """Return that the sensor should not be polled.""" + return False + + @property + def available(self): + """Return the availability of the sensor.""" + return self._api.data.get(self._device_id) is not None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def air_quality_index(self): + """Return the Air Quality Index (AQI).""" + return self._data("aqi") + + @property + def air_quality_index_level(self): + """Return the Air Quality Index level.""" + return self._data("aqi_level") + + @property + def air_quality_index_pollutant(self): + """Return the Air Quality Index level.""" + return self._data("aqi_pollutant") + + @property + def particulate_matter_2_5(self): + """Return the particulate matter 2.5 level.""" + return self._data("rpm25c") + + @property + def particulate_matter_10(self): + """Return the particulate matter 10 level.""" + return self._data("rpm10c") + + @property + def volatile_organic_compounds(self): + """Return the VOC (Volatile Organic Compounds) level.""" + return self._data("rtvoc") + + @property + def unique_id(self): + """Return the sensor's unique id.""" + return f"{self._device_id}_air_quality" + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + data = {} + attributes = [ + (ATTR_VOC, self.volatile_organic_compounds), + (ATTR_AQI_LEVEL, self.air_quality_index_level), + (ATTR_AQI_POLLUTANT, self.air_quality_index_pollutant), + ] + + for attr, value in attributes: + if value is not None: + data[attr] = value + + return data + + async def async_added_to_hass(self): + """Register callback.""" + async_dispatcher_connect( + self.hass, DISPATCHER_KAITERRA, self.async_write_ha_state + ) diff --git a/homeassistant/components/kaiterra/api_data.py b/homeassistant/components/kaiterra/api_data.py new file mode 100644 index 00000000000000..0c2d6d9366147d --- /dev/null +++ b/homeassistant/components/kaiterra/api_data.py @@ -0,0 +1,109 @@ +"""Data for all Kaiterra devices.""" +from logging import getLogger + +import asyncio + +import async_timeout + +from aiohttp.client_exceptions import ClientResponseError + +from kaiterra_async_client import KaiterraAPIClient, AQIStandard, Units + +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from homeassistant.const import CONF_API_KEY, CONF_DEVICES, CONF_DEVICE_ID, CONF_TYPE + +from .const import ( + AQI_SCALE, + AQI_LEVEL, + CONF_AQI_STANDARD, + CONF_PREFERRED_UNITS, + DISPATCHER_KAITERRA, +) + +_LOGGER = getLogger(__name__) + +POLLUTANTS = {"rpm25c": "PM2.5", "rpm10c": "PM10", "rtvoc": "TVOC"} + + +class KaiterraApiData: + """Get data from Kaiterra API.""" + + def __init__(self, hass, config, session): + """Initialize the API data object.""" + + api_key = config[CONF_API_KEY] + aqi_standard = config[CONF_AQI_STANDARD] + devices = config[CONF_DEVICES] + units = config[CONF_PREFERRED_UNITS] + + self._hass = hass + self._api = KaiterraAPIClient( + session, + api_key=api_key, + aqi_standard=AQIStandard.from_str(aqi_standard), + preferred_units=[Units.from_str(unit) for unit in units], + ) + self._devices_ids = [device[CONF_DEVICE_ID] for device in devices] + self._devices = [ + f"/{device[CONF_TYPE]}s/{device[CONF_DEVICE_ID]}" for device in devices + ] + self._scale = AQI_SCALE[aqi_standard] + self._level = AQI_LEVEL[aqi_standard] + self._update_listeners = [] + self.data = {} + + async def async_update(self) -> None: + """Get the data from Kaiterra API.""" + + try: + with async_timeout.timeout(10): + data = await self._api.get_latest_sensor_readings(self._devices) + except (ClientResponseError, asyncio.TimeoutError): + _LOGGER.debug("Couldn't fetch data") + self.data = {} + async_dispatcher_send(self._hass, DISPATCHER_KAITERRA) + + _LOGGER.debug("New data retrieved: %s", data) + + try: + self.data = {} + for i, device in enumerate(data): + if not device: + self.data[self._devices_ids[i]] = {} + continue + + aqi, main_pollutant = None, None + for sensor_name, sensor in device.items(): + points = sensor.get("points") + + if not points: + continue + + point = points[0] + sensor["value"] = point.get("value") + + if "aqi" not in point: + continue + + sensor["aqi"] = point["aqi"] + if not aqi or aqi < point["aqi"]: + aqi = point["aqi"] + main_pollutant = POLLUTANTS.get(sensor_name) + + level = None + for j in range(1, len(self._scale)): + if aqi <= self._scale[j]: + level = self._level[j - 1] + break + + device["aqi"] = {"value": aqi} + device["aqi_level"] = {"value": level} + device["aqi_pollutant"] = {"value": main_pollutant} + + self.data[self._devices_ids[i]] = device + + async_dispatcher_send(self._hass, DISPATCHER_KAITERRA) + except IndexError as err: + _LOGGER.error("Parsing error %s", err) + async_dispatcher_send(self._hass, DISPATCHER_KAITERRA) diff --git a/homeassistant/components/kaiterra/const.py b/homeassistant/components/kaiterra/const.py new file mode 100644 index 00000000000000..7e23edb1259fa9 --- /dev/null +++ b/homeassistant/components/kaiterra/const.py @@ -0,0 +1,57 @@ +"""Consts for Kaiterra integration.""" + +from datetime import timedelta + +DOMAIN = "kaiterra" + +DISPATCHER_KAITERRA = "kaiterra_update" + +AQI_SCALE = { + "cn": [0, 50, 100, 150, 200, 300, 400, 500], + "in": [0, 50, 100, 200, 300, 400, 500], + "us": [0, 50, 100, 150, 200, 300, 500], +} +AQI_LEVEL = { + "cn": [ + "Good", + "Satisfactory", + "Moderate", + "Unhealthy for sensitive groups", + "Unhealthy", + "Very unhealthy", + "Hazardous", + ], + "in": [ + "Good", + "Satisfactory", + "Moderately polluted", + "Poor", + "Very poor", + "Severe", + ], + "us": [ + "Good", + "Moderate", + "Unhealthy for sensitive groups", + "Unhealthy", + "Very unhealthy", + "Hazardous", + ], +} + +ATTR_VOC = "volatile_organic_compounds" +ATTR_AQI_LEVEL = "air_quality_index_level" +ATTR_AQI_POLLUTANT = "air_quality_index_pollutant" + +AVAILABLE_AQI_STANDARDS = ["us", "cn", "in"] +AVAILABLE_UNITS = ["x", "%", "C", "F", "mg/m³", "µg/m³", "ppm", "ppb"] +AVAILABLE_DEVICE_TYPES = ["laseregg", "sensedge"] + +CONF_AQI_STANDARD = "aqi_standard" +CONF_PREFERRED_UNITS = "preferred_units" + +DEFAULT_AQI_STANDARD = "us" +DEFAULT_PREFERRED_UNIT = [] +DEFAULT_SCAN_INTERVAL = timedelta(seconds=30) + +KAITERRA_COMPONENTS = ["sensor", "air_quality"] diff --git a/homeassistant/components/kaiterra/manifest.json b/homeassistant/components/kaiterra/manifest.json new file mode 100644 index 00000000000000..926f73fa4dbea9 --- /dev/null +++ b/homeassistant/components/kaiterra/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "kaiterra", + "name": "Kaiterra", + "documentation": "https://www.home-assistant.io/components/kaiterra", + "requirements": ["kaiterra-async-client==0.0.2"], + "codeowners": ["@Michsior14"], + "dependencies": [] +} \ No newline at end of file diff --git a/homeassistant/components/kaiterra/sensor.py b/homeassistant/components/kaiterra/sensor.py new file mode 100644 index 00000000000000..4ff6435b64d8b0 --- /dev/null +++ b/homeassistant/components/kaiterra/sensor.py @@ -0,0 +1,95 @@ +"""Support for Kaiterra Temperature ahn Humidity Sensors.""" +from homeassistant.helpers.entity import Entity + +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from homeassistant.const import CONF_DEVICE_ID, CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT + +from .const import DOMAIN, DISPATCHER_KAITERRA + +SENSORS = [ + {"name": "Temperature", "prop": "rtemp", "device_class": "temperature"}, + {"name": "Humidity", "prop": "rhumid", "device_class": "humidity"}, +] + + +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the kaiterra temperature and humidity sensor.""" + if discovery_info is None: + return + + api = hass.data[DOMAIN] + name = discovery_info[CONF_NAME] + device_id = discovery_info[CONF_DEVICE_ID] + + async_add_entities( + [KaiterraSensor(api, name, device_id, sensor) for sensor in SENSORS] + ) + + +class KaiterraSensor(Entity): + """Implementation of a Kaittera sensor.""" + + def __init__(self, api, name, device_id, sensor): + """Initialize the sensor.""" + self._api = api + self._name = f'{name} {sensor["name"]}' + self._device_id = device_id + self._kind = sensor["name"].lower() + self._property = sensor["prop"] + self._device_class = sensor["device_class"] + + @property + def _sensor(self): + """Return the sensor data.""" + return self._api.data.get(self._device_id, {}).get(self._property, {}) + + @property + def should_poll(self): + """Return that the sensor should not be polled.""" + return False + + @property + def available(self): + """Return the availability of the sensor.""" + return self._api.data.get(self._device_id) is not None + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def state(self): + """Return the state.""" + return self._sensor.get("value") + + @property + def unique_id(self): + """Return the sensor's unique id.""" + return f"{self._device_id}_{self._kind}" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + if not self._sensor.get("units"): + return None + + value = self._sensor["units"].value + + if value == "F": + return TEMP_FAHRENHEIT + if value == "C": + return TEMP_CELSIUS + return value + + async def async_added_to_hass(self): + """Register callback.""" + async_dispatcher_connect( + self.hass, DISPATCHER_KAITERRA, self.async_write_ha_state + ) diff --git a/homeassistant/components/keyboard_remote/__init__.py b/homeassistant/components/keyboard_remote/__init__.py index 1a3b41f74bd993..8b901dcc61e930 100644 --- a/homeassistant/components/keyboard_remote/__init__.py +++ b/homeassistant/components/keyboard_remote/__init__.py @@ -157,7 +157,7 @@ def run(self): try: event = self.dev.read_one() - except IOError: # Keyboard Disconnected + except OSError: # Keyboard Disconnected self.dev = None self.hass.bus.fire( KEYBOARD_REMOTE_DISCONNECTED, diff --git a/homeassistant/components/knx/light.py b/homeassistant/components/knx/light.py index c04feed23374fa..71a82c6df2a6eb 100644 --- a/homeassistant/components/knx/light.py +++ b/homeassistant/components/knx/light.py @@ -305,7 +305,10 @@ async def async_turn_on(self, **kwargs): await self.device.set_color_temperature(kelvin) elif self.device.supports_tunable_white and update_color_temp: # calculate relative_ct from Kelvin to fit typical KNX devices - kelvin = int(color_util.color_temperature_mired_to_kelvin(mireds)) + kelvin = min( + self._max_kelvin, + int(color_util.color_temperature_mired_to_kelvin(mireds)), + ) relative_ct = int( 255 * (kelvin - self._min_kelvin) diff --git a/homeassistant/components/life360/.translations/es.json b/homeassistant/components/life360/.translations/es.json index 8fc70a60a052e6..2b185cb1b6c476 100644 --- a/homeassistant/components/life360/.translations/es.json +++ b/homeassistant/components/life360/.translations/es.json @@ -9,7 +9,9 @@ }, "error": { "invalid_credentials": "Credenciales no v\u00e1lidas", - "invalid_username": "Nombre de usuario no v\u00e1lido" + "invalid_username": "Nombre de usuario no v\u00e1lido", + "unexpected": "Error inesperado al comunicarse con el servidor Life360", + "user_already_configured": "La cuenta ya ha sido configurada" }, "step": { "user": { diff --git a/homeassistant/components/life360/.translations/fr.json b/homeassistant/components/life360/.translations/fr.json index cb4682fc937103..947425e4807f91 100644 --- a/homeassistant/components/life360/.translations/fr.json +++ b/homeassistant/components/life360/.translations/fr.json @@ -10,6 +10,7 @@ "error": { "invalid_credentials": "Informations d'identification invalides", "invalid_username": "Nom d'utilisateur invalide", + "unexpected": "Erreur inattendue lors de la communication avec le serveur Life360", "user_already_configured": "Le compte a d\u00e9j\u00e0 \u00e9t\u00e9 configur\u00e9" }, "step": { diff --git a/homeassistant/components/life360/.translations/it.json b/homeassistant/components/life360/.translations/it.json index 9c4cb1cc4cb15f..b7d2d6c8f1b211 100644 --- a/homeassistant/components/life360/.translations/it.json +++ b/homeassistant/components/life360/.translations/it.json @@ -5,11 +5,12 @@ "user_already_configured": "L'account \u00e8 gi\u00e0 stato configurato" }, "create_entry": { - "default": "Per impostare le opzioni avanzate, consultare la [Documentazione Life360] ( {docs_url} )." + "default": "Per impostare le opzioni avanzate, consultare la [Documentazione Life360]({docs_url})." }, "error": { "invalid_credentials": "Credenziali non valide", "invalid_username": "Nome utente non valido", + "unexpected": "Errore imprevisto durante la comunicazione con il server di Life360", "user_already_configured": "L'account \u00e8 gi\u00e0 stato configurato" }, "step": { @@ -18,6 +19,7 @@ "password": "Password", "username": "Nome utente" }, + "description": "Per impostare le opzioni avanzate, vedere [Documentazione di Life360]({docs_url}).\n\u00c8 consigliabile eseguire questa operazione prima di aggiungere gli account.", "title": "Informazioni sull'account Life360" } }, diff --git a/homeassistant/components/life360/.translations/ko.json b/homeassistant/components/life360/.translations/ko.json index b81a6fd059f5ce..067b305b80c426 100644 --- a/homeassistant/components/life360/.translations/ko.json +++ b/homeassistant/components/life360/.translations/ko.json @@ -10,6 +10,7 @@ "error": { "invalid_credentials": "\ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "invalid_username": "\uc0ac\uc6a9\uc790 \uc774\ub984\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unexpected": "Life360 \uc11c\ubc84 \uc5f0\uacb0\uc911 \uc608\uc0c1\uce58 \ubabb\ud55c \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4", "user_already_configured": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." }, "step": { diff --git a/homeassistant/components/life360/.translations/lb.json b/homeassistant/components/life360/.translations/lb.json index bfed5937e24bea..3af9ab00728e5d 100644 --- a/homeassistant/components/life360/.translations/lb.json +++ b/homeassistant/components/life360/.translations/lb.json @@ -10,6 +10,7 @@ "error": { "invalid_credentials": "Ong\u00eblteg Login Informatioune", "invalid_username": "Ong\u00ebltege Benotzernumm", + "unexpected": "Onerwaarte Feeler bei der Kommunikatioun mam Life360 Server", "user_already_configured": "Kont ass scho konfigur\u00e9iert" }, "step": { diff --git a/homeassistant/components/life360/.translations/pl.json b/homeassistant/components/life360/.translations/pl.json index cd5e61fc123608..e9cd992030442b 100644 --- a/homeassistant/components/life360/.translations/pl.json +++ b/homeassistant/components/life360/.translations/pl.json @@ -2,7 +2,7 @@ "config": { "abort": { "invalid_credentials": "Nieprawid\u0142owe dane uwierzytelniaj\u0105ce", - "user_already_configured": "Konto zosta\u0142o ju\u017c skonfigurowane." + "user_already_configured": "Konto jest ju\u017c skonfigurowane" }, "create_entry": { "default": "Aby skonfigurowa\u0107 zaawansowane ustawienia, zapoznaj si\u0119 z [dokumentacj\u0105 Life360]({docs_url})." @@ -11,7 +11,7 @@ "invalid_credentials": "Nieprawid\u0142owe dane uwierzytelniaj\u0105ce", "invalid_username": "Nieprawid\u0142owa nazwa u\u017cytkownika", "unexpected": "Nieoczekiwany b\u0142\u0105d komunikacji z serwerem Life360", - "user_already_configured": "Konto zosta\u0142o ju\u017c skonfigurowane." + "user_already_configured": "Konto jest ju\u017c skonfigurowane" }, "step": { "user": { diff --git a/homeassistant/components/life360/.translations/ru.json b/homeassistant/components/life360/.translations/ru.json index c03ad0f7e1f6a6..1e962142373f89 100644 --- a/homeassistant/components/life360/.translations/ru.json +++ b/homeassistant/components/life360/.translations/ru.json @@ -5,7 +5,7 @@ "user_already_configured": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430" }, "create_entry": { - "default": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438 Life360]({docs_url}) \u0434\u043b\u044f \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u043e\u0439 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438." + "default": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438]({docs_url}) \u0434\u043b\u044f \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u043e\u0439 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438." }, "error": { "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435", @@ -19,7 +19,7 @@ "password": "\u041f\u0430\u0440\u043e\u043b\u044c", "username": "\u041b\u043e\u0433\u0438\u043d" }, - "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438 Life360]({docs_url}) \u0434\u043b\u044f \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u043e\u0439 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438. \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0441\u0434\u0435\u043b\u0430\u0442\u044c \u044d\u0442\u043e \u043f\u0435\u0440\u0435\u0434 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430.", + "description": "\u041e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438]({docs_url}) \u0434\u043b\u044f \u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u043d\u043e\u0439 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438. \u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u0441\u0434\u0435\u043b\u0430\u0442\u044c \u044d\u0442\u043e \u043f\u0435\u0440\u0435\u0434 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u043e\u0439 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430.", "title": "Life360" } }, diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index fd74d9831fca0a..131d1a23b6a5f3 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -13,7 +13,5 @@ ] }, "dependencies": [], - "codeowners": [ - "@amelchio" - ] + "codeowners": [] } diff --git a/homeassistant/components/lifx_cloud/manifest.json b/homeassistant/components/lifx_cloud/manifest.json index c2834fbc788b63..83805692e4d246 100644 --- a/homeassistant/components/lifx_cloud/manifest.json +++ b/homeassistant/components/lifx_cloud/manifest.json @@ -4,7 +4,5 @@ "documentation": "https://www.home-assistant.io/components/lifx_cloud", "requirements": [], "dependencies": [], - "codeowners": [ - "@amelchio" - ] + "codeowners": [] } diff --git a/homeassistant/components/lifx_legacy/manifest.json b/homeassistant/components/lifx_legacy/manifest.json index 4ff59ac17703df..fb38b41f314c4a 100644 --- a/homeassistant/components/lifx_legacy/manifest.json +++ b/homeassistant/components/lifx_legacy/manifest.json @@ -6,7 +6,5 @@ "liffylights==0.9.4" ], "dependencies": [], - "codeowners": [ - "@amelchio" - ] + "codeowners": [] } diff --git a/homeassistant/components/light/.translations/bg.json b/homeassistant/components/light/.translations/bg.json new file mode 100644 index 00000000000000..533ba76b6a7613 --- /dev/null +++ b/homeassistant/components/light/.translations/bg.json @@ -0,0 +1,13 @@ +{ + "device_automation": { + "action_type": { + "toggle": "\u041f\u0440\u0435\u0432\u043a\u043b\u044e\u0447\u0438 {entity_name}", + "turn_off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0438 {entity_name}", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438 {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u0435 \u0438\u0437\u043a\u043b.", + "is_on": "{entity_name} \u0435 \u0432\u043a\u043b." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/ca.json b/homeassistant/components/light/.translations/ca.json new file mode 100644 index 00000000000000..c9b727088ab180 --- /dev/null +++ b/homeassistant/components/light/.translations/ca.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Commuta {name}", + "turn_off": "Apaga {name}", + "turn_on": "Enc\u00e9n {name}" + }, + "condition_type": { + "is_off": "{name} est\u00e0 apagat", + "is_on": "{name} est\u00e0 enc\u00e8s" + }, + "trigger_type": { + "turned_off": "{name} apagat", + "turned_on": "{name} enc\u00e8s" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/da.json b/homeassistant/components/light/.translations/da.json new file mode 100644 index 00000000000000..4ea4a94014ea83 --- /dev/null +++ b/homeassistant/components/light/.translations/da.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "trigger_type": { + "turned_off": "{name} slukket", + "turned_on": "{name} t\u00e6ndt" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/de.json b/homeassistant/components/light/.translations/de.json new file mode 100644 index 00000000000000..2fe1c6b42dcd42 --- /dev/null +++ b/homeassistant/components/light/.translations/de.json @@ -0,0 +1,8 @@ +{ + "device_automation": { + "trigger_type": { + "turned_off": "{name} ausgeschaltet", + "turned_on": "{name} eingeschaltet" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/en.json b/homeassistant/components/light/.translations/en.json index 9e5d1abddaf486..3f37de5331e3ea 100644 --- a/homeassistant/components/light/.translations/en.json +++ b/homeassistant/components/light/.translations/en.json @@ -1,8 +1,17 @@ { "device_automation": { + "action_type": { + "toggle": "Toggle {entity_name}", + "turn_off": "Turn off {entity_name}", + "turn_on": "Turn on {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} is off", + "is_on": "{entity_name} is on" + }, "trigger_type": { - "turn_off": "{name} turned off", - "turn_on": "{name} turned on" + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on" } } } \ No newline at end of file diff --git a/homeassistant/components/light/.translations/es.json b/homeassistant/components/light/.translations/es.json new file mode 100644 index 00000000000000..6bf91651d2e148 --- /dev/null +++ b/homeassistant/components/light/.translations/es.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Alternar {entity_name}", + "turn_off": "Apagar {entity_name}", + "turn_on": "Encender {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} est\u00e1 apagada", + "is_on": "{entity_name} est\u00e1 encendida" + }, + "trigger_type": { + "turned_off": "{entity_name} apagada", + "turned_on": "{entity_name} encendida" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/fr.json b/homeassistant/components/light/.translations/fr.json new file mode 100644 index 00000000000000..fd30e9317180ec --- /dev/null +++ b/homeassistant/components/light/.translations/fr.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Basculer {entity_name}", + "turn_off": "\u00c9teindre {entity_name}", + "turn_on": "Allumer {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} est \u00e9teint", + "is_on": "{entity_name} est allum\u00e9" + }, + "trigger_type": { + "turned_off": "{entity_name} d\u00e9sactiv\u00e9", + "turned_on": "{entity_name} activ\u00e9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/it.json b/homeassistant/components/light/.translations/it.json new file mode 100644 index 00000000000000..2f4d2ca121f512 --- /dev/null +++ b/homeassistant/components/light/.translations/it.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Commuta {entity_name}", + "turn_off": "Spegnere {entity_name}", + "turn_on": "Accendere {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u00e8 disattivato", + "is_on": "{entity_name} \u00e8 attivo" + }, + "trigger_type": { + "turned_off": "{entity_name} disattivato", + "turned_on": "{entity_name} attivato" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/ko.json b/homeassistant/components/light/.translations/ko.json new file mode 100644 index 00000000000000..e055f67421ef53 --- /dev/null +++ b/homeassistant/components/light/.translations/ko.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "{entity_name} \ud1a0\uae00", + "turn_off": "{entity_name} \ub044\uae30", + "turn_on": "{entity_name} \ucf1c\uae30" + }, + "condition_type": { + "is_off": "{entity_name} \uc774(\uac00) \uaebc\uc84c\uc2b5\ub2c8\ub2e4", + "is_on": "{entity_name} \uc774(\uac00) \ucf1c\uc84c\uc2b5\ub2c8\ub2e4" + }, + "trigger_type": { + "turned_off": "{entity_name} \uc774(\uac00) \uaebc\uc84c\uc2b5\ub2c8\ub2e4", + "turned_on": "{entity_name} \uc774(\uac00) \ucf1c\uc84c\uc2b5\ub2c8\ub2e4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/lb.json b/homeassistant/components/light/.translations/lb.json new file mode 100644 index 00000000000000..a7f807e8dcda54 --- /dev/null +++ b/homeassistant/components/light/.translations/lb.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "{entity_name} \u00ebmschalten", + "turn_off": "{entity_name} ausschalten", + "turn_on": "{entity_name} uschalten" + }, + "condition_type": { + "is_off": "{entity_name} ass aus", + "is_on": "{entity_name} ass un" + }, + "trigger_type": { + "turned_off": "{entity_name} gouf ausgeschalt", + "turned_on": "{entity_name} gouf ugeschalt" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/nl.json b/homeassistant/components/light/.translations/nl.json new file mode 100644 index 00000000000000..63954ca83a9fa1 --- /dev/null +++ b/homeassistant/components/light/.translations/nl.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Omschakelen {naam}", + "turn_off": "{entity_name} uitschakelen", + "turn_on": "{entity_name} inschakelen" + }, + "condition_type": { + "is_off": "{name} is uitgeschakeld", + "is_on": "{name} is ingeschakeld" + }, + "trigger_type": { + "turned_off": "{name} is uitgeschakeld", + "turned_on": "{name} is ingeschakeld" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/no.json b/homeassistant/components/light/.translations/no.json new file mode 100644 index 00000000000000..785e9ca2912e00 --- /dev/null +++ b/homeassistant/components/light/.translations/no.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Veksle {entity_name}", + "turn_off": "Sl\u00e5 av {entity_name}", + "turn_on": "Sl\u00e5 p\u00e5 {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} er av", + "is_on": "{entity_name} er p\u00e5" + }, + "trigger_type": { + "turned_off": "{entity_name} sl\u00e5tt av", + "turned_on": "{entity_name} sl\u00e5tt p\u00e5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/pl.json b/homeassistant/components/light/.translations/pl.json new file mode 100644 index 00000000000000..22a93909578608 --- /dev/null +++ b/homeassistant/components/light/.translations/pl.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Prze\u0142\u0105cz {entity_name}", + "turn_off": "Wy\u0142\u0105cz {entity_name}", + "turn_on": "W\u0142\u0105cz {entity_name}" + }, + "condition_type": { + "is_off": "(entity_name} jest wy\u0142\u0105czony.", + "is_on": "(entity_name} jest w\u0142\u0105czony." + }, + "trigger_type": { + "turned_off": "{nazwa} wy\u0142\u0105czone", + "turned_on": "{name} w\u0142\u0105czone" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/ru.json b/homeassistant/components/light/.translations/ru.json new file mode 100644 index 00000000000000..a6a7994b7c3603 --- /dev/null +++ b/homeassistant/components/light/.translations/ru.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}", + "turn_off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "is_on": "{entity_name} \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + }, + "trigger_type": { + "turned_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "turned_on": "{entity_name} \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/sl.json b/homeassistant/components/light/.translations/sl.json new file mode 100644 index 00000000000000..bef4f1583b6b19 --- /dev/null +++ b/homeassistant/components/light/.translations/sl.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Preklopite {entity_name}", + "turn_off": "Izklopite {entity_name}", + "turn_on": "Vklopite {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} je izklopljen", + "is_on": "{entity_name} je vklopljen" + }, + "trigger_type": { + "turned_off": "{entity_name} izklopljen", + "turned_on": "{entity_name} vklopljen" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/.translations/zh-Hant.json b/homeassistant/components/light/.translations/zh-Hant.json new file mode 100644 index 00000000000000..5ac06129463b35 --- /dev/null +++ b/homeassistant/components/light/.translations/zh-Hant.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "\u5207\u63db {entity_name}", + "turn_off": "\u95dc\u9589 {entity_name}", + "turn_on": "\u958b\u555f {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u5df2\u95dc\u9589", + "is_on": "{entity_name} \u5df2\u958b\u555f" + }, + "trigger_type": { + "turned_off": "{entity_name} \u5df2\u95dc\u9589", + "turned_on": "{entity_name} \u5df2\u958b\u555f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/light/device_automation.py b/homeassistant/components/light/device_automation.py index ed75b5f906f5a8..61292d47449adf 100644 --- a/homeassistant/components/light/device_automation.py +++ b/homeassistant/components/light/device_automation.py @@ -1,91 +1,56 @@ """Provides device automations for lights.""" import voluptuous as vol -import homeassistant.components.automation.state as state -from homeassistant.core import split_entity_id -from homeassistant.const import ( - CONF_DEVICE_ID, - CONF_DOMAIN, - CONF_ENTITY_ID, - CONF_PLATFORM, - CONF_TYPE, -) -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity_registry import async_entries_for_device +from homeassistant.components.device_automation import toggle_entity +from homeassistant.const import CONF_DOMAIN from . import DOMAIN # mypy: allow-untyped-defs, no-check-untyped-defs -CONF_TURN_OFF = "turn_off" -CONF_TURN_ON = "turn_on" - -ENTITY_TRIGGERS = [ - { - # Trigger when light is turned on - CONF_PLATFORM: "device", - CONF_DOMAIN: DOMAIN, - CONF_TYPE: CONF_TURN_OFF, - }, - { - # Trigger when light is turned off - CONF_PLATFORM: "device", - CONF_DOMAIN: DOMAIN, - CONF_TYPE: CONF_TURN_ON, - }, -] - -TRIGGER_SCHEMA = vol.All( - vol.Schema( - { - vol.Required(CONF_PLATFORM): "device", - vol.Optional(CONF_DEVICE_ID): str, - vol.Required(CONF_DOMAIN): DOMAIN, - vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Required(CONF_TYPE): str, - } - ) +ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) + +CONDITION_SCHEMA = toggle_entity.CONDITION_SCHEMA.extend( + {vol.Required(CONF_DOMAIN): DOMAIN} ) +TRIGGER_SCHEMA = toggle_entity.TRIGGER_SCHEMA.extend( + {vol.Required(CONF_DOMAIN): DOMAIN} +) -def _is_domain(entity, domain): - return split_entity_id(entity.entity_id)[0] == domain +async def async_call_action_from_config(hass, config, variables, context): + """Change state based on configuration.""" + config = ACTION_SCHEMA(config) + await toggle_entity.async_call_action_from_config( + hass, config, variables, context, DOMAIN + ) -async def async_attach_trigger(hass, config, action, automation_info): - """Listen for state changes based on configuration.""" - trigger_type = config.get(CONF_TYPE) - if trigger_type == CONF_TURN_ON: - from_state = "off" - to_state = "on" - else: - from_state = "on" - to_state = "off" - state_config = { - state.CONF_ENTITY_ID: config[CONF_ENTITY_ID], - state.CONF_FROM: from_state, - state.CONF_TO: to_state, - } - - return await state.async_trigger(hass, state_config, action, automation_info) + +def async_condition_from_config(config, config_validation): + """Evaluate state based on configuration.""" + config = CONDITION_SCHEMA(config) + return toggle_entity.async_condition_from_config(config, config_validation) async def async_trigger(hass, config, action, automation_info): - """Temporary so existing automation framework can be used for testing.""" - return await async_attach_trigger(hass, config, action, automation_info) + """Listen for state changes based on configuration.""" + config = TRIGGER_SCHEMA(config) + return await toggle_entity.async_attach_trigger( + hass, config, action, automation_info + ) + + +async def async_get_actions(hass, device_id): + """List device actions.""" + return await toggle_entity.async_get_actions(hass, device_id, DOMAIN) + + +async def async_get_conditions(hass, device_id): + """List device conditions.""" + return await toggle_entity.async_get_conditions(hass, device_id, DOMAIN) async def async_get_triggers(hass, device_id): """List device triggers.""" - triggers = [] - entity_registry = await hass.helpers.entity_registry.async_get_registry() - - entities = async_entries_for_device(entity_registry, device_id) - domain_entities = [x for x in entities if _is_domain(x, DOMAIN)] - for entity in domain_entities: - for trigger in ENTITY_TRIGGERS: - trigger = dict(trigger) - trigger.update(device_id=device_id, entity_id=entity.entity_id) - triggers.append(trigger) - - return triggers + return await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) diff --git a/homeassistant/components/light/strings.json b/homeassistant/components/light/strings.json index 94954bb790b198..77b842ba07833a 100644 --- a/homeassistant/components/light/strings.json +++ b/homeassistant/components/light/strings.json @@ -1,8 +1,17 @@ { "device_automation": { + "action_type": { + "toggle": "Toggle {entity_name}", + "turn_on": "Turn on {entity_name}", + "turn_off": "Turn off {entity_name}" + }, + "condition_type": { + "is_on": "{entity_name} is on", + "is_off": "{entity_name} is off" + }, "trigger_type": { - "turn_on": "{name} turned on", - "turn_off": "{name} turned off" + "turned_on": "{entity_name} turned on", + "turned_off": "{entity_name} turned off" } } } diff --git a/homeassistant/components/linky/.translations/bg.json b/homeassistant/components/linky/.translations/bg.json new file mode 100644 index 00000000000000..6eeb898ee1ffc8 --- /dev/null +++ b/homeassistant/components/linky/.translations/bg.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "\u0412\u0435\u0447\u0435 \u0438\u043c\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d \u043f\u0440\u043e\u0444\u0438\u043b" + }, + "error": { + "access": "\u041d\u044f\u043c\u0430 \u0434\u043e\u0441\u0442\u044a\u043f \u0434\u043e Enedis.fr, \u043c\u043e\u043b\u044f, \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0418\u043d\u0442\u0435\u0440\u043d\u0435\u0442 \u0441\u0432\u044a\u0440\u0437\u0430\u043d\u043e\u0441\u0442\u0442\u0430 \u0441\u0438", + "enedis": "Enedis.fr \u043e\u0442\u0433\u043e\u0432\u043e\u0440\u0438 \u0441 \u0433\u0440\u0435\u0448\u043a\u0430: \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u043e-\u043a\u044a\u0441\u043d\u043e (\u043e\u0431\u0438\u043a\u043d\u043e\u0432\u0435\u043d\u043e \u043d\u0435 \u043c\u0435\u0436\u0434\u0443 23:00 \u0438 02:00)", + "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430 \u0433\u0440\u0435\u0448\u043a\u0430: \u043c\u043e\u043b\u044f, \u043e\u043f\u0438\u0442\u0430\u0439\u0442\u0435 \u043e\u0442\u043d\u043e\u0432\u043e \u043f\u043e-\u043a\u044a\u0441\u043d\u043e (\u043e\u0431\u0438\u043a\u043d\u043e\u0432\u0435\u043d\u043e \u043d\u0435 \u043c\u0435\u0436\u0434\u0443 23:00 \u0438 02:00)", + "username_exists": "\u0412\u0435\u0447\u0435 \u0438\u043c\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d \u043f\u0440\u043e\u0444\u0438\u043b", + "wrong_login": "\u0413\u0440\u0435\u0448\u043a\u0430 \u043f\u0440\u0438 \u0432\u043b\u0438\u0437\u0430\u043d\u0435: \u043f\u0440\u043e\u0432\u0435\u0440\u0435\u0442\u0435 \u0438\u043c\u0435\u0439\u043b\u0430 \u0438 \u043f\u0430\u0440\u043e\u043b\u0430\u0442\u0430 \u0441\u0438" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u0430", + "username": "E-mail" + }, + "description": "\u0412\u044a\u0432\u0435\u0434\u0435\u0442\u0435 \u0438\u043d\u0434\u0435\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u043e\u043d\u043d\u0438\u0442\u0435 \u0441\u0438 \u0434\u0430\u043d\u043d\u0438", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/ca.json b/homeassistant/components/linky/.translations/ca.json new file mode 100644 index 00000000000000..ca437417f590db --- /dev/null +++ b/homeassistant/components/linky/.translations/ca.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "El compte ja ha estat configurat" + }, + "error": { + "access": "No s'ha pogut accedir a Enedis.fr, comprova la teva connexi\u00f3 a Internet", + "enedis": "Enedis.fr ha respost amb un error: torna-ho a provar m\u00e9s tard (millo no entre les 23:00 i les 14:00)", + "unknown": "Error desconegut: torna-ho a provar m\u00e9s tard (millor no entre les 23:00 i les 14:00)", + "username_exists": "El compte ja ha estat configurat", + "wrong_login": "Error d\u2019inici de sessi\u00f3: comprova el teu correu electr\u00f2nic i la contrasenya" + }, + "step": { + "user": { + "data": { + "password": "Contrasenya", + "username": "Correu electr\u00f2nic" + }, + "description": "Introdueix les teves credencials", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/da.json b/homeassistant/components/linky/.translations/da.json new file mode 100644 index 00000000000000..cacad99de584bb --- /dev/null +++ b/homeassistant/components/linky/.translations/da.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "Kontoen er allerede konfigureret" + }, + "error": { + "access": "Kunne ikke f\u00e5 adgang til Enedis.fr, kontroller din internetforbindelse", + "enedis": "Enedis.fr svarede med en fejl: Pr\u00f8v igen senere (normalt ikke mellem 23:00 og 02:00)", + "unknown": "Ukendt fejl: Pr\u00f8v igen senere (normalt ikke mellem 23:00 og 02:00)", + "username_exists": "Kontoen er allerede konfigureret", + "wrong_login": "Loginfejl: Kontroller din e-mail og adgangskode" + }, + "step": { + "user": { + "data": { + "password": "Adgangskode", + "username": "E-mail" + }, + "description": "Indtast dine legitimationsoplysninger", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/de.json b/homeassistant/components/linky/.translations/de.json new file mode 100644 index 00000000000000..3fc13126270c66 --- /dev/null +++ b/homeassistant/components/linky/.translations/de.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "Konto bereits konfiguriert" + }, + "error": { + "access": "Konnte nicht auf Enedis.fr zugreifen, \u00fcberpr\u00fcfe bitte die Internetverbindung", + "enedis": "Enedis.fr antwortete mit einem Fehler: wiederhole den Vorgang sp\u00e4ter (in der Regel nicht zwischen 23 Uhr und 2 Uhr morgens)", + "unknown": "Unbekannter Fehler: Wiederhole den Vorgang sp\u00e4ter (in der Regel nicht zwischen 23 Uhr und 2 Uhr morgens)", + "username_exists": "Konto bereits konfiguriert", + "wrong_login": "Login-Fehler: Pr\u00fcfe bitte E-Mail & Passwort" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "E-Mail" + }, + "description": "Gib deine Zugangsdaten ein", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/es.json b/homeassistant/components/linky/.translations/es.json new file mode 100644 index 00000000000000..511f3c9d8e56f8 --- /dev/null +++ b/homeassistant/components/linky/.translations/es.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "Cuenta ya configurada" + }, + "error": { + "access": "No se pudo acceder a Enedis.fr, compruebe su conexi\u00f3n a Internet", + "enedis": "Enedis.fr respondi\u00f3 con un error: vuelva a intentarlo m\u00e1s tarde (normalmente no entre las 11:00 y las 2 de la ma\u00f1ana)", + "unknown": "Error desconocido: por favor, vuelva a intentarlo m\u00e1s tarde (normalmente no entre las 23:00 y las 02:00 horas).", + "username_exists": "Cuenta ya configurada", + "wrong_login": "Error de inicio de sesi\u00f3n: compruebe su direcci\u00f3n de correo electr\u00f3nico y contrase\u00f1a" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Correo electr\u00f3nico" + }, + "description": "Introduzca sus credenciales", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/fr.json b/homeassistant/components/linky/.translations/fr.json new file mode 100644 index 00000000000000..af12c2b654d8ff --- /dev/null +++ b/homeassistant/components/linky/.translations/fr.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "Compte d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "access": "Impossible d'acc\u00e9der \u00e0 Enedis.fr, merci de v\u00e9rifier votre connexion internet", + "enedis": "Erreur d'Enedis.fr: merci de r\u00e9essayer plus tard (pas entre 23h et 2h)", + "unknown": "Erreur inconnue: merci de r\u00e9essayer plus tard (pas entre 23h et 2h)", + "username_exists": "Compte d\u00e9j\u00e0 configur\u00e9", + "wrong_login": "Impossible de vous identifier: merci de v\u00e9rifier vos identifiants" + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "username": "Email" + }, + "description": "Entrez vos identifiants", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/it.json b/homeassistant/components/linky/.translations/it.json new file mode 100644 index 00000000000000..09d5f7e2d2bcb4 --- /dev/null +++ b/homeassistant/components/linky/.translations/it.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "Account gi\u00e0 configurato" + }, + "error": { + "access": "Impossibile accedere a Enedis.fr, si prega di controllare la connessione internet", + "enedis": "Enedis.fr ha risposto con un errore: si prega di riprovare pi\u00f9 tardi (di solito non tra le 23:00 e le 02:00).", + "unknown": "Errore sconosciuto: riprova pi\u00f9 tardi (in genere non tra le 23:00 e le 02:00)", + "username_exists": "Account gi\u00e0 configurato", + "wrong_login": "Errore di accesso: si prega di controllare la tua E-mail e la password" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "E-mail" + }, + "description": "Inserisci le tue credenziali", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/ko.json b/homeassistant/components/linky/.translations/ko.json new file mode 100644 index 00000000000000..45172e70097596 --- /dev/null +++ b/homeassistant/components/linky/.translations/ko.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "access": "Enedis.fr \uc5d0 \uc811\uc18d\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \uc778\ud130\ub137 \uc5f0\uacb0\uc744 \ud655\uc778\ud574\ubcf4\uc138\uc694", + "enedis": "Enedis.fr \uc774 \uc624\ub958\ub85c \uc751\ub2f5\ud588\uc2b5\ub2c8\ub2e4: \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694 (\uc800\ub141 11\uc2dc \ubd80\ud130 \uc0c8\ubcbd 2\uc2dc\ub294 \ud53c\ud574\uc8fc\uc138\uc694)", + "unknown": "\uc54c \uc218\uc5c6\ub294 \uc624\ub958: \ub098\uc911\uc5d0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694 (\uc800\ub141 11\uc2dc \ubd80\ud130 \uc0c8\ubcbd 2\uc2dc\ub294 \ud53c\ud574\uc8fc\uc138\uc694)", + "username_exists": "\uacc4\uc815\uc774 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "wrong_login": "\ub85c\uadf8\uc778 \uc624\ub958: \uc774\uba54\uc77c \ubc0f \ube44\ubc00\ubc88\ud638\ub97c \ud655\uc778\ud574\uc8fc\uc138\uc694" + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc774\uba54\uc77c" + }, + "description": "\uc790\uaca9 \uc99d\uba85\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/lb.json b/homeassistant/components/linky/.translations/lb.json new file mode 100644 index 00000000000000..cd3c7152c89e7d --- /dev/null +++ b/homeassistant/components/linky/.translations/lb.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "Kont ass scho konfigur\u00e9iert" + }, + "error": { + "access": "Keng Verbindung zu Enedis.fr, iwwerpr\u00e9ift d'Internet Verbindung", + "enedis": "Enedis.fr huet mat engem Feeler ge\u00e4ntwert: prob\u00e9iert sp\u00e9ider nach emol (normalerweis net t\u00ebscht 23h00 an 2h00)", + "unknown": "Onbekannte Feeler: prob\u00e9iert sp\u00e9ider nach emol (normalerweis net t\u00ebscht 23h00 an 2h00)", + "username_exists": "Kont ass scho konfigur\u00e9iert", + "wrong_login": "Feeler beim Login: iwwerpr\u00e9ift \u00e4r E-Mail & Passwuert" + }, + "step": { + "user": { + "data": { + "password": "Passwuert", + "username": "E-Mail" + }, + "description": "F\u00ebllt \u00e4r Login Informatiounen aus", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/nl.json b/homeassistant/components/linky/.translations/nl.json new file mode 100644 index 00000000000000..89759fdf21630e --- /dev/null +++ b/homeassistant/components/linky/.translations/nl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "Account reeds geconfigureerd" + }, + "error": { + "access": "Geen toegang tot Enedis.fr, controleer uw internetverbinding", + "enedis": "Enedis.fr antwoordde met een fout: probeer het later opnieuw (meestal niet tussen 23.00 en 02.00 uur)", + "unknown": "Onbekende fout: probeer het later opnieuw (meestal niet tussen 23.00 en 02.00 uur)", + "username_exists": "Account reeds geconfigureerd", + "wrong_login": "Aanmeldingsfout: controleer uw e-mailadres en wachtwoord" + }, + "step": { + "user": { + "data": { + "password": "Wachtwoord", + "username": "E-mail" + }, + "description": "Voer uw gegevens in", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/no.json b/homeassistant/components/linky/.translations/no.json new file mode 100644 index 00000000000000..c43f434562c152 --- /dev/null +++ b/homeassistant/components/linky/.translations/no.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "Kontoen er allerede konfigurert" + }, + "error": { + "access": "Kunne ikke f\u00e5 tilgang til Enedis.fr, vennligst sjekk internettforbindelsen din", + "enedis": "Enedis.fr svarte med en feil: vennligst pr\u00f8v p\u00e5 nytt senere (vanligvis ikke mellom 23:00 og 02:00)", + "unknown": "Ukjent feil: pr\u00f8v p\u00e5 nytt senere (vanligvis ikke mellom 23:00 og 02:00)", + "username_exists": "Kontoen er allerede konfigurert", + "wrong_login": "Innloggingsfeil: vennligst sjekk e-postadressen og passordet ditt" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "E-post" + }, + "description": "Skriv inn legitimasjonen din", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/pl.json b/homeassistant/components/linky/.translations/pl.json new file mode 100644 index 00000000000000..a4f68fa8687f0a --- /dev/null +++ b/homeassistant/components/linky/.translations/pl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "Konto jest ju\u017c skonfigurowane" + }, + "error": { + "access": "Nie mo\u017cna uzyska\u0107 dost\u0119pu do Enedis.fr, sprawd\u017a po\u0142\u0105czenie internetowe", + "enedis": "Enedis.fr odpowiedzia\u0142 b\u0142\u0119dem: spr\u00f3buj ponownie p\u00f3\u017aniej (zwykle nie mi\u0119dzy 23:00, a 2:00)", + "unknown": "Nieznany b\u0142\u0105d: spr\u00f3buj ponownie p\u00f3\u017aniej (zwykle nie mi\u0119dzy godzin\u0105 23:00, a 2:00)", + "username_exists": "Konto jest ju\u017c skonfigurowane", + "wrong_login": "B\u0142\u0105d logowania: sprawd\u017a adres e-mail i has\u0142o" + }, + "step": { + "user": { + "data": { + "password": "Has\u0142o", + "username": "E-mail" + }, + "description": "Wprowad\u017a dane uwierzytelniaj\u0105ce", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/ru.json b/homeassistant/components/linky/.translations/ru.json new file mode 100644 index 00000000000000..498b5b2f12f29b --- /dev/null +++ b/homeassistant/components/linky/.translations/ru.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430" + }, + "error": { + "access": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u043b\u0443\u0447\u0438\u0442\u044c \u0434\u043e\u0441\u0442\u0443\u043f \u043a Enedis.fr, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a \u0418\u043d\u0442\u0435\u0440\u043d\u0435\u0442\u0443", + "enedis": "Enedis.fr \u043e\u0442\u043f\u0440\u0430\u0432\u0438\u043b \u043e\u0442\u0432\u0435\u0442 \u0441 \u043e\u0448\u0438\u0431\u043a\u043e\u0439: \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435 (\u043d\u0435 \u0432 \u043f\u0440\u043e\u043c\u0435\u0436\u0443\u0442\u043a\u0435 \u0441 23:00 \u043f\u043e 2:00)", + "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430: \u043f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u043f\u043e\u0437\u0436\u0435 (\u043d\u0435 \u0432 \u043f\u0440\u043e\u043c\u0435\u0436\u0443\u0442\u043a\u0435 \u0441 23:00 \u043f\u043e 2:00)", + "username_exists": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0434\u043e\u0431\u0430\u0432\u043b\u0435\u043d\u0430", + "wrong_login": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0432\u0445\u043e\u0434\u0430: \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u0430\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b \u0438 \u043f\u0430\u0440\u043e\u043b\u044c" + }, + "step": { + "user": { + "data": { + "password": "\u041f\u0430\u0440\u043e\u043b\u044c", + "username": "\u0410\u0434\u0440\u0435\u0441 \u044d\u043b\u0435\u043a\u0442\u0440\u043e\u043d\u043d\u043e\u0439 \u043f\u043e\u0447\u0442\u044b" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0412\u0430\u0448\u0438 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/sl.json b/homeassistant/components/linky/.translations/sl.json new file mode 100644 index 00000000000000..9e9d6668fcb8fb --- /dev/null +++ b/homeassistant/components/linky/.translations/sl.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "Ra\u010dun \u017ee nastavljen" + }, + "error": { + "access": "Do Enedis.fr ni bilo mogo\u010de dostopati, preverite internetno povezavo", + "enedis": "Enedis.fr je odgovoril z napako: poskusite pozneje (ponavadi med 23. in 2. uro)", + "unknown": "Neznana napaka: Prosimo, poskusite pozneje (obi\u010dajno ne med 23. in 2. uro)", + "username_exists": "Ra\u010dun \u017ee nastavljen", + "wrong_login": "Napaka pri prijavi: preverite svoj e-po\u0161tni naslov in geslo" + }, + "step": { + "user": { + "data": { + "password": "Geslo", + "username": "E-po\u0161tni naslov" + }, + "description": "Vnesite svoje poverilnice", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/zh-Hans.json b/homeassistant/components/linky/.translations/zh-Hans.json new file mode 100644 index 00000000000000..b450a3cbdb08c7 --- /dev/null +++ b/homeassistant/components/linky/.translations/zh-Hans.json @@ -0,0 +1,16 @@ +{ + "config": { + "abort": { + "username_exists": "\u8d26\u6237\u5df2\u914d\u7f6e\u5b8c\u6210" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u7801", + "username": "\u7535\u5b50\u90ae\u7bb1" + }, + "description": "\u8f93\u5165\u60a8\u7684\u8eab\u4efd\u8ba4\u8bc1" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/.translations/zh-Hant.json b/homeassistant/components/linky/.translations/zh-Hant.json new file mode 100644 index 00000000000000..bcfac6643c8e6f --- /dev/null +++ b/homeassistant/components/linky/.translations/zh-Hant.json @@ -0,0 +1,25 @@ +{ + "config": { + "abort": { + "username_exists": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "access": "\u7121\u6cd5\u8a2a\u554f Enedis.fr\uff0c\u8acb\u6aa2\u67e5\u60a8\u7684\u7db2\u969b\u7db2\u8def\u9023\u7dda", + "enedis": "Endis.fr \u56de\u5831\u932f\u8aa4\uff1a\u8acb\u7a0d\u5f8c\u518d\u8a66\uff08\u901a\u5e38\u907f\u958b\u591c\u9593 11 - \u51cc\u6668 2 \u9ede\u4e4b\u9593\uff09", + "unknown": "\u672a\u77e5\u932f\u8aa4\uff1a\u8acb\u7a0d\u5f8c\u518d\u8a66\uff08\u901a\u5e38\u907f\u958b\u591c\u9593 11 - \u51cc\u6668 2 \u9ede\u4e4b\u9593\uff09", + "username_exists": "\u5e33\u865f\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "wrong_login": "\u767b\u5165\u932f\u8aa4\uff1a\u8acb\u78ba\u8a8d\u96fb\u5b50\u90f5\u4ef6\u8207\u79d8\u5bc6\u6b63\u78ba\u6027" + }, + "step": { + "user": { + "data": { + "password": "\u5bc6\u78bc", + "username": "\u96fb\u5b50\u90f5\u4ef6" + }, + "description": "\u8f38\u5165\u6191\u8b49", + "title": "Linky" + } + }, + "title": "Linky" + } +} \ No newline at end of file diff --git a/homeassistant/components/linky/sensor.py b/homeassistant/components/linky/sensor.py index bd2d38735d6192..5ff04c5ee70a38 100644 --- a/homeassistant/components/linky/sensor.py +++ b/homeassistant/components/linky/sensor.py @@ -18,6 +18,8 @@ from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.typing import HomeAssistantType +from .const import DOMAIN + _LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = timedelta(hours=4) @@ -28,17 +30,6 @@ INDEX_LAST = -2 ATTRIBUTION = "Data provided by Enedis" -SENSORS = { - "yesterday": ("Linky yesterday", DAILY, INDEX_LAST), - "current_month": ("Linky current month", MONTHLY, INDEX_CURRENT), - "last_month": ("Linky last month", MONTHLY, INDEX_LAST), - "current_year": ("Linky current year", YEARLY, INDEX_CURRENT), - "last_year": ("Linky last year", YEARLY, INDEX_LAST), -} -SENSORS_INDEX_LABEL = 0 -SENSORS_INDEX_SCALE = 1 -SENSORS_INDEX_WHEN = 2 - async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Old way of setting up the Linky platform.""" @@ -114,6 +105,12 @@ def __init__(self, name, account: LinkyAccount, scale, when): self._username = account.username self._time = None self._consumption = None + self._unique_id = f"{self._username}_{scale}_{when}" + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id @property def name(self): @@ -144,6 +141,15 @@ def device_state_attributes(self): CONF_USERNAME: self._username, } + @property + def device_info(self): + """Return device information.""" + return { + "identifiers": {(DOMAIN, self.unique_id)}, + "name": self.name, + "manufacturer": "Enedis", + } + async def async_update(self) -> None: """Retrieve the new data for the sensor.""" data = self._account.data[self._scale][self._when] diff --git a/homeassistant/components/liveboxplaytv/media_player.py b/homeassistant/components/liveboxplaytv/media_player.py index 98418d6be81895..c466d71c4c5fd3 100644 --- a/homeassistant/components/liveboxplaytv/media_player.py +++ b/homeassistant/components/liveboxplaytv/media_player.py @@ -70,7 +70,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= try: device = LiveboxPlayTvDevice(host, port, name) livebox_devices.append(device) - except IOError: + except OSError: _LOGGER.error( "Failed to connect to Livebox Play TV at %s:%s. " "Please check your configuration", diff --git a/homeassistant/components/locative/.translations/no.json b/homeassistant/components/locative/.translations/no.json index 00e3337dfe1eeb..8e9b3272f947b3 100644 --- a/homeassistant/components/locative/.translations/no.json +++ b/homeassistant/components/locative/.translations/no.json @@ -10,9 +10,9 @@ "step": { "user": { "description": "Er du sikker p\u00e5 at du vil sette opp Locative Webhook?", - "title": "Sett opp Lokative Webhook" + "title": "Sett opp Locative Webhook" } }, - "title": "Lokative Webhook" + "title": "Locative Webhook" } } \ No newline at end of file diff --git a/homeassistant/components/logi_circle/.translations/it.json b/homeassistant/components/logi_circle/.translations/it.json index 568bf79a40d207..d7c1d9ba9de7e4 100644 --- a/homeassistant/components/logi_circle/.translations/it.json +++ b/homeassistant/components/logi_circle/.translations/it.json @@ -12,10 +12,11 @@ "error": { "auth_error": "Autorizzazione API fallita.", "auth_timeout": "Timeout dell'autorizzazione durante la richiesta del token di accesso.", - "follow_link": "Segui il link e autenticati prima di premere Invio" + "follow_link": "Segui il link e autenticati prima di premere Invia" }, "step": { "auth": { + "description": "Segui il link qui sotto e Accetta l'accesso al tuo account Logi Circle, quindi torna indietro e premi Invia qui sotto. \n\n [Link]({authorization_url})", "title": "Autenticarsi con Logi Circle" }, "user": { diff --git a/homeassistant/components/logi_circle/.translations/pl.json b/homeassistant/components/logi_circle/.translations/pl.json index f39df48ae5a483..5d8e6a0607df4a 100644 --- a/homeassistant/components/logi_circle/.translations/pl.json +++ b/homeassistant/components/logi_circle/.translations/pl.json @@ -16,7 +16,7 @@ }, "step": { "auth": { - "description": "Kliknij poni\u017cszy link i Zaakceptuj dost\u0119p do swojego konta Logi Circle, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Prze\u015blij poni\u017cej. \n\n [Link]({authorization_url})", + "description": "Kliknij poni\u017cszy link i Zaakceptuj dost\u0119p do konta Logi Circle, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Prze\u015blij poni\u017cej. \n\n [Link]({authorization_url})", "title": "Uwierzytelnij za pomoc\u0105 Logi Circle" }, "user": { diff --git a/homeassistant/components/logi_circle/.translations/ru.json b/homeassistant/components/logi_circle/.translations/ru.json index 1e9c089828fe92..40c7c8853daeb4 100644 --- a/homeassistant/components/logi_circle/.translations/ru.json +++ b/homeassistant/components/logi_circle/.translations/ru.json @@ -4,7 +4,7 @@ "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", "external_error": "\u0418\u0441\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u043e \u0438\u0437 \u0434\u0440\u0443\u0433\u043e\u0433\u043e \u043f\u043e\u0442\u043e\u043a\u0430.", "external_setup": "Logi Circle \u0443\u0441\u043f\u0435\u0448\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d \u0438\u0437 \u0434\u0440\u0443\u0433\u043e\u0433\u043e \u043f\u043e\u0442\u043e\u043a\u0430.", - "no_flows": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Logi Circle \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. [\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/logi_circle/)." + "no_flows": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 Logi Circle \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/logi_circle/)." }, "create_entry": { "default": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." diff --git a/homeassistant/components/luci/manifest.json b/homeassistant/components/luci/manifest.json index 153f6b5aea6972..dffb4b52667369 100644 --- a/homeassistant/components/luci/manifest.json +++ b/homeassistant/components/luci/manifest.json @@ -3,8 +3,7 @@ "name": "Luci", "documentation": "https://www.home-assistant.io/components/luci", "requirements": [ - "openwrt-luci-rpc==1.1.0", - "packaging==19.1" + "openwrt-luci-rpc==1.1.1" ], "dependencies": [], "codeowners": ["@fbradyirl"] diff --git a/homeassistant/components/lutron/manifest.json b/homeassistant/components/lutron/manifest.json index bece55ae09d819..451a6f3e33d822 100644 --- a/homeassistant/components/lutron/manifest.json +++ b/homeassistant/components/lutron/manifest.json @@ -3,7 +3,7 @@ "name": "Lutron", "documentation": "https://www.home-assistant.io/components/lutron", "requirements": [ - "pylutron==0.2.2" + "pylutron==0.2.5" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/media_extractor/manifest.json b/homeassistant/components/media_extractor/manifest.json index 419d4b72864400..4e253741b051f8 100644 --- a/homeassistant/components/media_extractor/manifest.json +++ b/homeassistant/components/media_extractor/manifest.json @@ -3,7 +3,7 @@ "name": "Media extractor", "documentation": "https://www.home-assistant.io/components/media_extractor", "requirements": [ - "youtube_dl==2019.09.01" + "youtube_dl==2019.09.12.1" ], "dependencies": [ "media_player" diff --git a/homeassistant/components/media_player/reproduce_state.py b/homeassistant/components/media_player/reproduce_state.py index 4eba4657d9554d..dac08afe471dea 100644 --- a/homeassistant/components/media_player/reproduce_state.py +++ b/homeassistant/components/media_player/reproduce_state.py @@ -36,7 +36,7 @@ ) -# mypy: allow-incomplete-defs, allow-untyped-defs +# mypy: allow-untyped-defs async def _async_reproduce_states( @@ -44,7 +44,7 @@ async def _async_reproduce_states( ) -> None: """Reproduce component states.""" - async def call_service(service: str, keys: Iterable): + async def call_service(service: str, keys: Iterable) -> None: """Call service with set of attributes given.""" data = {} data["entity_id"] = state.entity_id diff --git a/homeassistant/components/met/.translations/es.json b/homeassistant/components/met/.translations/es.json index 7659ab4d2962a8..a475518bd851cb 100644 --- a/homeassistant/components/met/.translations/es.json +++ b/homeassistant/components/met/.translations/es.json @@ -1,7 +1,7 @@ { "config": { "error": { - "name_exists": "El nombre ya existe" + "name_exists": "La ubicaci\u00f3n ya existe" }, "step": { "user": { @@ -14,6 +14,7 @@ "description": "Instituto de meteorolog\u00eda", "title": "Ubicaci\u00f3n" } - } + }, + "title": "Met.no" } } \ No newline at end of file diff --git a/homeassistant/components/met/.translations/it.json b/homeassistant/components/met/.translations/it.json new file mode 100644 index 00000000000000..a1cfd12e8cda7c --- /dev/null +++ b/homeassistant/components/met/.translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "name_exists": "La posizione esiste gi\u00e0" + }, + "step": { + "user": { + "data": { + "elevation": "Altitudine", + "latitude": "Latitudine", + "longitude": "Longitudine", + "name": "Nome" + }, + "description": "Meteorologisk institutt", + "title": "Posizione" + } + }, + "title": "Met.no" + } +} \ No newline at end of file diff --git a/homeassistant/components/met/.translations/ko.json b/homeassistant/components/met/.translations/ko.json index 6900458ba60d15..81a98b9754fe81 100644 --- a/homeassistant/components/met/.translations/ko.json +++ b/homeassistant/components/met/.translations/ko.json @@ -1,7 +1,7 @@ { "config": { "error": { - "name_exists": "\uc774\ub984\uc774 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4" + "name_exists": "\uc704\uce58\uac00 \uc774\ubbf8 \uc874\uc7ac\ud569\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/met/.translations/lb.json b/homeassistant/components/met/.translations/lb.json index 660f639d859718..9f91d37c23360c 100644 --- a/homeassistant/components/met/.translations/lb.json +++ b/homeassistant/components/met/.translations/lb.json @@ -1,7 +1,7 @@ { "config": { "error": { - "name_exists": "Numm g\u00ebtt et schonn" + "name_exists": "Standuert g\u00ebtt et schonn" }, "step": { "user": { diff --git a/homeassistant/components/met/.translations/no.json b/homeassistant/components/met/.translations/no.json index 6ebaa08457f653..9a3ef350ab1082 100644 --- a/homeassistant/components/met/.translations/no.json +++ b/homeassistant/components/met/.translations/no.json @@ -1,7 +1,7 @@ { "config": { "error": { - "name_exists": "Navnet eksisterer allerede" + "name_exists": "Lokasjonen finnes allerede" }, "step": { "user": { diff --git a/homeassistant/components/met/.translations/ru.json b/homeassistant/components/met/.translations/ru.json index d298b1e3b07a5c..d92d28d948419d 100644 --- a/homeassistant/components/met/.translations/ru.json +++ b/homeassistant/components/met/.translations/ru.json @@ -1,7 +1,7 @@ { "config": { "error": { - "name_exists": "\u0418\u043c\u044f \u0443\u0436\u0435 \u0441\u0443\u0449\u0435\u0441\u0442\u0432\u0443\u0435\u0442" + "name_exists": "\u041c\u0435\u0441\u0442\u043e\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u043e" }, "step": { "user": { diff --git a/homeassistant/components/met/.translations/sl.json b/homeassistant/components/met/.translations/sl.json index 5dffbe133e7db6..71ffdaf8509583 100644 --- a/homeassistant/components/met/.translations/sl.json +++ b/homeassistant/components/met/.translations/sl.json @@ -1,7 +1,7 @@ { "config": { "error": { - "name_exists": "Ime \u017ee obstaja" + "name_exists": "Lokacija \u017ee obstaja" }, "step": { "user": { diff --git a/homeassistant/components/met/.translations/zh-Hans.json b/homeassistant/components/met/.translations/zh-Hans.json index 9565bb666181d4..9027347174d3e7 100644 --- a/homeassistant/components/met/.translations/zh-Hans.json +++ b/homeassistant/components/met/.translations/zh-Hans.json @@ -1,10 +1,15 @@ { "config": { + "error": { + "name_exists": "\u4f4d\u7f6e\u5df2\u5b58\u5728" + }, "step": { "user": { "data": { + "elevation": "\u6d77\u62d4", "latitude": "\u7eac\u5ea6", - "longitude": "\u7ecf\u5ea6" + "longitude": "\u7ecf\u5ea6", + "name": "\u540d\u79f0" }, "title": "\u4f4d\u7f6e" } diff --git a/homeassistant/components/met/.translations/zh-Hant.json b/homeassistant/components/met/.translations/zh-Hant.json index c49c90ee6e422c..de7c34ffc87969 100644 --- a/homeassistant/components/met/.translations/zh-Hant.json +++ b/homeassistant/components/met/.translations/zh-Hant.json @@ -1,7 +1,7 @@ { "config": { "error": { - "name_exists": "\u8a72\u540d\u7a31\u5df2\u5b58\u5728" + "name_exists": "\u8a72\u5ea7\u6a19\u5df2\u5b58\u5728" }, "step": { "user": { diff --git a/homeassistant/components/meteo_france/__init__.py b/homeassistant/components/meteo_france/__init__.py index d6460fd6e5aa01..cfcd78400bd5e8 100644 --- a/homeassistant/components/meteo_france/__init__.py +++ b/homeassistant/components/meteo_france/__init__.py @@ -4,70 +4,17 @@ import voluptuous as vol -from homeassistant.const import CONF_MONITORED_CONDITIONS, TEMP_CELSIUS +from homeassistant.const import CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv from homeassistant.helpers.discovery import load_platform from homeassistant.util import Throttle -_LOGGER = logging.getLogger(__name__) - -ATTRIBUTION = "Data provided by Météo-France" - -CONF_CITY = "city" +from .const import DOMAIN, CONF_CITY, SENSOR_TYPES, DATA_METEO_FRANCE -DATA_METEO_FRANCE = "data_meteo_france" -DEFAULT_WEATHER_CARD = True -DOMAIN = "meteo_france" +_LOGGER = logging.getLogger(__name__) SCAN_INTERVAL = datetime.timedelta(minutes=5) -SENSOR_TYPES = { - "rain_chance": ["Rain chance", "%"], - "freeze_chance": ["Freeze chance", "%"], - "thunder_chance": ["Thunder chance", "%"], - "snow_chance": ["Snow chance", "%"], - "weather": ["Weather", None], - "wind_speed": ["Wind Speed", "km/h"], - "next_rain": ["Next rain", "min"], - "temperature": ["Temperature", TEMP_CELSIUS], - "uv": ["UV", None], - "weather_alert": ["Weather Alert", None], -} - -CONDITION_CLASSES = { - "clear-night": ["Nuit Claire"], - "cloudy": ["Très nuageux"], - "fog": ["Brume ou bancs de brouillard", "Brouillard", "Brouillard givrant"], - "hail": ["Risque de grêle"], - "lightning": ["Risque d'orages", "Orages"], - "lightning-rainy": ["Pluie orageuses", "Pluies orageuses", "Averses orageuses"], - "partlycloudy": ["Ciel voilé", "Ciel voilé nuit", "Éclaircies"], - "pouring": ["Pluie forte"], - "rainy": [ - "Bruine / Pluie faible", - "Bruine", - "Pluie faible", - "Pluies éparses / Rares averses", - "Pluies éparses", - "Rares averses", - "Pluie / Averses", - "Averses", - "Pluie", - ], - "snowy": [ - "Neige / Averses de neige", - "Neige", - "Averses de neige", - "Neige forte", - "Quelques flocons", - ], - "snowy-rainy": ["Pluie et neige", "Pluie verglaçante"], - "sunny": ["Ensoleillé"], - "windy": [], - "windy-variant": [], - "exceptional": [], -} - def has_all_unique_cities(value): """Validate that all cities are unique.""" diff --git a/homeassistant/components/meteo_france/const.py b/homeassistant/components/meteo_france/const.py new file mode 100644 index 00000000000000..223aca20bac240 --- /dev/null +++ b/homeassistant/components/meteo_france/const.py @@ -0,0 +1,112 @@ +"""Meteo-France component constants.""" + +from homeassistant.const import TEMP_CELSIUS + +DOMAIN = "meteo_france" +DATA_METEO_FRANCE = "data_meteo_france" +ATTRIBUTION = "Data provided by Météo-France" + +CONF_CITY = "city" + +DEFAULT_WEATHER_CARD = True + +SENSOR_TYPE_NAME = "name" +SENSOR_TYPE_UNIT = "unit" +SENSOR_TYPE_ICON = "icon" +SENSOR_TYPE_CLASS = "device_class" +SENSOR_TYPES = { + "rain_chance": { + SENSOR_TYPE_NAME: "Rain chance", + SENSOR_TYPE_UNIT: "%", + SENSOR_TYPE_ICON: "mdi:weather-rainy", + SENSOR_TYPE_CLASS: None, + }, + "freeze_chance": { + SENSOR_TYPE_NAME: "Freeze chance", + SENSOR_TYPE_UNIT: "%", + SENSOR_TYPE_ICON: "mdi:snowflake", + SENSOR_TYPE_CLASS: None, + }, + "thunder_chance": { + SENSOR_TYPE_NAME: "Thunder chance", + SENSOR_TYPE_UNIT: "%", + SENSOR_TYPE_ICON: "mdi:weather-lightning", + SENSOR_TYPE_CLASS: None, + }, + "snow_chance": { + SENSOR_TYPE_NAME: "Snow chance", + SENSOR_TYPE_UNIT: "%", + SENSOR_TYPE_ICON: "mdi:weather-snowy", + SENSOR_TYPE_CLASS: None, + }, + "weather": { + SENSOR_TYPE_NAME: "Weather", + SENSOR_TYPE_UNIT: None, + SENSOR_TYPE_ICON: "mdi:weather-partly-cloudy", + SENSOR_TYPE_CLASS: None, + }, + "wind_speed": { + SENSOR_TYPE_NAME: "Wind Speed", + SENSOR_TYPE_UNIT: "km/h", + SENSOR_TYPE_ICON: "mdi:weather-windy", + SENSOR_TYPE_CLASS: None, + }, + "next_rain": { + SENSOR_TYPE_NAME: "Next rain", + SENSOR_TYPE_UNIT: "min", + SENSOR_TYPE_ICON: "mdi:weather-rainy", + SENSOR_TYPE_CLASS: None, + }, + "temperature": { + SENSOR_TYPE_NAME: "Temperature", + SENSOR_TYPE_UNIT: TEMP_CELSIUS, + SENSOR_TYPE_ICON: "mdi:thermometer", + SENSOR_TYPE_CLASS: "temperature", + }, + "uv": { + SENSOR_TYPE_NAME: "UV", + SENSOR_TYPE_UNIT: None, + SENSOR_TYPE_ICON: "mdi:sunglasses", + SENSOR_TYPE_CLASS: None, + }, + "weather_alert": { + SENSOR_TYPE_NAME: "Weather Alert", + SENSOR_TYPE_UNIT: None, + SENSOR_TYPE_ICON: "mdi:weather-cloudy-alert", + SENSOR_TYPE_CLASS: None, + }, +} + +CONDITION_CLASSES = { + "clear-night": ["Nuit Claire"], + "cloudy": ["Très nuageux"], + "fog": ["Brume ou bancs de brouillard", "Brouillard", "Brouillard givrant"], + "hail": ["Risque de grêle"], + "lightning": ["Risque d'orages", "Orages"], + "lightning-rainy": ["Pluie orageuses", "Pluies orageuses", "Averses orageuses"], + "partlycloudy": ["Ciel voilé", "Ciel voilé nuit", "Éclaircies"], + "pouring": ["Pluie forte"], + "rainy": [ + "Bruine / Pluie faible", + "Bruine", + "Pluie faible", + "Pluies éparses / Rares averses", + "Pluies éparses", + "Rares averses", + "Pluie / Averses", + "Averses", + "Pluie", + ], + "snowy": [ + "Neige / Averses de neige", + "Neige", + "Averses de neige", + "Neige forte", + "Quelques flocons", + ], + "snowy-rainy": ["Pluie et neige", "Pluie verglaçante"], + "sunny": ["Ensoleillé"], + "windy": [], + "windy-variant": [], + "exceptional": [], +} diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 95113a60cd38ee..8c2bd32048fc00 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -4,7 +4,16 @@ from homeassistant.const import ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS from homeassistant.helpers.entity import Entity -from . import ATTRIBUTION, CONF_CITY, DATA_METEO_FRANCE, SENSOR_TYPES +from .const import ( + ATTRIBUTION, + CONF_CITY, + DATA_METEO_FRANCE, + SENSOR_TYPES, + SENSOR_TYPE_ICON, + SENSOR_TYPE_NAME, + SENSOR_TYPE_UNIT, + SENSOR_TYPE_CLASS, +) _LOGGER = logging.getLogger(__name__) @@ -44,7 +53,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): alert_watcher = None else: _LOGGER.info( - "Weather alert watcher added for %s" "in department %s", + "Weather alert watcher added for %s in department %s", city, datas["dept"], ) @@ -79,7 +88,7 @@ def __init__(self, condition, client, alert_watcher): @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(self._data["name"], SENSOR_TYPES[self._condition][0]) + return f"{self._data['name']} {SENSOR_TYPES[self._condition][SENSOR_TYPE_NAME]}" @property def state(self): @@ -111,7 +120,17 @@ def device_state_attributes(self): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return SENSOR_TYPES[self._condition][1] + return SENSOR_TYPES[self._condition][SENSOR_TYPE_UNIT] + + @property + def icon(self): + """Return the icon.""" + return SENSOR_TYPES[self._condition][SENSOR_TYPE_ICON] + + @property + def device_class(self): + """Return the device class of the sensor.""" + return SENSOR_TYPES[self._condition][SENSOR_TYPE_CLASS] def update(self): """Fetch new state data for the sensor.""" diff --git a/homeassistant/components/meteo_france/weather.py b/homeassistant/components/meteo_france/weather.py index 9a861d13c2eab4..00da55809ffb64 100644 --- a/homeassistant/components/meteo_france/weather.py +++ b/homeassistant/components/meteo_france/weather.py @@ -12,7 +12,7 @@ import homeassistant.util.dt as dt_util from homeassistant.const import TEMP_CELSIUS -from . import ATTRIBUTION, CONDITION_CLASSES, CONF_CITY, DATA_METEO_FRANCE +from .const import ATTRIBUTION, CONDITION_CLASSES, CONF_CITY, DATA_METEO_FRANCE _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/miflora/sensor.py b/homeassistant/components/miflora/sensor.py index 86f1462e2cca55..28020a801750f3 100644 --- a/homeassistant/components/miflora/sensor.py +++ b/homeassistant/components/miflora/sensor.py @@ -157,7 +157,7 @@ def update(self): try: _LOGGER.debug("Polling data for %s", self.name) data = self.poller.parameter_value(self.parameter) - except IOError as ioerr: + except OSError as ioerr: _LOGGER.info("Polling error %s", ioerr) return except BluetoothBackendException as bterror: diff --git a/homeassistant/components/mitemp_bt/sensor.py b/homeassistant/components/mitemp_bt/sensor.py index 9cd1f1cebc29be..adeba48dbc8517 100644 --- a/homeassistant/components/mitemp_bt/sensor.py +++ b/homeassistant/components/mitemp_bt/sensor.py @@ -157,7 +157,7 @@ def update(self): try: _LOGGER.debug("Polling data for %s", self.name) data = self.poller.parameter_value(self.parameter) - except IOError as ioerr: + except OSError as ioerr: _LOGGER.warning("Polling error %s", ioerr) return except BluetoothBackendException as bterror: diff --git a/homeassistant/components/mobile_app/.translations/it.json b/homeassistant/components/mobile_app/.translations/it.json index 049e551d19bdfe..37c0deb9c2d8e3 100644 --- a/homeassistant/components/mobile_app/.translations/it.json +++ b/homeassistant/components/mobile_app/.translations/it.json @@ -1,11 +1,11 @@ { "config": { "abort": { - "install_app": "Apri l'app per dispositivi mobili per configurare l'integrazione con Home Assistant. Vedi [i documenti] ( {apps_url} ) per un elenco di app compatibili." + "install_app": "Apri l'App per dispositivi mobili per configurare l'integrazione con Home Assistant. Vedi [i documenti]({apps_url}) per un elenco di app compatibili." }, "step": { "confirm": { - "description": "Vuoi configurare il componente Mobile App?", + "description": "Si desidera configurare il componente App per dispositivi mobili?", "title": "App per dispositivi mobili" } }, diff --git a/homeassistant/components/mobile_app/notify.py b/homeassistant/components/mobile_app/notify.py index d16ed23266a412..1e6a0517026255 100644 --- a/homeassistant/components/mobile_app/notify.py +++ b/homeassistant/components/mobile_app/notify.py @@ -1,6 +1,5 @@ """Support for mobile_app push notifications.""" import asyncio -from datetime import datetime, timezone import logging import async_timeout @@ -60,7 +59,7 @@ def log_rate_limits(hass, device_name, resp, level=logging.INFO): rate_limits = resp[ATTR_PUSH_RATE_LIMITS] resetsAt = rate_limits[ATTR_PUSH_RATE_LIMITS_RESETS_AT] - resetsAtTime = dt_util.parse_datetime(resetsAt) - datetime.now(timezone.utc) + resetsAtTime = dt_util.parse_datetime(resetsAt) - dt_util.utcnow() rate_limit_msg = ( "mobile_app push notification rate limits for %s: " "%d sent, %d allowed, %d errors, " diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 75552d1d14b8d7..8d83cd0cc2ba0f 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -10,7 +10,7 @@ import socket import ssl import time -from typing import Any, Callable, List, Optional, Union, cast # noqa: F401 +from typing import Any, Callable, List, Optional, Union import attr import requests.certs @@ -479,7 +479,7 @@ async def _async_setup_server(hass: HomeAssistantType, config: ConfigType): This method is a coroutine. """ - conf = config.get(DOMAIN, {}) # type: ConfigType + conf: ConfigType = config.get(DOMAIN, {}) success, broker_config = await server.async_start( hass, conf.get(CONF_PASSWORD), conf.get(CONF_EMBEDDED) @@ -502,16 +502,16 @@ async def _async_setup_discovery( _LOGGER.error("Unable to load MQTT discovery") return False - success = await discovery.async_start( + success: bool = await discovery.async_start( hass, conf[CONF_DISCOVERY_PREFIX], hass_config, config_entry - ) # type: bool + ) return success async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: """Start the MQTT protocol service.""" - conf = config.get(DOMAIN) # type: Optional[ConfigType] + conf: Optional[ConfigType] = config.get(DOMAIN) # We need this because discovery can cause components to be set up and # otherwise it will not load the users config. @@ -621,7 +621,7 @@ async def async_setup_entry(hass, entry): birth_message = None # Be able to override versions other than TLSv1.0 under Python3.6 - conf_tls_version = conf.get(CONF_TLS_VERSION) # type: str + conf_tls_version: str = conf.get(CONF_TLS_VERSION) if conf_tls_version == "1.2": tls_version = ssl.PROTOCOL_TLSv1_2 elif conf_tls_version == "1.1": @@ -655,7 +655,7 @@ async def async_setup_entry(hass, entry): tls_version=tls_version, ) - result = await hass.data[DATA_MQTT].async_connect() # type: str + result: str = await hass.data[DATA_MQTT].async_connect() if result == CONNECTION_FAILED: return False @@ -671,11 +671,11 @@ async def async_stop_mqtt(event: Event): async def async_publish_service(call: ServiceCall): """Handle MQTT publish service calls.""" - msg_topic = call.data[ATTR_TOPIC] # type: str + msg_topic: str = call.data[ATTR_TOPIC] payload = call.data.get(ATTR_PAYLOAD) payload_template = call.data.get(ATTR_PAYLOAD_TEMPLATE) - qos = call.data[ATTR_QOS] # type: int - retain = call.data[ATTR_RETAIN] # type: bool + qos: int = call.data[ATTR_QOS] + retain: bool = call.data[ATTR_RETAIN] if payload_template is not None: try: payload = template.Template(payload_template, hass).async_render() @@ -741,14 +741,14 @@ def __init__( self.broker = broker self.port = port self.keepalive = keepalive - self.subscriptions = [] # type: List[Subscription] + self.subscriptions: List[Subscription] = [] self.birth_message = birth_message self.connected = False - self._mqttc = None # type: mqtt.Client + self._mqttc: mqtt.Client = None self._paho_lock = asyncio.Lock() if protocol == PROTOCOL_31: - proto = mqtt.MQTTv31 # type: int + proto: int = mqtt.MQTTv31 else: proto = mqtt.MQTTv311 @@ -796,7 +796,7 @@ async def async_connect(self) -> str: This method is a coroutine. """ - result = None # type: int + result: int = None try: result = await self.hass.async_add_job( self._mqttc.connect, self.broker, self.port, self.keepalive @@ -870,7 +870,7 @@ async def _async_unsubscribe(self, topic: str) -> None: This method is a coroutine. """ async with self._paho_lock: - result = None # type: int + result: int = None result, _ = await self.hass.async_add_job(self._mqttc.unsubscribe, topic) _raise_on_error(result) @@ -879,7 +879,7 @@ async def _async_perform_subscription(self, topic: str, qos: int) -> None: _LOGGER.debug("Subscribing to %s", topic) async with self._paho_lock: - result = None # type: int + result: int = None result, _ = await self.hass.async_add_job(self._mqttc.subscribe, topic, qos) _raise_on_error(result) @@ -928,7 +928,7 @@ def _mqtt_handle_message(self, msg) -> None: if not _match_topic(subscription.topic, msg.topic): continue - payload = msg.payload # type: SubscribePayloadType + payload: SubscribePayloadType = msg.payload if subscription.encoding is not None: try: payload = msg.payload.decode(subscription.encoding) @@ -1077,7 +1077,7 @@ class MqttAvailability(Entity): def __init__(self, config: dict) -> None: """Initialize the availability mixin.""" self._availability_sub_state = None - self._available = False # type: bool + self._available = False self._avail_config = config diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 4617fcf054a232..bcf398464bc7c0 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -1,4 +1,5 @@ """Support for MQTT binary sensors.""" +from datetime import timedelta import logging import voluptuous as vol @@ -21,7 +22,9 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.helpers.event as evt +from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.util import dt as dt_util from . import ( ATTR_DISCOVERY_HASH, @@ -43,12 +46,14 @@ DEFAULT_PAYLOAD_OFF = "OFF" DEFAULT_PAYLOAD_ON = "ON" DEFAULT_FORCE_UPDATE = False +CONF_EXPIRE_AFTER = "expire_after" PLATFORM_SCHEMA = ( mqtt.MQTT_RO_PLATFORM_SCHEMA.extend( { vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_EXPIRE_AFTER): cv.positive_int, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_OFF_DELAY): vol.All(vol.Coerce(int), vol.Range(min=0)), @@ -112,8 +117,9 @@ def __init__(self, config, config_entry, discovery_hash): self._unique_id = config.get(CONF_UNIQUE_ID) self._state = None self._sub_state = None + self._expiration_trigger = None self._delay_listener = None - + self._expired = None device_config = config.get(CONF_DEVICE) MqttAttributes.__init__(self, config) @@ -153,6 +159,26 @@ def off_delay_listener(now): def state_message_received(msg): """Handle a new received MQTT state message.""" payload = msg.payload + # auto-expire enabled? + expire_after = self._config.get(CONF_EXPIRE_AFTER) + + if expire_after is not None and expire_after > 0: + + # When expire_after is set, and we receive a message, assume device is not expired since it has to be to receive the message + self._expired = False + + # Reset old trigger + if self._expiration_trigger: + self._expiration_trigger() + self._expiration_trigger = None + + # Set new trigger + expiration_at = dt_util.utcnow() + timedelta(seconds=expire_after) + + self._expiration_trigger = async_track_point_in_utc_time( + self.hass, self.value_is_expired, expiration_at + ) + value_template = self._config.get(CONF_VALUE_TEMPLATE) if value_template is not None: payload = value_template.async_render_with_possible_json_value( @@ -202,6 +228,15 @@ async def async_will_remove_from_hass(self): await MqttAttributes.async_will_remove_from_hass(self) await MqttAvailability.async_will_remove_from_hass(self) + @callback + def value_is_expired(self, *_): + """Triggered when value is expired.""" + + self._expiration_trigger = None + self._expired = True + + self.async_write_ha_state() + @property def should_poll(self): """Return the polling state.""" @@ -231,3 +266,12 @@ def force_update(self): def unique_id(self): """Return a unique ID.""" return self._unique_id + + @property + def available(self) -> bool: + """Return true if the device is available and value has not expired.""" + expire_after = self._config.get(CONF_EXPIRE_AFTER) + # pylint: disable=no-member + return MqttAvailability.available.fget(self) and ( + expire_after is None or not self._expired + ) diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 1df635bbde4ab3..f3ae36c5746855 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -7,13 +7,19 @@ from homeassistant.components import camera, mqtt from homeassistant.components.camera import PLATFORM_SCHEMA, Camera -from homeassistant.const import CONF_NAME +from homeassistant.const import CONF_NAME, CONF_DEVICE from homeassistant.core import callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from . import ATTR_DISCOVERY_HASH, CONF_UNIQUE_ID, MqttDiscoveryUpdate, subscription +from . import ( + ATTR_DISCOVERY_HASH, + CONF_UNIQUE_ID, + MqttDiscoveryUpdate, + MqttEntityDeviceInfo, + subscription, +) from .discovery import MQTT_DISCOVERY_NEW, clear_discovery_hash _LOGGER = logging.getLogger(__name__) @@ -26,6 +32,7 @@ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_TOPIC): mqtt.valid_subscribe_topic, vol.Optional(CONF_UNIQUE_ID): cv.string, + vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, } ) @@ -45,7 +52,9 @@ async def async_discover(discovery_payload): try: discovery_hash = discovery_payload.pop(ATTR_DISCOVERY_HASH) config = PLATFORM_SCHEMA(discovery_payload) - await _async_setup_entity(config, async_add_entities, discovery_hash) + await _async_setup_entity( + config, async_add_entities, config_entry, discovery_hash + ) except Exception: if discovery_hash: clear_discovery_hash(hass, discovery_hash) @@ -56,15 +65,17 @@ async def async_discover(discovery_payload): ) -async def _async_setup_entity(config, async_add_entities, discovery_hash=None): +async def _async_setup_entity( + config, async_add_entities, config_entry=None, discovery_hash=None +): """Set up the MQTT Camera.""" - async_add_entities([MqttCamera(config, discovery_hash)]) + async_add_entities([MqttCamera(config, config_entry, discovery_hash)]) -class MqttCamera(MqttDiscoveryUpdate, Camera): +class MqttCamera(MqttDiscoveryUpdate, MqttEntityDeviceInfo, Camera): """representation of a MQTT camera.""" - def __init__(self, config, discovery_hash): + def __init__(self, config, config_entry, discovery_hash): """Initialize the MQTT Camera.""" self._config = config self._unique_id = config.get(CONF_UNIQUE_ID) @@ -73,8 +84,11 @@ def __init__(self, config, discovery_hash): self._qos = 0 self._last_image = None + device_config = config.get(CONF_DEVICE) + Camera.__init__(self) MqttDiscoveryUpdate.__init__(self, discovery_hash, self.discovery_update) + MqttEntityDeviceInfo.__init__(self, device_config, config_entry) async def async_added_to_hass(self): """Subscribe MQTT events.""" @@ -85,6 +99,7 @@ async def discovery_update(self, discovery_payload): """Handle updated discovery message.""" config = PLATFORM_SCHEMA(discovery_payload) self._config = config + await self.device_info_discovery_update(config) await self._subscribe_topics() self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/manifest.json b/homeassistant/components/mqtt/manifest.json index d63d1707fac2eb..2df50699a9d8d3 100644 --- a/homeassistant/components/mqtt/manifest.json +++ b/homeassistant/components/mqtt/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/mqtt", "requirements": [ - "hbmqtt==0.9.4", + "hbmqtt==0.9.5", "paho-mqtt==1.4.0" ], "dependencies": [ diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py index d12ecd9d3a61a9..45f603a2cb44f8 100644 --- a/homeassistant/components/mysensors/const.py +++ b/homeassistant/components/mysensors/const.py @@ -26,72 +26,72 @@ UPDATE_DELAY = 0.1 BINARY_SENSOR_TYPES = { - "S_DOOR": "V_TRIPPED", - "S_MOTION": "V_TRIPPED", - "S_SMOKE": "V_TRIPPED", - "S_SPRINKLER": "V_TRIPPED", - "S_WATER_LEAK": "V_TRIPPED", - "S_SOUND": "V_TRIPPED", - "S_VIBRATION": "V_TRIPPED", - "S_MOISTURE": "V_TRIPPED", + "S_DOOR": {"V_TRIPPED"}, + "S_MOTION": {"V_TRIPPED"}, + "S_SMOKE": {"V_TRIPPED"}, + "S_SPRINKLER": {"V_TRIPPED"}, + "S_WATER_LEAK": {"V_TRIPPED"}, + "S_SOUND": {"V_TRIPPED"}, + "S_VIBRATION": {"V_TRIPPED"}, + "S_MOISTURE": {"V_TRIPPED"}, } -CLIMATE_TYPES = {"S_HVAC": "V_HVAC_FLOW_STATE"} +CLIMATE_TYPES = {"S_HVAC": {"V_HVAC_FLOW_STATE"}} -COVER_TYPES = {"S_COVER": ["V_DIMMER", "V_PERCENTAGE", "V_LIGHT", "V_STATUS"]} +COVER_TYPES = {"S_COVER": {"V_DIMMER", "V_PERCENTAGE", "V_LIGHT", "V_STATUS"}} -DEVICE_TRACKER_TYPES = {"S_GPS": "V_POSITION"} +DEVICE_TRACKER_TYPES = {"S_GPS": {"V_POSITION"}} LIGHT_TYPES = { - "S_DIMMER": ["V_DIMMER", "V_PERCENTAGE"], - "S_RGB_LIGHT": "V_RGB", - "S_RGBW_LIGHT": "V_RGBW", + "S_DIMMER": {"V_DIMMER", "V_PERCENTAGE"}, + "S_RGB_LIGHT": {"V_RGB"}, + "S_RGBW_LIGHT": {"V_RGBW"}, } -NOTIFY_TYPES = {"S_INFO": "V_TEXT"} +NOTIFY_TYPES = {"S_INFO": {"V_TEXT"}} SENSOR_TYPES = { - "S_SOUND": "V_LEVEL", - "S_VIBRATION": "V_LEVEL", - "S_MOISTURE": "V_LEVEL", - "S_INFO": "V_TEXT", - "S_GPS": "V_POSITION", - "S_TEMP": "V_TEMP", - "S_HUM": "V_HUM", - "S_BARO": ["V_PRESSURE", "V_FORECAST"], - "S_WIND": ["V_WIND", "V_GUST", "V_DIRECTION"], - "S_RAIN": ["V_RAIN", "V_RAINRATE"], - "S_UV": "V_UV", - "S_WEIGHT": ["V_WEIGHT", "V_IMPEDANCE"], - "S_POWER": ["V_WATT", "V_KWH", "V_VAR", "V_VA", "V_POWER_FACTOR"], - "S_DISTANCE": "V_DISTANCE", - "S_LIGHT_LEVEL": ["V_LIGHT_LEVEL", "V_LEVEL"], - "S_IR": "V_IR_RECEIVE", - "S_WATER": ["V_FLOW", "V_VOLUME"], - "S_CUSTOM": ["V_VAR1", "V_VAR2", "V_VAR3", "V_VAR4", "V_VAR5", "V_CUSTOM"], - "S_SCENE_CONTROLLER": ["V_SCENE_ON", "V_SCENE_OFF"], - "S_COLOR_SENSOR": "V_RGB", - "S_MULTIMETER": ["V_VOLTAGE", "V_CURRENT", "V_IMPEDANCE"], - "S_GAS": ["V_FLOW", "V_VOLUME"], - "S_WATER_QUALITY": ["V_TEMP", "V_PH", "V_ORP", "V_EC"], - "S_AIR_QUALITY": ["V_DUST_LEVEL", "V_LEVEL"], - "S_DUST": ["V_DUST_LEVEL", "V_LEVEL"], + "S_SOUND": {"V_LEVEL"}, + "S_VIBRATION": {"V_LEVEL"}, + "S_MOISTURE": {"V_LEVEL"}, + "S_INFO": {"V_TEXT"}, + "S_GPS": {"V_POSITION"}, + "S_TEMP": {"V_TEMP"}, + "S_HUM": {"V_HUM"}, + "S_BARO": {"V_PRESSURE", "V_FORECAST"}, + "S_WIND": {"V_WIND", "V_GUST", "V_DIRECTION"}, + "S_RAIN": {"V_RAIN", "V_RAINRATE"}, + "S_UV": {"V_UV"}, + "S_WEIGHT": {"V_WEIGHT", "V_IMPEDANCE"}, + "S_POWER": {"V_WATT", "V_KWH", "V_VAR", "V_VA", "V_POWER_FACTOR"}, + "S_DISTANCE": {"V_DISTANCE"}, + "S_LIGHT_LEVEL": {"V_LIGHT_LEVEL", "V_LEVEL"}, + "S_IR": {"V_IR_RECEIVE"}, + "S_WATER": {"V_FLOW", "V_VOLUME"}, + "S_CUSTOM": {"V_VAR1", "V_VAR2", "V_VAR3", "V_VAR4", "V_VAR5", "V_CUSTOM"}, + "S_SCENE_CONTROLLER": {"V_SCENE_ON", "V_SCENE_OFF"}, + "S_COLOR_SENSOR": {"V_RGB"}, + "S_MULTIMETER": {"V_VOLTAGE", "V_CURRENT", "V_IMPEDANCE"}, + "S_GAS": {"V_FLOW", "V_VOLUME"}, + "S_WATER_QUALITY": {"V_TEMP", "V_PH", "V_ORP", "V_EC"}, + "S_AIR_QUALITY": {"V_DUST_LEVEL", "V_LEVEL"}, + "S_DUST": {"V_DUST_LEVEL", "V_LEVEL"}, } SWITCH_TYPES = { - "S_LIGHT": "V_LIGHT", - "S_BINARY": "V_STATUS", - "S_DOOR": "V_ARMED", - "S_MOTION": "V_ARMED", - "S_SMOKE": "V_ARMED", - "S_SPRINKLER": "V_STATUS", - "S_WATER_LEAK": "V_ARMED", - "S_SOUND": "V_ARMED", - "S_VIBRATION": "V_ARMED", - "S_MOISTURE": "V_ARMED", - "S_IR": "V_IR_SEND", - "S_LOCK": "V_LOCK_STATUS", - "S_WATER_QUALITY": "V_STATUS", + "S_LIGHT": {"V_LIGHT"}, + "S_BINARY": {"V_STATUS"}, + "S_DOOR": {"V_ARMED"}, + "S_MOTION": {"V_ARMED"}, + "S_SMOKE": {"V_ARMED"}, + "S_SPRINKLER": {"V_STATUS"}, + "S_WATER_LEAK": {"V_ARMED"}, + "S_SOUND": {"V_ARMED"}, + "S_VIBRATION": {"V_ARMED"}, + "S_MOISTURE": {"V_ARMED"}, + "S_IR": {"V_IR_SEND"}, + "S_LOCK": {"V_LOCK_STATUS"}, + "S_WATER_QUALITY": {"V_STATUS"}, } diff --git a/homeassistant/components/mysensors/helpers.py b/homeassistant/components/mysensors/helpers.py index fda89158293bb2..f0e9b06b762a4c 100644 --- a/homeassistant/components/mysensors/helpers.py +++ b/homeassistant/components/mysensors/helpers.py @@ -121,20 +121,23 @@ def validate_child(gateway, node_id, child, value_type=None): child_type_name = next( (member.name for member in pres if member.value == child.type), None ) - value_types = [value_type] if value_type else [*child.values] - value_type_names = [ + value_types = {value_type} if value_type else {*child.values} + value_type_names = { member.name for member in set_req if member.value in value_types - ] + } platforms = TYPE_TO_PLATFORMS.get(child_type_name, []) if not platforms: _LOGGER.warning("Child type %s is not supported", child.type) return validated for platform in platforms: - v_names = FLAT_PLATFORM_TYPES[platform, child_type_name] - if not isinstance(v_names, list): - v_names = [v_names] - v_names = [v_name for v_name in v_names if v_name in value_type_names] + platform_v_names = FLAT_PLATFORM_TYPES[platform, child_type_name] + v_names = platform_v_names & value_type_names + if not v_names: + child_value_names = { + member.name for member in set_req if member.value in child.values + } + v_names = platform_v_names & child_value_names for v_name in v_names: child_schema_gen = SCHEMAS.get((platform, v_name), default_schema) diff --git a/homeassistant/components/nest/.translations/ru.json b/homeassistant/components/nest/.translations/ru.json index 1c24acd96e427a..ac88ed224edb26 100644 --- a/homeassistant/components/nest/.translations/ru.json +++ b/homeassistant/components/nest/.translations/ru.json @@ -4,7 +4,7 @@ "already_setup": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430.", "authorize_url_fail": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", - "no_flows": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Nest \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. [\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/nest/)." + "no_flows": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 Nest \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/nest/)." }, "error": { "internal_error": "\u0412\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u044f\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043a\u043e\u0434\u0430", diff --git a/homeassistant/components/netgear_lte/manifest.json b/homeassistant/components/netgear_lte/manifest.json index 609ea72cc699c3..99ca3cb1ccfb82 100644 --- a/homeassistant/components/netgear_lte/manifest.json +++ b/homeassistant/components/netgear_lte/manifest.json @@ -6,7 +6,5 @@ "eternalegypt==0.0.10" ], "dependencies": [], - "codeowners": [ - "@amelchio" - ] + "codeowners": [] } diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 63bdbf8a928e35..5c5a095c8f4cef 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/components/nextbus", "dependencies": [], "codeowners": ["@vividboarder"], - "requirements": ["py_nextbus==0.1.2"] + "requirements": ["py_nextbusnext==0.1.4"] } diff --git a/homeassistant/components/notion/.translations/it.json b/homeassistant/components/notion/.translations/it.json new file mode 100644 index 00000000000000..035c0c38952707 --- /dev/null +++ b/homeassistant/components/notion/.translations/it.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Nome utente gi\u00e0 registrato", + "invalid_credentials": "Nome utente o password non validi", + "no_devices": "Nessun dispositivo trovato nell'account" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Nome utente / indirizzo E-mail" + }, + "title": "Inserisci le tue informazioni" + } + }, + "title": "Nozione" + } +} \ No newline at end of file diff --git a/homeassistant/components/notion/.translations/ko.json b/homeassistant/components/notion/.translations/ko.json index 32eb4b688558a0..76dc91cf46b0a1 100644 --- a/homeassistant/components/notion/.translations/ko.json +++ b/homeassistant/components/notion/.translations/ko.json @@ -3,7 +3,7 @@ "error": { "identifier_exists": "\uc0ac\uc6a9\uc790 \uc774\ub984\uc774 \uc774\ubbf8 \ub4f1\ub85d\ub418\uc5c8\uc2b5\ub2c8\ub2e4", "invalid_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ub610\ub294 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", - "no_devices": "\uacc4\uc815\uc5d0 \uae30\uae30\uac00 \uc874\uc7ac\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4" + "no_devices": "\uacc4\uc815\uc5d0 \ub4f1\ub85d\ub41c \uae30\uae30\uac00 \uc874\uc7ac\ud558\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4" }, "step": { "user": { diff --git a/homeassistant/components/notion/.translations/pl.json b/homeassistant/components/notion/.translations/pl.json index c35de9c535c1d5..380d4ad151e6d6 100644 --- a/homeassistant/components/notion/.translations/pl.json +++ b/homeassistant/components/notion/.translations/pl.json @@ -9,9 +9,9 @@ "user": { "data": { "password": "Has\u0142o", - "username": "Nazwa u\u017cytkownika/adres e-mail" + "username": "Nazwa u\u017cytkownika / adres e-mail" }, - "title": "Wprowad\u017a swoje dane" + "title": "Wprowad\u017a dane" } }, "title": "Poj\u0119cie" diff --git a/homeassistant/components/notion/.translations/ru.json b/homeassistant/components/notion/.translations/ru.json index f43fbeb58b7b0e..c7e89c368c178c 100644 --- a/homeassistant/components/notion/.translations/ru.json +++ b/homeassistant/components/notion/.translations/ru.json @@ -3,7 +3,7 @@ "error": { "identifier_exists": "\u0423\u0447\u0435\u0442\u043d\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u0443\u0436\u0435 \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u043d\u0430", "invalid_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043b\u043e\u0433\u0438\u043d \u0438\u043b\u0438 \u043f\u0430\u0440\u043e\u043b\u044c", - "no_devices": "\u0412 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b" + "no_devices": "\u041d\u0435\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432, \u0441\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0445 \u0441 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u044c\u044e" }, "step": { "user": { diff --git a/homeassistant/components/nuki/__init__.py b/homeassistant/components/nuki/__init__.py index 2e15ac8a68d052..c8b19082585c12 100644 --- a/homeassistant/components/nuki/__init__.py +++ b/homeassistant/components/nuki/__init__.py @@ -1 +1,3 @@ """The nuki component.""" + +DOMAIN = "nuki" diff --git a/homeassistant/components/nuki/lock.py b/homeassistant/components/nuki/lock.py index 31a655dfeddd93..7fda26b290041d 100644 --- a/homeassistant/components/nuki/lock.py +++ b/homeassistant/components/nuki/lock.py @@ -1,20 +1,18 @@ """Nuki.io lock platform.""" from datetime import timedelta import logging -import requests +from pynuki import NukiBridge +from requests.exceptions import RequestException import voluptuous as vol -from homeassistant.components.lock import ( - DOMAIN, - PLATFORM_SCHEMA, - LockDevice, - SUPPORT_OPEN, -) +from homeassistant.components.lock import PLATFORM_SCHEMA, SUPPORT_OPEN, LockDevice from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_PORT, CONF_TOKEN import homeassistant.helpers.config_validation as cv from homeassistant.helpers.service import extract_entity_ids +from . import DOMAIN + _LOGGER = logging.getLogger(__name__) DEFAULT_PORT = 8080 @@ -30,7 +28,8 @@ NUKI_DATA = "nuki" SERVICE_LOCK_N_GO = "lock_n_go" -SERVICE_CHECK_CONNECTION = "check_connection" + +ERROR_STATES = (0, 254, 255) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -47,48 +46,30 @@ } ) -CHECK_CONNECTION_SERVICE_SCHEMA = vol.Schema( - {vol.Optional(ATTR_ENTITY_ID): cv.entity_ids} -) - def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Nuki lock platform.""" - from pynuki import NukiBridge - bridge = NukiBridge( config[CONF_HOST], config[CONF_TOKEN], config[CONF_PORT], DEFAULT_TIMEOUT ) - add_entities([NukiLock(lock) for lock in bridge.locks]) + devices = [NukiLock(lock) for lock in bridge.locks] def service_handler(service): """Service handler for nuki services.""" entity_ids = extract_entity_ids(hass, service) - all_locks = hass.data[NUKI_DATA][DOMAIN] - target_locks = [] - if not entity_ids: - target_locks = all_locks - else: - for lock in all_locks: - if lock.entity_id in entity_ids: - target_locks.append(lock) - for lock in target_locks: - if service.service == SERVICE_LOCK_N_GO: - unlatch = service.data[ATTR_UNLATCH] - lock.lock_n_go(unlatch=unlatch) - elif service.service == SERVICE_CHECK_CONNECTION: - lock.check_connection() + unlatch = service.data[ATTR_UNLATCH] + + for lock in devices: + if lock.entity_id not in entity_ids: + continue + lock.lock_n_go(unlatch=unlatch) hass.services.register( - "nuki", SERVICE_LOCK_N_GO, service_handler, schema=LOCK_N_GO_SERVICE_SCHEMA - ) - hass.services.register( - "nuki", - SERVICE_CHECK_CONNECTION, - service_handler, - schema=CHECK_CONNECTION_SERVICE_SCHEMA, + DOMAIN, SERVICE_LOCK_N_GO, service_handler, schema=LOCK_N_GO_SERVICE_SCHEMA ) + add_entities(devices) + class NukiLock(LockDevice): """Representation of a Nuki lock.""" @@ -99,15 +80,7 @@ def __init__(self, nuki_lock): self._locked = nuki_lock.is_locked self._name = nuki_lock.name self._battery_critical = nuki_lock.battery_critical - self._available = nuki_lock.state != 255 - - async def async_added_to_hass(self): - """Call when entity is added to hass.""" - if NUKI_DATA not in self.hass.data: - self.hass.data[NUKI_DATA] = {} - if DOMAIN not in self.hass.data[NUKI_DATA]: - self.hass.data[NUKI_DATA][DOMAIN] = [] - self.hass.data[NUKI_DATA][DOMAIN].append(self) + self._available = nuki_lock.state not in ERROR_STATES @property def name(self): @@ -140,13 +113,19 @@ def available(self) -> bool: def update(self): """Update the nuki lock properties.""" - try: - self._nuki_lock.update(aggressive=False) - except requests.exceptions.RequestException: - self._available = False - return + for level in (False, True): + try: + self._nuki_lock.update(aggressive=level) + except RequestException: + _LOGGER.warning("Network issues detect with %s", self.name) + self._available = False + return + + # If in error state, we force an update and repoll data + self._available = self._nuki_lock.state not in ERROR_STATES + if self._available: + break - self._available = self._nuki_lock.state != 255 self._name = self._nuki_lock.name self._locked = self._nuki_lock.is_locked self._battery_critical = self._nuki_lock.battery_critical @@ -170,12 +149,3 @@ def lock_n_go(self, unlatch=False, **kwargs): amount of time depending on the lock settings) and relock. """ self._nuki_lock.lock_n_go(unlatch, kwargs) - - def check_connection(self, **kwargs): - """Update the nuki lock properties.""" - try: - self._nuki_lock.update(aggressive=True) - except requests.exceptions.RequestException: - self._available = False - else: - self._available = self._nuki_lock.state != 255 diff --git a/homeassistant/components/nuki/manifest.json b/homeassistant/components/nuki/manifest.json index 932b80690c4d2c..e7f078a1a0594a 100644 --- a/homeassistant/components/nuki/manifest.json +++ b/homeassistant/components/nuki/manifest.json @@ -2,11 +2,7 @@ "domain": "nuki", "name": "Nuki", "documentation": "https://www.home-assistant.io/components/nuki", - "requirements": [ - "pynuki==1.3.3" - ], + "requirements": ["pynuki==1.3.3"], "dependencies": [], - "codeowners": [ - "@pschmitt" - ] + "codeowners": ["@pvizeli"] } diff --git a/homeassistant/components/nws/manifest.json b/homeassistant/components/nws/manifest.json index b0e5fdb208844a..bad90d9e827d77 100644 --- a/homeassistant/components/nws/manifest.json +++ b/homeassistant/components/nws/manifest.json @@ -4,5 +4,5 @@ "documentation": "https://www.home-assistant.io/components/nws", "dependencies": [], "codeowners": ["@MatthewFlamm"], - "requirements": ["pynws==0.7.4"] + "requirements": ["pynws==0.8.1"] } diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index 2480daf2ead090..37744dce180342 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -1 +1,105 @@ """The nzbget component.""" +from datetime import timedelta +import logging + +import pynzbgetapi +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_SCAN_INTERVAL, + CONF_SSL, + CONF_USERNAME, +) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.event import track_time_interval + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "nzbget" +DATA_NZBGET = "data_nzbget" +DATA_UPDATED = "nzbget_data_updated" + +DEFAULT_NAME = "NZBGet" +DEFAULT_PORT = 6789 + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=5) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional( + CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL + ): cv.time_period, + vol.Optional(CONF_SSL, default=False): cv.boolean, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the NZBGet sensors.""" + host = config[DOMAIN][CONF_HOST] + port = config[DOMAIN][CONF_PORT] + ssl = "s" if config[DOMAIN][CONF_SSL] else "" + name = config[DOMAIN][CONF_NAME] + username = config[DOMAIN].get(CONF_USERNAME) + password = config[DOMAIN].get(CONF_PASSWORD) + scan_interval = config[DOMAIN][CONF_SCAN_INTERVAL] + + try: + nzbget_api = pynzbgetapi.NZBGetAPI(host, username, password, ssl, ssl, port) + nzbget_api.version() + except pynzbgetapi.NZBGetAPIException as conn_err: + _LOGGER.error("Error setting up NZBGet API: %s", conn_err) + return False + + _LOGGER.debug("Successfully validated NZBGet API connection") + + nzbget_data = hass.data[DATA_NZBGET] = NZBGetData(hass, nzbget_api) + nzbget_data.update() + + def refresh(event_time): + """Get the latest data from NZBGet.""" + nzbget_data.update() + + track_time_interval(hass, refresh, scan_interval) + + sensorconfig = {"client_name": name} + + hass.helpers.discovery.load_platform("sensor", DOMAIN, sensorconfig, config) + + return True + + +class NZBGetData: + """Get the latest data and update the states.""" + + def __init__(self, hass, api): + """Initialize the NZBGet RPC API.""" + self.hass = hass + self.status = None + self.available = True + self._api = api + + def update(self): + """Get the latest data from NZBGet instance.""" + try: + self.status = self._api.status() + self.available = True + dispatcher_send(self.hass, DATA_UPDATED) + except pynzbgetapi.NZBGetAPIException: + self.available = False + _LOGGER.error("Unable to refresh NZBGet data") diff --git a/homeassistant/components/nzbget/manifest.json b/homeassistant/components/nzbget/manifest.json index 69293ede516aee..17b11d6aef9fdc 100644 --- a/homeassistant/components/nzbget/manifest.json +++ b/homeassistant/components/nzbget/manifest.json @@ -2,7 +2,7 @@ "domain": "nzbget", "name": "Nzbget", "documentation": "https://www.home-assistant.io/components/nzbget", - "requirements": [], + "requirements": ["pynzbgetapi==0.2.0"], "dependencies": [], - "codeowners": [] + "codeowners": ["@chriscla"] } diff --git a/homeassistant/components/nzbget/sensor.py b/homeassistant/components/nzbget/sensor.py index 73643a5383cea1..ce1fda0839e10b 100644 --- a/homeassistant/components/nzbget/sensor.py +++ b/homeassistant/components/nzbget/sensor.py @@ -1,32 +1,15 @@ -"""Support for monitoring NZBGet NZB client.""" -from datetime import timedelta +"""Monitor the NZBGet API.""" import logging -from aiohttp.hdrs import CONTENT_TYPE -import requests -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_SSL, - CONF_HOST, - CONF_NAME, - CONF_PORT, - CONF_PASSWORD, - CONF_USERNAME, - CONTENT_TYPE_JSON, - CONF_MONITORED_VARIABLES, -) -import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle + +from . import DATA_NZBGET, DATA_UPDATED _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = "NZBGet" -DEFAULT_PORT = 6789 - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) SENSOR_TYPES = { "article_cache": ["ArticleCacheMB", "Article Cache", "MB"], @@ -40,66 +23,39 @@ "uptime": ["UpTimeSec", "Uptime", "min"], } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_MONITORED_VARIABLES, default=["download_rate"]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_SSL, default=False): cv.boolean, - vol.Optional(CONF_USERNAME): cv.string, - } -) - def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the NZBGet sensors.""" - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - ssl = "s" if config.get(CONF_SSL) else "" - name = config.get(CONF_NAME) - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - monitored_types = config.get(CONF_MONITORED_VARIABLES) - - url = f"http{ssl}://{host}:{port}/jsonrpc" - - try: - nzbgetapi = NZBGetAPI(api_url=url, username=username, password=password) - nzbgetapi.update() - except ( - requests.exceptions.ConnectionError, - requests.exceptions.HTTPError, - ) as conn_err: - _LOGGER.error("Error setting up NZBGet API: %s", conn_err) - return False + """Create NZBGet sensors.""" + + if discovery_info is None: + return + + nzbget_data = hass.data[DATA_NZBGET] + name = discovery_info["client_name"] devices = [] - for ng_type in monitored_types: + for sensor_type, sensor_config in SENSOR_TYPES.items(): new_sensor = NZBGetSensor( - api=nzbgetapi, sensor_type=SENSOR_TYPES.get(ng_type), client_name=name + nzbget_data, sensor_type, name, sensor_config[0], sensor_config[1] ) devices.append(new_sensor) - add_entities(devices) + add_entities(devices, True) class NZBGetSensor(Entity): """Representation of a NZBGet sensor.""" - def __init__(self, api, sensor_type, client_name): + def __init__( + self, nzbget_data, sensor_type, client_name, sensor_name, unit_of_measurement + ): """Initialize a new NZBGet sensor.""" - self._name = "{} {}".format(client_name, sensor_type[1]) - self.type = sensor_type[0] + self._name = f"{client_name} {sensor_type}" + self.type = sensor_name self.client_name = client_name - self.api = api + self.nzbget_data = nzbget_data self._state = None - self._unit_of_measurement = sensor_type[2] - self.update() - _LOGGER.debug("Created NZBGet sensor: %s", self.type) + self._unit_of_measurement = unit_of_measurement @property def name(self): @@ -116,21 +72,31 @@ def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement + @property + def available(self): + """Return whether the sensor is available.""" + return self.nzbget_data.available + + async def async_added_to_hass(self): + """Handle entity which will be added.""" + async_dispatcher_connect( + self.hass, DATA_UPDATED, self._schedule_immediate_update + ) + + @callback + def _schedule_immediate_update(self): + self.async_schedule_update_ha_state(True) + def update(self): """Update state of sensor.""" - try: - self.api.update() - except requests.exceptions.ConnectionError: - # Error calling the API, already logged in api.update() - return - if self.api.status is None: + if self.nzbget_data.status is None: _LOGGER.debug( "Update of %s requested, but no status is available", self._name ) return - value = self.api.status.get(self.type) + value = self.nzbget_data.status.get(self.type) if value is None: _LOGGER.warning("Unable to locate value for %s", self.type) return @@ -143,48 +109,3 @@ def update(self): self._state = round(value / 60, 2) else: self._state = value - - -class NZBGetAPI: - """Simple JSON-RPC wrapper for NZBGet's API.""" - - def __init__(self, api_url, username=None, password=None): - """Initialize NZBGet API and set headers needed later.""" - self.api_url = api_url - self.status = None - self.headers = {CONTENT_TYPE: CONTENT_TYPE_JSON} - - if username is not None and password is not None: - self.auth = (username, password) - else: - self.auth = None - self.update() - - def post(self, method, params=None): - """Send a POST request and return the response as a dict.""" - payload = {"method": method} - - if params: - payload["params"] = params - try: - response = requests.post( - self.api_url, - json=payload, - auth=self.auth, - headers=self.headers, - timeout=5, - ) - response.raise_for_status() - return response.json() - except requests.exceptions.ConnectionError as conn_exc: - _LOGGER.error( - "Failed to update NZBGet status from %s. Error: %s", - self.api_url, - conn_exc, - ) - raise - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update cached response.""" - self.status = self.post("status")["result"] diff --git a/homeassistant/components/obihai/__init__.py b/homeassistant/components/obihai/__init__.py new file mode 100644 index 00000000000000..8e65423b73bb3c --- /dev/null +++ b/homeassistant/components/obihai/__init__.py @@ -0,0 +1 @@ +"""The Obihai integration.""" diff --git a/homeassistant/components/obihai/manifest.json b/homeassistant/components/obihai/manifest.json new file mode 100644 index 00000000000000..e7706b0435ceec --- /dev/null +++ b/homeassistant/components/obihai/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "obihai", + "name": "Obihai", + "documentation": "https://www.home-assistant.io/components/obihai", + "requirements": [ + "pyobihai==1.1.0" + ], + "dependencies": [], + "codeowners": ["@dshokouhi"] + } + \ No newline at end of file diff --git a/homeassistant/components/obihai/sensor.py b/homeassistant/components/obihai/sensor.py new file mode 100644 index 00000000000000..4eb3881e95bf37 --- /dev/null +++ b/homeassistant/components/obihai/sensor.py @@ -0,0 +1,104 @@ +"""Support for Obihai Sensors.""" +import logging + +from datetime import timedelta +import voluptuous as vol + +from pyobihai import PyObihai + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_USERNAME, + DEVICE_CLASS_TIMESTAMP, +) + +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=5) + +OBIHAI = "Obihai" +DEFAULT_USERNAME = "admin" +DEFAULT_PASSWORD = "admin" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Obihai sensor platform.""" + + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + host = config[CONF_HOST] + + sensors = [] + + pyobihai = PyObihai() + + services = pyobihai.get_state(host, username, password) + + line_services = pyobihai.get_line_state(host, username, password) + + for key in services: + sensors.append(ObihaiServiceSensors(pyobihai, host, username, password, key)) + + for key in line_services: + sensors.append(ObihaiServiceSensors(pyobihai, host, username, password, key)) + + add_entities(sensors) + + +class ObihaiServiceSensors(Entity): + """Get the status of each Obihai Lines.""" + + def __init__(self, pyobihai, host, username, password, service_name): + """Initialize monitor sensor.""" + self._host = host + self._username = username + self._password = password + self._service_name = service_name + self._state = None + self._name = f"{OBIHAI} {self._service_name}" + self._pyobihai = pyobihai + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_class(self): + """Return the device class for uptime sensor.""" + if self._service_name == "Last Reboot": + return DEVICE_CLASS_TIMESTAMP + return None + + def update(self): + """Update the sensor.""" + services = self._pyobihai.get_state(self._host, self._username, self._password) + + if self._service_name in services: + self._state = services.get(self._service_name) + + services = self._pyobihai.get_line_state( + self._host, self._username, self._password + ) + + if self._service_name in services: + self._state = services.get(self._service_name) diff --git a/homeassistant/components/ombi/__init__.py b/homeassistant/components/ombi/__init__.py new file mode 100644 index 00000000000000..860c7d4dcb4df0 --- /dev/null +++ b/homeassistant/components/ombi/__init__.py @@ -0,0 +1,149 @@ +"""Support for Ombi.""" +import logging + +import pyombi +import voluptuous as vol + +from homeassistant.const import ( + CONF_API_KEY, + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_USERNAME, +) +import homeassistant.helpers.config_validation as cv + +from .const import ( + ATTR_NAME, + ATTR_SEASON, + CONF_URLBASE, + DEFAULT_PORT, + DEFAULT_SEASON, + DEFAULT_SSL, + DEFAULT_URLBASE, + DOMAIN, + SERVICE_MOVIE_REQUEST, + SERVICE_MUSIC_REQUEST, + SERVICE_TV_REQUEST, +) + +_LOGGER = logging.getLogger(__name__) + + +def urlbase(value) -> str: + """Validate and transform urlbase.""" + if value is None: + raise vol.Invalid("string value is None") + value = str(value).strip("/") + if not value: + return value + return value + "/" + + +SUBMIT_MOVIE_REQUEST_SERVICE_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) + +SUBMIT_MUSIC_REQUEST_SERVICE_SCHEMA = vol.Schema({vol.Required(ATTR_NAME): cv.string}) + +SUBMIT_TV_REQUEST_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(ATTR_NAME): cv.string, + vol.Optional(ATTR_SEASON, default=DEFAULT_SEASON): vol.In( + ["first", "latest", "all"] + ), + } +) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_URLBASE, default=DEFAULT_URLBASE): urlbase, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Set up the Ombi component platform.""" + + ombi = pyombi.Ombi( + ssl=config[DOMAIN][CONF_SSL], + host=config[DOMAIN][CONF_HOST], + port=config[DOMAIN][CONF_PORT], + api_key=config[DOMAIN][CONF_API_KEY], + username=config[DOMAIN][CONF_USERNAME], + urlbase=config[DOMAIN][CONF_URLBASE], + ) + + try: + ombi.test_connection() + except pyombi.OmbiError as err: + _LOGGER.warning("Unable to setup Ombi: %s", err) + return False + + hass.data[DOMAIN] = {"instance": ombi} + + def submit_movie_request(call): + """Submit request for movie.""" + name = call.data[ATTR_NAME] + movies = ombi.search_movie(name) + if movies: + movie = movies[0] + ombi.request_movie(movie["theMovieDbId"]) + else: + raise Warning("No movie found.") + + def submit_tv_request(call): + """Submit request for TV show.""" + name = call.data[ATTR_NAME] + tv_shows = ombi.search_tv(name) + + if tv_shows: + season = call.data[ATTR_SEASON] + show = tv_shows[0]["id"] + if season == "first": + ombi.request_tv(show, request_first=True) + elif season == "latest": + ombi.request_tv(show, request_latest=True) + elif season == "all": + ombi.request_tv(show, request_all=True) + else: + raise Warning("No TV show found.") + + def submit_music_request(call): + """Submit request for music album.""" + name = call.data[ATTR_NAME] + music = ombi.search_music_album(name) + if music: + ombi.request_music(music[0]["foreignAlbumId"]) + else: + raise Warning("No music album found.") + + hass.services.register( + DOMAIN, + SERVICE_MOVIE_REQUEST, + submit_movie_request, + schema=SUBMIT_MOVIE_REQUEST_SERVICE_SCHEMA, + ) + hass.services.register( + DOMAIN, + SERVICE_MUSIC_REQUEST, + submit_music_request, + schema=SUBMIT_MUSIC_REQUEST_SERVICE_SCHEMA, + ) + hass.services.register( + DOMAIN, + SERVICE_TV_REQUEST, + submit_tv_request, + schema=SUBMIT_TV_REQUEST_SERVICE_SCHEMA, + ) + hass.helpers.discovery.load_platform("sensor", DOMAIN, {}, config) + + return True diff --git a/homeassistant/components/ombi/const.py b/homeassistant/components/ombi/const.py new file mode 100644 index 00000000000000..42b58e7f50d631 --- /dev/null +++ b/homeassistant/components/ombi/const.py @@ -0,0 +1,24 @@ +"""Support for Ombi.""" +ATTR_NAME = "name" +ATTR_SEASON = "season" + +CONF_URLBASE = "urlbase" + +DEFAULT_NAME = DOMAIN = "ombi" +DEFAULT_PORT = 5000 +DEFAULT_SEASON = "latest" +DEFAULT_SSL = False +DEFAULT_URLBASE = "" + +SERVICE_MOVIE_REQUEST = "submit_movie_request" +SERVICE_MUSIC_REQUEST = "submit_music_request" +SERVICE_TV_REQUEST = "submit_tv_request" + +SENSOR_TYPES = { + "movies": {"type": "Movie requests", "icon": "mdi:movie"}, + "tv": {"type": "TV show requests", "icon": "mdi:television-classic"}, + "music": {"type": "Music album requests", "icon": "mdi:album"}, + "pending": {"type": "Pending requests", "icon": "mdi:clock-alert-outline"}, + "approved": {"type": "Approved requests", "icon": "mdi:check"}, + "available": {"type": "Available requests", "icon": "mdi:download"}, +} diff --git a/homeassistant/components/ombi/manifest.json b/homeassistant/components/ombi/manifest.json new file mode 100644 index 00000000000000..066f3270ccdd5b --- /dev/null +++ b/homeassistant/components/ombi/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "ombi", + "name": "Ombi", + "documentation": "https://www.home-assistant.io/components/ombi/", + "dependencies": [], + "codeowners": ["@larssont"], + "requirements": ["pyombi==0.1.5"] +} diff --git a/homeassistant/components/ombi/sensor.py b/homeassistant/components/ombi/sensor.py new file mode 100644 index 00000000000000..2a2f50532b4e23 --- /dev/null +++ b/homeassistant/components/ombi/sensor.py @@ -0,0 +1,77 @@ +"""Support for Ombi.""" +from datetime import timedelta +import logging + +from pyombi import OmbiError + +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN, SENSOR_TYPES + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=60) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Ombi sensor platform.""" + if discovery_info is None: + return + + sensors = [] + + ombi = hass.data[DOMAIN]["instance"] + + for sensor in SENSOR_TYPES: + sensor_label = sensor + sensor_type = SENSOR_TYPES[sensor]["type"] + sensor_icon = SENSOR_TYPES[sensor]["icon"] + sensors.append(OmbiSensor(sensor_label, sensor_type, ombi, sensor_icon)) + + add_entities(sensors, True) + + +class OmbiSensor(Entity): + """Representation of an Ombi sensor.""" + + def __init__(self, label, sensor_type, ombi, icon): + """Initialize the sensor.""" + self._state = None + self._label = label + self._type = sensor_type + self._ombi = ombi + self._icon = icon + + @property + def name(self): + """Return the name of the sensor.""" + return f"Ombi {self._type}" + + @property + def icon(self): + """Return the icon to use in the frontend.""" + return self._icon + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Update the sensor.""" + try: + if self._label == "movies": + self._state = self._ombi.movie_requests + elif self._label == "tv": + self._state = self._ombi.tv_requests + elif self._label == "music": + self._state = self._ombi.music_requests + elif self._label == "pending": + self._state = self._ombi.total_requests["pending"] + elif self._label == "approved": + self._state = self._ombi.total_requests["approved"] + elif self._label == "available": + self._state = self._ombi.total_requests["available"] + except OmbiError as err: + _LOGGER.warning("Unable to update Ombi sensor: %s", err) + self._state = None diff --git a/homeassistant/components/ombi/services.yaml b/homeassistant/components/ombi/services.yaml new file mode 100644 index 00000000000000..5f4f2defe32097 --- /dev/null +++ b/homeassistant/components/ombi/services.yaml @@ -0,0 +1,27 @@ +# Ombi services.yaml entries + +submit_movie_request: + description: Searches for a movie and requests the first result. + fields: + name: + description: Search parameter + example: "beverly hills cop" + + +submit_tv_request: + description: Searches for a TV show and requests the first result. + fields: + name: + description: Search parameter + example: "breaking bad" + season: + description: Which season(s) to request (first, latest or all) + example: "latest" + + +submit_music_request: + description: Searches for a music album and requests the first result. + fields: + name: + description: Search parameter + example: "nevermind" \ No newline at end of file diff --git a/homeassistant/components/onkyo/media_player.py b/homeassistant/components/onkyo/media_player.py index b5001a1f983801..023fb32e6e40da 100644 --- a/homeassistant/components/onkyo/media_player.py +++ b/homeassistant/components/onkyo/media_player.py @@ -1,8 +1,6 @@ """Support for Onkyo Receivers.""" import logging - -# pylint: disable=unused-import -from typing import List # noqa: F401 +from typing import List import voluptuous as vol @@ -54,7 +52,7 @@ | SUPPORT_PLAY_MEDIA ) -KNOWN_HOSTS = [] # type: List[str] +KNOWN_HOSTS: List[str] = [] DEFAULT_SOURCES = { "tv": "TV", "bd": "Bluray", diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 0635a2d1f11bb1..4fdd513f840f28 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -282,7 +282,7 @@ def setup_ptz(self): """Set up PTZ if available.""" _LOGGER.debug("Setting up the ONVIF PTZ service") if self._camera.get_service("ptz", create=False) is None: - _LOGGER.warning("PTZ is not available on this camera") + _LOGGER.debug("PTZ is not available") else: self._ptz_service = self._camera.create_ptz_service() _LOGGER.debug("Completed set up of the ONVIF camera component") diff --git a/homeassistant/components/openuv/.translations/pl.json b/homeassistant/components/openuv/.translations/pl.json index 2c4c47e8da44ed..ee3875c2903c42 100644 --- a/homeassistant/components/openuv/.translations/pl.json +++ b/homeassistant/components/openuv/.translations/pl.json @@ -12,7 +12,7 @@ "latitude": "Szeroko\u015b\u0107 geograficzna", "longitude": "D\u0142ugo\u015b\u0107 geograficzna" }, - "title": "Wprowad\u017a swoje dane" + "title": "Wprowad\u017a dane" } }, "title": "OpenUV" diff --git a/homeassistant/components/owntracks/.translations/it.json b/homeassistant/components/owntracks/.translations/it.json index 9b66b693c333a1..03b0c84744f75d 100644 --- a/homeassistant/components/owntracks/.translations/it.json +++ b/homeassistant/components/owntracks/.translations/it.json @@ -3,6 +3,9 @@ "abort": { "one_instance_allowed": "\u00c8 necessaria una sola istanza." }, + "create_entry": { + "default": "\n\nSu Android, apri l'[app OwnTracks]({android_url}), vai su preferenze -> connessione. Modifica le seguenti impostazioni: \n - Modalit\u00e0: HTTP privato \n - Host: {webhook_url} \n - Identificazione: \n - Nome utente: `` \n - ID dispositivo: ``\n\nSu iOS, apri l'[app OwnTracks]({ios_url}), tocca l'icona (i) in alto a sinistra -> impostazioni. Modifica le seguenti impostazioni: \n - Modalit\u00e0: HTTP \n - URL: {webhook_url} \n - Attiva autenticazione \n - UserID: `` \n\n {secret} \n \n Vedi [la documentazione]({docs_url}) per maggiori informazioni." + }, "step": { "user": { "description": "Sei sicuro di voler configurare OwnTracks?", diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index c6a2f91bab3977..832853c670d827 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -441,7 +441,7 @@ def ws_list_person( hass: HomeAssistantType, connection: websocket_api.ActiveConnection, msg ): """List persons.""" - manager = hass.data[DOMAIN] # type: PersonManager + manager: PersonManager = hass.data[DOMAIN] connection.send_result( msg["id"], {"storage": manager.storage_persons, "config": manager.config_persons}, @@ -464,7 +464,7 @@ async def ws_create_person( hass: HomeAssistantType, connection: websocket_api.ActiveConnection, msg ): """Create a person.""" - manager = hass.data[DOMAIN] # type: PersonManager + manager: PersonManager = hass.data[DOMAIN] try: person = await manager.async_create_person( name=msg["name"], @@ -495,7 +495,7 @@ async def ws_update_person( hass: HomeAssistantType, connection: websocket_api.ActiveConnection, msg ): """Update a person.""" - manager = hass.data[DOMAIN] # type: PersonManager + manager: PersonManager = hass.data[DOMAIN] changes = {} for key in ("name", "user_id", "device_trackers"): if key in msg: @@ -519,7 +519,7 @@ async def ws_delete_person( hass: HomeAssistantType, connection: websocket_api.ActiveConnection, msg ): """Delete a person.""" - manager = hass.data[DOMAIN] # type: PersonManager + manager: PersonManager = hass.data[DOMAIN] await manager.async_delete_person(msg["person_id"]) connection.send_result(msg["id"]) diff --git a/homeassistant/components/pi_hole/manifest.json b/homeassistant/components/pi_hole/manifest.json index 2d19ab25fe78dd..7fe8bba6873913 100644 --- a/homeassistant/components/pi_hole/manifest.json +++ b/homeassistant/components/pi_hole/manifest.json @@ -7,6 +7,7 @@ ], "dependencies": [], "codeowners": [ - "@fabaff" + "@fabaff", + "@johnluetke" ] } diff --git a/homeassistant/components/pilight/__init__.py b/homeassistant/components/pilight/__init__.py index 5d4f5dd25b52f8..2688b15e837c1d 100644 --- a/homeassistant/components/pilight/__init__.py +++ b/homeassistant/components/pilight/__init__.py @@ -92,7 +92,7 @@ def send_code(call): try: pilight_client.send_code(message_data) - except IOError: + except OSError: _LOGGER.error("Pilight send failed for %s", str(message_data)) hass.services.register(DOMAIN, SERVICE_NAME, send_code, schema=RF_CODE_SCHEMA) diff --git a/homeassistant/components/plaato/.translations/es.json b/homeassistant/components/plaato/.translations/es.json index e52a80be986370..ecb061e91c9c44 100644 --- a/homeassistant/components/plaato/.translations/es.json +++ b/homeassistant/components/plaato/.translations/es.json @@ -12,6 +12,7 @@ "description": "\u00bfEst\u00e1s seguro de que quieres configurar el Airlock de Plaato?", "title": "Configurar el webhook de Plaato" } - } + }, + "title": "Plaato Airlock" } } \ No newline at end of file diff --git a/homeassistant/components/plaato/.translations/it.json b/homeassistant/components/plaato/.translations/it.json new file mode 100644 index 00000000000000..7e7697a339bc20 --- /dev/null +++ b/homeassistant/components/plaato/.translations/it.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "La tua istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi da Plaato Airlook.", + "one_instance_allowed": "\u00c8 necessaria solo una singola istanza." + }, + "create_entry": { + "default": "Per inviare eventi a Home Assistant, dovrai impostare la funzione webhook in Plaato Airlock. \n\n Inserisci le seguenti informazioni: \n\n - URL: ` {webhook_url} ` \n - Metodo: POST \n\n Vedi [la documentazione]({docs_url}) per ulteriori dettagli." + }, + "step": { + "user": { + "description": "Sei sicuro di voler configurare Plaato Airlock?", + "title": "Configura il webhook di Plaato" + } + }, + "title": "Plaato Airlock" + } +} \ No newline at end of file diff --git a/homeassistant/components/plaato/.translations/zh-Hans.json b/homeassistant/components/plaato/.translations/zh-Hans.json new file mode 100644 index 00000000000000..8d5c25babfab46 --- /dev/null +++ b/homeassistant/components/plaato/.translations/zh-Hans.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u53ef\u4ece\u4e92\u8054\u7f51\u8bbf\u95ee\u4ee5\u63a5\u6536Plaato Airlock\u6d88\u606f\u3002" + }, + "step": { + "user": { + "description": "\u4f60\u786e\u5b9a\u8981\u8bbe\u7f6ePlaato Airlock\u5417\uff1f", + "title": "\u8bbe\u7f6ePlaato Webhook" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/ca.json b/homeassistant/components/plex/.translations/ca.json new file mode 100644 index 00000000000000..eb4f6459f4dcbe --- /dev/null +++ b/homeassistant/components/plex/.translations/ca.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "all_configured": "Tots els servidors enlla\u00e7ats ja estan configurats", + "already_configured": "Aquest servidor Plex ja est\u00e0 configurat", + "already_in_progress": "S\u2019est\u00e0 configurant Plex", + "invalid_import": "La configuraci\u00f3 importada \u00e9s inv\u00e0lida", + "unknown": "Ha fallat per motiu desconegut" + }, + "error": { + "faulty_credentials": "Ha fallat l'autoritzaci\u00f3", + "no_servers": "No hi ha servidors enlla\u00e7ats amb el compte", + "not_found": "No s'ha trobat el servidor Plex" + }, + "step": { + "select_server": { + "data": { + "server": "Servidor" + }, + "description": "Hi ha diversos servidors disponibles, selecciona'n un:", + "title": "Selecciona servidor Plex" + }, + "user": { + "data": { + "token": "Testimoni d'autenticaci\u00f3 Plex" + }, + "description": "Introdueix un testimoni d'autenticaci\u00f3 Plex per configurar-ho autom\u00e0ticament.", + "title": "Connexi\u00f3 amb el servidor Plex" + } + }, + "title": "Plex" + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/en.json b/homeassistant/components/plex/.translations/en.json new file mode 100644 index 00000000000000..7fa9f62be07763 --- /dev/null +++ b/homeassistant/components/plex/.translations/en.json @@ -0,0 +1,45 @@ +{ + "config": { + "abort": { + "all_configured": "All linked servers already configured", + "already_configured": "This Plex server is already configured", + "already_in_progress": "Plex is being configured", + "invalid_import": "Imported configuration is invalid", + "unknown": "Failed for unknown reason" + }, + "error": { + "faulty_credentials": "Authorization failed", + "no_servers": "No servers linked to account", + "no_token": "Provide a token or select manual setup", + "not_found": "Plex server not found" + }, + "step": { + "manual_setup": { + "data": { + "host": "Host", + "port": "Port", + "ssl": "Use SSL", + "token": "Token (if required)", + "verify_ssl": "Verify SSL certificate" + }, + "title": "Plex server" + }, + "select_server": { + "data": { + "server": "Server" + }, + "description": "Multiple servers available, select one:", + "title": "Select Plex server" + }, + "user": { + "data": { + "manual_setup": "Manual setup", + "token": "Plex token" + }, + "description": "Enter a Plex token for automatic setup or manually configure a server.", + "title": "Connect Plex server" + } + }, + "title": "Plex" + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/fr.json b/homeassistant/components/plex/.translations/fr.json new file mode 100644 index 00000000000000..58a5169ac02701 --- /dev/null +++ b/homeassistant/components/plex/.translations/fr.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "all_configured": "Tous les serveurs li\u00e9s sont d\u00e9j\u00e0 configur\u00e9s", + "already_configured": "Ce serveur Plex est d\u00e9j\u00e0 configur\u00e9", + "already_in_progress": "Plex en cours de configuration", + "invalid_import": "La configuration import\u00e9e est invalide", + "unknown": "\u00c9chec pour une raison inconnue" + }, + "error": { + "faulty_credentials": "L'autorisation \u00e0 \u00e9chou\u00e9e", + "no_servers": "Aucun serveur li\u00e9 au compte", + "not_found": "Serveur Plex introuvable" + }, + "step": { + "select_server": { + "data": { + "server": "Serveur" + }, + "description": "Plusieurs serveurs disponibles, s\u00e9lectionnez-en un:", + "title": "S\u00e9lectionnez le serveur Plex" + }, + "user": { + "data": { + "token": "Jeton plex" + }, + "description": "Entrez un jeton Plex pour la configuration automatique.", + "title": "Connecter un serveur Plex" + } + }, + "title": "Plex" + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/it.json b/homeassistant/components/plex/.translations/it.json new file mode 100644 index 00000000000000..2e77b4ba9768b5 --- /dev/null +++ b/homeassistant/components/plex/.translations/it.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "all_configured": "Tutti i server collegati sono gi\u00e0 configurati", + "already_configured": "Questo server Plex \u00e8 gi\u00e0 configurato", + "already_in_progress": "Plex \u00e8 in fase di configurazione", + "invalid_import": "La configurazione importata non \u00e8 valida", + "unknown": "Non riuscito per motivo sconosciuto" + }, + "error": { + "faulty_credentials": "Autorizzazione non riuscita", + "no_servers": "Nessun server collegato all'account", + "not_found": "Server Plex non trovato" + }, + "step": { + "select_server": { + "data": { + "server": "Server" + }, + "description": "Sono disponibili pi\u00f9 server, selezionarne uno:", + "title": "Selezionare il server Plex" + }, + "user": { + "data": { + "token": "Token Plex" + }, + "description": "Immettere un token Plex per la configurazione automatica.", + "title": "Collegare il server Plex" + } + }, + "title": "Plex" + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/ko.json b/homeassistant/components/plex/.translations/ko.json new file mode 100644 index 00000000000000..d2610c68aed74f --- /dev/null +++ b/homeassistant/components/plex/.translations/ko.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "all_configured": "\uc774\ubbf8 \uad6c\uc131\ub41c \ubaa8\ub4e0 \uc5f0\uacb0\ub41c \uc11c\ubc84", + "already_configured": "\uc774 Plex \uc11c\ubc84\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "already_in_progress": "Plex \ub97c \uad6c\uc131 \uc911\uc785\ub2c8\ub2e4", + "invalid_import": "\uac00\uc838\uc628 \uad6c\uc131 \ub0b4\uc6a9\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "unknown": "\uc54c \uc218 \uc5c6\ub294 \uc774\uc720\ub85c \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "faulty_credentials": "\uc778\uc99d\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4", + "no_servers": "\uacc4\uc815\uc5d0 \uc5f0\uacb0\ub41c \uc11c\ubc84\uac00 \uc5c6\uc2b5\ub2c8\ub2e4", + "not_found": "Plex \uc11c\ubc84\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + }, + "step": { + "select_server": { + "data": { + "server": "\uc11c\ubc84" + }, + "description": "\uc5ec\ub7ec \uc11c\ubc84\uac00 \uc0ac\uc6a9 \uac00\ub2a5\ud569\ub2c8\ub2e4. \ud558\ub098\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694:", + "title": "Plex \uc11c\ubc84 \uc120\ud0dd" + }, + "user": { + "data": { + "token": "Plex \ud1a0\ud070" + }, + "description": "\uc790\ub3d9 \uc124\uc815\uc744 \uc704\ud574 Plex \ud1a0\ud070\uc744 \uc785\ub825\ud574\uc8fc\uc138\uc694.", + "title": "Plex \uc11c\ubc84 \uc5f0\uacb0" + } + }, + "title": "Plex" + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/lb.json b/homeassistant/components/plex/.translations/lb.json new file mode 100644 index 00000000000000..130cf2067abe72 --- /dev/null +++ b/homeassistant/components/plex/.translations/lb.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "all_configured": "All verbonne Server sinn scho konfigur\u00e9iert", + "already_configured": "D\u00ebse Plex Server ass scho konfigur\u00e9iert", + "already_in_progress": "Plex g\u00ebtt konfigur\u00e9iert", + "invalid_import": "D\u00e9i importiert Konfiguratioun ass ong\u00eblteg", + "unknown": "Onbekannte Feeler opgetrueden" + }, + "error": { + "faulty_credentials": "Feeler beider Autorisatioun", + "no_servers": "Kee Server as mam Kont verbonnen", + "not_found": "Kee Plex Server fonnt" + }, + "step": { + "select_server": { + "data": { + "server": "Server" + }, + "description": "M\u00e9i Server disponibel, wielt een aus:", + "title": "Plex Server auswielen" + }, + "user": { + "data": { + "token": "Jeton fir de Plex" + }, + "description": "Gitt een Jeton fir de Plex un fir eng automatesch Konfiguratioun", + "title": "Plex Server verbannen" + } + }, + "title": "Plex" + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/no.json b/homeassistant/components/plex/.translations/no.json new file mode 100644 index 00000000000000..8ac90efe3d1474 --- /dev/null +++ b/homeassistant/components/plex/.translations/no.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "all_configured": "Alle knyttet servere som allerede er konfigurert", + "already_configured": "Denne Plex-serveren er allerede konfigurert", + "already_in_progress": "Plex blir konfigurert", + "invalid_import": "Den importerte konfigurasjonen er ugyldig", + "unknown": "Mislyktes av ukjent \u00e5rsak" + }, + "error": { + "faulty_credentials": "Autorisasjonen mislyktes", + "no_servers": "Ingen servere koblet til kontoen", + "not_found": "Plex-server ikke funnet" + }, + "step": { + "select_server": { + "data": { + "server": "Server" + }, + "description": "Flere servere tilgjengelig, velg en:", + "title": "Velg Plex-server" + }, + "user": { + "data": { + "token": "Plex token" + }, + "description": "Legg inn et Plex-token for automatisk oppsett.", + "title": "Koble til Plex-server" + } + }, + "title": "Plex" + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/pl.json b/homeassistant/components/plex/.translations/pl.json new file mode 100644 index 00000000000000..606f97d6965c60 --- /dev/null +++ b/homeassistant/components/plex/.translations/pl.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "all_configured": "Wszystkie znalezione serwery s\u0105 ju\u017c skonfigurowane.", + "already_configured": "Serwer Plex jest ju\u017c skonfigurowany", + "already_in_progress": "Plex jest konfigurowany", + "invalid_import": "Zaimportowana konfiguracja jest nieprawid\u0142owa", + "unknown": "Nieznany b\u0142\u0105d" + }, + "error": { + "faulty_credentials": "Autoryzacja nie powiod\u0142a si\u0119", + "no_servers": "Brak serwer\u00f3w po\u0142\u0105czonych z kontem", + "not_found": "Nie znaleziono serwera Plex" + }, + "step": { + "select_server": { + "data": { + "server": "Serwer" + }, + "description": "Dost\u0119pnych jest wiele serwer\u00f3w, wybierz jeden:", + "title": "Wybierz serwer Plex" + }, + "user": { + "data": { + "token": "Token Plex" + }, + "description": "Wprowad\u017a token Plex do automatycznej konfiguracji.", + "title": "Po\u0142\u0105cz z serwerem Plex" + } + }, + "title": "Plex" + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/ru.json b/homeassistant/components/plex/.translations/ru.json new file mode 100644 index 00000000000000..46cd613df4ac70 --- /dev/null +++ b/homeassistant/components/plex/.translations/ru.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "all_configured": "\u0412\u0441\u0435 \u0441\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0435 \u0441\u0435\u0440\u0432\u0435\u0440\u044b \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u044b", + "already_configured": "\u042d\u0442\u043e\u0442 \u0441\u0435\u0440\u0432\u0435\u0440 Plex \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d", + "already_in_progress": "\u0412\u044b\u043f\u043e\u043b\u043d\u044f\u0435\u0442\u0441\u044f \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430", + "invalid_import": "\u0418\u043c\u043f\u043e\u0440\u0442\u0438\u0440\u043e\u0432\u0430\u043d\u043d\u0430\u044f \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f \u043d\u0435\u0432\u0435\u0440\u043d\u0430", + "unknown": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u043e\u0439 \u043f\u0440\u0438\u0447\u0438\u043d\u0435" + }, + "error": { + "faulty_credentials": "\u041e\u0448\u0438\u0431\u043a\u0430 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438", + "no_servers": "\u041d\u0435\u0442 \u0441\u0435\u0440\u0432\u0435\u0440\u043e\u0432, \u0441\u0432\u044f\u0437\u0430\u043d\u043d\u044b\u0445 \u0441 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u044c\u044e", + "not_found": "\u0421\u0435\u0440\u0432\u0435\u0440 Plex \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d" + }, + "step": { + "select_server": { + "data": { + "server": "\u0421\u0435\u0440\u0432\u0435\u0440" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043e\u0434\u0438\u043d \u0438\u0437 \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0445 \u0441\u0435\u0440\u0432\u0435\u0440\u043e\u0432:", + "title": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0441\u0435\u0440\u0432\u0435\u0440 Plex" + }, + "user": { + "data": { + "token": "\u0422\u043e\u043a\u0435\u043d" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u0442\u043e\u043a\u0435\u043d Plex \u0434\u043b\u044f \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u043e\u0439 \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438.", + "title": "Plex" + } + }, + "title": "Plex" + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/.translations/zh-Hant.json b/homeassistant/components/plex/.translations/zh-Hant.json new file mode 100644 index 00000000000000..c79a49470e000d --- /dev/null +++ b/homeassistant/components/plex/.translations/zh-Hant.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "all_configured": "\u6240\u6709\u7d81\u5b9a\u4f3a\u670d\u5668\u90fd\u5df2\u8a2d\u5b9a\u5b8c\u6210", + "already_configured": "Plex \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "already_in_progress": "Plex \u5df2\u7d93\u8a2d\u5b9a", + "invalid_import": "\u532f\u5165\u4e4b\u8a2d\u5b9a\u7121\u6548", + "unknown": "\u672a\u77e5\u539f\u56e0\u5931\u6557" + }, + "error": { + "faulty_credentials": "\u9a57\u8b49\u5931\u6557", + "no_servers": "\u6b64\u5e33\u865f\u672a\u7d81\u5b9a\u4f3a\u670d\u5668", + "not_found": "\u627e\u4e0d\u5230 Plex \u4f3a\u670d\u5668" + }, + "step": { + "select_server": { + "data": { + "server": "\u4f3a\u670d\u5668" + }, + "description": "\u627e\u5230\u591a\u500b\u4f3a\u670d\u5668\uff0c\u8acb\u9078\u64c7\u4e00\u7d44\uff1a", + "title": "\u9078\u64c7 Plex \u4f3a\u670d\u5668" + }, + "user": { + "data": { + "token": "Plex \u5bc6\u9470" + }, + "description": "\u8acb\u8f38\u5165 Plex \u5bc6\u9470\u4ee5\u9032\u884c\u81ea\u52d5\u8a2d\u5b9a\u3002", + "title": "\u9023\u7dda\u81f3 Plex \u4f3a\u670d\u5668" + } + }, + "title": "Plex" + } +} \ No newline at end of file diff --git a/homeassistant/components/plex/__init__.py b/homeassistant/components/plex/__init__.py index 6e4e02026abff8..dd458dda07880a 100644 --- a/homeassistant/components/plex/__init__.py +++ b/homeassistant/components/plex/__init__.py @@ -1 +1,152 @@ -"""The plex component.""" +"""Support to embed Plex.""" +import asyncio +import logging + +import plexapi.exceptions +import requests.exceptions +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.media_player import DOMAIN as MP_DOMAIN +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_TOKEN, + CONF_URL, + CONF_VERIFY_SSL, +) +from homeassistant.helpers import config_validation as cv + +from .const import ( + CONF_USE_EPISODE_ART, + CONF_SHOW_ALL_CONTROLS, + CONF_SERVER, + CONF_SERVER_IDENTIFIER, + DEFAULT_PORT, + DEFAULT_SSL, + DEFAULT_VERIFY_SSL, + DOMAIN as PLEX_DOMAIN, + PLATFORMS, + PLEX_MEDIA_PLAYER_OPTIONS, + PLEX_SERVER_CONFIG, + REFRESH_LISTENERS, + SERVERS, +) +from .server import PlexServer + +MEDIA_PLAYER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_USE_EPISODE_ART, default=False): cv.boolean, + vol.Optional(CONF_SHOW_ALL_CONTROLS, default=False): cv.boolean, + } +) + +SERVER_CONFIG_SCHEMA = vol.Schema( + vol.All( + { + vol.Optional(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_TOKEN): cv.string, + vol.Optional(CONF_SERVER): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, + vol.Optional(MP_DOMAIN, default={}): MEDIA_PLAYER_SCHEMA, + }, + cv.has_at_least_one_key(CONF_HOST, CONF_TOKEN), + ) +) + +CONFIG_SCHEMA = vol.Schema({PLEX_DOMAIN: SERVER_CONFIG_SCHEMA}, extra=vol.ALLOW_EXTRA) + +_LOGGER = logging.getLogger(__package__) + + +def setup(hass, config): + """Set up the Plex component.""" + hass.data.setdefault(PLEX_DOMAIN, {SERVERS: {}, REFRESH_LISTENERS: {}}) + + plex_config = config.get(PLEX_DOMAIN, {}) + if plex_config: + _setup_plex(hass, plex_config) + + return True + + +def _setup_plex(hass, config): + """Pass configuration to a config flow.""" + server_config = dict(config) + if MP_DOMAIN in server_config: + hass.data[PLEX_MEDIA_PLAYER_OPTIONS] = server_config.pop(MP_DOMAIN) + if CONF_HOST in server_config: + prefix = "https" if server_config.pop(CONF_SSL) else "http" + server_config[ + CONF_URL + ] = f"{prefix}://{server_config.pop(CONF_HOST)}:{server_config.pop(CONF_PORT)}" + hass.async_create_task( + hass.config_entries.flow.async_init( + PLEX_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=server_config, + ) + ) + + +async def async_setup_entry(hass, entry): + """Set up Plex from a config entry.""" + server_config = entry.data[PLEX_SERVER_CONFIG] + + plex_server = PlexServer(server_config) + try: + await hass.async_add_executor_job(plex_server.connect) + except requests.exceptions.ConnectionError as error: + _LOGGER.error( + "Plex server (%s) could not be reached: [%s]", + server_config[CONF_URL], + error, + ) + return False + except ( + plexapi.exceptions.BadRequest, + plexapi.exceptions.Unauthorized, + plexapi.exceptions.NotFound, + ) as error: + _LOGGER.error( + "Login to %s failed, verify token and SSL settings: [%s]", + server_config[CONF_SERVER], + error, + ) + return False + + _LOGGER.debug( + "Connected to: %s (%s)", plex_server.friendly_name, plex_server.url_in_use + ) + hass.data[PLEX_DOMAIN][SERVERS][plex_server.machine_identifier] = plex_server + + if not hass.data.get(PLEX_MEDIA_PLAYER_OPTIONS): + hass.data[PLEX_MEDIA_PLAYER_OPTIONS] = MEDIA_PLAYER_SCHEMA({}) + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, platform) + ) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + server_id = entry.data[CONF_SERVER_IDENTIFIER] + + cancel = hass.data[PLEX_DOMAIN][REFRESH_LISTENERS].pop(server_id) + await hass.async_add_executor_job(cancel) + + tasks = [ + hass.config_entries.async_forward_entry_unload(entry, platform) + for platform in PLATFORMS + ] + await asyncio.gather(*tasks) + + hass.data[PLEX_DOMAIN][SERVERS].pop(server_id) + + return True diff --git a/homeassistant/components/plex/config_flow.py b/homeassistant/components/plex/config_flow.py new file mode 100644 index 00000000000000..e620e4869e5083 --- /dev/null +++ b/homeassistant/components/plex/config_flow.py @@ -0,0 +1,216 @@ +"""Config flow for Plex.""" +import logging + +import plexapi.exceptions +import requests.exceptions +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + CONF_URL, + CONF_TOKEN, + CONF_SSL, + CONF_VERIFY_SSL, +) +from homeassistant.core import callback +from homeassistant.util.json import load_json + +from .const import ( # pylint: disable=unused-import + CONF_SERVER, + CONF_SERVER_IDENTIFIER, + DEFAULT_PORT, + DEFAULT_SSL, + DEFAULT_VERIFY_SSL, + DOMAIN, + PLEX_CONFIG_FILE, + PLEX_SERVER_CONFIG, +) +from .errors import NoServersFound, ServerNotSpecified +from .server import PlexServer + +USER_SCHEMA = vol.Schema( + {vol.Optional(CONF_TOKEN): str, vol.Optional("manual_setup"): bool} +) + +_LOGGER = logging.getLogger(__package__) + + +@callback +def configured_servers(hass): + """Return a set of the configured Plex servers.""" + return set( + entry.data[CONF_SERVER_IDENTIFIER] + for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +class PlexFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a Plex config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize the Plex flow.""" + self.current_login = {} + self.discovery_info = {} + self.available_servers = None + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + if user_input.pop("manual_setup", False): + return await self.async_step_manual_setup(user_input) + if CONF_TOKEN in user_input: + return await self.async_step_server_validate(user_input) + errors[CONF_TOKEN] = "no_token" + + return self.async_show_form( + step_id="user", data_schema=USER_SCHEMA, errors=errors + ) + + async def async_step_server_validate(self, server_config): + """Validate a provided configuration.""" + errors = {} + self.current_login = server_config + + plex_server = PlexServer(server_config) + try: + await self.hass.async_add_executor_job(plex_server.connect) + + except NoServersFound: + errors["base"] = "no_servers" + except (plexapi.exceptions.BadRequest, plexapi.exceptions.Unauthorized): + _LOGGER.error("Invalid credentials provided, config not created") + errors["base"] = "faulty_credentials" + except (plexapi.exceptions.NotFound, requests.exceptions.ConnectionError): + _LOGGER.error( + "Plex server could not be reached: %s", server_config[CONF_URL] + ) + errors["base"] = "not_found" + + except ServerNotSpecified as available_servers: + self.available_servers = available_servers.args[0] + return await self.async_step_select_server() + + except Exception as error: # pylint: disable=broad-except + _LOGGER.error("Unknown error connecting to Plex server: %s", error) + return self.async_abort(reason="unknown") + + if errors: + return self.async_show_form( + step_id="user", data_schema=USER_SCHEMA, errors=errors + ) + + server_id = plex_server.machine_identifier + + for entry in self._async_current_entries(): + if entry.data[CONF_SERVER_IDENTIFIER] == server_id: + return self.async_abort(reason="already_configured") + + url = plex_server.url_in_use + token = server_config.get(CONF_TOKEN) + + entry_config = {CONF_URL: url} + if token: + entry_config[CONF_TOKEN] = token + if url.startswith("https"): + entry_config[CONF_VERIFY_SSL] = server_config.get( + CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL + ) + + _LOGGER.debug("Valid config created for %s", plex_server.friendly_name) + + return self.async_create_entry( + title=plex_server.friendly_name, + data={ + CONF_SERVER: plex_server.friendly_name, + CONF_SERVER_IDENTIFIER: server_id, + PLEX_SERVER_CONFIG: entry_config, + }, + ) + + async def async_step_manual_setup(self, user_input=None): + """Begin manual configuration.""" + if len(user_input) > 1: + host = user_input.pop(CONF_HOST) + port = user_input.pop(CONF_PORT) + prefix = "https" if user_input.pop(CONF_SSL) else "http" + user_input[CONF_URL] = f"{prefix}://{host}:{port}" + return await self.async_step_server_validate(user_input) + + data_schema = vol.Schema( + { + vol.Required( + CONF_HOST, default=self.discovery_info.get(CONF_HOST) + ): str, + vol.Required( + CONF_PORT, default=self.discovery_info.get(CONF_PORT, DEFAULT_PORT) + ): int, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): bool, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool, + vol.Optional(CONF_TOKEN, default=user_input.get(CONF_TOKEN, "")): str, + } + ) + return self.async_show_form(step_id="manual_setup", data_schema=data_schema) + + async def async_step_select_server(self, user_input=None): + """Use selected Plex server.""" + config = dict(self.current_login) + if user_input is not None: + config[CONF_SERVER] = user_input[CONF_SERVER] + return await self.async_step_server_validate(config) + + configured = configured_servers(self.hass) + available_servers = [ + name + for (name, server_id) in self.available_servers + if server_id not in configured + ] + + if not available_servers: + return self.async_abort(reason="all_configured") + if len(available_servers) == 1: + config[CONF_SERVER] = available_servers[0] + return await self.async_step_server_validate(config) + + return self.async_show_form( + step_id="select_server", + data_schema=vol.Schema( + {vol.Required(CONF_SERVER): vol.In(available_servers)} + ), + errors={}, + ) + + async def async_step_discovery(self, discovery_info): + """Set default host and port from discovery.""" + if self._async_current_entries() or self._async_in_progress(): + # Skip discovery if a config already exists or is in progress. + return self.async_abort(reason="already_configured") + + discovery_info[CONF_PORT] = int(discovery_info[CONF_PORT]) + self.discovery_info = discovery_info + json_file = self.hass.config.path(PLEX_CONFIG_FILE) + file_config = await self.hass.async_add_executor_job(load_json, json_file) + + if file_config: + host_and_port, host_config = file_config.popitem() + prefix = "https" if host_config[CONF_SSL] else "http" + + server_config = { + CONF_URL: f"{prefix}://{host_and_port}", + CONF_TOKEN: host_config[CONF_TOKEN], + CONF_VERIFY_SSL: host_config["verify"], + } + _LOGGER.info("Imported legacy config, file can be removed: %s", json_file) + return await self.async_step_server_validate(server_config) + + return await self.async_step_user() + + async def async_step_import(self, import_config): + """Import from Plex configuration.""" + _LOGGER.debug("Imported Plex configuration") + return await self.async_step_server_validate(import_config) diff --git a/homeassistant/components/plex/const.py b/homeassistant/components/plex/const.py new file mode 100644 index 00000000000000..478dd3754e7d8f --- /dev/null +++ b/homeassistant/components/plex/const.py @@ -0,0 +1,20 @@ +"""Constants for the Plex component.""" +DOMAIN = "plex" +NAME_FORMAT = "Plex {}" + +DEFAULT_PORT = 32400 +DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = True + +PLATFORMS = ["media_player", "sensor"] +REFRESH_LISTENERS = "refresh_listeners" +SERVERS = "servers" + +PLEX_CONFIG_FILE = "plex.conf" +PLEX_MEDIA_PLAYER_OPTIONS = "plex_mp_options" +PLEX_SERVER_CONFIG = "server_config" + +CONF_SERVER = "server" +CONF_SERVER_IDENTIFIER = "server_id" +CONF_USE_EPISODE_ART = "use_episode_art" +CONF_SHOW_ALL_CONTROLS = "show_all_controls" diff --git a/homeassistant/components/plex/errors.py b/homeassistant/components/plex/errors.py new file mode 100644 index 00000000000000..11c15404f4505c --- /dev/null +++ b/homeassistant/components/plex/errors.py @@ -0,0 +1,14 @@ +"""Errors for the Plex component.""" +from homeassistant.exceptions import HomeAssistantError + + +class PlexException(HomeAssistantError): + """Base class for Plex exceptions.""" + + +class NoServersFound(PlexException): + """No servers found on Plex account.""" + + +class ServerNotSpecified(PlexException): + """Multiple servers linked to account without choice provided.""" diff --git a/homeassistant/components/plex/manifest.json b/homeassistant/components/plex/manifest.json index 32ddb83476c81e..94d990952a684e 100644 --- a/homeassistant/components/plex/manifest.json +++ b/homeassistant/components/plex/manifest.json @@ -1,10 +1,13 @@ { "domain": "plex", "name": "Plex", + "config_flow": true, "documentation": "https://www.home-assistant.io/components/plex", "requirements": [ "plexapi==3.0.6" ], - "dependencies": ["configurator"], - "codeowners": [] + "dependencies": [], + "codeowners": [ + "@jjlawren" + ] } diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 39694a061c4839..4d097253ea1a93 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -3,10 +3,12 @@ import json import logging -import requests -import voluptuous as vol +import plexapi.exceptions +import plexapi.playlist +import plexapi.playqueue +import requests.exceptions -from homeassistant.components.media_player import MediaPlayerDevice, PLATFORM_SCHEMA +from homeassistant.components.media_player import MediaPlayerDevice from homeassistant.components.media_player.const import ( MEDIA_TYPE_MOVIE, MEDIA_TYPE_MUSIC, @@ -27,123 +29,52 @@ STATE_PAUSED, STATE_PLAYING, ) -from homeassistant.helpers import config_validation as cv from homeassistant.helpers.event import track_time_interval from homeassistant.util import dt as dt_util -from homeassistant.util.json import load_json, save_json -_CONFIGURING = {} +from .const import ( + CONF_USE_EPISODE_ART, + CONF_SHOW_ALL_CONTROLS, + CONF_SERVER_IDENTIFIER, + DOMAIN as PLEX_DOMAIN, + NAME_FORMAT, + PLEX_MEDIA_PLAYER_OPTIONS, + REFRESH_LISTENERS, + SERVERS, +) + _LOGGER = logging.getLogger(__name__) -NAME_FORMAT = "Plex {}" -PLEX_CONFIG_FILE = "plex.conf" -PLEX_DATA = "plex" - -CONF_USE_EPISODE_ART = "use_episode_art" -CONF_SHOW_ALL_CONTROLS = "show_all_controls" -CONF_REMOVE_UNAVAILABLE_CLIENTS = "remove_unavailable_clients" -CONF_CLIENT_REMOVE_INTERVAL = "client_remove_interval" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_USE_EPISODE_ART, default=False): cv.boolean, - vol.Optional(CONF_SHOW_ALL_CONTROLS, default=False): cv.boolean, - vol.Optional(CONF_REMOVE_UNAVAILABLE_CLIENTS, default=True): cv.boolean, - vol.Optional( - CONF_CLIENT_REMOVE_INTERVAL, default=timedelta(seconds=600) - ): vol.All(cv.time_period, cv.positive_timedelta), - } -) +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Plex media_player platform. -def setup_platform(hass, config, add_entities_callback, discovery_info=None): - """Set up the Plex platform.""" - if PLEX_DATA not in hass.data: - hass.data[PLEX_DATA] = {} + Deprecated. + """ + pass - # get config from plex.conf - file_config = load_json(hass.config.path(PLEX_CONFIG_FILE)) - if file_config: - # Setup a configured PlexServer - host, host_config = file_config.popitem() - token = host_config["token"] - try: - has_ssl = host_config["ssl"] - except KeyError: - has_ssl = False - try: - verify_ssl = host_config["verify"] - except KeyError: - verify_ssl = True - - # Via discovery - elif discovery_info is not None: - # Parse discovery data - host = discovery_info.get("host") - port = discovery_info.get("port") - host = f"{host}:{port}" - _LOGGER.info("Discovered PLEX server: %s", host) - - if host in _CONFIGURING: - return - token = None - has_ssl = False - verify_ssl = True - else: - return - - setup_plexserver( - host, token, has_ssl, verify_ssl, hass, config, add_entities_callback - ) +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Plex media_player from a config entry.""" + def add_entities(entities, update_before_add=False): + """Sync version of async add entities.""" + hass.add_job(async_add_entities, entities, update_before_add) + + hass.async_add_executor_job(_setup_platform, hass, config_entry, add_entities) -def setup_plexserver( - host, token, has_ssl, verify_ssl, hass, config, add_entities_callback -): - """Set up a plexserver based on host parameter.""" - import plexapi.server - import plexapi.exceptions - - cert_session = None - http_prefix = "https" if has_ssl else "http" - if has_ssl and (verify_ssl is False): - _LOGGER.info("Ignoring SSL verification") - cert_session = requests.Session() - cert_session.verify = False - try: - plexserver = plexapi.server.PlexServer( - f"{http_prefix}://{host}", token, cert_session - ) - _LOGGER.info("Discovery configuration done (no token needed)") - except ( - plexapi.exceptions.BadRequest, - plexapi.exceptions.Unauthorized, - plexapi.exceptions.NotFound, - ) as error: - _LOGGER.info(error) - # No token or wrong token - request_configuration(host, hass, config, add_entities_callback) - return - - # If we came here and configuring this host, mark as done - if host in _CONFIGURING: - request_id = _CONFIGURING.pop(host) - configurator = hass.components.configurator - configurator.request_done(request_id) - _LOGGER.info("Discovery configuration done") - - # Save config - save_json( - hass.config.path(PLEX_CONFIG_FILE), - {host: {"token": token, "ssl": has_ssl, "verify": verify_ssl}}, - ) - _LOGGER.info("Connected to: %s://%s", http_prefix, host) +def _setup_platform(hass, config_entry, add_entities_callback): + """Set up the Plex media_player platform.""" + server_id = config_entry.data[CONF_SERVER_IDENTIFIER] + config = hass.data[PLEX_MEDIA_PLAYER_OPTIONS] - plex_clients = hass.data[PLEX_DATA] + plexserver = hass.data[PLEX_DOMAIN][SERVERS][server_id] + plex_clients = {} plex_sessions = {} - track_time_interval(hass, lambda now: update_devices(), timedelta(seconds=10)) + hass.data[PLEX_DOMAIN][REFRESH_LISTENERS][server_id] = track_time_interval( + hass, lambda now: update_devices(), timedelta(seconds=10) + ) def update_devices(): """Update the devices objects.""" @@ -154,7 +85,9 @@ def update_devices(): return except requests.exceptions.RequestException as ex: _LOGGER.warning( - "Could not connect to plex server at http://%s (%s)", host, ex + "Could not connect to Plex server: %s (%s)", + plexserver.friendly_name, + ex, ) return @@ -186,7 +119,9 @@ def update_devices(): return except requests.exceptions.RequestException as ex: _LOGGER.warning( - "Could not connect to plex server at http://%s (%s)", host, ex + "Could not connect to Plex server: %s (%s)", + plexserver.friendly_name, + ex, ) return @@ -215,7 +150,6 @@ def update_devices(): _LOGGER.debug("Refreshing session: %s", machine_identifier) plex_clients[machine_identifier].refresh(None, session) - clients_to_remove = [] for client in plex_clients.values(): # force devices to idle that do not have a valid session if client.session is None: @@ -229,59 +163,10 @@ def update_devices(): if client not in new_plex_clients: client.schedule_update_ha_state() - if not config.get(CONF_REMOVE_UNAVAILABLE_CLIENTS) or client.available: - continue - - if (dt_util.utcnow() - client.marked_unavailable) >= ( - config.get(CONF_CLIENT_REMOVE_INTERVAL) - ): - hass.add_job(client.async_remove()) - clients_to_remove.append(client.machine_identifier) - - while clients_to_remove: - del plex_clients[clients_to_remove.pop()] - if new_plex_clients: add_entities_callback(new_plex_clients) -def request_configuration(host, hass, config, add_entities_callback): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - # We got an error if this method is called while we are configuring - if host in _CONFIGURING: - configurator.notify_errors( - _CONFIGURING[host], "Failed to register, please try again." - ) - - return - - def plex_configuration_callback(data): - """Handle configuration changes.""" - setup_plexserver( - host, - data.get("token"), - cv.boolean(data.get("has_ssl")), - cv.boolean(data.get("do_not_verify_ssl")), - hass, - config, - add_entities_callback, - ) - - _CONFIGURING[host] = configurator.request_config( - "Plex Media Server", - plex_configuration_callback, - description="Enter the X-Plex-Token", - entity_picture="/static/images/logo_plex_mediaserver.png", - submit_caption="Confirm", - fields=[ - {"id": "token", "name": "X-Plex-Token", "type": ""}, - {"id": "has_ssl", "name": "Use SSL", "type": ""}, - {"id": "do_not_verify_ssl", "name": "Do not verify SSL", "type": ""}, - ], - ) - - class PlexClient(MediaPlayerDevice): """Representation of a Plex device.""" @@ -354,9 +239,6 @@ def _clear_media_details(self): def refresh(self, device, session): """Refresh key device data.""" - import plexapi.exceptions - - # new data refresh self._clear_media_details() if session: # Not being triggered by Chrome or FireTablet Plex App @@ -827,8 +709,6 @@ def play_media(self, media_type, media_id, **kwargs): src["video_name"] ) - import plexapi.playlist - if ( media and media_type == "EPISODE" @@ -894,8 +774,6 @@ def _client_play_media(self, media, delete=False, **params): _LOGGER.error("Client cannot play media: %s", self.entity_id) return - import plexapi.playqueue - playqueue = plexapi.playqueue.PlayQueue.create( self.device.server, media, **params ) diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index d900b4de87c1d7..7d5b54356a0c82 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -1,132 +1,56 @@ """Support for Plex media server monitoring.""" from datetime import timedelta import logging -import voluptuous as vol - -from homeassistant.components.switch import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_NAME, - CONF_USERNAME, - CONF_PASSWORD, - CONF_HOST, - CONF_PORT, - CONF_TOKEN, - CONF_SSL, - CONF_VERIFY_SSL, -) + +import plexapi.exceptions +import requests.exceptions + from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -import homeassistant.helpers.config_validation as cv + +from .const import CONF_SERVER_IDENTIFIER, DOMAIN as PLEX_DOMAIN, SERVERS _LOGGER = logging.getLogger(__name__) -CONF_SERVER = "server" +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) -DEFAULT_HOST = "localhost" -DEFAULT_NAME = "Plex" -DEFAULT_PORT = 32400 -DEFAULT_SSL = False -DEFAULT_VERIFY_SSL = True -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Set up the Plex sensor platform. + + Deprecated. + """ + pass -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PASSWORD): cv.string, - vol.Optional(CONF_TOKEN): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_SERVER): cv.string, - vol.Optional(CONF_USERNAME): cv.string, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Plex sensor.""" - name = config.get(CONF_NAME) - plex_user = config.get(CONF_USERNAME) - plex_password = config.get(CONF_PASSWORD) - plex_server = config.get(CONF_SERVER) - plex_host = config.get(CONF_HOST) - plex_port = config.get(CONF_PORT) - plex_token = config.get(CONF_TOKEN) - - plex_url = "{}://{}:{}".format( - "https" if config.get(CONF_SSL) else "http", plex_host, plex_port - ) - - import plexapi.exceptions - - try: - add_entities( - [ - PlexSensor( - name, - plex_url, - plex_user, - plex_password, - plex_server, - plex_token, - config.get(CONF_VERIFY_SSL), - ) - ], - True, - ) - except ( - plexapi.exceptions.BadRequest, - plexapi.exceptions.Unauthorized, - plexapi.exceptions.NotFound, - ) as error: - _LOGGER.error(error) - return + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up Plex sensor from a config entry.""" + server_id = config_entry.data[CONF_SERVER_IDENTIFIER] + sensor = PlexSensor(hass.data[PLEX_DOMAIN][SERVERS][server_id]) + async_add_entities([sensor], True) class PlexSensor(Entity): """Representation of a Plex now playing sensor.""" - def __init__( - self, - name, - plex_url, - plex_user, - plex_password, - plex_server, - plex_token, - verify_ssl, - ): + def __init__(self, plex_server): """Initialize the sensor.""" - from plexapi.myplex import MyPlexAccount - from plexapi.server import PlexServer - from requests import Session - - self._name = name - self._state = 0 + self._state = None self._now_playing = [] - - cert_session = None - if not verify_ssl: - _LOGGER.info("Ignoring SSL verification") - cert_session = Session() - cert_session.verify = False - - if plex_token: - self._server = PlexServer(plex_url, plex_token, cert_session) - elif plex_user and plex_password: - user = MyPlexAccount(plex_user, plex_password) - server = plex_server if plex_server else user.resources()[0].name - self._server = user.resource(server).connect() - else: - self._server = PlexServer(plex_url, None, cert_session) + self._server = plex_server + self._name = f"Plex ({plex_server.friendly_name})" + self._unique_id = f"sensor-{plex_server.machine_identifier}" @property def name(self): """Return the name of the sensor.""" return self._name + @property + def unique_id(self): + """Return the id of this plex client.""" + return self._unique_id + @property def state(self): """Return the state of the sensor.""" @@ -145,7 +69,19 @@ def device_state_attributes(self): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update method for Plex sensor.""" - sessions = self._server.sessions() + try: + sessions = self._server.sessions() + except plexapi.exceptions.BadRequest: + _LOGGER.error( + "Error listing current Plex sessions on %s", self._server.friendly_name + ) + return + except requests.exceptions.RequestException as ex: + _LOGGER.warning( + "Temporary error connecting to %s (%s)", self._server.friendly_name, ex + ) + return + now_playing = [] for sess in sessions: user = sess.usernames[0] diff --git a/homeassistant/components/plex/server.py b/homeassistant/components/plex/server.py new file mode 100644 index 00000000000000..f41a9bdabae183 --- /dev/null +++ b/homeassistant/components/plex/server.py @@ -0,0 +1,82 @@ +"""Shared class to maintain Plex server instances.""" +import plexapi.myplex +import plexapi.server +from requests import Session + +from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL + +from .const import CONF_SERVER, DEFAULT_VERIFY_SSL +from .errors import NoServersFound, ServerNotSpecified + + +class PlexServer: + """Manages a single Plex server connection.""" + + def __init__(self, server_config): + """Initialize a Plex server instance.""" + self._plex_server = None + self._url = server_config.get(CONF_URL) + self._token = server_config.get(CONF_TOKEN) + self._server_name = server_config.get(CONF_SERVER) + self._verify_ssl = server_config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL) + + def connect(self): + """Connect to a Plex server directly, obtaining direct URL if necessary.""" + + def _set_missing_url(): + account = plexapi.myplex.MyPlexAccount(token=self._token) + available_servers = [ + (x.name, x.clientIdentifier) + for x in account.resources() + if "server" in x.provides + ] + + if not available_servers: + raise NoServersFound + if not self._server_name and len(available_servers) > 1: + raise ServerNotSpecified(available_servers) + + server_choice = ( + self._server_name if self._server_name else available_servers[0] + ) + connections = account.resource(server_choice).connections + local_url = [x.httpuri for x in connections if x.local] + remote_url = [x.uri for x in connections if not x.local] + self._url = local_url[0] if local_url else remote_url[0] + + def _connect_with_url(): + session = None + if self._url.startswith("https") and not self._verify_ssl: + session = Session() + session.verify = False + self._plex_server = plexapi.server.PlexServer( + self._url, self._token, session + ) + + if self._token and not self._url: + _set_missing_url() + + _connect_with_url() + + def clients(self): + """Pass through clients call to plexapi.""" + return self._plex_server.clients() + + def sessions(self): + """Pass through sessions call to plexapi.""" + return self._plex_server.sessions() + + @property + def friendly_name(self): + """Return name of connected Plex server.""" + return self._plex_server.friendlyName + + @property + def machine_identifier(self): + """Return unique identifier of connected Plex server.""" + return self._plex_server.machineIdentifier + + @property + def url_in_use(self): + """Return URL used for connected Plex server.""" + return self._plex_server._baseurl # pylint: disable=W0212 diff --git a/homeassistant/components/plex/strings.json b/homeassistant/components/plex/strings.json new file mode 100644 index 00000000000000..c093d4fe0cec1e --- /dev/null +++ b/homeassistant/components/plex/strings.json @@ -0,0 +1,45 @@ +{ + "config": { + "title": "Plex", + "step": { + "manual_setup": { + "title": "Plex server", + "data": { + "host": "Host", + "port": "Port", + "ssl": "Use SSL", + "verify_ssl": "Verify SSL certificate", + "token": "Token (if required)" + } + }, + "select_server": { + "title": "Select Plex server", + "description": "Multiple servers available, select one:", + "data": { + "server": "Server" + } + }, + "user": { + "title": "Connect Plex server", + "description": "Enter a Plex token for automatic setup or manually configure a server.", + "data": { + "token": "Plex token", + "manual_setup": "Manual setup" + } + } + }, + "error": { + "faulty_credentials": "Authorization failed", + "no_servers": "No servers linked to account", + "not_found": "Plex server not found", + "no_token": "Provide a token or select manual setup" + }, + "abort": { + "all_configured": "All linked servers already configured", + "already_configured": "This Plex server is already configured", + "already_in_progress": "Plex is being configured", + "invalid_import": "Imported configuration is invalid", + "unknown": "Failed for unknown reason" + } + } +} diff --git a/homeassistant/components/point/.translations/es.json b/homeassistant/components/point/.translations/es.json index 33b6b1d38271a9..9a94e54dd5fa30 100644 --- a/homeassistant/components/point/.translations/es.json +++ b/homeassistant/components/point/.translations/es.json @@ -27,6 +27,6 @@ "title": "Proveedor de autenticaci\u00f3n" } }, - "title": "Point de Minut" + "title": "Minut Point" } } \ No newline at end of file diff --git a/homeassistant/components/point/.translations/it.json b/homeassistant/components/point/.translations/it.json index 324801009ca5aa..3c0ef8306e0cb6 100644 --- a/homeassistant/components/point/.translations/it.json +++ b/homeassistant/components/point/.translations/it.json @@ -16,6 +16,7 @@ }, "step": { "auth": { + "description": "Segui il link qui sotto e Accetta l'accesso al tuo account Minut, quindi torna indietro e premi Invia qui sotto. \n\n [Link] ( {authorization_url} )", "title": "Autenticare Point" }, "user": { diff --git a/homeassistant/components/point/.translations/ko.json b/homeassistant/components/point/.translations/ko.json index d70859c8bde0d6..0dd9cd43adadc1 100644 --- a/homeassistant/components/point/.translations/ko.json +++ b/homeassistant/components/point/.translations/ko.json @@ -8,7 +8,7 @@ "no_flows": "Point \ub97c \uc778\uc99d\ud558\ub824\uba74 \uba3c\uc800 Point \ub97c \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/point/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694." }, "create_entry": { - "default": "Point \uae30\uae30\ub294 Minut \ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + "default": "Point \uae30\uae30\ub85c Minut \uc5d0 \uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4" }, "error": { "follow_link": "Submit \ubc84\ud2bc\uc744 \ub204\ub974\uae30 \uc804\uc5d0 \ub9c1\ud06c\ub97c \ub530\ub77c \uc778\uc99d\uc744 \ubc1b\uc544\uc8fc\uc138\uc694", diff --git a/homeassistant/components/point/.translations/no.json b/homeassistant/components/point/.translations/no.json index 58b6e1e63fd311..c87c1a702c8d39 100644 --- a/homeassistant/components/point/.translations/no.json +++ b/homeassistant/components/point/.translations/no.json @@ -8,11 +8,11 @@ "no_flows": "Du m\u00e5 konfigurere Point f\u00f8r du kan autentisere med den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/point/)." }, "create_entry": { - "default": "Vellykket godkjenning med Minut for din(e) Point enhet(er)" + "default": "Vellykket autentisering med Minut for din(e) Point enhet(er)" }, "error": { - "follow_link": "Vennligst f\u00f8lg lenken og godkjen f\u00f8r du trykker p\u00e5 Send", - "no_token": "Ikke godkjent med Minut" + "follow_link": "Vennligst f\u00f8lg lenken og autentiser f\u00f8r du trykker p\u00e5 Send", + "no_token": "Ikke autentisert med Minut" }, "step": { "auth": { diff --git a/homeassistant/components/point/.translations/pl.json b/homeassistant/components/point/.translations/pl.json index 66b454e47ff1b7..ca36001cc1ade8 100644 --- a/homeassistant/components/point/.translations/pl.json +++ b/homeassistant/components/point/.translations/pl.json @@ -16,7 +16,7 @@ }, "step": { "auth": { - "description": "Kliknij poni\u017cszy link i Zaakceptuj dost\u0119p do swojego konta Minut, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Prze\u015blij poni\u017cej. \n\n [Link]({authorization_url})", + "description": "Kliknij poni\u017cszy link i Zaakceptuj dost\u0119p do konta Minut, a nast\u0119pnie wr\u00f3\u0107 i naci\u015bnij Prze\u015blij poni\u017cej. \n\n [Link]({authorization_url})", "title": "Uwierzytelnienie Point" }, "user": { diff --git a/homeassistant/components/point/.translations/ru.json b/homeassistant/components/point/.translations/ru.json index 2a10b234e99e21..487510969481f6 100644 --- a/homeassistant/components/point/.translations/ru.json +++ b/homeassistant/components/point/.translations/ru.json @@ -5,7 +5,7 @@ "authorize_url_fail": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", "external_setup": "Point \u0443\u0441\u043f\u0435\u0448\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d \u0438\u0437 \u0434\u0440\u0443\u0433\u043e\u0433\u043e \u043f\u043e\u0442\u043e\u043a\u0430.", - "no_flows": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Point \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. [\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/point/)." + "no_flows": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 Point \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/point/)." }, "create_entry": { "default": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 1ba2c4809b6618..82db5f6725f512 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -1,5 +1,6 @@ """Support for Prometheus metrics export.""" import logging +import string from aiohttp import web import voluptuous as vol @@ -159,10 +160,23 @@ def _metric(self, metric, factory, documentation, labels=None): try: return self._metrics[metric] except KeyError: - full_metric_name = f"{self.metrics_prefix}{metric}" + full_metric_name = self._sanitize_metric_name( + f"{self.metrics_prefix}{metric}" + ) self._metrics[metric] = factory(full_metric_name, documentation, labels) return self._metrics[metric] + @staticmethod + def _sanitize_metric_name(metric: str) -> str: + return "".join( + [ + c + if c in string.ascii_letters or c.isdigit() or c == "_" or c == ":" + else f"u{hex(ord(c))}" + for c in metric + ] + ) + @staticmethod def state_as_number(state): """Return a state casted to a float.""" diff --git a/homeassistant/components/proximity/__init__.py b/homeassistant/components/proximity/__init__.py index b5856b7f78e921..1f86958d08e065 100644 --- a/homeassistant/components/proximity/__init__.py +++ b/homeassistant/components/proximity/__init__.py @@ -211,8 +211,8 @@ def check_proximity_state_change(self, entity, old_state, new_state): # Loop through each of the distances collected and work out the # closest. - closest_device = None # type: str - dist_to_zone = None # type: float + closest_device: str = None + dist_to_zone: float = None for device in distances_to_zone: if not dist_to_zone or distances_to_zone[device] < dist_to_zone: diff --git a/homeassistant/components/proxy/camera.py b/homeassistant/components/proxy/camera.py index 7d145315748417..53a4f620dcc3d4 100644 --- a/homeassistant/components/proxy/camera.py +++ b/homeassistant/components/proxy/camera.py @@ -66,7 +66,7 @@ def _precheck_image(image, opts): raise ValueError() try: img = Image.open(io.BytesIO(image)) - except IOError: + except OSError: _LOGGER.warning("Failed to open image") raise ValueError() imgfmt = str(img.format) diff --git a/homeassistant/components/ps4/.translations/fr.json b/homeassistant/components/ps4/.translations/fr.json index 03baf0c032e3a3..991222d45be78b 100644 --- a/homeassistant/components/ps4/.translations/fr.json +++ b/homeassistant/components/ps4/.translations/fr.json @@ -4,8 +4,8 @@ "credential_error": "Erreur lors de l'extraction des informations d'identification.", "devices_configured": "Tous les p\u00e9riph\u00e9riques trouv\u00e9s sont d\u00e9j\u00e0 configur\u00e9s.", "no_devices_found": "Aucun appareil PlayStation 4 trouv\u00e9 sur le r\u00e9seau.", - "port_987_bind_error": "Impossible de se connecter au port 997.", - "port_997_bind_error": "Impossible de se connecter au port 997." + "port_987_bind_error": "Impossible de se connecter au port 997. Reportez-vous \u00e0 la [documentation] (https://www.home-assistant.io/components/ps4/) pour plus d'informations.", + "port_997_bind_error": "Impossible de se connecter au port 997. Reportez-vous \u00e0 la [documentation] (https://www.home-assistant.io/components/ps4/) pour plus d'informations." }, "error": { "credential_timeout": "Le service d'informations d'identification a expir\u00e9. Appuyez sur soumettre pour red\u00e9marrer.", diff --git a/homeassistant/components/ps4/.translations/it.json b/homeassistant/components/ps4/.translations/it.json index afa32056757ccb..de5eb4e5e6f30f 100644 --- a/homeassistant/components/ps4/.translations/it.json +++ b/homeassistant/components/ps4/.translations/it.json @@ -4,11 +4,12 @@ "credential_error": "Errore nel recupero delle credenziali.", "devices_configured": "Tutti i dispositivi trovati sono gi\u00e0 configurati.", "no_devices_found": "Nessun dispositivo PlayStation 4 trovato in rete.", - "port_987_bind_error": "Impossibile connettersi alla porta 987.", - "port_997_bind_error": "Impossibile connettersi alla porta 997." + "port_987_bind_error": "Impossibile collegarsi alla porta 987. Per ulteriori informazioni, consultare la [documentazione] (https://www.home-assistant.io/components/ps4/) per ulteriori informazioni.", + "port_997_bind_error": "Impossibile collegarsi alla porta 997. Consultare la [documentazione] (https://www.home-assistant.io/components/ps4/) per ulteriori informazioni." }, "error": { - "login_failed": "Accoppiamento alla PlayStation 4 fallito. Verifica che il PIN sia corretto.", + "credential_timeout": "Servizio credenziali scaduto. Premi Invia per riavviare.", + "login_failed": "Impossibile eseguire l'associazione a PlayStation 4. Verificare che il PIN sia corretto.", "no_ipaddress": "Inserisci l'indirizzo IP della PlayStation 4 che desideri configurare.", "not_ready": "La PlayStation 4 non \u00e8 accesa o non \u00e8 collegata alla rete." }, @@ -24,7 +25,7 @@ "name": "Nome", "region": "Area geografica" }, - "description": "Inserisci le informazioni della tua PlayStation 4. Per il \"PIN\", vai su \"Impostazioni\" sulla tua console PlayStation 4. Quindi accedi a \"Impostazioni connessione app mobile\" e seleziona \"Aggiungi dispositivo\". Inserisci il PIN che viene visualizzato.", + "description": "Inserisci le tue informazioni su PlayStation 4. Per \"PIN\", vai a \"Impostazioni\" sulla console PlayStation 4. Quindi vai a 'Impostazioni di connessione app mobile' e seleziona 'Aggiungi dispositivo'. Immettere il PIN visualizzato. Fare riferimento alla [documentazione](https://www.home-assistant.io/components/ps4/) per ulteriori informazioni.", "title": "PlayStation 4" }, "mode": { diff --git a/homeassistant/components/ps4/.translations/ko.json b/homeassistant/components/ps4/.translations/ko.json index f13a66d5e8a039..25f64cd21e9db2 100644 --- a/homeassistant/components/ps4/.translations/ko.json +++ b/homeassistant/components/ps4/.translations/ko.json @@ -3,7 +3,7 @@ "abort": { "credential_error": "\uc790\uaca9 \uc99d\uba85\uc744 \uac00\uc838\uc624\ub294 \uc911 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", "devices_configured": "\ubc1c\uacac \ub41c \ubaa8\ub4e0 \uae30\uae30\ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", - "no_devices_found": "PlayStation 4 \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "no_devices_found": "PlayStation 4 \uae30\uae30\ub97c \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4.", "port_987_bind_error": "\ud3ec\ud2b8 987 \uc5d0 \ubc14\uc778\ub529 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ucd94\uac00 \uc815\ubcf4\ub294 [\uc548\ub0b4](https://www.home-assistant.io/components/ps4/) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694.", "port_997_bind_error": "\ud3ec\ud2b8 997 \uc5d0 \ubc14\uc778\ub529 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ucd94\uac00 \uc815\ubcf4\ub294 [\uc548\ub0b4](https://www.home-assistant.io/components/ps4/) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." }, diff --git a/homeassistant/components/ps4/.translations/lb.json b/homeassistant/components/ps4/.translations/lb.json index 17757cb9d20317..0986b0e0240afb 100644 --- a/homeassistant/components/ps4/.translations/lb.json +++ b/homeassistant/components/ps4/.translations/lb.json @@ -4,8 +4,8 @@ "credential_error": "Feeler beim ausliesen vun den Umeldungs Informatiounen.", "devices_configured": "All Apparater sinn schonn konfigur\u00e9iert", "no_devices_found": "Keng Playstation 4 am Netzwierk fonnt.", - "port_987_bind_error": "Konnt sech net mam Port 987 verbannen.", - "port_997_bind_error": "Konnt sech net mam Port 997 verbannen." + "port_987_bind_error": "Konnt sech net mam Port 987 verbannen. Liest [Dokumentatioun](https://www.home-assistant.io/components/ps4/) fir w\u00e9ider Informatiounen.", + "port_997_bind_error": "Konnt sech net mam Port 997 verbannen. Liest [Dokumentatioun](https://www.home-assistant.io/components/ps4/) fir w\u00e9ider Informatiounen." }, "error": { "credential_timeout": "Z\u00e4it Iwwerschreidung beim Service vun den Umeldungsinformatiounen. Dr\u00e9ck op ofsch\u00e9cke fir nach emol ze starten.", @@ -25,7 +25,7 @@ "name": "Numm", "region": "Regioun" }, - "description": "Gitt \u00e4r Playstation 4 Informatiounen an. Fir 'PIN', gitt an d'Astellunge vun der Playstation 4 Konsole. Dann op 'Mobile App Verbindungs Astellungen' a wielt \"Apparat dob\u00e4isetzen' aus. Gitt de PIN an deen ugewise g\u00ebtt.", + "description": "Gitt \u00e4r Playstation 4 Informatiounen an. Fir 'PIN', gitt an d'Astellunge vun der Playstation 4 Konsole. Dann op 'Mobile App Verbindungs Astellungen' a wielt \"Apparat dob\u00e4isetzen' aus. Gitt de PIN an deen ugewise g\u00ebtt. Liest [Dokumentatioun](https://www.home-assistant.io/components/ps4/) fir w\u00e9ider Informatiounen.", "title": "PlayStation 4" }, "mode": { diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index 610ec92a2b3939..83d70830b11a43 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -3,8 +3,8 @@ "name": "Python script", "documentation": "https://www.home-assistant.io/components/python_script", "requirements": [ - "restrictedpython==4.0" + "restrictedpython==5.0" ], "dependencies": [], "codeowners": [] -} +} \ No newline at end of file diff --git a/homeassistant/components/qld_bushfire/geo_location.py b/homeassistant/components/qld_bushfire/geo_location.py index e8d32c036d561f..8ae80ca9027a43 100644 --- a/homeassistant/components/qld_bushfire/geo_location.py +++ b/homeassistant/components/qld_bushfire/geo_location.py @@ -198,6 +198,11 @@ def _update_from_feed(self, feed_entry): self._updated_date = feed_entry.updated self._status = feed_entry.status + @property + def icon(self): + """Return the icon to use in the frontend.""" + return "mdi:fire" + @property def source(self) -> str: """Return source value of this external event.""" diff --git a/homeassistant/components/radiotherm/climate.py b/homeassistant/components/radiotherm/climate.py index f2c3e229c9586a..a007dd673ace3a 100644 --- a/homeassistant/components/radiotherm/climate.py +++ b/homeassistant/components/radiotherm/climate.py @@ -1,5 +1,4 @@ """Support for Radio Thermostat wifi-enabled home thermostats.""" -import datetime import logging import voluptuous as vol @@ -11,6 +10,11 @@ HVAC_MODE_COOL, HVAC_MODE_HEAT, HVAC_MODE_OFF, + FAN_ON, + FAN_OFF, + CURRENT_HVAC_IDLE, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_COOL, SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, ) @@ -21,12 +25,12 @@ TEMP_FAHRENHEIT, STATE_ON, ) +from homeassistant.util import dt as dt_util import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -ATTR_FAN = "fan" -ATTR_MODE = "mode" +ATTR_FAN_ACTION = "fan_action" CONF_HOLD_TEMP = "hold_temp" @@ -55,11 +59,11 @@ # Active thermostat state (is it heating or cooling?). In the future # this should probably made into heat and cool binary sensors. -CODE_TO_TEMP_STATE = {0: HVAC_MODE_OFF, 1: HVAC_MODE_HEAT, 2: HVAC_MODE_COOL} +CODE_TO_TEMP_STATE = {0: CURRENT_HVAC_IDLE, 1: CURRENT_HVAC_HEAT, 2: CURRENT_HVAC_COOL} # Active fan state. This is if the fan is actually on or not. In the # future this should probably made into a binary sensor for the fan. -CODE_TO_FAN_STATE = {0: HVAC_MODE_OFF, 1: STATE_ON} +CODE_TO_FAN_STATE = {0: FAN_OFF, 1: FAN_ON} def round_temp(temperature): @@ -160,7 +164,7 @@ def precision(self): @property def device_state_attributes(self): """Return the device specific state attributes.""" - return {ATTR_FAN: self._fstate, ATTR_MODE: self._tstate} + return {ATTR_FAN_ACTION: self._fstate} @property def fan_modes(self): @@ -200,6 +204,13 @@ def hvac_modes(self): """Return the operation modes list.""" return OPERATION_LIST + @property + def hvac_action(self): + """Return the current running hvac operation if supported.""" + if self.hvac_mode == HVAC_MODE_OFF: + return None + return self._tstate + @property def target_temperature(self): """Return the temperature we try to reach.""" @@ -261,9 +272,9 @@ def update(self): # This doesn't really work - tstate is only set if the HVAC is # active. If it's idle, we don't know what to do with the target # temperature. - if self._tstate == HVAC_MODE_COOL: + if self._tstate == CURRENT_HVAC_COOL: self._target_temperature = data["t_cool"] - elif self._tstate == HVAC_MODE_HEAT: + elif self._tstate == CURRENT_HVAC_HEAT: self._target_temperature = data["t_heat"] else: self._current_operation = HVAC_MODE_OFF @@ -281,9 +292,9 @@ def set_temperature(self, **kwargs): elif self._current_operation == HVAC_MODE_HEAT: self.device.t_heat = temperature elif self._current_operation == HVAC_MODE_AUTO: - if self._tstate == HVAC_MODE_COOL: + if self._tstate == CURRENT_HVAC_COOL: self.device.t_cool = temperature - elif self._tstate == HVAC_MODE_HEAT: + elif self._tstate == CURRENT_HVAC_HEAT: self.device.t_heat = temperature # Only change the hold if requested or if hold mode was turned @@ -299,7 +310,7 @@ def set_time(self): """Set device time.""" # Calling this clears any local temperature override and # reverts to the scheduled temperature. - now = datetime.datetime.now() + now = dt_util.now() self.device.time = { "day": now.weekday(), "hour": now.hour, diff --git a/homeassistant/components/rainmachine/.translations/pl.json b/homeassistant/components/rainmachine/.translations/pl.json index 9891ac50f4811f..9ab6156549d5a1 100644 --- a/homeassistant/components/rainmachine/.translations/pl.json +++ b/homeassistant/components/rainmachine/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "error": { - "identifier_exists": "Konto zosta\u0142o ju\u017c zarejestrowane", + "identifier_exists": "Konto jest ju\u017c zarejestrowane", "invalid_credentials": "Nieprawid\u0142owe po\u015bwiadczenia" }, "step": { @@ -11,7 +11,7 @@ "password": "Has\u0142o", "port": "Port" }, - "title": "Wprowad\u017a swoje dane" + "title": "Wprowad\u017a dane" } }, "title": "RainMachine" diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 0d814a5d74baac..9d34cc6fb79f27 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -7,7 +7,7 @@ import queue import threading import time -from typing import Any, Dict, Optional # noqa: F401 +from typing import Any, Dict, Optional import voluptuous as vol @@ -177,12 +177,12 @@ def __init__( self.hass = hass self.keep_days = keep_days self.purge_interval = purge_interval - self.queue = queue.Queue() # type: Any + self.queue: Any = queue.Queue() self.recording_start = dt_util.utcnow() self.db_url = uri self.async_db_ready = asyncio.Future() - self.engine = None # type: Any - self.run_info = None # type: Any + self.engine: Any = None + self.run_info: Any = None self.entity_filter = generate_filter( include.get(CONF_DOMAINS, []), diff --git a/homeassistant/components/remote_rpi_gpio/__init__.py b/homeassistant/components/remote_rpi_gpio/__init__.py index ccefd00c723ac3..33356d0e3b82cc 100644 --- a/homeassistant/components/remote_rpi_gpio/__init__.py +++ b/homeassistant/components/remote_rpi_gpio/__init__.py @@ -47,7 +47,7 @@ def setup_input(address, port, pull_mode, bouncetime): bounce_time=bouncetime, pin_factory=PiGPIOFactory(address), ) - except (ValueError, IndexError, KeyError, IOError): + except (ValueError, IndexError, KeyError, OSError): return None diff --git a/homeassistant/components/remote_rpi_gpio/binary_sensor.py b/homeassistant/components/remote_rpi_gpio/binary_sensor.py index 8c7d7b7d023b1d..e12d83324fd756 100644 --- a/homeassistant/components/remote_rpi_gpio/binary_sensor.py +++ b/homeassistant/components/remote_rpi_gpio/binary_sensor.py @@ -51,7 +51,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): button = remote_rpi_gpio.setup_input( address, port_num, pull_mode, bouncetime ) - except (ValueError, IndexError, KeyError, IOError): + except (ValueError, IndexError, KeyError, OSError): return new_sensor = RemoteRPiGPIOBinarySensor(port_name, button, invert_logic) devices.append(new_sensor) diff --git a/homeassistant/components/remote_rpi_gpio/switch.py b/homeassistant/components/remote_rpi_gpio/switch.py index aa20a2909d2ffd..8240de7951d710 100644 --- a/homeassistant/components/remote_rpi_gpio/switch.py +++ b/homeassistant/components/remote_rpi_gpio/switch.py @@ -36,7 +36,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for port, name in ports.items(): try: led = remote_rpi_gpio.setup_output(address, port, invert_logic) - except (ValueError, IndexError, KeyError, IOError): + except (ValueError, IndexError, KeyError, OSError): return new_switch = RemoteRPiGPIOSwitch(name, led, invert_logic) devices.append(new_switch) diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 5805114252e09a..697be4d1579220 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -1,9 +1,10 @@ """This component provides HA switch support for Ring Door Bell/Chimes.""" import logging -from datetime import datetime, timedelta +from datetime import timedelta from homeassistant.components.light import Light from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.core import callback +import homeassistant.util.dt as dt_util from . import DATA_RING_STICKUP_CAMS, SIGNAL_UPDATE_RING @@ -41,7 +42,7 @@ def __init__(self, device): self._device = device self._unique_id = self._device.id self._light_on = False - self._no_updates_until = datetime.now() + self._no_updates_until = dt_util.utcnow() async def async_added_to_hass(self): """Register callbacks.""" @@ -77,7 +78,7 @@ def _set_light(self, new_state): """Update light state, and causes HASS to correctly update.""" self._device.lights = new_state self._light_on = new_state == ON_STATE - self._no_updates_until = datetime.now() + SKIP_UPDATES_DELAY + self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY self.async_schedule_update_ha_state(True) def turn_on(self, **kwargs): @@ -90,7 +91,7 @@ def turn_off(self, **kwargs): def update(self): """Update current state of the light.""" - if self._no_updates_until > datetime.now(): + if self._no_updates_until > dt_util.utcnow(): _LOGGER.debug("Skipping update...") return diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index cbbecb1a40398b..413d2a70aae2ba 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -1,9 +1,10 @@ """This component provides HA switch support for Ring Door Bell/Chimes.""" import logging -from datetime import datetime, timedelta +from datetime import timedelta from homeassistant.components.switch import SwitchDevice from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.core import callback +import homeassistant.util.dt as dt_util from . import DATA_RING_STICKUP_CAMS, SIGNAL_UPDATE_RING @@ -72,14 +73,14 @@ class SirenSwitch(BaseRingSwitch): def __init__(self, device): """Initialize the switch for a device with a siren.""" super().__init__(device, "siren") - self._no_updates_until = datetime.now() + self._no_updates_until = dt_util.utcnow() self._siren_on = False def _set_switch(self, new_state): """Update switch state, and causes HASS to correctly update.""" self._device.siren = new_state self._siren_on = new_state > 0 - self._no_updates_until = datetime.now() + SKIP_UPDATES_DELAY + self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY self.schedule_update_ha_state() @property @@ -102,7 +103,7 @@ def icon(self): def update(self): """Update state of the siren.""" - if self._no_updates_until > datetime.now(): + if self._no_updates_until > dt_util.utcnow(): _LOGGER.debug("Skipping update...") return self._siren_on = self._device.siren > 0 diff --git a/homeassistant/components/season/sensor.py b/homeassistant/components/season/sensor.py index f2e0ccac2b0008..cdd6af57617e46 100644 --- a/homeassistant/components/season/sensor.py +++ b/homeassistant/components/season/sensor.py @@ -8,6 +8,7 @@ from homeassistant.const import CONF_TYPE from homeassistant.helpers.entity import Entity from homeassistant import util +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -104,7 +105,7 @@ def __init__(self, hass, hemisphere, season_tracking_type): """Initialize the season.""" self.hass = hass self.hemisphere = hemisphere - self.datetime = datetime.now() + self.datetime = dt_util.utcnow().replace(tzinfo=None) self.type = season_tracking_type self.season = get_season(self.datetime, self.hemisphere, self.type) @@ -125,5 +126,5 @@ def icon(self): def update(self): """Update season.""" - self.datetime = datetime.utcnow() + self.datetime = dt_util.utcnow().replace(tzinfo=None) self.season = get_season(self.datetime, self.hemisphere, self.type) diff --git a/homeassistant/components/sendgrid/manifest.json b/homeassistant/components/sendgrid/manifest.json index eb006f408bcbd8..1ffbe69888f329 100644 --- a/homeassistant/components/sendgrid/manifest.json +++ b/homeassistant/components/sendgrid/manifest.json @@ -3,7 +3,7 @@ "name": "Sendgrid", "documentation": "https://www.home-assistant.io/components/sendgrid", "requirements": [ - "sendgrid==6.0.5" + "sendgrid==6.1.0" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/sendgrid/notify.py b/homeassistant/components/sendgrid/notify.py index ac334587b89035..f16758a53559c2 100644 --- a/homeassistant/components/sendgrid/notify.py +++ b/homeassistant/components/sendgrid/notify.py @@ -3,6 +3,8 @@ import voluptuous as vol +from sendgrid import SendGridAPIClient + from homeassistant.const import ( CONF_API_KEY, CONF_RECIPIENT, @@ -45,8 +47,6 @@ class SendgridNotificationService(BaseNotificationService): def __init__(self, config): """Initialize the service.""" - from sendgrid import SendGridAPIClient - self.api_key = config[CONF_API_KEY] self.sender = config[CONF_SENDER] self.sender_name = config[CONF_SENDER_NAME] diff --git a/homeassistant/components/shodan/manifest.json b/homeassistant/components/shodan/manifest.json index 7ecc298e3f6f17..be7f0a524dcd0b 100644 --- a/homeassistant/components/shodan/manifest.json +++ b/homeassistant/components/shodan/manifest.json @@ -3,7 +3,7 @@ "name": "Shodan", "documentation": "https://www.home-assistant.io/components/shodan", "requirements": [ - "shodan==1.15.0" + "shodan==1.17.0" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/simplisafe/.translations/it.json b/homeassistant/components/simplisafe/.translations/it.json index 134bfae366821c..6f0e403a356548 100644 --- a/homeassistant/components/simplisafe/.translations/it.json +++ b/homeassistant/components/simplisafe/.translations/it.json @@ -9,7 +9,7 @@ "data": { "code": "Codice (Home Assistant)", "password": "Password", - "username": "Indirizzo email" + "username": "Indirizzo E-mail" }, "title": "Inserisci i tuoi dati" } diff --git a/homeassistant/components/simplisafe/.translations/pl.json b/homeassistant/components/simplisafe/.translations/pl.json index 0b83ba8cbedd10..c4d616600f56a7 100644 --- a/homeassistant/components/simplisafe/.translations/pl.json +++ b/homeassistant/components/simplisafe/.translations/pl.json @@ -1,7 +1,7 @@ { "config": { "error": { - "identifier_exists": "Konto zosta\u0142o ju\u017c zarejestrowane", + "identifier_exists": "Konto jest ju\u017c zarejestrowane", "invalid_credentials": "Nieprawid\u0142owe po\u015bwiadczenia" }, "step": { @@ -11,7 +11,7 @@ "password": "Has\u0142o", "username": "Adres e-mail" }, - "title": "Wprowad\u017a swoje dane" + "title": "Wprowad\u017a dane" } }, "title": "SimpliSafe" diff --git a/homeassistant/components/simplisafe/manifest.json b/homeassistant/components/simplisafe/manifest.json index 8a03ac47402bae..cf26955b207b5e 100644 --- a/homeassistant/components/simplisafe/manifest.json +++ b/homeassistant/components/simplisafe/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/simplisafe", "requirements": [ - "simplisafe-python==4.3.0" + "simplisafe-python==5.0.1" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/sky_hub/device_tracker.py b/homeassistant/components/sky_hub/device_tracker.py index c8969add244a19..109c410c16d0b8 100644 --- a/homeassistant/components/sky_hub/device_tracker.py +++ b/homeassistant/components/sky_hub/device_tracker.py @@ -94,7 +94,7 @@ def _parse_skyhub_response(data_str): """Parse the Sky Hub data format.""" pattmatch = re.search("attach_dev = '(.*)'", data_str) if pattmatch is None: - raise IOError( + raise OSError( "Error: Impossible to fetch data from" + " Sky Hub. Try to reboot the router." ) diff --git a/homeassistant/components/smartthings/.translations/it.json b/homeassistant/components/smartthings/.translations/it.json index 486a61847a71a0..c2b17eed04d3fe 100644 --- a/homeassistant/components/smartthings/.translations/it.json +++ b/homeassistant/components/smartthings/.translations/it.json @@ -5,6 +5,7 @@ "app_setup_error": "Impossibile configurare SmartApp. Riprovare.", "base_url_not_https": "Il `base_url` per il componente `http` deve essere configurato e deve iniziare con `https://`.", "token_already_setup": "Il token \u00e8 gi\u00e0 stato configurato.", + "token_forbidden": "Il token non dispone degli ambiti OAuth necessari.", "token_invalid_format": "Il token deve essere nel formato UID/GUID", "token_unauthorized": "Il token non \u00e8 valido o non \u00e8 pi\u00f9 autorizzato.", "webhook_error": "SmartThings non ha potuto convalidare l'endpoint configurato in `base_url`. Si prega di rivedere i requisiti del componente." @@ -18,6 +19,7 @@ "title": "Inserisci il Token di Accesso Personale" }, "wait_install": { + "description": "Si prega di installare l'Home Assistant SmartApp in almeno una posizione e fare clic su Invia.", "title": "Installa SmartApp" } }, diff --git a/homeassistant/components/solaredge/.translations/bg.json b/homeassistant/components/solaredge/.translations/bg.json new file mode 100644 index 00000000000000..72f1ad2a4c758f --- /dev/null +++ b/homeassistant/components/solaredge/.translations/bg.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "site_exists": "\u0422\u043e\u0432\u0430 site_id \u0432\u0435\u0447\u0435 \u0435 \u0437\u0430\u0434\u0430\u0434\u0435\u043d\u043e" + }, + "error": { + "site_exists": "\u0422\u043e\u0432\u0430 site_id \u0432\u0435\u0447\u0435 \u0435 \u0437\u0430\u0434\u0430\u0434\u0435\u043d\u043e" + }, + "step": { + "user": { + "data": { + "api_key": "API \u043a\u043b\u044e\u0447\u0430 \u0437\u0430 \u0442\u043e\u0437\u0438 \u0441\u0430\u0439\u0442", + "name": "\u041d\u0430\u0438\u043c\u0435\u043d\u043e\u0432\u0430\u043d\u0438\u0435\u0442\u043e \u043d\u0430 \u0442\u0430\u0437\u0438 \u0438\u043d\u0441\u0442\u0430\u043b\u0430\u0446\u0438\u044f", + "site_id": "\u041f\u0430\u0440\u0430\u043c\u0435\u0442\u044a\u0440\u044a\u0442 site-id \u043d\u0430 SolarEdge" + }, + "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438\u0442\u0435 \u043d\u0430 \u043f\u0440\u0438\u043b\u043e\u0436\u043d\u043e \u043f\u0440\u043e\u0433\u0440\u0430\u043c\u043d\u0438\u044f \u0438\u043d\u0442\u0435\u0440\u0444\u0435\u0439\u0441 (API) \u0437\u0430 \u0442\u0430\u0437\u0438 \u0438\u043d\u0441\u0442\u0430\u043b\u0430\u0446\u0438\u044f" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/ca.json b/homeassistant/components/solaredge/.translations/ca.json new file mode 100644 index 00000000000000..fd3707af3ddd88 --- /dev/null +++ b/homeassistant/components/solaredge/.translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "Aquest site_id ja est\u00e0 configurat" + }, + "error": { + "site_exists": "Aquest site_id ja est\u00e0 configurat" + }, + "step": { + "user": { + "data": { + "api_key": "Clau API d\u2019aquest lloc", + "name": "Nom d\u2019aquesta instal\u00b7laci\u00f3", + "site_id": "SolarEdge site_id" + }, + "title": "Configuraci\u00f3 dels par\u00e0metres de l'API per aquesta instal\u00b7laci\u00f3" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/da.json b/homeassistant/components/solaredge/.translations/da.json new file mode 100644 index 00000000000000..7ed64f51083d16 --- /dev/null +++ b/homeassistant/components/solaredge/.translations/da.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "Dette site_id er allerede konfigureret" + }, + "error": { + "site_exists": "Dette site_id er allerede konfigureret" + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8glen til dette websted", + "name": "Navnet p\u00e5 denne installation", + "site_id": "SolarEdge site-id" + }, + "title": "Definer API-parametre til denne installation" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/en.json b/homeassistant/components/solaredge/.translations/en.json new file mode 100644 index 00000000000000..7b06c110397f04 --- /dev/null +++ b/homeassistant/components/solaredge/.translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "This site_id is already configured" + }, + "error": { + "site_exists": "This site_id is already configured" + }, + "step": { + "user": { + "data": { + "api_key": "The API key for this site", + "name": "The name of this installation", + "site_id": "The SolarEdge site-id" + }, + "title": "Define the API parameters for this installation" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/es.json b/homeassistant/components/solaredge/.translations/es.json new file mode 100644 index 00000000000000..8708729bf4aebd --- /dev/null +++ b/homeassistant/components/solaredge/.translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "Este site_id ya est\u00e1 configurado" + }, + "error": { + "site_exists": "Este site_id ya est\u00e1 configurado" + }, + "step": { + "user": { + "data": { + "api_key": "La clave de la API para este sitio", + "name": "El nombre de esta instalaci\u00f3n", + "site_id": "La identificaci\u00f3n del sitio de SolarEdge" + }, + "title": "Definir los par\u00e1metros de la API para esta instalaci\u00f3n" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/fr.json b/homeassistant/components/solaredge/.translations/fr.json new file mode 100644 index 00000000000000..201e3ff49c6105 --- /dev/null +++ b/homeassistant/components/solaredge/.translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "Ce site est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "site_exists": "Ce site est d\u00e9j\u00e0 configur\u00e9" + }, + "step": { + "user": { + "data": { + "api_key": "La cl\u00e9 API pour ce site", + "name": "Le nom de cette installation", + "site_id": "L'identifiant de site SolarEdge" + }, + "title": "D\u00e9finir les param\u00e8tres de l'API pour cette installation" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/it.json b/homeassistant/components/solaredge/.translations/it.json new file mode 100644 index 00000000000000..6523f393628f27 --- /dev/null +++ b/homeassistant/components/solaredge/.translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "Questo site_id \u00e8 gi\u00e0 configurato" + }, + "error": { + "site_exists": "Questo site_id \u00e8 gi\u00e0 configurato" + }, + "step": { + "user": { + "data": { + "api_key": "La chiave API per questo sito", + "name": "Il nome di questa installazione", + "site_id": "Il sito-id di SolarEdge" + }, + "title": "Definire i parametri API per questa installazione" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/ko.json b/homeassistant/components/solaredge/.translations/ko.json new file mode 100644 index 00000000000000..3d4b344825289a --- /dev/null +++ b/homeassistant/components/solaredge/.translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "\uc774 site_id \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "site_exists": "\uc774 site_id \ub294 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "api_key": "\uc774 \uc0ac\uc774\ud2b8\uc758 API \ud0a4", + "name": "\uc774 \uc124\uce58\uc758 \uc774\ub984", + "site_id": "SolarEdge site-id" + }, + "title": "\uc774 \uc124\uce58\uc5d0 \ub300\ud55c API \ub9e4\uac1c\ubcc0\uc218\ub97c \uc815\uc758\ud574\uc8fc\uc138\uc694" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/lb.json b/homeassistant/components/solaredge/.translations/lb.json new file mode 100644 index 00000000000000..afc558ca80cd84 --- /dev/null +++ b/homeassistant/components/solaredge/.translations/lb.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "D\u00ebs site_id ass scho konfigur\u00e9iert" + }, + "error": { + "site_exists": "D\u00ebs site_id ass scho konfigur\u00e9iert" + }, + "step": { + "user": { + "data": { + "api_key": "API Schl\u00ebssel fir d\u00ebsen Site", + "name": "Numm vun d\u00ebser Installatioun", + "site_id": "SolarEdge site-ID" + }, + "title": "API Parameter fir d\u00ebs Installatioun d\u00e9fin\u00e9ieren" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/nl.json b/homeassistant/components/solaredge/.translations/nl.json new file mode 100644 index 00000000000000..3cc52b43a63126 --- /dev/null +++ b/homeassistant/components/solaredge/.translations/nl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "Deze site_id is al geconfigureerd" + }, + "error": { + "site_exists": "Deze site_id is al geconfigureerd" + }, + "step": { + "user": { + "data": { + "api_key": "De API-sleutel voor deze site", + "name": "De naam van deze installatie", + "site_id": "De SolarEdge site-id" + }, + "title": "Definieer de API-parameters voor deze installatie" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/no.json b/homeassistant/components/solaredge/.translations/no.json new file mode 100644 index 00000000000000..ad7cb55316b696 --- /dev/null +++ b/homeassistant/components/solaredge/.translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "Denne site_id er allerede konfigurert" + }, + "error": { + "site_exists": "Denne site_id er allerede konfigurert" + }, + "step": { + "user": { + "data": { + "api_key": "API-n\u00f8kkelen for dette nettstedet", + "name": "Navnet p\u00e5 denne installasjonen", + "site_id": "SolarEdge nettsted-id" + }, + "title": "Definer API-parametrene for denne installasjonen" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/pl.json b/homeassistant/components/solaredge/.translations/pl.json new file mode 100644 index 00000000000000..376a81219b0c81 --- /dev/null +++ b/homeassistant/components/solaredge/.translations/pl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "Ten site_id jest ju\u017c skonfigurowany" + }, + "error": { + "site_exists": "Ten site_id jest ju\u017c skonfigurowany" + }, + "step": { + "user": { + "data": { + "api_key": "Klucz API dla tej strony", + "name": "Nazwa tej instalacji", + "site_id": "SolarEdge site-id" + }, + "title": "Zdefiniuj parametry API dla tej instalacji" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/ru.json b/homeassistant/components/solaredge/.translations/ru.json new file mode 100644 index 00000000000000..fe36e4296feb92 --- /dev/null +++ b/homeassistant/components/solaredge/.translations/ru.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "\u042d\u0442\u043e\u0442 site_id \u0443\u0436\u0435 \u0441\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u043e\u0432\u0430\u043d" + }, + "error": { + "site_exists": "\u042d\u0442\u043e\u0442 site_id \u0443\u0436\u0435 \u0441\u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u043e\u0432\u0430\u043d" + }, + "step": { + "user": { + "data": { + "api_key": "\u041a\u043b\u044e\u0447 API \u0434\u043b\u044f \u044d\u0442\u043e\u0433\u043e \u0441\u0430\u0439\u0442\u0430", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435", + "site_id": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0441\u0430\u0439\u0442\u0430 SolarEdge" + }, + "title": "SolarEdge" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/sl.json b/homeassistant/components/solaredge/.translations/sl.json new file mode 100644 index 00000000000000..ebfefe40b0e54d --- /dev/null +++ b/homeassistant/components/solaredge/.translations/sl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "Ta site_id je \u017ee nastavljen" + }, + "error": { + "site_exists": "Ta site_id je \u017ee nastavljen" + }, + "step": { + "user": { + "data": { + "api_key": "API klju\u010d za to stran", + "name": "Ime te namestitve", + "site_id": "SolarEdge site-ID" + }, + "title": "Dolo\u010dite parametre API za to namestitev" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/.translations/zh-Hant.json b/homeassistant/components/solaredge/.translations/zh-Hant.json new file mode 100644 index 00000000000000..698c28d99bf72a --- /dev/null +++ b/homeassistant/components/solaredge/.translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "site_exists": "site_id \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "error": { + "site_exists": "site_id \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210" + }, + "step": { + "user": { + "data": { + "api_key": "API \u91d1\u9470", + "name": "\u5b89\u88dd\u540d\u7a31", + "site_id": "SolarEdge site-id" + }, + "title": "\u8a2d\u5b9a API \u53c3\u6578" + } + }, + "title": "SolarEdge" + } +} \ No newline at end of file diff --git a/homeassistant/components/solaredge/__init__.py b/homeassistant/components/solaredge/__init__.py index b675126c5fd69b..8909b970aafd2b 100644 --- a/homeassistant/components/solaredge/__init__.py +++ b/homeassistant/components/solaredge/__init__.py @@ -1 +1,43 @@ """The solaredge component.""" +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DEFAULT_NAME, DOMAIN, CONF_SITE_ID + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_SITE_ID): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +async def async_setup(hass, config): + """Platform setup, do nothing.""" + if DOMAIN not in config: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=dict(config[DOMAIN]) + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): + """Load the saved entities.""" + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + return True diff --git a/homeassistant/components/solaredge/config_flow.py b/homeassistant/components/solaredge/config_flow.py new file mode 100644 index 00000000000000..67f05d83aa0f17 --- /dev/null +++ b/homeassistant/components/solaredge/config_flow.py @@ -0,0 +1,98 @@ +"""Config flow for the SolarEdge platform.""" +import solaredge +import voluptuous as vol +from requests.exceptions import HTTPError, ConnectTimeout + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.util import slugify + +from .const import DOMAIN, DEFAULT_NAME, CONF_SITE_ID + + +@callback +def solaredge_entries(hass: HomeAssistant): + """Return the site_ids for the domain.""" + return set( + (entry.data[CONF_SITE_ID]) + for entry in hass.config_entries.async_entries(DOMAIN) + ) + + +class SolarEdgeConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self) -> None: + """Initialize the config flow.""" + self._errors = {} + + def _site_in_configuration_exists(self, site_id) -> bool: + """Return True if site_id exists in configuration.""" + if site_id in solaredge_entries(self.hass): + return True + return False + + def _check_site(self, site_id, api_key) -> bool: + """Check if we can connect to the soleredge api service.""" + api = solaredge.Solaredge(api_key) + try: + response = api.get_details(site_id) + except (ConnectTimeout, HTTPError): + self._errors[CONF_SITE_ID] = "could_not_connect" + return False + try: + if response["details"]["status"].lower() != "active": + self._errors[CONF_SITE_ID] = "site_not_active" + return False + except KeyError: + self._errors[CONF_SITE_ID] = "api_failure" + return False + return True + + async def async_step_user(self, user_input=None): + """Step when user intializes a integration.""" + self._errors = {} + if user_input is not None: + name = slugify(user_input.get(CONF_NAME, DEFAULT_NAME)) + if self._site_in_configuration_exists(user_input[CONF_SITE_ID]): + self._errors[CONF_SITE_ID] = "site_exists" + else: + site = user_input[CONF_SITE_ID] + api = user_input[CONF_API_KEY] + can_connect = await self.hass.async_add_executor_job( + self._check_site, site, api + ) + if can_connect: + return self.async_create_entry( + title=name, data={CONF_SITE_ID: site, CONF_API_KEY: api} + ) + + else: + user_input = {} + user_input[CONF_NAME] = DEFAULT_NAME + user_input[CONF_SITE_ID] = "" + user_input[CONF_API_KEY] = "" + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required( + CONF_NAME, default=user_input.get(CONF_NAME, DEFAULT_NAME) + ): str, + vol.Required(CONF_SITE_ID, default=user_input[CONF_SITE_ID]): str, + vol.Required(CONF_API_KEY, default=user_input[CONF_API_KEY]): str, + } + ), + errors=self._errors, + ) + + async def async_step_import(self, user_input=None): + """Import a config entry.""" + if self._site_in_configuration_exists(user_input[CONF_SITE_ID]): + return self.async_abort(reason="site_exists") + return await self.async_step_user(user_input) diff --git a/homeassistant/components/solaredge/const.py b/homeassistant/components/solaredge/const.py new file mode 100644 index 00000000000000..0d3d1a0cb5fff7 --- /dev/null +++ b/homeassistant/components/solaredge/const.py @@ -0,0 +1,68 @@ +"""Constants for the SolarEdge Monitoring API.""" +from datetime import timedelta + +from homeassistant.const import POWER_WATT, ENERGY_WATT_HOUR + +DOMAIN = "solaredge" + +# Config for solaredge monitoring api requests. +CONF_SITE_ID = "site_id" + +DEFAULT_NAME = "SolarEdge" + +OVERVIEW_UPDATE_DELAY = timedelta(minutes=10) +DETAILS_UPDATE_DELAY = timedelta(hours=12) +INVENTORY_UPDATE_DELAY = timedelta(hours=12) +POWER_FLOW_UPDATE_DELAY = timedelta(minutes=10) + +SCAN_INTERVAL = timedelta(minutes=10) + +# Supported overview sensor types: +# Key: ['json_key', 'name', unit, icon, default] +SENSOR_TYPES = { + "lifetime_energy": [ + "lifeTimeData", + "Lifetime energy", + ENERGY_WATT_HOUR, + "mdi:solar-power", + False, + ], + "energy_this_year": [ + "lastYearData", + "Energy this year", + ENERGY_WATT_HOUR, + "mdi:solar-power", + False, + ], + "energy_this_month": [ + "lastMonthData", + "Energy this month", + ENERGY_WATT_HOUR, + "mdi:solar-power", + False, + ], + "energy_today": [ + "lastDayData", + "Energy today", + ENERGY_WATT_HOUR, + "mdi:solar-power", + False, + ], + "current_power": [ + "currentPower", + "Current Power", + POWER_WATT, + "mdi:solar-power", + True, + ], + "site_details": [None, "Site details", None, None, False], + "meters": ["meters", "Meters", None, None, False], + "sensors": ["sensors", "Sensors", None, None, False], + "gateways": ["gateways", "Gateways", None, None, False], + "batteries": ["batteries", "Batteries", None, None, False], + "inverters": ["inverters", "Inverters", None, None, False], + "power_consumption": ["LOAD", "Power Consumption", None, "mdi:flash", False], + "solar_power": ["PV", "Solar Power", None, "mdi:solar-power", False], + "grid_power": ["GRID", "Grid Power", None, "mdi:power-plug", False], + "storage_power": ["STORAGE", "Storage Power", None, "mdi:car-battery", False], +} diff --git a/homeassistant/components/solaredge/manifest.json b/homeassistant/components/solaredge/manifest.json index b2707a0a9372de..7452790cd6043f 100644 --- a/homeassistant/components/solaredge/manifest.json +++ b/homeassistant/components/solaredge/manifest.json @@ -6,6 +6,7 @@ "solaredge==0.0.2", "stringcase==1.2.0" ], + "config_flow": true, "dependencies": [], "codeowners": [] } diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index cad81c3c3381eb..896596a2a34d01 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -1,102 +1,39 @@ """Support for SolarEdge Monitoring API.""" - -from datetime import timedelta import logging - -import voluptuous as vol +import solaredge from requests.exceptions import HTTPError, ConnectTimeout -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_API_KEY, - CONF_MONITORED_CONDITIONS, - CONF_NAME, - POWER_WATT, - ENERGY_WATT_HOUR, -) -import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_API_KEY from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -# Config for solaredge monitoring api requests. -CONF_SITE_ID = "site_id" - -OVERVIEW_UPDATE_DELAY = timedelta(minutes=10) -DETAILS_UPDATE_DELAY = timedelta(hours=12) -INVENTORY_UPDATE_DELAY = timedelta(hours=12) -POWER_FLOW_UPDATE_DELAY = timedelta(minutes=10) - -SCAN_INTERVAL = timedelta(minutes=10) - -# Supported overview sensor types: -# Key: ['json_key', 'name', unit, icon] -SENSOR_TYPES = { - "lifetime_energy": [ - "lifeTimeData", - "Lifetime energy", - ENERGY_WATT_HOUR, - "mdi:solar-power", - ], - "energy_this_year": [ - "lastYearData", - "Energy this year", - ENERGY_WATT_HOUR, - "mdi:solar-power", - ], - "energy_this_month": [ - "lastMonthData", - "Energy this month", - ENERGY_WATT_HOUR, - "mdi:solar-power", - ], - "energy_today": [ - "lastDayData", - "Energy today", - ENERGY_WATT_HOUR, - "mdi:solar-power", - ], - "current_power": ["currentPower", "Current Power", POWER_WATT, "mdi:solar-power"], - "site_details": [None, "Site details", None, None], - "meters": ["meters", "Meters", None, None], - "sensors": ["sensors", "Sensors", None, None], - "gateways": ["gateways", "Gateways", None, None], - "batteries": ["batteries", "Batteries", None, None], - "inverters": ["inverters", "Inverters", None, None], - "power_consumption": ["LOAD", "Power Consumption", None, "mdi:flash"], - "solar_power": ["PV", "Solar Power", None, "mdi:solar-power"], - "grid_power": ["GRID", "Grid Power", None, "mdi:power-plug"], - "storage_power": ["STORAGE", "Storage Power", None, "mdi:car-battery"], -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_SITE_ID): cv.string, - vol.Optional(CONF_NAME, default="SolarEdge"): cv.string, - vol.Optional(CONF_MONITORED_CONDITIONS, default=["current_power"]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] - ), - } +from .const import ( + CONF_SITE_ID, + OVERVIEW_UPDATE_DELAY, + DETAILS_UPDATE_DELAY, + INVENTORY_UPDATE_DELAY, + POWER_FLOW_UPDATE_DELAY, + SENSOR_TYPES, ) _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_entities, discovery_info=None): - """Create the SolarEdge Monitoring API sensor.""" - import solaredge +async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): + """Old configuration.""" + pass - api_key = config[CONF_API_KEY] - site_id = config[CONF_SITE_ID] - platform_name = config[CONF_NAME] - # Create new SolarEdge object to retrieve data - api = solaredge.Solaredge(api_key) +async def async_setup_entry(hass, entry, async_add_entities): + """Add an solarEdge entry.""" + # Add the needed sensors to hass + api = solaredge.Solaredge(entry.data[CONF_API_KEY]) # Check if api can be reached and site is active try: - response = api.get_details(site_id) - + response = await hass.async_add_executor_job( + api.get_details, entry.data[CONF_SITE_ID] + ) if response["details"]["status"].lower() != "active": _LOGGER.error("SolarEdge site is not active") return @@ -108,17 +45,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.error("Could not retrieve details from SolarEdge API") return - # Create sensor factory that will create sensors based on sensor_key. - sensor_factory = SolarEdgeSensorFactory(platform_name, site_id, api) - - # Create a new sensor for each sensor type. + sensor_factory = SolarEdgeSensorFactory(entry.title, entry.data[CONF_SITE_ID], api) entities = [] - for sensor_key in config[CONF_MONITORED_CONDITIONS]: + for sensor_key in SENSOR_TYPES: sensor = sensor_factory.create_sensor(sensor_key) if sensor is not None: entities.append(sensor) - - add_entities(entities, True) + async_add_entities(entities) class SolarEdgeSensorFactory: diff --git a/homeassistant/components/solaredge/strings.json b/homeassistant/components/solaredge/strings.json new file mode 100644 index 00000000000000..3265e3bb1b0a86 --- /dev/null +++ b/homeassistant/components/solaredge/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "title": "SolarEdge", + "step": { + "user": { + "title": "Define the API parameters for this installation", + "data": { + "name": "The name of this installation", + "site_id": "The SolarEdge site-id", + "api_key": "The API key for this site" + } + } + }, + "error": { + "site_exists": "This site_id is already configured" + }, + "abort": { + "site_exists": "This site_id is already configured" + } + } +} diff --git a/homeassistant/components/solaredge_local/manifest.json b/homeassistant/components/solaredge_local/manifest.json index 5fb07011983edd..291c774c383d56 100644 --- a/homeassistant/components/solaredge_local/manifest.json +++ b/homeassistant/components/solaredge_local/manifest.json @@ -1,8 +1,13 @@ { - "domain": "solaredge_local", - "name": "Solar Edge Local", - "documentation": "", - "dependencies": [], - "codeowners": ["@drobtravels"], - "requirements": ["solaredge-local==0.1.4"] - } \ No newline at end of file + "domain": "solaredge_local", + "name": "Solar Edge Local", + "documentation": "https://www.home-assistant.io/components/solaredge_local", + "requirements": [ + "solaredge-local==0.2.0" + ], + "dependencies": [], + "codeowners": [ + "@drobtravels", + "@scheric" + ] +} diff --git a/homeassistant/components/solaredge_local/sensor.py b/homeassistant/components/solaredge_local/sensor.py index 8586d950e39cdb..4fc62e44921448 100644 --- a/homeassistant/components/solaredge_local/sensor.py +++ b/homeassistant/components/solaredge_local/sensor.py @@ -1,19 +1,20 @@ -""" -Support for SolarEdge Monitoring API. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.solaredge_local/ -""" +"""Support for SolarEdge-local Monitoring API.""" import logging from datetime import timedelta +import statistics from requests.exceptions import HTTPError, ConnectTimeout from solaredge_local import SolarEdge import voluptuous as vol - from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME, POWER_WATT, ENERGY_WATT_HOUR +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_NAME, + POWER_WATT, + ENERGY_WATT_HOUR, + TEMP_CELSIUS, +) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -24,9 +25,10 @@ # Supported sensor types: # Key: ['json_key', 'name', unit, icon] SENSOR_TYPES = { - "lifetime_energy": [ - "energyTotal", - "Lifetime energy", + "current_power": ["currentPower", "Current Power", POWER_WATT, "mdi:solar-power"], + "energy_this_month": [ + "energyThisMonth", + "Energy this month", ENERGY_WATT_HOUR, "mdi:solar-power", ], @@ -36,19 +38,48 @@ ENERGY_WATT_HOUR, "mdi:solar-power", ], - "energy_this_month": [ - "energyThisMonth", - "Energy this month", - ENERGY_WATT_HOUR, - "mdi:solar-power", - ], "energy_today": [ "energyToday", "Energy today", ENERGY_WATT_HOUR, "mdi:solar-power", ], - "current_power": ["currentPower", "Current Power", POWER_WATT, "mdi:solar-power"], + "inverter_temperature": [ + "invertertemperature", + "Inverter Temperature", + TEMP_CELSIUS, + "mdi:thermometer", + ], + "lifetime_energy": [ + "energyTotal", + "Lifetime energy", + ENERGY_WATT_HOUR, + "mdi:solar-power", + ], + "optimizer_current": [ + "optimizercurrent", + "Avrage Optimizer Current", + "A", + "mdi:solar-panel", + ], + "optimizer_power": [ + "optimizerpower", + "Avrage Optimizer Power", + POWER_WATT, + "mdi:solar-panel", + ], + "optimizer_temperature": [ + "optimizertemperature", + "Avrage Optimizer Temperature", + TEMP_CELSIUS, + "mdi:solar-panel", + ], + "optimizer_voltage": [ + "optimizervoltage", + "Avrage Optimizer Voltage", + "V", + "mdi:solar-panel", + ], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( @@ -66,18 +97,16 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ip_address = config[CONF_IP_ADDRESS] platform_name = config[CONF_NAME] - # Create new SolarEdge object to retrieve data + # Create new SolarEdge object to retrieve data. api = SolarEdge(f"http://{ip_address}/") - # Check if api can be reached and site is active + # Check if api can be reached and site is active. try: status = api.get_status() - - status.energy # pylint: disable=pointless-statement _LOGGER.debug("Credentials correct and site is active") except AttributeError: - _LOGGER.error("Missing details data in solaredge response") - _LOGGER.debug("Response is: %s", status) + _LOGGER.error("Missing details data in solaredge status") + _LOGGER.debug("Status is: %s", status) return except (ConnectTimeout, HTTPError): _LOGGER.error("Could not retrieve details from SolarEdge API") @@ -111,7 +140,7 @@ def __init__(self, platform_name, sensor_key, data): @property def name(self): """Return the name.""" - return "{} ({})".format(self.platform_name, SENSOR_TYPES[self.sensor_key][1]) + return f"{self.platform_name} ({SENSOR_TYPES[self.sensor_key][1]})" @property def unit_of_measurement(self): @@ -147,21 +176,55 @@ def __init__(self, hass, api): def update(self): """Update the data from the SolarEdge Monitoring API.""" try: - response = self.api.get_status() - _LOGGER.debug("response from SolarEdge: %s", response) - except (ConnectTimeout): + status = self.api.get_status() + _LOGGER.debug("Status from SolarEdge: %s", status) + except ConnectTimeout: _LOGGER.error("Connection timeout, skipping update") return - except (HTTPError): - _LOGGER.error("Could not retrieve data, skipping update") + except HTTPError: + _LOGGER.error("Could not retrieve status, skipping update") return try: - self.data["energyTotal"] = response.energy.total - self.data["energyThisYear"] = response.energy.thisYear - self.data["energyThisMonth"] = response.energy.thisMonth - self.data["energyToday"] = response.energy.today - self.data["currentPower"] = response.powerWatt - _LOGGER.debug("Updated SolarEdge overview data: %s", self.data) - except AttributeError: - _LOGGER.error("Missing details data in SolarEdge response") + maintenance = self.api.get_maintenance() + _LOGGER.debug("Maintenance from SolarEdge: %s", maintenance) + except ConnectTimeout: + _LOGGER.error("Connection timeout, skipping update") + return + except HTTPError: + _LOGGER.error("Could not retrieve maintenance, skipping update") + return + + temperature = [] + voltage = [] + current = [] + power = 0 + + for optimizer in maintenance.diagnostics.inverters.primary.optimizer: + if not optimizer.online: + continue + temperature.append(optimizer.temperature.value) + voltage.append(optimizer.inputV) + current.append(optimizer.inputC) + + if not voltage: + temperature.append(0) + voltage.append(0) + current.append(0) + else: + power = statistics.mean(voltage) * statistics.mean(current) + + if status.sn: + self.data["energyTotal"] = round(status.energy.total, 2) + self.data["energyThisYear"] = round(status.energy.thisYear, 2) + self.data["energyThisMonth"] = round(status.energy.thisMonth, 2) + self.data["energyToday"] = round(status.energy.today, 2) + self.data["currentPower"] = round(status.powerWatt, 2) + self.data[ + "invertertemperature" + ] = status.inverters.primary.temperature.value + if maintenance.system.name: + self.data["optimizertemperature"] = statistics.mean(temperature) + self.data["optimizervoltage"] = statistics.mean(voltage) + self.data["optimizercurrent"] = statistics.mean(current) + self.data["optimizerpower"] = power diff --git a/homeassistant/components/solax/manifest.json b/homeassistant/components/solax/manifest.json index 52e50ab47998a5..3a154b857fe878 100644 --- a/homeassistant/components/solax/manifest.json +++ b/homeassistant/components/solax/manifest.json @@ -3,7 +3,7 @@ "name": "Solax Inverter", "documentation": "https://www.home-assistant.io/components/solax", "requirements": [ - "solax==0.1.2" + "solax==0.2.2" ], "dependencies": [], "codeowners": ["@squishykid"] diff --git a/homeassistant/components/solax/sensor.py b/homeassistant/components/solax/sensor.py index 0c1cfcf21da32a..a5b4547b344894 100644 --- a/homeassistant/components/solax/sensor.py +++ b/homeassistant/components/solax/sensor.py @@ -4,9 +4,11 @@ from datetime import timedelta import logging +from solax import real_time_api +from solax.inverter import InverterError import voluptuous as vol -from homeassistant.const import TEMP_CELSIUS, CONF_IP_ADDRESS +from homeassistant.const import TEMP_CELSIUS, CONF_IP_ADDRESS, CONF_PORT from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -15,24 +17,28 @@ _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({vol.Required(CONF_IP_ADDRESS): cv.string}) +DEFAULT_PORT = 80 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + } +) SCAN_INTERVAL = timedelta(seconds=30) async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Platform setup.""" - import solax - - api = solax.RealTimeAPI(config[CONF_IP_ADDRESS]) + api = await real_time_api(config[CONF_IP_ADDRESS], config[CONF_PORT]) endpoint = RealTimeDataEndpoint(hass, api) resp = await api.get_data() serial = resp.serial_number hass.async_add_job(endpoint.async_refresh) async_track_time_interval(hass, endpoint.async_refresh, SCAN_INTERVAL) devices = [] - for sensor in solax.INVERTER_SENSORS: - idx, unit = solax.INVERTER_SENSORS[sensor] + for sensor, (idx, unit) in api.inverter.sensor_map().items(): if unit == "C": unit = TEMP_CELSIUS uid = f"{serial}-{idx}" @@ -56,16 +62,14 @@ async def async_refresh(self, now=None): This is the only method that should fetch new data for Home Assistant. """ - from solax import SolaxRequestError - try: api_response = await self.api.get_data() self.ready.set() - except SolaxRequestError: + except InverterError: if now is not None: self.ready.clear() - else: - raise PlatformNotReady + return + raise PlatformNotReady data = api_response.data for sensor in self.sensors: if sensor.key in data: diff --git a/homeassistant/components/somfy/.translations/it.json b/homeassistant/components/somfy/.translations/it.json new file mode 100644 index 00000000000000..06fc8bed40f409 --- /dev/null +++ b/homeassistant/components/somfy/.translations/it.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_setup": "\u00c8 possibile configurare un solo account Somfy.", + "authorize_url_timeout": "Tempo scaduto nel generare l'url di autorizzazione", + "missing_configuration": "Il componente Somfy non \u00e8 configurato. Si prega di seguire la documentazione." + }, + "create_entry": { + "default": "Autenticato con successo con Somfy." + }, + "title": "Somfy" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/manifest.json b/homeassistant/components/sonos/manifest.json index 8c231ec63e0914..a08c0a59c07fd6 100644 --- a/homeassistant/components/sonos/manifest.json +++ b/homeassistant/components/sonos/manifest.json @@ -12,7 +12,5 @@ "urn:schemas-upnp-org:device:ZonePlayer:1" ] }, - "codeowners": [ - "@amelchio" - ] + "codeowners": [] } diff --git a/homeassistant/components/spaceapi/__init__.py b/homeassistant/components/spaceapi/__init__.py index 607d9c45538142..ea5a64d97e7cf2 100644 --- a/homeassistant/components/spaceapi/__init__.py +++ b/homeassistant/components/spaceapi/__init__.py @@ -7,9 +7,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_ICON, - ATTR_LATITUDE, ATTR_LOCATION, - ATTR_LONGITUDE, ATTR_STATE, ATTR_UNIT_OF_MEASUREMENT, CONF_ADDRESS, @@ -26,6 +24,15 @@ _LOGGER = logging.getLogger(__name__) ATTR_ADDRESS = "address" +ATTR_SPACEFED = "spacefed" +ATTR_CAM = "cam" +ATTR_STREAM = "stream" +ATTR_FEEDS = "feeds" +ATTR_CACHE = "cache" +ATTR_PROJECTS = "projects" +ATTR_RADIO_SHOW = "radio_show" +ATTR_LAT = "lat" +ATTR_LON = "lon" ATTR_API = "api" ATTR_CLOSE = "close" ATTR_CONTACT = "contact" @@ -49,32 +56,135 @@ CONF_IRC = "irc" CONF_ISSUE_REPORT_CHANNELS = "issue_report_channels" CONF_LOCATION = "location" +CONF_SPACEFED = "spacefed" +CONF_SPACENET = "spacenet" +CONF_SPACESAML = "spacesaml" +CONF_SPACEPHONE = "spacephone" +CONF_CAM = "cam" +CONF_STREAM = "stream" +CONF_M4 = "m4" +CONF_MJPEG = "mjpeg" +CONF_USTREAM = "ustream" +CONF_FEEDS = "feeds" +CONF_FEED_BLOG = "blog" +CONF_FEED_WIKI = "wiki" +CONF_FEED_CALENDAR = "calendar" +CONF_FEED_FLICKER = "flicker" +CONF_FEED_TYPE = "type" +CONF_FEED_URL = "url" +CONF_CACHE = "cache" +CONF_CACHE_SCHEDULE = "schedule" +CONF_PROJECTS = "projects" +CONF_RADIO_SHOW = "radio_show" +CONF_RADIO_SHOW_NAME = "name" +CONF_RADIO_SHOW_URL = "url" +CONF_RADIO_SHOW_TYPE = "type" +CONF_RADIO_SHOW_START = "start" +CONF_RADIO_SHOW_END = "end" CONF_LOGO = "logo" -CONF_MAILING_LIST = "mailing_list" CONF_PHONE = "phone" +CONF_SIP = "sip" +CONF_KEYMASTERS = "keymasters" +CONF_KEYMASTER_NAME = "name" +CONF_KEYMASTER_IRC_NICK = "irc_nick" +CONF_KEYMASTER_PHONE = "phone" +CONF_KEYMASTER_EMAIL = "email" +CONF_KEYMASTER_TWITTER = "twitter" +CONF_TWITTER = "twitter" +CONF_FACEBOOK = "facebook" +CONF_IDENTICA = "identica" +CONF_FOURSQUARE = "foursquare" +CONF_ML = "ml" +CONF_JABBER = "jabber" +CONF_ISSUE_MAIL = "issue_mail" CONF_SPACE = "space" CONF_TEMPERATURE = "temperature" -CONF_TWITTER = "twitter" DATA_SPACEAPI = "data_spaceapi" DOMAIN = "spaceapi" -ISSUE_REPORT_CHANNELS = [CONF_EMAIL, CONF_IRC, CONF_MAILING_LIST, CONF_TWITTER] +ISSUE_REPORT_CHANNELS = [CONF_EMAIL, CONF_ISSUE_MAIL, CONF_ML, CONF_TWITTER] SENSOR_TYPES = [CONF_HUMIDITY, CONF_TEMPERATURE] -SPACEAPI_VERSION = 0.13 +SPACEAPI_VERSION = "0.13" URL_API_SPACEAPI = "/api/spaceapi" -LOCATION_SCHEMA = vol.Schema({vol.Optional(CONF_ADDRESS): cv.string}, required=True) +LOCATION_SCHEMA = vol.Schema({vol.Optional(CONF_ADDRESS): cv.string}) + +SPACEFED_SCHEMA = vol.Schema( + { + vol.Optional(CONF_SPACENET): cv.boolean, + vol.Optional(CONF_SPACESAML): cv.boolean, + vol.Optional(CONF_SPACEPHONE): cv.boolean, + } +) + +STREAM_SCHEMA = vol.Schema( + { + vol.Optional(CONF_M4): cv.url, + vol.Optional(CONF_MJPEG): cv.url, + vol.Optional(CONF_USTREAM): cv.url, + } +) + +FEED_SCHEMA = vol.Schema( + {vol.Optional(CONF_FEED_TYPE): cv.string, vol.Required(CONF_FEED_URL): cv.url} +) + +FEEDS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_FEED_BLOG): FEED_SCHEMA, + vol.Optional(CONF_FEED_WIKI): FEED_SCHEMA, + vol.Optional(CONF_FEED_CALENDAR): FEED_SCHEMA, + vol.Optional(CONF_FEED_FLICKER): FEED_SCHEMA, + } +) + +CACHE_SCHEMA = vol.Schema( + { + vol.Required(CONF_CACHE_SCHEDULE): cv.matches_regex( + r"(m.02|m.05|m.10|m.15|m.30|h.01|h.02|h.04|h.08|h.12|d.01)" + ) + } +) + +RADIO_SHOW_SCHEMA = vol.Schema( + { + vol.Required(CONF_RADIO_SHOW_NAME): cv.string, + vol.Required(CONF_RADIO_SHOW_URL): cv.url, + vol.Required(CONF_RADIO_SHOW_TYPE): cv.matches_regex(r"(mp3|ogg)"), + vol.Required(CONF_RADIO_SHOW_START): cv.string, + vol.Required(CONF_RADIO_SHOW_END): cv.string, + } +) + +KEYMASTER_SCHEMA = vol.Schema( + { + vol.Optional(CONF_KEYMASTER_NAME): cv.string, + vol.Optional(CONF_KEYMASTER_IRC_NICK): cv.string, + vol.Optional(CONF_KEYMASTER_PHONE): cv.string, + vol.Optional(CONF_KEYMASTER_EMAIL): cv.string, + vol.Optional(CONF_KEYMASTER_TWITTER): cv.string, + } +) CONTACT_SCHEMA = vol.Schema( { vol.Optional(CONF_EMAIL): cv.string, vol.Optional(CONF_IRC): cv.string, - vol.Optional(CONF_MAILING_LIST): cv.string, + vol.Optional(CONF_ML): cv.string, vol.Optional(CONF_PHONE): cv.string, vol.Optional(CONF_TWITTER): cv.string, + vol.Optional(CONF_SIP): cv.string, + vol.Optional(CONF_FACEBOOK): cv.string, + vol.Optional(CONF_IDENTICA): cv.string, + vol.Optional(CONF_FOURSQUARE): cv.string, + vol.Optional(CONF_JABBER): cv.string, + vol.Optional(CONF_ISSUE_MAIL): cv.string, + vol.Optional(CONF_KEYMASTERS): vol.All( + cv.ensure_list, [KEYMASTER_SCHEMA], vol.Length(min=1) + ), }, required=False, ) @@ -100,12 +210,23 @@ vol.Required(CONF_ISSUE_REPORT_CHANNELS): vol.All( cv.ensure_list, [vol.In(ISSUE_REPORT_CHANNELS)] ), - vol.Required(CONF_LOCATION): LOCATION_SCHEMA, + vol.Optional(CONF_LOCATION): LOCATION_SCHEMA, vol.Required(CONF_LOGO): cv.url, vol.Required(CONF_SPACE): cv.string, vol.Required(CONF_STATE): STATE_SCHEMA, vol.Required(CONF_URL): cv.string, vol.Optional(CONF_SENSORS): SENSOR_SCHEMA, + vol.Optional(CONF_SPACEFED): SPACEFED_SCHEMA, + vol.Optional(CONF_CAM): vol.All( + cv.ensure_list, [cv.url], vol.Length(min=1) + ), + vol.Optional(CONF_STREAM): STREAM_SCHEMA, + vol.Optional(CONF_FEEDS): FEEDS_SCHEMA, + vol.Optional(CONF_CACHE): CACHE_SCHEMA, + vol.Optional(CONF_PROJECTS): vol.All(cv.ensure_list, [cv.url]), + vol.Optional(CONF_RADIO_SHOW): vol.All( + cv.ensure_list, [RADIO_SHOW_SCHEMA] + ), } ) }, @@ -150,11 +271,14 @@ def get(self, request): spaceapi = dict(hass.data[DATA_SPACEAPI]) is_sensors = spaceapi.get("sensors") - location = { - ATTR_ADDRESS: spaceapi[ATTR_LOCATION][CONF_ADDRESS], - ATTR_LATITUDE: hass.config.latitude, - ATTR_LONGITUDE: hass.config.longitude, - } + location = {ATTR_LAT: hass.config.latitude, ATTR_LON: hass.config.longitude} + + try: + location[ATTR_ADDRESS] = spaceapi[ATTR_LOCATION][CONF_ADDRESS] + except KeyError: + pass + except TypeError: + pass state_entity = spaceapi["state"][ATTR_ENTITY_ID] space_state = hass.states.get(state_entity) @@ -186,6 +310,41 @@ def get(self, request): ATTR_URL: spaceapi[CONF_URL], } + try: + data[ATTR_CAM] = spaceapi[CONF_CAM] + except KeyError: + pass + + try: + data[ATTR_SPACEFED] = spaceapi[CONF_SPACEFED] + except KeyError: + pass + + try: + data[ATTR_STREAM] = spaceapi[CONF_STREAM] + except KeyError: + pass + + try: + data[ATTR_FEEDS] = spaceapi[CONF_FEEDS] + except KeyError: + pass + + try: + data[ATTR_CACHE] = spaceapi[CONF_CACHE] + except KeyError: + pass + + try: + data[ATTR_PROJECTS] = spaceapi[CONF_PROJECTS] + except KeyError: + pass + + try: + data[ATTR_RADIO_SHOW] = spaceapi[CONF_RADIO_SHOW] + except KeyError: + pass + if is_sensors is not None: sensors = {} for sensor_type in is_sensors: diff --git a/homeassistant/components/srp_energy/__init__.py b/homeassistant/components/srp_energy/__init__.py deleted file mode 100644 index 71e04d7b8c99aa..00000000000000 --- a/homeassistant/components/srp_energy/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The srp_energy component.""" diff --git a/homeassistant/components/srp_energy/manifest.json b/homeassistant/components/srp_energy/manifest.json deleted file mode 100644 index 050a78223c17f5..00000000000000 --- a/homeassistant/components/srp_energy/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "srp_energy", - "name": "Srp energy", - "documentation": "https://www.home-assistant.io/components/srp_energy", - "requirements": [ - "srpenergy==1.0.6" - ], - "dependencies": [], - "codeowners": [] -} diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py deleted file mode 100644 index f1d1787b7b48c5..00000000000000 --- a/homeassistant/components/srp_energy/sensor.py +++ /dev/null @@ -1,156 +0,0 @@ -"""Platform for retrieving energy data from SRP.""" -from datetime import datetime, timedelta -import logging - -from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout -import voluptuous as vol - -from homeassistant.const import ( - CONF_NAME, - CONF_PASSWORD, - ENERGY_KILO_WATT_HOUR, - CONF_USERNAME, - CONF_ID, -) -import homeassistant.helpers.config_validation as cv -from homeassistant.util import Throttle -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.helpers.entity import Entity - -_LOGGER = logging.getLogger(__name__) - -ATTRIBUTION = "Powered by SRP Energy" - -DEFAULT_NAME = "SRP Energy" -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) -ENERGY_KWH = ENERGY_KILO_WATT_HOUR - -ATTR_READING_COST = "reading_cost" -ATTR_READING_TIME = "datetime" -ATTR_READING_USAGE = "reading_usage" -ATTR_DAILY_USAGE = "daily_usage" -ATTR_USAGE_HISTORY = "usage_history" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_ID): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the SRP energy.""" - _LOGGER.warning( - "The srp_energy integration is deprecated and will be removed " - "in Home Assistant 0.100.0. For more information see ADR-0004:" - "https://github.com/home-assistant/architecture/blob/master/adr/0004-webscraping.md" - ) - - name = config[CONF_NAME] - username = config[CONF_USERNAME] - password = config[CONF_PASSWORD] - account_id = config[CONF_ID] - - from srpenergy.client import SrpEnergyClient - - srp_client = SrpEnergyClient(account_id, username, password) - - if not srp_client.validate(): - _LOGGER.error("Couldn't connect to %s. Check credentials", name) - return - - add_entities([SrpEnergy(name, srp_client)], True) - - -class SrpEnergy(Entity): - """Representation of an srp usage.""" - - def __init__(self, name, client): - """Initialize SRP Usage.""" - self._state = None - self._name = name - self._client = client - self._history = None - self._usage = None - - @property - def attribution(self): - """Return the attribution.""" - return ATTRIBUTION - - @property - def state(self): - """Return the current state.""" - if self._state is None: - return None - - return f"{self._state:.2f}" - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return ENERGY_KWH - - @property - def history(self): - """Return the energy usage history of this entity, if any.""" - if self._usage is None: - return None - - history = [ - { - ATTR_READING_TIME: isodate, - ATTR_READING_USAGE: kwh, - ATTR_READING_COST: cost, - } - for _, _, isodate, kwh, cost in self._usage - ] - - return history - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attributes = {ATTR_USAGE_HISTORY: self.history} - - return attributes - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest usage from SRP Energy.""" - start_date = datetime.now() + timedelta(days=-1) - end_date = datetime.now() - - try: - - usage = self._client.usage(start_date, end_date) - - daily_usage = 0.0 - for _, _, _, kwh, _ in usage: - daily_usage += float(kwh) - - if usage: - - self._state = daily_usage - self._usage = usage - - else: - _LOGGER.error("Unable to fetch data from SRP. No data") - - except (ConnectError, HTTPError, Timeout) as error: - _LOGGER.error("Unable to connect to SRP. %s", error) - except ValueError as error: - _LOGGER.error("Value error connecting to SRP. %s", error) - except TypeError as error: - _LOGGER.error( - "Type error connecting to SRP. " "Check username and password. %s", - error, - ) diff --git a/homeassistant/components/startca/sensor.py b/homeassistant/components/startca/sensor.py index 5e370ed7b63290..1b567c58b45b87 100644 --- a/homeassistant/components/startca/sensor.py +++ b/homeassistant/components/startca/sensor.py @@ -18,8 +18,8 @@ DEFAULT_NAME = "Start.ca" CONF_TOTAL_BANDWIDTH = "total_bandwidth" -GIGABYTES = "GB" # type: str -PERCENT = "%" # type: str +GIGABYTES = "GB" +PERCENT = "%" MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1) REQUEST_TIMEOUT = 5 # seconds diff --git a/homeassistant/components/supla/__init__.py b/homeassistant/components/supla/__init__.py index 9eef9d989cb093..86e763142e6191 100644 --- a/homeassistant/components/supla/__init__.py +++ b/homeassistant/components/supla/__init__.py @@ -65,7 +65,7 @@ def setup(hass, base_config): srv_info, ) return False - except IOError: + except OSError: _LOGGER.exception( "Server: %s not configured. Error on Supla API access: ", server_address ) diff --git a/homeassistant/components/switch/.translations/bg.json b/homeassistant/components/switch/.translations/bg.json new file mode 100644 index 00000000000000..efccc652d5beb7 --- /dev/null +++ b/homeassistant/components/switch/.translations/bg.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "\u041f\u0440\u0435\u0432\u043a\u043b\u044e\u0447\u0438 {entity_name}", + "turn_off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0438 {entity_name}", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438 {entity_name}" + }, + "condition_type": { + "turn_off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 {entity_name}", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 {entity_name}" + }, + "trigger_type": { + "turned_off": "\u0418\u0437\u043a\u043b\u044e\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 {entity_name}", + "turned_on": "\u0412\u043a\u043b\u044e\u0447\u0432\u0430\u043d\u0435 \u043d\u0430 {entity_name}" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/.translations/ca.json b/homeassistant/components/switch/.translations/ca.json new file mode 100644 index 00000000000000..dbf5e152656497 --- /dev/null +++ b/homeassistant/components/switch/.translations/ca.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Commuta {entity_name}", + "turn_off": "Desactiva {entity_name}", + "turn_on": "Activa {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} est\u00e0 apagat", + "is_on": "{entity_name} est\u00e0 enc\u00e8s", + "turn_off": "{entity_name} desactivat", + "turn_on": "{entity_name} activat" + }, + "trigger_type": { + "turned_off": "{entity_name} desactivat", + "turned_on": "{entity_name} activat" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/.translations/en.json b/homeassistant/components/switch/.translations/en.json new file mode 100644 index 00000000000000..391a071cb8f4fb --- /dev/null +++ b/homeassistant/components/switch/.translations/en.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Toggle {entity_name}", + "turn_off": "Turn off {entity_name}", + "turn_on": "Turn on {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} is off", + "is_on": "{entity_name} is on", + "turn_off": "{entity_name} turned off", + "turn_on": "{entity_name} turned on" + }, + "trigger_type": { + "turned_off": "{entity_name} turned off", + "turned_on": "{entity_name} turned on" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/.translations/es.json b/homeassistant/components/switch/.translations/es.json new file mode 100644 index 00000000000000..24dbc2cdc1fff0 --- /dev/null +++ b/homeassistant/components/switch/.translations/es.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Alternar {entity_name}", + "turn_off": "Apagar {entity_name}", + "turn_on": "Encender {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} est\u00e1 apagada", + "is_on": "{entity_name} est\u00e1 encendida", + "turn_off": "{entity_name} apagado", + "turn_on": "{entity_name} encendido" + }, + "trigger_type": { + "turned_off": "{entity_name} apagado", + "turned_on": "{entity_name} encendido" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/.translations/fr.json b/homeassistant/components/switch/.translations/fr.json new file mode 100644 index 00000000000000..807b85c5fb56d6 --- /dev/null +++ b/homeassistant/components/switch/.translations/fr.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Basculer {entity_name}", + "turn_off": "\u00c9teindre {entity_name}", + "turn_on": "Allumer {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} est \u00e9teint", + "is_on": "{entity_name} est allum\u00e9", + "turn_off": "{entity_name} \u00e9teint", + "turn_on": "{entity_name} allum\u00e9" + }, + "trigger_type": { + "turned_off": "{entity_name} \u00e9teint", + "turned_on": "{entity_name} allum\u00e9" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/.translations/it.json b/homeassistant/components/switch/.translations/it.json new file mode 100644 index 00000000000000..ec742e4113bd7c --- /dev/null +++ b/homeassistant/components/switch/.translations/it.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Attivare / Disattivare {entity_name}", + "turn_off": "Disattivare {entity_name}", + "turn_on": "Attivare {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u00e8 disattivato", + "is_on": "{entity_name} \u00e8 attivo", + "turn_off": "{entity_name} disattivato", + "turn_on": "{entity_name} attivato" + }, + "trigger_type": { + "turned_off": "{entity_name} disattivato", + "turned_on": "{entity_name} attivato" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/.translations/ko.json b/homeassistant/components/switch/.translations/ko.json new file mode 100644 index 00000000000000..02c303f932987b --- /dev/null +++ b/homeassistant/components/switch/.translations/ko.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "toggle": "{entity_name} \ud1a0\uae00", + "turn_off": "{entity_name} \ub044\uae30", + "turn_on": "{entity_name} \ucf1c\uae30" + }, + "condition_type": { + "is_off": "{entity_name} \uc774(\uac00) \uaebc\uc84c\uc2b5\ub2c8\ub2e4", + "is_on": "{entity_name} \uc774(\uac00) \ucf1c\uc84c\uc2b5\ub2c8\ub2e4", + "turn_off": "{entity_name} \uc774(\uac00) \uaebc\uc84c\uc2b5\ub2c8\ub2e4", + "turn_on": "{entity_name} \uc774(\uac00) \ucf1c\uc84c\uc2b5\ub2c8\ub2e4" + }, + "trigger_type": { + "turned_off": "{entity_name} \uc774(\uac00) \uaebc\uc84c\uc2b5\ub2c8\ub2e4", + "turned_on": "{entity_name} \uc774(\uac00) \ucf1c\uc84c\uc2b5\ub2c8\ub2e4" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/.translations/lb.json b/homeassistant/components/switch/.translations/lb.json new file mode 100644 index 00000000000000..8e974a0a8de137 --- /dev/null +++ b/homeassistant/components/switch/.translations/lb.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "toggle": "{entity_name} \u00ebmschalten", + "turn_off": "{entity_name} ausschalten", + "turn_on": "{entity_name} uschalten" + }, + "condition_type": { + "is_off": "{entity_name} ass aus", + "is_on": "{entity_name} ass un", + "turn_off": "{entity_name} gouf ausgeschalt", + "turn_on": "{entity_name} gouf ugeschalt" + }, + "trigger_type": { + "turned_off": "{entity_name} gouf ausgeschalt", + "turned_on": "{entity_name} gouf ugeschalt" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/.translations/nl.json b/homeassistant/components/switch/.translations/nl.json new file mode 100644 index 00000000000000..5e2aa6747a4c32 --- /dev/null +++ b/homeassistant/components/switch/.translations/nl.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Omschakelen {entity_name}", + "turn_off": "Zet {entity_name} uit.", + "turn_on": "Zet {entity_name} aan." + }, + "condition_type": { + "is_off": "{entity_name} is uitgeschakeld", + "is_on": "{entity_name} is ingeschakeld", + "turn_off": "{entity_name} uitgeschakeld", + "turn_on": "{entity_name} ingeschakeld" + }, + "trigger_type": { + "turned_off": "{entity_name} uitgeschakeld", + "turned_on": "{entity_name} ingeschakeld" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/.translations/no.json b/homeassistant/components/switch/.translations/no.json new file mode 100644 index 00000000000000..3469079f230b43 --- /dev/null +++ b/homeassistant/components/switch/.translations/no.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Veksle {entity_name}", + "turn_off": "Sl\u00e5 av {entity_name}", + "turn_on": "Sl\u00e5 p\u00e5 {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} er av", + "is_on": "{entity_name} er p\u00e5", + "turn_off": "{entity_name} sl\u00e5tt av", + "turn_on": "{entity_name} sl\u00e5tt p\u00e5" + }, + "trigger_type": { + "turned_off": "{entity_name} sl\u00e5tt av", + "turned_on": "{entity_name} sl\u00e5tt p\u00e5" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/.translations/pl.json b/homeassistant/components/switch/.translations/pl.json new file mode 100644 index 00000000000000..199b150f68ee6d --- /dev/null +++ b/homeassistant/components/switch/.translations/pl.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Prze\u0142\u0105cz {entity_name}", + "turn_off": "Wy\u0142\u0105cz {entity_name}", + "turn_on": "W\u0142\u0105cz {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} jest wy\u0142\u0105czony.", + "is_on": "{entity_name} jest w\u0142\u0105czony", + "turn_off": "{entity_name} wy\u0142\u0105czone", + "turn_on": "{entity_name} w\u0142\u0105czone" + }, + "trigger_type": { + "turned_off": "{entity_name} wy\u0142\u0105czone", + "turned_on": "{entity_name} w\u0142\u0105czone" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/.translations/ru.json b/homeassistant/components/switch/.translations/ru.json new file mode 100644 index 00000000000000..cd5cbc0d6a174b --- /dev/null +++ b/homeassistant/components/switch/.translations/ru.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "toggle": "\u041f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}", + "turn_off": "\u0412\u044b\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}", + "turn_on": "\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "is_on": "{entity_name} \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "turn_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u043e", + "turn_on": "{entity_name} \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u043e" + }, + "trigger_type": { + "turned_off": "{entity_name} \u0432\u044b\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435", + "turned_on": "{entity_name} \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/.translations/sl.json b/homeassistant/components/switch/.translations/sl.json new file mode 100644 index 00000000000000..f1b851b05b6f4b --- /dev/null +++ b/homeassistant/components/switch/.translations/sl.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Preklopite {entity_name}", + "turn_off": "Izklopite {entity_name}", + "turn_on": "Vklopite {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} je izklopljen", + "is_on": "{entity_name} je vklopljen", + "turn_off": "{entity_name} izklopljen", + "turn_on": "{entity_name} vklopljen" + }, + "trigger_type": { + "turned_off": "{entity_name} izklopljen", + "turned_on": "{entity_name} vklopljen" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/.translations/zh-Hant.json b/homeassistant/components/switch/.translations/zh-Hant.json new file mode 100644 index 00000000000000..517d48354dcc2d --- /dev/null +++ b/homeassistant/components/switch/.translations/zh-Hant.json @@ -0,0 +1,19 @@ +{ + "device_automation": { + "action_type": { + "toggle": "\u5207\u63db {entity_name}", + "turn_off": "\u95dc\u9589 {entity_name}", + "turn_on": "\u958b\u555f {entity_name}" + }, + "condition_type": { + "is_off": "{entity_name} \u5df2\u95dc\u9589", + "is_on": "{entity_name} \u5df2\u958b\u555f", + "turn_off": "{entity_name} \u5df2\u95dc\u9589", + "turn_on": "{entity_name} \u5df2\u958b\u555f" + }, + "trigger_type": { + "turned_off": "{entity_name} \u5df2\u95dc\u9589", + "turned_on": "{entity_name} \u5df2\u958b\u555f" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/switch/device_automation.py b/homeassistant/components/switch/device_automation.py new file mode 100644 index 00000000000000..61292d47449adf --- /dev/null +++ b/homeassistant/components/switch/device_automation.py @@ -0,0 +1,56 @@ +"""Provides device automations for lights.""" +import voluptuous as vol + +from homeassistant.components.device_automation import toggle_entity +from homeassistant.const import CONF_DOMAIN +from . import DOMAIN + + +# mypy: allow-untyped-defs, no-check-untyped-defs + +ACTION_SCHEMA = toggle_entity.ACTION_SCHEMA.extend({vol.Required(CONF_DOMAIN): DOMAIN}) + +CONDITION_SCHEMA = toggle_entity.CONDITION_SCHEMA.extend( + {vol.Required(CONF_DOMAIN): DOMAIN} +) + +TRIGGER_SCHEMA = toggle_entity.TRIGGER_SCHEMA.extend( + {vol.Required(CONF_DOMAIN): DOMAIN} +) + + +async def async_call_action_from_config(hass, config, variables, context): + """Change state based on configuration.""" + config = ACTION_SCHEMA(config) + await toggle_entity.async_call_action_from_config( + hass, config, variables, context, DOMAIN + ) + + +def async_condition_from_config(config, config_validation): + """Evaluate state based on configuration.""" + config = CONDITION_SCHEMA(config) + return toggle_entity.async_condition_from_config(config, config_validation) + + +async def async_trigger(hass, config, action, automation_info): + """Listen for state changes based on configuration.""" + config = TRIGGER_SCHEMA(config) + return await toggle_entity.async_attach_trigger( + hass, config, action, automation_info + ) + + +async def async_get_actions(hass, device_id): + """List device actions.""" + return await toggle_entity.async_get_actions(hass, device_id, DOMAIN) + + +async def async_get_conditions(hass, device_id): + """List device conditions.""" + return await toggle_entity.async_get_conditions(hass, device_id, DOMAIN) + + +async def async_get_triggers(hass, device_id): + """List device triggers.""" + return await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) diff --git a/homeassistant/components/switch/light.py b/homeassistant/components/switch/light.py index 0b1094c0dd9d71..8f3b5d87f8c202 100644 --- a/homeassistant/components/switch/light.py +++ b/homeassistant/components/switch/light.py @@ -1,6 +1,6 @@ """Light support for switch entities.""" import logging -from typing import cast +from typing import cast, Callable, Dict, Optional, Sequence import voluptuous as vol @@ -14,13 +14,14 @@ ) from homeassistant.core import State, callback import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.components.light import PLATFORM_SCHEMA, Light -# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs +# mypy: allow-untyped-calls, allow-untyped-defs _LOGGER = logging.getLogger(__name__) @@ -35,7 +36,10 @@ async def async_setup_platform( - hass: HomeAssistantType, config: ConfigType, async_add_entities, discovery_info=None + hass: HomeAssistantType, + config: ConfigType, + async_add_entities: Callable[[Sequence[Entity], bool], None], + discovery_info: Optional[Dict] = None, ) -> None: """Initialize Light Switch platform.""" async_add_entities( @@ -48,10 +52,10 @@ class LightSwitch(Light): def __init__(self, name: str, switch_entity_id: str) -> None: """Initialize Light Switch.""" - self._name = name # type: str - self._switch_entity_id = switch_entity_id # type: str - self._is_on = False # type: bool - self._available = False # type: bool + self._name = name + self._switch_entity_id = switch_entity_id + self._is_on = False + self._available = False self._async_unsub_state_changed = None @property @@ -105,7 +109,7 @@ async def async_added_to_hass(self) -> None: @callback def async_state_changed_listener( entity_id: str, old_state: State, new_state: State - ): + ) -> None: """Handle child updates.""" self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/switch/strings.json b/homeassistant/components/switch/strings.json new file mode 100644 index 00000000000000..77b842ba07833a --- /dev/null +++ b/homeassistant/components/switch/strings.json @@ -0,0 +1,17 @@ +{ + "device_automation": { + "action_type": { + "toggle": "Toggle {entity_name}", + "turn_on": "Turn on {entity_name}", + "turn_off": "Turn off {entity_name}" + }, + "condition_type": { + "is_on": "{entity_name} is on", + "is_off": "{entity_name} is off" + }, + "trigger_type": { + "turned_on": "{entity_name} turned on", + "turned_off": "{entity_name} turned off" + } + } +} diff --git a/homeassistant/components/switcher_kis/switch.py b/homeassistant/components/switcher_kis/switch.py index a758a5843472cb..454baca4eef849 100644 --- a/homeassistant/components/switcher_kis/switch.py +++ b/homeassistant/components/switcher_kis/switch.py @@ -143,7 +143,7 @@ async def _control_device(self, send_on: bool) -> None: STATE_ON as SWITCHER_STATE_ON, ) - response = None # type: SwitcherV2ControlResponseMSG + response: "SwitcherV2ControlResponseMSG" = None async with SwitcherV2Api( self.hass.loop, self._device_data.ip_addr, diff --git a/homeassistant/components/systemmonitor/sensor.py b/homeassistant/components/systemmonitor/sensor.py index 446a36ec350f88..ad2072baaa5230 100644 --- a/homeassistant/components/systemmonitor/sensor.py +++ b/homeassistant/components/systemmonitor/sensor.py @@ -1,5 +1,4 @@ """Support for monitoring the local system.""" -from datetime import datetime import logging import os import socket @@ -193,7 +192,7 @@ def update(self): counters = psutil.net_io_counters(pernic=True) if self.argument in counters: counter = counters[self.argument][IO_COUNTER[self.type]] - now = datetime.now() + now = dt_util.utcnow() if self._last_value and self._last_value < counter: self._state = round( (counter - self._last_value) diff --git a/homeassistant/components/sytadin/__init__.py b/homeassistant/components/sytadin/__init__.py deleted file mode 100644 index 5243fe379a774e..00000000000000 --- a/homeassistant/components/sytadin/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The sytadin component.""" diff --git a/homeassistant/components/sytadin/manifest.json b/homeassistant/components/sytadin/manifest.json deleted file mode 100644 index c1453d88d81448..00000000000000 --- a/homeassistant/components/sytadin/manifest.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "domain": "sytadin", - "name": "Sytadin", - "documentation": "https://www.home-assistant.io/components/sytadin", - "requirements": [ - "beautifulsoup4==4.8.0" - ], - "dependencies": [], - "codeowners": [ - "@gautric" - ] -} diff --git a/homeassistant/components/sytadin/sensor.py b/homeassistant/components/sytadin/sensor.py deleted file mode 100644 index b7c94933a39974..00000000000000 --- a/homeassistant/components/sytadin/sensor.py +++ /dev/null @@ -1,151 +0,0 @@ -"""Support for Sytadin Traffic, French Traffic Supervision.""" -import logging -import re -from datetime import timedelta - -import requests -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - LENGTH_KILOMETERS, - CONF_MONITORED_CONDITIONS, - CONF_NAME, - ATTR_ATTRIBUTION, -) -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle - -_LOGGER = logging.getLogger(__name__) - -URL = "http://www.sytadin.fr/sys/barometres_de_la_circulation.jsp.html" - -ATTRIBUTION = "Data provided by Direction des routes Île-de-France (DiRIF)" - -DEFAULT_NAME = "Sytadin" -REGEX = r"(\d*\.\d+|\d+)" - -OPTION_TRAFFIC_JAM = "traffic_jam" -OPTION_MEAN_VELOCITY = "mean_velocity" -OPTION_CONGESTION = "congestion" - -SENSOR_TYPES = { - OPTION_CONGESTION: ["Congestion", ""], - OPTION_MEAN_VELOCITY: ["Mean Velocity", LENGTH_KILOMETERS + "/h"], - OPTION_TRAFFIC_JAM: ["Traffic Jam", LENGTH_KILOMETERS], -} - -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_MONITORED_CONDITIONS, default=[OPTION_TRAFFIC_JAM]): vol.All( - cv.ensure_list, [vol.In(SENSOR_TYPES)] - ), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up of the Sytadin Traffic sensor platform.""" - _LOGGER.warning( - "The sytadin integration is deprecated and will be removed " - "in Home Assistant 0.100.0. For more information see ADR-0004:" - "https://github.com/home-assistant/architecture/blob/master/adr/0004-webscraping.md" - ) - - name = config.get(CONF_NAME) - - sytadin = SytadinData(URL) - - dev = [] - for option in config.get(CONF_MONITORED_CONDITIONS): - _LOGGER.debug("Sensor device - %s", option) - dev.append( - SytadinSensor( - sytadin, name, option, SENSOR_TYPES[option][0], SENSOR_TYPES[option][1] - ) - ) - add_entities(dev, True) - - -class SytadinSensor(Entity): - """Representation of a Sytadin Sensor.""" - - def __init__(self, data, name, sensor_type, option, unit): - """Initialize the sensor.""" - self.data = data - self._state = None - self._name = name - self._option = option - self._type = sensor_type - self._unit = unit - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} {self._option}" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return {ATTR_ATTRIBUTION: ATTRIBUTION} - - def update(self): - """Fetch new state data for the sensor.""" - self.data.update() - - if self.data is None: - return - - if self._type == OPTION_TRAFFIC_JAM: - self._state = self.data.traffic_jam - elif self._type == OPTION_MEAN_VELOCITY: - self._state = self.data.mean_velocity - elif self._type == OPTION_CONGESTION: - self._state = self.data.congestion - - -class SytadinData: - """The class for handling the data retrieval.""" - - def __init__(self, resource): - """Initialize the data object.""" - self._resource = resource - self.data = None - self.traffic_jam = self.mean_velocity = self.congestion = None - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data from the Sytadin.""" - from bs4 import BeautifulSoup - - try: - raw_html = requests.get(self._resource, timeout=10).text - data = BeautifulSoup(raw_html, "html.parser") - - values = data.select(".barometre_valeur") - parse_traffic_jam = re.search(REGEX, values[0].text) - if parse_traffic_jam: - self.traffic_jam = parse_traffic_jam.group() - parse_mean_velocity = re.search(REGEX, values[1].text) - if parse_mean_velocity: - self.mean_velocity = parse_mean_velocity.group() - parse_congestion = re.search(REGEX, values[2].text) - if parse_congestion: - self.congestion = parse_congestion.group() - except requests.exceptions.ConnectionError: - _LOGGER.error("Connection error") - self.data = None diff --git a/homeassistant/components/teksavvy/sensor.py b/homeassistant/components/teksavvy/sensor.py index 74c39a221ba0cf..dc8b16b8ce18fc 100644 --- a/homeassistant/components/teksavvy/sensor.py +++ b/homeassistant/components/teksavvy/sensor.py @@ -17,8 +17,8 @@ DEFAULT_NAME = "TekSavvy" CONF_TOTAL_BANDWIDTH = "total_bandwidth" -GIGABYTES = "GB" # type: str -PERCENT = "%" # type: str +GIGABYTES = "GB" +PERCENT = "%" MIN_TIME_BETWEEN_UPDATES = timedelta(hours=1) REQUEST_TIMEOUT = 5 # seconds diff --git a/homeassistant/components/tellduslive/.translations/es.json b/homeassistant/components/tellduslive/.translations/es.json index b0313a1eee357a..677e0389d45b71 100644 --- a/homeassistant/components/tellduslive/.translations/es.json +++ b/homeassistant/components/tellduslive/.translations/es.json @@ -18,6 +18,7 @@ "data": { "host": "Host" }, + "description": "Vac\u00edo", "title": "Elige el punto final." } }, diff --git a/homeassistant/components/tellduslive/.translations/it.json b/homeassistant/components/tellduslive/.translations/it.json index 3baa307de51f7b..ce152285e757d3 100644 --- a/homeassistant/components/tellduslive/.translations/it.json +++ b/homeassistant/components/tellduslive/.translations/it.json @@ -11,12 +11,14 @@ }, "step": { "auth": { + "description": "Per collegare il tuo account TelldusLive:\n 1. Clicca sul link sottostante\n 2. Accedi a Telldus Live\n 3. Autorizzare **{app_name}**** (cliccare **S\u00ec**).\n 4. Torna qui e clicca su **SUBMIT**.\n\n [Collega account TelldusLive]({auth_url})", "title": "Autenticati con TelldusLive" }, "user": { "data": { "host": "Host" }, + "description": "Vuoto", "title": "Scegli l'endpoint." } }, diff --git a/homeassistant/components/tellduslive/.translations/no.json b/homeassistant/components/tellduslive/.translations/no.json index d311b3b0d38068..090de51703654f 100644 --- a/homeassistant/components/tellduslive/.translations/no.json +++ b/homeassistant/components/tellduslive/.translations/no.json @@ -12,7 +12,7 @@ "step": { "auth": { "description": "For \u00e5 koble TelldusLive-kontoen din:\n 1. Klikk p\u00e5 linken under\n 2. Logg inn p\u00e5 Telldus Live \n 3. Tillat **{app_name}** (klikk**Ja**). \n 4. Kom tilbake hit og klikk **SUBMIT**. \n\n [Link TelldusLive-konto]({auth_url})", - "title": "Godkjen mot TelldusLive" + "title": "Godkjenn mot TelldusLive" }, "user": { "data": { diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py index 83b56c2cf394d8..98d162d6d81b5d 100644 --- a/homeassistant/components/tellstick/sensor.py +++ b/homeassistant/components/tellstick/sensor.py @@ -5,7 +5,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import TEMP_CELSIUS, CONF_ID, CONF_NAME +from homeassistant.const import TEMP_CELSIUS, CONF_ID, CONF_NAME, CONF_PROTOCOL from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -16,6 +16,7 @@ CONF_DATATYPE_MASK = "datatype_mask" CONF_ONLY_NAMED = "only_named" CONF_TEMPERATURE_SCALE = "temperature_scale" +CONF_MODEL = "model" DEFAULT_DATATYPE_MASK = 127 DEFAULT_TEMPERATURE_SCALE = TEMP_CELSIUS @@ -35,6 +36,8 @@ { vol.Required(CONF_ID): cv.positive_int, vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_PROTOCOL): cv.string, + vol.Optional(CONF_MODEL): cv.string, } ) ], @@ -74,18 +77,36 @@ def setup_platform(hass, config, add_entities, discovery_info=None): datatype_mask = config.get(CONF_DATATYPE_MASK) if config[CONF_ONLY_NAMED]: - named_sensors = { - named_sensor[CONF_ID]: named_sensor[CONF_NAME] - for named_sensor in config[CONF_ONLY_NAMED] - } + named_sensors = {} + for named_sensor in config[CONF_ONLY_NAMED]: + name = named_sensor[CONF_NAME] + proto = named_sensor.get(CONF_PROTOCOL) + model = named_sensor.get(CONF_MODEL) + id_ = named_sensor[CONF_ID] + if proto is not None: + if model is not None: + named_sensors["{}{}{}".format(proto, model, id_)] = name + else: + named_sensors["{}{}".format(proto, id_)] = name + else: + named_sensors[id_] = name for tellcore_sensor in tellcore_lib.sensors(): if not config[CONF_ONLY_NAMED]: sensor_name = str(tellcore_sensor.id) else: - if tellcore_sensor.id not in named_sensors: + proto_id = "{}{}".format(tellcore_sensor.protocol, tellcore_sensor.id) + proto_model_id = "{}{}{}".format( + tellcore_sensor.protocol, tellcore_sensor.model, tellcore_sensor.id + ) + if tellcore_sensor.id in named_sensors: + sensor_name = named_sensors[tellcore_sensor.id] + elif proto_id in named_sensors: + sensor_name = named_sensors[proto_id] + elif proto_model_id in named_sensors: + sensor_name = named_sensors[proto_model_id] + else: continue - sensor_name = named_sensors[tellcore_sensor.id] for datatype in sensor_value_descriptions: if datatype & datatype_mask and tellcore_sensor.has_value(datatype): diff --git a/homeassistant/components/telnet/switch.py b/homeassistant/components/telnet/switch.py index a4777af54578bb..87fb70bb8886a6 100644 --- a/homeassistant/components/telnet/switch.py +++ b/homeassistant/components/telnet/switch.py @@ -117,7 +117,7 @@ def _telnet_command(self, command): response = telnet.read_until(b"\r", timeout=self._timeout) _LOGGER.debug("telnet response: %s", response.decode("ASCII").strip()) return response.decode("ASCII").strip() - except IOError as error: + except OSError as error: _LOGGER.error( 'Command "%s" failed with exception: %s', command, repr(error) ) diff --git a/homeassistant/components/temper/sensor.py b/homeassistant/components/temper/sensor.py index c5e5c4af978773..a32de3da10fb62 100644 --- a/homeassistant/components/temper/sensor.py +++ b/homeassistant/components/temper/sensor.py @@ -96,7 +96,7 @@ def update(self): ) sensor_value = self.temper_device.get_temperature(format_str) self.current_value = round(sensor_value, 1) - except IOError: + except OSError: _LOGGER.error( "Failed to get temperature. The device address may" "have changed. Attempting to reset device" diff --git a/homeassistant/components/tfiac/manifest.json b/homeassistant/components/tfiac/manifest.json index 9997ae00f0a4f4..d7282317d95dfb 100644 --- a/homeassistant/components/tfiac/manifest.json +++ b/homeassistant/components/tfiac/manifest.json @@ -3,7 +3,7 @@ "name": "Tfiac", "documentation": "https://www.home-assistant.io/components/tfiac", "requirements": [ - "pytfiac==0.3" + "pytfiac==0.4" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/tibber/sensor.py b/homeassistant/components/tibber/sensor.py index 3dfe0265bdeef5..a5a7f320d93315 100644 --- a/homeassistant/components/tibber/sensor.py +++ b/homeassistant/components/tibber/sensor.py @@ -44,8 +44,8 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info= async_add_entities(dev, True) -class TibberSensorElPrice(Entity): - """Representation of an Tibber sensor for el price.""" +class TibberSensor(Entity): + """Representation of a generic Tibber sensor.""" def __init__(self, tibber_home): """Initialize the sensor.""" @@ -54,10 +54,25 @@ def __init__(self, tibber_home): self._state = None self._is_available = False self._device_state_attributes = {} - self._unit_of_measurement = self._tibber_home.price_unit - self._name = "Electricity price {}".format( - tibber_home.info["viewer"]["home"]["appNickname"] - ) + self._name = tibber_home.info["viewer"]["home"]["appNickname"] + if self._name is None: + self._name = tibber_home.info["viewer"]["home"]["address"].get( + "address1", "" + ) + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._device_state_attributes + + @property + def state(self): + """Return the state of the device.""" + return self._state + + +class TibberSensorElPrice(TibberSensor): + """Representation of a Tibber sensor for el price.""" async def async_update(self): """Get the latest data and updates the states.""" @@ -86,11 +101,6 @@ async def async_update(self): self._device_state_attributes.update(attrs) self._is_available = self._state is not None - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self._device_state_attributes - @property def available(self): """Return True if entity is available.""" @@ -99,12 +109,7 @@ def available(self): @property def name(self): """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the device.""" - return self._state + return "Electricity price {}".format(self._name) @property def icon(self): @@ -114,7 +119,7 @@ def icon(self): @property def unit_of_measurement(self): """Return the unit of measurement of this entity.""" - return self._unit_of_measurement + return self._tibber_home.price_unit @property def unique_id(self): @@ -139,17 +144,8 @@ async def _fetch_data(self): ]["estimatedAnnualConsumption"] -class TibberSensorRT(Entity): - """Representation of an Tibber sensor for real time consumption.""" - - def __init__(self, tibber_home): - """Initialize the sensor.""" - self._tibber_home = tibber_home - self._state = None - self._device_state_attributes = {} - self._unit_of_measurement = "W" - nickname = tibber_home.info["viewer"]["home"]["appNickname"] - self._name = f"Real time consumption {nickname}" +class TibberSensorRT(TibberSensor): + """Representation of a Tibber sensor for real time consumption.""" async def async_added_to_hass(self): """Start unavailability tracking.""" @@ -175,11 +171,6 @@ async def _async_callback(self, payload): self.async_schedule_update_ha_state() - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self._device_state_attributes - @property def available(self): """Return True if entity is available.""" @@ -188,18 +179,13 @@ def available(self): @property def name(self): """Return the name of the sensor.""" - return self._name + return "Real time consumption {}".format(self._name) @property def should_poll(self): """Return the polling state.""" return False - @property - def state(self): - """Return the state of the device.""" - return self._state - @property def icon(self): """Return the icon to use in the frontend.""" @@ -208,7 +194,7 @@ def icon(self): @property def unit_of_measurement(self): """Return the unit of measurement of this entity.""" - return self._unit_of_measurement + return "W" @property def unique_id(self): diff --git a/homeassistant/components/toon/.translations/it.json b/homeassistant/components/toon/.translations/it.json index 696c770f130952..7934913558114f 100644 --- a/homeassistant/components/toon/.translations/it.json +++ b/homeassistant/components/toon/.translations/it.json @@ -2,6 +2,7 @@ "config": { "abort": { "client_id": "L'ID client dalla configurazione non \u00e8 valido.", + "client_secret": "Il client segreto della configurazione non \u00e8 valido.", "no_agreements": "Questo account non ha display Toon.", "no_app": "\u00c8 necessario configurare Toon prima di poter eseguire l'autenticazione con esso. [Si prega di leggere le istruzioni] (https://www.home-assistant.io/components/toon/).", "unknown_auth_fail": "Si \u00e8 verificato un errore imprevisto durante l'autenticazione." @@ -14,6 +15,7 @@ "authenticate": { "data": { "password": "Password", + "tenant": "Inquilino", "username": "Nome utente" }, "description": "Autenticati con il tuo account Eneco Toon (non l'account sviluppatore).", diff --git a/homeassistant/components/toon/.translations/no.json b/homeassistant/components/toon/.translations/no.json index 37dcd8ac22f9f5..a033d2954d98d1 100644 --- a/homeassistant/components/toon/.translations/no.json +++ b/homeassistant/components/toon/.translations/no.json @@ -8,7 +8,7 @@ "unknown_auth_fail": "Uventet feil oppstod under autentisering." }, "error": { - "credentials": "De oppgitte legitimasjonene er ugyldige.", + "credentials": "Den oppgitte kontoinformasjonen er ugyldig.", "display_exists": "Den valgte skjermen er allerede konfigurert." }, "step": { @@ -18,7 +18,7 @@ "tenant": "Leietaker", "username": "Brukernavn" }, - "description": "Godkjen med Eneco Toon kontoen din (ikke utviklerkontoen).", + "description": "Godkjenn med Eneco Toon kontoen din (ikke utviklerkontoen).", "title": "Linken din Toon konto" }, "display": { diff --git a/homeassistant/components/toon/.translations/pl.json b/homeassistant/components/toon/.translations/pl.json index 26627389ddd592..403be9bc067a54 100644 --- a/homeassistant/components/toon/.translations/pl.json +++ b/homeassistant/components/toon/.translations/pl.json @@ -18,8 +18,8 @@ "tenant": "Najemca", "username": "Nazwa u\u017cytkownika" }, - "description": "Uwierzytelnij swoje konto Eneco Toon (nie konto programisty).", - "title": "Po\u0142\u0105cz swoje konto Toon" + "description": "Uwierzytelnij konto Eneco Toon (nie konto programisty).", + "title": "Po\u0142\u0105cz konto Toon" }, "display": { "data": { diff --git a/homeassistant/components/toon/.translations/ru.json b/homeassistant/components/toon/.translations/ru.json index 012aa65187c28b..0eddbe2a151d79 100644 --- a/homeassistant/components/toon/.translations/ru.json +++ b/homeassistant/components/toon/.translations/ru.json @@ -4,7 +4,7 @@ "client_id": "Client ID \u0438\u0437 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.", "client_secret": "Client secret \u0438\u0437 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u043d\u0435\u0434\u0435\u0439\u0441\u0442\u0432\u0438\u0442\u0435\u043b\u0435\u043d.", "no_agreements": "\u0423 \u044d\u0442\u043e\u0439 \u0443\u0447\u0435\u0442\u043d\u043e\u0439 \u0437\u0430\u043f\u0438\u0441\u0438 \u043d\u0435\u0442 \u0434\u0438\u0441\u043f\u043b\u0435\u0435\u0432 Toon.", - "no_app": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Toon \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/toon/).", + "no_app": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 Toon \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 [\u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/toon/).", "unknown_auth_fail": "\u0412\u043e \u0432\u0440\u0435\u043c\u044f \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u043f\u0440\u043e\u0438\u0437\u043e\u0448\u043b\u0430 \u043d\u0435\u043f\u0440\u0435\u0434\u0432\u0438\u0434\u0435\u043d\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430." }, "error": { diff --git a/homeassistant/components/torque/sensor.py b/homeassistant/components/torque/sensor.py index 0806ba0799c452..10161856a47a71 100644 --- a/homeassistant/components/torque/sensor.py +++ b/homeassistant/components/torque/sensor.py @@ -88,7 +88,12 @@ def get(self, request): names[pid] = data[key] elif is_unit: pid = convert_pid(is_unit.group(1)) - units[pid] = data[key] + + temp_unit = data[key] + if "\\xC2\\xB0" in temp_unit: + temp_unit = temp_unit.replace("\\xC2\\xB0", "°") + + units[pid] = temp_unit elif is_value: pid = convert_pid(is_value.group(1)) if pid in self.sensors: diff --git a/homeassistant/components/traccar/.translations/de.json b/homeassistant/components/traccar/.translations/de.json new file mode 100644 index 00000000000000..c835ddf76b2481 --- /dev/null +++ b/homeassistant/components/traccar/.translations/de.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Ihre Home Assistant-Instanz muss \u00fcber das Internet zug\u00e4nglich sein, um Nachrichten von Traccar zu empfangen.", + "one_instance_allowed": "Es ist nur eine einzelne Instanz erforderlich." + }, + "create_entry": { + "default": "Um Ereignisse an den Heimassistenten zu senden, m\u00fcssen die Webhook-Funktionen in Traccar eingerichtet werden.\n\nVerwende die folgende URL: `{webhook_url}}`\n\nSiehe [die Dokumentation]({docs_url}) f\u00fcr weitere Details." + }, + "step": { + "user": { + "description": "M\u00f6chten Sie Traccar wirklich einrichten?", + "title": "Traccar einrichten" + } + }, + "title": "Traccar" + } +} \ No newline at end of file diff --git a/homeassistant/components/traccar/.translations/es.json b/homeassistant/components/traccar/.translations/es.json new file mode 100644 index 00000000000000..dedaf02971c79f --- /dev/null +++ b/homeassistant/components/traccar/.translations/es.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Su instancia de Home Assistant debe ser accesible desde Internet para recibir mensajes de Traccar.", + "one_instance_allowed": "S\u00f3lo se necesita una \u00fanica instancia." + }, + "create_entry": { + "default": "Para enviar eventos a Home Assistant, necesitar\u00e1 configurar la funci\u00f3n de webhook en Traccar.\n\nUtilice la siguiente url: ``{webhook_url}``\n\nConsulte la [documentaci\u00f3n]({docs_url}) para m\u00e1s detalles." + }, + "step": { + "user": { + "description": "\u00bfEst\u00e1 seguro de querer configurar Traccar?", + "title": "Configurar Traccar" + } + }, + "title": "Traccar" + } +} \ No newline at end of file diff --git a/homeassistant/components/traccar/.translations/fr.json b/homeassistant/components/traccar/.translations/fr.json new file mode 100644 index 00000000000000..0948a31739fcea --- /dev/null +++ b/homeassistant/components/traccar/.translations/fr.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Votre instance de Home Assistant doit \u00eatre accessible depuis Internet pour recevoir les messages de Traccar.", + "one_instance_allowed": "Une seule instance est n\u00e9cessaire." + }, + "create_entry": { + "default": "Pour envoyer des \u00e9v\u00e9nements \u00e0 Home Assistant, vous devez configurer la fonction Webhook dans Traccar. \n\n Utilisez l'URL suivante: ` {webhook_url} ` \n\n Voir [la documentation] ( {docs_url} ) pour plus de d\u00e9tails." + }, + "step": { + "user": { + "description": "\u00cates-vous s\u00fbr de vouloir configurer Traccar?", + "title": "Configurer Traccar" + } + }, + "title": "Traccar" + } +} \ No newline at end of file diff --git a/homeassistant/components/traccar/.translations/it.json b/homeassistant/components/traccar/.translations/it.json new file mode 100644 index 00000000000000..a0980644a71a27 --- /dev/null +++ b/homeassistant/components/traccar/.translations/it.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "La tua istanza di Home Assistant deve essere accessibile da Internet per ricevere messaggi da Traccar.", + "one_instance_allowed": "\u00c8 necessaria solo una singola istanza." + }, + "create_entry": { + "default": "Per inviare eventi a Home Assistant, \u00e8 necessario configurare la funzionalit\u00e0 webhook in Traccar.\n\nUtilizzare l'URL seguente: `{webhook_url}`\n\nPer ulteriori dettagli, vedere [la documentazione]({docs_url}) ." + }, + "step": { + "user": { + "description": "Sei sicuro di voler configurare Traccar?", + "title": "Imposta Traccar" + } + }, + "title": "Traccar" + } +} \ No newline at end of file diff --git a/homeassistant/components/traccar/.translations/ko.json b/homeassistant/components/traccar/.translations/ko.json new file mode 100644 index 00000000000000..d9f31967e68b9e --- /dev/null +++ b/homeassistant/components/traccar/.translations/ko.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Traccar \uba54\uc2dc\uc9c0\ub97c \ubc1b\uc73c\ub824\uba74 \uc778\ud130\ub137\uc5d0\uc11c Home Assistant \uc778\uc2a4\ud134\uc2a4\uc5d0 \uc561\uc138\uc2a4 \ud560 \uc218 \uc788\uc5b4\uc57c \ud569\ub2c8\ub2e4.", + "one_instance_allowed": "\ud558\ub098\uc758 \uc778\uc2a4\ud134\uc2a4\ub9cc \ud544\uc694\ud569\ub2c8\ub2e4." + }, + "create_entry": { + "default": "Home Assistant \ub85c \uc774\ubca4\ud2b8\ub97c \ubcf4\ub0b4\ub824\uba74 Traccar \uc5d0\uc11c Webhook \uc744 \uc124\uc815\ud574\uc57c\ud569\ub2c8\ub2e4. \n\n\ub2e4\uc74c URL \uc815\ubcf4\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4: `{webhook_url}`\n \n\uc790\uc138\ud55c \uc815\ubcf4\ub294 [\uc548\ub0b4]({docs_url}) \ub97c \ucc38\uc870\ud574\uc8fc\uc138\uc694." + }, + "step": { + "user": { + "description": "Traccar \ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Traccar \uc124\uc815" + } + }, + "title": "Traccar" + } +} \ No newline at end of file diff --git a/homeassistant/components/traccar/.translations/lb.json b/homeassistant/components/traccar/.translations/lb.json new file mode 100644 index 00000000000000..8808d85a1d6db2 --- /dev/null +++ b/homeassistant/components/traccar/.translations/lb.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u00c4r Home Assistant Instanz muss iwwert Internet accessibel si fir Traccar Noriichten z'empf\u00e4nken.", + "one_instance_allowed": "N\u00ebmmen eng eenzeg Instanz ass n\u00e9ideg." + }, + "create_entry": { + "default": "Fir Evenementer un Home Assistant ze sch\u00e9cken, muss den Webhook Feature am Traccar ageriicht ginn.\n\nF\u00ebllt folgend Informatiounen aus:\n\n- URL: `{webhook_url}`\n- Method: POST\n\nLiest [Dokumentatioun]({docs_url}) fir w\u00e9ider Informatiounen." + }, + "step": { + "user": { + "description": "S\u00e9cher fir Traccar anzeriichten?", + "title": "Traccar ariichten" + } + }, + "title": "Traccar" + } +} \ No newline at end of file diff --git a/homeassistant/components/traccar/.translations/no.json b/homeassistant/components/traccar/.translations/no.json new file mode 100644 index 00000000000000..dea146b649aace --- /dev/null +++ b/homeassistant/components/traccar/.translations/no.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Din Home Assistant-forekomst m\u00e5 v\u00e6re tilgjengelig fra Internett for \u00e5 motta meldinger fra Traccar.", + "one_instance_allowed": "Kun en enkelt forekomst er n\u00f8dvendig." + }, + "create_entry": { + "default": "Hvis du vil sende hendelser til Home Assistant, m\u00e5 du konfigurere webhook-funksjonen i Traccar.\n\nBruk f\u00f8lgende URL-adresse: ' {webhook_url} '\n\nSe [dokumentasjonen] ({docs_url}) for mer informasjon." + }, + "step": { + "user": { + "description": "Er du sikker p\u00e5 at du vil sette opp Traccar?", + "title": "Sett opp Traccar" + } + }, + "title": "Traccar" + } +} \ No newline at end of file diff --git a/homeassistant/components/traccar/.translations/sl.json b/homeassistant/components/traccar/.translations/sl.json new file mode 100644 index 00000000000000..95aaca7e67df2f --- /dev/null +++ b/homeassistant/components/traccar/.translations/sl.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161 Home Assistant mora biti dostopen prek interneta, da boste lahko prejemali Traccar sporo\u010dila.", + "one_instance_allowed": "Potrebna je samo ena instanca." + }, + "create_entry": { + "default": "\u010ce \u017eelite poslati dogodke v Home Assistant, boste morali nastaviti funkcijo \"webhook\" v traccar.\n\nUporabite naslednji URL: ' {webhook_url} '\n\nZa podrobnej\u0161e informacije glejte [dokumentacijo] ({docs_url})." + }, + "step": { + "user": { + "description": "Ali ste prepri\u010dani, da \u017eelite nastaviti Traccar?", + "title": "Nastavite Traccar" + } + }, + "title": "Traccar" + } +} \ No newline at end of file diff --git a/homeassistant/components/traccar/.translations/zh-Hans.json b/homeassistant/components/traccar/.translations/zh-Hans.json new file mode 100644 index 00000000000000..248e8f9f44ed89 --- /dev/null +++ b/homeassistant/components/traccar/.translations/zh-Hans.json @@ -0,0 +1,8 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u53ef\u4ece\u4e92\u8054\u7f51\u8bbf\u95ee\u4ee5\u63a5\u6536Traccar\u6d88\u606f\u3002", + "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tradfri/.translations/it.json b/homeassistant/components/tradfri/.translations/it.json index 4c114492336492..99ba9053d79792 100644 --- a/homeassistant/components/tradfri/.translations/it.json +++ b/homeassistant/components/tradfri/.translations/it.json @@ -1,7 +1,8 @@ { "config": { "abort": { - "already_configured": "Il bridge \u00e8 gi\u00e0 configurato" + "already_configured": "Bridge gi\u00e0 configurato.", + "already_in_progress": "La configurazione del Bridge \u00e8 gi\u00e0 in corso." }, "error": { "cannot_connect": "Impossibile connettersi al gateway.", diff --git a/homeassistant/components/tradfri/.translations/pl.json b/homeassistant/components/tradfri/.translations/pl.json index e3fcfc89c5bd4b..3a1798e66d995b 100644 --- a/homeassistant/components/tradfri/.translations/pl.json +++ b/homeassistant/components/tradfri/.translations/pl.json @@ -15,7 +15,7 @@ "host": "Host", "security_code": "Kod bezpiecze\u0144stwa" }, - "description": "Mo\u017cesz znale\u017a\u0107 kod bezpiecze\u0144stwa z ty\u0142u bramy.", + "description": "Mo\u017cesz znale\u017a\u0107 kod bezpiecze\u0144stwa z ty\u0142u bramki.", "title": "Wprowad\u017a kod bezpiecze\u0144stwa" } }, diff --git a/homeassistant/components/tradfri/__init__.py b/homeassistant/components/tradfri/__init__.py index 87b073db052001..bca91134bedf47 100644 --- a/homeassistant/components/tradfri/__init__.py +++ b/homeassistant/components/tradfri/__init__.py @@ -131,6 +131,9 @@ async def on_hass_stop(event): sw_version=gateway_info.firmware_version, ) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "cover") + ) hass.async_create_task( hass.config_entries.async_forward_entry_setup(entry, "light") ) diff --git a/homeassistant/components/tradfri/cover.py b/homeassistant/components/tradfri/cover.py new file mode 100644 index 00000000000000..3dea978044fcae --- /dev/null +++ b/homeassistant/components/tradfri/cover.py @@ -0,0 +1,149 @@ +"""Support for IKEA Tradfri covers.""" +import logging + +from pytradfri.error import PytradfriError + +from homeassistant.components.cover import ( + CoverDevice, + ATTR_POSITION, + SUPPORT_OPEN, + SUPPORT_CLOSE, + SUPPORT_SET_POSITION, +) +from homeassistant.core import callback +from . import DOMAIN as TRADFRI_DOMAIN, KEY_API, KEY_GATEWAY +from .const import CONF_GATEWAY_ID + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Load Tradfri covers based on a config entry.""" + gateway_id = config_entry.data[CONF_GATEWAY_ID] + api = hass.data[KEY_API][config_entry.entry_id] + gateway = hass.data[KEY_GATEWAY][config_entry.entry_id] + + devices_commands = await api(gateway.get_devices()) + devices = await api(devices_commands) + covers = [dev for dev in devices if dev.has_blind_control] + if covers: + async_add_entities(TradfriCover(cover, api, gateway_id) for cover in covers) + + +class TradfriCover(CoverDevice): + """The platform class required by Home Assistant.""" + + def __init__(self, cover, api, gateway_id): + """Initialize a cover.""" + self._api = api + self._unique_id = f"{gateway_id}-{cover.id}" + self._cover = None + self._cover_control = None + self._cover_data = None + self._name = None + self._available = True + self._gateway_id = gateway_id + + self._refresh(cover) + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION + + @property + def unique_id(self): + """Return unique ID for cover.""" + return self._unique_id + + @property + def device_info(self): + """Return the device info.""" + info = self._cover.device_info + + return { + "identifiers": {(TRADFRI_DOMAIN, self._cover.id)}, + "name": self._name, + "manufacturer": info.manufacturer, + "model": info.model_number, + "sw_version": info.firmware_version, + "via_device": (TRADFRI_DOMAIN, self._gateway_id), + } + + async def async_added_to_hass(self): + """Start thread when added to hass.""" + self._async_start_observe() + + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @property + def should_poll(self): + """No polling needed for tradfri cover.""" + return False + + @property + def name(self): + """Return the display name of this cover.""" + return self._name + + @property + def current_cover_position(self): + """Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + return 100 - self._cover_data.current_cover_position + + async def async_set_cover_position(self, **kwargs): + """Move the cover to a specific position.""" + await self._api(self._cover_control.set_state(100 - kwargs[ATTR_POSITION])) + + async def async_open_cover(self, **kwargs): + """Open the cover.""" + await self._api(self._cover_control.set_state(0)) + + async def async_close_cover(self, **kwargs): + """Close cover.""" + await self._api(self._cover_control.set_state(100)) + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + return self.current_cover_position == 0 + + @callback + def _async_start_observe(self, exc=None): + """Start observation of cover.""" + if exc: + self._available = False + self.async_schedule_update_ha_state() + _LOGGER.warning("Observation failed for %s", self._name, exc_info=exc) + try: + cmd = self._cover.observe( + callback=self._observe_update, + err_callback=self._async_start_observe, + duration=0, + ) + self.hass.async_create_task(self._api(cmd)) + except PytradfriError as err: + _LOGGER.warning("Observation failed, trying again", exc_info=err) + self._async_start_observe() + + def _refresh(self, cover): + """Refresh the cover data.""" + self._cover = cover + + # Caching of BlindControl and cover object + self._available = cover.reachable + self._cover_control = cover.blind_control + self._cover_data = cover.blind_control.blinds[0] + self._name = cover.name + + @callback + def _observe_update(self, tradfri_device): + """Receive new state data for this cover.""" + self._refresh(tradfri_device) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/tradfri/light.py b/homeassistant/components/tradfri/light.py index 97fdfd9d36d885..615899a98c8d22 100644 --- a/homeassistant/components/tradfri/light.py +++ b/homeassistant/components/tradfri/light.py @@ -1,6 +1,9 @@ """Support for IKEA Tradfri lights.""" import logging +from pytradfri.error import PytradfriError + +import homeassistant.util.color as color_util from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, @@ -14,8 +17,6 @@ Light, ) from homeassistant.core import callback -import homeassistant.util.color as color_util - from . import DOMAIN as TRADFRI_DOMAIN, KEY_API, KEY_GATEWAY from .const import CONF_GATEWAY_ID, CONF_IMPORT_GROUPS @@ -26,7 +27,6 @@ ATTR_SAT = "saturation" ATTR_TRANSITION_TIME = "transition_time" PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA -IKEA = "IKEA of Sweden" TRADFRI_LIGHT_MANAGER = "Tradfri Light Manager" SUPPORTED_FEATURES = SUPPORT_TRANSITION SUPPORTED_GROUP_FEATURES = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION @@ -113,9 +113,6 @@ async def async_turn_on(self, **kwargs): @callback def _async_start_observe(self, exc=None): """Start observation of light.""" - # pylint: disable=import-error - from pytradfri.error import PytradfriError - if exc: _LOGGER.warning("Observation failed for %s", self._name, exc_info=exc) @@ -339,8 +336,6 @@ async def async_turn_on(self, **kwargs): @callback def _async_start_observe(self, exc=None): """Start observation of light.""" - # pylint: disable=import-error - from pytradfri.error import PytradfriError if exc: self._available = False diff --git a/homeassistant/components/tradfri/manifest.json b/homeassistant/components/tradfri/manifest.json index ba6b21e00283ab..d847c6df24f7cb 100644 --- a/homeassistant/components/tradfri/manifest.json +++ b/homeassistant/components/tradfri/manifest.json @@ -3,17 +3,11 @@ "name": "Tradfri", "config_flow": true, "documentation": "https://www.home-assistant.io/components/tradfri", - "requirements": [ - "pytradfri[async]==6.0.1" - ], + "requirements": ["pytradfri[async]==6.3.1"], "homekit": { - "models": [ - "TRADFRI" - ] + "models": ["TRADFRI"] }, "dependencies": [], "zeroconf": ["_coap._udp.local."], - "codeowners": [ - "@ggravlingen" - ] + "codeowners": ["@ggravlingen"] } diff --git a/homeassistant/components/tradfri/sensor.py b/homeassistant/components/tradfri/sensor.py index 627a98821549ce..4877dbbb541f1a 100644 --- a/homeassistant/components/tradfri/sensor.py +++ b/homeassistant/components/tradfri/sensor.py @@ -1,10 +1,11 @@ """Support for IKEA Tradfri sensors.""" -from datetime import timedelta import logging +from datetime import timedelta + +from pytradfri.error import PytradfriError from homeassistant.core import callback from homeassistant.helpers.entity import Entity - from . import KEY_API, KEY_GATEWAY _LOGGER = logging.getLogger(__name__) @@ -79,9 +80,6 @@ def state(self): @callback def _async_start_observe(self, exc=None): """Start observation of light.""" - # pylint: disable=import-error - from pytradfri.error import PytradfriError - if exc: _LOGGER.warning("Observation failed for %s", self._name, exc_info=exc) diff --git a/homeassistant/components/tradfri/switch.py b/homeassistant/components/tradfri/switch.py index 4be72eb7359e64..545c1ad93cec17 100644 --- a/homeassistant/components/tradfri/switch.py +++ b/homeassistant/components/tradfri/switch.py @@ -1,15 +1,15 @@ """Support for IKEA Tradfri switches.""" import logging +from pytradfri.error import PytradfriError + from homeassistant.components.switch import SwitchDevice from homeassistant.core import callback - from . import DOMAIN as TRADFRI_DOMAIN, KEY_API, KEY_GATEWAY from .const import CONF_GATEWAY_ID _LOGGER = logging.getLogger(__name__) -IKEA = "IKEA of Sweden" TRADFRI_SWITCH_MANAGER = "Tradfri Switch Manager" @@ -98,8 +98,6 @@ async def async_turn_on(self, **kwargs): @callback def _async_start_observe(self, exc=None): """Start observation of switch.""" - from pytradfri.error import PytradfriError - if exc: self._available = False self.async_schedule_update_ha_state() diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 2c72dd60490899..9ac72419612aa3 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -40,6 +40,8 @@ def __init__(self, tuya): @property def brightness(self): """Return the brightness of the light.""" + if self.tuya.brightness() is None: + return None return int(self.tuya.brightness()) @property diff --git a/homeassistant/components/twentemilieu/.translations/de.json b/homeassistant/components/twentemilieu/.translations/de.json new file mode 100644 index 00000000000000..502a54a8a3d7ee --- /dev/null +++ b/homeassistant/components/twentemilieu/.translations/de.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "address_exists": "Adresse bereits eingerichtet." + }, + "error": { + "connection_error": "Fehler beim Herstellen einer Verbindung.", + "invalid_address": "Adresse nicht im Einzugsgebiet von Twente Milieu gefunden." + }, + "step": { + "user": { + "data": { + "house_letter": "Hausbrief/zusatz", + "house_number": "Hausnummer", + "post_code": "Postleitzahl" + }, + "description": "Richten Sie Twente Milieu mit Informationen zur Abfallsammlung unter Ihrer Adresse ein.", + "title": "Twente Milieu" + } + }, + "title": "Twente Milieu" + } +} \ No newline at end of file diff --git a/homeassistant/components/twentemilieu/.translations/es.json b/homeassistant/components/twentemilieu/.translations/es.json new file mode 100644 index 00000000000000..60a412684f775b --- /dev/null +++ b/homeassistant/components/twentemilieu/.translations/es.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "address_exists": "Direcci\u00f3n ya configurada." + }, + "error": { + "connection_error": "No se conect\u00f3.", + "invalid_address": "Direcci\u00f3n no encontrada en el \u00e1rea de servicio de Twente Milieu." + }, + "step": { + "user": { + "data": { + "house_letter": "Letra de la casa/adicional", + "house_number": "N\u00famero de casa", + "post_code": "C\u00f3digo postal" + }, + "description": "Configure Twente Milieu proporcionando informaci\u00f3n sobre la recolecci\u00f3n de residuos en su direcci\u00f3n.", + "title": "Twente Milieu" + } + }, + "title": "Twente Milieu" + } +} \ No newline at end of file diff --git a/homeassistant/components/twentemilieu/.translations/fr.json b/homeassistant/components/twentemilieu/.translations/fr.json new file mode 100644 index 00000000000000..0321a6b73cec35 --- /dev/null +++ b/homeassistant/components/twentemilieu/.translations/fr.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "address_exists": "Adresse d\u00e9j\u00e0 configur\u00e9e." + }, + "error": { + "connection_error": "\u00c9chec de connexion.", + "invalid_address": "Adresse introuvable dans la zone de service de Twente Milieu." + }, + "step": { + "user": { + "data": { + "house_letter": "Lettre de la maison / suppl\u00e9mentaire", + "house_number": "Num\u00e9ro de maison", + "post_code": "Code postal" + }, + "description": "Configurez Twente Milieu en fournissant des informations sur la collecte des d\u00e9chets sur votre adresse.", + "title": "Twente Milieu" + } + }, + "title": "Twente Milieu" + } +} \ No newline at end of file diff --git a/homeassistant/components/twentemilieu/.translations/it.json b/homeassistant/components/twentemilieu/.translations/it.json new file mode 100644 index 00000000000000..27850d207b0591 --- /dev/null +++ b/homeassistant/components/twentemilieu/.translations/it.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "address_exists": "Indirizzo gi\u00e0 impostato." + }, + "error": { + "connection_error": "Impossibile connettersi.", + "invalid_address": "Indirizzo non trovato nell'area di servizio di Twente Milieu." + }, + "step": { + "user": { + "data": { + "house_letter": "Edificio, Scala, Interno, ecc. / Informazioni aggiuntive", + "house_number": "Numero civico", + "post_code": "Codice di Avviamento Postale" + }, + "description": "Imposta Twente Milieu fornendo le informazioni sulla raccolta dei rifiuti al tuo indirizzo.", + "title": "Twente Milieu" + } + }, + "title": "Twente Milieu" + } +} \ No newline at end of file diff --git a/homeassistant/components/twentemilieu/.translations/ko.json b/homeassistant/components/twentemilieu/.translations/ko.json new file mode 100644 index 00000000000000..a78867d86a8ba2 --- /dev/null +++ b/homeassistant/components/twentemilieu/.translations/ko.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "address_exists": "\uc8fc\uc18c\uac00 \uc774\ubbf8 \uc124\uc815\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "error": { + "connection_error": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4.", + "invalid_address": "Twente Milieu \uc11c\ube44\uc2a4 \uc9c0\uc5ed\uc5d0\uc11c \uc8fc\uc18c\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "house_letter": "\uc9d1 \uc8fc\uc18c/\ucd94\uac00\uc815\ubcf4", + "house_number": "\uc9d1 \ubc88\ud638", + "post_code": "\uc6b0\ud3b8\ubc88\ud638" + }, + "description": "\uc8fc\uc18c\uc5d0 \uc4f0\ub808\uae30 \uc218\uac70 \uc815\ubcf4\ub97c \ub123\uc5b4 Twente Milieu \ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694.", + "title": "Twente Milieu" + } + }, + "title": "Twente Milieu" + } +} \ No newline at end of file diff --git a/homeassistant/components/twentemilieu/.translations/lb.json b/homeassistant/components/twentemilieu/.translations/lb.json new file mode 100644 index 00000000000000..b6f10842b4d1c7 --- /dev/null +++ b/homeassistant/components/twentemilieu/.translations/lb.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "address_exists": "Adresse ass scho ageriicht." + }, + "error": { + "connection_error": "Feeler beim verbannen.", + "invalid_address": "Adresse net am Twente Milieu Service Ber\u00e4ich fonnt" + }, + "step": { + "user": { + "data": { + "house_letter": "Haus Buschtaf/zous\u00e4tzlech", + "house_number": "Haus Nummer", + "post_code": "Postleitzuel" + }, + "description": "Offallsammlung Informatiounen vun Twente Milieu zu \u00e4erer Adresse ariichten.", + "title": "Twente Milieu" + } + }, + "title": "Twente Milieu" + } +} \ No newline at end of file diff --git a/homeassistant/components/twentemilieu/.translations/no.json b/homeassistant/components/twentemilieu/.translations/no.json new file mode 100644 index 00000000000000..1d4395bb2c80fd --- /dev/null +++ b/homeassistant/components/twentemilieu/.translations/no.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "address_exists": "Adressen er allerede konfigurert." + }, + "error": { + "connection_error": "Tilkobling mislyktes.", + "invalid_address": "Adresse ble ikke funnet i Twente Milieu tjenesteomr\u00e5de." + }, + "step": { + "user": { + "data": { + "house_letter": "Hus brev/ekstra", + "house_number": "Husnummer", + "post_code": "Postnummer" + }, + "description": "Sett opp Twente Milieu som gir informasjon om innsamling av avfall p\u00e5 adressen din.", + "title": "Twente Milieu" + } + }, + "title": "Twente Milieu" + } +} \ No newline at end of file diff --git a/homeassistant/components/twentemilieu/.translations/sl.json b/homeassistant/components/twentemilieu/.translations/sl.json new file mode 100644 index 00000000000000..7b74b96d0574a6 --- /dev/null +++ b/homeassistant/components/twentemilieu/.translations/sl.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "address_exists": "Naslov je \u017ee nastavljen." + }, + "error": { + "connection_error": "Povezava ni uspela.", + "invalid_address": "V storitvenem obmo\u010dju Twente Milieu ni mogo\u010de najti naslova." + }, + "step": { + "user": { + "data": { + "house_letter": "Hi\u0161na \u0161tevilka -\u010drka/dodatno", + "house_number": "Hi\u0161na \u0161tevilka", + "post_code": "Po\u0161tna \u0161tevilka" + }, + "description": "Nastavite Twente milieu, ki zagotavlja informacije o zbiranju odpadkov na va\u0161em naslovu.", + "title": "Twente Milieu" + } + }, + "title": "Twente Milieu" + } +} \ No newline at end of file diff --git a/homeassistant/components/twentemilieu/.translations/zh-Hans.json b/homeassistant/components/twentemilieu/.translations/zh-Hans.json new file mode 100644 index 00000000000000..80301cfd57b808 --- /dev/null +++ b/homeassistant/components/twentemilieu/.translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "address_exists": "\u5730\u5740\u5df2\u7ecf\u8bbe\u7f6e\u597d\u4e86\u3002" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/uk_transport/sensor.py b/homeassistant/components/uk_transport/sensor.py index 8e6e46531e2f41..eb325d32212e11 100644 --- a/homeassistant/components/uk_transport/sensor.py +++ b/homeassistant/components/uk_transport/sensor.py @@ -11,6 +11,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_MODE from homeassistant.helpers.entity import Entity @@ -277,12 +278,11 @@ def device_state_attributes(self): def _delta_mins(hhmm_time_str): """Calculate time delta in minutes to a time in hh:mm format.""" - now = datetime.now() + now = dt_util.now() hhmm_time = datetime.strptime(hhmm_time_str, "%H:%M") - hhmm_datetime = datetime( - now.year, now.month, now.day, hour=hhmm_time.hour, minute=hhmm_time.minute - ) + hhmm_datetime = now.replace(hour=hhmm_time.hour, minute=hhmm_time.minute) + if hhmm_datetime < now: hhmm_datetime += timedelta(days=1) diff --git a/homeassistant/components/unifi/.translations/ca.json b/homeassistant/components/unifi/.translations/ca.json index 8a8d8b11f57661..3741b035d7a9d6 100644 --- a/homeassistant/components/unifi/.translations/ca.json +++ b/homeassistant/components/unifi/.translations/ca.json @@ -32,6 +32,12 @@ "track_devices": "Segueix dispositius de la xarxa (dispositius Ubiquiti)", "track_wired_clients": "Inclou clients de xarxa per cable" } + }, + "init": { + "data": { + "one": "un", + "other": "altre" + } } } } diff --git a/homeassistant/components/unifi/.translations/de.json b/homeassistant/components/unifi/.translations/de.json index 0c44871f583c97..e447e89644f5b5 100644 --- a/homeassistant/components/unifi/.translations/de.json +++ b/homeassistant/components/unifi/.translations/de.json @@ -32,6 +32,12 @@ "track_devices": "Verfolgen von Netzwerkger\u00e4ten (Ubiquiti-Ger\u00e4te)", "track_wired_clients": "Einbinden von kabelgebundenen Netzwerk-Clients" } + }, + "init": { + "data": { + "one": "eins", + "other": "andere" + } } } } diff --git a/homeassistant/components/unifi/.translations/es.json b/homeassistant/components/unifi/.translations/es.json index 8b0eb56203700d..0539f5607b4c36 100644 --- a/homeassistant/components/unifi/.translations/es.json +++ b/homeassistant/components/unifi/.translations/es.json @@ -29,8 +29,15 @@ "data": { "detection_time": "Tiempo en segundos desde la \u00faltima vez que se vio hasta considerarlo desconectado", "track_clients": "Seguimiento de los clientes de red", + "track_devices": "Rastree dispositivos de red (dispositivos Ubiquiti)", "track_wired_clients": "Incluir clientes de red cableada" } + }, + "init": { + "data": { + "one": "uno", + "other": "otro" + } } } } diff --git a/homeassistant/components/unifi/.translations/fr.json b/homeassistant/components/unifi/.translations/fr.json index 9e567fcc394a75..8c2526f8a1565a 100644 --- a/homeassistant/components/unifi/.translations/fr.json +++ b/homeassistant/components/unifi/.translations/fr.json @@ -22,5 +22,17 @@ } }, "title": "Contr\u00f4leur UniFi" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "detection_time": "Temps en secondes depuis la derni\u00e8re vue avant de consid\u00e9rer comme absent", + "track_clients": "Suivre les clients du r\u00e9seau", + "track_devices": "Suivre les p\u00e9riph\u00e9riques r\u00e9seau (p\u00e9riph\u00e9riques Ubiquiti)", + "track_wired_clients": "Inclure les clients du r\u00e9seau filaire" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/it.json b/homeassistant/components/unifi/.translations/it.json index 407371bf89f198..5285ed21873529 100644 --- a/homeassistant/components/unifi/.translations/it.json +++ b/homeassistant/components/unifi/.translations/it.json @@ -22,5 +22,23 @@ } }, "title": "UniFi Controller" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "detection_time": "Tempo in secondi dall'ultima volta che viene visto fino a quando non \u00e8 considerato lontano", + "track_clients": "Traccia i client di rete", + "track_devices": "Tracciare i dispositivi di rete (dispositivi Ubiquiti)", + "track_wired_clients": "Includi i client di rete cablata" + } + }, + "init": { + "data": { + "one": "uno", + "other": "altro" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/ko.json b/homeassistant/components/unifi/.translations/ko.json index 431d6bbf5e6d73..1fff9887906b53 100644 --- a/homeassistant/components/unifi/.translations/ko.json +++ b/homeassistant/components/unifi/.translations/ko.json @@ -22,5 +22,17 @@ } }, "title": "UniFi \ucee8\ud2b8\ub864\ub7ec" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "detection_time": "\ub9c8\uc9c0\ub9c9\uc73c\ub85c \ud655\uc778\ub41c \uc2dc\uac04\ubd80\ud130 \uc678\ucd9c \uc0c1\ud0dc\ub85c \uac04\uc8fc\ub418\ub294 \uc2dc\uac04 (\ucd08)", + "track_clients": "\ub124\ud2b8\uc6cc\ud06c \ud074\ub77c\uc774\uc5b8\ud2b8 \ucd94\uc801 \ub300\uc0c1", + "track_devices": "\ub124\ud2b8\uc6cc\ud06c \uae30\uae30 \ucd94\uc801 (Ubiquiti \uae30\uae30)", + "track_wired_clients": "\uc720\uc120 \ub124\ud2b8\uc6cc\ud06c \ud074\ub77c\uc774\uc5b8\ud2b8 \ud3ec\ud568" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/lb.json b/homeassistant/components/unifi/.translations/lb.json index 3bef273b83e53f..05b0ffc0c44cdf 100644 --- a/homeassistant/components/unifi/.translations/lb.json +++ b/homeassistant/components/unifi/.translations/lb.json @@ -22,5 +22,23 @@ } }, "title": "Unifi Kontroller" + }, + "options": { + "step": { + "device_tracker": { + "data": { + "detection_time": "Z\u00e4it a Sekonne vum leschten Z\u00e4itpunkt un bis den Apparat als \u00ebnnerwee consider\u00e9iert g\u00ebtt", + "track_clients": "Netzwierk Cliente verfollegen", + "track_devices": "Netzwierk Apparater (Ubiquiti Apparater) verfollegen", + "track_wired_clients": "Kabel Netzwierk Cliente abez\u00e9ien" + } + }, + "init": { + "data": { + "one": "Een", + "other": "M\u00e9i" + } + } + } } } \ No newline at end of file diff --git a/homeassistant/components/unifi/.translations/nl.json b/homeassistant/components/unifi/.translations/nl.json index f907364327c958..518f0066534411 100644 --- a/homeassistant/components/unifi/.translations/nl.json +++ b/homeassistant/components/unifi/.translations/nl.json @@ -32,6 +32,12 @@ "track_devices": "Netwerkapparaten volgen (Ubiquiti-apparaten)", "track_wired_clients": "Inclusief bedrade netwerkcli\u00ebnten" } + }, + "init": { + "data": { + "one": "Leeg", + "other": "Leeg" + } } } } diff --git a/homeassistant/components/unifi/.translations/no.json b/homeassistant/components/unifi/.translations/no.json index 068f4341544900..c21a47c7ea2c8b 100644 --- a/homeassistant/components/unifi/.translations/no.json +++ b/homeassistant/components/unifi/.translations/no.json @@ -32,6 +32,12 @@ "track_devices": "Spore nettverksenheter (Ubiquiti-enheter)", "track_wired_clients": "Inkluder kablede nettverksklienter" } + }, + "init": { + "data": { + "one": "en", + "other": "andre" + } } } } diff --git a/homeassistant/components/unifi/config_flow.py b/homeassistant/components/unifi/config_flow.py index 00b003746a2568..fdb75d09194ef9 100644 --- a/homeassistant/components/unifi/config_flow.py +++ b/homeassistant/components/unifi/config_flow.py @@ -1,4 +1,4 @@ -"""Config flow for Unifi.""" +"""Config flow for UniFi.""" import voluptuous as vol from homeassistant import config_entries @@ -164,20 +164,6 @@ async def async_step_site(self, user_input=None): errors=errors, ) - async def async_step_import(self, import_config): - """Import from UniFi device tracker config.""" - config = { - CONF_HOST: import_config[CONF_HOST], - CONF_USERNAME: import_config[CONF_USERNAME], - CONF_PASSWORD: import_config[CONF_PASSWORD], - CONF_PORT: import_config.get(CONF_PORT), - CONF_VERIFY_SSL: import_config.get(CONF_VERIFY_SSL), - } - - self.desc = import_config[CONF_SITE_ID] - - return await self.async_step_user(user_input=config) - class UnifiOptionsFlowHandler(config_entries.OptionsFlow): """Handle Unifi options.""" diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index ca6ddb6820660c..b3982e7327d42d 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -1,36 +1,20 @@ -"""Support for Unifi WAP controllers.""" -from datetime import timedelta - +"""Track devices using UniFi controllers.""" import logging import voluptuous as vol -from homeassistant import config_entries from homeassistant.components.unifi.config_flow import get_controller_from_config_entry from homeassistant.components.device_tracker import DOMAIN, PLATFORM_SCHEMA from homeassistant.components.device_tracker.config_entry import ScannerEntity from homeassistant.components.device_tracker.const import SOURCE_TYPE_ROUTER from homeassistant.core import callback -from homeassistant.const import ( - CONF_HOST, - CONF_USERNAME, - CONF_PASSWORD, - CONF_PORT, - CONF_VERIFY_SSL, -) from homeassistant.helpers import entity_registry from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_registry import DISABLED_CONFIG_ENTRY -import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -from .const import ( - ATTR_MANUFACTURER, - CONF_CONTROLLER, - CONF_SITE_ID, - DOMAIN as UNIFI_DOMAIN, -) +from .const import ATTR_MANUFACTURER LOGGER = logging.getLogger(__name__) @@ -55,51 +39,11 @@ "vlan", ] -CONF_DT_SITE_ID = "site_id" - -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 8443 -DEFAULT_VERIFY_SSL = True -DEFAULT_DETECTION_TIME = timedelta(seconds=300) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, - vol.Optional(CONF_DT_SITE_ID, default="default"): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): vol.Any( - cv.boolean, cv.isfile - ), - }, - extra=vol.ALLOW_EXTRA, -) +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({}, extra=vol.ALLOW_EXTRA) async def async_setup_scanner(hass, config, sync_see, discovery_info): """Set up the Unifi integration.""" - config[CONF_SITE_ID] = config.pop(CONF_DT_SITE_ID) # Current from legacy - - exist = False - - for entry in hass.config_entries.async_entries(UNIFI_DOMAIN): - if ( - config[CONF_HOST] == entry.data[CONF_CONTROLLER][CONF_HOST] - and config[CONF_SITE_ID] == entry.data[CONF_CONTROLLER][CONF_SITE_ID] - ): - exist = True - break - - if not exist: - hass.async_create_task( - hass.config_entries.flow.async_init( - UNIFI_DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=config, - ) - ) - return True diff --git a/homeassistant/components/upc_connect/device_tracker.py b/homeassistant/components/upc_connect/device_tracker.py index 384b82d139564c..fc9225c6ef4256 100644 --- a/homeassistant/components/upc_connect/device_tracker.py +++ b/homeassistant/components/upc_connect/device_tracker.py @@ -1,10 +1,9 @@ """Support for UPC ConnectBox router.""" -import asyncio import logging +from typing import List, Optional -import aiohttp -from aiohttp.hdrs import REFERER, USER_AGENT -import async_timeout +from connect_box import ConnectBox +from connect_box.exceptions import ConnectBoxError, ConnectBoxLoginError import voluptuous as vol from homeassistant.components.device_tracker import ( @@ -12,118 +11,66 @@ PLATFORM_SCHEMA, DeviceScanner, ) -from homeassistant.const import CONF_HOST, HTTP_HEADER_X_REQUESTED_WITH -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CMD_DEVICES = 123 - DEFAULT_IP = "192.168.0.1" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Optional(CONF_HOST, default=DEFAULT_IP): cv.string} + { + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_IP): cv.string, + } ) async def async_get_scanner(hass, config): """Return the UPC device scanner.""" - scanner = UPCDeviceScanner(hass, config[DOMAIN]) - success_init = await scanner.async_initialize_token() + conf = config[DOMAIN] + session = hass.helpers.aiohttp_client.async_get_clientsession() + connect_box = ConnectBox(session, conf[CONF_PASSWORD], host=conf[CONF_HOST]) + + # Check login data + try: + await connect_box.async_initialize_token() + except ConnectBoxLoginError: + _LOGGER.error("ConnectBox login data error!") + return None + except ConnectBoxError: + pass + + async def _shutdown(event): + """Shutdown event.""" + await connect_box.async_close_session() - return scanner if success_init else None + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + + return UPCDeviceScanner(connect_box) class UPCDeviceScanner(DeviceScanner): """This class queries a router running UPC ConnectBox firmware.""" - def __init__(self, hass, config): + def __init__(self, connect_box: ConnectBox): """Initialize the scanner.""" - self.hass = hass - self.host = config[CONF_HOST] - - self.data = {} - self.token = None + self.connect_box: ConnectBox = connect_box - self.headers = { - HTTP_HEADER_X_REQUESTED_WITH: "XMLHttpRequest", - REFERER: f"http://{self.host}/index.html", - USER_AGENT: ( - "Mozilla/5.0 (Windows NT 10.0; WOW64) " - "AppleWebKit/537.36 (KHTML, like Gecko) " - "Chrome/47.0.2526.106 Safari/537.36" - ), - } - - self.websession = async_get_clientsession(hass) - - async def async_scan_devices(self): + async def async_scan_devices(self) -> List[str]: """Scan for new devices and return a list with found device IDs.""" - import defusedxml.ElementTree as ET - - if self.token is None: - token_initialized = await self.async_initialize_token() - if not token_initialized: - _LOGGER.error("Not connected to %s", self.host) - return [] - - raw = await self._async_ws_function(CMD_DEVICES) - try: - xml_root = ET.fromstring(raw) - return [mac.text for mac in xml_root.iter("MACAddr")] - except (ET.ParseError, TypeError): - _LOGGER.warning("Can't read device from %s", self.host) - self.token = None + await self.connect_box.async_get_devices() + except ConnectBoxError: return [] - async def async_get_device_name(self, device): - """Get the device name (the name of the wireless device not used).""" - return None - - async def async_initialize_token(self): - """Get first token.""" - try: - # get first token - with async_timeout.timeout(10): - response = await self.websession.get( - f"http://{self.host}/common_page/login.html", headers=self.headers - ) - - await response.text() + return [device.mac for device in self.connect_box.devices] - self.token = response.cookies["sessionToken"].value - - return True - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Can not load login page from %s", self.host) - return False + async def async_get_device_name(self, device: str) -> Optional[str]: + """Get the device name (the name of the wireless device not used).""" + for connected_device in self.connect_box.devices: + if connected_device != device: + continue + return connected_device.hostname - async def _async_ws_function(self, function): - """Execute a command on UPC firmware webservice.""" - try: - with async_timeout.timeout(10): - # The 'token' parameter has to be first, and 'fun' second - # or the UPC firmware will return an error - response = await self.websession.post( - f"http://{self.host}/xml/getter.xml", - data=f"token={self.token}&fun={function}", - headers=self.headers, - allow_redirects=False, - ) - - # Error? - if response.status != 200: - _LOGGER.warning("Receive http code %d", response.status) - self.token = None - return - - # Load data, store token for next request - self.token = response.cookies["sessionToken"].value - return await response.text() - - except (asyncio.TimeoutError, aiohttp.ClientError): - _LOGGER.error("Error on %s", function) - self.token = None + return None diff --git a/homeassistant/components/upc_connect/manifest.json b/homeassistant/components/upc_connect/manifest.json index 36a06ac3204223..efa38286e7e2f6 100644 --- a/homeassistant/components/upc_connect/manifest.json +++ b/homeassistant/components/upc_connect/manifest.json @@ -2,9 +2,7 @@ "domain": "upc_connect", "name": "Upc connect", "documentation": "https://www.home-assistant.io/components/upc_connect", - "requirements": [ - "defusedxml==0.6.0" - ], + "requirements": ["connect-box==0.2.4"], "dependencies": [], - "codeowners": [] + "codeowners": ["@pvizeli"] } diff --git a/homeassistant/components/upnp/.translations/ca.json b/homeassistant/components/upnp/.translations/ca.json index 161b5d85599147..28ad9ce954d995 100644 --- a/homeassistant/components/upnp/.translations/ca.json +++ b/homeassistant/components/upnp/.translations/ca.json @@ -8,6 +8,10 @@ "no_sensors_or_port_mapping": "Activa, com a m\u00ednim, els sensors o l'assignaci\u00f3 de ports", "single_instance_allowed": "Nom\u00e9s cal una sola configuraci\u00f3 de UPnP/IGD." }, + "error": { + "one": "un", + "other": "altre" + }, "step": { "confirm": { "description": "Vols configurar UPnP/IGD?", diff --git a/homeassistant/components/upnp/.translations/it.json b/homeassistant/components/upnp/.translations/it.json index 798f6578093950..e822895a6cfaab 100644 --- a/homeassistant/components/upnp/.translations/it.json +++ b/homeassistant/components/upnp/.translations/it.json @@ -8,6 +8,10 @@ "no_sensors_or_port_mapping": "Abilita almeno i sensori o la mappatura delle porte", "single_instance_allowed": "\u00c8 necessaria una sola configurazione di UPnP/IGD." }, + "error": { + "one": "Vuoto", + "other": "Vuoto" + }, "step": { "confirm": { "description": "Vuoi configurare UPnP/IGD?", diff --git a/homeassistant/components/upnp/.translations/ko.json b/homeassistant/components/upnp/.translations/ko.json index 9fa37e1236dd32..d846a5e38ce342 100644 --- a/homeassistant/components/upnp/.translations/ko.json +++ b/homeassistant/components/upnp/.translations/ko.json @@ -2,9 +2,9 @@ "config": { "abort": { "already_configured": "UPnP/IGD \uac00 \uc774\ubbf8 \uc124\uc815\ub41c \uc0c1\ud0dc\uc785\ub2c8\ub2e4", - "incomplete_device": "\ubd88\uc644\uc804\ud55c UPnP \uc7a5\uce58 \ubb34\uc2dc\ud558\uae30", + "incomplete_device": "\ubd88\uc644\uc804\ud55c UPnP \uae30\uae30 \ubb34\uc2dc\ud558\uae30", "no_devices_discovered": "\ubc1c\uacac\ub41c UPnP/IGD \uac00 \uc5c6\uc2b5\ub2c8\ub2e4", - "no_devices_found": "UPnP/IGD \uc7a5\uce58\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "no_devices_found": "UPnP/IGD \uae30\uae30\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", "no_sensors_or_port_mapping": "\ucd5c\uc18c\ud55c \uc13c\uc11c \ud639\uc740 \ud3ec\ud2b8 \ub9e4\ud551\uc744 \ud65c\uc131\ud654 \ud574\uc57c \ud569\ub2c8\ub2e4", "single_instance_allowed": "\ud558\ub098\uc758 UPnP/IGD \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." }, diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 6120b6b3ca6d9e..9aec23a687ce0d 100644 --- a/homeassistant/components/upnp/manifest.json +++ b/homeassistant/components/upnp/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/upnp", "requirements": [ - "async-upnp-client==0.14.10" + "async-upnp-client==0.14.11" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/upnp/sensor.py b/homeassistant/components/upnp/sensor.py index e5746e088f866b..b721fa29cdd860 100644 --- a/homeassistant/components/upnp/sensor.py +++ b/homeassistant/components/upnp/sensor.py @@ -1,11 +1,11 @@ """Support for UPnP/IGD Sensors.""" -from datetime import datetime import logging from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import HomeAssistantType +import homeassistant.util.dt as dt_util from .const import DOMAIN as DOMAIN_UPNP, SIGNAL_REMOVE_SENSOR @@ -199,10 +199,10 @@ async def async_update(self): if self._last_value is None: self._last_value = new_value - self._last_update_time = datetime.now() + self._last_update_time = dt_util.utcnow() return - now = datetime.now() + now = dt_util.utcnow() if self._is_overflowed(new_value): self._state = None # temporarily report nothing else: diff --git a/homeassistant/components/ups/__init__.py b/homeassistant/components/ups/__init__.py deleted file mode 100644 index 690d3102f9c5bf..00000000000000 --- a/homeassistant/components/ups/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""The ups component.""" diff --git a/homeassistant/components/ups/manifest.json b/homeassistant/components/ups/manifest.json deleted file mode 100644 index 98db00c30948e1..00000000000000 --- a/homeassistant/components/ups/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "ups", - "name": "Ups", - "documentation": "https://www.home-assistant.io/components/ups", - "requirements": [ - "upsmychoice==1.0.6" - ], - "dependencies": [], - "codeowners": [] -} diff --git a/homeassistant/components/ups/sensor.py b/homeassistant/components/ups/sensor.py deleted file mode 100644 index cfe35a9a63fc0c..00000000000000 --- a/homeassistant/components/ups/sensor.py +++ /dev/null @@ -1,126 +0,0 @@ -"""Sensor for UPS packages.""" -import logging -from collections import defaultdict -from datetime import timedelta - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - ATTR_ATTRIBUTION, - CONF_NAME, - CONF_PASSWORD, - CONF_SCAN_INTERVAL, - CONF_USERNAME, -) -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle, slugify -from homeassistant.util.dt import now, parse_date - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "ups" -COOKIE = "upsmychoice_cookies.pickle" -ICON = "mdi:package-variant-closed" -STATUS_DELIVERED = "delivered" - -SCAN_INTERVAL = timedelta(seconds=1800) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME): cv.string, - } -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the UPS platform.""" - import upsmychoice - - _LOGGER.warning( - "The ups integration is deprecated and will be removed " - "in Home Assistant 0.100.0. For more information see ADR-0004:" - "https://github.com/home-assistant/architecture/blob/master/adr/0004-webscraping.md" - ) - - try: - cookie = hass.config.path(COOKIE) - session = upsmychoice.get_session( - config.get(CONF_USERNAME), config.get(CONF_PASSWORD), cookie_path=cookie - ) - except upsmychoice.UPSError: - _LOGGER.exception("Could not connect to UPS My Choice") - return False - - add_entities( - [ - UPSSensor( - session, - config.get(CONF_NAME), - config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL), - ) - ], - True, - ) - - -class UPSSensor(Entity): - """UPS Sensor.""" - - def __init__(self, session, name, interval): - """Initialize the sensor.""" - self._session = session - self._name = name - self._attributes = None - self._state = None - self.update = Throttle(interval)(self._update) - - @property - def name(self): - """Return the name of the sensor.""" - return self._name or DOMAIN - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return "packages" - - def _update(self): - """Update device state.""" - import upsmychoice - - status_counts = defaultdict(int) - try: - for package in upsmychoice.get_packages(self._session): - status = slugify(package["status"]) - skip = ( - status == STATUS_DELIVERED - and parse_date(package["delivery_date"]) < now().date() - ) - if skip: - continue - status_counts[status] += 1 - except upsmychoice.UPSError: - _LOGGER.error("Could not connect to UPS My Choice account") - - self._attributes = {ATTR_ATTRIBUTION: upsmychoice.ATTRIBUTION} - self._attributes.update(status_counts) - self._state = sum(status_counts.values()) - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self._attributes - - @property - def icon(self): - """Icon to use in the frontend.""" - return ICON diff --git a/homeassistant/components/usps/__init__.py b/homeassistant/components/usps/__init__.py deleted file mode 100644 index 61da78fa6d7194..00000000000000 --- a/homeassistant/components/usps/__init__.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Support for USPS packages and mail.""" -from datetime import timedelta -import logging - -import voluptuous as vol - -from homeassistant.const import CONF_NAME, CONF_USERNAME, CONF_PASSWORD -from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.util import Throttle -from homeassistant.util.dt import now - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "usps" -DATA_USPS = "data_usps" -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) -COOKIE = "usps_cookies.pickle" -CACHE = "usps_cache" -CONF_DRIVER = "driver" - -USPS_TYPE = ["sensor", "camera"] - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - { - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME, default=DOMAIN): cv.string, - vol.Optional(CONF_DRIVER): cv.string, - } - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -def setup(hass, config): - """Use config values to set up a function enabling status retrieval.""" - _LOGGER.warning( - "The usps integration is deprecated and will be removed " - "in Home Assistant 0.100.0. For more information see ADR-0004:" - "https://github.com/home-assistant/architecture/blob/master/adr/0004-webscraping.md" - ) - - conf = config[DOMAIN] - username = conf.get(CONF_USERNAME) - password = conf.get(CONF_PASSWORD) - name = conf.get(CONF_NAME) - driver = conf.get(CONF_DRIVER) - - import myusps - - try: - cookie = hass.config.path(COOKIE) - cache = hass.config.path(CACHE) - session = myusps.get_session( - username, password, cookie_path=cookie, cache_path=cache, driver=driver - ) - except myusps.USPSError: - _LOGGER.exception("Could not connect to My USPS") - return False - - hass.data[DATA_USPS] = USPSData(session, name) - - for component in USPS_TYPE: - discovery.load_platform(hass, component, DOMAIN, {}, config) - - return True - - -class USPSData: - """Stores the data retrieved from USPS. - - For each entity to use, acts as the single point responsible for fetching - updates from the server. - """ - - def __init__(self, session, name): - """Initialize the data object.""" - self.session = session - self.name = name - self.packages = [] - self.mail = [] - self.attribution = None - - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self, **kwargs): - """Fetch the latest info from USPS.""" - import myusps - - self.packages = myusps.get_packages(self.session) - self.mail = myusps.get_mail(self.session, now().date()) - self.attribution = myusps.ATTRIBUTION - _LOGGER.debug("Mail, request date: %s, list: %s", now().date(), self.mail) - _LOGGER.debug("Package list: %s", self.packages) diff --git a/homeassistant/components/usps/camera.py b/homeassistant/components/usps/camera.py deleted file mode 100644 index 3141314b049cb5..00000000000000 --- a/homeassistant/components/usps/camera.py +++ /dev/null @@ -1,88 +0,0 @@ -"""Support for a camera made up of USPS mail images.""" -from datetime import timedelta -import logging - -from homeassistant.components.camera import Camera - -from . import DATA_USPS - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(seconds=10) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up USPS mail camera.""" - if discovery_info is None: - return - - usps = hass.data[DATA_USPS] - add_entities([USPSCamera(usps)]) - - -class USPSCamera(Camera): - """Representation of the images available from USPS.""" - - def __init__(self, usps): - """Initialize the USPS camera images.""" - super().__init__() - - self._usps = usps - self._name = self._usps.name - self._session = self._usps.session - - self._mail_img = [] - self._last_mail = None - self._mail_index = 0 - self._mail_count = 0 - - self._timer = None - - def camera_image(self): - """Update the camera's image if it has changed.""" - self._usps.update() - try: - self._mail_count = len(self._usps.mail) - except TypeError: - # No mail - return None - - if self._usps.mail != self._last_mail: - # Mail items must have changed - self._mail_img = [] - if len(self._usps.mail) >= 1: - self._last_mail = self._usps.mail - for article in self._usps.mail: - _LOGGER.debug("Fetching article image: %s", article) - img = self._session.get(article["image"]).content - self._mail_img.append(img) - - try: - return self._mail_img[self._mail_index] - except IndexError: - return None - - @property - def name(self): - """Return the name of this camera.""" - return f"{self._name} mail" - - @property - def model(self): - """Return date of mail as model.""" - try: - return "Date: {}".format(str(self._usps.mail[0]["date"])) - except IndexError: - return None - - @property - def should_poll(self): - """Update the mail image index periodically.""" - return True - - def update(self): - """Update mail image index.""" - if self._mail_index < (self._mail_count - 1): - self._mail_index += 1 - else: - self._mail_index = 0 diff --git a/homeassistant/components/usps/manifest.json b/homeassistant/components/usps/manifest.json deleted file mode 100644 index 9e2f8886d3acbd..00000000000000 --- a/homeassistant/components/usps/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "domain": "usps", - "name": "Usps", - "documentation": "https://www.home-assistant.io/components/usps", - "requirements": [ - "myusps==1.3.2" - ], - "dependencies": [], - "codeowners": [] -} diff --git a/homeassistant/components/usps/sensor.py b/homeassistant/components/usps/sensor.py deleted file mode 100644 index 7e26e6c9e5c793..00000000000000 --- a/homeassistant/components/usps/sensor.py +++ /dev/null @@ -1,122 +0,0 @@ -"""Sensor for USPS packages.""" -from collections import defaultdict -import logging - -from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DATE -from homeassistant.helpers.entity import Entity -from homeassistant.util import slugify -from homeassistant.util.dt import now - -from . import DATA_USPS - -_LOGGER = logging.getLogger(__name__) - -STATUS_DELIVERED = "delivered" - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the USPS platform.""" - if discovery_info is None: - return - - usps = hass.data[DATA_USPS] - add_entities([USPSPackageSensor(usps), USPSMailSensor(usps)], True) - - -class USPSPackageSensor(Entity): - """USPS Package Sensor.""" - - def __init__(self, usps): - """Initialize the sensor.""" - self._usps = usps - self._name = self._usps.name - self._attributes = None - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} packages" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - def update(self): - """Update device state.""" - self._usps.update() - status_counts = defaultdict(int) - for package in self._usps.packages: - status = slugify(package["primary_status"]) - if status == STATUS_DELIVERED and package["delivery_date"] < now().date(): - continue - status_counts[status] += 1 - self._attributes = {ATTR_ATTRIBUTION: self._usps.attribution} - self._attributes.update(status_counts) - self._state = sum(status_counts.values()) - - @property - def device_state_attributes(self): - """Return the state attributes.""" - return self._attributes - - @property - def icon(self): - """Return the icon to use in the frontend.""" - return "mdi:package-variant-closed" - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return "packages" - - -class USPSMailSensor(Entity): - """USPS Mail Sensor.""" - - def __init__(self, usps): - """Initialize the sensor.""" - self._usps = usps - self._name = self._usps.name - self._attributes = None - self._state = None - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._name} mail" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - def update(self): - """Update device state.""" - self._usps.update() - if self._usps.mail is not None: - self._state = len(self._usps.mail) - else: - self._state = 0 - - @property - def device_state_attributes(self): - """Return the state attributes.""" - attr = {} - attr[ATTR_ATTRIBUTION] = self._usps.attribution - try: - attr[ATTR_DATE] = str(self._usps.mail[0]["date"]) - except IndexError: - pass - return attr - - @property - def icon(self): - """Icon to use in the frontend.""" - return "mdi:mailbox" - - @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity, if any.""" - return "pieces" diff --git a/homeassistant/components/velbus/.translations/de.json b/homeassistant/components/velbus/.translations/de.json new file mode 100644 index 00000000000000..72af917e12ed29 --- /dev/null +++ b/homeassistant/components/velbus/.translations/de.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "port_exists": "Dieser Port ist bereits konfiguriert" + }, + "error": { + "connection_failed": "Die Velbus-Verbindung ist fehlgeschlagen", + "port_exists": "Dieser Port ist bereits konfiguriert" + }, + "step": { + "user": { + "data": { + "name": "Der Name f\u00fcr diese Velbus-Verbindung", + "port": "Verbindungs details" + }, + "title": "Definieren des Velbus-Verbindungstyps" + } + }, + "title": "Velbus-Schnittstelle" + } +} \ No newline at end of file diff --git a/homeassistant/components/velbus/.translations/es.json b/homeassistant/components/velbus/.translations/es.json new file mode 100644 index 00000000000000..1e1e8897c30155 --- /dev/null +++ b/homeassistant/components/velbus/.translations/es.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "port_exists": "Este puerto ya est\u00e1 configurado" + }, + "error": { + "connection_failed": "La conexi\u00f3n velbus fall\u00f3", + "port_exists": "Este puerto ya est\u00e1 configurado" + }, + "step": { + "user": { + "data": { + "name": "El nombre de esta conexi\u00f3n velbus", + "port": "Cadena de conexi\u00f3n" + }, + "title": "Definir el tipo de conexi\u00f3n velbus" + } + }, + "title": "Interfaz Velbus" + } +} \ No newline at end of file diff --git a/homeassistant/components/velbus/.translations/fr.json b/homeassistant/components/velbus/.translations/fr.json new file mode 100644 index 00000000000000..8d93adbf4a92dd --- /dev/null +++ b/homeassistant/components/velbus/.translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "port_exists": "Ce port est d\u00e9j\u00e0 configur\u00e9" + }, + "error": { + "connection_failed": "La connexion velbus a \u00e9chou\u00e9", + "port_exists": "Ce port est d\u00e9j\u00e0 configur\u00e9" + }, + "step": { + "user": { + "data": { + "name": "Le nom pour cette connexion velbus", + "port": "Cha\u00eene de connexion" + }, + "title": "D\u00e9finir le type de connexion velbus" + } + }, + "title": "Interface Velbus" + } +} \ No newline at end of file diff --git a/homeassistant/components/velbus/.translations/it.json b/homeassistant/components/velbus/.translations/it.json new file mode 100644 index 00000000000000..e4f1fbf9c6b7b3 --- /dev/null +++ b/homeassistant/components/velbus/.translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "port_exists": "Questa porta \u00e8 gi\u00e0 configurata" + }, + "error": { + "connection_failed": "La connessione Velbus non \u00e8 riuscita", + "port_exists": "Questa porta \u00e8 gi\u00e0 configurata" + }, + "step": { + "user": { + "data": { + "name": "Il nome per questa connessione Velbus", + "port": "Stringa di connessione" + }, + "title": "Definire il tipo di connessione Velbus" + } + }, + "title": "Interfaccia Velbus" + } +} \ No newline at end of file diff --git a/homeassistant/components/velbus/.translations/ko.json b/homeassistant/components/velbus/.translations/ko.json new file mode 100644 index 00000000000000..6e218afc97c10a --- /dev/null +++ b/homeassistant/components/velbus/.translations/ko.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "port_exists": "\ud3ec\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "error": { + "connection_failed": "Velbus \uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "port_exists": "\ud3ec\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "name": "Velbus \uc5f0\uacb0 \uc774\ub984", + "port": "\uc5f0\uacb0 \ubb38\uc790\uc5f4" + }, + "title": "Velbus \uc5f0\uacb0 \uc720\ud615 \uc815\uc758" + } + }, + "title": "Velbus \uc778\ud130\ud398\uc774\uc2a4" + } +} \ No newline at end of file diff --git a/homeassistant/components/velbus/.translations/lb.json b/homeassistant/components/velbus/.translations/lb.json new file mode 100644 index 00000000000000..f38a74e5c1fd19 --- /dev/null +++ b/homeassistant/components/velbus/.translations/lb.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "port_exists": "D\u00ebse Port ass scho konfigur\u00e9iert" + }, + "error": { + "connection_failed": "Feeler bei der velbus Verbindung", + "port_exists": "D\u00ebse Port ass scho konfigur\u00e9iert" + }, + "step": { + "user": { + "data": { + "name": "Numm fir d\u00ebs velbus Verbindung", + "port": "Verbindungs zeeche-folleg" + }, + "title": "D\u00e9fin\u00e9iert den Typ vun der Velbus Verbindung" + } + }, + "title": "Velbus Interface" + } +} \ No newline at end of file diff --git a/homeassistant/components/velbus/.translations/no.json b/homeassistant/components/velbus/.translations/no.json new file mode 100644 index 00000000000000..c6b16170877edf --- /dev/null +++ b/homeassistant/components/velbus/.translations/no.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "port_exists": "Denne porten er allerede konfigurert" + }, + "error": { + "connection_failed": "Velbus-tilkoblingen mislyktes", + "port_exists": "Denne porten er allerede konfigurert" + }, + "step": { + "user": { + "data": { + "name": "Navnet p\u00e5 denne velbus tilkoblingen", + "port": "Tilkoblingsstreng" + }, + "title": "Definer tilkoblingstype for velbus" + } + }, + "title": "Velbus-grensesnitt" + } +} \ No newline at end of file diff --git a/homeassistant/components/velbus/.translations/sl.json b/homeassistant/components/velbus/.translations/sl.json new file mode 100644 index 00000000000000..2fa1ccadcea616 --- /dev/null +++ b/homeassistant/components/velbus/.translations/sl.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "port_exists": "Ta vrata so \u017ee nastavljena" + }, + "error": { + "connection_failed": "Povezava z velbusom ni uspela", + "port_exists": "Ta vrata so \u017ee nastavljena" + }, + "step": { + "user": { + "data": { + "name": "Ime za to velbus povezavo", + "port": "Povezovalni niz" + }, + "title": "Dolo\u010dite vrsto povezave z velbusom" + } + }, + "title": "Velbus vmesnik" + } +} \ No newline at end of file diff --git a/homeassistant/components/velbus/.translations/zh-Hans.json b/homeassistant/components/velbus/.translations/zh-Hans.json new file mode 100644 index 00000000000000..7b2bc3b028b78d --- /dev/null +++ b/homeassistant/components/velbus/.translations/zh-Hans.json @@ -0,0 +1,14 @@ +{ + "config": { + "error": { + "port_exists": "\u6b64\u7aef\u53e3\u5df2\u914d\u7f6e\u5b8c\u6210" + }, + "step": { + "user": { + "data": { + "name": "\u8fd9\u4e2avelbus\u8fde\u63a5\u7684\u540d\u79f0" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 4c21bb7fdefbf5..51f615e68aaf5a 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -1,11 +1,12 @@ """Support for VELUX KLF 200 devices.""" import logging - import voluptuous as vol +from pyvlx import PyVLX +from pyvlx import PyVLXException from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_HOST, CONF_PASSWORD +from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP DOMAIN = "velux" DATA_VELUX = "data_velux" @@ -24,10 +25,9 @@ async def async_setup(hass, config): """Set up the velux component.""" - from pyvlx import PyVLXException - try: - hass.data[DATA_VELUX] = VeluxModule(hass, config) + hass.data[DATA_VELUX] = VeluxModule(hass, config[DOMAIN]) + hass.data[DATA_VELUX].setup() await hass.data[DATA_VELUX].async_start() except PyVLXException as ex: @@ -44,15 +44,27 @@ async def async_setup(hass, config): class VeluxModule: """Abstraction for velux component.""" - def __init__(self, hass, config): + def __init__(self, hass, domain_config): """Initialize for velux component.""" - from pyvlx import PyVLX + self.pyvlx = None + self._hass = hass + self._domain_config = domain_config + + def setup(self): + """Velux component setup.""" + + async def on_hass_stop(event): + """Close connection when hass stops.""" + _LOGGER.debug("Velux interface terminated") + await self.pyvlx.disconnect() - host = config[DOMAIN].get(CONF_HOST) - password = config[DOMAIN].get(CONF_PASSWORD) + self._hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop) + host = self._domain_config.get(CONF_HOST) + password = self._domain_config.get(CONF_PASSWORD) self.pyvlx = PyVLX(host=host, password=password) async def async_start(self): """Start velux component.""" + _LOGGER.debug("Velux interface started") await self.pyvlx.load_scenes() await self.pyvlx.load_nodes() diff --git a/homeassistant/components/vesync/.translations/de.json b/homeassistant/components/vesync/.translations/de.json new file mode 100644 index 00000000000000..44b3ea86c5509d --- /dev/null +++ b/homeassistant/components/vesync/.translations/de.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "Nur eine Vesync-Instanz ist zul\u00e4ssig" + }, + "error": { + "invalid_login": "Ung\u00fcltiger Benutzername oder Kennwort" + }, + "step": { + "user": { + "data": { + "password": "Passwort", + "username": "E-Mail-Adresse" + }, + "title": "Benutzername und Passwort eingeben" + } + }, + "title": "VeSync" + } +} \ No newline at end of file diff --git a/homeassistant/components/vesync/.translations/es.json b/homeassistant/components/vesync/.translations/es.json new file mode 100644 index 00000000000000..856dc77a52c48d --- /dev/null +++ b/homeassistant/components/vesync/.translations/es.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "Solo se permite una instancia de Vesync" + }, + "error": { + "invalid_login": "Nombre de usuario o contrase\u00f1a no v\u00e1lidos" + }, + "step": { + "user": { + "data": { + "password": "Contrase\u00f1a", + "username": "Direcci\u00f3n de correo electr\u00f3nico" + }, + "title": "Introduzca el nombre de usuario y la contrase\u00f1a" + } + }, + "title": "VeSync" + } +} \ No newline at end of file diff --git a/homeassistant/components/vesync/.translations/fr.json b/homeassistant/components/vesync/.translations/fr.json new file mode 100644 index 00000000000000..4928ea4f0be508 --- /dev/null +++ b/homeassistant/components/vesync/.translations/fr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "Une seule instance de Vesync est autoris\u00e9e" + }, + "error": { + "invalid_login": "Nom d'utilisateur ou mot de passe invalide" + }, + "step": { + "user": { + "data": { + "password": "Mot de passe", + "username": "Adresse e-mail" + }, + "title": "Entrez vos identifiants" + } + }, + "title": "VeSync" + } +} \ No newline at end of file diff --git a/homeassistant/components/vesync/.translations/it.json b/homeassistant/components/vesync/.translations/it.json new file mode 100644 index 00000000000000..d3e53547559a7e --- /dev/null +++ b/homeassistant/components/vesync/.translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "\u00c8 consentita una sola istanza di Vesync" + }, + "error": { + "invalid_login": "Nome utente o password non validi" + }, + "step": { + "user": { + "data": { + "password": "Password", + "username": "Indirizzo E-mail" + }, + "title": "Immettere nome utente e password" + } + }, + "title": "VeSync" + } +} \ No newline at end of file diff --git a/homeassistant/components/vesync/.translations/ko.json b/homeassistant/components/vesync/.translations/ko.json new file mode 100644 index 00000000000000..ca43b90acc9632 --- /dev/null +++ b/homeassistant/components/vesync/.translations/ko.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "\ud558\ub098\uc758 Vesync \uc778\uc2a4\ud134\uc2a4\ub9cc \ud5c8\uc6a9\ub429\ub2c8\ub2e4" + }, + "error": { + "invalid_login": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ub610\ub294 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4" + }, + "step": { + "user": { + "data": { + "password": "\ube44\ubc00\ubc88\ud638", + "username": "\uc774\uba54\uc77c \uc8fc\uc18c" + }, + "title": "\uc0ac\uc6a9\uc790 \uc774\ub984\uacfc \ube44\ubc00\ubc88\ud638\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694" + } + }, + "title": "VeSync" + } +} \ No newline at end of file diff --git a/homeassistant/components/vesync/.translations/lb.json b/homeassistant/components/vesync/.translations/lb.json new file mode 100644 index 00000000000000..cfccd8b1dbb49a --- /dev/null +++ b/homeassistant/components/vesync/.translations/lb.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "N\u00ebmmen eng eenzeg Instanz vu Vesync ass erlaabt." + }, + "error": { + "invalid_login": "Ong\u00ebltege Benotzernumm oder Passwuert" + }, + "step": { + "user": { + "data": { + "password": "Passwuert", + "username": "E-Mail Adresse" + }, + "title": "Benotznumm a Passwuert aginn" + } + }, + "title": "VeSync" + } +} \ No newline at end of file diff --git a/homeassistant/components/vesync/.translations/no.json b/homeassistant/components/vesync/.translations/no.json new file mode 100644 index 00000000000000..be5f27b7a0f0e0 --- /dev/null +++ b/homeassistant/components/vesync/.translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "Bare en Vesync-forekomst er tillatt" + }, + "error": { + "invalid_login": "Ugyldig brukernavn eller passord" + }, + "step": { + "user": { + "data": { + "password": "Passord", + "username": "E-postadresse" + }, + "title": "Skriv inn brukernavn og passord" + } + }, + "title": "VeSync" + } +} \ No newline at end of file diff --git a/homeassistant/components/vesync/.translations/sl.json b/homeassistant/components/vesync/.translations/sl.json new file mode 100644 index 00000000000000..636237dcfc1946 --- /dev/null +++ b/homeassistant/components/vesync/.translations/sl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "already_setup": "Dovoljen je samo ena instanca Vesync" + }, + "error": { + "invalid_login": "Neveljavno uporabni\u0161ko ime ali geslo" + }, + "step": { + "user": { + "data": { + "password": "Geslo", + "username": "E-po\u0161tni naslov" + }, + "title": "Vnesite uporabni\u0161ko Ime in Geslo" + } + }, + "title": "VeSync" + } +} \ No newline at end of file diff --git a/homeassistant/components/vesync/.translations/zh-Hans.json b/homeassistant/components/vesync/.translations/zh-Hans.json new file mode 100644 index 00000000000000..caa00f36c89435 --- /dev/null +++ b/homeassistant/components/vesync/.translations/zh-Hans.json @@ -0,0 +1,7 @@ +{ + "config": { + "abort": { + "already_setup": "\u53ea\u5141\u8bb8\u4e00\u4e2aVesync\u5b9e\u4f8b" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/vicare/__init__.py b/homeassistant/components/vicare/__init__.py new file mode 100644 index 00000000000000..9fec04f23283f3 --- /dev/null +++ b/homeassistant/components/vicare/__init__.py @@ -0,0 +1,58 @@ +"""The ViCare integration.""" +import logging + +import voluptuous as vol +from PyViCare.PyViCareDevice import Device + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_NAME +from homeassistant.helpers import discovery + +_LOGGER = logging.getLogger(__name__) + +VICARE_PLATFORMS = ["climate", "water_heater"] + +DOMAIN = "vicare" +VICARE_API = "api" +VICARE_NAME = "name" + +CONF_CIRCUIT = "circuit" + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_CIRCUIT): int, + vol.Optional(CONF_NAME, default="ViCare"): cv.string, + } + ) + }, + extra=vol.ALLOW_EXTRA, +) + + +def setup(hass, config): + """Create the ViCare component.""" + conf = config[DOMAIN] + params = {"token_file": "/tmp/vicare_token.save"} + if conf.get(CONF_CIRCUIT) is not None: + params["circuit"] = conf[CONF_CIRCUIT] + + try: + vicare_api = Device(conf[CONF_USERNAME], conf[CONF_PASSWORD], **params) + except AttributeError: + _LOGGER.error( + "Failed to create PyViCare API client. Please check your credentials." + ) + return False + + hass.data[DOMAIN] = {} + hass.data[DOMAIN][VICARE_API] = vicare_api + hass.data[DOMAIN][VICARE_NAME] = conf[CONF_NAME] + + for platform in VICARE_PLATFORMS: + discovery.load_platform(hass, platform, DOMAIN, {}, config) + + return True diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py new file mode 100644 index 00000000000000..7010f943707303 --- /dev/null +++ b/homeassistant/components/vicare/climate.py @@ -0,0 +1,235 @@ +"""Viessmann ViCare climate device.""" +import logging + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, + PRESET_ECO, + PRESET_COMFORT, + HVAC_MODE_OFF, + HVAC_MODE_HEAT, + HVAC_MODE_AUTO, +) +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE, PRECISION_WHOLE + +from . import DOMAIN as VICARE_DOMAIN +from . import VICARE_API +from . import VICARE_NAME + +_LOGGER = logging.getLogger(__name__) + +VICARE_MODE_DHW = "dhw" +VICARE_MODE_DHWANDHEATING = "dhwAndHeating" +VICARE_MODE_FORCEDREDUCED = "forcedReduced" +VICARE_MODE_FORCEDNORMAL = "forcedNormal" +VICARE_MODE_OFF = "standby" + +VICARE_PROGRAM_ACTIVE = "active" +VICARE_PROGRAM_COMFORT = "comfort" +VICARE_PROGRAM_ECO = "eco" +VICARE_PROGRAM_EXTERNAL = "external" +VICARE_PROGRAM_HOLIDAY = "holiday" +VICARE_PROGRAM_NORMAL = "normal" +VICARE_PROGRAM_REDUCED = "reduced" +VICARE_PROGRAM_STANDBY = "standby" + +VICARE_HOLD_MODE_AWAY = "away" +VICARE_HOLD_MODE_HOME = "home" +VICARE_HOLD_MODE_OFF = "off" + +VICARE_TEMP_HEATING_MIN = 3 +VICARE_TEMP_HEATING_MAX = 37 + +SUPPORT_FLAGS_HEATING = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + +VICARE_TO_HA_HVAC_HEATING = { + VICARE_MODE_DHW: HVAC_MODE_OFF, + VICARE_MODE_DHWANDHEATING: HVAC_MODE_AUTO, + VICARE_MODE_FORCEDREDUCED: HVAC_MODE_OFF, + VICARE_MODE_FORCEDNORMAL: HVAC_MODE_HEAT, + VICARE_MODE_OFF: HVAC_MODE_OFF, +} + +HA_TO_VICARE_HVAC_HEATING = { + HVAC_MODE_HEAT: VICARE_MODE_FORCEDNORMAL, + HVAC_MODE_OFF: VICARE_MODE_FORCEDREDUCED, + HVAC_MODE_AUTO: VICARE_MODE_DHWANDHEATING, +} + +VICARE_TO_HA_PRESET_HEATING = { + VICARE_PROGRAM_COMFORT: PRESET_COMFORT, + VICARE_PROGRAM_ECO: PRESET_ECO, +} + +HA_TO_VICARE_PRESET_HEATING = { + PRESET_COMFORT: VICARE_PROGRAM_COMFORT, + PRESET_ECO: VICARE_PROGRAM_ECO, +} + +PYVICARE_ERROR = "error" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Create the ViCare climate devices.""" + if discovery_info is None: + return + vicare_api = hass.data[VICARE_DOMAIN][VICARE_API] + add_entities( + [ViCareClimate(f"{hass.data[VICARE_DOMAIN][VICARE_NAME]} Heating", vicare_api)] + ) + + +class ViCareClimate(ClimateDevice): + """Representation of the ViCare heating climate device.""" + + def __init__(self, name, api): + """Initialize the climate device.""" + self._name = name + self._state = None + self._api = api + self._attributes = {} + self._target_temperature = None + self._current_mode = None + self._current_temperature = None + self._current_program = None + + def update(self): + """Let HA know there has been an update from the ViCare API.""" + _room_temperature = self._api.getRoomTemperature() + _supply_temperature = self._api.getSupplyTemperature() + if _room_temperature is not None and _room_temperature != PYVICARE_ERROR: + self._current_temperature = _room_temperature + elif _supply_temperature != PYVICARE_ERROR: + self._current_temperature = _supply_temperature + else: + self._current_temperature = None + self._current_program = self._api.getActiveProgram() + + # The getCurrentDesiredTemperature call can yield 'error' (str) when the system is in standby + desired_temperature = self._api.getCurrentDesiredTemperature() + if desired_temperature == PYVICARE_ERROR: + desired_temperature = None + + self._target_temperature = desired_temperature + + self._current_mode = self._api.getActiveMode() + + # Update the device attributes + self._attributes = {} + self._attributes["room_temperature"] = _room_temperature + self._attributes["supply_temperature"] = _supply_temperature + self._attributes["outside_temperature"] = self._api.getOutsideTemperature() + self._attributes["active_vicare_program"] = self._current_program + self._attributes["active_vicare_mode"] = self._current_mode + self._attributes["heating_curve_slope"] = self._api.getHeatingCurveSlope() + self._attributes["heating_curve_shift"] = self._api.getHeatingCurveShift() + self._attributes[ + "month_since_last_service" + ] = self._api.getMonthSinceLastService() + self._attributes["date_last_service"] = self._api.getLastServiceDate() + self._attributes["error_history"] = self._api.getErrorHistory() + self._attributes["active_error"] = self._api.getActiveError() + self._attributes[ + "circulationpump_active" + ] = self._api.getCirculationPumpActive() + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS_HEATING + + @property + def name(self): + """Return the name of the climate device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def hvac_mode(self): + """Return current hvac mode.""" + return VICARE_TO_HA_HVAC_HEATING.get(self._current_mode) + + def set_hvac_mode(self, hvac_mode): + """Set a new hvac mode on the ViCare API.""" + vicare_mode = HA_TO_VICARE_HVAC_HEATING.get(hvac_mode) + if vicare_mode is None: + _LOGGER.error( + "Cannot set invalid vicare mode: %s / %s", hvac_mode, vicare_mode + ) + return + + _LOGGER.debug("Setting hvac mode to %s / %s", hvac_mode, vicare_mode) + self._api.setMode(vicare_mode) + + @property + def hvac_modes(self): + """Return the list of available hvac modes.""" + return list(HA_TO_VICARE_HVAC_HEATING) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return VICARE_TEMP_HEATING_MIN + + @property + def max_temp(self): + """Return the maximum temperature.""" + return VICARE_TEMP_HEATING_MAX + + @property + def precision(self): + """Return the precision of the system.""" + return PRECISION_WHOLE + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is not None: + self._api.setProgramTemperature( + self._current_program, self._target_temperature + ) + + @property + def preset_mode(self): + """Return the current preset mode, e.g., home, away, temp.""" + return VICARE_TO_HA_PRESET_HEATING.get(self._current_program) + + @property + def preset_modes(self): + """Return the available preset mode.""" + return list(VICARE_TO_HA_PRESET_HEATING) + + def set_preset_mode(self, preset_mode): + """Set new preset mode and deactivate any existing programs.""" + vicare_program = HA_TO_VICARE_PRESET_HEATING.get(preset_mode) + if vicare_program is None: + _LOGGER.error( + "Cannot set invalid vicare program: %s / %s", + preset_mode, + vicare_program, + ) + return + + _LOGGER.debug("Setting preset to %s / %s", preset_mode, vicare_program) + self._api.deactivateProgram(self._current_program) + self._api.activateProgram(vicare_program) + + @property + def device_state_attributes(self): + """Show Device Attributes.""" + return self._attributes diff --git a/homeassistant/components/vicare/manifest.json b/homeassistant/components/vicare/manifest.json new file mode 100644 index 00000000000000..e5f55b20ddaf8d --- /dev/null +++ b/homeassistant/components/vicare/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "vicare", + "name": "Viessmann ViCare", + "documentation": "https://www.home-assistant.io/components/vicare", + "dependencies": [], + "codeowners": ["@oischinger"], + "requirements": ["PyViCare==0.1.1"] +} + diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py new file mode 100644 index 00000000000000..71c0f6c2aefe7a --- /dev/null +++ b/homeassistant/components/vicare/water_heater.py @@ -0,0 +1,132 @@ +"""Viessmann ViCare water_heater device.""" +import logging + +from homeassistant.components.water_heater import ( + SUPPORT_TARGET_TEMPERATURE, + WaterHeaterDevice, +) +from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE, PRECISION_WHOLE + +from . import DOMAIN as VICARE_DOMAIN +from . import VICARE_API +from . import VICARE_NAME + +_LOGGER = logging.getLogger(__name__) + +VICARE_MODE_DHW = "dhw" +VICARE_MODE_DHWANDHEATING = "dhwAndHeating" +VICARE_MODE_FORCEDREDUCED = "forcedReduced" +VICARE_MODE_FORCEDNORMAL = "forcedNormal" +VICARE_MODE_OFF = "standby" + +VICARE_TEMP_WATER_MIN = 10 +VICARE_TEMP_WATER_MAX = 60 + +OPERATION_MODE_ON = "on" +OPERATION_MODE_OFF = "off" + +SUPPORT_FLAGS_HEATER = SUPPORT_TARGET_TEMPERATURE + +VICARE_TO_HA_HVAC_DHW = { + VICARE_MODE_DHW: OPERATION_MODE_ON, + VICARE_MODE_DHWANDHEATING: OPERATION_MODE_ON, + VICARE_MODE_FORCEDREDUCED: OPERATION_MODE_OFF, + VICARE_MODE_FORCEDNORMAL: OPERATION_MODE_ON, + VICARE_MODE_OFF: OPERATION_MODE_OFF, +} + +HA_TO_VICARE_HVAC_DHW = { + OPERATION_MODE_OFF: VICARE_MODE_OFF, + OPERATION_MODE_ON: VICARE_MODE_DHW, +} + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Create the ViCare water_heater devices.""" + if discovery_info is None: + return + vicare_api = hass.data[VICARE_DOMAIN][VICARE_API] + add_entities( + [ViCareWater(f"{hass.data[VICARE_DOMAIN][VICARE_NAME]} Water", vicare_api)] + ) + + +class ViCareWater(WaterHeaterDevice): + """Representation of the ViCare domestic hot water device.""" + + def __init__(self, name, api): + """Initialize the DHW water_heater device.""" + self._name = name + self._state = None + self._api = api + self._target_temperature = None + self._current_temperature = None + self._current_mode = None + + def update(self): + """Let HA know there has been an update from the ViCare API.""" + current_temperature = self._api.getDomesticHotWaterStorageTemperature() + if current_temperature is not None and current_temperature != "error": + self._current_temperature = current_temperature + else: + self._current_temperature = None + + self._target_temperature = self._api.getDomesticHotWaterConfiguredTemperature() + + self._current_mode = self._api.getActiveMode() + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_FLAGS_HEATER + + @property + def name(self): + """Return the name of the water_heater device.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + def set_temperature(self, **kwargs): + """Set new target temperatures.""" + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is not None: + self._api.setDomesticHotWaterTemperature(self._target_temperature) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return VICARE_TEMP_WATER_MIN + + @property + def max_temp(self): + """Return the maximum temperature.""" + return VICARE_TEMP_WATER_MAX + + @property + def precision(self): + """Return the precision of the system.""" + return PRECISION_WHOLE + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return VICARE_TO_HA_HVAC_DHW.get(self._current_mode) + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return list(HA_TO_VICARE_HVAC_DHW) diff --git a/homeassistant/components/vivotek/__init__.py b/homeassistant/components/vivotek/__init__.py new file mode 100644 index 00000000000000..b5220b12a9b68a --- /dev/null +++ b/homeassistant/components/vivotek/__init__.py @@ -0,0 +1 @@ +"""The Vivotek camera component.""" diff --git a/homeassistant/components/vivotek/camera.py b/homeassistant/components/vivotek/camera.py new file mode 100644 index 00000000000000..012c1e1df34755 --- /dev/null +++ b/homeassistant/components/vivotek/camera.py @@ -0,0 +1,125 @@ +"""Support for Vivotek IP Cameras.""" + +import logging + +import voluptuous as vol +from libpyvivotek import VivotekCamera + +from homeassistant.const import ( + CONF_IP_ADDRESS, + CONF_NAME, + CONF_PASSWORD, + CONF_SSL, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.components.camera import PLATFORM_SCHEMA, SUPPORT_STREAM, Camera +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +CONF_FRAMERATE = "framerate" + +DEFAULT_CAMERA_BRAND = "Vivotek" +DEFAULT_NAME = "Vivotek Camera" +DEFAULT_EVENT_0_KEY = "event_i0_enable" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_SSL, default=False): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=True): cv.boolean, + vol.Optional(CONF_FRAMERATE, default=2): cv.positive_int, + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up a Vivotek IP Camera.""" + args = dict( + config=config, + cam=VivotekCamera( + host=config[CONF_IP_ADDRESS], + port=(443 if config[CONF_SSL] else 80), + verify_ssl=config[CONF_VERIFY_SSL], + usr=config[CONF_USERNAME], + pwd=config[CONF_PASSWORD], + ), + stream_source=( + "rtsp://%s:%s@%s:554/live.sdp", + config[CONF_USERNAME], + config[CONF_PASSWORD], + config[CONF_IP_ADDRESS], + ), + ) + add_entities([VivotekCam(**args)], True) + + +class VivotekCam(Camera): + """A Vivotek IP camera.""" + + def __init__(self, config, cam, stream_source): + """Initialize a Vivotek camera.""" + super().__init__() + + self._cam = cam + self._frame_interval = 1 / config[CONF_FRAMERATE] + self._motion_detection_enabled = False + self._model_name = None + self._name = config[CONF_NAME] + self._stream_source = stream_source + + @property + def supported_features(self): + """Return supported features for this camera.""" + return SUPPORT_STREAM + + @property + def frame_interval(self): + """Return the interval between frames of the mjpeg stream.""" + return self._frame_interval + + def camera_image(self): + """Return bytes of camera image.""" + return self._cam.snapshot() + + @property + def name(self): + """Return the name of this device.""" + return self._name + + async def stream_source(self): + """Return the source of the stream.""" + return self._stream_source + + @property + def motion_detection_enabled(self): + """Return the camera motion detection status.""" + return self._motion_detection_enabled + + def disable_motion_detection(self): + """Disable motion detection in camera.""" + response = self._cam.set_param(DEFAULT_EVENT_0_KEY, 0) + self._motion_detection_enabled = int(response) == 1 + + def enable_motion_detection(self): + """Enable motion detection in camera.""" + response = self._cam.set_param(DEFAULT_EVENT_0_KEY, 1) + self._motion_detection_enabled = int(response) == 1 + + @property + def brand(self): + """Return the camera brand.""" + return DEFAULT_CAMERA_BRAND + + @property + def model(self): + """Return the camera model.""" + return self._model_name + + def update(self): + """Update entity status.""" + self._model_name = self._cam.model_name diff --git a/homeassistant/components/vivotek/manifest.json b/homeassistant/components/vivotek/manifest.json new file mode 100644 index 00000000000000..cce2307bc4b554 --- /dev/null +++ b/homeassistant/components/vivotek/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "vivotek", + "name": "Vivotek", + "documentation": "https://www.home-assistant.io/components/vivotek", + "requirements": [ + "libpyvivotek==0.2.2" + ], + "dependencies": [], + "codeowners": [] +} diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index 8bd1952a6507d0..7c13488c3f573e 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -306,7 +306,7 @@ def async_mute_volume(self, mute): def async_set_shuffle(self, shuffle): """Enable/disable shuffle mode.""" return self.send_volumio_msg( - "commands", params={"cmd": "random", "value": str(shuffle)} + "commands", params={"cmd": "random", "value": str(shuffle).lower()} ) def async_select_source(self, source): diff --git a/homeassistant/components/watson_tts/tts.py b/homeassistant/components/watson_tts/tts.py index 0b7228fb568bcf..a30d08f31f3d9e 100644 --- a/homeassistant/components/watson_tts/tts.py +++ b/homeassistant/components/watson_tts/tts.py @@ -99,6 +99,7 @@ def get_engine(hass, config): supported_languages = list({s[:5] for s in SUPPORTED_VOICES}) default_voice = config[CONF_VOICE] output_format = config[CONF_OUTPUT_FORMAT] + service.set_default_headers({"x-watson-learning-opt-out": "true"}) return WatsonTTSProvider(service, supported_languages, default_voice, output_format) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index 0b5696709fd212..1da70bc60ec255 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging from urllib.parse import urlparse -from typing import Dict # noqa: F401 pylint: disable=unused-import +from typing import Dict import voluptuous as vol @@ -36,7 +36,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.script import Script -_CONFIGURING = {} # type: Dict[str, str] +_CONFIGURING: Dict[str, str] = {} _LOGGER = logging.getLogger(__name__) CONF_SOURCES = "sources" diff --git a/homeassistant/components/wemo/config_flow.py b/homeassistant/components/wemo/config_flow.py index a1614eb1ce30c2..21c911a66ce944 100644 --- a/homeassistant/components/wemo/config_flow.py +++ b/homeassistant/components/wemo/config_flow.py @@ -1,14 +1,16 @@ """Config flow for Wemo.""" + +import pywemo + from homeassistant.helpers import config_entry_flow from homeassistant import config_entries + from . import DOMAIN async def _async_has_devices(hass): """Return if there are devices that can be discovered.""" - import pywemo - - return bool(pywemo.discover_devices()) + return bool(await hass.async_add_executor_job(pywemo.discover_devices)) config_entry_flow.register_discovery_flow( diff --git a/homeassistant/components/whois/manifest.json b/homeassistant/components/whois/manifest.json index dec3e78a50362b..6040c8655b9f25 100644 --- a/homeassistant/components/whois/manifest.json +++ b/homeassistant/components/whois/manifest.json @@ -3,7 +3,7 @@ "name": "Whois", "documentation": "https://www.home-assistant.io/components/whois", "requirements": [ - "python-whois==0.7.1" + "python-whois==0.7.2" ], "dependencies": [], "codeowners": [] diff --git a/homeassistant/components/whois/sensor.py b/homeassistant/components/whois/sensor.py index 313a6337a11b5b..09cf40f193fe87 100644 --- a/homeassistant/components/whois/sensor.py +++ b/homeassistant/components/whois/sensor.py @@ -3,6 +3,7 @@ import logging import voluptuous as vol +import whois from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_NAME @@ -32,8 +33,6 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the WHOIS sensor.""" - import whois - domain = config.get(CONF_DOMAIN) name = config.get(CONF_NAME) @@ -41,7 +40,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if "expiration_date" in whois.whois(domain): add_entities([WhoisSensor(name, domain)], True) else: - _LOGGER.error("WHOIS lookup for %s didn't contain expiration_date", domain) + _LOGGER.error( + "WHOIS lookup for %s didn't contain an expiration date", domain + ) return except whois.BaseException as ex: _LOGGER.error("Exception %s occurred during WHOIS lookup for %s", ex, domain) @@ -53,8 +54,6 @@ class WhoisSensor(Entity): def __init__(self, name, domain): """Initialize the sensor.""" - import whois - self.whois = whois.whois self._name = name @@ -95,8 +94,6 @@ def _empty_state_and_attributes(self): def update(self): """Get the current WHOIS data for the domain.""" - import whois - try: response = self.whois(self._domain) except whois.BaseException as ex: diff --git a/homeassistant/components/withings/.translations/ca.json b/homeassistant/components/withings/.translations/ca.json new file mode 100644 index 00000000000000..2f2fdbe9b3f742 --- /dev/null +++ b/homeassistant/components/withings/.translations/ca.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "no_flows": "Necessites configurar Withings abans de poder autenticar't-hi. Llegeix la documentaci\u00f3." + }, + "create_entry": { + "default": "Autenticaci\u00f3 exitosa amb Withings per al perfil seleccionat." + }, + "step": { + "user": { + "data": { + "profile": "Perfil" + }, + "description": "Selecciona un perfil d'usuari amb el qual vols que Home Assistant s'uneixi amb un perfil de Withings. A la p\u00e0gina de Withings, assegura't de seleccionar el mateix usuari o, les dades no seran les correctes.", + "title": "Perfil d'usuari." + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/da.json b/homeassistant/components/withings/.translations/da.json new file mode 100644 index 00000000000000..d2dddbbd204bfb --- /dev/null +++ b/homeassistant/components/withings/.translations/da.json @@ -0,0 +1,17 @@ +{ + "config": { + "create_entry": { + "default": "Godkendt med Withings for den valgte profil." + }, + "step": { + "user": { + "data": { + "profile": "Profil" + }, + "description": "V\u00e6lg en brugerprofil, som du vil have Home Assistant til at tilknytte med en Withings-profil. P\u00e5 siden Withings skal du s\u00f8rge for at v\u00e6lge den samme bruger eller data vil ikke blive m\u00e6rket korrekt.", + "title": "Brugerprofil." + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/de.json b/homeassistant/components/withings/.translations/de.json new file mode 100644 index 00000000000000..15b6f4e3b01f59 --- /dev/null +++ b/homeassistant/components/withings/.translations/de.json @@ -0,0 +1,17 @@ +{ + "config": { + "create_entry": { + "default": "Erfolgreiche Authentifizierung mit Withings f\u00fcr das ausgew\u00e4hlte Profil." + }, + "step": { + "user": { + "data": { + "profile": "Profil" + }, + "description": "W\u00e4hlen Sie ein Benutzerprofil aus, dem Home Assistant ein Withings-Profil zuordnen soll. Stellen Sie sicher, dass Sie auf der Withings-Seite denselben Benutzer ausw\u00e4hlen, da sonst die Daten nicht korrekt gekennzeichnet werden.", + "title": "Benutzerprofil." + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/en.json b/homeassistant/components/withings/.translations/en.json index 2b906dd80030fb..16ce491e776d02 100644 --- a/homeassistant/components/withings/.translations/en.json +++ b/homeassistant/components/withings/.translations/en.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "no_flows": "You need to configure Withings before being able to authenticate with it. Please read the documentation." + }, "create_entry": { "default": "Successfully authenticated with Withings for the selected profile." }, diff --git a/homeassistant/components/withings/.translations/es.json b/homeassistant/components/withings/.translations/es.json new file mode 100644 index 00000000000000..fac325a7097645 --- /dev/null +++ b/homeassistant/components/withings/.translations/es.json @@ -0,0 +1,17 @@ +{ + "config": { + "create_entry": { + "default": "Autenticado correctamente con Withings para el perfil seleccionado." + }, + "step": { + "user": { + "data": { + "profile": "Perfil" + }, + "description": "Seleccione un perfil de usuario para el cual desea que Home Assistant se conecte con el perfil de Withings. En la p\u00e1gina de Withings, aseg\u00farese de seleccionar el mismo usuario o los datos no se identificar\u00e1n correctamente.", + "title": "Perfil de usuario." + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/fr.json b/homeassistant/components/withings/.translations/fr.json new file mode 100644 index 00000000000000..ad715d54eb1df5 --- /dev/null +++ b/homeassistant/components/withings/.translations/fr.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "no_flows": "Vous devez configurer Withings avant de pouvoir vous authentifier avec celui-ci. Veuillez lire la documentation." + }, + "create_entry": { + "default": "Authentifi\u00e9 avec succ\u00e8s \u00e0 Withings pour le profil s\u00e9lectionn\u00e9." + }, + "step": { + "user": { + "data": { + "profile": "Profil" + }, + "description": "S\u00e9lectionnez l'utilisateur que vous souhaitez associer \u00e0 Withings. Sur la page withings, veillez \u00e0 s\u00e9lectionner le m\u00eame utilisateur, sinon les donn\u00e9es ne seront pas \u00e9tiquet\u00e9es correctement.", + "title": "Profil utilisateur" + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/it.json b/homeassistant/components/withings/.translations/it.json new file mode 100644 index 00000000000000..51276869ec605c --- /dev/null +++ b/homeassistant/components/withings/.translations/it.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "no_flows": "\u00c8 necessario configurare Withings prima di potersi autenticare con esso. Si prega di leggere la documentazione." + }, + "create_entry": { + "default": "Autenticazione completata con Withings per il profilo selezionato." + }, + "step": { + "user": { + "data": { + "profile": "Profilo" + }, + "description": "Seleziona un profilo utente a cui desideri associare Home Assistant con un profilo Withings. Nella pagina Withings, assicurati di selezionare lo stesso utente o i dati non saranno etichettati correttamente.", + "title": "Profilo utente." + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/ko.json b/homeassistant/components/withings/.translations/ko.json new file mode 100644 index 00000000000000..617964e0596aba --- /dev/null +++ b/homeassistant/components/withings/.translations/ko.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "no_flows": "Withings \ub97c \uc778\uc99d\ud558\ub824\uba74 \uba3c\uc800 Withings \ub97c \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/withings/) \ub97c \uc77d\uc5b4\ubcf4\uc138\uc694." + }, + "create_entry": { + "default": "\uc120\ud0dd\ud55c \ud504\ub85c\ud544\ub85c Withings \uc5d0 \uc131\uacf5\uc801\uc73c\ub85c \uc778\uc99d\ub418\uc5c8\uc2b5\ub2c8\ub2e4." + }, + "step": { + "user": { + "data": { + "profile": "\ud504\ub85c\ud544" + }, + "description": "Home Assistant \uac00 Withings \ud504\ub85c\ud544\uacfc \ub9f5\ud551\ud560 \uc0ac\uc6a9\uc790 \ud504\ub85c\ud544\uc744 \uc120\ud0dd\ud574\uc8fc\uc138\uc694. Withings \ud398\uc774\uc9c0\uc5d0\uc11c \ub3d9\uc77c\ud55c \uc0ac\uc6a9\uc790\ub97c \uc120\ud0dd\ud574\uc57c\ud569\ub2c8\ub2e4. \uadf8\ub807\uc9c0 \uc54a\uc73c\uba74 \ub370\uc774\ud130\uc5d0 \uc62c\ubc14\ub978 \ub808\uc774\ube14\uc774 \uc9c0\uc815\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4.", + "title": "\uc0ac\uc6a9\uc790 \ud504\ub85c\ud544." + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/lb.json b/homeassistant/components/withings/.translations/lb.json new file mode 100644 index 00000000000000..5ca969f039102b --- /dev/null +++ b/homeassistant/components/withings/.translations/lb.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "no_flows": "Dir musst Withingss konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung k\u00ebnnt benotzen. Liest w.e.g. d'Instruktioune." + }, + "create_entry": { + "default": "Erfollegr\u00e4ich mam ausgewielte Profile mat Withings authentifiz\u00e9iert." + }, + "step": { + "user": { + "data": { + "profile": "Profil" + }, + "description": "Wielt ee Benotzer Profile aus dee mam Withings Profile soll verbonne ginn. Stellt s\u00e9cher dass dir op der Withings S\u00e4it deeselwechte Benotzer auswielt, soss ginn d'Donn\u00e9e net richteg ugewisen.", + "title": "Benotzer Profil." + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/nl.json b/homeassistant/components/withings/.translations/nl.json new file mode 100644 index 00000000000000..3776621bec208e --- /dev/null +++ b/homeassistant/components/withings/.translations/nl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "no_flows": "U moet Withings configureren voordat u zich ermee kunt verifi\u00ebren. [Gelieve de documentatie te lezen]" + }, + "create_entry": { + "default": "Succesvol geverifieerd met Withings voor het geselecteerde profiel." + }, + "step": { + "user": { + "data": { + "profile": "Profiel" + }, + "description": "Selecteer een gebruikersprofiel waaraan u Home Assistant wilt toewijzen met een Withings-profiel. Zorg ervoor dat u op de pagina Withings dezelfde gebruiker selecteert, anders worden de gegevens niet correct gelabeld.", + "title": "Gebruikersprofiel." + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/no.json b/homeassistant/components/withings/.translations/no.json new file mode 100644 index 00000000000000..d32c9640fd7636 --- /dev/null +++ b/homeassistant/components/withings/.translations/no.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "no_flows": "Du m\u00e5 konfigurere Withings f\u00f8r du kan godkjenne med den. Vennligst les dokumentasjonen." + }, + "create_entry": { + "default": "Vellykket autentisering for Withings og den valgte profilen." + }, + "step": { + "user": { + "data": { + "profile": "Profil" + }, + "description": "Velg en brukerprofil som du vil at Home Assistant skal kartlegge med en Withings-profil. P\u00e5 Withings-siden m\u00e5 du passe p\u00e5 at du velger samme bruker ellers vil ikke dataen bli merket riktig.", + "title": "Brukerprofil." + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/pl.json b/homeassistant/components/withings/.translations/pl.json new file mode 100644 index 00000000000000..3c345a1a788bd6 --- /dev/null +++ b/homeassistant/components/withings/.translations/pl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "no_flows": "Musisz skonfigurowa\u0107 Withings, aby m\u00f3c si\u0119 z nim uwierzytelni\u0107. Przeczytaj prosz\u0119 dokumentacj\u0119." + }, + "create_entry": { + "default": "Pomy\u015blnie uwierzytelniono z Withings dla wybranego profilu" + }, + "step": { + "user": { + "data": { + "profile": "Profil" + }, + "description": "Wybierz profil u\u017cytkownika Withings, na kt\u00f3ry chcesz po\u0142\u0105czy\u0107 z Home Assistant'em. Na stronie Withings wybierz ten sam profil u\u017cytkownika by dane by\u0142y poprawnie oznaczone.", + "title": "Profil u\u017cytkownika" + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/ru.json b/homeassistant/components/withings/.translations/ru.json new file mode 100644 index 00000000000000..c6c621fbdf33ee --- /dev/null +++ b/homeassistant/components/withings/.translations/ru.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "no_flows": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0443 Withings \u043f\u0435\u0440\u0435\u0434 \u043f\u0440\u043e\u0445\u043e\u0436\u0434\u0435\u043d\u0438\u0435\u043c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438." + }, + "create_entry": { + "default": "\u0410\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044f \u043f\u0440\u043e\u0439\u0434\u0435\u043d\u0430 \u0443\u0441\u043f\u0435\u0448\u043d\u043e." + }, + "step": { + "user": { + "data": { + "profile": "\u041f\u0440\u043e\u0444\u0438\u043b\u044c" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u043f\u0440\u043e\u0444\u0438\u043b\u044c \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f. \u041d\u0430 \u0441\u0442\u0440\u0430\u043d\u0438\u0446\u0435 Withings \u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e \u0432\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0442\u043e\u0433\u043e \u0436\u0435 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u044f, \u0438\u043d\u0430\u0447\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0431\u0443\u0434\u0443\u0442 \u043f\u043e\u043c\u0435\u0447\u0435\u043d\u044b \u043d\u0435\u043f\u0440\u0430\u0432\u0438\u043b\u044c\u043d\u043e.", + "title": "Withings" + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/sl.json b/homeassistant/components/withings/.translations/sl.json new file mode 100644 index 00000000000000..71934516ea7f1b --- /dev/null +++ b/homeassistant/components/withings/.translations/sl.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "no_flows": "withings morate prvo konfigurirati, preden ga boste lahko uporabili za overitev. Prosimo, preberite dokumentacijo." + }, + "create_entry": { + "default": "Uspe\u0161no overjen z Withings za izbrani profil." + }, + "step": { + "user": { + "data": { + "profile": "Profil" + }, + "description": "Izberite uporabni\u0161ki profil, za katerega \u017eelite, da se Home Assistant prika\u017ee s profilom Withings. Na Withings strani ne pozabite izbrati istega uporabnika sicer podatki ne bodo pravilno ozna\u010deni.", + "title": "Uporabni\u0161ki profil." + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/zh-Hans.json b/homeassistant/components/withings/.translations/zh-Hans.json new file mode 100644 index 00000000000000..c7485b09248ccd --- /dev/null +++ b/homeassistant/components/withings/.translations/zh-Hans.json @@ -0,0 +1,10 @@ +{ + "config": { + "step": { + "user": { + "description": "\u8bf7\u9009\u62e9\u4f60\u60f3\u8981Home Assistant\u548cWithings\u5bf9\u5e94\u7684\u7528\u6237\u914d\u7f6e\u6587\u4ef6\u3002\u5728Withings\u9875\u9762\u4e0a\uff0c\u8bf7\u52a1\u5fc5\u9009\u62e9\u76f8\u540c\u7684\u7528\u6237\uff0c\u5426\u5219\u6570\u636e\u5c06\u65e0\u6cd5\u6b63\u786e\u6807\u8bb0\u3002", + "title": "\u7528\u6237\u8d44\u6599" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/.translations/zh-Hant.json b/homeassistant/components/withings/.translations/zh-Hant.json new file mode 100644 index 00000000000000..9e408eb0d5c19e --- /dev/null +++ b/homeassistant/components/withings/.translations/zh-Hant.json @@ -0,0 +1,20 @@ +{ + "config": { + "abort": { + "no_flows": "\u5fc5\u9808\u5148\u8a2d\u5b9a Withings \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002\u8acb\u53c3\u95b1\u6587\u4ef6\u3002" + }, + "create_entry": { + "default": "\u5df2\u6210\u529f\u4f7f\u7528\u6240\u9078\u8a2d\u5b9a\u8a8d\u8b49 Withings \u88dd\u7f6e\u3002" + }, + "step": { + "user": { + "data": { + "profile": "\u500b\u4eba\u8a2d\u5b9a" + }, + "description": "\u9078\u64c7 Home Assistant \u6240\u8981\u5c0d\u61c9\u4f7f\u7528\u7684 Withings \u500b\u4eba\u8a2d\u5b9a\u3002\u65bc Withings \u9801\u9762\u3001\u78ba\u5b9a\u9078\u53d6\u76f8\u540c\u7684\u4f7f\u7528\u8005\uff0c\u5426\u5247\u8cc7\u6599\u5c07\u7121\u6cd5\u6b63\u78ba\u6a19\u793a\u3002", + "title": "\u500b\u4eba\u8a2d\u5b9a\u3002" + } + }, + "title": "Withings" + } +} \ No newline at end of file diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index 23cc74281e8b9b..f28a4f59d80195 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -88,7 +88,10 @@ async def async_step_import(self, user_input=None): async def async_step_user(self, user_input=None): """Create an entry for selecting a profile.""" - flow = self.hass.data.get(DATA_FLOW_IMPL, {}) + flow = self.hass.data.get(DATA_FLOW_IMPL) + + if not flow: + return self.async_abort(reason="no_flows") if user_input: return await self.async_step_auth(user_input) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 88b8e6d5ea0944..1a99abc7255651 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -12,6 +12,9 @@ }, "create_entry": { "default": "Successfully authenticated with Withings for the selected profile." + }, + "abort": { + "no_flows": "You need to configure Withings before being able to authenticate with it. Please read the documentation." } } -} \ No newline at end of file +} diff --git a/homeassistant/components/wwlln/.translations/it.json b/homeassistant/components/wwlln/.translations/it.json new file mode 100644 index 00000000000000..f0fc32636072fa --- /dev/null +++ b/homeassistant/components/wwlln/.translations/it.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "identifier_exists": "Localit\u00e0 gi\u00e0 registrata" + }, + "step": { + "user": { + "data": { + "latitude": "Latitudine", + "longitude": "Longitudine", + "radius": "Raggio (utilizzando il tuo sistema di unit\u00e0 di misura di base)" + }, + "title": "Inserisci le informazioni sulla tua posizione." + } + }, + "title": "Rete mondiale di localizzazione dei fulmini (WWLLN)" + } +} \ No newline at end of file diff --git a/homeassistant/components/wwlln/.translations/ko.json b/homeassistant/components/wwlln/.translations/ko.json index 5e879cd7330311..e5831f5af29224 100644 --- a/homeassistant/components/wwlln/.translations/ko.json +++ b/homeassistant/components/wwlln/.translations/ko.json @@ -10,7 +10,7 @@ "longitude": "\uacbd\ub3c4", "radius": "\ubc18\uacbd (\uae30\ubcf8 \ub2e8\uc704 \uc2dc\uc2a4\ud15c \uc0ac\uc6a9)" }, - "title": "\uc704\uce58 \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694" + "title": "\uc704\uce58 \uc815\ubcf4\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694." } }, "title": "\uc138\uacc4 \ub099\ub8b0 \uc704\uce58\ub9dd (WWLLN)" diff --git a/homeassistant/components/wwlln/.translations/pl.json b/homeassistant/components/wwlln/.translations/pl.json index 704c7baeecb3c2..652d580644fce6 100644 --- a/homeassistant/components/wwlln/.translations/pl.json +++ b/homeassistant/components/wwlln/.translations/pl.json @@ -10,7 +10,7 @@ "longitude": "D\u0142ugo\u015b\u0107 geograficzna", "radius": "Promie\u0144 (przy u\u017cyciu systemu jednostki bazowej)" }, - "title": "Wpisz informacje o swojej lokalizacji." + "title": "Wprowad\u017a informacje o lokalizacji." } }, "title": "\u015awiatowa sie\u0107 lokalizacji wy\u0142adowa\u0144 atmosferycznych (WWLLN)" diff --git a/homeassistant/components/wwlln/manifest.json b/homeassistant/components/wwlln/manifest.json index ef9295341c065b..189b9365105159 100644 --- a/homeassistant/components/wwlln/manifest.json +++ b/homeassistant/components/wwlln/manifest.json @@ -4,7 +4,7 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/components/wwlln", "requirements": [ - "aiowwlln==1.0.0" + "aiowwlln==2.0.2" ], "dependencies": [], "codeowners": [ diff --git a/homeassistant/components/yandex_transport/__init__.py b/homeassistant/components/yandex_transport/__init__.py new file mode 100644 index 00000000000000..d007b2d3df8581 --- /dev/null +++ b/homeassistant/components/yandex_transport/__init__.py @@ -0,0 +1 @@ +"""Service for obtaining information about closer bus from Transport Yandex Service.""" diff --git a/homeassistant/components/yandex_transport/manifest.json b/homeassistant/components/yandex_transport/manifest.json new file mode 100644 index 00000000000000..6c633f848c0c68 --- /dev/null +++ b/homeassistant/components/yandex_transport/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "yandex_transport", + "name": "Yandex Transport", + "documentation": "https://www.home-assistant.io/components/yandex_transport", + "requirements": [ + "ya_ma==0.3.7" + ], + "dependencies": [], + "codeowners": [ + "@rishatik92" + ] +} diff --git a/homeassistant/components/yandex_transport/sensor.py b/homeassistant/components/yandex_transport/sensor.py new file mode 100644 index 00000000000000..340291807ead98 --- /dev/null +++ b/homeassistant/components/yandex_transport/sensor.py @@ -0,0 +1,128 @@ +# -*- coding: utf-8 -*- +"""Service for obtaining information about closer bus from Transport Yandex Service.""" + +import logging +from datetime import timedelta + +import voluptuous as vol +from ya_ma import YandexMapsRequester + +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME, ATTR_ATTRIBUTION, DEVICE_CLASS_TIMESTAMP +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +STOP_NAME = "stop_name" +USER_AGENT = "Home Assistant" +ATTRIBUTION = "Data provided by maps.yandex.ru" + +CONF_STOP_ID = "stop_id" +CONF_ROUTE = "routes" + +DEFAULT_NAME = "Yandex Transport" +ICON = "mdi:bus" + +SCAN_INTERVAL = timedelta(minutes=1) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + { + vol.Required(CONF_STOP_ID): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_ROUTE, default=[]): vol.All(cv.ensure_list, [cv.string]), + } +) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Yandex transport sensor.""" + stop_id = config[CONF_STOP_ID] + name = config[CONF_NAME] + routes = config[CONF_ROUTE] + + data = YandexMapsRequester(user_agent=USER_AGENT) + add_entities([DiscoverMoscowYandexTransport(data, stop_id, routes, name)], True) + + +class DiscoverMoscowYandexTransport(Entity): + """Implementation of yandex_transport sensor.""" + + def __init__(self, requester, stop_id, routes, name): + """Initialize sensor.""" + self.requester = requester + self._stop_id = stop_id + self._routes = [] + self._routes = routes + self._state = None + self._name = name + self._attrs = None + + def update(self): + """Get the latest data from maps.yandex.ru and update the states.""" + attrs = {} + closer_time = None + try: + yandex_reply = self.requester.get_stop_info(self._stop_id) + data = yandex_reply["data"] + stop_metadata = data["properties"]["StopMetaData"] + except KeyError as key_error: + _LOGGER.warning( + "Exception KeyError was captured, missing key is %s. Yandex returned: %s", + key_error, + yandex_reply, + ) + self.requester.set_new_session() + data = self.requester.get_stop_info(self._stop_id)["data"] + stop_metadata = data["properties"]["StopMetaData"] + stop_name = data["properties"]["name"] + transport_list = stop_metadata["Transport"] + for transport in transport_list: + route = transport["name"] + if self._routes and route not in self._routes: + # skip unnecessary route info + continue + if "Events" in transport["BriefSchedule"]: + for event in transport["BriefSchedule"]["Events"]: + if "Estimated" in event: + posix_time_next = int(event["Estimated"]["value"]) + if closer_time is None or closer_time > posix_time_next: + closer_time = posix_time_next + if route not in attrs: + attrs[route] = [] + attrs[route].append(event["Estimated"]["text"]) + attrs[STOP_NAME] = stop_name + attrs[ATTR_ATTRIBUTION] = ATTRIBUTION + if closer_time is None: + self._state = None + else: + self._state = dt_util.utc_from_timestamp(closer_time).isoformat( + timespec="seconds" + ) + self._attrs = attrs + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_class(self): + """Return the device class.""" + return DEVICE_CLASS_TIMESTAMP + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attrs + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON diff --git a/homeassistant/components/yeelight/__init__.py b/homeassistant/components/yeelight/__init__.py index 431c34aa06ef80..c899c811a47d35 100644 --- a/homeassistant/components/yeelight/__init__.py +++ b/homeassistant/components/yeelight/__init__.py @@ -37,6 +37,7 @@ CONF_MODE_MUSIC = "use_music_mode" CONF_FLOW_PARAMS = "flow_params" CONF_CUSTOM_EFFECTS = "custom_effects" +CONF_NIGHTLIGHT_SWITCH_TYPE = "nightlight_switch_type" ATTR_COUNT = "count" ATTR_ACTION = "action" @@ -48,6 +49,8 @@ ACTIVE_MODE_NIGHTLIGHT = "1" +NIGHTLIGHT_SWITCH_TYPE_LIGHT = "light" + SCAN_INTERVAL = timedelta(seconds=30) YEELIGHT_RGB_TRANSITION = "RGBTransition" @@ -84,6 +87,9 @@ vol.Optional(CONF_TRANSITION, default=DEFAULT_TRANSITION): cv.positive_int, vol.Optional(CONF_MODE_MUSIC, default=False): cv.boolean, vol.Optional(CONF_SAVE_ON_CHANGE, default=False): cv.boolean, + vol.Optional(CONF_NIGHTLIGHT_SWITCH_TYPE): vol.Any( + NIGHTLIGHT_SWITCH_TYPE_LIGHT + ), vol.Optional(CONF_MODEL): cv.string, } ) @@ -256,10 +262,12 @@ def type(self): return self._device_type - def turn_on(self, duration=DEFAULT_TRANSITION, light_type=None): + def turn_on(self, duration=DEFAULT_TRANSITION, light_type=None, power_mode=None): """Turn on device.""" try: - self.bulb.turn_on(duration=duration, light_type=light_type) + self.bulb.turn_on( + duration=duration, light_type=light_type, power_mode=power_mode + ) except BulbException as ex: _LOGGER.error("Unable to turn the bulb on: %s", ex) diff --git a/homeassistant/components/yeelight/light.py b/homeassistant/components/yeelight/light.py index 8601e0e16322c1..b47cdb981612e4 100644 --- a/homeassistant/components/yeelight/light.py +++ b/homeassistant/components/yeelight/light.py @@ -3,9 +3,10 @@ import voluptuous as vol from yeelight import RGBTransition, SleepTransition, Flow, BulbException -from yeelight.enums import PowerMode, LightType, BulbType +from yeelight.enums import PowerMode, LightType, BulbType, SceneClass from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service import extract_entity_ids +import homeassistant.helpers.config_validation as cv from homeassistant.util.color import ( color_temperature_mired_to_kelvin as mired_to_kelvin, color_temperature_kelvin_to_mired as kelvin_to_mired, @@ -28,6 +29,8 @@ SUPPORT_FLASH, SUPPORT_EFFECT, Light, + ATTR_RGB_COLOR, + ATTR_KELVIN, ) import homeassistant.util.color as color_util from . import ( @@ -45,10 +48,14 @@ CONF_FLOW_PARAMS, ATTR_ACTION, ATTR_COUNT, + NIGHTLIGHT_SWITCH_TYPE_LIGHT, + CONF_NIGHTLIGHT_SWITCH_TYPE, ) _LOGGER = logging.getLogger(__name__) +PLATFORM_DATA_KEY = f"{DATA_YEELIGHT}_lights" + SUPPORT_YEELIGHT = ( SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION | SUPPORT_FLASH | SUPPORT_EFFECT ) @@ -58,9 +65,15 @@ SUPPORT_YEELIGHT_RGB = SUPPORT_YEELIGHT_WHITE_TEMP | SUPPORT_COLOR ATTR_MODE = "mode" +ATTR_MINUTES = "minutes" SERVICE_SET_MODE = "set_mode" SERVICE_START_FLOW = "start_flow" +SERVICE_SET_COLOR_SCENE = "set_color_scene" +SERVICE_SET_HSV_SCENE = "set_hsv_scene" +SERVICE_SET_COLOR_TEMP_SCENE = "set_color_temp_scene" +SERVICE_SET_COLOR_FLOW_SCENE = "set_color_flow_scene" +SERVICE_SET_AUTO_DELAY_OFF_SCENE = "set_auto_delay_off_scene" EFFECT_DISCO = "Disco" EFFECT_TEMP = "Slow Temp" @@ -121,6 +134,60 @@ "ceiling4": BulbType.WhiteTempMood, } +VALID_BRIGHTNESS = vol.All(vol.Coerce(int), vol.Range(min=1, max=100)) + +SERVICE_SCHEMA_SET_MODE = YEELIGHT_SERVICE_SCHEMA.extend( + {vol.Required(ATTR_MODE): vol.In([mode.name.lower() for mode in PowerMode])} +) + +SERVICE_SCHEMA_START_FLOW = YEELIGHT_SERVICE_SCHEMA.extend( + YEELIGHT_FLOW_TRANSITION_SCHEMA +) + +SERVICE_SCHEMA_SET_COLOR_SCENE = YEELIGHT_SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_RGB_COLOR): vol.All( + vol.ExactSequence((cv.byte, cv.byte, cv.byte)), vol.Coerce(tuple) + ), + vol.Required(ATTR_BRIGHTNESS): VALID_BRIGHTNESS, + } +) + +SERVICE_SCHEMA_SET_HSV_SCENE = YEELIGHT_SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_HS_COLOR): vol.All( + vol.ExactSequence( + ( + vol.All(vol.Coerce(float), vol.Range(min=0, max=359)), + vol.All(vol.Coerce(float), vol.Range(min=0, max=100)), + ) + ), + vol.Coerce(tuple), + ), + vol.Required(ATTR_BRIGHTNESS): VALID_BRIGHTNESS, + } +) + +SERVICE_SCHEMA_SET_COLOR_TEMP_SCENE = YEELIGHT_SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_KELVIN): vol.All( + vol.Coerce(int), vol.Range(min=1700, max=6500) + ), + vol.Required(ATTR_BRIGHTNESS): VALID_BRIGHTNESS, + } +) + +SERVICE_SCHEMA_SET_COLOR_FLOW_SCENE = YEELIGHT_SERVICE_SCHEMA.extend( + YEELIGHT_FLOW_TRANSITION_SCHEMA +) + +SERVICE_SCHEMA_SET_AUTO_DELAY_OFF = YEELIGHT_SERVICE_SCHEMA.extend( + { + vol.Required(ATTR_MINUTES): vol.All(vol.Coerce(int), vol.Range(min=1, max=60)), + vol.Required(ATTR_BRIGHTNESS): VALID_BRIGHTNESS, + } +) + def _transitions_config_parser(transitions): """Parse transitions config into initialized objects.""" @@ -165,18 +232,20 @@ def _wrap(self, *args, **kwargs): def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Yeelight bulbs.""" - data_key = f"{DATA_YEELIGHT}_lights" if not discovery_info: return - if data_key not in hass.data: - hass.data[data_key] = [] + if PLATFORM_DATA_KEY not in hass.data: + hass.data[PLATFORM_DATA_KEY] = [] device = hass.data[DATA_YEELIGHT][discovery_info[CONF_HOST]] _LOGGER.debug("Adding %s", device.name) custom_effects = _parse_custom_effects(discovery_info[CONF_CUSTOM_EFFECTS]) + nl_switch_light = ( + discovery_info.get(CONF_NIGHTLIGHT_SWITCH_TYPE) == NIGHTLIGHT_SWITCH_TYPE_LIGHT + ) lights = [] @@ -193,9 +262,17 @@ def _lights_setup_helper(klass): elif device_type == BulbType.Color: _lights_setup_helper(YeelightColorLight) elif device_type == BulbType.WhiteTemp: - _lights_setup_helper(YeelightWhiteTempLight) + if nl_switch_light and device.is_nightlight_supported: + _lights_setup_helper(YeelightWithNightLight) + _lights_setup_helper(YeelightNightLightMode) + else: + _lights_setup_helper(YeelightWhiteTempWithoutNightlightSwitch) elif device_type == BulbType.WhiteTempMood: - _lights_setup_helper(YeelightWithAmbientLight) + if nl_switch_light and device.is_nightlight_supported: + _lights_setup_helper(YeelightNightLightMode) + _lights_setup_helper(YeelightWithAmbientAndNightlight) + else: + _lights_setup_helper(YeelightWithAmbientWithoutNightlight) _lights_setup_helper(YeelightAmbientLight) else: _lights_setup_helper(YeelightGenericLight) @@ -205,41 +282,120 @@ def _lights_setup_helper(klass): device.name, ) - hass.data[data_key] += lights + hass.data[PLATFORM_DATA_KEY] += lights add_entities(lights, True) + setup_services(hass) - def service_handler(service): - """Dispatch service calls to target entities.""" - params = { - key: value for key, value in service.data.items() if key != ATTR_ENTITY_ID - } - entity_ids = extract_entity_ids(hass, service) - target_devices = [ - light for light in hass.data[data_key] if light.entity_id in entity_ids - ] - - for target_device in target_devices: - if service.service == SERVICE_SET_MODE: - target_device.set_mode(**params) - elif service.service == SERVICE_START_FLOW: - params[ATTR_TRANSITIONS] = _transitions_config_parser( - params[ATTR_TRANSITIONS] - ) - target_device.start_flow(**params) +def setup_services(hass): + """Set up the service listeners.""" + + def service_call(func): + def service_to_entities(service): + """Return the known entities that a service call mentions.""" + + entity_ids = extract_entity_ids(hass, service) + target_devices = [ + light + for light in hass.data[PLATFORM_DATA_KEY] + if light.entity_id in entity_ids + ] + + return target_devices - service_schema_set_mode = YEELIGHT_SERVICE_SCHEMA.extend( - {vol.Required(ATTR_MODE): vol.In([mode.name.lower() for mode in PowerMode])} + def service_to_params(service): + """Return service call params, without entity_id.""" + return { + key: value + for key, value in service.data.items() + if key != ATTR_ENTITY_ID + } + + def wrapper(service): + params = service_to_params(service) + target_devices = service_to_entities(service) + for device in target_devices: + func(device, params) + + return wrapper + + @service_call + def service_set_mode(target_device, params): + target_device.set_mode(**params) + + @service_call + def service_start_flow(target_devices, params): + params[ATTR_TRANSITIONS] = _transitions_config_parser(params[ATTR_TRANSITIONS]) + target_devices.start_flow(**params) + + @service_call + def service_set_color_scene(target_device, params): + target_device.set_scene( + SceneClass.COLOR, *[*params[ATTR_RGB_COLOR], params[ATTR_BRIGHTNESS]] + ) + + @service_call + def service_set_hsv_scene(target_device, params): + target_device.set_scene( + SceneClass.HSV, *[*params[ATTR_HS_COLOR], params[ATTR_BRIGHTNESS]] + ) + + @service_call + def service_set_color_temp_scene(target_device, params): + target_device.set_scene( + SceneClass.CT, params[ATTR_KELVIN], params[ATTR_BRIGHTNESS] + ) + + @service_call + def service_set_color_flow_scene(target_device, params): + flow = Flow( + count=params[ATTR_COUNT], + action=Flow.actions[params[ATTR_ACTION]], + transitions=_transitions_config_parser(params[ATTR_TRANSITIONS]), + ) + target_device.set_scene(SceneClass.CF, flow) + + @service_call + def service_set_auto_delay_off_scene(target_device, params): + target_device.set_scene( + SceneClass.AUTO_DELAY_OFF, params[ATTR_BRIGHTNESS], params[ATTR_MINUTES] + ) + + hass.services.register( + DOMAIN, SERVICE_SET_MODE, service_set_mode, schema=SERVICE_SCHEMA_SET_MODE ) hass.services.register( - DOMAIN, SERVICE_SET_MODE, service_handler, schema=service_schema_set_mode + DOMAIN, SERVICE_START_FLOW, service_start_flow, schema=SERVICE_SCHEMA_START_FLOW ) - - service_schema_start_flow = YEELIGHT_SERVICE_SCHEMA.extend( - YEELIGHT_FLOW_TRANSITION_SCHEMA + hass.services.register( + DOMAIN, + SERVICE_SET_COLOR_SCENE, + service_set_color_scene, + schema=SERVICE_SCHEMA_SET_COLOR_SCENE, + ) + hass.services.register( + DOMAIN, + SERVICE_SET_HSV_SCENE, + service_set_hsv_scene, + schema=SERVICE_SCHEMA_SET_HSV_SCENE, + ) + hass.services.register( + DOMAIN, + SERVICE_SET_COLOR_TEMP_SCENE, + service_set_color_temp_scene, + schema=SERVICE_SCHEMA_SET_COLOR_TEMP_SCENE, + ) + hass.services.register( + DOMAIN, + SERVICE_SET_COLOR_FLOW_SCENE, + service_set_color_flow_scene, + schema=SERVICE_SCHEMA_SET_COLOR_FLOW_SCENE, ) hass.services.register( - DOMAIN, SERVICE_START_FLOW, service_handler, schema=service_schema_start_flow + DOMAIN, + SERVICE_SET_AUTO_DELAY_OFF_SCENE, + service_set_auto_delay_off_scene, + schema=SERVICE_SCHEMA_SET_AUTO_DELAY_OFF, ) @@ -376,6 +532,10 @@ def _brightness_property(self): def _power_property(self): return "power" + @property + def _turn_on_power_mode(self): + return PowerMode.LAST + @property def _predefined_effects(self): return YEELIGHT_MONO_EFFECT_LIST @@ -559,7 +719,11 @@ def turn_on(self, **kwargs) -> None: if ATTR_TRANSITION in kwargs: # passed kwarg overrides config duration = int(kwargs.get(ATTR_TRANSITION) * 1000) # kwarg in s - self.device.turn_on(duration=duration, light_type=self.light_type) + self.device.turn_on( + duration=duration, + light_type=self.light_type, + power_mode=self._turn_on_power_mode, + ) if self.config[CONF_MODE_MUSIC] and not self._bulb.music_mode: try: @@ -618,6 +782,18 @@ def start_flow(self, transitions, count=0, action=ACTION_RECOVER): except BulbException as ex: _LOGGER.error("Unable to set effect: %s", ex) + def set_scene(self, scene_class, *args): + """ + Set the light directly to the specified state. + + If the light is off, it will first be turned on. + """ + try: + self._bulb.set_scene(scene_class, *args) + self.device.update() + except BulbException as ex: + _LOGGER.error("Unable to set scene: %s", ex) + class YeelightColorLight(YeelightGenericLight): """Representation of a Color Yeelight light.""" @@ -632,7 +808,7 @@ def _predefined_effects(self): return YEELIGHT_COLOR_EFFECT_LIST -class YeelightWhiteTempLight(YeelightGenericLight): +class YeelightWhiteTempLightsupport: """Representation of a Color Yeelight light.""" @property @@ -640,17 +816,84 @@ def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_YEELIGHT_WHITE_TEMP + @property + def _predefined_effects(self): + return YEELIGHT_TEMP_ONLY_EFFECT_LIST + + +class YeelightWhiteTempWithoutNightlightSwitch( + YeelightWhiteTempLightsupport, YeelightGenericLight +): + """White temp light, when nightlight switch is not set to light.""" + @property def _brightness_property(self): return "current_brightness" + +class YeelightWithNightLight(YeelightWhiteTempLightsupport, YeelightGenericLight): + """Representation of a Yeelight with nightlight support. + + It represents case when nightlight switch is set to light. + """ + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return super().is_on and not self.device.is_nightlight_enabled + + @property + def _turn_on_power_mode(self): + return PowerMode.NORMAL + + +class YeelightNightLightMode(YeelightGenericLight): + """Representation of a Yeelight when in nightlight mode.""" + + @property + def name(self) -> str: + """Return the name of the device if any.""" + return f"{self.device.name} nightlight" + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return "mdi:weather-night" + + @property + def is_on(self) -> bool: + """Return true if device is on.""" + return super().is_on and self.device.is_nightlight_enabled + + @property + def _brightness_property(self): + return "nl_br" + + @property + def _turn_on_power_mode(self): + return PowerMode.MOONLIGHT + @property def _predefined_effects(self): return YEELIGHT_TEMP_ONLY_EFFECT_LIST -class YeelightWithAmbientLight(YeelightWhiteTempLight): - """Representation of a Yeelight which has ambilight support.""" +class YeelightWithAmbientWithoutNightlight(YeelightWhiteTempWithoutNightlightSwitch): + """Representation of a Yeelight which has ambilight support. + + And nightlight switch type is none. + """ + + @property + def _power_property(self): + return "main_power" + + +class YeelightWithAmbientAndNightlight(YeelightWithNightLight): + """Representation of a Yeelight which has ambilight support. + + And nightlight switch type is set to light. + """ @property def _power_property(self): diff --git a/homeassistant/components/yeelight/services.yaml b/homeassistant/components/yeelight/services.yaml index 14dcfb27a4d54a..52106a42063545 100644 --- a/homeassistant/components/yeelight/services.yaml +++ b/homeassistant/components/yeelight/services.yaml @@ -7,7 +7,69 @@ set_mode: mode: description: Operation mode. Valid values are 'last', 'normal', 'rgb', 'hsv', 'color_flow', 'moonlight'. example: 'moonlight' - +set_color_scene: + description: Changes the light to the specified RGB color and brightness. If the light is off, it will be turned on. + fields: + entity_id: + description: Name of the light entity. + example: 'light.yeelight' + rgb_color: + description: Color for the light in RGB-format. + example: '[255, 100, 100]' + brightness: + description: The brightness value to set (1-100). + example: 50 +set_hsv_scene: + description: Changes the light to the specified HSV color and brightness. If the light is off, it will be turned on. + fields: + entity_id: + description: Name of the light entity. + example: 'light.yeelight' + hs_color: + description: Color for the light in hue/sat format. Hue is 0-359 and Sat is 0-100. + example: '[300, 70]' + brightness: + description: The brightness value to set (1-100). + example: 50 +set_color_temp_scene: + description: Changes the light to the specified color temperature. If the light is off, it will be turned on. + fields: + entity_id: + description: Name of the light entity. + example: 'light.yeelight' + kelvin: + description: Color temperature for the light in Kelvin. + example: 4000 + brightness: + description: The brightness value to set (1-100). + example: 50 +set_color_flow_scene: + description: starts a color flow. If the light is off, it will be turned on. + fields: + entity_id: + description: Name of the light entity. + example: 'light.yeelight' + count: + description: The number of times to run this flow (0 to run forever). + example: 0 + action: + description: The action to take after the flow stops. Can be 'recover', 'stay', 'off'. (default 'recover') + example: 'stay' + transitions: + description: Array of transitions, for desired effect. Examples https://yeelight.readthedocs.io/en/stable/flow.html + example: '[{ "TemperatureTransition": [1900, 1000, 80] }, { "TemperatureTransition": [1900, 1000, 10] }]' +set_auto_delay_off_scene: + description: Turns the light on to the specified brightness and sets a timer to turn it back off after the given number of minutes. If the light is off, Set a color scene, if light is off, it will be turned on. + fields: + entity_id: + description: Name of the light entity. + example: 'light.yeelight' + minutes: + description: The minutes to wait before automatically turning the light off. + example: 5 + brightness: + description: The brightness value to set (1-100). + example: 50 start_flow: description: Start a custom flow, using transitions from https://yeelight.readthedocs.io/en/stable/yeelight.html#flow-objects fields: diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index be079e83fa6bb0..ff9f27d4843c20 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -19,9 +19,16 @@ ATTR_COMMAND, ATTR_COMMAND_TYPE, ATTR_ENDPOINT_ID, + ATTR_LEVEL, ATTR_MANUFACTURER, ATTR_NAME, ATTR_VALUE, + ATTR_WARNING_DEVICE_DURATION, + ATTR_WARNING_DEVICE_MODE, + ATTR_WARNING_DEVICE_STROBE, + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, + ATTR_WARNING_DEVICE_STROBE_INTENSITY, + CHANNEL_IAS_WD, CLUSTER_COMMAND_SERVER, CLUSTER_COMMANDS_CLIENT, CLUSTER_COMMANDS_SERVER, @@ -31,6 +38,11 @@ DATA_ZHA_GATEWAY, DOMAIN, MFG_CLUSTER_ID_START, + WARNING_DEVICE_MODE_EMERGENCY, + WARNING_DEVICE_SOUND_HIGH, + WARNING_DEVICE_SQUAWK_MODE_ARMED, + WARNING_DEVICE_STROBE_HIGH, + WARNING_DEVICE_STROBE_YES, ) from .core.helpers import async_is_bindable_target, convert_ieee, get_matched_clusters @@ -56,6 +68,8 @@ SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = "issue_zigbee_cluster_command" SERVICE_DIRECT_ZIGBEE_BIND = "issue_direct_zigbee_bind" SERVICE_DIRECT_ZIGBEE_UNBIND = "issue_direct_zigbee_unbind" +SERVICE_WARNING_DEVICE_SQUAWK = "warning_device_squawk" +SERVICE_WARNING_DEVICE_WARN = "warning_device_warn" SERVICE_ZIGBEE_BIND = "service_zigbee_bind" IEEE_SERVICE = "ieee_based_service" @@ -80,6 +94,41 @@ vol.Optional(ATTR_MANUFACTURER): cv.positive_int, } ), + SERVICE_WARNING_DEVICE_SQUAWK: vol.Schema( + { + vol.Required(ATTR_IEEE): convert_ieee, + vol.Optional( + ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_SQUAWK_MODE_ARMED + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ): cv.positive_int, + vol.Optional( + ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ): cv.positive_int, + } + ), + SERVICE_WARNING_DEVICE_WARN: vol.Schema( + { + vol.Required(ATTR_IEEE): convert_ieee, + vol.Optional( + ATTR_WARNING_DEVICE_MODE, default=WARNING_DEVICE_MODE_EMERGENCY + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE, default=WARNING_DEVICE_STROBE_YES + ): cv.positive_int, + vol.Optional( + ATTR_LEVEL, default=WARNING_DEVICE_SOUND_HIGH + ): cv.positive_int, + vol.Optional(ATTR_WARNING_DEVICE_DURATION, default=5): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE, default=0x00 + ): cv.positive_int, + vol.Optional( + ATTR_WARNING_DEVICE_STROBE_INTENSITY, default=WARNING_DEVICE_STROBE_HIGH + ): cv.positive_int, + } + ), SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND: vol.Schema( { vol.Required(ATTR_IEEE): convert_ieee, @@ -610,6 +659,85 @@ async def issue_zigbee_cluster_command(service): schema=SERVICE_SCHEMAS[SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND], ) + async def warning_device_squawk(service): + """Issue the squawk command for an IAS warning device.""" + ieee = service.data[ATTR_IEEE] + mode = service.data.get(ATTR_WARNING_DEVICE_MODE) + strobe = service.data.get(ATTR_WARNING_DEVICE_STROBE) + level = service.data.get(ATTR_LEVEL) + + zha_device = zha_gateway.get_device(ieee) + if zha_device is not None: + channel = zha_device.cluster_channels.get(CHANNEL_IAS_WD) + if channel: + await channel.squawk(mode, strobe, level) + else: + _LOGGER.error( + "Squawking IASWD: %s is missing the required IASWD channel!", + "{}: [{}]".format(ATTR_IEEE, str(ieee)), + ) + else: + _LOGGER.error( + "Squawking IASWD: %s could not be found!", + "{}: [{}]".format(ATTR_IEEE, str(ieee)), + ) + _LOGGER.debug( + "Squawking IASWD: %s %s %s %s", + "{}: [{}]".format(ATTR_IEEE, str(ieee)), + "{}: [{}]".format(ATTR_WARNING_DEVICE_MODE, mode), + "{}: [{}]".format(ATTR_WARNING_DEVICE_STROBE, strobe), + "{}: [{}]".format(ATTR_LEVEL, level), + ) + + hass.helpers.service.async_register_admin_service( + DOMAIN, + SERVICE_WARNING_DEVICE_SQUAWK, + warning_device_squawk, + schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_SQUAWK], + ) + + async def warning_device_warn(service): + """Issue the warning command for an IAS warning device.""" + ieee = service.data[ATTR_IEEE] + mode = service.data.get(ATTR_WARNING_DEVICE_MODE) + strobe = service.data.get(ATTR_WARNING_DEVICE_STROBE) + level = service.data.get(ATTR_LEVEL) + duration = service.data.get(ATTR_WARNING_DEVICE_DURATION) + duty_mode = service.data.get(ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE) + intensity = service.data.get(ATTR_WARNING_DEVICE_STROBE_INTENSITY) + + zha_device = zha_gateway.get_device(ieee) + if zha_device is not None: + channel = zha_device.cluster_channels.get(CHANNEL_IAS_WD) + if channel: + await channel.start_warning( + mode, strobe, level, duration, duty_mode, intensity + ) + else: + _LOGGER.error( + "Warning IASWD: %s is missing the required IASWD channel!", + "{}: [{}]".format(ATTR_IEEE, str(ieee)), + ) + else: + _LOGGER.error( + "Warning IASWD: %s could not be found!", + "{}: [{}]".format(ATTR_IEEE, str(ieee)), + ) + _LOGGER.debug( + "Warning IASWD: %s %s %s %s", + "{}: [{}]".format(ATTR_IEEE, str(ieee)), + "{}: [{}]".format(ATTR_WARNING_DEVICE_MODE, mode), + "{}: [{}]".format(ATTR_WARNING_DEVICE_STROBE, strobe), + "{}: [{}]".format(ATTR_LEVEL, level), + ) + + hass.helpers.service.async_register_admin_service( + DOMAIN, + SERVICE_WARNING_DEVICE_WARN, + warning_device_warn, + schema=SERVICE_SCHEMAS[SERVICE_WARNING_DEVICE_WARN], + ) + websocket_api.async_register_command(hass, websocket_permit_devices) websocket_api.async_register_command(hass, websocket_get_devices) websocket_api.async_register_command(hass, websocket_get_device) @@ -629,3 +757,5 @@ def async_unload_api(hass): hass.services.async_remove(DOMAIN, SERVICE_REMOVE) hass.services.async_remove(DOMAIN, SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE) hass.services.async_remove(DOMAIN, SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND) + hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_SQUAWK) + hass.services.async_remove(DOMAIN, SERVICE_WARNING_DEVICE_WARN) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 9e3b69a80df07c..aed12bc65a5495 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -202,7 +202,7 @@ async def async_configure(self): # Xiaomi devices don't need this and it disrupts pairing if self._zha_device.manufacturer != "LUMI": await self.bind() - if self.cluster.cluster_id not in self.cluster.endpoint.out_clusters: + if self.cluster.is_server: for report_config in self._report_config: await self.configure_reporting( report_config["attr"], report_config["config"] diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py index 6ed9de9b30313c..e15acdaf5e31bd 100644 --- a/homeassistant/components/zha/core/channels/manufacturerspecific.py +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -6,9 +6,17 @@ """ import logging -from . import AttributeListeningChannel +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from . import AttributeListeningChannel, ZigbeeChannel from .. import registries -from ..const import REPORT_CONFIG_ASAP, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT +from ..const import ( + REPORT_CONFIG_ASAP, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_MIN_INT, + SIGNAL_ATTR_UPDATED, +) _LOGGER = logging.getLogger(__name__) @@ -26,6 +34,14 @@ class SmartThingsHumidity(AttributeListeningChannel): ] +@registries.CHANNEL_ONLY_CLUSTERS.register(0xFD00) +@registries.ZIGBEE_CHANNEL_REGISTRY.register(0xFD00) +class OsramButton(ZigbeeChannel): + """Osram button channel.""" + + REPORT_CONFIG = [] + + @registries.ZIGBEE_CHANNEL_REGISTRY.register( registries.SMARTTHINGS_ACCELERATION_CLUSTER ) @@ -38,3 +54,23 @@ class SmartThingsAcceleration(AttributeListeningChannel): {"attr": "y_axis", "config": REPORT_CONFIG_ASAP}, {"attr": "z_axis", "config": REPORT_CONFIG_ASAP}, ] + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute updates on this cluster.""" + if attrid == self.value_attribute: + async_dispatcher_send( + self._zha_device.hass, f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", value + ) + else: + self.zha_send_event( + self._cluster, + SIGNAL_ATTR_UPDATED, + { + "attribute_id": attrid, + "attribute_name": self._cluster.attributes.get(attrid, ["Unknown"])[ + 0 + ], + "value": value, + }, + ) diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py index cd407cfc416b68..25c11a9fd4f1cd 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -13,7 +13,15 @@ from . import ZigbeeChannel from .. import registries -from ..const import SIGNAL_ATTR_UPDATED +from ..const import ( + CLUSTER_COMMAND_SERVER, + SIGNAL_ATTR_UPDATED, + WARNING_DEVICE_MODE_EMERGENCY, + WARNING_DEVICE_SOUND_HIGH, + WARNING_DEVICE_SQUAWK_MODE_ARMED, + WARNING_DEVICE_STROBE_HIGH, + WARNING_DEVICE_STROBE_YES, +) _LOGGER = logging.getLogger(__name__) @@ -25,11 +33,95 @@ class IasAce(ZigbeeChannel): pass +@registries.CHANNEL_ONLY_CLUSTERS.register(security.IasWd.cluster_id) @registries.ZIGBEE_CHANNEL_REGISTRY.register(security.IasWd.cluster_id) class IasWd(ZigbeeChannel): """IAS Warning Device channel.""" - pass + @staticmethod + def set_bit(destination_value, destination_bit, source_value, source_bit): + """Set the specified bit in the value.""" + + if IasWd.get_bit(source_value, source_bit): + return destination_value | (1 << destination_bit) + return destination_value + + @staticmethod + def get_bit(value, bit): + """Get the specified bit from the value.""" + return (value & (1 << bit)) != 0 + + async def squawk( + self, + mode=WARNING_DEVICE_SQUAWK_MODE_ARMED, + strobe=WARNING_DEVICE_STROBE_YES, + squawk_level=WARNING_DEVICE_SOUND_HIGH, + ): + """Issue a squawk command. + + This command uses the WD capabilities to emit a quick audible/visible pulse called a + "squawk". The squawk command has no effect if the WD is currently active + (warning in progress). + """ + value = 0 + value = IasWd.set_bit(value, 0, squawk_level, 0) + value = IasWd.set_bit(value, 1, squawk_level, 1) + + value = IasWd.set_bit(value, 3, strobe, 0) + + value = IasWd.set_bit(value, 4, mode, 0) + value = IasWd.set_bit(value, 5, mode, 1) + value = IasWd.set_bit(value, 6, mode, 2) + value = IasWd.set_bit(value, 7, mode, 3) + + await self.device.issue_cluster_command( + self.cluster.endpoint.endpoint_id, + self.cluster.cluster_id, + 0x0001, + CLUSTER_COMMAND_SERVER, + [value], + ) + + async def start_warning( + self, + mode=WARNING_DEVICE_MODE_EMERGENCY, + strobe=WARNING_DEVICE_STROBE_YES, + siren_level=WARNING_DEVICE_SOUND_HIGH, + warning_duration=5, # seconds + strobe_duty_cycle=0x00, + strobe_intensity=WARNING_DEVICE_STROBE_HIGH, + ): + """Issue a start warning command. + + This command starts the WD operation. The WD alerts the surrounding area by audible + (siren) and visual (strobe) signals. + + strobe_duty_cycle indicates the length of the flash cycle. This provides a means + of varying the flash duration for different alarm types (e.g., fire, police, burglar). + Valid range is 0-100 in increments of 10. All other values SHALL be rounded to the + nearest valid value. Strobe SHALL calculate duty cycle over a duration of one second. + The ON state SHALL precede the OFF state. For example, if Strobe Duty Cycle Field specifies + “40,” then the strobe SHALL flash ON for 4/10ths of a second and then turn OFF for + 6/10ths of a second. + """ + value = 0 + value = IasWd.set_bit(value, 0, siren_level, 0) + value = IasWd.set_bit(value, 1, siren_level, 1) + + value = IasWd.set_bit(value, 2, strobe, 0) + + value = IasWd.set_bit(value, 4, mode, 0) + value = IasWd.set_bit(value, 5, mode, 1) + value = IasWd.set_bit(value, 6, mode, 2) + value = IasWd.set_bit(value, 7, mode, 3) + + await self.device.issue_cluster_command( + self.cluster.endpoint.endpoint_id, + self.cluster.cluster_id, + 0x0000, + CLUSTER_COMMAND_SERVER, + [value, warning_duration, strobe_duty_cycle, strobe_intensity], + ) @registries.BINARY_SENSOR_CLUSTERS.register(security.IasZone.cluster_id) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index c35cb168fdff31..ac83c2cdcd8f41 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -34,6 +34,11 @@ ATTR_SIGNATURE = "signature" ATTR_TYPE = "type" ATTR_VALUE = "value" +ATTR_WARNING_DEVICE_DURATION = "duration" +ATTR_WARNING_DEVICE_MODE = "mode" +ATTR_WARNING_DEVICE_STROBE = "strobe" +ATTR_WARNING_DEVICE_STROBE_DUTY_CYCLE = "duty_cycle" +ATTR_WARNING_DEVICE_STROBE_INTENSITY = "intensity" BAUD_RATES = [2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200, 128000, 256000] @@ -44,6 +49,7 @@ CHANNEL_ELECTRICAL_MEASUREMENT = "electrical_measurement" CHANNEL_EVENT_RELAY = "event_relay" CHANNEL_FAN = "fan" +CHANNEL_IAS_WD = "ias_wd" CHANNEL_LEVEL = ATTR_LEVEL CHANNEL_ON_OFF = "on_off" CHANNEL_POWER_CONFIGURATION = "power" @@ -177,6 +183,30 @@ def list(cls): UNKNOWN_MANUFACTURER = "unk_manufacturer" UNKNOWN_MODEL = "unk_model" +WARNING_DEVICE_MODE_STOP = 0 +WARNING_DEVICE_MODE_BURGLAR = 1 +WARNING_DEVICE_MODE_FIRE = 2 +WARNING_DEVICE_MODE_EMERGENCY = 3 +WARNING_DEVICE_MODE_POLICE_PANIC = 4 +WARNING_DEVICE_MODE_FIRE_PANIC = 5 +WARNING_DEVICE_MODE_EMERGENCY_PANIC = 6 + +WARNING_DEVICE_STROBE_NO = 0 +WARNING_DEVICE_STROBE_YES = 1 + +WARNING_DEVICE_SOUND_LOW = 0 +WARNING_DEVICE_SOUND_MEDIUM = 1 +WARNING_DEVICE_SOUND_HIGH = 2 +WARNING_DEVICE_SOUND_VERY_HIGH = 3 + +WARNING_DEVICE_STROBE_LOW = 0x00 +WARNING_DEVICE_STROBE_MEDIUM = 0x01 +WARNING_DEVICE_STROBE_HIGH = 0x02 +WARNING_DEVICE_STROBE_VERY_HIGH = 0x03 + +WARNING_DEVICE_SQUAWK_MODE_ARMED = 0 +WARNING_DEVICE_SQUAWK_MODE_DISARMED = 1 + ZHA_DISCOVERY_NEW = "zha_discovery_new_{}" ZHA_GW_MSG_RAW_INIT = "raw_device_initialized" ZHA_GW_MSG = "zha_gateway_message" diff --git a/homeassistant/components/zha/core/store.py b/homeassistant/components/zha/core/store.py index 85b4261e4ecf3c..cea38517767c85 100644 --- a/homeassistant/components/zha/core/store.py +++ b/homeassistant/components/zha/core/store.py @@ -2,7 +2,7 @@ # pylint: disable=W0611 from collections import OrderedDict import logging -from typing import MutableMapping # noqa: F401 +from typing import MutableMapping from typing import cast import attr @@ -35,7 +35,7 @@ class ZhaDeviceStorage: def __init__(self, hass: HomeAssistantType) -> None: """Initialize the zha device storage.""" self.hass = hass - self.devices = {} # type: MutableMapping[str, ZhaDeviceEntry] + self.devices: MutableMapping[str, ZhaDeviceEntry] = {} self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) @callback @@ -88,7 +88,7 @@ async def async_load(self) -> None: """Load the registry of zha device entries.""" data = await self._store.async_load() - devices = OrderedDict() # type: OrderedDict[str, ZhaDeviceEntry] + devices: "OrderedDict[str, ZhaDeviceEntry]" = OrderedDict() if data is not None: for device in data["devices"]: diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 379f69febbb82f..c2273c54073ac0 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -27,9 +27,15 @@ DEFAULT_DURATION = 5 +CAPABILITIES_COLOR_LOOP = 0x4 CAPABILITIES_COLOR_XY = 0x08 CAPABILITIES_COLOR_TEMP = 0x10 +UPDATE_COLORLOOP_ACTION = 0x1 +UPDATE_COLORLOOP_DIRECTION = 0x2 +UPDATE_COLORLOOP_TIME = 0x4 +UPDATE_COLORLOOP_HUE = 0x8 + UNSUPPORTED_ATTRIBUTE = 0x86 SCAN_INTERVAL = timedelta(minutes=60) PARALLEL_UPDATES = 5 @@ -85,6 +91,8 @@ def __init__(self, unique_id, zha_device, channels, **kwargs): self._color_temp = None self._hs_color = None self._brightness = None + self._effect_list = [] + self._effect = None self._on_off_channel = self.cluster_channels.get(CHANNEL_ON_OFF) self._level_channel = self.cluster_channels.get(CHANNEL_LEVEL) self._color_channel = self.cluster_channels.get(CHANNEL_COLOR) @@ -103,6 +111,10 @@ def __init__(self, unique_id, zha_device, channels, **kwargs): self._supported_features |= light.SUPPORT_COLOR self._hs_color = (0, 0) + if color_capabilities & CAPABILITIES_COLOR_LOOP: + self._supported_features |= light.SUPPORT_EFFECT + self._effect_list.append(light.EFFECT_COLORLOOP) + @property def is_on(self) -> bool: """Return true if entity is on.""" @@ -141,6 +153,16 @@ def color_temp(self): """Return the CT color value in mireds.""" return self._color_temp + @property + def effect_list(self): + """Return the list of supported effects.""" + return self._effect_list + + @property + def effect(self): + """Return the current effect.""" + return self._effect + @property def supported_features(self): """Flag supported features.""" @@ -173,12 +195,15 @@ def async_restore_last_state(self, last_state): self._color_temp = last_state.attributes["color_temp"] if "hs_color" in last_state.attributes: self._hs_color = last_state.attributes["hs_color"] + if "effect" in last_state.attributes: + self._effect = last_state.attributes["effect"] async def async_turn_on(self, **kwargs): """Turn the entity on.""" transition = kwargs.get(light.ATTR_TRANSITION) - duration = transition * 10 if transition else DEFAULT_DURATION + duration = transition * 10 if transition is not None else DEFAULT_DURATION brightness = kwargs.get(light.ATTR_BRIGHTNESS) + effect = kwargs.get(light.ATTR_EFFECT) t_log = {} if ( @@ -234,6 +259,36 @@ async def async_turn_on(self, **kwargs): return self._hs_color = hs_color + if ( + effect == light.EFFECT_COLORLOOP + and self.supported_features & light.SUPPORT_EFFECT + ): + result = await self._color_channel.color_loop_set( + UPDATE_COLORLOOP_ACTION + | UPDATE_COLORLOOP_DIRECTION + | UPDATE_COLORLOOP_TIME, + 0x2, # start from current hue + 0x1, # only support up + transition if transition else 7, # transition + 0, # no hue + ) + t_log["color_loop_set"] = result + self._effect = light.EFFECT_COLORLOOP + elif ( + self._effect == light.EFFECT_COLORLOOP + and effect != light.EFFECT_COLORLOOP + and self.supported_features & light.SUPPORT_EFFECT + ): + result = await self._color_channel.color_loop_set( + UPDATE_COLORLOOP_ACTION, + 0x0, + 0x0, + 0x0, + 0x0, # update action only, action off, no dir,time,hue + ) + t_log["color_loop_set"] = result + self._effect = None + self.debug("turned on: %s", t_log) self.async_schedule_update_ha_state() @@ -292,6 +347,15 @@ async def async_get_state(self, from_cache=True): self._hs_color = color_util.color_xy_to_hs( float(color_x / 65535), float(color_y / 65535) ) + if ( + color_capabilities is not None + and color_capabilities & CAPABILITIES_COLOR_LOOP + ): + color_loop_active = await self._color_channel.get_attribute_value( + "color_loop_active", from_cache=from_cache + ) + if color_loop_active is not None and color_loop_active == 1: + self._effect = light.EFFECT_COLORLOOP async def refresh(self, time): """Call async_get_state at an interval.""" diff --git a/homeassistant/components/zha/manifest.json b/homeassistant/components/zha/manifest.json index bf97dca1c708eb..e78661a04e534d 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -5,11 +5,11 @@ "documentation": "https://www.home-assistant.io/components/zha", "requirements": [ "bellows-homeassistant==0.9.1", - "zha-quirks==0.0.22", - "zigpy-deconz==0.2.2", - "zigpy-homeassistant==0.7.1", + "zha-quirks==0.0.23", + "zigpy-deconz==0.3.0", + "zigpy-homeassistant==0.8.0", "zigpy-xbee-homeassistant==0.4.0", - "zigpy-zigate==0.2.0" + "zigpy-zigate==0.3.1" ], "dependencies": [], "codeowners": ["@dmulcahey", "@adminiuga"] diff --git a/homeassistant/components/zha/services.yaml b/homeassistant/components/zha/services.yaml index ffd5aa21472c83..d279af46335fe1 100644 --- a/homeassistant/components/zha/services.yaml +++ b/homeassistant/components/zha/services.yaml @@ -82,3 +82,55 @@ issue_zigbee_cluster_command: manufacturer: description: manufacturer code example: 0x00FC + +warning_device_squawk: + description: >- + This service uses the WD capabilities to emit a quick audible/visible pulse called a "squawk". The squawk command has no effect if the WD is currently active (warning in progress). + fields: + ieee: + description: IEEE address for the device + example: "00:0d:6f:00:05:7d:2d:34" + mode: + description: >- + The Squawk Mode field is used as a 4-bit enumeration, and can have one of the values shown in Table 8-24 of the ZCL spec - Squawk Mode Field. The exact operation of each mode (how the WD “squawks”) is implementation specific. + example: 1 + strobe: + description: >- + The strobe field is used as a Boolean, and determines if the visual indication is also required in addition to the audible squawk, as shown in Table 8-25 of the ZCL spec - Strobe Bit. + example: 1 + level: + description: >- + The squawk level field is used as a 2-bit enumeration, and determines the intensity of audible squawk sound as shown in Table 8-26 of the ZCL spec - Squawk Level Field Values. + example: 2 + +warning_device_warn: + description: >- + This service starts the WD operation. The WD alerts the surrounding area by audible (siren) and visual (strobe) signals. + fields: + ieee: + description: IEEE address for the device + example: "00:0d:6f:00:05:7d:2d:34" + mode: + description: >- + The Warning Mode field is used as an 4-bit enumeration, can have one of the values defined below in table 8-20 of the ZCL spec. The exact behavior of the WD device in each mode is according to the relevant security standards. + example: 1 + strobe: + description: >- + The Strobe field is used as a 2-bit enumeration, and determines if the visual indication is required in addition to the audible siren, as indicated in Table 8-21 of the ZCL spec. If the strobe field is “1” and the Warning Mode is “0” (“Stop”) then only the strobe is activated. + example: 1 + level: + description: >- + The Siren Level field is used as a 2-bit enumeration, and indicates the intensity of audible squawk sound as shown in Table 8-22 of the ZCL spec. + example: 2 + duration: + description: >- + Requested duration of warning, in seconds. If both Strobe and Warning Mode are "0" this field SHALL be ignored. + example: 2 + duty_cycle: + description: >- + Indicates the length of the flash cycle. This provides a means of varying the flash duration for different alarm types (e.g., fire, police, burglar). Valid range is 0-100 in increments of 10. All other values SHALL be rounded to the nearest valid value. Strobe SHALL calculate duty cycle over a duration of one second. The ON state SHALL precede the OFF state. For example, if Strobe Duty Cycle Field specifies “40,” then the strobe SHALL flash ON for 4/10ths of a second and then turn OFF for 6/10ths of a second. + example: 2 + intensity: + description: >- + Indicates the intensity of the strobe as shown in Table 8-23 of the ZCL spec. This attribute is designed to vary the output of the strobe (i.e., brightness) and not its frequency, which is detailed in section 8.4.2.3.1.6 of the ZCL spec. + example: 2 diff --git a/homeassistant/components/zwave/climate.py b/homeassistant/components/zwave/climate.py index 2c7ce4b18a457c..b40fff669589c6 100644 --- a/homeassistant/components/zwave/climate.py +++ b/homeassistant/components/zwave/climate.py @@ -171,6 +171,28 @@ def supported_features(self): def update_properties(self): """Handle the data changes for node values.""" # Operation Mode + self._update_operation_mode() + + # Current Temp + self._update_current_temp() + + # Fan Mode + self._update_fan_mode() + + # Swing mode + self._update_swing_mode() + + # Set point + self._update_target_temp() + + # Operating state + self._update_operating_state() + + # Fan operating state + self._update_fan_state() + + def _update_operation_mode(self): + """Update hvac and preset modes.""" if self.values.mode: self._hvac_list = [] self._hvac_mapping = {} @@ -259,22 +281,27 @@ def update_properties(self): _LOGGER.debug("self._preset_list=%s", self._preset_list) _LOGGER.debug("self._preset_mode=%s", self._preset_mode) - # Current Temp + def _update_current_temp(self): + """Update current temperature.""" if self.values.temperature: self._current_temperature = self.values.temperature.data device_unit = self.values.temperature.units if device_unit is not None: self._unit = device_unit - # Fan Mode + def _update_fan_mode(self): + """Update fan mode.""" if self.values.fan_mode: self._current_fan_mode = self.values.fan_mode.data fan_modes = self.values.fan_mode.data_items if fan_modes: self._fan_modes = list(fan_modes) + _LOGGER.debug("self._fan_modes=%s", self._fan_modes) _LOGGER.debug("self._current_fan_mode=%s", self._current_fan_mode) - # Swing mode + + def _update_swing_mode(self): + """Update swing mode.""" if self._zxt_120 == 1: if self.values.zxt_120_swing_mode: self._current_swing_mode = self.values.zxt_120_swing_mode.data @@ -283,7 +310,9 @@ def update_properties(self): self._swing_modes = list(swing_modes) _LOGGER.debug("self._swing_modes=%s", self._swing_modes) _LOGGER.debug("self._current_swing_mode=%s", self._current_swing_mode) - # Set point + + def _update_target_temp(self): + """Update target temperature.""" if self.values.primary.data == 0: _LOGGER.debug( "Setpoint is 0, setting default to " "current_temperature=%s", @@ -294,12 +323,14 @@ def update_properties(self): else: self._target_temperature = round((float(self.values.primary.data)), 1) - # Operating state + def _update_operating_state(self): + """Update operating state.""" if self.values.operating_state: mode = self.values.operating_state.data self._hvac_action = HVAC_CURRENT_MAPPINGS.get(str(mode).lower(), mode) - # Fan operating state + def _update_fan_state(self): + """Update fan state.""" if self.values.fan_action: self._fan_action = self.values.fan_action.data @@ -448,7 +479,7 @@ def set_preset_mode(self, preset_mode): return if preset_mode == PRESET_NONE: # Activate the current hvac mode - self.update_properties() + self._update_operation_mode() operation_mode = self._hvac_mapping.get(self.hvac_mode) _LOGGER.debug("Set operation_mode to %s", operation_mode) self.values.mode.data = operation_mode diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index 66c3452f7c881d..44241e91daf942 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -17,6 +17,7 @@ EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED, COMMAND_CLASS_CENTRAL_SCENE, + COMMAND_CLASS_VERSION, DOMAIN, ) from .util import node_name, is_node_parsed, node_device_id_and_name @@ -30,6 +31,7 @@ ATTR_PRODUCT_NAME = "product_name" ATTR_MANUFACTURER_NAME = "manufacturer_name" ATTR_NODE_NAME = "node_name" +ATTR_APPLICATION_VERSION = "application_version" STAGE_COMPLETE = "Complete" @@ -130,10 +132,14 @@ def __init__(self, node, network): self._product_name = node.product_name self._manufacturer_name = node.manufacturer_name self._unique_id = self._compute_unique_id() + self._application_version = None self._attributes = {} self.wakeup_interval = None self.location = None self.battery_level = None + dispatcher.connect( + self.network_node_value_added, ZWaveNetwork.SIGNAL_VALUE_ADDED + ) dispatcher.connect(self.network_node_changed, ZWaveNetwork.SIGNAL_VALUE_CHANGED) dispatcher.connect(self.network_node_changed, ZWaveNetwork.SIGNAL_NODE) dispatcher.connect(self.network_node_changed, ZWaveNetwork.SIGNAL_NOTIFICATION) @@ -161,6 +167,24 @@ def device_info(self): info["via_device"] = (DOMAIN, 1) return info + def maybe_update_application_version(self, value): + """Update application version if value is a Command Class Version, Application Value.""" + if ( + value + and value.command_class == COMMAND_CLASS_VERSION + and value.label == "Application Version" + ): + self._application_version = value.data + + def network_node_value_added(self, node=None, value=None, args=None): + """Handle a added value to a none on the network.""" + if node and node.node_id != self.node_id: + return + if args is not None and "nodeId" in args and args["nodeId"] != self.node_id: + return + + self.maybe_update_application_version(value) + def network_node_changed(self, node=None, value=None, args=None): """Handle a changed node on the network.""" if node and node.node_id != self.node_id: @@ -172,6 +196,8 @@ def network_node_changed(self, node=None, value=None, args=None): if value is not None and value.command_class == COMMAND_CLASS_CENTRAL_SCENE: self.central_scene_activated(value.index, value.data) + self.maybe_update_application_version(value) + self.node_changed() def get_node_statistics(self): @@ -343,6 +369,8 @@ def device_state_attributes(self): attrs[ATTR_BATTERY_LEVEL] = self.battery_level if self.wakeup_interval is not None: attrs[ATTR_WAKEUP] = self.wakeup_interval + if self._application_version is not None: + attrs[ATTR_APPLICATION_VERSION] = self._application_version return attrs diff --git a/homeassistant/config.py b/homeassistant/config.py index 4b7efed00e4082..d3bd97dad8f777 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -289,7 +289,7 @@ def _write_default_config(config_dir: str) -> Optional[str]: return config_path - except IOError: + except OSError: print("Unable to create default configuration file", config_path) return None @@ -393,7 +393,7 @@ def process_ha_config_upgrade(hass: HomeAssistant) -> None: try: with open(config_path, "wt", encoding="utf-8") as config_file: config_file.write(config_raw) - except IOError: + except OSError: _LOGGER.exception("Migrating to google_translate tts failed") pass diff --git a/homeassistant/const.py b/homeassistant/const.py index 4cfd16b8c9f0cc..26cb3c20942666 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 99 +MINOR_VERSION = 100 PATCH_VERSION = "0.dev0" __short_version__ = "{}.{}".format(MAJOR_VERSION, MINOR_VERSION) __version__ = "{}.{}".format(__short_version__, PATCH_VERSION) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 32690153221b65..9a534c01bbf2c6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -3,6 +3,7 @@ To update, run python3 -m script.hassfest """ +# fmt: off FLOWS = [ "adguard", @@ -24,10 +25,12 @@ "homekit_controller", "homematicip_cloud", "hue", + "iaqualink", "ifttt", "ios", "ipma", "iqvia", + "izone", "life360", "lifx", "linky", @@ -43,12 +46,14 @@ "openuv", "owntracks", "plaato", + "plex", "point", "ps4", "rainmachine", "simplisafe", "smartthings", "smhi", + "solaredge", "somfy", "sonos", "tellduslive", diff --git a/homeassistant/generated/ssdp.py b/homeassistant/generated/ssdp.py index 28df05a872cfb0..6d62c47110b2d7 100644 --- a/homeassistant/generated/ssdp.py +++ b/homeassistant/generated/ssdp.py @@ -3,6 +3,7 @@ To update, run python3 -m script.hassfest """ +# fmt: off SSDP = { "device_type": {}, diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 09c1712c061d52..6200e2facb0c60 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -3,6 +3,7 @@ To update, run python3 -m script.hassfest """ +# fmt: off ZEROCONF = { "_axis-video._tcp.local.": [ diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index 40465f83728c0a..133251e779d1db 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -1,4 +1,5 @@ """Offer reusable conditions.""" +import asyncio from datetime import datetime, timedelta import functools as ft import logging @@ -10,6 +11,9 @@ from homeassistant.core import HomeAssistant, State from homeassistant.components import zone as zone_cmp +from homeassistant.components.device_automation import ( # noqa: F401 pylint: disable=unused-import + async_device_condition_from_config as async_device_from_config, +) from homeassistant.const import ( ATTR_GPS_ACCURACY, ATTR_LATITUDE, @@ -41,40 +45,9 @@ _LOGGER = logging.getLogger(__name__) -# PyLint does not like the use of _threaded_factory -# pylint: disable=invalid-name - - -def _threaded_factory( - async_factory: Callable[[ConfigType, bool], Callable[..., bool]] -) -> Callable[[ConfigType, bool], Callable[..., bool]]: - """Create threaded versions of async factories.""" - - @ft.wraps(async_factory) - def factory( - config: ConfigType, config_validation: bool = True - ) -> Callable[..., bool]: - """Threaded factory.""" - async_check = async_factory(config, config_validation) - - def condition_if( - hass: HomeAssistant, variables: TemplateVarsType = None - ) -> bool: - """Validate condition.""" - return cast( - bool, - run_callback_threadsafe( - hass.loop, async_check, hass, variables - ).result(), - ) - - return condition_if - return factory - - -def async_from_config( - config: ConfigType, config_validation: bool = True +async def async_from_config( + hass: HomeAssistant, config: ConfigType, config_validation: bool = True ) -> Callable[..., bool]: """Turn a condition configuration into a method. @@ -95,29 +68,30 @@ def async_from_config( ) ) - return cast(Callable[..., bool], factory(config, config_validation)) - + # Check for partials to properly determine if coroutine function + check_factory = factory + while isinstance(check_factory, ft.partial): + check_factory = check_factory.func -from_config = _threaded_factory(async_from_config) + if asyncio.iscoroutinefunction(check_factory): + return cast(Callable[..., bool], await factory(hass, config, config_validation)) + return cast(Callable[..., bool], factory(config, config_validation)) -def async_and_from_config( - config: ConfigType, config_validation: bool = True +async def async_and_from_config( + hass: HomeAssistant, config: ConfigType, config_validation: bool = True ) -> Callable[..., bool]: """Create multi condition matcher using 'AND'.""" if config_validation: config = cv.AND_CONDITION_SCHEMA(config) - checks = None + checks = [ + await async_from_config(hass, entry, False) for entry in config["conditions"] + ] def if_and_condition( hass: HomeAssistant, variables: TemplateVarsType = None ) -> bool: """Test and condition.""" - nonlocal checks - - if checks is None: - checks = [async_from_config(entry, False) for entry in config["conditions"]] - try: for check in checks: if not check(hass, variables): @@ -131,26 +105,20 @@ def if_and_condition( return if_and_condition -and_from_config = _threaded_factory(async_and_from_config) - - -def async_or_from_config( - config: ConfigType, config_validation: bool = True +async def async_or_from_config( + hass: HomeAssistant, config: ConfigType, config_validation: bool = True ) -> Callable[..., bool]: """Create multi condition matcher using 'OR'.""" if config_validation: config = cv.OR_CONDITION_SCHEMA(config) - checks = None + checks = [ + await async_from_config(hass, entry, False) for entry in config["conditions"] + ] def if_or_condition( hass: HomeAssistant, variables: TemplateVarsType = None ) -> bool: """Test and condition.""" - nonlocal checks - - if checks is None: - checks = [async_from_config(entry, False) for entry in config["conditions"]] - try: for check in checks: if check(hass, variables): @@ -163,9 +131,6 @@ def if_or_condition( return if_or_condition -or_from_config = _threaded_factory(async_or_from_config) - - def numeric_state( hass: HomeAssistant, entity: Union[None, str, State], @@ -263,9 +228,6 @@ def if_numeric_state( return if_numeric_state -numeric_state_from_config = _threaded_factory(async_numeric_state_from_config) - - def state( hass: HomeAssistant, entity: Union[None, str, State], @@ -423,9 +385,6 @@ def template_if(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool return template_if -template_from_config = _threaded_factory(async_template_from_config) - - def time( before: Optional[dt_util.dt.time] = None, after: Optional[dt_util.dt.time] = None, diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 471c6d50360698..952fa41c42c1ce 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -24,10 +24,14 @@ CONF_ALIAS, CONF_BELOW, CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, CONF_ENTITY_ID, CONF_ENTITY_NAMESPACE, + CONF_FOR, CONF_PLATFORM, CONF_SCAN_INTERVAL, + CONF_STATE, CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, CONF_VALUE_TEMPLATE, @@ -48,7 +52,7 @@ from homeassistant.util import slugify as util_slugify -# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs +# mypy: allow-untyped-calls, allow-untyped-defs # mypy: no-check-untyped-defs, no-warn-return-any # pylint: disable=invalid-name @@ -91,7 +95,7 @@ def validate(obj: Dict) -> Dict: return validate -def has_at_most_one_key(*keys: str) -> Callable: +def has_at_most_one_key(*keys: str) -> Callable[[Dict], Dict]: """Validate that zero keys exist or one key exists.""" def validate(obj: Dict) -> Dict: @@ -220,7 +224,7 @@ def entity_ids(value: Union[str, List]) -> List[str]: comp_entity_ids = vol.Any(vol.All(vol.Lower, ENTITY_MATCH_ALL), entity_ids) -def entity_domain(domain: str): +def entity_domain(domain: str) -> Callable[[Any], str]: """Validate that entity belong to domain.""" def validate(value: Any) -> str: @@ -231,7 +235,7 @@ def validate(value: Any) -> str: return validate -def entities_domain(domain: str): +def entities_domain(domain: str) -> Callable[[Union[str, List]], List[str]]: """Validate that entities belong to domain.""" def validate(values: Union[str, List]) -> List[str]: @@ -280,7 +284,7 @@ def icon(value): ) -def time(value) -> time_sys: +def time(value: Any) -> time_sys: """Validate and transform a time.""" if isinstance(value, time_sys): return value @@ -296,7 +300,7 @@ def time(value) -> time_sys: return time_val -def date(value) -> date_sys: +def date(value: Any) -> date_sys: """Validate and transform a date.""" if isinstance(value, date_sys): return value @@ -435,7 +439,7 @@ def string(value: Any) -> str: return str(value) -def temperature_unit(value) -> str: +def temperature_unit(value: Any) -> str: """Validate and transform temperature unit.""" value = str(value).upper() if value == "C": @@ -574,7 +578,7 @@ def deprecated( replacement_key: Optional[str] = None, invalidation_version: Optional[str] = None, default: Optional[Any] = None, -): +) -> Callable[[Dict], Dict]: """ Log key as deprecated and provide a replacement (if exists). @@ -622,7 +626,7 @@ def deprecated( " deprecated, please remove it from your configuration" ) - def check_for_invalid_version(value: Optional[Any]): + def check_for_invalid_version(value: Optional[Any]) -> None: """Raise error if current version has reached invalidation.""" if not invalidation_version: return @@ -637,7 +641,7 @@ def check_for_invalid_version(value: Optional[Any]): ) ) - def validator(config: Dict): + def validator(config: Dict) -> Dict: """Check if key is in config and log warning.""" if key in config: value = config[key] @@ -746,8 +750,8 @@ def validator(value): { vol.Required(CONF_CONDITION): "state", vol.Required(CONF_ENTITY_ID): entity_id, - vol.Required("state"): str, - vol.Optional("for"): vol.All(time_period, positive_timedelta), + vol.Required(CONF_STATE): str, + vol.Optional(CONF_FOR): vol.All(time_period, positive_timedelta), # To support use_trigger_value in automation # Deprecated 2016/04/25 vol.Optional("from"): str, @@ -823,6 +827,11 @@ def validator(value): } ) +DEVICE_CONDITION_SCHEMA = vol.Schema( + {vol.Required(CONF_CONDITION): "device", vol.Required(CONF_DOMAIN): str}, + extra=vol.ALLOW_EXTRA, +) + CONDITION_SCHEMA: vol.Schema = vol.Any( NUMERIC_STATE_CONDITION_SCHEMA, STATE_CONDITION_SCHEMA, @@ -832,6 +841,7 @@ def validator(value): ZONE_CONDITION_SCHEMA, AND_CONDITION_SCHEMA, OR_CONDITION_SCHEMA, + DEVICE_CONDITION_SCHEMA, ) _SCRIPT_DELAY_SCHEMA = vol.Schema( @@ -852,6 +862,11 @@ def validator(value): } ) +DEVICE_ACTION_SCHEMA = vol.Schema( + {vol.Required(CONF_DEVICE_ID): string, vol.Required(CONF_DOMAIN): str}, + extra=vol.ALLOW_EXTRA, +) + SCRIPT_SCHEMA = vol.All( ensure_list, [ @@ -861,6 +876,7 @@ def validator(value): _SCRIPT_WAIT_TEMPLATE_SCHEMA, EVENT_SCHEMA, CONDITION_SCHEMA, + DEVICE_ACTION_SCHEMA, ) ], ) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 3be00c859a7a41..00671e9c7763ae 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -53,7 +53,7 @@ class RegistryEntry: device_id = attr.ib(type=str, default=None) config_entry_id = attr.ib(type=str, default=None) disabled_by = attr.ib( - type=str, + type=Optional[str], default=None, validator=attr.validators.in_( ( @@ -64,7 +64,7 @@ class RegistryEntry: None, ) ), - ) # type: Optional[str] + ) domain = attr.ib(type=str, init=False, repr=False) @domain.default @@ -154,8 +154,8 @@ def async_get_or_create( if entity_id: return self._async_update_entity( entity_id, - config_entry_id=config_entry_id, - device_id=device_id, + config_entry_id=config_entry_id or _UNDEF, + device_id=device_id or _UNDEF, # When we changed our slugify algorithm, we invalidated some # stored entity IDs with either a __ or ending in _. # Fix introduced in 0.86 (Jan 23, 2019). Next line can be diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 3afb5cb88e46dc..b7707b844d417a 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1,5 +1,5 @@ """Helpers for listening to events.""" -from datetime import timedelta +from datetime import datetime, timedelta import functools as ft from typing import Callable @@ -21,8 +21,7 @@ from homeassistant.util.async_ import run_callback_threadsafe -# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs -# mypy: no-check-untyped-defs, no-warn-return-any +# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs # PyLint does not like the use of threaded_listener_factory # pylint: disable=invalid-name @@ -187,7 +186,9 @@ def state_for_cancel_listener(entity, from_state, to_state): @callback @bind_hass -def async_track_point_in_time(hass, action, point_in_time) -> CALLBACK_TYPE: +def async_track_point_in_time( + hass: HomeAssistant, action: Callable[..., None], point_in_time: datetime +) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in time.""" utc_point_in_time = dt_util.as_utc(point_in_time) @@ -204,7 +205,9 @@ def utc_converter(utc_now): @callback @bind_hass -def async_track_point_in_utc_time(hass, action, point_in_time) -> CALLBACK_TYPE: +def async_track_point_in_utc_time( + hass: HomeAssistant, action: Callable[..., None], point_in_time: datetime +) -> CALLBACK_TYPE: """Add a listener that fires once after a specific point in UTC time.""" # Ensure point_in_time is UTC point_in_time = dt_util.as_utc(point_in_time) @@ -284,8 +287,8 @@ class SunListener: action = attr.ib(type=Callable) event = attr.ib(type=str) offset = attr.ib(type=timedelta) - _unsub_sun = attr.ib(default=None) - _unsub_config = attr.ib(default=None) + _unsub_sun: CALLBACK_TYPE = attr.ib(default=None) + _unsub_config: CALLBACK_TYPE = attr.ib(default=None) @callback def async_attach(self): diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 43ef156ef09f3f..23728b651098aa 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -4,12 +4,17 @@ from contextlib import suppress from datetime import datetime from itertools import islice -from typing import Optional, Sequence, Callable, Dict, List, Set, Tuple +from typing import Optional, Sequence, Callable, Dict, List, Set, Tuple, Any import voluptuous as vol from homeassistant.core import HomeAssistant, Context, callback, CALLBACK_TYPE -from homeassistant.const import CONF_CONDITION, CONF_TIMEOUT +from homeassistant.const import ( + CONF_CONDITION, + CONF_DEVICE_ID, + CONF_DOMAIN, + CONF_TIMEOUT, +) from homeassistant import exceptions from homeassistant.helpers import ( service, @@ -22,12 +27,12 @@ async_track_template, ) from homeassistant.helpers.typing import ConfigType +from homeassistant.loader import async_get_integration import homeassistant.util.dt as date_util from homeassistant.util.async_ import run_coroutine_threadsafe, run_callback_threadsafe -# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs -# mypy: no-check-untyped-defs +# mypy: allow-untyped-calls, allow-untyped-defs, no-check-untyped-defs _LOGGER = logging.getLogger(__name__) @@ -48,6 +53,7 @@ ACTION_CHECK_CONDITION = "condition" ACTION_FIRE_EVENT = "event" ACTION_CALL_SERVICE = "call_service" +ACTION_DEVICE_AUTOMATION = "device" def _determine_action(action): @@ -64,6 +70,9 @@ def _determine_action(action): if CONF_EVENT in action: return ACTION_FIRE_EVENT + if CONF_DEVICE_ID in action: + return ACTION_DEVICE_AUTOMATION + return ACTION_CALL_SERVICE @@ -91,9 +100,9 @@ class Script: def __init__( self, hass: HomeAssistant, - sequence, + sequence: Sequence[Dict[str, Any]], name: Optional[str] = None, - change_listener=None, + change_listener: Optional[Callable[..., Any]] = None, ) -> None: """Initialize the script.""" self.hass = hass @@ -117,6 +126,7 @@ def __init__( ACTION_CHECK_CONDITION: self._async_check_condition, ACTION_FIRE_EVENT: self._async_fire_event, ACTION_CALL_SERVICE: self._async_call_service, + ACTION_DEVICE_AUTOMATION: self._async_device_automation, } @property @@ -318,6 +328,19 @@ async def _async_call_service(self, action, variables, context): context=context, ) + async def _async_device_automation(self, action, variables, context): + """Perform the device automation specified in the action. + + This method is a coroutine. + """ + self.last_action = action.get(CONF_ALIAS, "device automation") + self._log("Executing step %s" % self.last_action) + integration = await async_get_integration(self.hass, action[CONF_DOMAIN]) + platform = integration.get_platform("device_automation") + await platform.async_call_action_from_config( + self.hass, action, variables, context + ) + async def _async_fire_event(self, action, variables, context): """Fire an event.""" self.last_action = action.get(CONF_ALIAS, action[CONF_EVENT]) @@ -338,7 +361,7 @@ async def _async_check_condition(self, action, variables, context): config_cache_key = frozenset((k, str(v)) for k, v in action.items()) config = self._config_cache.get(config_cache_key) if not config: - config = condition.async_from_config(action, False) + config = await condition.async_from_config(self.hass, action, False) self._config_cache[config_cache_key] = config self.last_action = action.get(CONF_ALIAS, action[CONF_CONDITION]) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 98e3849bfb6332..9af1998e894ebe 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -7,7 +7,7 @@ import re from datetime import datetime from functools import wraps -from typing import Iterable +from typing import Any, Iterable import jinja2 from jinja2 import contextfilter, contextfunction @@ -25,13 +25,13 @@ from homeassistant.core import State, callback, split_entity_id, valid_entity_id from homeassistant.exceptions import TemplateError from homeassistant.helpers import location as loc_helper -from homeassistant.helpers.typing import TemplateVarsType +from homeassistant.helpers.typing import HomeAssistantType, TemplateVarsType from homeassistant.loader import bind_hass from homeassistant.util import convert, dt as dt_util, location as loc_util from homeassistant.util.async_ import run_callback_threadsafe -# mypy: allow-incomplete-defs, allow-untyped-calls, allow-untyped-defs +# mypy: allow-untyped-calls, allow-untyped-defs # mypy: no-check-untyped-defs, no-warn-return-any _LOGGER = logging.getLogger(__name__) @@ -106,7 +106,7 @@ def extract_entities(template, variables=None): return MATCH_ALL -def _true(arg) -> bool: +def _true(arg: Any) -> bool: return True @@ -191,7 +191,7 @@ def extract_entities(self, variables=None): """Extract all entities for state_changed listener.""" return extract_entities(self.template, variables) - def render(self, variables: TemplateVarsType = None, **kwargs): + def render(self, variables: TemplateVarsType = None, **kwargs: Any) -> str: """Render given template.""" if variables is not None: kwargs.update(variables) @@ -201,7 +201,7 @@ def render(self, variables: TemplateVarsType = None, **kwargs): ).result() @callback - def async_render(self, variables: TemplateVarsType = None, **kwargs) -> str: + def async_render(self, variables: TemplateVarsType = None, **kwargs: Any) -> str: """Render given template. This method must be run in the event loop. @@ -218,7 +218,7 @@ def async_render(self, variables: TemplateVarsType = None, **kwargs) -> str: @callback def async_render_to_info( - self, variables: TemplateVarsType = None, **kwargs + self, variables: TemplateVarsType = None, **kwargs: Any ) -> RenderInfo: """Render the template and collect an entity filter.""" assert self.hass and _RENDER_INFO not in self.hass.data @@ -479,7 +479,7 @@ def _resolve_state(hass, entity_id_or_state): return None -def expand(hass, *args) -> Iterable[State]: +def expand(hass: HomeAssistantType, *args: Any) -> Iterable[State]: """Expand out any groups into entity states.""" search = list(args) found = {} @@ -635,7 +635,7 @@ def distance(hass, *args): ) -def is_state(hass, entity_id: str, state: State) -> bool: +def is_state(hass: HomeAssistantType, entity_id: str, state: State) -> bool: """Test if a state is a specific value.""" state_obj = _get_state(hass, entity_id) return state_obj is not None and state_obj.state == state diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 70284348157e76..1a9a3d256acb2d 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -322,9 +322,7 @@ def __init__(self, from_domain: str, to_domain: str) -> None: def _load_file( - hass, # type: HomeAssistant - comp_or_platform: str, - base_paths: List[str], + hass: "HomeAssistant", comp_or_platform: str, base_paths: List[str] ) -> Optional[ModuleType]: """Try to load specified file. @@ -391,11 +389,7 @@ def _load_file( class ModuleWrapper: """Class to wrap a Python module and auto fill in hass argument.""" - def __init__( - self, - hass, # type: HomeAssistant - module: ModuleType, - ) -> None: + def __init__(self, hass: "HomeAssistant", module: ModuleType) -> None: """Initialize the module wrapper.""" self._hass = hass self._module = module @@ -414,9 +408,7 @@ def __getattr__(self, attr: str) -> Any: class Components: """Helper to load components.""" - def __init__( - self, hass # type: HomeAssistant - ) -> None: + def __init__(self, hass: "HomeAssistant") -> None: """Initialize the Components class.""" self._hass = hass @@ -442,9 +434,7 @@ def __getattr__(self, comp_name: str) -> ModuleWrapper: class Helpers: """Helper to load helpers.""" - def __init__( - self, hass # type: HomeAssistant - ) -> None: + def __init__(self, hass: "HomeAssistant") -> None: """Initialize the Helpers class.""" self._hass = hass @@ -462,10 +452,7 @@ def bind_hass(func: CALLABLE_T) -> CALLABLE_T: return func -async def async_component_dependencies( - hass, # type: HomeAssistant - domain: str, -) -> Set[str]: +async def async_component_dependencies(hass: "HomeAssistant", domain: str) -> Set[str]: """Return all dependencies and subdependencies of components. Raises CircularDependency if a circular dependency is found. @@ -474,10 +461,7 @@ async def async_component_dependencies( async def _async_component_dependencies( - hass, # type: HomeAssistant - domain: str, - loaded: Set[str], - loading: Set, + hass: "HomeAssistant", domain: str, loaded: Set[str], loading: Set ) -> Set[str]: """Recursive function to get component dependencies. @@ -508,9 +492,7 @@ async def _async_component_dependencies( return loaded -def _async_mount_config_dir( - hass, # type: HomeAssistant -) -> bool: +def _async_mount_config_dir(hass: "HomeAssistant") -> bool: """Mount config dir in order to load custom_component. Async friendly but not a coroutine. diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2fa5a1cd41ae4b..842cf4840c832a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ PyJWT==1.7.1 PyNaCl==1.3.0 -aiohttp==3.5.4 +aiohttp==3.6.1 aiohttp_cors==0.7.0 astral==1.10.1 async_timeout==3.0.1 @@ -11,12 +11,12 @@ contextvars==2.4;python_version<"3.7" cryptography==2.7 distro==1.4.0 hass-nabucasa==0.17 -home-assistant-frontend==20190901.0 -importlib-metadata==0.19 +home-assistant-frontend==20190919.0 +importlib-metadata==0.23 jinja2>=2.10.1 netdisco==2.6.0 pip>=8.0.3 -python-slugify==3.0.3 +python-slugify==3.0.4 pytz>=2019.02 pyyaml==5.1.2 requests==2.22.0 diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index 0a9bac301883b4..00f5984c58ba43 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -5,7 +5,7 @@ import logging import os import sys -from typing import List +from typing import List, Optional, Sequence, Text from homeassistant.bootstrap import async_mount_local_lib_path from homeassistant.config import get_default_config_dir @@ -13,7 +13,7 @@ from homeassistant.util.package import install_package, is_virtual_env, is_installed -# mypy: allow-untyped-defs, allow-incomplete-defs, no-warn-return-any +# mypy: allow-untyped-defs, no-warn-return-any def run(args: List) -> int: @@ -62,13 +62,13 @@ def run(args: List) -> int: return script.run(args[1:]) # type: ignore -def extract_config_dir(args=None) -> str: +def extract_config_dir(args: Optional[Sequence[Text]] = None) -> str: """Extract the config dir from the arguments or get the default.""" parser = argparse.ArgumentParser(add_help=False) parser.add_argument("-c", "--config", default=None) - args = parser.parse_known_args(args)[0] + parsed_args = parser.parse_known_args(args)[0] return ( - os.path.join(os.getcwd(), args.config) - if args.config + os.path.join(os.getcwd(), parsed_args.config) + if parsed_args.config else get_default_config_dir() ) diff --git a/homeassistant/scripts/macos/__init__.py b/homeassistant/scripts/macos/__init__.py index e8d8306c8ce038..ceb3609dbdb1b7 100644 --- a/homeassistant/scripts/macos/__init__.py +++ b/homeassistant/scripts/macos/__init__.py @@ -27,7 +27,7 @@ def install_osx(): try: with open(path, "w", encoding="utf-8") as outp: outp.write(plist) - except IOError as err: + except OSError as err: print("Unable to write to " + path, err) return diff --git a/requirements_all.txt b/requirements_all.txt index 852788e1be305f..3214c5e43ab38a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,17 +1,17 @@ # Home Assistant core -aiohttp==3.5.4 +aiohttp==3.6.1 astral==1.10.1 async_timeout==3.0.1 attrs==19.1.0 bcrypt==3.1.7 certifi>=2019.6.16 contextvars==2.4;python_version<"3.7" -importlib-metadata==0.19 +importlib-metadata==0.23 jinja2>=2.10.1 PyJWT==1.7.1 cryptography==2.7 pip>=8.0.3 -python-slugify==3.0.3 +python-slugify==3.0.4 pytz>=2019.02 pyyaml==5.1.2 requests==2.22.0 @@ -35,7 +35,7 @@ Adafruit-SHT31==1.0.2 # Adafruit_BBIO==1.0.0 # homeassistant.components.homekit -HAP-python==2.5.0 +HAP-python==2.6.0 # homeassistant.components.mastodon Mastodon.py==1.4.6 @@ -74,6 +74,9 @@ PyRMVtransport==0.1.3 # homeassistant.components.transport_nsw PyTransportNSW==0.1.1 +# homeassistant.components.vicare +PyViCare==0.1.1 + # homeassistant.components.xiaomi_aqara PyXiaomiGateway==0.12.4 @@ -176,7 +179,7 @@ aioswitcher==2019.4.26 aiounifi==11 # homeassistant.components.wwlln -aiowwlln==1.0.0 +aiowwlln==2.0.2 # homeassistant.components.aladdin_connect aladdin_connect==0.3 @@ -194,7 +197,7 @@ ambiclimate==0.2.1 amcrest==1.5.3 # homeassistant.components.androidtv -androidtv==0.0.26 +androidtv==0.0.27 # homeassistant.components.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 @@ -225,7 +228,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.dlna_dmr # homeassistant.components.upnp -async-upnp-client==0.14.10 +async-upnp-client==0.14.11 # homeassistant.components.aurora_abb_powerone aurorapy==0.2.6 @@ -259,7 +262,6 @@ batinfo==0.4.2 # homeassistant.components.linksys_ap # homeassistant.components.scrape -# homeassistant.components.sytadin beautifulsoup4==4.8.0 # homeassistant.components.beewi_smartclim @@ -351,6 +353,9 @@ colorlog==4.0.2 # homeassistant.components.concord232 concord232==0.15 +# homeassistant.components.upc_connect +connect-box==0.2.4 + # homeassistant.components.eddystone_temperature # homeassistant.components.eq3btsmart # homeassistant.components.xiaomi_miio @@ -377,7 +382,6 @@ datapoint==0.4.3 # homeassistant.components.ihc # homeassistant.components.namecheapdns # homeassistant.components.ohmconnect -# homeassistant.components.upc_connect defusedxml==0.6.0 # homeassistant.components.deluge @@ -441,7 +445,7 @@ enocean==0.50 enturclient==0.2.0 # homeassistant.components.environment_canada -env_canada==0.0.24 +env_canada==0.0.25 # homeassistant.components.envirophat # envirophat==0.0.6 @@ -474,9 +478,6 @@ evohome-async==0.3.3b4 # homeassistant.components.fastdotcom fastdotcom==0.0.3 -# homeassistant.components.fedex -fedexdeliverymanager==1.0.6 - # homeassistant.components.feedreader feedparser-homeassistant==5.2.2.dev1 @@ -522,7 +523,7 @@ gearbest_parser==1.0.7 geizhals==0.0.9 # homeassistant.components.geniushub -geniushub-client==0.6.5 +geniushub-client==0.6.13 # homeassistant.components.geo_json_events # homeassistant.components.nsw_rural_fire_service_feed @@ -582,6 +583,9 @@ greeneye_monitor==1.0 # homeassistant.components.greenwave greenwavereality==0.5.1 +# homeassistant.components.growatt_server +growattServer==0.0.1 + # homeassistant.components.gstreamer gstreamer-player==1.1.2 @@ -604,7 +608,7 @@ hangups==0.4.9 hass-nabucasa==0.17 # homeassistant.components.mqtt -hbmqtt==0.9.4 +hbmqtt==0.9.5 # homeassistant.components.jewish_calendar hdate==0.9.0 @@ -612,6 +616,9 @@ hdate==0.9.0 # homeassistant.components.heatmiser heatmiserV3==0.9.1 +# homeassistant.components.here_travel_time +herepy==0.6.3.1 + # homeassistant.components.hikvisioncam hikvision==0.4 @@ -631,7 +638,7 @@ hole==0.5.0 holidays==0.9.11 # homeassistant.components.frontend -home-assistant-frontend==20190901.0 +home-assistant-frontend==20190919.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.4 @@ -660,6 +667,9 @@ hydrawiser==0.1.1 # homeassistant.components.htu21d # i2csense==0.0.4 +# homeassistant.components.iaqualink +iaqualink==0.2.9 + # homeassistant.components.watson_tts ibm-watson==3.0.3 @@ -676,13 +686,13 @@ ihcsdk==2.3.0 incomfort-client==0.3.1 # homeassistant.components.influxdb -influxdb==5.2.0 +influxdb==5.2.3 # homeassistant.components.insteon insteonplm==0.16.5 # homeassistant.components.iperf3 -iperf3==0.1.10 +iperf3==0.1.11 # homeassistant.components.route53 ipify==1.0.0 @@ -696,6 +706,9 @@ jsonrpc-async==0.6 # homeassistant.components.kodi jsonrpc-websocket==0.6 +# homeassistant.components.kaiterra +kaiterra-async-client==0.0.2 + # homeassistant.components.keba keba-kecontact==0.2.0 @@ -720,6 +733,9 @@ libpurecool==0.5.0 # homeassistant.components.foscam libpyfoscam==1.0 +# homeassistant.components.vivotek +libpyvivotek==0.2.2 + # homeassistant.components.mikrotik librouteros==2.3.0 @@ -819,9 +835,6 @@ mychevy==1.2.0 # homeassistant.components.mycroft mycroftapi==2.0 -# homeassistant.components.usps -myusps==1.3.2 - # homeassistant.components.n26 n26==0.2.7 @@ -899,14 +912,11 @@ opensensemap-api==0.1.5 openwebifpy==3.1.1 # homeassistant.components.luci -openwrt-luci-rpc==1.1.0 +openwrt-luci-rpc==1.1.1 # homeassistant.components.orvibo orvibo==1.1.1 -# homeassistant.components.luci -packaging==19.1 - # homeassistant.components.mqtt # homeassistant.components.shiftr paho-mqtt==1.4.0 @@ -1045,7 +1055,7 @@ pyW215==0.6.0 pyW800rf32==0.1 # homeassistant.components.nextbus -py_nextbus==0.1.2 +py_nextbusnext==0.1.4 # homeassistant.components.noaa_tides # py_noaa==0.3.0 @@ -1068,6 +1078,9 @@ pyarlo==0.2.3 # homeassistant.components.netatmo pyatmo==2.2.1 +# homeassistant.components.atome +pyatome==0.1.1 + # homeassistant.components.apple_tv pyatv==0.3.13 @@ -1093,7 +1106,7 @@ pycfdns==0.0.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==3.2.2 +pychromecast==4.0.1 # homeassistant.components.cmus pycmus==0.1.1 @@ -1125,6 +1138,9 @@ pydelijn==0.5.1 # homeassistant.components.zwave pydispatcher==2.0.5 +# homeassistant.components.doods +pydoods==1.0.2 + # homeassistant.components.android_ip_webcam pydroid-ipcam==0.8 @@ -1271,7 +1287,7 @@ pyloopenergy==0.1.3 pylutron-caseta==0.5.0 # homeassistant.components.lutron -pylutron==0.2.2 +pylutron==0.2.5 # homeassistant.components.mailgun pymailgunner==1.4 @@ -1322,11 +1338,20 @@ pynuki==1.3.3 pynut2==2.1.2 # homeassistant.components.nws -pynws==0.7.4 +pynws==0.8.1 # homeassistant.components.nx584 pynx584==0.4 +# homeassistant.components.nzbget +pynzbgetapi==0.2.0 + +# homeassistant.components.obihai +pyobihai==1.1.0 + +# homeassistant.components.ombi +pyombi==0.1.5 + # homeassistant.components.openuv pyopenuv==1.0.9 @@ -1444,7 +1469,7 @@ pytautulli==0.5.0 pyteleloisirs==3.5 # homeassistant.components.tfiac -pytfiac==0.3 +pytfiac==0.4 # homeassistant.components.thinkingcleaner pythinkingcleaner==0.0.3 @@ -1482,6 +1507,9 @@ python-gitlab==1.6.0 # homeassistant.components.hp_ilo python-hpilo==4.3 +# homeassistant.components.izone +python-izone==1.1.1 + # homeassistant.components.joaoapps_join python-join-api==0.0.4 @@ -1543,7 +1571,7 @@ python-velbus==2.0.27 python-vlc==1.1.2 # homeassistant.components.whois -python-whois==0.7.1 +python-whois==0.7.2 # homeassistant.components.wink python-wink==1.10.5 @@ -1570,7 +1598,7 @@ pytraccar==0.9.0 pytrackr==0.0.5 # homeassistant.components.tradfri -pytradfri[async]==6.0.1 +pytradfri[async]==6.3.1 # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation @@ -1640,7 +1668,7 @@ recollect-waste==1.0.1 regenmaschine==1.5.1 # homeassistant.components.python_script -restrictedpython==4.0 +restrictedpython==5.0 # homeassistant.components.idteck_prox rfk101py==0.0.1 @@ -1694,7 +1722,7 @@ schiene==0.23 scsgate==0.1.0 # homeassistant.components.sendgrid -sendgrid==6.0.5 +sendgrid==6.1.0 # homeassistant.components.sensehat sense-hat==2.2.0 @@ -1706,13 +1734,13 @@ sense_energy==0.7.0 sharp_aquos_rc==0.3.2 # homeassistant.components.shodan -shodan==1.15.0 +shodan==1.17.0 # homeassistant.components.simplepush simplepush==1.1.4 # homeassistant.components.simplisafe -simplisafe-python==4.3.0 +simplisafe-python==5.0.1 # homeassistant.components.sisyphus sisyphus-control==2.2.1 @@ -1753,13 +1781,13 @@ snapcast==2.0.10 socialbladeclient==0.2 # homeassistant.components.solaredge_local -solaredge-local==0.1.4 +solaredge-local==0.2.0 # homeassistant.components.solaredge solaredge==0.0.2 # homeassistant.components.solax -solax==0.1.2 +solax==0.2.2 # homeassistant.components.honeywell somecomfort==0.5.2 @@ -1783,9 +1811,6 @@ spotipy-homeassistant==2.4.4.dev1 # homeassistant.components.sql sqlalchemy==1.3.8 -# homeassistant.components.srp_energy -srpenergy==1.0.6 - # homeassistant.components.starlingbank starlingbank==3.1 @@ -1881,9 +1906,6 @@ twilio==6.19.1 # homeassistant.components.upcloud upcloud-api==0.4.3 -# homeassistant.components.ups -upsmychoice==1.0.6 - # homeassistant.components.uscis uscisstatus==0.1.1 @@ -1965,6 +1987,9 @@ xmltodict==0.12.0 # homeassistant.components.xs1 xs1-api-client==2.3.5 +# homeassistant.components.yandex_transport +ya_ma==0.3.7 + # homeassistant.components.yweather yahooweather==0.10 @@ -1978,7 +2003,7 @@ yeelight==0.5.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2019.09.01 +youtube_dl==2019.09.12.1 # homeassistant.components.zengge zengge==0.2 @@ -1987,7 +2012,7 @@ zengge==0.2 zeroconf==0.23.0 # homeassistant.components.zha -zha-quirks==0.0.22 +zha-quirks==0.0.23 # homeassistant.components.zhong_hong zhong_hong_hvac==1.0.9 @@ -1996,16 +2021,16 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.2.2 +zigpy-deconz==0.3.0 # homeassistant.components.zha -zigpy-homeassistant==0.7.1 +zigpy-homeassistant==0.8.0 # homeassistant.components.zha zigpy-xbee-homeassistant==0.4.0 # homeassistant.components.zha -zigpy-zigate==0.2.0 +zigpy-zigate==0.3.1 # homeassistant.components.zoneminder zm-py==0.3.3 diff --git a/requirements_test.txt b/requirements_test.txt index bfe459b0cfb8d3..b9b919c4bfd077 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -10,12 +10,12 @@ flake8-docstrings==1.3.1 flake8==3.7.8 mock-open==1.3.1 mypy==0.720 -pre-commit==1.18.2 +pre-commit==1.18.3 pydocstyle==4.0.1 pylint==2.3.1 pytest-aiohttp==0.3.0 pytest-cov==2.7.1 pytest-sugar==0.9.2 pytest-timeout==1.3.3 -pytest==5.1.1 -requests_mock==1.6.0 +pytest==5.1.3 +requests_mock==1.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a23bc7ce610ee8..c0e94a5afe58e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -11,19 +11,19 @@ flake8-docstrings==1.3.1 flake8==3.7.8 mock-open==1.3.1 mypy==0.720 -pre-commit==1.18.2 +pre-commit==1.18.3 pydocstyle==4.0.1 pylint==2.3.1 pytest-aiohttp==0.3.0 pytest-cov==2.7.1 pytest-sugar==0.9.2 pytest-timeout==1.3.3 -pytest==5.1.1 -requests_mock==1.6.0 +pytest==5.1.3 +requests_mock==1.7.0 # homeassistant.components.homekit -HAP-python==2.5.0 +HAP-python==2.6.0 # homeassistant.components.mobile_app # homeassistant.components.owntracks @@ -73,13 +73,13 @@ aioswitcher==2019.4.26 aiounifi==11 # homeassistant.components.wwlln -aiowwlln==1.0.0 +aiowwlln==2.0.2 # homeassistant.components.ambiclimate ambiclimate==0.2.1 # homeassistant.components.androidtv -androidtv==0.0.26 +androidtv==0.0.27 # homeassistant.components.apns apns2==0.3.0 @@ -105,7 +105,6 @@ coinmarketcap==5.0.3 # homeassistant.components.ihc # homeassistant.components.namecheapdns # homeassistant.components.ohmconnect -# homeassistant.components.upc_connect defusedxml==0.6.0 # homeassistant.components.dsmr @@ -167,11 +166,14 @@ hangups==0.4.9 hass-nabucasa==0.17 # homeassistant.components.mqtt -hbmqtt==0.9.4 +hbmqtt==0.9.5 # homeassistant.components.jewish_calendar hdate==0.9.0 +# homeassistant.components.here_travel_time +herepy==0.6.3.1 + # homeassistant.components.pi_hole hole==0.5.0 @@ -179,7 +181,7 @@ hole==0.5.0 holidays==0.9.11 # homeassistant.components.frontend -home-assistant-frontend==20190901.0 +home-assistant-frontend==20190919.0 # homeassistant.components.homekit_controller homekit[IP]==0.15.0 @@ -194,8 +196,11 @@ httplib2==0.10.3 # homeassistant.components.huawei_lte huawei-lte-api==1.3.0 +# homeassistant.components.iaqualink +iaqualink==0.2.9 + # homeassistant.components.influxdb -influxdb==5.2.0 +influxdb==5.2.3 # homeassistant.components.verisure jsonpath==0.75 @@ -247,6 +252,9 @@ pexpect==4.6.0 # homeassistant.components.pilight pilight==0.1.1 +# homeassistant.components.plex +plexapi==3.0.6 + # homeassistant.components.mhz19 # homeassistant.components.serial_pm pmsensor==0.4 @@ -276,6 +284,9 @@ pyMetno==0.4.6 # homeassistant.components.blackbird pyblackbird==0.5 +# homeassistant.components.cast +pychromecast==4.0.1 + # homeassistant.components.deconz pydeconz==62 @@ -304,7 +315,7 @@ pymfy==0.5.2 pymonoprice==0.3 # homeassistant.components.nws -pynws==0.7.4 +pynws==0.8.1 # homeassistant.components.nx584 pynx584==0.4 @@ -341,6 +352,9 @@ pyspcwebgw==0.4.0 # homeassistant.components.darksky python-forecastio==1.4.0 +# homeassistant.components.izone +python-izone==1.1.1 + # homeassistant.components.nest python-nest==4.1.0 @@ -351,7 +365,7 @@ python-velbus==2.0.27 python_awair==0.0.4 # homeassistant.components.tradfri -pytradfri[async]==6.0.1 +pytradfri[async]==6.3.1 # homeassistant.components.vesync pyvesync==1.1.0 @@ -363,7 +377,7 @@ pywebpush==1.9.2 regenmaschine==1.5.1 # homeassistant.components.python_script -restrictedpython==4.0 +restrictedpython==5.0 # homeassistant.components.rflink rflink==0.0.46 @@ -375,7 +389,7 @@ ring_doorbell==0.2.3 rxv==0.6.0 # homeassistant.components.simplisafe -simplisafe-python==4.3.0 +simplisafe-python==5.0.1 # homeassistant.components.sleepiq sleepyq==0.7 @@ -383,6 +397,9 @@ sleepyq==0.7 # homeassistant.components.smhi smhi-pkg==1.0.10 +# homeassistant.components.solaredge +solaredge==0.0.2 + # homeassistant.components.honeywell somecomfort==0.5.2 @@ -390,9 +407,6 @@ somecomfort==0.5.2 # homeassistant.components.sql sqlalchemy==1.3.8 -# homeassistant.components.srp_energy -srpenergy==1.0.6 - # homeassistant.components.statsd statsd==3.2.1 @@ -420,4 +434,4 @@ wakeonlan==1.1.6 zeroconf==0.23.0 # homeassistant.components.zha -zigpy-homeassistant==0.7.1 +zigpy-homeassistant==0.8.0 diff --git a/script/dev_docker b/script/dev_docker deleted file mode 100755 index 514fce734777e9..00000000000000 --- a/script/dev_docker +++ /dev/null @@ -1,33 +0,0 @@ -#!/bin/sh -# Build and run Home Assinstant in Docker. - -# Optional: pass in a timezone as first argument -# If not given will attempt to mount /etc/localtime - -# Stop on errors -set -e - -cd "$(dirname "$0")/.." - -docker build -t home-assistant-dev -f virtualization/Docker/Dockerfile.dev . - -if [ $# -gt 0 ] -then - docker run \ - --net=host \ - --device=/dev/ttyUSB0:/zwaveusbstick:rwm \ - -e "TZ=$1" \ - -v `pwd`:/usr/src/app \ - -v `pwd`/config:/config \ - -t -i home-assistant-dev - -else - docker run \ - --net=host \ - -v /etc/localtime:/etc/localtime:ro \ - -v `pwd`:/usr/src/app \ - -v `pwd`/config:/config \ - --rm \ - -t -i home-assistant-dev - -fi diff --git a/script/dev_openzwave_docker b/script/dev_openzwave_docker deleted file mode 100755 index 7304995f3e18eb..00000000000000 --- a/script/dev_openzwave_docker +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -# Open a docker that can be used to debug/dev python-openzwave. Pass in a command line argument to build - -cd "$(dirname "$0")/.." - -if [ $# -gt 0 ] -then - docker build -t home-assistant-dev . -fi - -docker run \ - --device=/dev/ttyUSB0:/zwaveusbstick:rwm \ - -v `pwd`:/usr/src/app \ - -p 8123:8123 \ - -t -i home-assistant-dev \ - /bin/bash diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 1468969d9dd719..d74a57d678d494 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -10,8 +10,8 @@ from script.hassfest.model import Integration COMMENT_REQUIREMENTS = ( - "Adafruit-DHT", "Adafruit_BBIO", + "Adafruit-DHT", "avion", "beacontools", "blinkt", @@ -26,7 +26,6 @@ "i2csense", "opencv-python-headless", "py_noaa", - "VL53L1X2", "pybluez", "pycups", "PySwitchbot", @@ -39,11 +38,11 @@ "RPi.GPIO", "smbus-cffi", "tensorflow", + "VL53L1X2", ) TEST_REQUIREMENTS = ( "adguardhome", - "ambiclimate", "aio_geojson_geonetnz_quakes", "aioambient", "aioautomatic", @@ -52,14 +51,16 @@ "aiohttp_cors", "aiohue", "aionotion", - "aiounifi", "aioswitcher", + "aiounifi", "aiowwlln", + "ambiclimate", "androidtv", "apns2", "aprslib", "av", "axis", + "bellows-homeassistant", "caldav", "coinmarketcap", "defusedxml", @@ -86,6 +87,7 @@ "haversine", "hbmqtt", "hdate", + "herepy", "hole", "holidays", "home-assistant-frontend", @@ -93,12 +95,12 @@ "homematicip", "httplib2", "huawei-lte-api", + "iaqualink", "influxdb", "jsonpath", "libpurecool", "libsoundtouch", "luftdaten", - "pyMetno", "mbddns", "mficlient", "minio", @@ -109,53 +111,61 @@ "paho-mqtt", "pexpect", "pilight", + "plexapi", "pmsensor", "prometheus_client", "ptvsd", "pushbullet.py", "py-canary", + "py17track", "pyblackbird", + "pychromecast", "pydeconz", "pydispatcher", "pyheos", "pyhomematic", + "pyHS100", "pyiqvia", "pylinky", "pylitejet", + "pyMetno", "pymfy", "pymonoprice", + "PyNaCl", "pynws", "pynx584", "pyopenuv", "pyotp", "pyps4-homeassistant", + "pyqwikswitch", + "PyRMVtransport", "pysma", "pysmartapp", "pysmartthings", "pysonos", - "pyqwikswitch", - "PyRMVtransport", - "PyTransportNSW", "pyspcwebgw", + "python_awair", "python-forecastio", + "python-izone", "python-nest", - "python_awair", "python-velbus", + "pythonwhois", "pytradfri[async]", + "PyTransportNSW", "pyunifi", "pyupnp-async", "pyvesync", "pywebpush", - "pyHS100", - "PyNaCl", "regenmaschine", "restrictedpython", "rflink", "ring_doorbell", + "ruamel.yaml", "rxv", "simplisafe-python", "sleepyq", "smhi-pkg", + "solaredge", "somecomfort", "sqlalchemy", "srpenergy", @@ -164,16 +174,12 @@ "twentemilieu", "uvcclient", "vsure", - "warrant", - "pythonwhois", - "wakeonlan", "vultr", + "wakeonlan", + "warrant", "YesssSMS", - "ruamel.yaml", "zeroconf", "zigpy-homeassistant", - "bellows-homeassistant", - "py17track", ) IGNORE_PIN = ("colorlog>2.1,<3", "keyring>=9.3,<10.0", "urllib3") diff --git a/script/hassfest/config_flow.py b/script/hassfest/config_flow.py index 5376f21db9eb81..4384399f4db393 100644 --- a/script/hassfest/config_flow.py +++ b/script/hassfest/config_flow.py @@ -10,6 +10,7 @@ To update, run python3 -m script.hassfest \"\"\" +# fmt: off FLOWS = {} """.strip() diff --git a/script/hassfest/ssdp.py b/script/hassfest/ssdp.py index 82068af6a7acbf..3b02ea181514df 100644 --- a/script/hassfest/ssdp.py +++ b/script/hassfest/ssdp.py @@ -11,6 +11,7 @@ To update, run python3 -m script.hassfest \"\"\" +# fmt: off SSDP = {} """.strip() diff --git a/script/hassfest/zeroconf.py b/script/hassfest/zeroconf.py index bdd765e315ecd1..3d93d363086162 100644 --- a/script/hassfest/zeroconf.py +++ b/script/hassfest/zeroconf.py @@ -11,6 +11,7 @@ To update, run python3 -m script.hassfest \"\"\" +# fmt: off ZEROCONF = {} diff --git a/script/lint_docker b/script/lint_docker deleted file mode 100755 index 7e6ff42e074c9e..00000000000000 --- a/script/lint_docker +++ /dev/null @@ -1,13 +0,0 @@ -#!/bin/sh -# Execute lint in a docker container to spot code mistakes. - -# Stop on errors -set -e - -cd "$(dirname "$0")/.." - -docker build -t home-assistant-test -f virtualization/Docker/Dockerfile.dev . -docker run --rm \ - -v `pwd`/.tox/:/usr/src/app/.tox/ \ - -t -i home-assistant-test \ - tox -e lint diff --git a/script/scaffold/__init__.py b/script/scaffold/__init__.py new file mode 100644 index 00000000000000..2eca398d998245 --- /dev/null +++ b/script/scaffold/__init__.py @@ -0,0 +1 @@ +"""Scaffold new integration.""" diff --git a/script/scaffold/__main__.py b/script/scaffold/__main__.py new file mode 100644 index 00000000000000..93bcc5aba4172c --- /dev/null +++ b/script/scaffold/__main__.py @@ -0,0 +1,87 @@ +"""Validate manifests.""" +import argparse +from pathlib import Path +import subprocess +import sys + +from . import gather_info, generate, error +from .const import COMPONENT_DIR + + +TEMPLATES = [ + p.name for p in (Path(__file__).parent / "templates").glob("*") if p.is_dir() +] + + +def valid_integration(integration): + """Test if it's a valid integration.""" + if not (COMPONENT_DIR / integration).exists(): + raise argparse.ArgumentTypeError( + f"The integration {integration} does not exist." + ) + + return integration + + +def get_arguments() -> argparse.Namespace: + """Get parsed passed in arguments.""" + parser = argparse.ArgumentParser(description="Home Assistant Scaffolder") + parser.add_argument("template", type=str, choices=TEMPLATES) + parser.add_argument( + "--develop", action="store_true", help="Automatically fill in info" + ) + parser.add_argument( + "--integration", type=valid_integration, help="Integration to target." + ) + + arguments = parser.parse_args() + + return arguments + + +def main(): + """Scaffold an integration.""" + if not Path("requirements_all.txt").is_file(): + print("Run from project root") + return 1 + + args = get_arguments() + + info = gather_info.gather_info(args) + + generate.generate(args.template, info) + + # If creating new integration, create config flow too + if args.template == "integration": + if info.authentication or not info.discoverable: + template = "config_flow" + else: + template = "config_flow_discovery" + + generate.generate(template, info) + + print("Running hassfest to pick up new information.") + subprocess.run("python -m script.hassfest", shell=True) + print() + + print("Running tests") + print(f"$ pytest tests/components/{info.domain}") + if ( + subprocess.run(f"pytest tests/components/{info.domain}", shell=True).returncode + != 0 + ): + return 1 + print() + + print(f"Done!") + + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except error.ExitApp as err: + print() + print(f"Fatal Error: {err.reason}") + sys.exit(err.exit_code) diff --git a/script/scaffold/const.py b/script/scaffold/const.py new file mode 100644 index 00000000000000..cf66bb4e2ae4d7 --- /dev/null +++ b/script/scaffold/const.py @@ -0,0 +1,5 @@ +"""Constants for scaffolding.""" +from pathlib import Path + +COMPONENT_DIR = Path("homeassistant/components") +TESTS_DIR = Path("tests/components") diff --git a/script/scaffold/docs.py b/script/scaffold/docs.py new file mode 100644 index 00000000000000..54a182be31bd63 --- /dev/null +++ b/script/scaffold/docs.py @@ -0,0 +1,22 @@ +"""Print links to relevant docs.""" +from .model import Info + + +def print_relevant_docs(template: str, info: Info) -> None: + """Print relevant docs.""" + if template == "integration": + print( + f""" +Your integration has been created at {info.integration_dir} . Next step is to fill in the blanks for the code marked with TODO. + +For a breakdown of each file, check the developer documentation at: +https://developers.home-assistant.io/docs/en/creating_integration_file_structure.html +""" + ) + + elif template == "config_flow": + print( + f""" +The config flow has been added to the {info.domain} integration. Next step is to fill in the blanks for the code marked with TODO. +""" + ) diff --git a/script/scaffold/error.py b/script/scaffold/error.py new file mode 100644 index 00000000000000..75a869572fd7fc --- /dev/null +++ b/script/scaffold/error.py @@ -0,0 +1,10 @@ +"""Errors for scaffolding.""" + + +class ExitApp(Exception): + """Exception to indicate app should exit.""" + + def __init__(self, reason, exit_code=1): + """Initialize the exit app exception.""" + self.reason = reason + self.exit_code = exit_code diff --git a/script/scaffold/gather_info.py b/script/scaffold/gather_info.py new file mode 100644 index 00000000000000..a7263daaf41982 --- /dev/null +++ b/script/scaffold/gather_info.py @@ -0,0 +1,183 @@ +"""Gather info for scaffolding.""" +import json + +from homeassistant.util import slugify + +from .const import COMPONENT_DIR +from .model import Info +from .error import ExitApp + + +CHECK_EMPTY = ["Cannot be empty", lambda value: value] + + +def gather_info(arguments) -> Info: + """Gather info.""" + existing = arguments.template != "integration" + + if arguments.develop: + print("Running in developer mode. Automatically filling in info.") + print() + + if existing: + if arguments.develop: + return _load_existing_integration("develop") + + if arguments.integration: + return _load_existing_integration(arguments.integration) + + return gather_existing_integration() + + if arguments.develop: + return Info( + domain="develop", + name="Develop Hub", + codeowner="@developer", + requirement="aiodevelop==1.2.3", + ) + + return gather_new_integration() + + +def gather_new_integration() -> Info: + """Gather info about new integration from user.""" + return Info( + **_gather_info( + { + "domain": { + "prompt": "What is the domain?", + "validators": [ + CHECK_EMPTY, + [ + "Domains cannot contain spaces or special characters.", + lambda value: value == slugify(value), + ], + [ + "There already is an integration with this domain.", + lambda value: not (COMPONENT_DIR / value).exists(), + ], + ], + }, + "name": { + "prompt": "What is the name of your integration?", + "validators": [CHECK_EMPTY], + }, + "codeowner": { + "prompt": "What is your GitHub handle?", + "validators": [ + CHECK_EMPTY, + [ + 'GitHub handles need to start with an "@"', + lambda value: value.startswith("@"), + ], + ], + }, + "requirement": { + "prompt": "What PyPI package and version do you depend on? Leave blank for none.", + "validators": [ + [ + "Versions should be pinned using '=='.", + lambda value: not value or "==" in value, + ] + ], + }, + "authentication": { + "prompt": "Does Home Assistant need the user to authenticate to control the device/service? (yes/no)", + "default": "yes", + "validators": [ + [ + "Type either 'yes' or 'no'", + lambda value: value in ("yes", "no"), + ] + ], + "convertor": lambda value: value == "yes", + }, + "discoverable": { + "prompt": "Is the device/service discoverable on the local network? (yes/no)", + "default": "no", + "validators": [ + [ + "Type either 'yes' or 'no'", + lambda value: value in ("yes", "no"), + ] + ], + "convertor": lambda value: value == "yes", + }, + } + ) + ) + + +def gather_existing_integration() -> Info: + """Gather info about existing integration from user.""" + answers = _gather_info( + { + "domain": { + "prompt": "What is the domain?", + "validators": [ + CHECK_EMPTY, + [ + "Domains cannot contain spaces or special characters.", + lambda value: value == slugify(value), + ], + [ + "This integration does not exist.", + lambda value: (COMPONENT_DIR / value).exists(), + ], + ], + } + } + ) + + return _load_existing_integration(answers["domain"]) + + +def _load_existing_integration(domain) -> Info: + """Load an existing integration.""" + if not (COMPONENT_DIR / domain).exists(): + raise ExitApp("Integration does not exist", 1) + + manifest = json.loads((COMPONENT_DIR / domain / "manifest.json").read_text()) + + return Info(domain=domain, name=manifest["name"]) + + +def _gather_info(fields) -> dict: + """Gather info from user.""" + answers = {} + + for key, info in fields.items(): + hint = None + while key not in answers: + if hint is not None: + print() + print(f"Error: {hint}") + + try: + print() + msg = info["prompt"] + if "default" in info: + msg += f" [{info['default']}]" + value = input(f"{msg}\n> ") + except (KeyboardInterrupt, EOFError): + raise ExitApp("Interrupted!", 1) + + value = value.strip() + + if value == "" and "default" in info: + value = info["default"] + + hint = None + + for validator_hint, validator in info["validators"]: + if not validator(value): + hint = validator_hint + break + + if hint is None: + if "convertor" in info: + value = info["convertor"](value) + answers[key] = value + + print() + return answers diff --git a/script/scaffold/generate.py b/script/scaffold/generate.py new file mode 100644 index 00000000000000..6bccf6529feeea --- /dev/null +++ b/script/scaffold/generate.py @@ -0,0 +1,121 @@ +"""Generate an integration.""" +from pathlib import Path + +from .error import ExitApp +from .model import Info + +TEMPLATE_DIR = Path(__file__).parent / "templates" +TEMPLATE_INTEGRATION = TEMPLATE_DIR / "integration" +TEMPLATE_TESTS = TEMPLATE_DIR / "tests" + + +def generate(template: str, info: Info) -> None: + """Generate a template.""" + _validate(template, info) + + print(f"Scaffolding {template} for the {info.domain} integration...") + _ensure_tests_dir_exists(info) + _generate(TEMPLATE_DIR / template / "integration", info.integration_dir, info) + _generate(TEMPLATE_DIR / template / "tests", info.tests_dir, info) + _custom_tasks(template, info) + print() + + +def _validate(template, info): + """Validate we can run this task.""" + if template == "config_flow": + if (info.integration_dir / "config_flow.py").exists(): + raise ExitApp(f"Integration {info.domain} already has a config flow.") + + +def _generate(src_dir, target_dir, info: Info) -> None: + """Generate an integration.""" + replaces = {"NEW_DOMAIN": info.domain, "NEW_NAME": info.name} + + if not target_dir.exists(): + target_dir.mkdir() + + for source_file in src_dir.glob("**/*"): + content = source_file.read_text() + + for to_search, to_replace in replaces.items(): + content = content.replace(to_search, to_replace) + + target_file = target_dir / source_file.relative_to(src_dir) + print(f"Writing {target_file}") + target_file.write_text(content) + + +def _ensure_tests_dir_exists(info: Info) -> None: + """Ensure a test dir exists.""" + if info.tests_dir.exists(): + return + + info.tests_dir.mkdir() + print(f"Writing {info.tests_dir / '__init__.py'}") + (info.tests_dir / "__init__.py").write_text( + f'"""Tests for the {info.name} integration."""\n' + ) + + +def _custom_tasks(template, info) -> None: + """Handle custom tasks for templates.""" + if template == "integration": + changes = {"codeowners": [info.codeowner]} + + if info.requirement: + changes["requirements"] = [info.requirement] + + info.update_manifest(**changes) + + if template == "config_flow": + info.update_manifest(config_flow=True) + info.update_strings( + config={ + "title": info.name, + "step": { + "user": {"title": "Connect to the device", "data": {"host": "Host"}} + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error", + }, + "abort": {"already_configured": "Device is already configured"}, + } + ) + + if template == "config_flow_discovery": + info.update_manifest(config_flow=True) + info.update_strings( + config={ + "title": info.name, + "step": { + "confirm": { + "title": info.name, + "description": f"Do you want to set up {info.name}?", + } + }, + "abort": { + "single_instance_allowed": f"Only a single configuration of {info.name} is possible.", + "no_devices_found": f"No {info.name} devices found on the network.", + }, + } + ) + + if template in ("config_flow", "config_flow_discovery"): + init_file = info.integration_dir / "__init__.py" + init_file.write_text( + init_file.read_text() + + """ + +async def async_setup_entry(hass, entry): + \"\"\"Set up a config entry for NEW_NAME.\"\"\" + # TODO forward the entry for each platform that you want to set up. + # hass.async_create_task( + # hass.config_entries.async_forward_entry_setup(entry, "media_player") + # ) + + return True +""" + ) diff --git a/script/scaffold/model.py b/script/scaffold/model.py new file mode 100644 index 00000000000000..68ab771122e0ab --- /dev/null +++ b/script/scaffold/model.py @@ -0,0 +1,61 @@ +"""Models for scaffolding.""" +import json +from pathlib import Path + +import attr + +from .const import COMPONENT_DIR, TESTS_DIR + + +@attr.s +class Info: + """Info about new integration.""" + + domain: str = attr.ib() + name: str = attr.ib() + codeowner: str = attr.ib(default=None) + requirement: str = attr.ib(default=None) + authentication: str = attr.ib(default=None) + discoverable: str = attr.ib(default=None) + + @property + def integration_dir(self) -> Path: + """Return directory if integration.""" + return COMPONENT_DIR / self.domain + + @property + def tests_dir(self) -> Path: + """Return test directory.""" + return TESTS_DIR / self.domain + + @property + def manifest_path(self) -> Path: + """Path to the manifest.""" + return COMPONENT_DIR / self.domain / "manifest.json" + + def manifest(self) -> dict: + """Return integration manifest.""" + return json.loads(self.manifest_path.read_text()) + + def update_manifest(self, **kwargs) -> None: + """Update the integration manifest.""" + print(f"Updating {self.domain} manifest: {kwargs}") + self.manifest_path.write_text( + json.dumps({**self.manifest(), **kwargs}, indent=2) + ) + + @property + def strings_path(self) -> Path: + """Path to the strings.""" + return COMPONENT_DIR / self.domain / "strings.json" + + def strings(self) -> dict: + """Return integration strings.""" + if not self.strings_path.exists(): + return {} + return json.loads(self.strings_path.read_text()) + + def update_strings(self, **kwargs) -> None: + """Update the integration strings.""" + print(f"Updating {self.domain} strings: {list(kwargs)}") + self.strings_path.write_text(json.dumps({**self.strings(), **kwargs}, indent=2)) diff --git a/script/scaffold/templates/config_flow/integration/config_flow.py b/script/scaffold/templates/config_flow/integration/config_flow.py new file mode 100644 index 00000000000000..e08851f47a0358 --- /dev/null +++ b/script/scaffold/templates/config_flow/integration/config_flow.py @@ -0,0 +1,64 @@ +"""Config flow for NEW_NAME integration.""" +import logging + +import voluptuous as vol + +from homeassistant import core, config_entries, exceptions + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +# TODO adjust the data schema to the data that you need +DATA_SCHEMA = vol.Schema({"host": str, "username": str, "password": str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + # TODO validate the data can be used to set up a connection. + # If you cannot connect: + # throw CannotConnect + # If the authentication is wrong: + # InvalidAuth + + # Return some info we want to store in the config entry. + return {"title": "Name of the device"} + + +class DomainConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for NEW_NAME.""" + + VERSION = 1 + # TODO pick one of the available connection classes in homeassistant/config_entries.py + CONNECTION_CLASS = config_entries.CONN_CLASS_UNKNOWN + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + + return self.async_create_entry(title=info["title"], data=user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/script/scaffold/templates/config_flow/tests/test_config_flow.py b/script/scaffold/templates/config_flow/tests/test_config_flow.py new file mode 100644 index 00000000000000..35d8a96ab2b796 --- /dev/null +++ b/script/scaffold/templates/config_flow/tests/test_config_flow.py @@ -0,0 +1,93 @@ +"""Test the NEW_NAME config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, setup +from homeassistant.components.NEW_DOMAIN.const import DOMAIN +from homeassistant.components.NEW_DOMAIN.config_flow import CannotConnect, InvalidAuth + +from tests.common import mock_coro + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.validate_input", + return_value=mock_coro({"title": "Test Title"}), + ), patch( + "homeassistant.components.NEW_DOMAIN.async_setup", return_value=mock_coro(True) + ) as mock_setup, patch( + "homeassistant.components.NEW_DOMAIN.async_setup_entry", + return_value=mock_coro(True), + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Test Title" + assert result2["data"] == { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.validate_input", + side_effect=InvalidAuth, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.NEW_DOMAIN.config_flow.validate_input", + side_effect=CannotConnect, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "username": "test-username", + "password": "test-password", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} diff --git a/script/scaffold/templates/config_flow_discovery/integration/config_flow.py b/script/scaffold/templates/config_flow_discovery/integration/config_flow.py new file mode 100644 index 00000000000000..16d13aaa99ffc0 --- /dev/null +++ b/script/scaffold/templates/config_flow_discovery/integration/config_flow.py @@ -0,0 +1,18 @@ +"""Config flow for NEW_NAME.""" +import my_pypi_dependency + +from homeassistant.helpers import config_entry_flow +from homeassistant import config_entries +from .const import DOMAIN + + +async def _async_has_devices(hass) -> bool: + """Return if there are devices that can be discovered.""" + # TODO Check if there are any devices that can be discovered in the network. + devices = await hass.async_add_executor_job(my_pypi_dependency.discover) + return len(devices) > 0 + + +config_entry_flow.register_discovery_flow( + DOMAIN, "NEW_NAME", _async_has_devices, config_entries.CONN_CLASS_UNKNOWN +) diff --git a/script/scaffold/templates/integration/integration/__init__.py b/script/scaffold/templates/integration/integration/__init__.py new file mode 100644 index 00000000000000..7ab8b736782f62 --- /dev/null +++ b/script/scaffold/templates/integration/integration/__init__.py @@ -0,0 +1,12 @@ +"""The NEW_NAME integration.""" +import voluptuous as vol + +from .const import DOMAIN + + +CONFIG_SCHEMA = vol.Schema({vol.Optional(DOMAIN): {}}) + + +async def async_setup(hass, config): + """Set up the NEW_NAME integration.""" + return True diff --git a/script/scaffold/templates/integration/integration/const.py b/script/scaffold/templates/integration/integration/const.py new file mode 100644 index 00000000000000..e8a1c494d49729 --- /dev/null +++ b/script/scaffold/templates/integration/integration/const.py @@ -0,0 +1,3 @@ +"""Constants for the NEW_NAME integration.""" + +DOMAIN = "NEW_DOMAIN" diff --git a/script/scaffold/templates/integration/integration/manifest.json b/script/scaffold/templates/integration/integration/manifest.json new file mode 100644 index 00000000000000..cb4ecac61fb76c --- /dev/null +++ b/script/scaffold/templates/integration/integration/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "NEW_DOMAIN", + "name": "NEW_NAME", + "config_flow": false, + "documentation": "https://www.home-assistant.io/components/NEW_DOMAIN", + "requirements": [], + "ssdp": {}, + "homekit": {}, + "dependencies": [], + "codeowners": [] +} diff --git a/script/test_docker b/script/test_docker deleted file mode 100755 index bbea52a3a0bd61..00000000000000 --- a/script/test_docker +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/sh -# Executes the tests with tox in a docker container. -# Every argument is passed to tox to allow running only a subset of tests. -# The following example will only run media_player tests: -# ./test_docker -- tests/components/media_player/ - -# Stop on errors -set -e - -cd "$(dirname "$0")/.." - -docker build -t home-assistant-test -f virtualization/Docker/Dockerfile.dev . -docker run --rm \ - -v `pwd`/.tox/:/usr/src/app/.tox/ \ - -t -i home-assistant-test \ - tox -e py36 ${@:2} diff --git a/script/translations_upload b/script/translations_upload index 22a2bbceba202f..fec8a3387c1dae 100755 --- a/script/translations_upload +++ b/script/translations_upload @@ -27,7 +27,7 @@ LANG_ISO=en CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) # Check Travis and Azure environment as well -if [ "${CURRENT_BRANCH-}" != "dev" ] && [ "${TRAVIS_BRANCH-}" != "dev" ] && [ "${AZURE_BRANCH-}" != "dev" ]; then +if [ "${CURRENT_BRANCH-}" != "dev" ] && [ "${AZURE_BRANCH-}" != "dev" ]; then echo "Please only run the translations upload script from a clean checkout of dev." exit 1 fi diff --git a/script/travis_deploy b/script/travis_deploy deleted file mode 100755 index 359f6a46077654..00000000000000 --- a/script/travis_deploy +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -# Safe bash settings -# -e Exit on command fail -# -u Exit on unset variable -# -o pipefail Exit if piped command has error code -set -eu -o pipefail - -cd "$(dirname "$0")/.." - -script/translations_upload diff --git a/script/version_bump.py b/script/version_bump.py index d5df8e66902f0d..de6638df30ba8c 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -108,7 +108,7 @@ def write_version(version): content = re.sub("MAJOR_VERSION = .*\n", f"MAJOR_VERSION = {major}\n", content) content = re.sub("MINOR_VERSION = .*\n", f"MINOR_VERSION = {minor}\n", content) - content = re.sub("PATCH_VERSION = .*\n", f"PATCH_VERSION = '{patch}'\n", content) + content = re.sub("PATCH_VERSION = .*\n", f'PATCH_VERSION = "{patch}"\n', content) with open("homeassistant/const.py", "wt") as fil: content = fil.write(content) @@ -126,9 +126,15 @@ def main(): "--commit", action="store_true", help="Create a version bump commit." ) arguments = parser.parse_args() + + if arguments.commit and subprocess.run(["git", "diff", "--quiet"]).returncode == 1: + print("Cannot use --commit because git is dirty.") + return + current = Version(const.__version__) bumped = bump_version(current, arguments.type) assert bumped > current, "BUG! New version is not newer than old version" + write_version(bumped) if not arguments.commit: diff --git a/setup.py b/setup.py index 5ab8d74c64cf00..26f112bb008207 100755 --- a/setup.py +++ b/setup.py @@ -31,20 +31,20 @@ PACKAGES = find_packages(exclude=["tests", "tests.*"]) REQUIRES = [ - "aiohttp==3.5.4", + "aiohttp==3.6.1", "astral==1.10.1", "async_timeout==3.0.1", "attrs==19.1.0", "bcrypt==3.1.7", "certifi>=2019.6.16", 'contextvars==2.4;python_version<"3.7"', - "importlib-metadata==0.19", + "importlib-metadata==0.23", "jinja2>=2.10.1", "PyJWT==1.7.1", # PyJWT has loose dependency. We want the latest one. "cryptography==2.7", "pip>=8.0.3", - "python-slugify==3.0.3", + "python-slugify==3.0.4", "pytz>=2019.02", "pyyaml==5.1.2", "requests==2.22.0", diff --git a/tests/common.py b/tests/common.py index 0e2f701c210a75..fda5c743222703 100644 --- a/tests/common.py +++ b/tests/common.py @@ -602,40 +602,40 @@ def __init__( ) -class MockToggleDevice(entity.ToggleEntity): +class MockToggleEntity(entity.ToggleEntity): """Provide a mock toggle device.""" - def __init__(self, name, state): - """Initialize the mock device.""" + def __init__(self, name, state, unique_id=None): + """Initialize the mock entity.""" self._name = name or DEVICE_DEFAULT_NAME self._state = state self.calls = [] @property def name(self): - """Return the name of the device if any.""" + """Return the name of the entity if any.""" self.calls.append(("name", {})) return self._name @property def state(self): - """Return the name of the device if any.""" + """Return the state of the entity if any.""" self.calls.append(("state", {})) return self._state @property def is_on(self): - """Return true if device is on.""" + """Return true if entity is on.""" self.calls.append(("is_on", {})) return self._state == STATE_ON def turn_on(self, **kwargs): - """Turn the device on.""" + """Turn the entity on.""" self.calls.append(("turn_on", kwargs)) self._state = STATE_ON def turn_off(self, **kwargs): - """Turn the device off.""" + """Turn the entity off.""" self.calls.append(("turn_off", kwargs)) self._state = STATE_OFF @@ -1030,3 +1030,18 @@ def capture_events(event): hass.bus.async_listen(event_name, capture_events) return events + + +@ha.callback +def async_mock_signal(hass, signal): + """Catch all dispatches to a signal.""" + calls = [] + + @ha.callback + def mock_signal_handler(*args): + """Mock service call.""" + calls.append(args) + + hass.helpers.dispatcher.async_dispatcher_connect(signal, mock_signal_handler) + + return calls diff --git a/tests/components/binary_sensor/test_device_automation.py b/tests/components/binary_sensor/test_device_automation.py new file mode 100644 index 00000000000000..91124d47f4e4ef --- /dev/null +++ b/tests/components/binary_sensor/test_device_automation.py @@ -0,0 +1,309 @@ +"""The test for binary_sensor device automation.""" +import pytest + +from homeassistant.components.binary_sensor import DOMAIN, DEVICE_CLASSES +from homeassistant.components.binary_sensor.device_automation import ( + ENTITY_CONDITIONS, + ENTITY_TRIGGERS, +) +from homeassistant.const import STATE_ON, STATE_OFF, CONF_PLATFORM +from homeassistant.setup import async_setup_component +import homeassistant.components.automation as automation +from homeassistant.components.device_automation import ( + _async_get_device_automations as async_get_device_automations, +) +from homeassistant.helpers import device_registry + +from tests.common import ( + MockConfigEntry, + async_mock_service, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def calls(hass): + """Track calls to a mock serivce.""" + return async_mock_service(hass, "test", "automation") + + +def _same_lists(a, b): + if len(a) != len(b): + return False + + for d in a: + if d not in b: + return False + return True + + +async def test_get_actions(hass, device_reg, entity_reg): + """Test we get the expected actions from a binary_sensor.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create( + DOMAIN, + "test", + platform.ENTITIES["battery"].unique_id, + device_id=device_entry.id, + ) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_actions = [] + actions = await async_get_device_automations( + hass, "async_get_actions", device_entry.id + ) + assert _same_lists(actions, expected_actions) + + +async def test_get_conditions(hass, device_reg, entity_reg): + """Test we get the expected conditions from a binary_sensor.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + for device_class in DEVICE_CLASSES: + entity_reg.async_get_or_create( + DOMAIN, + "test", + platform.ENTITIES[device_class].unique_id, + device_id=device_entry.id, + ) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_conditions = [ + { + "condition": "device", + "domain": DOMAIN, + "type": condition["type"], + "device_id": device_entry.id, + "entity_id": platform.ENTITIES[device_class].entity_id, + } + for device_class in DEVICE_CLASSES + for condition in ENTITY_CONDITIONS[device_class] + ] + conditions = await async_get_device_automations( + hass, "async_get_conditions", device_entry.id + ) + assert _same_lists(conditions, expected_conditions) + + +async def test_get_triggers(hass, device_reg, entity_reg): + """Test we get the expected triggers from a binary_sensor.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + for device_class in DEVICE_CLASSES: + entity_reg.async_get_or_create( + DOMAIN, + "test", + platform.ENTITIES[device_class].unique_id, + device_id=device_entry.id, + ) + + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": trigger["type"], + "device_id": device_entry.id, + "entity_id": platform.ENTITIES[device_class].entity_id, + } + for device_class in DEVICE_CLASSES + for trigger in ENTITY_TRIGGERS[device_class] + ] + triggers = await async_get_device_automations( + hass, "async_get_triggers", device_entry.id + ) + assert _same_lists(triggers, expected_triggers) + + +async def test_if_fires_on_state_change(hass, calls): + """Test for on and off triggers firing.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + sensor1 = platform.ENTITIES["battery"] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": sensor1.entity_id, + "type": "bat_low", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "bat_low {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": sensor1.entity_id, + "type": "not_bat_low", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "not_bat_low {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get(sensor1.entity_id).state == STATE_ON + assert len(calls) == 0 + + hass.states.async_set(sensor1.entity_id, STATE_OFF) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "not_bat_low state - {} - on - off - None".format( + sensor1.entity_id + ) + + hass.states.async_set(sensor1.entity_id, STATE_ON) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "bat_low state - {} - off - on - None".format( + sensor1.entity_id + ) + + +async def test_if_state(hass, calls): + """Test for turn_on and turn_off conditions.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + sensor1 = platform.ENTITIES["battery"] + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": sensor1.entity_id, + "type": "is_bat_low", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_on {{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event2"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": sensor1.entity_id, + "type": "is_not_bat_low", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_off {{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get(sensor1.entity_id).state == STATE_ON + assert len(calls) == 0 + + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_on event - test_event1" + + hass.states.async_set(sensor1.entity_id, STATE_OFF) + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "is_off event - test_event2" diff --git a/tests/components/cast/test_home_assistant_cast.py b/tests/components/cast/test_home_assistant_cast.py new file mode 100644 index 00000000000000..8db6fd4609e850 --- /dev/null +++ b/tests/components/cast/test_home_assistant_cast.py @@ -0,0 +1,50 @@ +"""Test Home Assistant Cast.""" +from unittest.mock import Mock, patch +from homeassistant.components.cast import home_assistant_cast + +from tests.common import MockConfigEntry, async_mock_signal + + +async def test_service_show_view(hass): + """Test we don't set app id in prod.""" + hass.config.api = Mock(base_url="http://example.com") + await home_assistant_cast.async_setup_ha_cast(hass, MockConfigEntry()) + calls = async_mock_signal(hass, home_assistant_cast.SIGNAL_HASS_CAST_SHOW_VIEW) + + await hass.services.async_call( + "cast", + "show_lovelace_view", + {"entity_id": "media_player.kitchen", "view_path": "mock_path"}, + blocking=True, + ) + + assert len(calls) == 1 + controller, entity_id, view_path = calls[0] + assert controller.hass_url == "http://example.com" + assert controller.client_id is None + # Verify user did not accidentally submit their dev app id + assert controller.supporting_app_id == "B12CE3CA" + assert entity_id == "media_player.kitchen" + assert view_path == "mock_path" + + +async def test_use_cloud_url(hass): + """Test that we fall back to cloud url.""" + hass.config.api = Mock(base_url="http://example.com") + await home_assistant_cast.async_setup_ha_cast(hass, MockConfigEntry()) + calls = async_mock_signal(hass, home_assistant_cast.SIGNAL_HASS_CAST_SHOW_VIEW) + + with patch( + "homeassistant.components.cloud.async_remote_ui_url", + return_value="https://something.nabu.acas", + ): + await hass.services.async_call( + "cast", + "show_lovelace_view", + {"entity_id": "media_player.kitchen", "view_path": "mock_path"}, + blocking=True, + ) + + assert len(calls) == 1 + controller = calls[0][0] + assert controller.hass_url == "https://something.nabu.acas" diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 7995ba8f7817e0..8f33709fb2d1a4 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -22,12 +22,16 @@ @pytest.fixture(autouse=True) def cast_mock(): """Mock pychromecast.""" - with patch.dict( - "sys.modules", - { - "pychromecast": MagicMock(), - "pychromecast.controllers.multizone": MagicMock(), - }, + pycast_mock = MagicMock() + + with patch( + "homeassistant.components.cast.media_player.pychromecast", pycast_mock + ), patch( + "homeassistant.components.cast.discovery.pychromecast", pycast_mock + ), patch( + "homeassistant.components.cast.helpers.dial", MagicMock() + ), patch( + "homeassistant.components.cast.media_player.MultizoneManager", MagicMock() ): yield @@ -73,7 +77,8 @@ async def async_setup_cast_internal_discovery(hass, config=None, discovery_info= browser = MagicMock(zc={}) with patch( - "pychromecast.start_discovery", return_value=(listener, browser) + "homeassistant.components.cast.discovery.pychromecast.start_discovery", + return_value=(listener, browser), ) as start_discovery: add_entities = await async_setup_cast(hass, config, discovery_info) await hass.async_block_till_done() @@ -104,7 +109,8 @@ async def async_setup_media_player_cast(hass: HomeAssistantType, info: Chromecas cast.CastStatusListener = MagicMock() with patch( - "pychromecast._get_chromecast_from_host", return_value=chromecast + "homeassistant.components.cast.discovery.pychromecast._get_chromecast_from_host", + return_value=chromecast, ) as get_chromecast: await async_setup_component( hass, @@ -122,7 +128,8 @@ async def async_setup_media_player_cast(hass: HomeAssistantType, info: Chromecas def test_start_discovery_called_once(hass): """Test pychromecast.start_discovery called exactly once.""" with patch( - "pychromecast.start_discovery", return_value=(None, None) + "homeassistant.components.cast.discovery.pychromecast.start_discovery", + return_value=(None, None), ) as start_discovery: yield from async_setup_cast(hass) @@ -138,14 +145,17 @@ def test_stop_discovery_called_on_stop(hass): browser = MagicMock(zc={}) with patch( - "pychromecast.start_discovery", return_value=(None, browser) + "homeassistant.components.cast.discovery.pychromecast.start_discovery", + return_value=(None, browser), ) as start_discovery: # start_discovery should be called with empty config yield from async_setup_cast(hass, {}) assert start_discovery.call_count == 1 - with patch("pychromecast.stop_discovery") as stop_discovery: + with patch( + "homeassistant.components.cast.discovery.pychromecast.stop_discovery" + ) as stop_discovery: # stop discovery should be called on shutdown hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) yield from hass.async_block_till_done() @@ -153,7 +163,8 @@ def test_stop_discovery_called_on_stop(hass): stop_discovery.assert_called_once_with(browser) with patch( - "pychromecast.start_discovery", return_value=(None, browser) + "homeassistant.components.cast.discovery.pychromecast.start_discovery", + return_value=(None, browser), ) as start_discovery: # start_discovery should be called again on re-startup yield from async_setup_cast(hass) @@ -173,7 +184,10 @@ async def test_internal_discovery_callback_fill_out(hass): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - with patch("pychromecast.dial.get_device_status", return_value=full_info): + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=full_info, + ): signal = MagicMock() async_dispatcher_connect(hass, "cast_discovered", signal) @@ -210,7 +224,7 @@ async def test_normal_chromecast_not_starting_discovery(hass): """Test cast platform not starting discovery when not required.""" # pylint: disable=no-member with patch( - "homeassistant.components.cast.media_player." "_setup_internal_discovery" + "homeassistant.components.cast.media_player.setup_internal_discovery" ) as setup_discovery: # normal (non-group) chromecast shouldn't start discovery. add_entities = await async_setup_cast(hass, {"host": "host1"}) @@ -275,7 +289,10 @@ async def test_entity_media_states(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - with patch("pychromecast.dial.get_device_status", return_value=full_info): + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=full_info, + ): chromecast, entity = await async_setup_media_player_cast(hass, info) entity._available = True @@ -330,7 +347,10 @@ async def test_group_media_states(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - with patch("pychromecast.dial.get_device_status", return_value=full_info): + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=full_info, + ): chromecast, entity = await async_setup_media_player_cast(hass, info) entity._available = True @@ -377,7 +397,10 @@ async def test_dynamic_group_media_states(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - with patch("pychromecast.dial.get_device_status", return_value=full_info): + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=full_info, + ): chromecast, entity = await async_setup_media_player_cast(hass, info) entity._available = True @@ -426,12 +449,14 @@ async def test_group_media_control(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - with patch("pychromecast.dial.get_device_status", return_value=full_info): + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=full_info, + ): chromecast, entity = await async_setup_media_player_cast(hass, info) entity._available = True - entity.schedule_update_ha_state() - await hass.async_block_till_done() + entity.async_write_ha_state() state = hass.states.get("media_player.speaker") assert state is not None @@ -480,7 +505,10 @@ async def test_dynamic_group_media_control(hass: HomeAssistantType): info, model_name="google home", friendly_name="Speaker", uuid=FakeUUID ) - with patch("pychromecast.dial.get_device_status", return_value=full_info): + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=full_info, + ): chromecast, entity = await async_setup_media_player_cast(hass, info) entity._available = True @@ -529,7 +557,10 @@ async def test_disconnect_on_stop(hass: HomeAssistantType): """Test cast device disconnects socket on stop.""" info = get_fake_chromecast_info() - with patch("pychromecast.dial.get_device_status", return_value=info): + with patch( + "homeassistant.components.cast.helpers.dial.get_device_status", + return_value=info, + ): chromecast, _ = await async_setup_media_player_cast(hass, info) hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py index f8c99496a563eb..f44e65512e35e5 100644 --- a/tests/components/cert_expiry/test_config_flow.py +++ b/tests/components/cert_expiry/test_config_flow.py @@ -8,7 +8,7 @@ from homeassistant.components.cert_expiry.const import DEFAULT_PORT from homeassistant.const import CONF_PORT, CONF_NAME, CONF_HOST -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, mock_coro NAME = "Cert Expiry test 1 2 3" PORT = 443 @@ -20,7 +20,7 @@ def mock_controller(): """Mock a successfull _prt_in_configuration_exists.""" with patch( "homeassistant.components.cert_expiry.config_flow.CertexpiryConfigFlow._test_connection", - return_value=True, + side_effect=lambda *_: mock_coro(True), ): yield diff --git a/tests/components/deconz/test_binary_sensor.py b/tests/components/deconz/test_binary_sensor.py index acf06728d0d0d5..2f42193291c8dd 100644 --- a/tests/components/deconz/test_binary_sensor.py +++ b/tests/components/deconz/test_binary_sensor.py @@ -1,81 +1,54 @@ """deCONZ binary sensor platform tests.""" -from unittest.mock import Mock, patch +from copy import deepcopy -from tests.common import mock_coro - -from homeassistant import config_entries from homeassistant.components import deconz -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component import homeassistant.components.binary_sensor as binary_sensor +from .test_gateway import ENTRY_CONFIG, DECONZ_WEB_REQUEST, setup_deconz_integration -SENSOR = { +SENSORS = { "1": { - "id": "Sensor 1 id", - "name": "Sensor 1 name", + "id": "Presence sensor id", + "name": "Presence sensor", "type": "ZHAPresence", - "state": {"presence": False}, - "config": {}, + "state": {"dark": False, "presence": False}, + "config": {"on": True, "reachable": True, "temperature": 10}, "uniqueid": "00:00:00:00:00:00:00:00-00", }, "2": { - "id": "Sensor 2 id", - "name": "Sensor 2 name", + "id": "Temperature sensor id", + "name": "Temperature sensor", "type": "ZHATemperature", "state": {"temperature": False}, "config": {}, + "uniqueid": "00:00:00:00:00:00:00:01-00", + }, + "3": { + "id": "CLIP presence sensor id", + "name": "CLIP presence sensor", + "type": "CLIPPresence", + "state": {}, + "config": {}, + "uniqueid": "00:00:00:00:00:00:00:02-00", + }, + "4": { + "id": "Vibration sensor id", + "name": "Vibration sensor", + "type": "ZHAVibration", + "state": { + "orientation": [1, 2, 3], + "tiltangle": 36, + "vibration": True, + "vibrationstrength": 10, + }, + "config": {"on": True, "reachable": True, "temperature": 10}, + "uniqueid": "00:00:00:00:00:00:00:03-00", }, } -ENTRY_CONFIG = { - deconz.config_flow.CONF_API_KEY: "ABCDEF", - deconz.config_flow.CONF_BRIDGEID: "0123456789", - deconz.config_flow.CONF_HOST: "1.2.3.4", - deconz.config_flow.CONF_PORT: 80, -} - -ENTRY_OPTIONS = { - deconz.const.CONF_ALLOW_CLIP_SENSOR: True, - deconz.const.CONF_ALLOW_DECONZ_GROUPS: True, -} - - -async def setup_gateway(hass, data, allow_clip_sensor=True): - """Load the deCONZ binary sensor platform.""" - from pydeconz import DeconzSession - - loop = Mock() - session = Mock() - - ENTRY_OPTIONS[deconz.const.CONF_ALLOW_CLIP_SENSOR] = allow_clip_sensor - - config_entry = config_entries.ConfigEntry( - 1, - deconz.DOMAIN, - "Mock Title", - ENTRY_CONFIG, - "test", - config_entries.CONN_CLASS_LOCAL_PUSH, - system_options={}, - options=ENTRY_OPTIONS, - ) - gateway = deconz.DeconzGateway(hass, config_entry) - gateway.api = DeconzSession(loop, session, **config_entry.data) - gateway.api.config = Mock() - hass.data[deconz.DOMAIN] = {gateway.bridgeid: gateway} - - with patch("pydeconz.DeconzSession.async_get_state", return_value=mock_coro(data)): - await gateway.api.async_load_parameters() - - await hass.config_entries.async_forward_entry_setup(config_entry, "binary_sensor") - # To flush out the service call to update the group - await hass.async_block_till_done() - return gateway - - async def test_platform_manually_configured(hass): """Test that we do not discover anything or try to set up a gateway.""" assert ( @@ -89,58 +62,98 @@ async def test_platform_manually_configured(hass): async def test_no_binary_sensors(hass): """Test that no sensors in deconz results in no sensor entities.""" - data = {} - gateway = await setup_gateway(hass, data) - assert len(hass.data[deconz.DOMAIN][gateway.bridgeid].deconz_ids) == 0 + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert len(gateway.deconz_ids) == 0 assert len(hass.states.async_all()) == 0 async def test_binary_sensors(hass): """Test successful creation of binary sensor entities.""" - data = {"sensors": SENSOR} - gateway = await setup_gateway(hass, data) - assert "binary_sensor.sensor_1_name" in gateway.deconz_ids - assert "binary_sensor.sensor_2_name" not in gateway.deconz_ids - assert len(hass.states.async_all()) == 1 - - hass.data[deconz.DOMAIN][gateway.bridgeid].api.sensors["1"].async_update( - {"state": {"on": False}} + data = deepcopy(DECONZ_WEB_REQUEST) + data["sensors"] = deepcopy(SENSORS) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data ) + assert "binary_sensor.presence_sensor" in gateway.deconz_ids + assert "binary_sensor.temperature_sensor" not in gateway.deconz_ids + assert "binary_sensor.clip_presence_sensor" not in gateway.deconz_ids + assert "binary_sensor.vibration_sensor" in gateway.deconz_ids + assert len(hass.states.async_all()) == 3 + presence_sensor = hass.states.get("binary_sensor.presence_sensor") + assert presence_sensor.state == "off" -async def test_add_new_sensor(hass): - """Test successful creation of sensor entities.""" - data = {} - gateway = await setup_gateway(hass, data) - sensor = Mock() - sensor.name = "name" - sensor.type = "ZHAPresence" - sensor.BINARY = True - sensor.uniqueid = "1" - sensor.register_async_callback = Mock() - async_dispatcher_send(hass, gateway.async_event_new_device("sensor"), [sensor]) - await hass.async_block_till_done() - assert "binary_sensor.name" in gateway.deconz_ids - - -async def test_do_not_allow_clip_sensor(hass): - """Test that clip sensors can be ignored.""" - data = {} - gateway = await setup_gateway(hass, data, allow_clip_sensor=False) - sensor = Mock() - sensor.name = "name" - sensor.type = "CLIPPresence" - sensor.register_async_callback = Mock() - async_dispatcher_send(hass, gateway.async_event_new_device("sensor"), [sensor]) - await hass.async_block_till_done() - assert len(gateway.deconz_ids) == 0 + temperature_sensor = hass.states.get("binary_sensor.temperature_sensor") + assert temperature_sensor is None + clip_presence_sensor = hass.states.get("binary_sensor.clip_presence_sensor") + assert clip_presence_sensor is None + + vibration_sensor = hass.states.get("binary_sensor.vibration_sensor") + assert vibration_sensor.state == "on" + + gateway.api.sensors["1"].async_update({"state": {"presence": True}}) + await hass.async_block_till_done() -async def test_unload_switch(hass): - """Test that it works to unload switch entities.""" - data = {"sensors": SENSOR} - gateway = await setup_gateway(hass, data) + presence_sensor = hass.states.get("binary_sensor.presence_sensor") + assert presence_sensor.state == "on" await gateway.async_reset() assert len(hass.states.async_all()) == 0 + + +async def test_allow_clip_sensor(hass): + """Test that CLIP sensors can be allowed.""" + data = deepcopy(DECONZ_WEB_REQUEST) + data["sensors"] = deepcopy(SENSORS) + gateway = await setup_deconz_integration( + hass, + ENTRY_CONFIG, + options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: True}, + get_state_response=data, + ) + assert "binary_sensor.presence_sensor" in gateway.deconz_ids + assert "binary_sensor.temperature_sensor" not in gateway.deconz_ids + assert "binary_sensor.clip_presence_sensor" in gateway.deconz_ids + assert "binary_sensor.vibration_sensor" in gateway.deconz_ids + assert len(hass.states.async_all()) == 4 + + presence_sensor = hass.states.get("binary_sensor.presence_sensor") + assert presence_sensor.state == "off" + + temperature_sensor = hass.states.get("binary_sensor.temperature_sensor") + assert temperature_sensor is None + + clip_presence_sensor = hass.states.get("binary_sensor.clip_presence_sensor") + assert clip_presence_sensor.state == "off" + + vibration_sensor = hass.states.get("binary_sensor.vibration_sensor") + assert vibration_sensor.state == "on" + + +async def test_add_new_binary_sensor(hass): + """Test that adding a new binary sensor works.""" + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert len(gateway.deconz_ids) == 0 + + state_added = { + "t": "event", + "e": "added", + "r": "sensors", + "id": "1", + "sensor": deepcopy(SENSORS["1"]), + } + gateway.api.async_event_handler(state_added) + await hass.async_block_till_done() + + assert "binary_sensor.presence_sensor" in gateway.deconz_ids + + presence_sensor = hass.states.get("binary_sensor.presence_sensor") + assert presence_sensor.state == "off" diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index 1547f58a12b1bc..cee91f00c4283b 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -1,23 +1,19 @@ """deCONZ climate platform tests.""" from copy import deepcopy -from unittest.mock import Mock, patch -import asynctest +from asynctest import patch -from homeassistant import config_entries from homeassistant.components import deconz -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component import homeassistant.components.climate as climate -from tests.common import mock_coro +from .test_gateway import ENTRY_CONFIG, DECONZ_WEB_REQUEST, setup_deconz_integration - -SENSOR = { +SENSORS = { "1": { - "id": "Climate 1 id", - "name": "Climate 1 name", + "id": "Thermostat id", + "name": "Thermostat", "type": "ZHAThermostat", "state": {"on": True, "temperature": 2260, "valve": 30}, "config": { @@ -30,63 +26,23 @@ "uniqueid": "00:00:00:00:00:00:00:00-00", }, "2": { - "id": "Sensor 2 id", - "name": "Sensor 2 name", + "id": "Presence sensor id", + "name": "Presence sensor", "type": "ZHAPresence", "state": {"presence": False}, - "config": {}, + "config": {"reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:01-00", + }, + "3": { + "id": "CLIP thermostat id", + "name": "CLIP thermostat", + "type": "CLIPThermostat", + "state": {"on": True, "temperature": 2260, "valve": 30}, + "config": {"reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:02-00", }, } -ENTRY_CONFIG = { - deconz.config_flow.CONF_API_KEY: "ABCDEF", - deconz.config_flow.CONF_BRIDGEID: "0123456789", - deconz.config_flow.CONF_HOST: "1.2.3.4", - deconz.config_flow.CONF_PORT: 80, -} - -ENTRY_OPTIONS = { - deconz.const.CONF_ALLOW_CLIP_SENSOR: True, - deconz.const.CONF_ALLOW_DECONZ_GROUPS: True, -} - - -async def setup_gateway(hass, data, allow_clip_sensor=True): - """Load the deCONZ sensor platform.""" - from pydeconz import DeconzSession - - response = Mock( - status=200, json=asynctest.CoroutineMock(), text=asynctest.CoroutineMock() - ) - response.content_type = "application/json" - - session = Mock(put=asynctest.CoroutineMock(return_value=response)) - - ENTRY_OPTIONS[deconz.const.CONF_ALLOW_CLIP_SENSOR] = allow_clip_sensor - - config_entry = config_entries.ConfigEntry( - 1, - deconz.DOMAIN, - "Mock Title", - ENTRY_CONFIG, - "test", - config_entries.CONN_CLASS_LOCAL_PUSH, - system_options={}, - options=ENTRY_OPTIONS, - ) - gateway = deconz.DeconzGateway(hass, config_entry) - gateway.api = DeconzSession(hass.loop, session, **config_entry.data) - gateway.api.config = Mock() - hass.data[deconz.DOMAIN] = {gateway.bridgeid: gateway} - - with patch("pydeconz.DeconzSession.async_get_state", return_value=mock_coro(data)): - await gateway.api.async_load_parameters() - - await hass.config_entries.async_forward_entry_setup(config_entry, "climate") - # To flush out the service call to update the group - await hass.async_block_till_done() - return gateway - async def test_platform_manually_configured(hass): """Test that we do not discover anything or try to set up a gateway.""" @@ -101,69 +57,159 @@ async def test_platform_manually_configured(hass): async def test_no_sensors(hass): """Test that no sensors in deconz results in no climate entities.""" - gateway = await setup_gateway(hass, {}) - assert not hass.data[deconz.DOMAIN][gateway.bridgeid].deconz_ids - assert not hass.states.async_all() + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert len(gateway.deconz_ids) == 0 + assert len(hass.states.async_all()) == 0 async def test_climate_devices(hass): """Test successful creation of sensor entities.""" - gateway = await setup_gateway(hass, {"sensors": deepcopy(SENSOR)}) - assert "climate.climate_1_name" in gateway.deconz_ids - assert "sensor.sensor_2_name" not in gateway.deconz_ids - assert len(hass.states.async_all()) == 1 - - gateway.api.sensors["1"].async_update({"state": {"on": False}}) - - await hass.services.async_call( - "climate", - "set_hvac_mode", - {"entity_id": "climate.climate_1_name", "hvac_mode": "auto"}, - blocking=True, - ) - gateway.api.session.put.assert_called_with( - "http://1.2.3.4:80/api/ABCDEF/sensors/1/config", data='{"mode": "auto"}' + data = deepcopy(DECONZ_WEB_REQUEST) + data["sensors"] = deepcopy(SENSORS) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data ) + assert "climate.thermostat" in gateway.deconz_ids + assert "sensor.thermostat" not in gateway.deconz_ids + assert "sensor.thermostat_battery_level" in gateway.deconz_ids + assert "climate.presence_sensor" not in gateway.deconz_ids + assert "climate.clip_thermostat" not in gateway.deconz_ids + assert len(hass.states.async_all()) == 3 + + thermostat = hass.states.get("climate.thermostat") + assert thermostat.state == "auto" - await hass.services.async_call( - "climate", - "set_hvac_mode", - {"entity_id": "climate.climate_1_name", "hvac_mode": "heat"}, - blocking=True, - ) - gateway.api.session.put.assert_called_with( - "http://1.2.3.4:80/api/ABCDEF/sensors/1/config", data='{"mode": "heat"}' - ) + thermostat = hass.states.get("sensor.thermostat") + assert thermostat is None - await hass.services.async_call( - "climate", - "set_hvac_mode", - {"entity_id": "climate.climate_1_name", "hvac_mode": "off"}, - blocking=True, - ) - gateway.api.session.put.assert_called_with( - "http://1.2.3.4:80/api/ABCDEF/sensors/1/config", data='{"mode": "off"}' - ) + thermostat_battery_level = hass.states.get("sensor.thermostat_battery_level") + assert thermostat_battery_level.state == "100" - await hass.services.async_call( - "climate", - "set_temperature", - {"entity_id": "climate.climate_1_name", "temperature": 20}, - blocking=True, - ) - gateway.api.session.put.assert_called_with( - "http://1.2.3.4:80/api/ABCDEF/sensors/1/config", data='{"heatsetpoint": 2000.0}' + presence_sensor = hass.states.get("climate.presence_sensor") + assert presence_sensor is None + + clip_thermostat = hass.states.get("climate.clip_thermostat") + assert clip_thermostat is None + + thermostat_device = gateway.api.sensors["1"] + + thermostat_device.async_update({"config": {"mode": "off"}}) + await hass.async_block_till_done() + + thermostat = hass.states.get("climate.thermostat") + assert thermostat.state == "off" + + thermostat_device.async_update({"config": {"mode": "other"}, "state": {"on": True}}) + await hass.async_block_till_done() + + thermostat = hass.states.get("climate.thermostat") + assert thermostat.state == "heat" + + thermostat_device.async_update({"state": {"on": False}}) + await hass.async_block_till_done() + + thermostat = hass.states.get("climate.thermostat") + assert thermostat.state == "off" + + # Verify service calls + + with patch.object( + thermostat_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.thermostat", "hvac_mode": "auto"}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("/sensors/1/config", {"mode": "auto"}) + + with patch.object( + thermostat_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.thermostat", "hvac_mode": "heat"}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("/sensors/1/config", {"mode": "heat"}) + + with patch.object( + thermostat_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_HVAC_MODE, + {"entity_id": "climate.thermostat", "hvac_mode": "off"}, + blocking=True, + ) + set_callback.assert_called_with("/sensors/1/config", {"mode": "off"}) + + with patch.object( + thermostat_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + climate.DOMAIN, + climate.SERVICE_SET_TEMPERATURE, + {"entity_id": "climate.thermostat", "temperature": 20}, + blocking=True, + ) + set_callback.assert_called_with("/sensors/1/config", {"heatsetpoint": 2000.0}) + + await gateway.async_reset() + + assert len(hass.states.async_all()) == 0 + + +async def test_clip_climate_device(hass): + """Test successful creation of sensor entities.""" + data = deepcopy(DECONZ_WEB_REQUEST) + data["sensors"] = deepcopy(SENSORS) + gateway = await setup_deconz_integration( + hass, + ENTRY_CONFIG, + options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: True}, + get_state_response=data, ) + assert "climate.thermostat" in gateway.deconz_ids + assert "sensor.thermostat" not in gateway.deconz_ids + assert "sensor.thermostat_battery_level" in gateway.deconz_ids + assert "climate.presence_sensor" not in gateway.deconz_ids + assert "climate.clip_thermostat" in gateway.deconz_ids + assert len(hass.states.async_all()) == 4 + + thermostat = hass.states.get("climate.thermostat") + assert thermostat.state == "auto" + + thermostat = hass.states.get("sensor.thermostat") + assert thermostat is None - assert len(gateway.api.session.put.mock_calls) == 4 + thermostat_battery_level = hass.states.get("sensor.thermostat_battery_level") + assert thermostat_battery_level.state == "100" + + presence_sensor = hass.states.get("climate.presence_sensor") + assert presence_sensor is None + + clip_thermostat = hass.states.get("climate.clip_thermostat") + assert clip_thermostat.state == "heat" async def test_verify_state_update(hass): """Test that state update properly.""" - gateway = await setup_gateway(hass, {"sensors": deepcopy(SENSOR)}) - assert "climate.climate_1_name" in gateway.deconz_ids + data = deepcopy(DECONZ_WEB_REQUEST) + data["sensors"] = deepcopy(SENSORS) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert "climate.thermostat" in gateway.deconz_ids - thermostat = hass.states.get("climate.climate_1_name") + thermostat = hass.states.get("climate.thermostat") assert thermostat.state == "auto" state_update = { @@ -174,44 +220,32 @@ async def test_verify_state_update(hass): "state": {"on": False}, } gateway.api.async_event_handler(state_update) - await hass.async_block_till_done() - assert len(hass.states.async_all()) == 1 - thermostat = hass.states.get("climate.climate_1_name") + thermostat = hass.states.get("climate.thermostat") assert thermostat.state == "auto" assert gateway.api.sensors["1"].changed_keys == {"state", "r", "t", "on", "e", "id"} async def test_add_new_climate_device(hass): - """Test successful creation of climate entities.""" - gateway = await setup_gateway(hass, {}) - sensor = Mock() - sensor.name = "name" - sensor.type = "ZHAThermostat" - sensor.uniqueid = "1" - sensor.register_async_callback = Mock() - async_dispatcher_send(hass, gateway.async_event_new_device("sensor"), [sensor]) - await hass.async_block_till_done() - assert "climate.name" in gateway.deconz_ids - - -async def test_do_not_allow_clipsensor(hass): - """Test that clip sensors can be ignored.""" - gateway = await setup_gateway(hass, {}, allow_clip_sensor=False) - sensor = Mock() - sensor.name = "name" - sensor.type = "CLIPThermostat" - sensor.register_async_callback = Mock() - async_dispatcher_send(hass, gateway.async_event_new_device("sensor"), [sensor]) - await hass.async_block_till_done() + """Test that adding a new climate device works.""" + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) assert len(gateway.deconz_ids) == 0 + state_added = { + "t": "event", + "e": "added", + "r": "sensors", + "id": "1", + "sensor": deepcopy(SENSORS["1"]), + } + gateway.api.async_event_handler(state_added) + await hass.async_block_till_done() -async def test_unload_sensor(hass): - """Test that it works to unload sensor entities.""" - gateway = await setup_gateway(hass, {"sensors": SENSOR}) - - await gateway.async_reset() + assert "climate.thermostat" in gateway.deconz_ids - assert len(hass.states.async_all()) == 0 + thermostat = hass.states.get("climate.thermostat") + assert thermostat.state == "auto" diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index ea3abead02870e..d7071d6daef08c 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -234,41 +234,6 @@ async def test_bridge_discovery_update_existing_entry(hass): assert entry.data[config_flow.CONF_HOST] == "mock-deconz" -async def test_import_without_api_key(hass): - """Test importing a host without an API key.""" - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - data={config_flow.CONF_HOST: "1.2.3.4"}, - context={"source": "import"}, - ) - - assert result["type"] == "form" - assert result["step_id"] == "link" - - -async def test_import_with_api_key(hass): - """Test importing a host with an API key.""" - result = await hass.config_entries.flow.async_init( - config_flow.DOMAIN, - data={ - config_flow.CONF_BRIDGEID: "id", - config_flow.CONF_HOST: "mock-deconz", - config_flow.CONF_PORT: 80, - config_flow.CONF_API_KEY: "1234567890ABCDEF", - }, - context={"source": "import"}, - ) - - assert result["type"] == "create_entry" - assert result["title"] == "deCONZ-id" - assert result["data"] == { - config_flow.CONF_BRIDGEID: "id", - config_flow.CONF_HOST: "mock-deconz", - config_flow.CONF_PORT: 80, - config_flow.CONF_API_KEY: "1234567890ABCDEF", - } - - async def test_create_entry(hass, aioclient_mock): """Test that _create_entry work and that bridgeid can be requested.""" aioclient_mock.get( @@ -382,3 +347,29 @@ async def test_hassio_confirm(hass): config_flow.CONF_BRIDGEID: "id", config_flow.CONF_API_KEY: "1234567890ABCDEF", } + + +async def test_option_flow(hass): + """Test config flow selection of one of two bridges.""" + entry = MockConfigEntry(domain=config_flow.DOMAIN, data={}, options=None) + hass.config_entries._entries.append(entry) + + flow = await hass.config_entries.options._async_create_flow( + entry.entry_id, context={"source": "test"}, data=None + ) + + result = await flow.async_step_init() + assert result["type"] == "form" + assert result["step_id"] == "deconz_devices" + + result = await flow.async_step_deconz_devices( + user_input={ + config_flow.CONF_ALLOW_CLIP_SENSOR: False, + config_flow.CONF_ALLOW_DECONZ_GROUPS: False, + } + ) + assert result["type"] == "create_entry" + assert result["data"] == { + config_flow.CONF_ALLOW_CLIP_SENSOR: False, + config_flow.CONF_ALLOW_DECONZ_GROUPS: False, + } diff --git a/tests/components/deconz/test_cover.py b/tests/components/deconz/test_cover.py index 7230ff4fb7bdff..5c7ee48a78a2eb 100644 --- a/tests/components/deconz/test_cover.py +++ b/tests/components/deconz/test_cover.py @@ -1,84 +1,42 @@ """deCONZ cover platform tests.""" -from unittest.mock import Mock, patch +from copy import deepcopy + +from asynctest import patch -from homeassistant import config_entries from homeassistant.components import deconz -from homeassistant.components.deconz.const import COVER_TYPES -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component import homeassistant.components.cover as cover -from tests.common import mock_coro +from .test_gateway import ENTRY_CONFIG, DECONZ_WEB_REQUEST, setup_deconz_integration -SUPPORTED_COVERS = { +COVERS = { "1": { - "id": "Cover 1 id", - "name": "Cover 1 name", + "id": "Level controllable cover id", + "name": "Level controllable cover", "type": "Level controllable output", "state": {"bri": 255, "on": False, "reachable": True}, "modelid": "Not zigbee spec", "uniqueid": "00:00:00:00:00:00:00:00-00", }, "2": { - "id": "Cover 2 id", - "name": "Cover 2 name", + "id": "Window covering device id", + "name": "Window covering device", "type": "Window covering device", "state": {"bri": 255, "on": True, "reachable": True}, "modelid": "lumi.curtain", + "uniqueid": "00:00:00:00:00:00:00:01-00", }, -} - -UNSUPPORTED_COVER = { - "1": { - "id": "Cover id", - "name": "Unsupported switch", + "3": { + "id": "Unsupported cover id", + "name": "Unsupported cover", "type": "Not a cover", - "state": {}, - } -} - - -ENTRY_CONFIG = { - deconz.const.CONF_ALLOW_CLIP_SENSOR: True, - deconz.const.CONF_ALLOW_DECONZ_GROUPS: True, - deconz.config_flow.CONF_API_KEY: "ABCDEF", - deconz.config_flow.CONF_BRIDGEID: "0123456789", - deconz.config_flow.CONF_HOST: "1.2.3.4", - deconz.config_flow.CONF_PORT: 80, + "state": {"reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:02-00", + }, } -async def setup_gateway(hass, data): - """Load the deCONZ cover platform.""" - from pydeconz import DeconzSession - - loop = Mock() - session = Mock() - - config_entry = config_entries.ConfigEntry( - 1, - deconz.DOMAIN, - "Mock Title", - ENTRY_CONFIG, - "test", - config_entries.CONN_CLASS_LOCAL_PUSH, - system_options={}, - ) - gateway = deconz.DeconzGateway(hass, config_entry) - gateway.api = DeconzSession(loop, session, **config_entry.data) - gateway.api.config = Mock() - hass.data[deconz.DOMAIN] = {gateway.bridgeid: gateway} - - with patch("pydeconz.DeconzSession.async_get_state", return_value=mock_coro(data)): - await gateway.api.async_load_parameters() - - await hass.config_entries.async_forward_entry_setup(config_entry, "cover") - # To flush out the service call to update the group - await hass.async_block_till_done() - return gateway - - async def test_platform_manually_configured(hass): """Test that we do not discover anything or try to set up a gateway.""" assert ( @@ -92,64 +50,73 @@ async def test_platform_manually_configured(hass): async def test_no_covers(hass): """Test that no cover entities are created.""" - gateway = await setup_gateway(hass, {}) - assert not hass.data[deconz.DOMAIN][gateway.bridgeid].deconz_ids + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert len(gateway.deconz_ids) == 0 assert len(hass.states.async_all()) == 0 async def test_cover(hass): """Test that all supported cover entities are created.""" - with patch("pydeconz.DeconzSession.async_put_state", return_value=mock_coro(True)): - gateway = await setup_gateway(hass, {"lights": SUPPORTED_COVERS}) - assert "cover.cover_1_name" in gateway.deconz_ids - assert len(SUPPORTED_COVERS) == len(COVER_TYPES) - assert len(hass.states.async_all()) == 3 - - cover_1 = hass.states.get("cover.cover_1_name") - assert cover_1 is not None - assert cover_1.state == "open" - - gateway.api.lights["1"].async_update({}) - - await hass.services.async_call( - "cover", "open_cover", {"entity_id": "cover.cover_1_name"}, blocking=True - ) - await hass.services.async_call( - "cover", "close_cover", {"entity_id": "cover.cover_1_name"}, blocking=True - ) - await hass.services.async_call( - "cover", "stop_cover", {"entity_id": "cover.cover_1_name"}, blocking=True + data = deepcopy(DECONZ_WEB_REQUEST) + data["lights"] = deepcopy(COVERS) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data ) + assert "cover.level_controllable_cover" in gateway.deconz_ids + assert "cover.window_covering_device" in gateway.deconz_ids + assert "cover.unsupported_cover" not in gateway.deconz_ids + assert len(hass.states.async_all()) == 5 - await hass.services.async_call( - "cover", "close_cover", {"entity_id": "cover.cover_2_name"}, blocking=True - ) + level_controllable_cover = hass.states.get("cover.level_controllable_cover") + assert level_controllable_cover.state == "open" + level_controllable_cover_device = gateway.api.lights["1"] -async def test_add_new_cover(hass): - """Test successful creation of cover entity.""" - data = {} - gateway = await setup_gateway(hass, data) - cover = Mock() - cover.name = "name" - cover.type = "Level controllable output" - cover.uniqueid = "1" - cover.register_async_callback = Mock() - async_dispatcher_send(hass, gateway.async_event_new_device("light"), [cover]) + level_controllable_cover_device.async_update({"state": {"on": True}}) await hass.async_block_till_done() - assert "cover.name" in gateway.deconz_ids - -async def test_unsupported_cover(hass): - """Test that unsupported covers are not created.""" - await setup_gateway(hass, {"lights": UNSUPPORTED_COVER}) - assert len(hass.states.async_all()) == 0 - - -async def test_unload_cover(hass): - """Test that it works to unload switch entities.""" - gateway = await setup_gateway(hass, {"lights": SUPPORTED_COVERS}) + level_controllable_cover = hass.states.get("cover.level_controllable_cover") + assert level_controllable_cover.state == "closed" + + with patch.object( + level_controllable_cover_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + cover.DOMAIN, + cover.SERVICE_OPEN_COVER, + {"entity_id": "cover.level_controllable_cover"}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("/lights/1/state", {"on": False}) + + with patch.object( + level_controllable_cover_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + cover.DOMAIN, + cover.SERVICE_CLOSE_COVER, + {"entity_id": "cover.level_controllable_cover"}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("/lights/1/state", {"on": True, "bri": 255}) + + with patch.object( + level_controllable_cover_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + cover.DOMAIN, + cover.SERVICE_STOP_COVER, + {"entity_id": "cover.level_controllable_cover"}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("/lights/1/state", {"bri_inc": 0}) await gateway.async_reset() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 2 diff --git a/tests/components/deconz/test_deconz_event.py b/tests/components/deconz/test_deconz_event.py new file mode 100644 index 00000000000000..ade9aa02ad4afb --- /dev/null +++ b/tests/components/deconz/test_deconz_event.py @@ -0,0 +1,74 @@ +"""Test deCONZ remote events.""" +from copy import deepcopy + +from asynctest import Mock + +from homeassistant.components.deconz.deconz_event import CONF_DECONZ_EVENT + +from .test_gateway import ENTRY_CONFIG, DECONZ_WEB_REQUEST, setup_deconz_integration + +SENSORS = { + "1": { + "id": "Switch 1 id", + "name": "Switch 1", + "type": "ZHASwitch", + "state": {"buttonevent": 1000}, + "config": {}, + "uniqueid": "00:00:00:00:00:00:00:01-00", + }, + "2": { + "id": "Switch 2 id", + "name": "Switch 2", + "type": "ZHASwitch", + "state": {"buttonevent": 1000}, + "config": {"battery": 100}, + "uniqueid": "00:00:00:00:00:00:00:02-00", + }, +} + + +async def test_deconz_events(hass): + """Test successful creation of deconz events.""" + data = deepcopy(DECONZ_WEB_REQUEST) + data["sensors"] = deepcopy(SENSORS) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert "sensor.switch_1" not in gateway.deconz_ids + assert "sensor.switch_1_battery_level" not in gateway.deconz_ids + assert "sensor.switch_2" not in gateway.deconz_ids + assert "sensor.switch_2_battery_level" in gateway.deconz_ids + assert len(hass.states.async_all()) == 1 + assert len(gateway.events) == 2 + + switch_1 = hass.states.get("sensor.switch_1") + assert switch_1 is None + + switch_1_battery_level = hass.states.get("sensor.switch_1_battery_level") + assert switch_1_battery_level is None + + switch_2 = hass.states.get("sensor.switch_2") + assert switch_2 is None + + switch_2_battery_level = hass.states.get("sensor.switch_2_battery_level") + assert switch_2_battery_level.state == "100" + + mock_listener = Mock() + unsub = hass.bus.async_listen(CONF_DECONZ_EVENT, mock_listener) + + gateway.api.sensors["1"].async_update({"state": {"buttonevent": 2000}}) + await hass.async_block_till_done() + + assert len(mock_listener.mock_calls) == 1 + assert mock_listener.mock_calls[0][1][0].data == { + "id": "switch_1", + "unique_id": "00:00:00:00:00:00:00:01", + "event": 2000, + } + + unsub() + + await gateway.async_reset() + + assert len(hass.states.async_all()) == 0 + assert len(gateway.events) == 0 diff --git a/tests/components/deconz/test_device_automation.py b/tests/components/deconz/test_device_automation.py new file mode 100644 index 00000000000000..0be566d4b52e43 --- /dev/null +++ b/tests/components/deconz/test_device_automation.py @@ -0,0 +1,138 @@ +"""deCONZ device automation tests.""" +from asynctest import patch + +from homeassistant import config_entries +from homeassistant.components import deconz +from homeassistant.components.device_automation import ( + _async_get_device_automations as async_get_device_automations, +) + +BRIDGEID = "0123456789" + +ENTRY_CONFIG = { + deconz.config_flow.CONF_API_KEY: "ABCDEF", + deconz.config_flow.CONF_BRIDGEID: BRIDGEID, + deconz.config_flow.CONF_HOST: "1.2.3.4", + deconz.config_flow.CONF_PORT: 80, +} + +DECONZ_CONFIG = { + "bridgeid": BRIDGEID, + "mac": "00:11:22:33:44:55", + "name": "deCONZ mock gateway", + "sw_version": "2.05.69", + "websocketport": 1234, +} + +DECONZ_SENSOR = { + "1": { + "config": { + "alert": "none", + "battery": 60, + "group": "10", + "on": True, + "reachable": True, + }, + "ep": 1, + "etag": "1b355c0b6d2af28febd7ca9165881952", + "manufacturername": "IKEA of Sweden", + "mode": 1, + "modelid": "TRADFRI on/off switch", + "name": "TRADFRI on/off switch ", + "state": {"buttonevent": 2002, "lastupdated": "2019-09-07T07:39:39"}, + "swversion": "1.4.018", + "type": "ZHASwitch", + "uniqueid": "d0:cf:5e:ff:fe:71:a4:3a-01-1000", + } +} + +DECONZ_WEB_REQUEST = {"config": DECONZ_CONFIG, "sensors": DECONZ_SENSOR} + + +def _same_lists(a, b): + if len(a) != len(b): + return False + + for d in a: + if d not in b: + return False + return True + + +async def setup_deconz(hass, options): + """Create the deCONZ gateway.""" + config_entry = config_entries.ConfigEntry( + version=1, + domain=deconz.DOMAIN, + title="Mock Title", + data=ENTRY_CONFIG, + source="test", + connection_class=config_entries.CONN_CLASS_LOCAL_PUSH, + system_options={}, + options=options, + entry_id="1", + ) + + with patch( + "pydeconz.DeconzSession.async_get_state", return_value=DECONZ_WEB_REQUEST + ): + await deconz.async_setup_entry(hass, config_entry) + await hass.async_block_till_done() + + hass.config_entries._entries.append(config_entry) + + return hass.data[deconz.DOMAIN][BRIDGEID] + + +async def test_get_triggers(hass): + """Test triggers work.""" + gateway = await setup_deconz(hass, options={}) + device_id = gateway.events[0].device_id + triggers = await async_get_device_automations(hass, "async_get_triggers", device_id) + + expected_triggers = [ + { + "device_id": device_id, + "domain": "deconz", + "platform": "device", + "type": deconz.device_automation.CONF_SHORT_PRESS, + "subtype": deconz.device_automation.CONF_TURN_ON, + }, + { + "device_id": device_id, + "domain": "deconz", + "platform": "device", + "type": deconz.device_automation.CONF_LONG_PRESS, + "subtype": deconz.device_automation.CONF_TURN_ON, + }, + { + "device_id": device_id, + "domain": "deconz", + "platform": "device", + "type": deconz.device_automation.CONF_LONG_RELEASE, + "subtype": deconz.device_automation.CONF_TURN_ON, + }, + { + "device_id": device_id, + "domain": "deconz", + "platform": "device", + "type": deconz.device_automation.CONF_SHORT_PRESS, + "subtype": deconz.device_automation.CONF_TURN_OFF, + }, + { + "device_id": device_id, + "domain": "deconz", + "platform": "device", + "type": deconz.device_automation.CONF_LONG_PRESS, + "subtype": deconz.device_automation.CONF_TURN_OFF, + }, + { + "device_id": device_id, + "domain": "deconz", + "platform": "device", + "type": deconz.device_automation.CONF_LONG_RELEASE, + "subtype": deconz.device_automation.CONF_TURN_OFF, + }, + ] + + assert _same_lists(triggers, expected_triggers) diff --git a/tests/components/deconz/test_gateway.py b/tests/components/deconz/test_gateway.py index 3750d14cd342a0..25a1cd465c510a 100644 --- a/tests/components/deconz/test_gateway.py +++ b/tests/components/deconz/test_gateway.py @@ -1,204 +1,178 @@ """Test deCONZ gateway.""" -from unittest.mock import Mock, patch +from copy import deepcopy + +from asynctest import Mock, patch import pytest +from homeassistant import config_entries +from homeassistant.components import deconz +from homeassistant.components import ssdp from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.components.deconz import errors, gateway - -from tests.common import mock_coro +from homeassistant.helpers.dispatcher import async_dispatcher_connect import pydeconz +BRIDGEID = "0123456789" ENTRY_CONFIG = { - "host": "1.2.3.4", - "port": 80, - "api_key": "1234567890ABCDEF", - "bridgeid": "0123456789ABCDEF", - "allow_clip_sensor": True, - "allow_deconz_groups": True, + deconz.config_flow.CONF_API_KEY: "ABCDEF", + deconz.config_flow.CONF_BRIDGEID: BRIDGEID, + deconz.config_flow.CONF_HOST: "1.2.3.4", + deconz.config_flow.CONF_PORT: 80, } +DECONZ_CONFIG = { + "bridgeid": BRIDGEID, + "ipaddress": "1.2.3.4", + "mac": "00:11:22:33:44:55", + "modelid": "deCONZ", + "name": "deCONZ mock gateway", + "sw_version": "2.05.69", + "uuid": "1234", + "websocketport": 1234, +} -async def test_gateway_setup(): - """Successful setup.""" - hass = Mock() - entry = Mock() - entry.data = ENTRY_CONFIG - api = Mock() - api.async_add_remote.return_value = Mock() - api.sensors = {} - - deconz_gateway = gateway.DeconzGateway(hass, entry) - - with patch.object( - gateway, "get_gateway", return_value=mock_coro(api) - ), patch.object(gateway, "async_dispatcher_connect", return_value=Mock()): - assert await deconz_gateway.async_setup() is True - - assert deconz_gateway.api is api - assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 7 - assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == ( - entry, - "binary_sensor", - ) - assert hass.config_entries.async_forward_entry_setup.mock_calls[1][1] == ( - entry, - "climate", - ) - assert hass.config_entries.async_forward_entry_setup.mock_calls[2][1] == ( - entry, - "cover", - ) - assert hass.config_entries.async_forward_entry_setup.mock_calls[3][1] == ( - entry, - "light", +DECONZ_WEB_REQUEST = {"config": DECONZ_CONFIG} + + +async def setup_deconz_integration(hass, config, options, get_state_response): + """Create the deCONZ gateway.""" + config_entry = config_entries.ConfigEntry( + version=1, + domain=deconz.DOMAIN, + title="Mock Title", + data=config, + source="test", + connection_class=config_entries.CONN_CLASS_LOCAL_PUSH, + system_options={}, + options=options, + entry_id="1", ) - assert hass.config_entries.async_forward_entry_setup.mock_calls[4][1] == ( - entry, - "scene", - ) - assert hass.config_entries.async_forward_entry_setup.mock_calls[5][1] == ( - entry, - "sensor", - ) - assert hass.config_entries.async_forward_entry_setup.mock_calls[6][1] == ( - entry, - "switch", - ) - assert len(api.start.mock_calls) == 1 - -async def test_gateway_retry(): - """Retry setup.""" - hass = Mock() - entry = Mock() - entry.data = ENTRY_CONFIG + with patch( + "pydeconz.DeconzSession.async_get_state", return_value=get_state_response + ), patch("pydeconz.DeconzSession.start", return_value=True): + await deconz.async_setup_entry(hass, config_entry) + await hass.async_block_till_done() - deconz_gateway = gateway.DeconzGateway(hass, entry) + hass.config_entries._entries.append(config_entry) - with patch.object( - gateway, "get_gateway", side_effect=errors.CannotConnect - ), pytest.raises(ConfigEntryNotReady): - await deconz_gateway.async_setup() + return hass.data[deconz.DOMAIN].get(config[deconz.CONF_BRIDGEID]) -async def test_gateway_setup_fails(): +async def test_gateway_setup(hass): + """Successful setup.""" + data = deepcopy(DECONZ_WEB_REQUEST) + with patch( + "homeassistant.config_entries.ConfigEntries.async_forward_entry_setup", + return_value=True, + ) as forward_entry_setup: + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert gateway.bridgeid == BRIDGEID + assert gateway.master is True + assert gateway.option_allow_clip_sensor is False + assert gateway.option_allow_deconz_groups is True + + assert len(gateway.deconz_ids) == 0 + assert len(hass.states.async_all()) == 0 + + entry = gateway.config_entry + assert forward_entry_setup.mock_calls[0][1] == (entry, "binary_sensor") + assert forward_entry_setup.mock_calls[1][1] == (entry, "climate") + assert forward_entry_setup.mock_calls[2][1] == (entry, "cover") + assert forward_entry_setup.mock_calls[3][1] == (entry, "light") + assert forward_entry_setup.mock_calls[4][1] == (entry, "scene") + assert forward_entry_setup.mock_calls[5][1] == (entry, "sensor") + assert forward_entry_setup.mock_calls[6][1] == (entry, "switch") + + +async def test_gateway_retry(hass): """Retry setup.""" - hass = Mock() - entry = Mock() - entry.data = ENTRY_CONFIG - - deconz_gateway = gateway.DeconzGateway(hass, entry) + data = deepcopy(DECONZ_WEB_REQUEST) + with patch( + "homeassistant.components.deconz.gateway.get_gateway", + side_effect=deconz.errors.CannotConnect, + ), pytest.raises(ConfigEntryNotReady): + await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) - with patch.object(gateway, "get_gateway", side_effect=Exception): - result = await deconz_gateway.async_setup() - assert not result +async def test_gateway_setup_fails(hass): + """Retry setup.""" + data = deepcopy(DECONZ_WEB_REQUEST) + with patch( + "homeassistant.components.deconz.gateway.get_gateway", side_effect=Exception + ): + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert gateway is None -async def test_connection_status(hass): +async def test_connection_status_signalling(hass): """Make sure that connection status triggers a dispatcher send.""" - entry = Mock() - entry.data = ENTRY_CONFIG - - deconz_gateway = gateway.DeconzGateway(hass, entry) - with patch.object(gateway, "async_dispatcher_send") as mock_dispatch_send: - deconz_gateway.async_connection_status_callback(True) - - await hass.async_block_till_done() - assert len(mock_dispatch_send.mock_calls) == 1 - assert len(mock_dispatch_send.mock_calls[0]) == 3 - - -async def test_add_device(hass): - """Successful retry setup.""" - entry = Mock() - entry.data = ENTRY_CONFIG - - deconz_gateway = gateway.DeconzGateway(hass, entry) - with patch.object(gateway, "async_dispatcher_send") as mock_dispatch_send: - deconz_gateway.async_add_device_callback("sensor", Mock()) - - await hass.async_block_till_done() - assert len(mock_dispatch_send.mock_calls) == 1 - assert len(mock_dispatch_send.mock_calls[0]) == 3 - - -async def test_add_remote(): - """Successful add remote.""" - hass = Mock() - entry = Mock() - entry.data = ENTRY_CONFIG - - remote = Mock() - remote.name = "name" - remote.type = "ZHASwitch" - remote.register_async_callback = Mock() - - deconz_gateway = gateway.DeconzGateway(hass, entry) - deconz_gateway.async_add_remote([remote]) - - assert len(deconz_gateway.events) == 1 - - -async def test_shutdown(): - """Successful shutdown.""" - hass = Mock() - entry = Mock() - entry.data = ENTRY_CONFIG - - deconz_gateway = gateway.DeconzGateway(hass, entry) - deconz_gateway.api = Mock() - deconz_gateway.shutdown(None) + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) - assert len(deconz_gateway.api.close.mock_calls) == 1 + event_call = Mock() + unsub = async_dispatcher_connect(hass, gateway.signal_reachable, event_call) + gateway.async_connection_status_callback(False) + await hass.async_block_till_done() -async def test_reset_after_successful_setup(): - """Verify that reset works on a setup component.""" - hass = Mock() - entry = Mock() - entry.data = ENTRY_CONFIG - api = Mock() - api.async_add_remote.return_value = Mock() - api.sensors = {} + assert gateway.available is False + assert len(event_call.mock_calls) == 1 - deconz_gateway = gateway.DeconzGateway(hass, entry) + unsub() - with patch.object( - gateway, "get_gateway", return_value=mock_coro(api) - ), patch.object(gateway, "async_dispatcher_connect", return_value=Mock()): - assert await deconz_gateway.async_setup() is True - listener = Mock() - deconz_gateway.listeners = [listener] - event = Mock() - event.async_will_remove_from_hass = Mock() - deconz_gateway.events = [event] - deconz_gateway.deconz_ids = {"key": "value"} +async def test_update_address(hass): + """Make sure that connection status triggers a dispatcher send.""" + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert gateway.api.host == "1.2.3.4" + + await hass.config_entries.flow.async_init( + deconz.config_flow.DOMAIN, + data={ + deconz.config_flow.CONF_HOST: "2.3.4.5", + deconz.config_flow.CONF_PORT: 80, + ssdp.ATTR_SERIAL: BRIDGEID, + ssdp.ATTR_MANUFACTURERURL: deconz.config_flow.DECONZ_MANUFACTURERURL, + deconz.config_flow.ATTR_UUID: "uuid:1234", + }, + context={"source": "ssdp"}, + ) + await hass.async_block_till_done() - hass.config_entries.async_forward_entry_unload.return_value = mock_coro(True) - assert await deconz_gateway.async_reset() is True + assert gateway.api.host == "2.3.4.5" - assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 7 - assert len(listener.mock_calls) == 1 - assert len(deconz_gateway.listeners) == 0 +async def test_reset_after_successful_setup(hass): + """Make sure that connection status triggers a dispatcher send.""" + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) - assert len(event.async_will_remove_from_hass.mock_calls) == 1 - assert len(deconz_gateway.events) == 0 + result = await gateway.async_reset() + await hass.async_block_till_done() - assert len(deconz_gateway.deconz_ids) == 0 + assert result is True async def test_get_gateway(hass): """Successful call.""" - with patch( - "pydeconz.DeconzSession.async_load_parameters", return_value=mock_coro(True) - ): - assert await gateway.get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) + with patch("pydeconz.DeconzSession.async_load_parameters", return_value=True): + assert await deconz.gateway.get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) async def test_get_gateway_fails_unauthorized(hass): @@ -206,8 +180,11 @@ async def test_get_gateway_fails_unauthorized(hass): with patch( "pydeconz.DeconzSession.async_load_parameters", side_effect=pydeconz.errors.Unauthorized, - ), pytest.raises(errors.AuthenticationRequired): - assert await gateway.get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) is False + ), pytest.raises(deconz.errors.AuthenticationRequired): + assert ( + await deconz.gateway.get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) + is False + ) async def test_get_gateway_fails_cannot_connect(hass): @@ -215,41 +192,8 @@ async def test_get_gateway_fails_cannot_connect(hass): with patch( "pydeconz.DeconzSession.async_load_parameters", side_effect=pydeconz.errors.RequestError, - ), pytest.raises(errors.CannotConnect): - assert await gateway.get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) is False - - -async def test_create_event(): - """Successfully created a deCONZ event.""" - hass = Mock() - remote = Mock() - remote.name = "Name" - - event = gateway.DeconzEvent(hass, remote) - - assert event._id == "name" - - -async def test_update_event(): - """Successfully update a deCONZ event.""" - hass = Mock() - remote = Mock() - remote.name = "Name" - - event = gateway.DeconzEvent(hass, remote) - remote.changed_keys = {"state": True} - event.async_update_callback() - - assert len(hass.bus.async_fire.mock_calls) == 1 - - -async def test_remove_event(): - """Successfully update a deCONZ event.""" - hass = Mock() - remote = Mock() - remote.name = "Name" - - event = gateway.DeconzEvent(hass, remote) - event.async_will_remove_from_hass() - - assert event._device is None + ), pytest.raises(deconz.errors.CannotConnect): + assert ( + await deconz.gateway.get_gateway(hass, ENTRY_CONFIG, Mock(), Mock()) + is False + ) diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index b0456e0b6248bb..7d630498cde14e 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -3,10 +3,8 @@ import asyncio import pytest -import voluptuous as vol from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.setup import async_setup_component from homeassistant.components import deconz from tests.common import mock_coro, MockConfigEntry @@ -34,74 +32,13 @@ async def setup_entry(hass, entry): assert await deconz.async_setup_entry(hass, entry) is True -async def test_config_with_host_passed_to_config_entry(hass): - """Test that configured options for a host are loaded via config entry.""" - with patch.object(hass.config_entries, "flow") as mock_config_flow: - assert ( - await async_setup_component( - hass, - deconz.DOMAIN, - { - deconz.DOMAIN: { - deconz.CONF_HOST: ENTRY1_HOST, - deconz.CONF_PORT: ENTRY1_PORT, - } - }, - ) - is True - ) - # Import flow started - assert len(mock_config_flow.mock_calls) == 1 - - -async def test_config_without_host_not_passed_to_config_entry(hass): - """Test that a configuration without a host does not initiate an import.""" - MockConfigEntry(domain=deconz.DOMAIN, data={}).add_to_hass(hass) - with patch.object(hass.config_entries, "flow") as mock_config_flow: - assert ( - await async_setup_component(hass, deconz.DOMAIN, {deconz.DOMAIN: {}}) - is True - ) - # No flow started - assert len(mock_config_flow.mock_calls) == 0 - - -async def test_config_import_entry_fails_when_entries_exist(hass): - """Test that an already registered host does not initiate an import.""" - MockConfigEntry(domain=deconz.DOMAIN, data={}).add_to_hass(hass) - with patch.object(hass.config_entries, "flow") as mock_config_flow: - assert ( - await async_setup_component( - hass, - deconz.DOMAIN, - { - deconz.DOMAIN: { - deconz.CONF_HOST: ENTRY1_HOST, - deconz.CONF_PORT: ENTRY1_PORT, - } - }, - ) - is True - ) - # No flow started - assert len(mock_config_flow.mock_calls) == 0 - - -async def test_config_discovery(hass): - """Test that a discovered bridge does not initiate an import.""" - with patch.object(hass, "config_entries") as mock_config_entries: - assert await async_setup_component(hass, deconz.DOMAIN, {}) is True - # No flow started - assert len(mock_config_entries.flow.mock_calls) == 0 - - async def test_setup_entry_fails(hass): """Test setup entry fails if deCONZ is not available.""" entry = Mock() entry.data = { - deconz.CONF_HOST: ENTRY1_HOST, - deconz.CONF_PORT: ENTRY1_PORT, - deconz.CONF_API_KEY: ENTRY1_API_KEY, + deconz.config_flow.CONF_HOST: ENTRY1_HOST, + deconz.config_flow.CONF_PORT: ENTRY1_PORT, + deconz.config_flow.CONF_API_KEY: ENTRY1_API_KEY, } with patch("pydeconz.DeconzSession.async_load_parameters", side_effect=Exception): await deconz.async_setup_entry(hass, entry) @@ -111,9 +48,9 @@ async def test_setup_entry_no_available_bridge(hass): """Test setup entry fails if deCONZ is not available.""" entry = Mock() entry.data = { - deconz.CONF_HOST: ENTRY1_HOST, - deconz.CONF_PORT: ENTRY1_PORT, - deconz.CONF_API_KEY: ENTRY1_API_KEY, + deconz.config_flow.CONF_HOST: ENTRY1_HOST, + deconz.config_flow.CONF_PORT: ENTRY1_PORT, + deconz.config_flow.CONF_API_KEY: ENTRY1_API_KEY, } with patch( "pydeconz.DeconzSession.async_load_parameters", side_effect=asyncio.TimeoutError @@ -126,9 +63,9 @@ async def test_setup_entry_successful(hass): entry = MockConfigEntry( domain=deconz.DOMAIN, data={ - deconz.CONF_HOST: ENTRY1_HOST, - deconz.CONF_PORT: ENTRY1_PORT, - deconz.CONF_API_KEY: ENTRY1_API_KEY, + deconz.config_flow.CONF_HOST: ENTRY1_HOST, + deconz.config_flow.CONF_PORT: ENTRY1_PORT, + deconz.config_flow.CONF_API_KEY: ENTRY1_API_KEY, deconz.CONF_BRIDGEID: ENTRY1_BRIDGEID, }, ) @@ -145,9 +82,9 @@ async def test_setup_entry_multiple_gateways(hass): entry = MockConfigEntry( domain=deconz.DOMAIN, data={ - deconz.CONF_HOST: ENTRY1_HOST, - deconz.CONF_PORT: ENTRY1_PORT, - deconz.CONF_API_KEY: ENTRY1_API_KEY, + deconz.config_flow.CONF_HOST: ENTRY1_HOST, + deconz.config_flow.CONF_PORT: ENTRY1_PORT, + deconz.config_flow.CONF_API_KEY: ENTRY1_API_KEY, deconz.CONF_BRIDGEID: ENTRY1_BRIDGEID, }, ) @@ -156,9 +93,9 @@ async def test_setup_entry_multiple_gateways(hass): entry2 = MockConfigEntry( domain=deconz.DOMAIN, data={ - deconz.CONF_HOST: ENTRY2_HOST, - deconz.CONF_PORT: ENTRY2_PORT, - deconz.CONF_API_KEY: ENTRY2_API_KEY, + deconz.config_flow.CONF_HOST: ENTRY2_HOST, + deconz.config_flow.CONF_PORT: ENTRY2_PORT, + deconz.config_flow.CONF_API_KEY: ENTRY2_API_KEY, deconz.CONF_BRIDGEID: ENTRY2_BRIDGEID, }, ) @@ -178,9 +115,9 @@ async def test_unload_entry(hass): entry = MockConfigEntry( domain=deconz.DOMAIN, data={ - deconz.CONF_HOST: ENTRY1_HOST, - deconz.CONF_PORT: ENTRY1_PORT, - deconz.CONF_API_KEY: ENTRY1_API_KEY, + deconz.config_flow.CONF_HOST: ENTRY1_HOST, + deconz.config_flow.CONF_PORT: ENTRY1_PORT, + deconz.config_flow.CONF_API_KEY: ENTRY1_API_KEY, deconz.CONF_BRIDGEID: ENTRY1_BRIDGEID, }, ) @@ -201,9 +138,9 @@ async def test_unload_entry_multiple_gateways(hass): entry = MockConfigEntry( domain=deconz.DOMAIN, data={ - deconz.CONF_HOST: ENTRY1_HOST, - deconz.CONF_PORT: ENTRY1_PORT, - deconz.CONF_API_KEY: ENTRY1_API_KEY, + deconz.config_flow.CONF_HOST: ENTRY1_HOST, + deconz.config_flow.CONF_PORT: ENTRY1_PORT, + deconz.config_flow.CONF_API_KEY: ENTRY1_API_KEY, deconz.CONF_BRIDGEID: ENTRY1_BRIDGEID, }, ) @@ -212,9 +149,9 @@ async def test_unload_entry_multiple_gateways(hass): entry2 = MockConfigEntry( domain=deconz.DOMAIN, data={ - deconz.CONF_HOST: ENTRY2_HOST, - deconz.CONF_PORT: ENTRY2_PORT, - deconz.CONF_API_KEY: ENTRY2_API_KEY, + deconz.config_flow.CONF_HOST: ENTRY2_HOST, + deconz.config_flow.CONF_PORT: ENTRY2_PORT, + deconz.config_flow.CONF_API_KEY: ENTRY2_API_KEY, deconz.CONF_BRIDGEID: ENTRY2_BRIDGEID, }, ) @@ -230,98 +167,3 @@ async def test_unload_entry_multiple_gateways(hass): assert ENTRY2_BRIDGEID in hass.data[deconz.DOMAIN] assert hass.data[deconz.DOMAIN][ENTRY2_BRIDGEID].master - - -async def test_service_configure(hass): - """Test that service invokes pydeconz with the correct path and data.""" - entry = MockConfigEntry( - domain=deconz.DOMAIN, - data={ - deconz.CONF_HOST: ENTRY1_HOST, - deconz.CONF_PORT: ENTRY1_PORT, - deconz.CONF_API_KEY: ENTRY1_API_KEY, - deconz.CONF_BRIDGEID: ENTRY1_BRIDGEID, - }, - ) - entry.add_to_hass(hass) - - await setup_entry(hass, entry) - - hass.data[deconz.DOMAIN][ENTRY1_BRIDGEID].deconz_ids = {"light.test": "/light/1"} - data = {"on": True, "attr1": 10, "attr2": 20} - - # only field - with patch("pydeconz.DeconzSession.async_put_state", return_value=mock_coro(True)): - await hass.services.async_call( - "deconz", "configure", service_data={"field": "/light/42", "data": data} - ) - await hass.async_block_till_done() - - # only entity - with patch("pydeconz.DeconzSession.async_put_state", return_value=mock_coro(True)): - await hass.services.async_call( - "deconz", "configure", service_data={"entity": "light.test", "data": data} - ) - await hass.async_block_till_done() - - # entity + field - with patch("pydeconz.DeconzSession.async_put_state", return_value=mock_coro(True)): - await hass.services.async_call( - "deconz", - "configure", - service_data={"entity": "light.test", "field": "/state", "data": data}, - ) - await hass.async_block_till_done() - - # non-existing entity (or not from deCONZ) - with patch("pydeconz.DeconzSession.async_put_state", return_value=mock_coro(True)): - await hass.services.async_call( - "deconz", - "configure", - service_data={ - "entity": "light.nonexisting", - "field": "/state", - "data": data, - }, - ) - await hass.async_block_till_done() - - # field does not start with / - with pytest.raises(vol.Invalid): - with patch( - "pydeconz.DeconzSession.async_put_state", return_value=mock_coro(True) - ): - await hass.services.async_call( - "deconz", - "configure", - service_data={"entity": "light.test", "field": "state", "data": data}, - ) - await hass.async_block_till_done() - - -async def test_service_refresh_devices(hass): - """Test that service can refresh devices.""" - entry = MockConfigEntry( - domain=deconz.DOMAIN, - data={ - deconz.CONF_HOST: ENTRY1_HOST, - deconz.CONF_PORT: ENTRY1_PORT, - deconz.CONF_API_KEY: ENTRY1_API_KEY, - deconz.CONF_BRIDGEID: ENTRY1_BRIDGEID, - }, - ) - entry.add_to_hass(hass) - - await setup_entry(hass, entry) - - with patch( - "pydeconz.DeconzSession.async_load_parameters", return_value=mock_coro(True) - ): - await hass.services.async_call("deconz", "device_refresh", service_data={}) - await hass.async_block_till_done() - - with patch( - "pydeconz.DeconzSession.async_load_parameters", return_value=mock_coro(False) - ): - await hass.services.async_call("deconz", "device_refresh", service_data={}) - await hass.async_block_till_done() diff --git a/tests/components/deconz/test_light.py b/tests/components/deconz/test_light.py index afe7ca445e5738..14dc5cc8eac1c7 100644 --- a/tests/components/deconz/test_light.py +++ b/tests/components/deconz/test_light.py @@ -1,49 +1,29 @@ """deCONZ light platform tests.""" -from unittest.mock import Mock, patch +from copy import deepcopy + +from asynctest import patch -from homeassistant import config_entries from homeassistant.components import deconz -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component import homeassistant.components.light as light -from tests.common import mock_coro - +from .test_gateway import ENTRY_CONFIG, DECONZ_WEB_REQUEST, setup_deconz_integration -LIGHT = { +GROUPS = { "1": { - "id": "Light 1 id", - "name": "Light 1 name", - "state": { - "on": True, - "bri": 255, - "colormode": "xy", - "xy": (500, 500), - "reachable": True, - }, - "uniqueid": "00:00:00:00:00:00:00:00-00", - }, - "2": { - "id": "Light 2 id", - "name": "Light 2 name", - "state": {"on": True, "colormode": "ct", "ct": 2500, "reachable": True}, - }, -} - -GROUP = { - "1": { - "id": "Group 1 id", - "name": "Group 1 name", + "id": "Light group id", + "name": "Light group", "type": "LightGroup", - "state": {}, + "state": {"all_on": False, "any_on": True}, "action": {}, "scenes": [], "lights": ["1", "2"], }, "2": { - "id": "Group 2 id", - "name": "Group 2 name", + "id": "Empty group id", + "name": "Empty group", + "type": "LightGroup", "state": {}, "action": {}, "scenes": [], @@ -51,62 +31,38 @@ }, } -SWITCH = { +LIGHTS = { "1": { - "id": "Switch 1 id", - "name": "Switch 1 name", + "id": "RGB light id", + "name": "RGB light", + "state": { + "on": True, + "bri": 255, + "colormode": "xy", + "effect": "colorloop", + "xy": (500, 500), + "reachable": True, + }, + "type": "Extended color light", + "uniqueid": "00:00:00:00:00:00:00:00-00", + }, + "2": { + "id": "Tunable white light id", + "name": "Tunable white light", + "state": {"on": True, "colormode": "ct", "ct": 2500, "reachable": True}, + "type": "Tunable white light", + "uniqueid": "00:00:00:00:00:00:00:01-00", + }, + "3": { + "id": "On off switch id", + "name": "On off switch", "type": "On/Off plug-in unit", - "state": {}, - } -} - - -ENTRY_CONFIG = { - deconz.config_flow.CONF_API_KEY: "ABCDEF", - deconz.config_flow.CONF_BRIDGEID: "0123456789", - deconz.config_flow.CONF_HOST: "1.2.3.4", - deconz.config_flow.CONF_PORT: 80, -} - -ENTRY_OPTIONS = { - deconz.const.CONF_ALLOW_CLIP_SENSOR: True, - deconz.const.CONF_ALLOW_DECONZ_GROUPS: True, + "state": {"reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:02-00", + }, } -async def setup_gateway(hass, data, allow_deconz_groups=True): - """Load the deCONZ light platform.""" - from pydeconz import DeconzSession - - loop = Mock() - session = Mock() - - ENTRY_OPTIONS[deconz.const.CONF_ALLOW_DECONZ_GROUPS] = allow_deconz_groups - - config_entry = config_entries.ConfigEntry( - 1, - deconz.DOMAIN, - "Mock Title", - ENTRY_CONFIG, - "test", - config_entries.CONN_CLASS_LOCAL_PUSH, - system_options={}, - options=ENTRY_OPTIONS, - ) - gateway = deconz.DeconzGateway(hass, config_entry) - gateway.api = DeconzSession(loop, session, **config_entry.data) - gateway.api.config = Mock() - hass.data[deconz.DOMAIN] = {gateway.bridgeid: gateway} - - with patch("pydeconz.DeconzSession.async_get_state", return_value=mock_coro(data)): - await gateway.api.async_load_parameters() - - await hass.config_entries.async_forward_entry_setup(config_entry, "light") - # To flush out the service call to update the group - await hass.async_block_till_done() - return gateway - - async def test_platform_manually_configured(hass): """Test that we do not discover anything or try to set up a gateway.""" assert ( @@ -120,118 +76,161 @@ async def test_platform_manually_configured(hass): async def test_no_lights_or_groups(hass): """Test that no lights or groups entities are created.""" - gateway = await setup_gateway(hass, {}) - assert not hass.data[deconz.DOMAIN][gateway.bridgeid].deconz_ids + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert len(gateway.deconz_ids) == 0 assert len(hass.states.async_all()) == 0 async def test_lights_and_groups(hass): """Test that lights or groups entities are created.""" - with patch("pydeconz.DeconzSession.async_put_state", return_value=mock_coro(True)): - gateway = await setup_gateway(hass, {"lights": LIGHT, "groups": GROUP}) - assert "light.light_1_name" in gateway.deconz_ids - assert "light.light_2_name" in gateway.deconz_ids - assert "light.group_1_name" in gateway.deconz_ids - assert "light.group_2_name" not in gateway.deconz_ids - assert len(hass.states.async_all()) == 4 - - lamp_1 = hass.states.get("light.light_1_name") - assert lamp_1 is not None - assert lamp_1.state == "on" - assert lamp_1.attributes["brightness"] == 255 - assert lamp_1.attributes["hs_color"] == (224.235, 100.0) - - light_2 = hass.states.get("light.light_2_name") - assert light_2 is not None - assert light_2.state == "on" - assert light_2.attributes["color_temp"] == 2500 - - gateway.api.lights["1"].async_update({}) - - await hass.services.async_call( - "light", - "turn_on", - { - "entity_id": "light.light_1_name", - "color_temp": 2500, - "brightness": 200, - "transition": 5, - "flash": "short", - "effect": "colorloop", - }, - blocking=True, - ) - await hass.services.async_call( - "light", - "turn_on", - { - "entity_id": "light.light_1_name", - "hs_color": (20, 30), - "flash": "long", - "effect": "None", - }, - blocking=True, - ) - await hass.services.async_call( - "light", - "turn_off", - {"entity_id": "light.light_1_name", "transition": 5, "flash": "short"}, - blocking=True, - ) - await hass.services.async_call( - "light", - "turn_off", - {"entity_id": "light.light_1_name", "flash": "long"}, - blocking=True, + data = deepcopy(DECONZ_WEB_REQUEST) + data["groups"] = deepcopy(GROUPS) + data["lights"] = deepcopy(LIGHTS) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data ) + assert "light.rgb_light" in gateway.deconz_ids + assert "light.tunable_white_light" in gateway.deconz_ids + assert "light.light_group" in gateway.deconz_ids + assert "light.empty_group" not in gateway.deconz_ids + assert "light.on_off_switch" not in gateway.deconz_ids + # 4 entities + 2 groups (one for switches and one for lights) + assert len(hass.states.async_all()) == 6 + + rgb_light = hass.states.get("light.rgb_light") + assert rgb_light.state == "on" + assert rgb_light.attributes["brightness"] == 255 + assert rgb_light.attributes["hs_color"] == (224.235, 100.0) + assert rgb_light.attributes["is_deconz_group"] is False + + tunable_white_light = hass.states.get("light.tunable_white_light") + assert tunable_white_light.state == "on" + assert tunable_white_light.attributes["color_temp"] == 2500 + + light_group = hass.states.get("light.light_group") + assert light_group.state == "on" + assert light_group.attributes["all_on"] is False + + empty_group = hass.states.get("light.empty_group") + assert empty_group is None + + rgb_light_device = gateway.api.lights["1"] + + rgb_light_device.async_update({"state": {"on": False}}) + await hass.async_block_till_done() + rgb_light = hass.states.get("light.rgb_light") + assert rgb_light.state == "off" + + with patch.object( + rgb_light_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + light.DOMAIN, + light.SERVICE_TURN_ON, + { + "entity_id": "light.rgb_light", + "color_temp": 2500, + "brightness": 200, + "transition": 5, + "flash": "short", + "effect": "colorloop", + }, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with( + "/lights/1/state", + { + "ct": 2500, + "bri": 200, + "transitiontime": 50, + "alert": "select", + "effect": "colorloop", + }, + ) -async def test_add_new_light(hass): - """Test successful creation of light entities.""" - gateway = await setup_gateway(hass, {}) - light = Mock() - light.name = "name" - light.uniqueid = "1" - light.register_async_callback = Mock() - async_dispatcher_send(hass, gateway.async_event_new_device("light"), [light]) - await hass.async_block_till_done() - assert "light.name" in gateway.deconz_ids + with patch.object( + rgb_light_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + light.DOMAIN, + light.SERVICE_TURN_ON, + { + "entity_id": "light.rgb_light", + "hs_color": (20, 30), + "flash": "long", + "effect": "None", + }, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with( + "/lights/1/state", + {"xy": (0.411, 0.351), "alert": "lselect", "effect": "none"}, + ) + with patch.object( + rgb_light_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + light.DOMAIN, + light.SERVICE_TURN_OFF, + {"entity_id": "light.rgb_light", "transition": 5, "flash": "short"}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with( + "/lights/1/state", {"bri": 0, "transitiontime": 50, "alert": "select"} + ) -async def test_add_new_group(hass): - """Test successful creation of group entities.""" - gateway = await setup_gateway(hass, {}) - group = Mock() - group.name = "name" - group.register_async_callback = Mock() - async_dispatcher_send(hass, gateway.async_event_new_device("group"), [group]) - await hass.async_block_till_done() - assert "light.name" in gateway.deconz_ids + with patch.object( + rgb_light_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + light.DOMAIN, + light.SERVICE_TURN_OFF, + {"entity_id": "light.rgb_light", "flash": "long"}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("/lights/1/state", {"alert": "lselect"}) + await gateway.async_reset() -async def test_do_not_add_deconz_groups(hass): - """Test that clip sensors can be ignored.""" - gateway = await setup_gateway(hass, {}, allow_deconz_groups=False) - group = Mock() - group.name = "name" - group.register_async_callback = Mock() - async_dispatcher_send(hass, gateway.async_event_new_device("group"), [group]) - await hass.async_block_till_done() - assert len(gateway.deconz_ids) == 0 + assert len(hass.states.async_all()) == 2 -async def test_no_switch(hass): - """Test that a switch doesn't get created as a light entity.""" - gateway = await setup_gateway(hass, {"lights": SWITCH}) - assert len(gateway.deconz_ids) == 0 - assert len(hass.states.async_all()) == 0 +async def test_disable_light_groups(hass): + """Test successful creation of sensor entities.""" + data = deepcopy(DECONZ_WEB_REQUEST) + data["groups"] = deepcopy(GROUPS) + data["lights"] = deepcopy(LIGHTS) + gateway = await setup_deconz_integration( + hass, + ENTRY_CONFIG, + options={deconz.gateway.CONF_ALLOW_DECONZ_GROUPS: False}, + get_state_response=data, + ) + assert "light.rgb_light" in gateway.deconz_ids + assert "light.tunable_white_light" in gateway.deconz_ids + assert "light.light_group" not in gateway.deconz_ids + assert "light.empty_group" not in gateway.deconz_ids + assert "light.on_off_switch" not in gateway.deconz_ids + # 4 entities + 2 groups (one for switches and one for lights) + assert len(hass.states.async_all()) == 5 + rgb_light = hass.states.get("light.rgb_light") + assert rgb_light is not None -async def test_unload_light(hass): - """Test that it works to unload switch entities.""" - gateway = await setup_gateway(hass, {"lights": LIGHT, "groups": GROUP}) + tunable_white_light = hass.states.get("light.tunable_white_light") + assert tunable_white_light is not None - await gateway.async_reset() + light_group = hass.states.get("light.light_group") + assert light_group is None - # Group.all_lights will not be removed - assert len(hass.states.async_all()) == 1 + empty_group = hass.states.get("light.empty_group") + assert empty_group is None diff --git a/tests/components/deconz/test_scene.py b/tests/components/deconz/test_scene.py index 074e943548de40..dcc8ba500c32f7 100644 --- a/tests/components/deconz/test_scene.py +++ b/tests/components/deconz/test_scene.py @@ -1,67 +1,28 @@ """deCONZ scene platform tests.""" -from unittest.mock import Mock, patch +from copy import deepcopy + +from asynctest import patch -from homeassistant import config_entries from homeassistant.components import deconz from homeassistant.setup import async_setup_component import homeassistant.components.scene as scene -from tests.common import mock_coro - +from .test_gateway import ENTRY_CONFIG, DECONZ_WEB_REQUEST, setup_deconz_integration -GROUP = { +GROUPS = { "1": { - "id": "Group 1 id", - "name": "Group 1 name", - "state": {}, + "id": "Light group id", + "name": "Light group", + "type": "LightGroup", + "state": {"all_on": False, "any_on": True}, "action": {}, - "scenes": [{"id": "1", "name": "Scene 1"}], + "scenes": [{"id": "1", "name": "Scene"}], "lights": [], } } -ENTRY_CONFIG = { - deconz.const.CONF_ALLOW_CLIP_SENSOR: True, - deconz.const.CONF_ALLOW_DECONZ_GROUPS: True, - deconz.config_flow.CONF_API_KEY: "ABCDEF", - deconz.config_flow.CONF_BRIDGEID: "0123456789", - deconz.config_flow.CONF_HOST: "1.2.3.4", - deconz.config_flow.CONF_PORT: 80, -} - - -async def setup_gateway(hass, data): - """Load the deCONZ scene platform.""" - from pydeconz import DeconzSession - - loop = Mock() - session = Mock() - - config_entry = config_entries.ConfigEntry( - 1, - deconz.DOMAIN, - "Mock Title", - ENTRY_CONFIG, - "test", - config_entries.CONN_CLASS_LOCAL_PUSH, - system_options={}, - ) - gateway = deconz.DeconzGateway(hass, config_entry) - gateway.api = DeconzSession(loop, session, **config_entry.data) - gateway.api.config = Mock() - hass.data[deconz.DOMAIN] = {gateway.bridgeid: gateway} - - with patch("pydeconz.DeconzSession.async_get_state", return_value=mock_coro(data)): - await gateway.api.async_load_parameters() - - await hass.config_entries.async_forward_entry_setup(config_entry, "scene") - # To flush out the service call to update the group - await hass.async_block_till_done() - return gateway - - async def test_platform_manually_configured(hass): """Test that we do not discover anything or try to set up a gateway.""" assert ( @@ -75,26 +36,38 @@ async def test_platform_manually_configured(hass): async def test_no_scenes(hass): """Test that scenes can be loaded without scenes being available.""" - gateway = await setup_gateway(hass, {}) - assert not hass.data[deconz.DOMAIN][gateway.bridgeid].deconz_ids + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert len(gateway.deconz_ids) == 0 assert len(hass.states.async_all()) == 0 async def test_scenes(hass): """Test that scenes works.""" - with patch("pydeconz.DeconzSession.async_put_state", return_value=mock_coro(True)): - gateway = await setup_gateway(hass, {"groups": GROUP}) - assert "scene.group_1_name_scene_1" in gateway.deconz_ids + data = deepcopy(DECONZ_WEB_REQUEST) + data["groups"] = deepcopy(GROUPS) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + + assert "scene.light_group_scene" in gateway.deconz_ids assert len(hass.states.async_all()) == 1 - await hass.services.async_call( - "scene", "turn_on", {"entity_id": "scene.group_1_name_scene_1"}, blocking=True - ) + light_group_scene = hass.states.get("scene.light_group_scene") + assert light_group_scene + group_scene = gateway.api.groups["1"].scenes["1"] -async def test_unload_scene(hass): - """Test that it works to unload scene entities.""" - gateway = await setup_gateway(hass, {"groups": GROUP}) + with patch.object( + group_scene, "_async_set_state_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + "scene", "turn_on", {"entity_id": "scene.light_group_scene"}, blocking=True + ) + await hass.async_block_till_done() + set_callback.assert_called_with("/groups/1/scenes/1/recall", {}) await gateway.async_reset() diff --git a/tests/components/deconz/test_sensor.py b/tests/components/deconz/test_sensor.py index fa1ba175ed5762..928e527dd0706e 100644 --- a/tests/components/deconz/test_sensor.py +++ b/tests/components/deconz/test_sensor.py @@ -1,125 +1,81 @@ """deCONZ sensor platform tests.""" -from unittest.mock import Mock, patch +from copy import deepcopy -from tests.common import mock_coro - -from homeassistant import config_entries from homeassistant.components import deconz -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component import homeassistant.components.sensor as sensor +from .test_gateway import ENTRY_CONFIG, DECONZ_WEB_REQUEST, setup_deconz_integration -SENSOR = { +SENSORS = { "1": { - "id": "Sensor 1 id", - "name": "Sensor 1 name", + "id": "Light sensor id", + "name": "Light level sensor", "type": "ZHALightLevel", "state": {"lightlevel": 30000, "dark": False}, - "config": {"reachable": True}, + "config": {"on": True, "reachable": True, "temperature": 10}, "uniqueid": "00:00:00:00:00:00:00:00-00", }, "2": { - "id": "Sensor 2 id", - "name": "Sensor 2 name", + "id": "Presence sensor id", + "name": "Presence sensor", "type": "ZHAPresence", "state": {"presence": False}, "config": {}, + "uniqueid": "00:00:00:00:00:00:00:01-00", }, "3": { - "id": "Sensor 3 id", - "name": "Sensor 3 name", + "id": "Switch 1 id", + "name": "Switch 1", "type": "ZHASwitch", "state": {"buttonevent": 1000}, "config": {}, + "uniqueid": "00:00:00:00:00:00:00:02-00", }, "4": { - "id": "Sensor 4 id", - "name": "Sensor 4 name", + "id": "Switch 2 id", + "name": "Switch 2", "type": "ZHASwitch", "state": {"buttonevent": 1000}, "config": {"battery": 100}, - "uniqueid": "00:00:00:00:00:00:00:01-00", + "uniqueid": "00:00:00:00:00:00:00:03-00", }, "5": { - "id": "Sensor 5 id", - "name": "Sensor 5 name", - "type": "ZHASwitch", - "state": {"buttonevent": 1000}, - "config": {"battery": 100}, - "uniqueid": "00:00:00:00:00:00:00:02:00-00", - }, - "6": { - "id": "Sensor 6 id", - "name": "Sensor 6 name", + "id": "Daylight sensor id", + "name": "Daylight sensor", "type": "Daylight", - "state": {"daylight": True}, + "state": {"daylight": True, "status": 130}, "config": {}, + "uniqueid": "00:00:00:00:00:00:00:04-00", }, - "7": { - "id": "Sensor 7 id", - "name": "Sensor 7 name", + "6": { + "id": "Power sensor id", + "name": "Power sensor", "type": "ZHAPower", "state": {"current": 2, "power": 6, "voltage": 3}, "config": {"reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:05-00", }, - "8": { - "id": "Sensor 8 id", - "name": "Sensor 8 name", + "7": { + "id": "Consumption id", + "name": "Consumption sensor", "type": "ZHAConsumption", "state": {"consumption": 2, "power": 6}, "config": {"reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:06-00", + }, + "8": { + "id": "CLIP light sensor id", + "name": "CLIP light level sensor", + "type": "CLIPLightLevel", + "state": {"lightlevel": 30000}, + "config": {"reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:07-00", }, } -ENTRY_CONFIG = { - deconz.config_flow.CONF_API_KEY: "ABCDEF", - deconz.config_flow.CONF_BRIDGEID: "0123456789", - deconz.config_flow.CONF_HOST: "1.2.3.4", - deconz.config_flow.CONF_PORT: 80, -} - -ENTRY_OPTIONS = { - deconz.const.CONF_ALLOW_CLIP_SENSOR: True, - deconz.const.CONF_ALLOW_DECONZ_GROUPS: True, -} - - -async def setup_gateway(hass, data, allow_clip_sensor=True): - """Load the deCONZ sensor platform.""" - from pydeconz import DeconzSession - - loop = Mock() - session = Mock() - - ENTRY_OPTIONS[deconz.const.CONF_ALLOW_CLIP_SENSOR] = allow_clip_sensor - - config_entry = config_entries.ConfigEntry( - 1, - deconz.DOMAIN, - "Mock Title", - ENTRY_CONFIG, - "test", - config_entries.CONN_CLASS_LOCAL_PUSH, - system_options={}, - options=ENTRY_OPTIONS, - ) - gateway = deconz.DeconzGateway(hass, config_entry) - gateway.api = DeconzSession(loop, session, **config_entry.data) - gateway.api.config = Mock() - hass.data[deconz.DOMAIN] = {gateway.bridgeid: gateway} - - with patch("pydeconz.DeconzSession.async_get_state", return_value=mock_coro(data)): - await gateway.api.async_load_parameters() - - await hass.config_entries.async_forward_entry_setup(config_entry, "sensor") - # To flush out the service call to update the group - await hass.async_block_till_done() - return gateway - - async def test_platform_manually_configured(hass): """Test that we do not discover anything or try to set up a gateway.""" assert ( @@ -133,56 +89,147 @@ async def test_platform_manually_configured(hass): async def test_no_sensors(hass): """Test that no sensors in deconz results in no sensor entities.""" - gateway = await setup_gateway(hass, {}) - assert not hass.data[deconz.DOMAIN][gateway.bridgeid].deconz_ids + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert len(gateway.deconz_ids) == 0 assert len(hass.states.async_all()) == 0 async def test_sensors(hass): """Test successful creation of sensor entities.""" - gateway = await setup_gateway(hass, {"sensors": SENSOR}) - assert "sensor.sensor_1_name" in gateway.deconz_ids - assert "sensor.sensor_2_name" not in gateway.deconz_ids - assert "sensor.sensor_3_name" not in gateway.deconz_ids - assert "sensor.sensor_3_name_battery_level" not in gateway.deconz_ids - assert "sensor.sensor_4_name" not in gateway.deconz_ids - assert "sensor.sensor_4_name_battery_level" in gateway.deconz_ids + data = deepcopy(DECONZ_WEB_REQUEST) + data["sensors"] = deepcopy(SENSORS) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert "sensor.light_level_sensor" in gateway.deconz_ids + assert "sensor.presence_sensor" not in gateway.deconz_ids + assert "sensor.switch_1" not in gateway.deconz_ids + assert "sensor.switch_1_battery_level" not in gateway.deconz_ids + assert "sensor.switch_2" not in gateway.deconz_ids + assert "sensor.switch_2_battery_level" in gateway.deconz_ids + assert "sensor.daylight_sensor" in gateway.deconz_ids + assert "sensor.power_sensor" in gateway.deconz_ids + assert "sensor.consumption_sensor" in gateway.deconz_ids + assert "sensor.clip_light_level_sensor" not in gateway.deconz_ids assert len(hass.states.async_all()) == 6 - gateway.api.sensors["1"].async_update({"state": {"on": False}}) - gateway.api.sensors["4"].async_update({"config": {"battery": 75}}) + light_level_sensor = hass.states.get("sensor.light_level_sensor") + assert light_level_sensor.state == "999.8" + presence_sensor = hass.states.get("sensor.presence_sensor") + assert presence_sensor is None -async def test_add_new_sensor(hass): - """Test successful creation of sensor entities.""" - gateway = await setup_gateway(hass, {}) - sensor = Mock() - sensor.name = "name" - sensor.type = "ZHATemperature" - sensor.uniqueid = "1" - sensor.BINARY = False - sensor.register_async_callback = Mock() - async_dispatcher_send(hass, gateway.async_event_new_device("sensor"), [sensor]) - await hass.async_block_till_done() - assert "sensor.name" in gateway.deconz_ids + switch_1 = hass.states.get("sensor.switch_1") + assert switch_1 is None + + switch_1_battery_level = hass.states.get("sensor.switch_1_battery_level") + assert switch_1_battery_level is None + + switch_2 = hass.states.get("sensor.switch_2") + assert switch_2 is None + + switch_2_battery_level = hass.states.get("sensor.switch_2_battery_level") + assert switch_2_battery_level.state == "100" + + daylight_sensor = hass.states.get("sensor.daylight_sensor") + assert daylight_sensor.state == "dawn" + + power_sensor = hass.states.get("sensor.power_sensor") + assert power_sensor.state == "6" + consumption_sensor = hass.states.get("sensor.consumption_sensor") + assert consumption_sensor.state == "0.002" -async def test_do_not_allow_clipsensor(hass): - """Test that clip sensors can be ignored.""" - gateway = await setup_gateway(hass, {}, allow_clip_sensor=False) - sensor = Mock() - sensor.name = "name" - sensor.type = "CLIPTemperature" - sensor.register_async_callback = Mock() - async_dispatcher_send(hass, gateway.async_event_new_device("sensor"), [sensor]) + gateway.api.sensors["1"].async_update({"state": {"lightlevel": 2000}}) + gateway.api.sensors["4"].async_update({"config": {"battery": 75}}) await hass.async_block_till_done() - assert len(gateway.deconz_ids) == 0 + light_level_sensor = hass.states.get("sensor.light_level_sensor") + assert light_level_sensor.state == "1.6" -async def test_unload_sensor(hass): - """Test that it works to unload sensor entities.""" - gateway = await setup_gateway(hass, {"sensors": SENSOR}) + switch_2_battery_level = hass.states.get("sensor.switch_2_battery_level") + assert switch_2_battery_level.state == "75" await gateway.async_reset() assert len(hass.states.async_all()) == 0 + + +async def test_allow_clip_sensors(hass): + """Test that CLIP sensors can be allowed.""" + data = deepcopy(DECONZ_WEB_REQUEST) + data["sensors"] = deepcopy(SENSORS) + gateway = await setup_deconz_integration( + hass, + ENTRY_CONFIG, + options={deconz.gateway.CONF_ALLOW_CLIP_SENSOR: True}, + get_state_response=data, + ) + assert "sensor.light_level_sensor" in gateway.deconz_ids + assert "sensor.presence_sensor" not in gateway.deconz_ids + assert "sensor.switch_1" not in gateway.deconz_ids + assert "sensor.switch_1_battery_level" not in gateway.deconz_ids + assert "sensor.switch_2" not in gateway.deconz_ids + assert "sensor.switch_2_battery_level" in gateway.deconz_ids + assert "sensor.daylight_sensor" in gateway.deconz_ids + assert "sensor.power_sensor" in gateway.deconz_ids + assert "sensor.consumption_sensor" in gateway.deconz_ids + assert "sensor.clip_light_level_sensor" in gateway.deconz_ids + assert len(hass.states.async_all()) == 7 + + light_level_sensor = hass.states.get("sensor.light_level_sensor") + assert light_level_sensor.state == "999.8" + + presence_sensor = hass.states.get("sensor.presence_sensor") + assert presence_sensor is None + + switch_1 = hass.states.get("sensor.switch_1") + assert switch_1 is None + + switch_1_battery_level = hass.states.get("sensor.switch_1_battery_level") + assert switch_1_battery_level is None + + switch_2 = hass.states.get("sensor.switch_2") + assert switch_2 is None + + switch_2_battery_level = hass.states.get("sensor.switch_2_battery_level") + assert switch_2_battery_level.state == "100" + + daylight_sensor = hass.states.get("sensor.daylight_sensor") + assert daylight_sensor.state == "dawn" + + power_sensor = hass.states.get("sensor.power_sensor") + assert power_sensor.state == "6" + + consumption_sensor = hass.states.get("sensor.consumption_sensor") + assert consumption_sensor.state == "0.002" + + clip_light_level_sensor = hass.states.get("sensor.clip_light_level_sensor") + assert clip_light_level_sensor.state == "999.8" + + +async def test_add_new_sensor(hass): + """Test that adding a new sensor works.""" + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert len(gateway.deconz_ids) == 0 + + state_added = { + "t": "event", + "e": "added", + "r": "sensors", + "id": "1", + "sensor": deepcopy(SENSORS["1"]), + } + gateway.api.async_event_handler(state_added) + await hass.async_block_till_done() + + assert "sensor.light_level_sensor" in gateway.deconz_ids + + light_level_sensor = hass.states.get("sensor.light_level_sensor") + assert light_level_sensor.state == "999.8" diff --git a/tests/components/deconz/test_services.py b/tests/components/deconz/test_services.py new file mode 100644 index 00000000000000..533d85eef7cbe3 --- /dev/null +++ b/tests/components/deconz/test_services.py @@ -0,0 +1,227 @@ +"""deCONZ service tests.""" +from copy import deepcopy + +from asynctest import Mock, patch + +import pytest +import voluptuous as vol + +from homeassistant.components import deconz + +from .test_gateway import ( + BRIDGEID, + ENTRY_CONFIG, + DECONZ_WEB_REQUEST, + setup_deconz_integration, +) + +GROUP = { + "1": { + "id": "Group 1 id", + "name": "Group 1 name", + "type": "LightGroup", + "state": {}, + "action": {}, + "scenes": [{"id": "1", "name": "Scene 1"}], + "lights": ["1"], + } +} + +LIGHT = { + "1": { + "id": "Light 1 id", + "name": "Light 1 name", + "state": {"reachable": True}, + "type": "Light", + "uniqueid": "00:00:00:00:00:00:00:01-00", + } +} + +SENSOR = { + "1": { + "id": "Sensor 1 id", + "name": "Sensor 1 name", + "type": "ZHALightLevel", + "state": {"lightlevel": 30000, "dark": False}, + "config": {"reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:02-00", + } +} + + +async def test_service_setup(hass): + """Verify service setup works.""" + assert deconz.services.DECONZ_SERVICES not in hass.data + with patch( + "homeassistant.core.ServiceRegistry.async_register", return_value=Mock(True) + ) as async_register: + await deconz.services.async_setup_services(hass) + assert hass.data[deconz.services.DECONZ_SERVICES] is True + assert async_register.call_count == 2 + + +async def test_service_setup_already_registered(hass): + """Make sure that services are only registered once.""" + hass.data[deconz.services.DECONZ_SERVICES] = True + with patch( + "homeassistant.core.ServiceRegistry.async_register", return_value=Mock(True) + ) as async_register: + await deconz.services.async_setup_services(hass) + async_register.assert_not_called() + + +async def test_service_unload(hass): + """Verify service unload works.""" + hass.data[deconz.services.DECONZ_SERVICES] = True + with patch( + "homeassistant.core.ServiceRegistry.async_remove", return_value=Mock(True) + ) as async_remove: + await deconz.services.async_unload_services(hass) + assert hass.data[deconz.services.DECONZ_SERVICES] is False + assert async_remove.call_count == 2 + + +async def test_service_unload_not_registered(hass): + """Make sure that services can only be unloaded once.""" + with patch( + "homeassistant.core.ServiceRegistry.async_remove", return_value=Mock(True) + ) as async_remove: + await deconz.services.async_unload_services(hass) + assert deconz.services.DECONZ_SERVICES not in hass.data + async_remove.assert_not_called() + + +async def test_configure_service_with_field(hass): + """Test that service invokes pydeconz with the correct path and data.""" + data = deepcopy(DECONZ_WEB_REQUEST) + await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + + data = { + deconz.services.SERVICE_FIELD: "/light/2", + deconz.CONF_BRIDGEID: BRIDGEID, + deconz.services.SERVICE_DATA: {"on": True, "attr1": 10, "attr2": 20}, + } + + with patch( + "pydeconz.DeconzSession.async_put_state", return_value=Mock(True) + ) as put_state: + await hass.services.async_call( + deconz.DOMAIN, deconz.services.SERVICE_CONFIGURE_DEVICE, service_data=data + ) + await hass.async_block_till_done() + put_state.assert_called_with("/light/2", {"on": True, "attr1": 10, "attr2": 20}) + + +async def test_configure_service_with_entity(hass): + """Test that service invokes pydeconz with the correct path and data.""" + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + + gateway.deconz_ids["light.test"] = "/light/1" + data = { + deconz.services.SERVICE_ENTITY: "light.test", + deconz.services.SERVICE_DATA: {"on": True, "attr1": 10, "attr2": 20}, + } + + with patch( + "pydeconz.DeconzSession.async_put_state", return_value=Mock(True) + ) as put_state: + await hass.services.async_call( + deconz.DOMAIN, deconz.services.SERVICE_CONFIGURE_DEVICE, service_data=data + ) + await hass.async_block_till_done() + put_state.assert_called_with("/light/1", {"on": True, "attr1": 10, "attr2": 20}) + + +async def test_configure_service_with_entity_and_field(hass): + """Test that service invokes pydeconz with the correct path and data.""" + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + + gateway.deconz_ids["light.test"] = "/light/1" + data = { + deconz.services.SERVICE_ENTITY: "light.test", + deconz.services.SERVICE_FIELD: "/state", + deconz.services.SERVICE_DATA: {"on": True, "attr1": 10, "attr2": 20}, + } + + with patch( + "pydeconz.DeconzSession.async_put_state", return_value=Mock(True) + ) as put_state: + await hass.services.async_call( + deconz.DOMAIN, deconz.services.SERVICE_CONFIGURE_DEVICE, service_data=data + ) + await hass.async_block_till_done() + put_state.assert_called_with( + "/light/1/state", {"on": True, "attr1": 10, "attr2": 20} + ) + + +async def test_configure_service_with_faulty_field(hass): + """Test that service invokes pydeconz with the correct path and data.""" + data = deepcopy(DECONZ_WEB_REQUEST) + await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + + data = {deconz.services.SERVICE_FIELD: "light/2", deconz.services.SERVICE_DATA: {}} + + with pytest.raises(vol.Invalid): + await hass.services.async_call( + deconz.DOMAIN, deconz.services.SERVICE_CONFIGURE_DEVICE, service_data=data + ) + await hass.async_block_till_done() + + +async def test_configure_service_with_faulty_entity(hass): + """Test that service invokes pydeconz with the correct path and data.""" + data = deepcopy(DECONZ_WEB_REQUEST) + await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + + data = { + deconz.services.SERVICE_ENTITY: "light.nonexisting", + deconz.services.SERVICE_DATA: {}, + } + + with patch( + "pydeconz.DeconzSession.async_put_state", return_value=Mock(True) + ) as put_state: + await hass.services.async_call( + deconz.DOMAIN, deconz.services.SERVICE_CONFIGURE_DEVICE, service_data=data + ) + await hass.async_block_till_done() + put_state.assert_not_called() + + +async def test_service_refresh_devices(hass): + """Test that service can refresh devices.""" + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + + data = {deconz.CONF_BRIDGEID: BRIDGEID} + + with patch( + "pydeconz.DeconzSession.async_get_state", + return_value={"groups": GROUP, "lights": LIGHT, "sensors": SENSOR}, + ): + await hass.services.async_call( + deconz.DOMAIN, deconz.services.SERVICE_DEVICE_REFRESH, service_data=data + ) + await hass.async_block_till_done() + + assert gateway.deconz_ids == { + "light.group_1_name": "/groups/1", + "light.light_1_name": "/lights/1", + "scene.group_1_name_scene_1": "/groups/1/scenes/1", + "sensor.sensor_1_name": "/sensors/1", + } diff --git a/tests/components/deconz/test_switch.py b/tests/components/deconz/test_switch.py index 746d1b6342c4f8..262bd7001f5846 100644 --- a/tests/components/deconz/test_switch.py +++ b/tests/components/deconz/test_switch.py @@ -1,88 +1,47 @@ """deCONZ switch platform tests.""" -from unittest.mock import Mock, patch +from copy import deepcopy + +from asynctest import patch -from homeassistant import config_entries from homeassistant.components import deconz -from homeassistant.components.deconz.const import SWITCH_TYPES -from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.setup import async_setup_component import homeassistant.components.switch as switch -from tests.common import mock_coro +from .test_gateway import ENTRY_CONFIG, DECONZ_WEB_REQUEST, setup_deconz_integration -SUPPORTED_SWITCHES = { +SWITCHES = { "1": { - "id": "Switch 1 id", - "name": "Switch 1 name", + "id": "On off switch id", + "name": "On off switch", "type": "On/Off plug-in unit", "state": {"on": True, "reachable": True}, "uniqueid": "00:00:00:00:00:00:00:00-00", }, "2": { - "id": "Switch 2 id", - "name": "Switch 2 name", + "id": "Smart plug id", + "name": "Smart plug", "type": "Smart plug", - "state": {"on": True, "reachable": True}, + "state": {"on": False, "reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:01-00", }, "3": { - "id": "Switch 3 id", - "name": "Switch 3 name", + "id": "Warning device id", + "name": "Warning device", "type": "Warning device", "state": {"alert": "lselect", "reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:02-00", }, -} - -UNSUPPORTED_SWITCH = { - "1": { - "id": "Switch id", + "4": { + "id": "Unsupported switch id", "name": "Unsupported switch", "type": "Not a smart plug", - "state": {}, - } -} - - -ENTRY_CONFIG = { - deconz.const.CONF_ALLOW_CLIP_SENSOR: True, - deconz.const.CONF_ALLOW_DECONZ_GROUPS: True, - deconz.config_flow.CONF_API_KEY: "ABCDEF", - deconz.config_flow.CONF_BRIDGEID: "0123456789", - deconz.config_flow.CONF_HOST: "1.2.3.4", - deconz.config_flow.CONF_PORT: 80, + "state": {"reachable": True}, + "uniqueid": "00:00:00:00:00:00:00:03-00", + }, } -async def setup_gateway(hass, data): - """Load the deCONZ switch platform.""" - from pydeconz import DeconzSession - - loop = Mock() - session = Mock() - - config_entry = config_entries.ConfigEntry( - 1, - deconz.DOMAIN, - "Mock Title", - ENTRY_CONFIG, - "test", - config_entries.CONN_CLASS_LOCAL_PUSH, - system_options={}, - ) - gateway = deconz.DeconzGateway(hass, config_entry) - gateway.api = DeconzSession(loop, session, **config_entry.data) - gateway.api.config = Mock() - hass.data[deconz.DOMAIN] = {gateway.bridgeid: gateway} - - with patch("pydeconz.DeconzSession.async_get_state", return_value=mock_coro(data)): - await gateway.api.async_load_parameters() - - await hass.config_entries.async_forward_entry_setup(config_entry, "switch") - # To flush out the service call to update the group - await hass.async_block_till_done() - return gateway - - async def test_platform_manually_configured(hass): """Test that we do not discover anything or try to set up a gateway.""" assert ( @@ -96,68 +55,97 @@ async def test_platform_manually_configured(hass): async def test_no_switches(hass): """Test that no switch entities are created.""" - gateway = await setup_gateway(hass, {}) - assert not hass.data[deconz.DOMAIN][gateway.bridgeid].deconz_ids + data = deepcopy(DECONZ_WEB_REQUEST) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data + ) + assert len(gateway.deconz_ids) == 0 assert len(hass.states.async_all()) == 0 async def test_switches(hass): """Test that all supported switch entities are created.""" - with patch("pydeconz.DeconzSession.async_put_state", return_value=mock_coro(True)): - gateway = await setup_gateway(hass, {"lights": SUPPORTED_SWITCHES}) - assert "switch.switch_1_name" in gateway.deconz_ids - assert "switch.switch_2_name" in gateway.deconz_ids - assert "switch.switch_3_name" in gateway.deconz_ids - assert len(SUPPORTED_SWITCHES) == len(SWITCH_TYPES) - assert len(hass.states.async_all()) == 4 - - switch_1 = hass.states.get("switch.switch_1_name") - assert switch_1 is not None - assert switch_1.state == "on" - switch_3 = hass.states.get("switch.switch_3_name") - assert switch_3 is not None - assert switch_3.state == "on" - - gateway.api.lights["1"].async_update({}) - - await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.switch_1_name"}, blocking=True - ) - await hass.services.async_call( - "switch", "turn_off", {"entity_id": "switch.switch_1_name"}, blocking=True + data = deepcopy(DECONZ_WEB_REQUEST) + data["lights"] = deepcopy(SWITCHES) + gateway = await setup_deconz_integration( + hass, ENTRY_CONFIG, options={}, get_state_response=data ) + assert "switch.on_off_switch" in gateway.deconz_ids + assert "switch.smart_plug" in gateway.deconz_ids + assert "switch.warning_device" in gateway.deconz_ids + assert "switch.unsupported_switch" not in gateway.deconz_ids + assert len(hass.states.async_all()) == 6 - await hass.services.async_call( - "switch", "turn_on", {"entity_id": "switch.switch_3_name"}, blocking=True - ) - await hass.services.async_call( - "switch", "turn_off", {"entity_id": "switch.switch_3_name"}, blocking=True - ) + on_off_switch = hass.states.get("switch.on_off_switch") + assert on_off_switch.state == "on" + smart_plug = hass.states.get("switch.smart_plug") + assert smart_plug.state == "off" -async def test_add_new_switch(hass): - """Test successful creation of switch entity.""" - gateway = await setup_gateway(hass, {}) - switch = Mock() - switch.name = "name" - switch.type = "Smart plug" - switch.uniqueid = "1" - switch.register_async_callback = Mock() - async_dispatcher_send(hass, gateway.async_event_new_device("light"), [switch]) - await hass.async_block_till_done() - assert "switch.name" in gateway.deconz_ids + warning_device = hass.states.get("switch.warning_device") + assert warning_device.state == "on" + on_off_switch_device = gateway.api.lights["1"] + warning_device_device = gateway.api.lights["3"] -async def test_unsupported_switch(hass): - """Test that unsupported switches are not created.""" - await setup_gateway(hass, {"lights": UNSUPPORTED_SWITCH}) - assert len(hass.states.async_all()) == 0 + on_off_switch_device.async_update({"state": {"on": False}}) + warning_device_device.async_update({"state": {"alert": None}}) + await hass.async_block_till_done() + on_off_switch = hass.states.get("switch.on_off_switch") + assert on_off_switch.state == "off" -async def test_unload_switch(hass): - """Test that it works to unload switch entities.""" - gateway = await setup_gateway(hass, {"lights": SUPPORTED_SWITCHES}) + warning_device = hass.states.get("switch.warning_device") + assert warning_device.state == "off" + + with patch.object( + on_off_switch_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_ON, + {"entity_id": "switch.on_off_switch"}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("/lights/1/state", {"on": True}) + + with patch.object( + on_off_switch_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_OFF, + {"entity_id": "switch.on_off_switch"}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("/lights/1/state", {"on": False}) + + with patch.object( + warning_device_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_ON, + {"entity_id": "switch.warning_device"}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("/lights/3/state", {"alert": "lselect"}) + + with patch.object( + warning_device_device, "_async_set_callback", return_value=True + ) as set_callback: + await hass.services.async_call( + switch.DOMAIN, + switch.SERVICE_TURN_OFF, + {"entity_id": "switch.warning_device"}, + blocking=True, + ) + await hass.async_block_till_done() + set_callback.assert_called_with("/lights/3/state", {"alert": "none"}) await gateway.async_reset() - assert len(hass.states.async_all()) == 1 + assert len(hass.states.async_all()) == 2 diff --git a/tests/components/device_automation/test_init.py b/tests/components/device_automation/test_init.py index 16320257b40bc2..b05c04a16f1dc5 100644 --- a/tests/components/device_automation/test_init.py +++ b/tests/components/device_automation/test_init.py @@ -21,7 +21,7 @@ def entity_reg(hass): return mock_registry(hass) -def _same_triggers(a, b): +def _same_lists(a, b): if len(a) != len(b): return False @@ -31,6 +31,94 @@ def _same_triggers(a, b): return True +async def test_websocket_get_actions(hass, hass_ws_client, device_reg, entity_reg): + """Test we get the expected conditions from a light through websocket.""" + await async_setup_component(hass, "device_automation", {}) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + expected_actions = [ + { + "domain": "light", + "type": "turn_off", + "device_id": device_entry.id, + "entity_id": "light.test_5678", + }, + { + "domain": "light", + "type": "turn_on", + "device_id": device_entry.id, + "entity_id": "light.test_5678", + }, + { + "domain": "light", + "type": "toggle", + "device_id": device_entry.id, + "entity_id": "light.test_5678", + }, + ] + + client = await hass_ws_client(hass) + await client.send_json( + {"id": 1, "type": "device_automation/action/list", "device_id": device_entry.id} + ) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + actions = msg["result"] + assert _same_lists(actions, expected_actions) + + +async def test_websocket_get_conditions(hass, hass_ws_client, device_reg, entity_reg): + """Test we get the expected conditions from a light through websocket.""" + await async_setup_component(hass, "device_automation", {}) + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + expected_conditions = [ + { + "condition": "device", + "domain": "light", + "type": "is_off", + "device_id": device_entry.id, + "entity_id": "light.test_5678", + }, + { + "condition": "device", + "domain": "light", + "type": "is_on", + "device_id": device_entry.id, + "entity_id": "light.test_5678", + }, + ] + + client = await hass_ws_client(hass) + await client.send_json( + { + "id": 1, + "type": "device_automation/condition/list", + "device_id": device_entry.id, + } + ) + msg = await client.receive_json() + + assert msg["id"] == 1 + assert msg["type"] == TYPE_RESULT + assert msg["success"] + conditions = msg["result"] + assert _same_lists(conditions, expected_conditions) + + async def test_websocket_get_triggers(hass, hass_ws_client, device_reg, entity_reg): """Test we get the expected triggers from a light through websocket.""" await async_setup_component(hass, "device_automation", {}) @@ -45,14 +133,14 @@ async def test_websocket_get_triggers(hass, hass_ws_client, device_reg, entity_r { "platform": "device", "domain": "light", - "type": "turn_off", + "type": "turned_off", "device_id": device_entry.id, "entity_id": "light.test_5678", }, { "platform": "device", "domain": "light", - "type": "turn_on", + "type": "turned_on", "device_id": device_entry.id, "entity_id": "light.test_5678", }, @@ -71,5 +159,5 @@ async def test_websocket_get_triggers(hass, hass_ws_client, device_reg, entity_r assert msg["id"] == 1 assert msg["type"] == TYPE_RESULT assert msg["success"] - triggers = msg["result"]["triggers"] - assert _same_triggers(triggers, expected_triggers) + triggers = msg["result"] + assert _same_lists(triggers, expected_triggers) diff --git a/tests/components/device_sun_light_trigger/test_init.py b/tests/components/device_sun_light_trigger/test_init.py index dd23bf9cff6da0..70681a6d1504d4 100644 --- a/tests/components/device_sun_light_trigger/test_init.py +++ b/tests/components/device_sun_light_trigger/test_init.py @@ -6,7 +6,12 @@ from homeassistant.setup import async_setup_component from homeassistant.const import CONF_PLATFORM, STATE_HOME, STATE_NOT_HOME -from homeassistant.components import device_tracker, light, device_sun_light_trigger +from homeassistant.components import ( + device_tracker, + light, + device_sun_light_trigger, + group, +) from homeassistant.components.device_tracker.const import ( ENTITY_ID_FORMAT as DT_ENTITY_ID_FORMAT, ) @@ -90,6 +95,8 @@ async def test_lights_turn_off_when_everyone_leaves(hass, scanner): hass, device_sun_light_trigger.DOMAIN, {device_sun_light_trigger.DOMAIN: {}} ) + assert light.is_on(hass) + hass.states.async_set(device_tracker.ENTITY_ID_ALL_DEVICES, STATE_NOT_HOME) await hass.async_block_till_done() @@ -111,3 +118,58 @@ async def test_lights_turn_on_when_coming_home_after_sun_set(hass, scanner): await hass.async_block_till_done() assert light.is_on(hass) + + +async def test_lights_turn_on_when_coming_home_after_sun_set_person(hass, scanner): + """Test lights turn on when coming home after sun set.""" + device_1 = DT_ENTITY_ID_FORMAT.format("device_1") + device_2 = DT_ENTITY_ID_FORMAT.format("device_2") + + test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.utcnow", return_value=test_time): + await common_light.async_turn_off(hass) + hass.states.async_set(device_1, STATE_NOT_HOME) + hass.states.async_set(device_2, STATE_NOT_HOME) + await hass.async_block_till_done() + + assert not light.is_on(hass) + assert hass.states.get(device_tracker.ENTITY_ID_ALL_DEVICES).state == "not_home" + assert hass.states.get(device_1).state == "not_home" + assert hass.states.get(device_2).state == "not_home" + + assert await async_setup_component( + hass, + "person", + {"person": [{"id": "me", "name": "Me", "device_trackers": [device_1]}]}, + ) + + await group.Group.async_create_group(hass, "person_me", ["person.me"]) + + assert await async_setup_component( + hass, + device_sun_light_trigger.DOMAIN, + {device_sun_light_trigger.DOMAIN: {"device_group": "group.person_me"}}, + ) + + assert not light.is_on(hass) + assert hass.states.get(device_1).state == "not_home" + assert hass.states.get(device_2).state == "not_home" + assert hass.states.get("person.me").state == "not_home" + + # Unrelated device has no impact + hass.states.async_set(device_2, STATE_HOME) + await hass.async_block_till_done() + + assert not light.is_on(hass) + assert hass.states.get(device_1).state == "not_home" + assert hass.states.get(device_2).state == "home" + assert hass.states.get("person.me").state == "not_home" + + # person home switches on + hass.states.async_set(device_1, STATE_HOME) + await hass.async_block_till_done() + + assert light.is_on(hass) + assert hass.states.get(device_1).state == "home" + assert hass.states.get(device_2).state == "home" + assert hass.states.get("person.me").state == "home" diff --git a/tests/components/flux/test_switch.py b/tests/components/flux/test_switch.py index 08a49c4a6670d4..fb35485f5c9685 100644 --- a/tests/components/flux/test_switch.py +++ b/tests/components/flux/test_switch.py @@ -82,10 +82,10 @@ async def test_flux_when_switch_is_off(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -113,7 +113,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], } }, ) @@ -131,10 +131,10 @@ async def test_flux_before_sunrise(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -162,7 +162,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], } }, ) @@ -184,10 +184,10 @@ async def test_flux_before_sunrise_known_location(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -210,7 +210,7 @@ async def test_flux_before_sunrise_known_location(hass): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], # 'brightness': 255, # 'disable_brightness_adjust': True, # 'mode': 'rgb', @@ -237,10 +237,10 @@ async def test_flux_after_sunrise_before_sunset(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -267,7 +267,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], } }, ) @@ -290,10 +290,10 @@ async def test_flux_after_sunset_before_stop(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -320,7 +320,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], "stop_time": "22:00", } }, @@ -344,10 +344,10 @@ async def test_flux_after_stop_before_sunrise(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -374,7 +374,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], } }, ) @@ -397,10 +397,10 @@ async def test_flux_with_custom_start_stop_times(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -427,7 +427,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], "start_time": "6:00", "stop_time": "23:30", } @@ -454,10 +454,10 @@ async def test_flux_before_sunrise_stop_next_day(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -484,7 +484,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], "stop_time": "01:00", } }, @@ -512,10 +512,10 @@ async def test_flux_after_sunrise_before_sunset_stop_next_day(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -542,7 +542,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], "stop_time": "01:00", } }, @@ -570,10 +570,10 @@ async def test_flux_after_sunset_before_midnight_stop_next_day(hass, x): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -600,7 +600,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], "stop_time": "01:00", } }, @@ -627,10 +627,10 @@ async def test_flux_after_sunset_after_midnight_stop_next_day(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -657,7 +657,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], "stop_time": "01:00", } }, @@ -684,10 +684,10 @@ async def test_flux_after_stop_before_sunrise_stop_next_day(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -714,7 +714,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], "stop_time": "01:00", } }, @@ -738,10 +738,10 @@ async def test_flux_with_custom_colortemps(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -768,7 +768,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], "start_colortemp": "1000", "stop_colortemp": "6000", "stop_time": "22:00", @@ -794,10 +794,10 @@ async def test_flux_with_custom_brightness(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -824,7 +824,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], "brightness": 255, "stop_time": "22:00", } @@ -848,23 +848,23 @@ async def test_flux_with_multiple_lights(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1, dev2, dev3 = platform.DEVICES - common_light.turn_on(hass, entity_id=dev2.entity_id) + ent1, ent2, ent3 = platform.ENTITIES + common_light.turn_on(hass, entity_id=ent2.entity_id) await hass.async_block_till_done() - common_light.turn_on(hass, entity_id=dev3.entity_id) + common_light.turn_on(hass, entity_id=ent3.entity_id) await hass.async_block_till_done() - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None - state = hass.states.get(dev2.entity_id) + state = hass.states.get(ent2.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None - state = hass.states.get(dev3.entity_id) + state = hass.states.get(ent3.entity_id) assert STATE_ON == state.state assert state.attributes.get("xy_color") is None assert state.attributes.get("brightness") is None @@ -893,7 +893,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id, dev2.entity_id, dev3.entity_id], + "lights": [ent1.entity_id, ent2.entity_id, ent3.entity_id], } }, ) @@ -921,10 +921,10 @@ async def test_flux_with_mired(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("color_temp") is None @@ -950,7 +950,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], "mode": "mired", } }, @@ -972,10 +972,10 @@ async def test_flux_with_rgb(hass): hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1 = platform.DEVICES[0] + ent1 = platform.ENTITIES[0] # Verify initial state of light - state = hass.states.get(dev1.entity_id) + state = hass.states.get(ent1.entity_id) assert STATE_ON == state.state assert state.attributes.get("color_temp") is None @@ -1001,7 +1001,7 @@ def event_date(hass, event, now=None): switch.DOMAIN: { "platform": "flux", "name": "flux", - "lights": [dev1.entity_id], + "lights": [ent1.entity_id], "mode": "rgb", } }, diff --git a/tests/components/generic_thermostat/test_climate.py b/tests/components/generic_thermostat/test_climate.py index 367ea52b3a2bcd..776d8f39f69613 100644 --- a/tests/components/generic_thermostat/test_climate.py +++ b/tests/components/generic_thermostat/test_climate.py @@ -116,7 +116,7 @@ async def test_heater_switch(hass, setup_comp_1): """Test heater switching test switch.""" platform = getattr(hass.components, "test.switch") platform.init() - switch_1 = platform.DEVICES[1] + switch_1 = platform.ENTITIES[1] assert await async_setup_component( hass, switch.DOMAIN, {"switch": {"platform": "test"}} ) diff --git a/tests/components/group/test_light.py b/tests/components/group/test_light.py index d3b0d8dd3010d3..87898e42d593e9 100644 --- a/tests/components/group/test_light.py +++ b/tests/components/group/test_light.py @@ -186,6 +186,56 @@ async def test_color_temp(hass): assert state.attributes["color_temp"] == 1000 +async def test_emulated_color_temp_group(hass): + """Test emulated color temperature in a group.""" + await async_setup_component( + hass, + "light", + { + "light": [ + {"platform": "demo"}, + { + "platform": "group", + "entities": [ + "light.bed_light", + "light.ceiling_lights", + "light.kitchen_lights", + ], + }, + ] + }, + ) + await hass.async_block_till_done() + + hass.states.async_set("light.bed_light", "on", {"supported_features": 2}) + await hass.async_block_till_done() + hass.states.async_set("light.ceiling_lights", "on", {"supported_features": 63}) + await hass.async_block_till_done() + hass.states.async_set("light.kitchen_lights", "on", {"supported_features": 61}) + await hass.async_block_till_done() + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": "light.light_group", "color_temp": 200}, + blocking=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("light.bed_light") + assert state.state == "on" + assert state.attributes["color_temp"] == 200 + assert "hs_color" not in state.attributes.keys() + + state = hass.states.get("light.ceiling_lights") + assert state.state == "on" + assert state.attributes["color_temp"] == 200 + assert "hs_color" in state.attributes.keys() + + state = hass.states.get("light.kitchen_lights") + assert state.state == "on" + assert state.attributes["hs_color"] == (27.001, 19.243) + + async def test_min_max_mireds(hass): """Test min/max mireds reporting.""" await async_setup_component( diff --git a/tests/components/here_travel_time/__init__.py b/tests/components/here_travel_time/__init__.py new file mode 100644 index 00000000000000..ac0ec709654e10 --- /dev/null +++ b/tests/components/here_travel_time/__init__.py @@ -0,0 +1 @@ +"""Tests for here_travel_time component.""" diff --git a/tests/components/here_travel_time/test_sensor.py b/tests/components/here_travel_time/test_sensor.py new file mode 100644 index 00000000000000..783209690a389c --- /dev/null +++ b/tests/components/here_travel_time/test_sensor.py @@ -0,0 +1,947 @@ +"""The test for the here_travel_time sensor platform.""" +import logging +from unittest.mock import patch +import urllib + +import herepy +import pytest + +from homeassistant.components.here_travel_time.sensor import ( + ATTR_ATTRIBUTION, + ATTR_DESTINATION, + ATTR_DESTINATION_NAME, + ATTR_DISTANCE, + ATTR_DURATION, + ATTR_DURATION_IN_TRAFFIC, + ATTR_ORIGIN, + ATTR_ORIGIN_NAME, + ATTR_ROUTE, + CONF_MODE, + CONF_TRAFFIC_MODE, + CONF_UNIT_SYSTEM, + ICON_BICYCLE, + ICON_CAR, + ICON_PEDESTRIAN, + ICON_PUBLIC, + ICON_TRUCK, + NO_ROUTE_ERROR_MESSAGE, + ROUTE_MODE_FASTEST, + ROUTE_MODE_SHORTEST, + SCAN_INTERVAL, + TRAFFIC_MODE_DISABLED, + TRAFFIC_MODE_ENABLED, + TRAVEL_MODE_BICYCLE, + TRAVEL_MODE_CAR, + TRAVEL_MODE_PEDESTRIAN, + TRAVEL_MODE_PUBLIC, + TRAVEL_MODE_PUBLIC_TIME_TABLE, + TRAVEL_MODE_TRUCK, + UNIT_OF_MEASUREMENT, +) +from homeassistant.const import ATTR_ICON +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import async_fire_time_changed, load_fixture + +DOMAIN = "sensor" + +PLATFORM = "here_travel_time" + +APP_ID = "test" +APP_CODE = "test" + +TRUCK_ORIGIN_LATITUDE = "41.9798" +TRUCK_ORIGIN_LONGITUDE = "-87.8801" +TRUCK_DESTINATION_LATITUDE = "41.9043" +TRUCK_DESTINATION_LONGITUDE = "-87.9216" + +BIKE_ORIGIN_LATITUDE = "41.9798" +BIKE_ORIGIN_LONGITUDE = "-87.8801" +BIKE_DESTINATION_LATITUDE = "41.9043" +BIKE_DESTINATION_LONGITUDE = "-87.9216" + +CAR_ORIGIN_LATITUDE = "38.9" +CAR_ORIGIN_LONGITUDE = "-77.04833" +CAR_DESTINATION_LATITUDE = "39.0" +CAR_DESTINATION_LONGITUDE = "-77.1" + + +def _build_mock_url(origin, destination, modes, app_id, app_code, departure): + """Construct a url for HERE.""" + base_url = "https://route.cit.api.here.com/routing/7.2/calculateroute.json?" + parameters = { + "waypoint0": origin, + "waypoint1": destination, + "mode": ";".join(str(herepy.RouteMode[mode]) for mode in modes), + "app_id": app_id, + "app_code": app_code, + "departure": departure, + } + url = base_url + urllib.parse.urlencode(parameters) + return url + + +def _assert_truck_sensor(sensor): + """Assert that states and attributes are correct for truck_response.""" + assert sensor.state == "14" + assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT + + assert sensor.attributes.get(ATTR_ATTRIBUTION) is None + assert sensor.attributes.get(ATTR_DURATION) == 13.533333333333333 + assert sensor.attributes.get(ATTR_DISTANCE) == 13.049 + assert sensor.attributes.get(ATTR_ROUTE) == ( + "I-190; I-294 S - Tri-State Tollway; I-290 W - Eisenhower Expy W; " + "IL-64 W - E North Ave; I-290 E - Eisenhower Expy E; I-290" + ) + assert sensor.attributes.get(CONF_UNIT_SYSTEM) == "metric" + assert sensor.attributes.get(ATTR_DURATION_IN_TRAFFIC) == 13.533333333333333 + assert sensor.attributes.get(ATTR_ORIGIN) == ",".join( + [TRUCK_ORIGIN_LATITUDE, TRUCK_ORIGIN_LONGITUDE] + ) + assert sensor.attributes.get(ATTR_DESTINATION) == ",".join( + [TRUCK_DESTINATION_LATITUDE, TRUCK_DESTINATION_LONGITUDE] + ) + assert sensor.attributes.get(ATTR_ORIGIN_NAME) == "" + assert sensor.attributes.get(ATTR_DESTINATION_NAME) == "Eisenhower Expy E" + assert sensor.attributes.get(CONF_MODE) == TRAVEL_MODE_TRUCK + assert sensor.attributes.get(CONF_TRAFFIC_MODE) is False + + assert sensor.attributes.get(ATTR_ICON) == ICON_TRUCK + + +@pytest.fixture +def requests_mock_credentials_check(requests_mock): + """Add the url used in the api validation to all requests mock.""" + modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_DISABLED] + response_url = _build_mock_url( + ",".join([CAR_ORIGIN_LATITUDE, CAR_ORIGIN_LONGITUDE]), + ",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]), + modes, + APP_ID, + APP_CODE, + "now", + ) + requests_mock.get( + response_url, text=load_fixture("here_travel_time/car_response.json") + ) + return requests_mock + + +@pytest.fixture +def requests_mock_truck_response(requests_mock_credentials_check): + """Return a requests_mock for truck respones.""" + modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_TRUCK, TRAFFIC_MODE_DISABLED] + response_url = _build_mock_url( + ",".join([TRUCK_ORIGIN_LATITUDE, TRUCK_ORIGIN_LONGITUDE]), + ",".join([TRUCK_DESTINATION_LATITUDE, TRUCK_DESTINATION_LONGITUDE]), + modes, + APP_ID, + APP_CODE, + "now", + ) + requests_mock_credentials_check.get( + response_url, text=load_fixture("here_travel_time/truck_response.json") + ) + + +@pytest.fixture +def requests_mock_car_disabled_response(requests_mock_credentials_check): + """Return a requests_mock for truck respones.""" + modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_DISABLED] + response_url = _build_mock_url( + ",".join([CAR_ORIGIN_LATITUDE, CAR_ORIGIN_LONGITUDE]), + ",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]), + modes, + APP_ID, + APP_CODE, + "now", + ) + requests_mock_credentials_check.get( + response_url, text=load_fixture("here_travel_time/car_response.json") + ) + + +async def test_car(hass, requests_mock_car_disabled_response): + """Test that car works.""" + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": CAR_ORIGIN_LATITUDE, + "origin_longitude": CAR_ORIGIN_LONGITUDE, + "destination_latitude": CAR_DESTINATION_LATITUDE, + "destination_longitude": CAR_DESTINATION_LONGITUDE, + "app_id": APP_ID, + "app_code": APP_CODE, + } + } + + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + assert sensor.state == "30" + assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT + + assert sensor.attributes.get(ATTR_ATTRIBUTION) is None + assert sensor.attributes.get(ATTR_DURATION) == 30.05 + assert sensor.attributes.get(ATTR_DISTANCE) == 23.903 + assert sensor.attributes.get(ATTR_ROUTE) == ( + "US-29 - K St NW; US-29 - Whitehurst Fwy; " + "I-495 N - Capital Beltway; MD-187 S - Old Georgetown Rd" + ) + assert sensor.attributes.get(CONF_UNIT_SYSTEM) == "metric" + assert sensor.attributes.get(ATTR_DURATION_IN_TRAFFIC) == 31.016666666666666 + assert sensor.attributes.get(ATTR_ORIGIN) == ",".join( + [CAR_ORIGIN_LATITUDE, CAR_ORIGIN_LONGITUDE] + ) + assert sensor.attributes.get(ATTR_DESTINATION) == ",".join( + [CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE] + ) + assert sensor.attributes.get(ATTR_ORIGIN_NAME) == "22nd St NW" + assert sensor.attributes.get(ATTR_DESTINATION_NAME) == "Service Rd S" + assert sensor.attributes.get(CONF_MODE) == TRAVEL_MODE_CAR + assert sensor.attributes.get(CONF_TRAFFIC_MODE) is False + + assert sensor.attributes.get(ATTR_ICON) == ICON_CAR + + # Test traffic mode disabled + assert sensor.attributes.get(ATTR_DURATION) != sensor.attributes.get( + ATTR_DURATION_IN_TRAFFIC + ) + + +async def test_traffic_mode_enabled(hass, requests_mock_credentials_check): + """Test that traffic mode enabled works.""" + modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_ENABLED] + response_url = _build_mock_url( + ",".join([CAR_ORIGIN_LATITUDE, CAR_ORIGIN_LONGITUDE]), + ",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]), + modes, + APP_ID, + APP_CODE, + "now", + ) + requests_mock_credentials_check.get( + response_url, text=load_fixture("here_travel_time/car_enabled_response.json") + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": CAR_ORIGIN_LATITUDE, + "origin_longitude": CAR_ORIGIN_LONGITUDE, + "destination_latitude": CAR_DESTINATION_LATITUDE, + "destination_longitude": CAR_DESTINATION_LONGITUDE, + "app_id": APP_ID, + "app_code": APP_CODE, + "traffic_mode": True, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + + # Test traffic mode enabled + assert sensor.attributes.get(ATTR_DURATION) != sensor.attributes.get( + ATTR_DURATION_IN_TRAFFIC + ) + + +async def test_imperial(hass, requests_mock_car_disabled_response): + """Test that imperial units work.""" + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": CAR_ORIGIN_LATITUDE, + "origin_longitude": CAR_ORIGIN_LONGITUDE, + "destination_latitude": CAR_DESTINATION_LATITUDE, + "destination_longitude": CAR_DESTINATION_LONGITUDE, + "app_id": APP_ID, + "app_code": APP_CODE, + "unit_system": "imperial", + } + } + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + assert sensor.attributes.get(ATTR_DISTANCE) == 14.852635608048994 + + +async def test_route_mode_shortest(hass, requests_mock_credentials_check): + """Test that route mode shortest works.""" + origin = "38.902981,-77.048338" + destination = "39.042158,-77.119116" + modes = [ROUTE_MODE_SHORTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_DISABLED] + response_url = _build_mock_url(origin, destination, modes, APP_ID, APP_CODE, "now") + requests_mock_credentials_check.get( + response_url, text=load_fixture("here_travel_time/car_shortest_response.json") + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": origin.split(",")[0], + "origin_longitude": origin.split(",")[1], + "destination_latitude": destination.split(",")[0], + "destination_longitude": destination.split(",")[1], + "app_id": APP_ID, + "app_code": APP_CODE, + "route_mode": ROUTE_MODE_SHORTEST, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + assert sensor.attributes.get(ATTR_DISTANCE) == 18.388 + + +async def test_route_mode_fastest(hass, requests_mock_credentials_check): + """Test that route mode fastest works.""" + origin = "38.902981,-77.048338" + destination = "39.042158,-77.119116" + modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_ENABLED] + response_url = _build_mock_url(origin, destination, modes, APP_ID, APP_CODE, "now") + requests_mock_credentials_check.get( + response_url, text=load_fixture("here_travel_time/car_enabled_response.json") + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": origin.split(",")[0], + "origin_longitude": origin.split(",")[1], + "destination_latitude": destination.split(",")[0], + "destination_longitude": destination.split(",")[1], + "app_id": APP_ID, + "app_code": APP_CODE, + "traffic_mode": True, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + assert sensor.attributes.get(ATTR_DISTANCE) == 23.381 + + +async def test_truck(hass, requests_mock_truck_response): + """Test that truck works.""" + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": TRUCK_ORIGIN_LATITUDE, + "origin_longitude": TRUCK_ORIGIN_LONGITUDE, + "destination_latitude": TRUCK_DESTINATION_LATITUDE, + "destination_longitude": TRUCK_DESTINATION_LONGITUDE, + "app_id": APP_ID, + "app_code": APP_CODE, + "mode": TRAVEL_MODE_TRUCK, + } + } + assert await async_setup_component(hass, DOMAIN, config) + sensor = hass.states.get("sensor.test") + _assert_truck_sensor(sensor) + + +async def test_public_transport(hass, requests_mock_credentials_check): + """Test that publicTransport works.""" + origin = "41.9798,-87.8801" + destination = "41.9043,-87.9216" + modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PUBLIC, TRAFFIC_MODE_DISABLED] + response_url = _build_mock_url(origin, destination, modes, APP_ID, APP_CODE, "now") + requests_mock_credentials_check.get( + response_url, text=load_fixture("here_travel_time/public_response.json") + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": origin.split(",")[0], + "origin_longitude": origin.split(",")[1], + "destination_latitude": destination.split(",")[0], + "destination_longitude": destination.split(",")[1], + "app_id": APP_ID, + "app_code": APP_CODE, + "mode": TRAVEL_MODE_PUBLIC, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + assert sensor.state == "89" + assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT + + assert sensor.attributes.get(ATTR_ATTRIBUTION) is None + assert sensor.attributes.get(ATTR_DURATION) == 89.16666666666667 + assert sensor.attributes.get(ATTR_DISTANCE) == 22.325 + assert sensor.attributes.get(ATTR_ROUTE) == ( + "332 - Palmer/Schiller; 332 - Cargo Rd./Delta Cargo; " "332 - Palmer/Schiller" + ) + assert sensor.attributes.get(CONF_UNIT_SYSTEM) == "metric" + assert sensor.attributes.get(ATTR_DURATION_IN_TRAFFIC) == 89.16666666666667 + assert sensor.attributes.get(ATTR_ORIGIN) == origin + assert sensor.attributes.get(ATTR_DESTINATION) == destination + assert sensor.attributes.get(ATTR_ORIGIN_NAME) == "Mannheim Rd" + assert sensor.attributes.get(ATTR_DESTINATION_NAME) == "" + assert sensor.attributes.get(CONF_MODE) == TRAVEL_MODE_PUBLIC + assert sensor.attributes.get(CONF_TRAFFIC_MODE) is False + + assert sensor.attributes.get(ATTR_ICON) == ICON_PUBLIC + + +async def test_public_transport_time_table(hass, requests_mock_credentials_check): + """Test that publicTransportTimeTable works.""" + origin = "41.9798,-87.8801" + destination = "41.9043,-87.9216" + modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAFFIC_MODE_DISABLED] + response_url = _build_mock_url(origin, destination, modes, APP_ID, APP_CODE, "now") + requests_mock_credentials_check.get( + response_url, + text=load_fixture("here_travel_time/public_time_table_response.json"), + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": origin.split(",")[0], + "origin_longitude": origin.split(",")[1], + "destination_latitude": destination.split(",")[0], + "destination_longitude": destination.split(",")[1], + "app_id": APP_ID, + "app_code": APP_CODE, + "mode": TRAVEL_MODE_PUBLIC_TIME_TABLE, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + assert sensor.state == "80" + assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT + + assert sensor.attributes.get(ATTR_ATTRIBUTION) is None + assert sensor.attributes.get(ATTR_DURATION) == 79.73333333333333 + assert sensor.attributes.get(ATTR_DISTANCE) == 14.775 + assert sensor.attributes.get(ATTR_ROUTE) == ( + "330 - Archer/Harlem (Terminal); 309 - Elmhurst Metra Station" + ) + assert sensor.attributes.get(CONF_UNIT_SYSTEM) == "metric" + assert sensor.attributes.get(ATTR_DURATION_IN_TRAFFIC) == 79.73333333333333 + assert sensor.attributes.get(ATTR_ORIGIN) == origin + assert sensor.attributes.get(ATTR_DESTINATION) == destination + assert sensor.attributes.get(ATTR_ORIGIN_NAME) == "Mannheim Rd" + assert sensor.attributes.get(ATTR_DESTINATION_NAME) == "" + assert sensor.attributes.get(CONF_MODE) == TRAVEL_MODE_PUBLIC_TIME_TABLE + assert sensor.attributes.get(CONF_TRAFFIC_MODE) is False + + assert sensor.attributes.get(ATTR_ICON) == ICON_PUBLIC + + +async def test_pedestrian(hass, requests_mock_credentials_check): + """Test that pedestrian works.""" + origin = "41.9798,-87.8801" + destination = "41.9043,-87.9216" + modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_PEDESTRIAN, TRAFFIC_MODE_DISABLED] + response_url = _build_mock_url(origin, destination, modes, APP_ID, APP_CODE, "now") + requests_mock_credentials_check.get( + response_url, text=load_fixture("here_travel_time/pedestrian_response.json") + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": origin.split(",")[0], + "origin_longitude": origin.split(",")[1], + "destination_latitude": destination.split(",")[0], + "destination_longitude": destination.split(",")[1], + "app_id": APP_ID, + "app_code": APP_CODE, + "mode": TRAVEL_MODE_PEDESTRIAN, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + assert sensor.state == "211" + assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT + + assert sensor.attributes.get(ATTR_ATTRIBUTION) is None + assert sensor.attributes.get(ATTR_DURATION) == 210.51666666666668 + assert sensor.attributes.get(ATTR_DISTANCE) == 12.533 + assert sensor.attributes.get(ATTR_ROUTE) == ( + "Mannheim Rd; W Belmont Ave; Cullerton St; E Fullerton Ave; " + "La Porte Ave; E Palmer Ave; N Railroad Ave; W North Ave; " + "E North Ave; E Third St" + ) + assert sensor.attributes.get(CONF_UNIT_SYSTEM) == "metric" + assert sensor.attributes.get(ATTR_DURATION_IN_TRAFFIC) == 210.51666666666668 + assert sensor.attributes.get(ATTR_ORIGIN) == origin + assert sensor.attributes.get(ATTR_DESTINATION) == destination + assert sensor.attributes.get(ATTR_ORIGIN_NAME) == "Mannheim Rd" + assert sensor.attributes.get(ATTR_DESTINATION_NAME) == "" + assert sensor.attributes.get(CONF_MODE) == TRAVEL_MODE_PEDESTRIAN + assert sensor.attributes.get(CONF_TRAFFIC_MODE) is False + + assert sensor.attributes.get(ATTR_ICON) == ICON_PEDESTRIAN + + +async def test_bicycle(hass, requests_mock_credentials_check): + """Test that bicycle works.""" + origin = "41.9798,-87.8801" + destination = "41.9043,-87.9216" + modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_BICYCLE, TRAFFIC_MODE_DISABLED] + response_url = _build_mock_url(origin, destination, modes, APP_ID, APP_CODE, "now") + requests_mock_credentials_check.get( + response_url, text=load_fixture("here_travel_time/bike_response.json") + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": origin.split(",")[0], + "origin_longitude": origin.split(",")[1], + "destination_latitude": destination.split(",")[0], + "destination_longitude": destination.split(",")[1], + "app_id": APP_ID, + "app_code": APP_CODE, + "mode": TRAVEL_MODE_BICYCLE, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + assert sensor.state == "55" + assert sensor.attributes.get("unit_of_measurement") == UNIT_OF_MEASUREMENT + + assert sensor.attributes.get(ATTR_ATTRIBUTION) is None + assert sensor.attributes.get(ATTR_DURATION) == 54.86666666666667 + assert sensor.attributes.get(ATTR_DISTANCE) == 12.613 + assert sensor.attributes.get(ATTR_ROUTE) == ( + "Mannheim Rd; W Belmont Ave; Cullerton St; N Landen Dr; " + "E Fullerton Ave; N Wolf Rd; W North Ave; N Clinton Ave; " + "E Third St; N Caroline Ave" + ) + assert sensor.attributes.get(CONF_UNIT_SYSTEM) == "metric" + assert sensor.attributes.get(ATTR_DURATION_IN_TRAFFIC) == 54.86666666666667 + assert sensor.attributes.get(ATTR_ORIGIN) == origin + assert sensor.attributes.get(ATTR_DESTINATION) == destination + assert sensor.attributes.get(ATTR_ORIGIN_NAME) == "Mannheim Rd" + assert sensor.attributes.get(ATTR_DESTINATION_NAME) == "" + assert sensor.attributes.get(CONF_MODE) == TRAVEL_MODE_BICYCLE + assert sensor.attributes.get(CONF_TRAFFIC_MODE) is False + + assert sensor.attributes.get(ATTR_ICON) == ICON_BICYCLE + + +async def test_location_zone(hass, requests_mock_truck_response): + """Test that origin/destination supplied by a zone works.""" + utcnow = dt_util.utcnow() + # Patching 'utcnow' to gain more control over the timed update. + with patch("homeassistant.util.dt.utcnow", return_value=utcnow): + zone_config = { + "zone": [ + { + "name": "Destination", + "latitude": TRUCK_DESTINATION_LATITUDE, + "longitude": TRUCK_DESTINATION_LONGITUDE, + "radius": 250, + "passive": False, + }, + { + "name": "Origin", + "latitude": TRUCK_ORIGIN_LATITUDE, + "longitude": TRUCK_ORIGIN_LONGITUDE, + "radius": 250, + "passive": False, + }, + ] + } + assert await async_setup_component(hass, "zone", zone_config) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_entity_id": "zone.origin", + "destination_entity_id": "zone.destination", + "app_id": APP_ID, + "app_code": APP_CODE, + "mode": TRAVEL_MODE_TRUCK, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + _assert_truck_sensor(sensor) + + # Test that update works more than once + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.test") + _assert_truck_sensor(sensor) + + +async def test_location_sensor(hass, requests_mock_truck_response): + """Test that origin/destination supplied by a sensor works.""" + utcnow = dt_util.utcnow() + # Patching 'utcnow' to gain more control over the timed update. + with patch("homeassistant.util.dt.utcnow", return_value=utcnow): + hass.states.async_set( + "sensor.origin", ",".join([TRUCK_ORIGIN_LATITUDE, TRUCK_ORIGIN_LONGITUDE]) + ) + hass.states.async_set( + "sensor.destination", + ",".join([TRUCK_DESTINATION_LATITUDE, TRUCK_DESTINATION_LONGITUDE]), + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_entity_id": "sensor.origin", + "destination_entity_id": "sensor.destination", + "app_id": APP_ID, + "app_code": APP_CODE, + "mode": TRAVEL_MODE_TRUCK, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + _assert_truck_sensor(sensor) + + # Test that update works more than once + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.test") + _assert_truck_sensor(sensor) + + +async def test_location_person(hass, requests_mock_truck_response): + """Test that origin/destination supplied by a person works.""" + utcnow = dt_util.utcnow() + # Patching 'utcnow' to gain more control over the timed update. + with patch("homeassistant.util.dt.utcnow", return_value=utcnow): + hass.states.async_set( + "person.origin", + "unknown", + { + "latitude": float(TRUCK_ORIGIN_LATITUDE), + "longitude": float(TRUCK_ORIGIN_LONGITUDE), + }, + ) + hass.states.async_set( + "person.destination", + "unknown", + { + "latitude": float(TRUCK_DESTINATION_LATITUDE), + "longitude": float(TRUCK_DESTINATION_LONGITUDE), + }, + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_entity_id": "person.origin", + "destination_entity_id": "person.destination", + "app_id": APP_ID, + "app_code": APP_CODE, + "mode": TRAVEL_MODE_TRUCK, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + _assert_truck_sensor(sensor) + + # Test that update works more than once + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.test") + _assert_truck_sensor(sensor) + + +async def test_location_device_tracker(hass, requests_mock_truck_response): + """Test that origin/destination supplied by a device_tracker works.""" + utcnow = dt_util.utcnow() + # Patching 'utcnow' to gain more control over the timed update. + with patch("homeassistant.util.dt.utcnow", return_value=utcnow): + hass.states.async_set( + "device_tracker.origin", + "unknown", + { + "latitude": float(TRUCK_ORIGIN_LATITUDE), + "longitude": float(TRUCK_ORIGIN_LONGITUDE), + }, + ) + hass.states.async_set( + "device_tracker.destination", + "unknown", + { + "latitude": float(TRUCK_DESTINATION_LATITUDE), + "longitude": float(TRUCK_DESTINATION_LONGITUDE), + }, + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_entity_id": "device_tracker.origin", + "destination_entity_id": "device_tracker.destination", + "app_id": APP_ID, + "app_code": APP_CODE, + "mode": TRAVEL_MODE_TRUCK, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + _assert_truck_sensor(sensor) + + # Test that update works more than once + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.test") + _assert_truck_sensor(sensor) + + +async def test_location_device_tracker_added_after_update( + hass, requests_mock_truck_response, caplog +): + """Test that device_tracker added after first update works.""" + caplog.set_level(logging.ERROR) + utcnow = dt_util.utcnow() + # Patching 'utcnow' to gain more control over the timed update. + with patch("homeassistant.util.dt.utcnow", return_value=utcnow): + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_entity_id": "device_tracker.origin", + "destination_entity_id": "device_tracker.destination", + "app_id": APP_ID, + "app_code": APP_CODE, + "mode": TRAVEL_MODE_TRUCK, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + assert len(caplog.records) == 2 + assert "Unable to find entity" in caplog.text + caplog.clear() + + # Device tracker appear after first update + hass.states.async_set( + "device_tracker.origin", + "unknown", + { + "latitude": float(TRUCK_ORIGIN_LATITUDE), + "longitude": float(TRUCK_ORIGIN_LONGITUDE), + }, + ) + hass.states.async_set( + "device_tracker.destination", + "unknown", + { + "latitude": float(TRUCK_DESTINATION_LATITUDE), + "longitude": float(TRUCK_DESTINATION_LONGITUDE), + }, + ) + + # Test that update works more than once + async_fire_time_changed(hass, utcnow + SCAN_INTERVAL) + await hass.async_block_till_done() + + sensor = hass.states.get("sensor.test") + _assert_truck_sensor(sensor) + assert len(caplog.records) == 0 + + +async def test_location_device_tracker_in_zone( + hass, requests_mock_truck_response, caplog +): + """Test that device_tracker in zone uses device_tracker state works.""" + caplog.set_level(logging.DEBUG) + zone_config = { + "zone": [ + { + "name": "Origin", + "latitude": TRUCK_ORIGIN_LATITUDE, + "longitude": TRUCK_ORIGIN_LONGITUDE, + "radius": 250, + "passive": False, + } + ] + } + assert await async_setup_component(hass, "zone", zone_config) + hass.states.async_set( + "device_tracker.origin", "origin", {"latitude": None, "longitude": None} + ) + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_entity_id": "device_tracker.origin", + "destination_latitude": TRUCK_DESTINATION_LATITUDE, + "destination_longitude": TRUCK_DESTINATION_LONGITUDE, + "app_id": APP_ID, + "app_code": APP_CODE, + "mode": TRAVEL_MODE_TRUCK, + } + } + assert await async_setup_component(hass, DOMAIN, config) + + sensor = hass.states.get("sensor.test") + _assert_truck_sensor(sensor) + assert ", getting zone location" in caplog.text + + +async def test_route_not_found(hass, requests_mock_credentials_check, caplog): + """Test that route not found error is correctly handled.""" + caplog.set_level(logging.ERROR) + origin = "52.516,13.3779" + destination = "47.013399,-10.171986" + modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_DISABLED] + response_url = _build_mock_url(origin, destination, modes, APP_ID, APP_CODE, "now") + requests_mock_credentials_check.get( + response_url, + text=load_fixture("here_travel_time/routing_error_no_route_found.json"), + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": origin.split(",")[0], + "origin_longitude": origin.split(",")[1], + "destination_latitude": destination.split(",")[0], + "destination_longitude": destination.split(",")[1], + "app_id": APP_ID, + "app_code": APP_CODE, + } + } + assert await async_setup_component(hass, DOMAIN, config) + assert len(caplog.records) == 1 + assert NO_ROUTE_ERROR_MESSAGE in caplog.text + + +async def test_pattern_origin(hass, caplog): + """Test that pattern matching the origin works.""" + caplog.set_level(logging.ERROR) + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": "138.90", + "origin_longitude": "-77.04833", + "destination_latitude": CAR_DESTINATION_LATITUDE, + "destination_longitude": CAR_DESTINATION_LONGITUDE, + "app_id": APP_ID, + "app_code": APP_CODE, + } + } + assert await async_setup_component(hass, DOMAIN, config) + assert len(caplog.records) == 1 + assert "invalid latitude" in caplog.text + + +async def test_pattern_destination(hass, caplog): + """Test that pattern matching the destination works.""" + caplog.set_level(logging.ERROR) + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": CAR_ORIGIN_LATITUDE, + "origin_longitude": CAR_ORIGIN_LONGITUDE, + "destination_latitude": "139.0", + "destination_longitude": "-77.1", + "app_id": APP_ID, + "app_code": APP_CODE, + } + } + assert await async_setup_component(hass, DOMAIN, config) + assert len(caplog.records) == 1 + assert "invalid latitude" in caplog.text + + +async def test_invalid_credentials(hass, requests_mock, caplog): + """Test that invalid credentials error is correctly handled.""" + caplog.set_level(logging.ERROR) + modes = [ROUTE_MODE_FASTEST, TRAVEL_MODE_CAR, TRAFFIC_MODE_DISABLED] + response_url = _build_mock_url( + ",".join([CAR_ORIGIN_LATITUDE, CAR_ORIGIN_LONGITUDE]), + ",".join([CAR_DESTINATION_LATITUDE, CAR_DESTINATION_LONGITUDE]), + modes, + APP_ID, + APP_CODE, + "now", + ) + requests_mock.get( + response_url, + text=load_fixture("here_travel_time/routing_error_invalid_credentials.json"), + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": CAR_ORIGIN_LATITUDE, + "origin_longitude": CAR_ORIGIN_LONGITUDE, + "destination_latitude": CAR_DESTINATION_LATITUDE, + "destination_longitude": CAR_DESTINATION_LONGITUDE, + "app_id": APP_ID, + "app_code": APP_CODE, + } + } + assert await async_setup_component(hass, DOMAIN, config) + assert len(caplog.records) == 1 + assert "Invalid credentials" in caplog.text + + +async def test_attribution(hass, requests_mock_credentials_check): + """Test that attributions are correctly displayed.""" + origin = "50.037751372637686,14.39233448220898" + destination = "50.07993838201255,14.42582157361062" + modes = [ROUTE_MODE_SHORTEST, TRAVEL_MODE_PUBLIC_TIME_TABLE, TRAFFIC_MODE_ENABLED] + response_url = _build_mock_url(origin, destination, modes, APP_ID, APP_CODE, "now") + requests_mock_credentials_check.get( + response_url, text=load_fixture("here_travel_time/attribution_response.json") + ) + + config = { + DOMAIN: { + "platform": PLATFORM, + "name": "test", + "origin_latitude": origin.split(",")[0], + "origin_longitude": origin.split(",")[1], + "destination_latitude": destination.split(",")[0], + "destination_longitude": destination.split(",")[1], + "app_id": APP_ID, + "app_code": APP_CODE, + "traffic_mode": True, + "route_mode": ROUTE_MODE_SHORTEST, + "mode": TRAVEL_MODE_PUBLIC_TIME_TABLE, + } + } + assert await async_setup_component(hass, DOMAIN, config) + sensor = hass.states.get("sensor.test") + assert ( + sensor.attributes.get(ATTR_ATTRIBUTION) + == "With the support of HERE Technologies. All information is provided without warranty of any kind." + ) diff --git a/tests/components/iaqualink/__init__.py b/tests/components/iaqualink/__init__.py new file mode 100644 index 00000000000000..c4e3b75d0ae6b2 --- /dev/null +++ b/tests/components/iaqualink/__init__.py @@ -0,0 +1 @@ +"""Tests for the iAqualink component.""" diff --git a/tests/components/iaqualink/test_config_flow.py b/tests/components/iaqualink/test_config_flow.py new file mode 100644 index 00000000000000..5c4d75ee3c155f --- /dev/null +++ b/tests/components/iaqualink/test_config_flow.py @@ -0,0 +1,77 @@ +"""Tests for iAqualink config flow.""" +from unittest.mock import patch + +import iaqualink +import pytest + +from homeassistant.components.iaqualink import config_flow +from tests.common import MockConfigEntry, mock_coro + +DATA = {"username": "test@example.com", "password": "pass"} + + +@pytest.mark.parametrize("step", ["import", "user"]) +async def test_already_configured(hass, step): + """Test config flow when iaqualink component is already setup.""" + MockConfigEntry(domain="iaqualink", data=DATA).add_to_hass(hass) + + flow = config_flow.AqualinkFlowHandler() + flow.hass = hass + flow.context = {} + + fname = f"async_step_{step}" + func = getattr(flow, fname) + result = await func(DATA) + + assert result["type"] == "abort" + + +@pytest.mark.parametrize("step", ["import", "user"]) +async def test_without_config(hass, step): + """Test with no configuration.""" + flow = config_flow.AqualinkFlowHandler() + flow.hass = hass + flow.context = {} + + fname = f"async_step_{step}" + func = getattr(flow, fname) + result = await func() + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {} + + +@pytest.mark.parametrize("step", ["import", "user"]) +async def test_with_invalid_credentials(hass, step): + """Test config flow with invalid username and/or password.""" + flow = config_flow.AqualinkFlowHandler() + flow.hass = hass + + fname = f"async_step_{step}" + func = getattr(flow, fname) + with patch( + "iaqualink.AqualinkClient.login", side_effect=iaqualink.AqualinkLoginException + ): + result = await func(DATA) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"] == {"base": "connection_failure"} + + +@pytest.mark.parametrize("step", ["import", "user"]) +async def test_with_existing_config(hass, step): + """Test with existing configuration.""" + flow = config_flow.AqualinkFlowHandler() + flow.hass = hass + flow.context = {} + + fname = f"async_step_{step}" + func = getattr(flow, fname) + with patch("iaqualink.AqualinkClient.login", return_value=mock_coro(None)): + result = await func(DATA) + + assert result["type"] == "create_entry" + assert result["title"] == DATA["username"] + assert result["data"] == DATA diff --git a/tests/components/izone/__init__.py b/tests/components/izone/__init__.py new file mode 100644 index 00000000000000..1baeb3fee82702 --- /dev/null +++ b/tests/components/izone/__init__.py @@ -0,0 +1 @@ +"""IZone tests.""" diff --git a/tests/components/izone/test_config_flow.py b/tests/components/izone/test_config_flow.py new file mode 100644 index 00000000000000..faa920271e385b --- /dev/null +++ b/tests/components/izone/test_config_flow.py @@ -0,0 +1,83 @@ +"""Tests for iZone.""" + +from unittest.mock import Mock, patch + +import pytest + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.izone.const import IZONE, DISPATCH_CONTROLLER_DISCOVERED + +from tests.common import mock_coro + + +@pytest.fixture +def mock_disco(): + """Mock discovery service.""" + disco = Mock() + disco.pi_disco = Mock() + disco.pi_disco.controllers = {} + yield disco + + +def _mock_start_discovery(hass, mock_disco): + from homeassistant.helpers.dispatcher import async_dispatcher_send + + def do_disovered(*args): + async_dispatcher_send(hass, DISPATCH_CONTROLLER_DISCOVERED, True) + return mock_coro(mock_disco) + + return do_disovered + + +async def test_not_found(hass, mock_disco): + """Test not finding iZone controller.""" + + with patch( + "homeassistant.components.izone.discovery.async_start_discovery_service" + ) as start_disco, patch( + "homeassistant.components.izone.discovery.async_stop_discovery_service", + return_value=mock_coro(), + ) as stop_disco: + start_disco.side_effect = _mock_start_discovery(hass, mock_disco) + result = await hass.config_entries.flow.async_init( + IZONE, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + + await hass.async_block_till_done() + + stop_disco.assert_called_once() + + +async def test_found(hass, mock_disco): + """Test not finding iZone controller.""" + mock_disco.pi_disco.controllers["blah"] = object() + + with patch( + "homeassistant.components.izone.climate.async_setup_entry", + return_value=mock_coro(True), + ) as mock_setup, patch( + "homeassistant.components.izone.discovery.async_start_discovery_service" + ) as start_disco, patch( + "homeassistant.components.izone.async_start_discovery_service", + return_value=mock_coro(), + ): + start_disco.side_effect = _mock_start_discovery(hass, mock_disco) + result = await hass.config_entries.flow.async_init( + IZONE, context={"source": config_entries.SOURCE_USER} + ) + + # Confirmation form + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + await hass.async_block_till_done() + + mock_setup.assert_called_once() diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index d6928c189e8c57..54589a640cc0cd 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -1 +1,72 @@ """Tests for the jewish_calendar component.""" +from datetime import datetime +from collections import namedtuple +from contextlib import contextmanager +from unittest.mock import patch + +from homeassistant.components import jewish_calendar +import homeassistant.util.dt as dt_util + + +_LatLng = namedtuple("_LatLng", ["lat", "lng"]) + +NYC_LATLNG = _LatLng(40.7128, -74.0060) +JERUSALEM_LATLNG = _LatLng(31.778, 35.235) + +ORIG_TIME_ZONE = dt_util.DEFAULT_TIME_ZONE + + +def teardown_module(): + """Reset time zone.""" + dt_util.set_default_time_zone(ORIG_TIME_ZONE) + + +def make_nyc_test_params(dtime, results, havdalah_offset=0): + """Make test params for NYC.""" + if isinstance(results, dict): + time_zone = dt_util.get_time_zone("America/New_York") + results = { + key: time_zone.localize(value) if isinstance(value, datetime) else value + for key, value in results.items() + } + return ( + dtime, + jewish_calendar.CANDLE_LIGHT_DEFAULT, + havdalah_offset, + True, + "America/New_York", + NYC_LATLNG.lat, + NYC_LATLNG.lng, + results, + ) + + +def make_jerusalem_test_params(dtime, results, havdalah_offset=0): + """Make test params for Jerusalem.""" + if isinstance(results, dict): + time_zone = dt_util.get_time_zone("Asia/Jerusalem") + results = { + key: time_zone.localize(value) if isinstance(value, datetime) else value + for key, value in results.items() + } + return ( + dtime, + jewish_calendar.CANDLE_LIGHT_DEFAULT, + havdalah_offset, + False, + "Asia/Jerusalem", + JERUSALEM_LATLNG.lat, + JERUSALEM_LATLNG.lng, + results, + ) + + +@contextmanager +def alter_time(local_time): + """Manage multiple time mocks.""" + utc_time = dt_util.as_utc(local_time) + patch1 = patch("homeassistant.util.dt.utcnow", return_value=utc_time) + patch2 = patch("homeassistant.util.dt.now", return_value=local_time) + + with patch1, patch2: + yield diff --git a/tests/components/jewish_calendar/test_binary_sensor.py b/tests/components/jewish_calendar/test_binary_sensor.py new file mode 100644 index 00000000000000..64745d8929f7e7 --- /dev/null +++ b/tests/components/jewish_calendar/test_binary_sensor.py @@ -0,0 +1,97 @@ +"""The tests for the Jewish calendar binary sensors.""" +from datetime import timedelta +from datetime import datetime as dt + +import pytest + +from homeassistant.const import STATE_ON, STATE_OFF +import homeassistant.util.dt as dt_util +from homeassistant.setup import async_setup_component +from homeassistant.components import jewish_calendar + +from tests.common import async_fire_time_changed +from . import alter_time, make_nyc_test_params, make_jerusalem_test_params + + +MELACHA_PARAMS = [ + make_nyc_test_params(dt(2018, 9, 1, 16, 0), STATE_ON), + make_nyc_test_params(dt(2018, 9, 1, 20, 21), STATE_OFF), + make_nyc_test_params(dt(2018, 9, 7, 13, 1), STATE_OFF), + make_nyc_test_params(dt(2018, 9, 8, 21, 25), STATE_OFF), + make_nyc_test_params(dt(2018, 9, 9, 21, 25), STATE_ON), + make_nyc_test_params(dt(2018, 9, 10, 21, 25), STATE_ON), + make_nyc_test_params(dt(2018, 9, 28, 21, 25), STATE_ON), + make_nyc_test_params(dt(2018, 9, 29, 21, 25), STATE_OFF), + make_nyc_test_params(dt(2018, 9, 30, 21, 25), STATE_ON), + make_nyc_test_params(dt(2018, 10, 1, 21, 25), STATE_ON), + make_jerusalem_test_params(dt(2018, 9, 29, 21, 25), STATE_OFF), + make_jerusalem_test_params(dt(2018, 9, 30, 21, 25), STATE_ON), + make_jerusalem_test_params(dt(2018, 10, 1, 21, 25), STATE_OFF), +] + +MELACHA_TEST_IDS = [ + "currently_first_shabbat", + "after_first_shabbat", + "friday_upcoming_shabbat", + "upcoming_rosh_hashana", + "currently_rosh_hashana", + "second_day_rosh_hashana", + "currently_shabbat_chol_hamoed", + "upcoming_two_day_yomtov_in_diaspora", + "currently_first_day_of_two_day_yomtov_in_diaspora", + "currently_second_day_of_two_day_yomtov_in_diaspora", + "upcoming_one_day_yom_tov_in_israel", + "currently_one_day_yom_tov_in_israel", + "after_one_day_yom_tov_in_israel", +] + + +@pytest.mark.parametrize( + [ + "now", + "candle_lighting", + "havdalah", + "diaspora", + "tzname", + "latitude", + "longitude", + "result", + ], + MELACHA_PARAMS, + ids=MELACHA_TEST_IDS, +) +async def test_issur_melacha_sensor( + hass, now, candle_lighting, havdalah, diaspora, tzname, latitude, longitude, result +): + """Test Issur Melacha sensor output.""" + time_zone = dt_util.get_time_zone(tzname) + test_time = time_zone.localize(now) + + hass.config.time_zone = time_zone + hass.config.latitude = latitude + hass.config.longitude = longitude + + with alter_time(test_time): + assert await async_setup_component( + hass, + jewish_calendar.DOMAIN, + { + "jewish_calendar": { + "name": "test", + "language": "english", + "diaspora": diaspora, + "candle_lighting_minutes_before_sunset": candle_lighting, + "havdalah_minutes_after_sunset": havdalah, + } + }, + ) + await hass.async_block_till_done() + + future = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert ( + hass.states.get("binary_sensor.test_issur_melacha_in_effect").state + == result + ) diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index f8c214f9800007..8d72830b3698ab 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -1,733 +1,565 @@ -"""The tests for the Jewish calendar sensor platform.""" -from collections import namedtuple -from datetime import time +"""The tests for the Jewish calendar sensors.""" +from datetime import time, timedelta from datetime import datetime as dt -from unittest.mock import patch import pytest -from homeassistant.util.async_ import run_coroutine_threadsafe -from homeassistant.util.dt import get_time_zone, set_default_time_zone -from homeassistant.setup import setup_component -from homeassistant.components.jewish_calendar.sensor import ( - JewishCalSensor, - CANDLE_LIGHT_DEFAULT, -) -from tests.common import get_test_home_assistant +import homeassistant.util.dt as dt_util +from homeassistant.setup import async_setup_component +from homeassistant.components import jewish_calendar +from tests.common import async_fire_time_changed +from . import alter_time, make_nyc_test_params, make_jerusalem_test_params -_LatLng = namedtuple("_LatLng", ["lat", "lng"]) -NYC_LATLNG = _LatLng(40.7128, -74.0060) -JERUSALEM_LATLNG = _LatLng(31.778, 35.235) +async def test_jewish_calendar_min_config(hass): + """Test minimum jewish calendar configuration.""" + assert await async_setup_component( + hass, jewish_calendar.DOMAIN, {"jewish_calendar": {}} + ) + await hass.async_block_till_done() + assert hass.states.get("sensor.jewish_calendar_date") is not None -def make_nyc_test_params(dtime, results, havdalah_offset=0): - """Make test params for NYC.""" - return ( - dtime, - CANDLE_LIGHT_DEFAULT, - havdalah_offset, - True, - "America/New_York", - NYC_LATLNG.lat, - NYC_LATLNG.lng, - results, +async def test_jewish_calendar_hebrew(hass): + """Test jewish calendar sensor with language set to hebrew.""" + assert await async_setup_component( + hass, jewish_calendar.DOMAIN, {"jewish_calendar": {"language": "hebrew"}} ) + await hass.async_block_till_done() + assert hass.states.get("sensor.jewish_calendar_date") is not None -def make_jerusalem_test_params(dtime, results, havdalah_offset=0): - """Make test params for Jerusalem.""" - return ( - dtime, - CANDLE_LIGHT_DEFAULT, - havdalah_offset, +TEST_PARAMS = [ + (dt(2018, 9, 3), "UTC", 31.778, 35.235, "english", "date", False, "23 Elul 5778"), + ( + dt(2018, 9, 3), + "UTC", + 31.778, + 35.235, + "hebrew", + "date", + False, + 'כ"ג אלול ה\' תשע"ח', + ), + ( + dt(2018, 9, 10), + "UTC", + 31.778, + 35.235, + "hebrew", + "holiday_name", + False, + "א' ראש השנה", + ), + ( + dt(2018, 9, 10), + "UTC", + 31.778, + 35.235, + "english", + "holiday_name", + False, + "Rosh Hashana I", + ), + (dt(2018, 9, 10), "UTC", 31.778, 35.235, "english", "holiday_type", False, 1), + ( + dt(2018, 9, 8), + "UTC", + 31.778, + 35.235, + "hebrew", + "parshat_hashavua", + False, + "נצבים", + ), + ( + dt(2018, 9, 8), + "America/New_York", + 40.7128, + -74.0060, + "hebrew", + "t_set_hakochavim", + True, + time(19, 48), + ), + ( + dt(2018, 9, 8), + "Asia/Jerusalem", + 31.778, + 35.235, + "hebrew", + "t_set_hakochavim", False, + time(19, 21), + ), + ( + dt(2018, 10, 14), "Asia/Jerusalem", - JERUSALEM_LATLNG.lat, - JERUSALEM_LATLNG.lng, - results, - ) + 31.778, + 35.235, + "hebrew", + "parshat_hashavua", + False, + "לך לך", + ), + ( + dt(2018, 10, 14, 17, 0, 0), + "Asia/Jerusalem", + 31.778, + 35.235, + "hebrew", + "date", + False, + "ה' מרחשוון ה' תשע\"ט", + ), + ( + dt(2018, 10, 14, 19, 0, 0), + "Asia/Jerusalem", + 31.778, + 35.235, + "hebrew", + "date", + False, + "ו' מרחשוון ה' תשע\"ט", + ), +] +TEST_IDS = [ + "date_output", + "date_output_hebrew", + "holiday_name", + "holiday_name_english", + "holiday_type", + "torah_reading", + "first_stars_ny", + "first_stars_jerusalem", + "torah_reading_weekday", + "date_before_sunset", + "date_after_sunset", +] -class TestJewishCalenderSensor: - """Test the Jewish Calendar sensor.""" - - # pylint: disable=attribute-defined-outside-init - def setup_method(self, method): - """Set up things to run when tests begin.""" - self.hass = get_test_home_assistant() - - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - # Reset the default timezone, so we don't affect other tests - set_default_time_zone(get_time_zone("UTC")) - - def test_jewish_calendar_min_config(self): - """Test minimum jewish calendar configuration.""" - config = {"sensor": {"platform": "jewish_calendar"}} - assert setup_component(self.hass, "sensor", config) - - def test_jewish_calendar_hebrew(self): - """Test jewish calendar sensor with language set to hebrew.""" - config = {"sensor": {"platform": "jewish_calendar", "language": "hebrew"}} - - assert setup_component(self.hass, "sensor", config) - - def test_jewish_calendar_multiple_sensors(self): - """Test jewish calendar sensor with multiple sensors setup.""" - config = { - "sensor": { - "platform": "jewish_calendar", - "sensors": [ - "date", - "weekly_portion", - "holiday_name", - "holyness", - "first_light", - "gra_end_shma", - "mga_end_shma", - "plag_mincha", - "first_stars", - ], - } - } - - assert setup_component(self.hass, "sensor", config) - - test_params = [ - ( - dt(2018, 9, 3), - "UTC", - 31.778, - 35.235, - "english", - "date", - False, - "23 Elul 5778", - ), - ( - dt(2018, 9, 3), - "UTC", - 31.778, - 35.235, - "hebrew", - "date", - False, - 'כ"ג אלול ה\' תשע"ח', - ), - ( - dt(2018, 9, 10), - "UTC", - 31.778, - 35.235, - "hebrew", - "holiday_name", - False, - "א' ראש השנה", - ), - ( - dt(2018, 9, 10), - "UTC", - 31.778, - 35.235, - "english", - "holiday_name", - False, - "Rosh Hashana I", - ), - (dt(2018, 9, 10), "UTC", 31.778, 35.235, "english", "holyness", False, 1), - ( - dt(2018, 9, 8), - "UTC", - 31.778, - 35.235, - "hebrew", - "weekly_portion", - False, - "נצבים", - ), - ( - dt(2018, 9, 8), - "America/New_York", - 40.7128, - -74.0060, - "hebrew", - "first_stars", - True, - time(19, 48), - ), - ( - dt(2018, 9, 8), - "Asia/Jerusalem", - 31.778, - 35.235, - "hebrew", - "first_stars", - False, - time(19, 21), - ), - ( - dt(2018, 10, 14), - "Asia/Jerusalem", - 31.778, - 35.235, - "hebrew", - "weekly_portion", - False, - "לך לך", - ), - ( - dt(2018, 10, 14, 17, 0, 0), - "Asia/Jerusalem", - 31.778, - 35.235, - "hebrew", - "date", - False, - "ה' מרחשוון ה' תשע\"ט", - ), - ( - dt(2018, 10, 14, 19, 0, 0), - "Asia/Jerusalem", - 31.778, - 35.235, - "hebrew", - "date", - False, - "ו' מרחשוון ה' תשע\"ט", - ), - ] - - test_ids = [ - "date_output", - "date_output_hebrew", - "holiday_name", - "holiday_name_english", - "holyness", - "torah_reading", - "first_stars_ny", - "first_stars_jerusalem", - "torah_reading_weekday", - "date_before_sunset", - "date_after_sunset", - ] - - @pytest.mark.parametrize( - [ - "cur_time", - "tzname", - "latitude", - "longitude", - "language", - "sensor", - "diaspora", - "result", - ], - test_params, - ids=test_ids, - ) - def test_jewish_calendar_sensor( - self, cur_time, tzname, latitude, longitude, language, sensor, diaspora, result - ): - """Test Jewish calendar sensor output.""" - time_zone = get_time_zone(tzname) - set_default_time_zone(time_zone) - test_time = time_zone.localize(cur_time) - self.hass.config.latitude = latitude - self.hass.config.longitude = longitude - sensor = JewishCalSensor( - name="test", - language=language, - sensor_type=sensor, - latitude=latitude, - longitude=longitude, - timezone=time_zone, - diaspora=diaspora, - ) - sensor.hass = self.hass - with patch("homeassistant.util.dt.now", return_value=test_time): - run_coroutine_threadsafe(sensor.async_update(), self.hass.loop).result() - assert sensor.state == result - - shabbat_params = [ - make_nyc_test_params( - dt(2018, 9, 1, 16, 0), - { - "upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 15), - "upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 14), - "weekly_portion": "Ki Tavo", - "hebrew_weekly_portion": "כי תבוא", - }, - ), - make_nyc_test_params( - dt(2018, 9, 1, 16, 0), - { - "upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 15), - "upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 22), - "weekly_portion": "Ki Tavo", - "hebrew_weekly_portion": "כי תבוא", - }, - havdalah_offset=50, - ), - make_nyc_test_params( - dt(2018, 9, 1, 20, 0), - { - "upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 15), - "upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 14), - "upcoming_candle_lighting": dt(2018, 8, 31, 19, 15), - "upcoming_havdalah": dt(2018, 9, 1, 20, 14), - "weekly_portion": "Ki Tavo", - "hebrew_weekly_portion": "כי תבוא", - }, - ), - make_nyc_test_params( - dt(2018, 9, 1, 20, 21), - { - "upcoming_shabbat_candle_lighting": dt(2018, 9, 7, 19, 4), - "upcoming_shabbat_havdalah": dt(2018, 9, 8, 20, 2), - "weekly_portion": "Nitzavim", - "hebrew_weekly_portion": "נצבים", - }, - ), - make_nyc_test_params( - dt(2018, 9, 7, 13, 1), - { - "upcoming_shabbat_candle_lighting": dt(2018, 9, 7, 19, 4), - "upcoming_shabbat_havdalah": dt(2018, 9, 8, 20, 2), - "weekly_portion": "Nitzavim", - "hebrew_weekly_portion": "נצבים", - }, - ), - make_nyc_test_params( - dt(2018, 9, 8, 21, 25), - { - "upcoming_candle_lighting": dt(2018, 9, 9, 19, 1), - "upcoming_havdalah": dt(2018, 9, 11, 19, 57), - "upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 52), - "upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 50), - "weekly_portion": "Vayeilech", - "hebrew_weekly_portion": "וילך", - "holiday_name": "Erev Rosh Hashana", - "hebrew_holiday_name": "ערב ראש השנה", - }, - ), - make_nyc_test_params( - dt(2018, 9, 9, 21, 25), - { - "upcoming_candle_lighting": dt(2018, 9, 9, 19, 1), - "upcoming_havdalah": dt(2018, 9, 11, 19, 57), - "upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 52), - "upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 50), - "weekly_portion": "Vayeilech", - "hebrew_weekly_portion": "וילך", - "holiday_name": "Rosh Hashana I", - "hebrew_holiday_name": "א' ראש השנה", - }, - ), - make_nyc_test_params( - dt(2018, 9, 10, 21, 25), - { - "upcoming_candle_lighting": dt(2018, 9, 9, 19, 1), - "upcoming_havdalah": dt(2018, 9, 11, 19, 57), - "upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 52), - "upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 50), - "weekly_portion": "Vayeilech", - "hebrew_weekly_portion": "וילך", - "holiday_name": "Rosh Hashana II", - "hebrew_holiday_name": "ב' ראש השנה", - }, - ), - make_nyc_test_params( - dt(2018, 9, 28, 21, 25), - { - "upcoming_shabbat_candle_lighting": dt(2018, 9, 28, 18, 28), - "upcoming_shabbat_havdalah": dt(2018, 9, 29, 19, 25), - "weekly_portion": "none", - "hebrew_weekly_portion": "none", - }, - ), - make_nyc_test_params( - dt(2018, 9, 29, 21, 25), - { - "upcoming_candle_lighting": dt(2018, 9, 30, 18, 25), - "upcoming_havdalah": dt(2018, 10, 2, 19, 20), - "upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 17), - "upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 13), - "weekly_portion": "Bereshit", - "hebrew_weekly_portion": "בראשית", - "holiday_name": "Hoshana Raba", - "hebrew_holiday_name": "הושענא רבה", - }, - ), - make_nyc_test_params( - dt(2018, 9, 30, 21, 25), - { - "upcoming_candle_lighting": dt(2018, 9, 30, 18, 25), - "upcoming_havdalah": dt(2018, 10, 2, 19, 20), - "upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 17), - "upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 13), - "weekly_portion": "Bereshit", - "hebrew_weekly_portion": "בראשית", - "holiday_name": "Shmini Atzeret", - "hebrew_holiday_name": "שמיני עצרת", - }, - ), - make_nyc_test_params( - dt(2018, 10, 1, 21, 25), - { - "upcoming_candle_lighting": dt(2018, 9, 30, 18, 25), - "upcoming_havdalah": dt(2018, 10, 2, 19, 20), - "upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 17), - "upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 13), - "weekly_portion": "Bereshit", - "hebrew_weekly_portion": "בראשית", - "holiday_name": "Simchat Torah", - "hebrew_holiday_name": "שמחת תורה", - }, - ), - make_jerusalem_test_params( - dt(2018, 9, 29, 21, 25), - { - "upcoming_candle_lighting": dt(2018, 9, 30, 18, 10), - "upcoming_havdalah": dt(2018, 10, 1, 19, 2), - "upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 3), - "upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 56), - "weekly_portion": "Bereshit", - "hebrew_weekly_portion": "בראשית", - "holiday_name": "Hoshana Raba", - "hebrew_holiday_name": "הושענא רבה", - }, - ), - make_jerusalem_test_params( - dt(2018, 9, 30, 21, 25), - { - "upcoming_candle_lighting": dt(2018, 9, 30, 18, 10), - "upcoming_havdalah": dt(2018, 10, 1, 19, 2), - "upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 3), - "upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 56), - "weekly_portion": "Bereshit", - "hebrew_weekly_portion": "בראשית", - "holiday_name": "Shmini Atzeret", - "hebrew_holiday_name": "שמיני עצרת", - }, - ), - make_jerusalem_test_params( - dt(2018, 10, 1, 21, 25), - { - "upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 3), - "upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 56), - "weekly_portion": "Bereshit", - "hebrew_weekly_portion": "בראשית", - }, - ), - make_nyc_test_params( - dt(2016, 6, 11, 8, 25), - { - "upcoming_candle_lighting": dt(2016, 6, 10, 20, 7), - "upcoming_havdalah": dt(2016, 6, 13, 21, 17), - "upcoming_shabbat_candle_lighting": dt(2016, 6, 10, 20, 7), - "upcoming_shabbat_havdalah": None, - "weekly_portion": "Bamidbar", - "hebrew_weekly_portion": "במדבר", - "holiday_name": "Erev Shavuot", - "hebrew_holiday_name": "ערב שבועות", - }, - ), - make_nyc_test_params( - dt(2016, 6, 12, 8, 25), - { - "upcoming_candle_lighting": dt(2016, 6, 10, 20, 7), - "upcoming_havdalah": dt(2016, 6, 13, 21, 17), - "upcoming_shabbat_candle_lighting": dt(2016, 6, 17, 20, 10), - "upcoming_shabbat_havdalah": dt(2016, 6, 18, 21, 19), - "weekly_portion": "Nasso", - "hebrew_weekly_portion": "נשא", - "holiday_name": "Shavuot", - "hebrew_holiday_name": "שבועות", - }, - ), - make_jerusalem_test_params( - dt(2017, 9, 21, 8, 25), - { - "upcoming_candle_lighting": dt(2017, 9, 20, 18, 23), - "upcoming_havdalah": dt(2017, 9, 23, 19, 13), - "upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 14), - "upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 13), - "weekly_portion": "Ha'Azinu", - "hebrew_weekly_portion": "האזינו", - "holiday_name": "Rosh Hashana I", - "hebrew_holiday_name": "א' ראש השנה", - }, - ), - make_jerusalem_test_params( - dt(2017, 9, 22, 8, 25), + +@pytest.mark.parametrize( + [ + "now", + "tzname", + "latitude", + "longitude", + "language", + "sensor", + "diaspora", + "result", + ], + TEST_PARAMS, + ids=TEST_IDS, +) +async def test_jewish_calendar_sensor( + hass, now, tzname, latitude, longitude, language, sensor, diaspora, result +): + """Test Jewish calendar sensor output.""" + time_zone = dt_util.get_time_zone(tzname) + test_time = time_zone.localize(now) + + hass.config.time_zone = time_zone + hass.config.latitude = latitude + hass.config.longitude = longitude + + with alter_time(test_time): + assert await async_setup_component( + hass, + jewish_calendar.DOMAIN, { - "upcoming_candle_lighting": dt(2017, 9, 20, 18, 23), - "upcoming_havdalah": dt(2017, 9, 23, 19, 13), - "upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 14), - "upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 13), - "weekly_portion": "Ha'Azinu", - "hebrew_weekly_portion": "האזינו", - "holiday_name": "Rosh Hashana II", - "hebrew_holiday_name": "ב' ראש השנה", + "jewish_calendar": { + "name": "test", + "language": language, + "diaspora": diaspora, + } }, - ), - make_jerusalem_test_params( - dt(2017, 9, 23, 8, 25), + ) + await hass.async_block_till_done() + + future = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert hass.states.get(f"sensor.test_{sensor}").state == str(result) + + +SHABBAT_PARAMS = [ + make_nyc_test_params( + dt(2018, 9, 1, 16, 0), + { + "english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 15), + "english_upcoming_havdalah": dt(2018, 9, 1, 20, 14), + "english_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 15), + "english_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 14), + "english_parshat_hashavua": "Ki Tavo", + "hebrew_parshat_hashavua": "כי תבוא", + }, + ), + make_nyc_test_params( + dt(2018, 9, 1, 16, 0), + { + "english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 15), + "english_upcoming_havdalah": dt(2018, 9, 1, 20, 22), + "english_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 15), + "english_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 22), + "english_parshat_hashavua": "Ki Tavo", + "hebrew_parshat_hashavua": "כי תבוא", + }, + havdalah_offset=50, + ), + make_nyc_test_params( + dt(2018, 9, 1, 20, 0), + { + "english_upcoming_shabbat_candle_lighting": dt(2018, 8, 31, 19, 15), + "english_upcoming_shabbat_havdalah": dt(2018, 9, 1, 20, 14), + "english_upcoming_candle_lighting": dt(2018, 8, 31, 19, 15), + "english_upcoming_havdalah": dt(2018, 9, 1, 20, 14), + "english_parshat_hashavua": "Ki Tavo", + "hebrew_parshat_hashavua": "כי תבוא", + }, + ), + make_nyc_test_params( + dt(2018, 9, 1, 20, 21), + { + "english_upcoming_candle_lighting": dt(2018, 9, 7, 19, 4), + "english_upcoming_havdalah": dt(2018, 9, 8, 20, 2), + "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 7, 19, 4), + "english_upcoming_shabbat_havdalah": dt(2018, 9, 8, 20, 2), + "english_parshat_hashavua": "Nitzavim", + "hebrew_parshat_hashavua": "נצבים", + }, + ), + make_nyc_test_params( + dt(2018, 9, 7, 13, 1), + { + "english_upcoming_candle_lighting": dt(2018, 9, 7, 19, 4), + "english_upcoming_havdalah": dt(2018, 9, 8, 20, 2), + "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 7, 19, 4), + "english_upcoming_shabbat_havdalah": dt(2018, 9, 8, 20, 2), + "english_parshat_hashavua": "Nitzavim", + "hebrew_parshat_hashavua": "נצבים", + }, + ), + make_nyc_test_params( + dt(2018, 9, 8, 21, 25), + { + "english_upcoming_candle_lighting": dt(2018, 9, 9, 19, 1), + "english_upcoming_havdalah": dt(2018, 9, 11, 19, 57), + "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 52), + "english_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 50), + "english_parshat_hashavua": "Vayeilech", + "hebrew_parshat_hashavua": "וילך", + "english_holiday_name": "Erev Rosh Hashana", + "hebrew_holiday_name": "ערב ראש השנה", + }, + ), + make_nyc_test_params( + dt(2018, 9, 9, 21, 25), + { + "english_upcoming_candle_lighting": dt(2018, 9, 9, 19, 1), + "english_upcoming_havdalah": dt(2018, 9, 11, 19, 57), + "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 52), + "english_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 50), + "english_parshat_hashavua": "Vayeilech", + "hebrew_parshat_hashavua": "וילך", + "english_holiday_name": "Rosh Hashana I", + "hebrew_holiday_name": "א' ראש השנה", + }, + ), + make_nyc_test_params( + dt(2018, 9, 10, 21, 25), + { + "english_upcoming_candle_lighting": dt(2018, 9, 9, 19, 1), + "english_upcoming_havdalah": dt(2018, 9, 11, 19, 57), + "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 14, 18, 52), + "english_upcoming_shabbat_havdalah": dt(2018, 9, 15, 19, 50), + "english_parshat_hashavua": "Vayeilech", + "hebrew_parshat_hashavua": "וילך", + "english_holiday_name": "Rosh Hashana II", + "hebrew_holiday_name": "ב' ראש השנה", + }, + ), + make_nyc_test_params( + dt(2018, 9, 28, 21, 25), + { + "english_upcoming_candle_lighting": dt(2018, 9, 28, 18, 28), + "english_upcoming_havdalah": dt(2018, 9, 29, 19, 25), + "english_upcoming_shabbat_candle_lighting": dt(2018, 9, 28, 18, 28), + "english_upcoming_shabbat_havdalah": dt(2018, 9, 29, 19, 25), + "english_parshat_hashavua": "none", + "hebrew_parshat_hashavua": "none", + }, + ), + make_nyc_test_params( + dt(2018, 9, 29, 21, 25), + { + "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 25), + "english_upcoming_havdalah": dt(2018, 10, 2, 19, 20), + "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 17), + "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 13), + "english_parshat_hashavua": "Bereshit", + "hebrew_parshat_hashavua": "בראשית", + "english_holiday_name": "Hoshana Raba", + "hebrew_holiday_name": "הושענא רבה", + }, + ), + make_nyc_test_params( + dt(2018, 9, 30, 21, 25), + { + "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 25), + "english_upcoming_havdalah": dt(2018, 10, 2, 19, 20), + "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 17), + "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 13), + "english_parshat_hashavua": "Bereshit", + "hebrew_parshat_hashavua": "בראשית", + "english_holiday_name": "Shmini Atzeret", + "hebrew_holiday_name": "שמיני עצרת", + }, + ), + make_nyc_test_params( + dt(2018, 10, 1, 21, 25), + { + "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 25), + "english_upcoming_havdalah": dt(2018, 10, 2, 19, 20), + "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 17), + "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 19, 13), + "english_parshat_hashavua": "Bereshit", + "hebrew_parshat_hashavua": "בראשית", + "english_holiday_name": "Simchat Torah", + "hebrew_holiday_name": "שמחת תורה", + }, + ), + make_jerusalem_test_params( + dt(2018, 9, 29, 21, 25), + { + "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 10), + "english_upcoming_havdalah": dt(2018, 10, 1, 19, 2), + "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 3), + "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 56), + "english_parshat_hashavua": "Bereshit", + "hebrew_parshat_hashavua": "בראשית", + "english_holiday_name": "Hoshana Raba", + "hebrew_holiday_name": "הושענא רבה", + }, + ), + make_jerusalem_test_params( + dt(2018, 9, 30, 21, 25), + { + "english_upcoming_candle_lighting": dt(2018, 9, 30, 18, 10), + "english_upcoming_havdalah": dt(2018, 10, 1, 19, 2), + "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 3), + "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 56), + "english_parshat_hashavua": "Bereshit", + "hebrew_parshat_hashavua": "בראשית", + "english_holiday_name": "Shmini Atzeret", + "hebrew_holiday_name": "שמיני עצרת", + }, + ), + make_jerusalem_test_params( + dt(2018, 10, 1, 21, 25), + { + "english_upcoming_candle_lighting": dt(2018, 10, 5, 18, 3), + "english_upcoming_havdalah": dt(2018, 10, 6, 18, 56), + "english_upcoming_shabbat_candle_lighting": dt(2018, 10, 5, 18, 3), + "english_upcoming_shabbat_havdalah": dt(2018, 10, 6, 18, 56), + "english_parshat_hashavua": "Bereshit", + "hebrew_parshat_hashavua": "בראשית", + }, + ), + make_nyc_test_params( + dt(2016, 6, 11, 8, 25), + { + "english_upcoming_candle_lighting": dt(2016, 6, 10, 20, 7), + "english_upcoming_havdalah": dt(2016, 6, 13, 21, 17), + "english_upcoming_shabbat_candle_lighting": dt(2016, 6, 10, 20, 7), + "english_upcoming_shabbat_havdalah": "unknown", + "english_parshat_hashavua": "Bamidbar", + "hebrew_parshat_hashavua": "במדבר", + "english_holiday_name": "Erev Shavuot", + "hebrew_holiday_name": "ערב שבועות", + }, + ), + make_nyc_test_params( + dt(2016, 6, 12, 8, 25), + { + "english_upcoming_candle_lighting": dt(2016, 6, 10, 20, 7), + "english_upcoming_havdalah": dt(2016, 6, 13, 21, 17), + "english_upcoming_shabbat_candle_lighting": dt(2016, 6, 17, 20, 10), + "english_upcoming_shabbat_havdalah": dt(2016, 6, 18, 21, 19), + "english_parshat_hashavua": "Nasso", + "hebrew_parshat_hashavua": "נשא", + "english_holiday_name": "Shavuot", + "hebrew_holiday_name": "שבועות", + }, + ), + make_jerusalem_test_params( + dt(2017, 9, 21, 8, 25), + { + "english_upcoming_candle_lighting": dt(2017, 9, 20, 18, 23), + "english_upcoming_havdalah": dt(2017, 9, 23, 19, 13), + "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 14), + "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 13), + "english_parshat_hashavua": "Ha'Azinu", + "hebrew_parshat_hashavua": "האזינו", + "english_holiday_name": "Rosh Hashana I", + "hebrew_holiday_name": "א' ראש השנה", + }, + ), + make_jerusalem_test_params( + dt(2017, 9, 22, 8, 25), + { + "english_upcoming_candle_lighting": dt(2017, 9, 20, 18, 23), + "english_upcoming_havdalah": dt(2017, 9, 23, 19, 13), + "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 14), + "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 13), + "english_parshat_hashavua": "Ha'Azinu", + "hebrew_parshat_hashavua": "האזינו", + "english_holiday_name": "Rosh Hashana II", + "hebrew_holiday_name": "ב' ראש השנה", + }, + ), + make_jerusalem_test_params( + dt(2017, 9, 23, 8, 25), + { + "english_upcoming_candle_lighting": dt(2017, 9, 20, 18, 23), + "english_upcoming_havdalah": dt(2017, 9, 23, 19, 13), + "english_upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 14), + "english_upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 13), + "english_parshat_hashavua": "Ha'Azinu", + "hebrew_parshat_hashavua": "האזינו", + "english_holiday_name": "", + "hebrew_holiday_name": "", + }, + ), +] + +SHABBAT_TEST_IDS = [ + "currently_first_shabbat", + "currently_first_shabbat_with_havdalah_offset", + "currently_first_shabbat_bein_hashmashot_lagging_date", + "after_first_shabbat", + "friday_upcoming_shabbat", + "upcoming_rosh_hashana", + "currently_rosh_hashana", + "second_day_rosh_hashana", + "currently_shabbat_chol_hamoed", + "upcoming_two_day_yomtov_in_diaspora", + "currently_first_day_of_two_day_yomtov_in_diaspora", + "currently_second_day_of_two_day_yomtov_in_diaspora", + "upcoming_one_day_yom_tov_in_israel", + "currently_one_day_yom_tov_in_israel", + "after_one_day_yom_tov_in_israel", + # Type 1 = Sat/Sun/Mon + "currently_first_day_of_three_day_type1_yomtov_in_diaspora", + "currently_second_day_of_three_day_type1_yomtov_in_diaspora", + # Type 2 = Thurs/Fri/Sat + "currently_first_day_of_three_day_type2_yomtov_in_israel", + "currently_second_day_of_three_day_type2_yomtov_in_israel", + "currently_third_day_of_three_day_type2_yomtov_in_israel", +] + + +@pytest.mark.parametrize("language", ["english", "hebrew"]) +@pytest.mark.parametrize( + [ + "now", + "candle_lighting", + "havdalah", + "diaspora", + "tzname", + "latitude", + "longitude", + "result", + ], + SHABBAT_PARAMS, + ids=SHABBAT_TEST_IDS, +) +async def test_shabbat_times_sensor( + hass, + language, + now, + candle_lighting, + havdalah, + diaspora, + tzname, + latitude, + longitude, + result, +): + """Test sensor output for upcoming shabbat/yomtov times.""" + time_zone = dt_util.get_time_zone(tzname) + test_time = time_zone.localize(now) + + hass.config.time_zone = time_zone + hass.config.latitude = latitude + hass.config.longitude = longitude + + with alter_time(test_time): + assert await async_setup_component( + hass, + jewish_calendar.DOMAIN, { - "upcoming_candle_lighting": dt(2017, 9, 20, 18, 23), - "upcoming_havdalah": dt(2017, 9, 23, 19, 13), - "upcoming_shabbat_candle_lighting": dt(2017, 9, 22, 19, 14), - "upcoming_shabbat_havdalah": dt(2017, 9, 23, 19, 13), - "weekly_portion": "Ha'Azinu", - "hebrew_weekly_portion": "האזינו", - "holiday_name": "", - "hebrew_holiday_name": "", + "jewish_calendar": { + "name": "test", + "language": language, + "diaspora": diaspora, + "candle_lighting_minutes_before_sunset": candle_lighting, + "havdalah_minutes_after_sunset": havdalah, + } }, - ), - ] - - shabbat_test_ids = [ - "currently_first_shabbat", - "currently_first_shabbat_with_havdalah_offset", - "currently_first_shabbat_bein_hashmashot_lagging_date", - "after_first_shabbat", - "friday_upcoming_shabbat", - "upcoming_rosh_hashana", - "currently_rosh_hashana", - "second_day_rosh_hashana", - "currently_shabbat_chol_hamoed", - "upcoming_two_day_yomtov_in_diaspora", - "currently_first_day_of_two_day_yomtov_in_diaspora", - "currently_second_day_of_two_day_yomtov_in_diaspora", - "upcoming_one_day_yom_tov_in_israel", - "currently_one_day_yom_tov_in_israel", - "after_one_day_yom_tov_in_israel", - # Type 1 = Sat/Sun/Mon - "currently_first_day_of_three_day_type1_yomtov_in_diaspora", - "currently_second_day_of_three_day_type1_yomtov_in_diaspora", - # Type 2 = Thurs/Fri/Sat - "currently_first_day_of_three_day_type2_yomtov_in_israel", - "currently_second_day_of_three_day_type2_yomtov_in_israel", - "currently_third_day_of_three_day_type2_yomtov_in_israel", - ] - - @pytest.mark.parametrize( - [ - "now", - "candle_lighting", - "havdalah", - "diaspora", - "tzname", - "latitude", - "longitude", - "result", - ], - shabbat_params, - ids=shabbat_test_ids, - ) - def test_shabbat_times_sensor( - self, - now, - candle_lighting, - havdalah, - diaspora, - tzname, - latitude, - longitude, - result, - ): - """Test sensor output for upcoming shabbat/yomtov times.""" - time_zone = get_time_zone(tzname) - set_default_time_zone(time_zone) - test_time = time_zone.localize(now) - for sensor_type, value in result.items(): - if isinstance(value, dt): - result[sensor_type] = time_zone.localize(value) - self.hass.config.latitude = latitude - self.hass.config.longitude = longitude - - if ( - "upcoming_shabbat_candle_lighting" in result - and "upcoming_candle_lighting" not in result - ): - result["upcoming_candle_lighting"] = result[ - "upcoming_shabbat_candle_lighting" - ] - if "upcoming_shabbat_havdalah" in result and "upcoming_havdalah" not in result: - result["upcoming_havdalah"] = result["upcoming_shabbat_havdalah"] - - for sensor_type, result_value in result.items(): - language = "english" - if sensor_type.startswith("hebrew_"): - language = "hebrew" - sensor_type = sensor_type.replace("hebrew_", "") - sensor = JewishCalSensor( - name="test", - language=language, - sensor_type=sensor_type, - latitude=latitude, - longitude=longitude, - timezone=time_zone, - diaspora=diaspora, - havdalah_offset=havdalah, - candle_lighting_offset=candle_lighting, - ) - sensor.hass = self.hass - with patch("homeassistant.util.dt.now", return_value=test_time): - run_coroutine_threadsafe(sensor.async_update(), self.hass.loop).result() - assert sensor.state == result_value, "Value for {}".format(sensor_type) - - melacha_params = [ - make_nyc_test_params(dt(2018, 9, 1, 16, 0), True), - make_nyc_test_params(dt(2018, 9, 1, 20, 21), False), - make_nyc_test_params(dt(2018, 9, 7, 13, 1), False), - make_nyc_test_params(dt(2018, 9, 8, 21, 25), False), - make_nyc_test_params(dt(2018, 9, 9, 21, 25), True), - make_nyc_test_params(dt(2018, 9, 10, 21, 25), True), - make_nyc_test_params(dt(2018, 9, 28, 21, 25), True), - make_nyc_test_params(dt(2018, 9, 29, 21, 25), False), - make_nyc_test_params(dt(2018, 9, 30, 21, 25), True), - make_nyc_test_params(dt(2018, 10, 1, 21, 25), True), - make_jerusalem_test_params(dt(2018, 9, 29, 21, 25), False), - make_jerusalem_test_params(dt(2018, 9, 30, 21, 25), True), - make_jerusalem_test_params(dt(2018, 10, 1, 21, 25), False), - ] - melacha_test_ids = [ - "currently_first_shabbat", - "after_first_shabbat", - "friday_upcoming_shabbat", - "upcoming_rosh_hashana", - "currently_rosh_hashana", - "second_day_rosh_hashana", - "currently_shabbat_chol_hamoed", - "upcoming_two_day_yomtov_in_diaspora", - "currently_first_day_of_two_day_yomtov_in_diaspora", - "currently_second_day_of_two_day_yomtov_in_diaspora", - "upcoming_one_day_yom_tov_in_israel", - "currently_one_day_yom_tov_in_israel", - "after_one_day_yom_tov_in_israel", - ] - - @pytest.mark.parametrize( - [ - "now", - "candle_lighting", - "havdalah", - "diaspora", - "tzname", - "latitude", - "longitude", - "result", - ], - melacha_params, - ids=melacha_test_ids, - ) - def test_issur_melacha_sensor( - self, - now, - candle_lighting, - havdalah, - diaspora, - tzname, - latitude, - longitude, - result, - ): - """Test Issur Melacha sensor output.""" - time_zone = get_time_zone(tzname) - set_default_time_zone(time_zone) - test_time = time_zone.localize(now) - self.hass.config.latitude = latitude - self.hass.config.longitude = longitude - sensor = JewishCalSensor( - name="test", - language="english", - sensor_type="issur_melacha_in_effect", - latitude=latitude, - longitude=longitude, - timezone=time_zone, - diaspora=diaspora, - havdalah_offset=havdalah, - candle_lighting_offset=candle_lighting, ) - sensor.hass = self.hass - with patch("homeassistant.util.dt.now", return_value=test_time): - run_coroutine_threadsafe(sensor.async_update(), self.hass.loop).result() - assert sensor.state == result - - omer_params = [ - make_nyc_test_params(dt(2019, 4, 21, 0, 0), 1), - make_jerusalem_test_params(dt(2019, 4, 21, 0, 0), 1), - make_nyc_test_params(dt(2019, 4, 21, 23, 0), 2), - make_jerusalem_test_params(dt(2019, 4, 21, 23, 0), 2), - make_nyc_test_params(dt(2019, 5, 23, 0, 0), 33), - make_jerusalem_test_params(dt(2019, 5, 23, 0, 0), 33), - make_nyc_test_params(dt(2019, 6, 8, 0, 0), 49), - make_jerusalem_test_params(dt(2019, 6, 8, 0, 0), 49), - make_nyc_test_params(dt(2019, 6, 9, 0, 0), 0), - make_jerusalem_test_params(dt(2019, 6, 9, 0, 0), 0), - make_nyc_test_params(dt(2019, 1, 1, 0, 0), 0), - make_jerusalem_test_params(dt(2019, 1, 1, 0, 0), 0), - ] - omer_test_ids = [ - "nyc_first_day_of_omer", - "israel_first_day_of_omer", - "nyc_first_day_of_omer_after_tzeit", - "israel_first_day_of_omer_after_tzeit", - "nyc_lag_baomer", - "israel_lag_baomer", - "nyc_last_day_of_omer", - "israel_last_day_of_omer", - "nyc_shavuot_no_omer", - "israel_shavuot_no_omer", - "nyc_jan_1st_no_omer", - "israel_jan_1st_no_omer", - ] - - @pytest.mark.parametrize( - [ - "now", - "candle_lighting", - "havdalah", - "diaspora", - "tzname", - "latitude", - "longitude", - "result", - ], - omer_params, - ids=omer_test_ids, - ) - def test_omer_sensor( - self, - now, - candle_lighting, - havdalah, - diaspora, - tzname, - latitude, - longitude, - result, - ): - """Test Omer Count sensor output.""" - time_zone = get_time_zone(tzname) - set_default_time_zone(time_zone) - test_time = time_zone.localize(now) - self.hass.config.latitude = latitude - self.hass.config.longitude = longitude - sensor = JewishCalSensor( - name="test", - language="english", - sensor_type="omer_count", - latitude=latitude, - longitude=longitude, - timezone=time_zone, - diaspora=diaspora, + await hass.async_block_till_done() + + future = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + for sensor_type, result_value in result.items(): + if not sensor_type.startswith(language): + print(f"Not checking {sensor_type} for {language}") + continue + + sensor_type = sensor_type.replace(f"{language}_", "") + + assert hass.states.get(f"sensor.test_{sensor_type}").state == str( + result_value + ), f"Value for {sensor_type}" + + +OMER_PARAMS = [ + (dt(2019, 4, 21, 0), "1"), + (dt(2019, 4, 21, 23), "2"), + (dt(2019, 5, 23, 0), "33"), + (dt(2019, 6, 8, 0), "49"), + (dt(2019, 6, 9, 0), "0"), + (dt(2019, 1, 1, 0), "0"), +] +OMER_TEST_IDS = [ + "first_day_of_omer", + "first_day_of_omer_after_tzeit", + "lag_baomer", + "last_day_of_omer", + "shavuot_no_omer", + "jan_1st_no_omer", +] + + +@pytest.mark.parametrize(["test_time", "result"], OMER_PARAMS, ids=OMER_TEST_IDS) +async def test_omer_sensor(hass, test_time, result): + """Test Omer Count sensor output.""" + test_time = hass.config.time_zone.localize(test_time) + + with alter_time(test_time): + assert await async_setup_component( + hass, jewish_calendar.DOMAIN, {"jewish_calendar": {"name": "test"}} ) - sensor.hass = self.hass - with patch("homeassistant.util.dt.now", return_value=test_time): - run_coroutine_threadsafe(sensor.async_update(), self.hass.loop).result() - assert sensor.state == result + await hass.async_block_till_done() + + future = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert hass.states.get("sensor.test_day_of_the_omer").state == result diff --git a/tests/components/light/test_device_automation.py b/tests/components/light/test_device_automation.py index 3e92c15ee06d2d..27b8b860d7227c 100644 --- a/tests/components/light/test_device_automation.py +++ b/tests/components/light/test_device_automation.py @@ -1,16 +1,15 @@ """The test for light device automation.""" import pytest -from homeassistant.components import light +from homeassistant.components.light import DOMAIN from homeassistant.const import STATE_ON, STATE_OFF, CONF_PLATFORM from homeassistant.setup import async_setup_component import homeassistant.components.automation as automation from homeassistant.components.device_automation import ( - async_get_device_automation_triggers, + _async_get_device_automations as async_get_device_automations, ) from homeassistant.helpers import device_registry - from tests.common import ( MockConfigEntry, async_mock_service, @@ -37,7 +36,7 @@ def calls(hass): return async_mock_service(hass, "test", "automation") -def _same_triggers(a, b): +def _same_lists(a, b): if len(a) != len(b): return False @@ -47,6 +46,72 @@ def _same_triggers(a, b): return True +async def test_get_actions(hass, device_reg, entity_reg): + """Test we get the expected actions from a light.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) + expected_actions = [ + { + "domain": DOMAIN, + "type": "turn_off", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "domain": DOMAIN, + "type": "turn_on", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "domain": DOMAIN, + "type": "toggle", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + ] + actions = await async_get_device_automations( + hass, "async_get_actions", device_entry.id + ) + assert _same_lists(actions, expected_actions) + + +async def test_get_conditions(hass, device_reg, entity_reg): + """Test we get the expected conditions from a light.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) + expected_conditions = [ + { + "condition": "device", + "domain": DOMAIN, + "type": "is_off", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_on", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + ] + conditions = await async_get_device_automations( + hass, "async_get_conditions", device_entry.id + ) + assert _same_lists(conditions, expected_conditions) + + async def test_get_triggers(hass, device_reg, entity_reg): """Test we get the expected triggers from a light.""" config_entry = MockConfigEntry(domain="test", data={}) @@ -55,37 +120,37 @@ async def test_get_triggers(hass, device_reg, entity_reg): config_entry_id=config_entry.entry_id, connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, ) - entity_reg.async_get_or_create("light", "test", "5678", device_id=device_entry.id) + entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) expected_triggers = [ { "platform": "device", - "domain": "light", - "type": "turn_off", + "domain": DOMAIN, + "type": "turned_off", "device_id": device_entry.id, - "entity_id": "light.test_5678", + "entity_id": f"{DOMAIN}.test_5678", }, { "platform": "device", - "domain": "light", - "type": "turn_on", + "domain": DOMAIN, + "type": "turned_on", "device_id": device_entry.id, - "entity_id": "light.test_5678", + "entity_id": f"{DOMAIN}.test_5678", }, ] - triggers = await async_get_device_automation_triggers(hass, device_entry.id) - assert _same_triggers(triggers, expected_triggers) + triggers = await async_get_device_automations( + hass, "async_get_triggers", device_entry.id + ) + assert _same_lists(triggers, expected_triggers) async def test_if_fires_on_state_change(hass, calls): """Test for turn_on and turn_off triggers firing.""" - platform = getattr(hass.components, "test.light") + platform = getattr(hass.components, f"test.{DOMAIN}") platform.init() - assert await async_setup_component( - hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} - ) + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) - dev1, dev2, dev3 = platform.DEVICES + ent1, ent2, ent3 = platform.ENTITIES assert await async_setup_component( hass, @@ -95,9 +160,10 @@ async def test_if_fires_on_state_change(hass, calls): { "trigger": { "platform": "device", - "domain": light.DOMAIN, - "entity_id": dev1.entity_id, - "type": "turn_on", + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "turned_on", }, "action": { "service": "test.automation", @@ -118,9 +184,10 @@ async def test_if_fires_on_state_change(hass, calls): { "trigger": { "platform": "device", - "domain": light.DOMAIN, - "entity_id": dev1.entity_id, - "type": "turn_off", + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "turned_off", }, "action": { "service": "test.automation", @@ -142,19 +209,165 @@ async def test_if_fires_on_state_change(hass, calls): }, ) await hass.async_block_till_done() - assert hass.states.get(dev1.entity_id).state == STATE_ON + assert hass.states.get(ent1.entity_id).state == STATE_ON assert len(calls) == 0 - hass.states.async_set(dev1.entity_id, STATE_OFF) + hass.states.async_set(ent1.entity_id, STATE_OFF) await hass.async_block_till_done() assert len(calls) == 1 assert calls[0].data["some"] == "turn_off state - {} - on - off - None".format( - dev1.entity_id + ent1.entity_id ) - hass.states.async_set(dev1.entity_id, STATE_ON) + hass.states.async_set(ent1.entity_id, STATE_ON) await hass.async_block_till_done() assert len(calls) == 2 assert calls[1].data["some"] == "turn_on state - {} - off - on - None".format( - dev1.entity_id + ent1.entity_id ) + + +async def test_if_state(hass, calls): + """Test for turn_on and turn_off conditions.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + ent1, ent2, ent3 = platform.ENTITIES + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "is_on", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_on {{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event2"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "is_off", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_off {{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_ON + assert len(calls) == 0 + + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_on event - test_event1" + + hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "is_off event - test_event2" + + +async def test_action(hass, calls): + """Test for turn_on and turn_off actions.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + ent1, ent2, ent3 = platform.ENTITIES + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "action": { + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "turn_off", + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event2"}, + "action": { + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "turn_on", + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event3"}, + "action": { + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "toggle", + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_ON + assert len(calls) == 0 + + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_OFF + + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_OFF + + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_ON + + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_ON + + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_OFF + + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_ON diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index dc4cb7502c5765..8ceda6cbd3efa7 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -137,39 +137,39 @@ def test_services(self): self.hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1, dev2, dev3 = platform.DEVICES + ent1, ent2, ent3 = platform.ENTITIES # Test init - assert light.is_on(self.hass, dev1.entity_id) - assert not light.is_on(self.hass, dev2.entity_id) - assert not light.is_on(self.hass, dev3.entity_id) + assert light.is_on(self.hass, ent1.entity_id) + assert not light.is_on(self.hass, ent2.entity_id) + assert not light.is_on(self.hass, ent3.entity_id) # Test basic turn_on, turn_off, toggle services - common.turn_off(self.hass, entity_id=dev1.entity_id) - common.turn_on(self.hass, entity_id=dev2.entity_id) + common.turn_off(self.hass, entity_id=ent1.entity_id) + common.turn_on(self.hass, entity_id=ent2.entity_id) self.hass.block_till_done() - assert not light.is_on(self.hass, dev1.entity_id) - assert light.is_on(self.hass, dev2.entity_id) + assert not light.is_on(self.hass, ent1.entity_id) + assert light.is_on(self.hass, ent2.entity_id) # turn on all lights common.turn_on(self.hass) self.hass.block_till_done() - assert light.is_on(self.hass, dev1.entity_id) - assert light.is_on(self.hass, dev2.entity_id) - assert light.is_on(self.hass, dev3.entity_id) + assert light.is_on(self.hass, ent1.entity_id) + assert light.is_on(self.hass, ent2.entity_id) + assert light.is_on(self.hass, ent3.entity_id) # turn off all lights common.turn_off(self.hass) self.hass.block_till_done() - assert not light.is_on(self.hass, dev1.entity_id) - assert not light.is_on(self.hass, dev2.entity_id) - assert not light.is_on(self.hass, dev3.entity_id) + assert not light.is_on(self.hass, ent1.entity_id) + assert not light.is_on(self.hass, ent2.entity_id) + assert not light.is_on(self.hass, ent3.entity_id) # turn off all lights by setting brightness to 0 common.turn_on(self.hass) @@ -180,97 +180,97 @@ def test_services(self): self.hass.block_till_done() - assert not light.is_on(self.hass, dev1.entity_id) - assert not light.is_on(self.hass, dev2.entity_id) - assert not light.is_on(self.hass, dev3.entity_id) + assert not light.is_on(self.hass, ent1.entity_id) + assert not light.is_on(self.hass, ent2.entity_id) + assert not light.is_on(self.hass, ent3.entity_id) # toggle all lights common.toggle(self.hass) self.hass.block_till_done() - assert light.is_on(self.hass, dev1.entity_id) - assert light.is_on(self.hass, dev2.entity_id) - assert light.is_on(self.hass, dev3.entity_id) + assert light.is_on(self.hass, ent1.entity_id) + assert light.is_on(self.hass, ent2.entity_id) + assert light.is_on(self.hass, ent3.entity_id) # toggle all lights common.toggle(self.hass) self.hass.block_till_done() - assert not light.is_on(self.hass, dev1.entity_id) - assert not light.is_on(self.hass, dev2.entity_id) - assert not light.is_on(self.hass, dev3.entity_id) + assert not light.is_on(self.hass, ent1.entity_id) + assert not light.is_on(self.hass, ent2.entity_id) + assert not light.is_on(self.hass, ent3.entity_id) # Ensure all attributes process correctly common.turn_on( - self.hass, dev1.entity_id, transition=10, brightness=20, color_name="blue" + self.hass, ent1.entity_id, transition=10, brightness=20, color_name="blue" ) common.turn_on( - self.hass, dev2.entity_id, rgb_color=(255, 255, 255), white_value=255 + self.hass, ent2.entity_id, rgb_color=(255, 255, 255), white_value=255 ) - common.turn_on(self.hass, dev3.entity_id, xy_color=(0.4, 0.6)) + common.turn_on(self.hass, ent3.entity_id, xy_color=(0.4, 0.6)) self.hass.block_till_done() - _, data = dev1.last_call("turn_on") + _, data = ent1.last_call("turn_on") assert { light.ATTR_TRANSITION: 10, light.ATTR_BRIGHTNESS: 20, light.ATTR_HS_COLOR: (240, 100), } == data - _, data = dev2.last_call("turn_on") + _, data = ent2.last_call("turn_on") assert {light.ATTR_HS_COLOR: (0, 0), light.ATTR_WHITE_VALUE: 255} == data - _, data = dev3.last_call("turn_on") + _, data = ent3.last_call("turn_on") assert {light.ATTR_HS_COLOR: (71.059, 100)} == data # Ensure attributes are filtered when light is turned off common.turn_on( - self.hass, dev1.entity_id, transition=10, brightness=0, color_name="blue" + self.hass, ent1.entity_id, transition=10, brightness=0, color_name="blue" ) common.turn_on( self.hass, - dev2.entity_id, + ent2.entity_id, brightness=0, rgb_color=(255, 255, 255), white_value=0, ) - common.turn_on(self.hass, dev3.entity_id, brightness=0, xy_color=(0.4, 0.6)) + common.turn_on(self.hass, ent3.entity_id, brightness=0, xy_color=(0.4, 0.6)) self.hass.block_till_done() - assert not light.is_on(self.hass, dev1.entity_id) - assert not light.is_on(self.hass, dev2.entity_id) - assert not light.is_on(self.hass, dev3.entity_id) + assert not light.is_on(self.hass, ent1.entity_id) + assert not light.is_on(self.hass, ent2.entity_id) + assert not light.is_on(self.hass, ent3.entity_id) - _, data = dev1.last_call("turn_off") + _, data = ent1.last_call("turn_off") assert {light.ATTR_TRANSITION: 10} == data - _, data = dev2.last_call("turn_off") + _, data = ent2.last_call("turn_off") assert {} == data - _, data = dev3.last_call("turn_off") + _, data = ent3.last_call("turn_off") assert {} == data # One of the light profiles prof_name, prof_h, prof_s, prof_bri = "relax", 35.932, 69.412, 144 # Test light profiles - common.turn_on(self.hass, dev1.entity_id, profile=prof_name) + common.turn_on(self.hass, ent1.entity_id, profile=prof_name) # Specify a profile and a brightness attribute to overwrite it - common.turn_on(self.hass, dev2.entity_id, profile=prof_name, brightness=100) + common.turn_on(self.hass, ent2.entity_id, profile=prof_name, brightness=100) self.hass.block_till_done() - _, data = dev1.last_call("turn_on") + _, data = ent1.last_call("turn_on") assert { light.ATTR_BRIGHTNESS: prof_bri, light.ATTR_HS_COLOR: (prof_h, prof_s), } == data - _, data = dev2.last_call("turn_on") + _, data = ent2.last_call("turn_on") assert { light.ATTR_BRIGHTNESS: 100, light.ATTR_HS_COLOR: (prof_h, prof_s), @@ -278,34 +278,34 @@ def test_services(self): # Test bad data common.turn_on(self.hass) - common.turn_on(self.hass, dev1.entity_id, profile="nonexisting") - common.turn_on(self.hass, dev2.entity_id, xy_color=["bla-di-bla", 5]) - common.turn_on(self.hass, dev3.entity_id, rgb_color=[255, None, 2]) + common.turn_on(self.hass, ent1.entity_id, profile="nonexisting") + common.turn_on(self.hass, ent2.entity_id, xy_color=["bla-di-bla", 5]) + common.turn_on(self.hass, ent3.entity_id, rgb_color=[255, None, 2]) self.hass.block_till_done() - _, data = dev1.last_call("turn_on") + _, data = ent1.last_call("turn_on") assert {} == data - _, data = dev2.last_call("turn_on") + _, data = ent2.last_call("turn_on") assert {} == data - _, data = dev3.last_call("turn_on") + _, data = ent3.last_call("turn_on") assert {} == data # faulty attributes will not trigger a service call common.turn_on( - self.hass, dev1.entity_id, profile=prof_name, brightness="bright" + self.hass, ent1.entity_id, profile=prof_name, brightness="bright" ) - common.turn_on(self.hass, dev1.entity_id, rgb_color="yellowish") - common.turn_on(self.hass, dev2.entity_id, white_value="high") + common.turn_on(self.hass, ent1.entity_id, rgb_color="yellowish") + common.turn_on(self.hass, ent2.entity_id, white_value="high") self.hass.block_till_done() - _, data = dev1.last_call("turn_on") + _, data = ent1.last_call("turn_on") assert {} == data - _, data = dev2.last_call("turn_on") + _, data = ent2.last_call("turn_on") assert {} == data def test_broken_light_profiles(self): @@ -340,24 +340,24 @@ def test_light_profiles(self): self.hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev1, _, _ = platform.DEVICES + ent1, _, _ = platform.ENTITIES - common.turn_on(self.hass, dev1.entity_id, profile="test") + common.turn_on(self.hass, ent1.entity_id, profile="test") self.hass.block_till_done() - _, data = dev1.last_call("turn_on") + _, data = ent1.last_call("turn_on") - assert light.is_on(self.hass, dev1.entity_id) + assert light.is_on(self.hass, ent1.entity_id) assert {light.ATTR_HS_COLOR: (71.059, 100), light.ATTR_BRIGHTNESS: 100} == data - common.turn_on(self.hass, dev1.entity_id, profile="test_off") + common.turn_on(self.hass, ent1.entity_id, profile="test_off") self.hass.block_till_done() - _, data = dev1.last_call("turn_off") + _, data = ent1.last_call("turn_off") - assert not light.is_on(self.hass, dev1.entity_id) + assert not light.is_on(self.hass, ent1.entity_id) assert {} == data def test_default_profiles_group(self): @@ -387,10 +387,10 @@ def _mock_open(path, *args, **kwargs): self.hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev, _, _ = platform.DEVICES - common.turn_on(self.hass, dev.entity_id) + ent, _, _ = platform.ENTITIES + common.turn_on(self.hass, ent.entity_id) self.hass.block_till_done() - _, data = dev.last_call("turn_on") + _, data = ent.last_call("turn_on") assert {light.ATTR_HS_COLOR: (71.059, 100), light.ATTR_BRIGHTNESS: 99} == data def test_default_profiles_light(self): @@ -424,7 +424,9 @@ def _mock_open(path, *args, **kwargs): self.hass, light.DOMAIN, {light.DOMAIN: {CONF_PLATFORM: "test"}} ) - dev = next(filter(lambda x: x.entity_id == "light.ceiling_2", platform.DEVICES)) + dev = next( + filter(lambda x: x.entity_id == "light.ceiling_2", platform.ENTITIES) + ) common.turn_on(self.hass, dev.entity_id) self.hass.block_till_done() _, data = dev.last_call("turn_on") diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index af27ff8c7d1c3d..28f1a7e9720091 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -1,7 +1,8 @@ """The tests for the MQTT binary sensor platform.""" -from datetime import timedelta +from datetime import datetime, timedelta import json -from unittest.mock import ANY + +from unittest.mock import ANY, patch from homeassistant.components import binary_sensor, mqtt from homeassistant.components.mqtt.discovery import async_start @@ -24,6 +25,107 @@ ) +async def test_setting_sensor_value_expires_availability_topic(hass, mqtt_mock, caplog): + """Test the expiration of the value.""" + assert await async_setup_component( + hass, + binary_sensor.DOMAIN, + { + binary_sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "expire_after": 4, + "force_update": True, + "availability_topic": "availability-topic", + } + }, + ) + + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_UNAVAILABLE + + async_fire_mqtt_message(hass, "availability-topic", "online") + + state = hass.states.get("binary_sensor.test") + assert state.state != STATE_UNAVAILABLE + + await expires_helper(hass, mqtt_mock, caplog) + + +async def test_setting_sensor_value_expires(hass, mqtt_mock, caplog): + """Test the expiration of the value.""" + assert await async_setup_component( + hass, + binary_sensor.DOMAIN, + { + binary_sensor.DOMAIN: { + "platform": "mqtt", + "name": "test", + "state_topic": "test-topic", + "expire_after": 4, + "force_update": True, + } + }, + ) + + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + + await expires_helper(hass, mqtt_mock, caplog) + + +async def expires_helper(hass, mqtt_mock, caplog): + """Run the basic expiry code.""" + + now = datetime(2017, 1, 1, 1, tzinfo=dt_util.UTC) + with patch(("homeassistant.helpers.event." "dt_util.utcnow"), return_value=now): + async_fire_time_changed(hass, now) + async_fire_mqtt_message(hass, "test-topic", "ON") + await hass.async_block_till_done() + + # Value was set correctly. + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON + + # Time jump +3s + now = now + timedelta(seconds=3) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + # Value is not yet expired + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON + + # Next message resets timer + with patch(("homeassistant.helpers.event." "dt_util.utcnow"), return_value=now): + async_fire_time_changed(hass, now) + async_fire_mqtt_message(hass, "test-topic", "OFF") + await hass.async_block_till_done() + + # Value was updated correctly. + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + + # Time jump +3s + now = now + timedelta(seconds=3) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + # Value is not yet expired + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF + + # Time jump +2s + now = now + timedelta(seconds=2) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() + + # Value is expired now + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_UNAVAILABLE + + async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock): """Test the setting of the value via MQTT.""" assert await async_setup_component( @@ -41,6 +143,7 @@ async def test_setting_sensor_value_via_mqtt_message(hass, mqtt_mock): ) state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF async_fire_mqtt_message(hass, "test-topic", "ON") diff --git a/tests/components/mqtt/test_camera.py b/tests/components/mqtt/test_camera.py index ecc54e0e209b0c..70b5e941fe3868 100644 --- a/tests/components/mqtt/test_camera.py +++ b/tests/components/mqtt/test_camera.py @@ -1,5 +1,6 @@ """The tests for mqtt camera component.""" from unittest.mock import ANY +import json from homeassistant.components import camera, mqtt from homeassistant.components.mqtt.discovery import async_start @@ -167,3 +168,79 @@ async def test_entity_id_update(hass, mqtt_mock): assert state is not None assert mock_mqtt.async_subscribe.call_count == 1 mock_mqtt.async_subscribe.assert_any_call("test-topic", ANY, 0, None) + + +async def test_entity_device_info_with_identifier(hass, mqtt_mock): + """Test MQTT camera device registry integration.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + data = json.dumps( + { + "platform": "mqtt", + "name": "Test 1", + "topic": "test-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", + } + ) + async_fire_mqtt_message(hass, "homeassistant/camera/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device({("mqtt", "helloworld")}, set()) + assert device is not None + assert device.identifiers == {("mqtt", "helloworld")} + assert device.connections == {("mac", "02:5b:26:a8:dc:12")} + assert device.manufacturer == "Whatever" + assert device.name == "Beer" + assert device.model == "Glass" + assert device.sw_version == "0.1-beta" + + +async def test_entity_device_info_update(hass, mqtt_mock): + """Test device registry update.""" + entry = MockConfigEntry(domain=mqtt.DOMAIN) + entry.add_to_hass(hass) + await async_start(hass, "homeassistant", {}, entry) + registry = await hass.helpers.device_registry.async_get_registry() + + config = { + "platform": "mqtt", + "name": "Test 1", + "topic": "test-topic", + "device": { + "identifiers": ["helloworld"], + "connections": [["mac", "02:5b:26:a8:dc:12"]], + "manufacturer": "Whatever", + "name": "Beer", + "model": "Glass", + "sw_version": "0.1-beta", + }, + "unique_id": "veryunique", + } + + data = json.dumps(config) + async_fire_mqtt_message(hass, "homeassistant/camera/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device({("mqtt", "helloworld")}, set()) + assert device is not None + assert device.name == "Beer" + + config["device"]["name"] = "Milk" + data = json.dumps(config) + async_fire_mqtt_message(hass, "homeassistant/camera/bla/config", data) + await hass.async_block_till_done() + + device = registry.async_get_device({("mqtt", "helloworld")}, set()) + assert device is not None + assert device.name == "Milk" diff --git a/tests/components/nws/test_weather.py b/tests/components/nws/test_weather.py index 436d25750fc518..0e450f06238d94 100644 --- a/tests/components/nws/test_weather.py +++ b/tests/components/nws/test_weather.py @@ -110,9 +110,7 @@ async def test_imperial(hass, aioclient_mock): STAURL.format(40.0, -85.0), text=load_fixture("nws-weather-sta-valid.json") ) aioclient_mock.get( - OBSURL.format("KMIE"), - text=load_fixture("nws-weather-obs-valid.json"), - params={"limit": 1}, + OBSURL.format("KMIE"), text=load_fixture("nws-weather-obs-valid.json") ) aioclient_mock.get( FORCURL.format(40.0, -85.0), text=load_fixture("nws-weather-fore-valid.json") @@ -142,9 +140,7 @@ async def test_metric(hass, aioclient_mock): STAURL.format(40.0, -85.0), text=load_fixture("nws-weather-sta-valid.json") ) aioclient_mock.get( - OBSURL.format("KMIE"), - text=load_fixture("nws-weather-obs-valid.json"), - params={"limit": 1}, + OBSURL.format("KMIE"), text=load_fixture("nws-weather-obs-valid.json") ) aioclient_mock.get( FORCURL.format(40.0, -85.0), text=load_fixture("nws-weather-fore-valid.json") @@ -174,9 +170,7 @@ async def test_none(hass, aioclient_mock): STAURL.format(40.0, -85.0), text=load_fixture("nws-weather-sta-valid.json") ) aioclient_mock.get( - OBSURL.format("KMIE"), - text=load_fixture("nws-weather-obs-null.json"), - params={"limit": 1}, + OBSURL.format("KMIE"), text=load_fixture("nws-weather-obs-null.json") ) aioclient_mock.get( FORCURL.format(40.0, -85.0), text=load_fixture("nws-weather-fore-null.json") @@ -208,7 +202,6 @@ async def test_fail_obs(hass, aioclient_mock): aioclient_mock.get( OBSURL.format("KMIE"), text=load_fixture("nws-weather-obs-valid.json"), - params={"limit": 1}, status=400, ) aioclient_mock.get( @@ -234,9 +227,7 @@ async def test_fail_stn(hass, aioclient_mock): status=400, ) aioclient_mock.get( - OBSURL.format("KMIE"), - text=load_fixture("nws-weather-obs-valid.json"), - params={"limit": 1}, + OBSURL.format("KMIE"), text=load_fixture("nws-weather-obs-valid.json") ) aioclient_mock.get( FORCURL.format(40.0, -85.0), text=load_fixture("nws-weather-fore-valid.json") @@ -257,9 +248,7 @@ async def test_invalid_config(hass, aioclient_mock): STAURL.format(40.0, -85.0), text=load_fixture("nws-weather-sta-valid.json") ) aioclient_mock.get( - OBSURL.format("KMIE"), - text=load_fixture("nws-weather-obs-valid.json"), - params={"limit": 1}, + OBSURL.format("KMIE"), text=load_fixture("nws-weather-obs-valid.json") ) aioclient_mock.get( FORCURL.format(40.0, -85.0), text=load_fixture("nws-weather-fore-valid.json") diff --git a/tests/components/plex/__init__.py b/tests/components/plex/__init__.py new file mode 100644 index 00000000000000..9c9c00d87ace68 --- /dev/null +++ b/tests/components/plex/__init__.py @@ -0,0 +1 @@ +"""Tests for the Plex component.""" diff --git a/tests/components/plex/mock_classes.py b/tests/components/plex/mock_classes.py new file mode 100644 index 00000000000000..d027087828073c --- /dev/null +++ b/tests/components/plex/mock_classes.py @@ -0,0 +1,35 @@ +"""Mock classes used in tests.""" + +MOCK_HOST_1 = "1.2.3.4" +MOCK_PORT_1 = "32400" +MOCK_HOST_2 = "4.3.2.1" +MOCK_PORT_2 = "32400" + + +class MockAvailableServer: # pylint: disable=too-few-public-methods + """Mock avilable server objects.""" + + def __init__(self, name, client_id): + """Initialize the object.""" + self.name = name + self.clientIdentifier = client_id # pylint: disable=invalid-name + self.provides = ["server"] + + +class MockConnection: # pylint: disable=too-few-public-methods + """Mock a single account resource connection object.""" + + def __init__(self, ssl): + """Initialize the object.""" + prefix = "https" if ssl else "http" + self.httpuri = f"{prefix}://{MOCK_HOST_1}:{MOCK_PORT_1}" + self.uri = "{prefix}://{MOCK_HOST_2}:{MOCK_PORT_2}" + self.local = True + + +class MockConnections: # pylint: disable=too-few-public-methods + """Mock a list of resource connections.""" + + def __init__(self, ssl=False): + """Initialize the object.""" + self.connections = [MockConnection(ssl)] diff --git a/tests/components/plex/test_config_flow.py b/tests/components/plex/test_config_flow.py new file mode 100644 index 00000000000000..e98aed793cfdca --- /dev/null +++ b/tests/components/plex/test_config_flow.py @@ -0,0 +1,522 @@ +"""Tests for Plex config flow.""" +from unittest.mock import MagicMock, Mock, patch, PropertyMock +import plexapi.exceptions +import requests.exceptions + +from homeassistant.components.plex import config_flow +from homeassistant.const import ( + CONF_HOST, + CONF_PORT, + CONF_SSL, + CONF_VERIFY_SSL, + CONF_TOKEN, + CONF_URL, +) + +from tests.common import MockConfigEntry + +from .mock_classes import MOCK_HOST_1, MOCK_PORT_1, MockAvailableServer, MockConnections + +MOCK_NAME_1 = "Plex Server 1" +MOCK_ID_1 = "unique_id_123" +MOCK_NAME_2 = "Plex Server 2" +MOCK_ID_2 = "unique_id_456" +MOCK_TOKEN = "secret_token" +MOCK_FILE_CONTENTS = { + f"{MOCK_HOST_1}:{MOCK_PORT_1}": {"ssl": False, "token": MOCK_TOKEN, "verify": True} +} +MOCK_SERVER_1 = MockAvailableServer(MOCK_NAME_1, MOCK_ID_1) +MOCK_SERVER_2 = MockAvailableServer(MOCK_NAME_2, MOCK_ID_2) + + +def init_config_flow(hass): + """Init a configuration flow.""" + flow = config_flow.PlexFlowHandler() + flow.hass = hass + return flow + + +async def test_bad_credentials(hass): + """Test when provided credentials are rejected.""" + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch( + "plexapi.myplex.MyPlexAccount", side_effect=plexapi.exceptions.Unauthorized + ): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TOKEN: MOCK_TOKEN, "manual_setup": False}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"]["base"] == "faulty_credentials" + + +async def test_import_file_from_discovery(hass): + """Test importing a legacy file during discovery.""" + + file_host_and_port, file_config = list(MOCK_FILE_CONTENTS.items())[0] + used_url = f"http://{file_host_and_port}" + + with patch("plexapi.server.PlexServer") as mock_plex_server, patch( + "homeassistant.components.plex.config_flow.load_json", + return_value=MOCK_FILE_CONTENTS, + ): + type(mock_plex_server.return_value).machineIdentifier = PropertyMock( + return_value=MOCK_ID_1 + ) + type(mock_plex_server.return_value).friendlyName = PropertyMock( + return_value=MOCK_NAME_1 + ) + type( # pylint: disable=protected-access + mock_plex_server.return_value + )._baseurl = PropertyMock(return_value=used_url) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "discovery"}, + data={CONF_HOST: MOCK_HOST_1, CONF_PORT: MOCK_PORT_1}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == MOCK_NAME_1 + assert result["data"][config_flow.CONF_SERVER] == MOCK_NAME_1 + assert result["data"][config_flow.CONF_SERVER_IDENTIFIER] == MOCK_ID_1 + assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL] == used_url + assert ( + result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] + == file_config[CONF_TOKEN] + ) + + +async def test_discovery(hass): + """Test starting a flow from discovery.""" + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "discovery"}, + data={CONF_HOST: MOCK_HOST_1, CONF_PORT: MOCK_PORT_1}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + +async def test_discovery_while_in_progress(hass): + """Test starting a flow from discovery.""" + + await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "discovery"}, + data={CONF_HOST: MOCK_HOST_1, CONF_PORT: MOCK_PORT_1}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_import_success(hass): + """Test a successful configuration import.""" + + mock_connections = MockConnections(ssl=True) + + mm_plex_account = MagicMock() + mm_plex_account.resources = Mock(return_value=[MOCK_SERVER_1]) + mm_plex_account.resource = Mock(return_value=mock_connections) + + with patch("plexapi.server.PlexServer") as mock_plex_server: + type(mock_plex_server.return_value).machineIdentifier = PropertyMock( + return_value=MOCK_SERVER_1.clientIdentifier + ) + type(mock_plex_server.return_value).friendlyName = PropertyMock( + return_value=MOCK_SERVER_1.name + ) + type( # pylint: disable=protected-access + mock_plex_server.return_value + )._baseurl = PropertyMock(return_value=mock_connections.connections[0].httpuri) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "import"}, + data={ + CONF_TOKEN: MOCK_TOKEN, + CONF_URL: f"https://{MOCK_HOST_1}:{MOCK_PORT_1}", + }, + ) + + assert result["type"] == "create_entry" + assert result["title"] == MOCK_SERVER_1.name + assert result["data"][config_flow.CONF_SERVER] == MOCK_SERVER_1.name + assert ( + result["data"][config_flow.CONF_SERVER_IDENTIFIER] + == MOCK_SERVER_1.clientIdentifier + ) + assert ( + result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL] + == mock_connections.connections[0].httpuri + ) + assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + + +async def test_import_bad_hostname(hass): + """Test when an invalid address is provided.""" + + with patch( + "plexapi.server.PlexServer", side_effect=requests.exceptions.ConnectionError + ): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "import"}, + data={ + CONF_TOKEN: MOCK_TOKEN, + CONF_URL: f"http://{MOCK_HOST_1}:{MOCK_PORT_1}", + }, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"]["base"] == "not_found" + + +async def test_unknown_exception(hass): + """Test when an unknown exception is encountered.""" + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + with patch("plexapi.myplex.MyPlexAccount", side_effect=Exception): + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, + context={"source": "user"}, + data={CONF_TOKEN: MOCK_TOKEN, "manual_setup": False}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "unknown" + + +async def test_no_servers_found(hass): + """Test when no servers are on an account.""" + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + mm_plex_account = MagicMock() + mm_plex_account.resources = Mock(return_value=[]) + + with patch("plexapi.myplex.MyPlexAccount", return_value=mm_plex_account): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TOKEN: MOCK_TOKEN, "manual_setup": False}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + assert result["errors"]["base"] == "no_servers" + + +async def test_single_available_server(hass): + """Test creating an entry with one server available.""" + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + mock_connections = MockConnections() + + mm_plex_account = MagicMock() + mm_plex_account.resources = Mock(return_value=[MOCK_SERVER_1]) + mm_plex_account.resource = Mock(return_value=mock_connections) + + with patch("plexapi.myplex.MyPlexAccount", return_value=mm_plex_account), patch( + "plexapi.server.PlexServer" + ) as mock_plex_server: + type(mock_plex_server.return_value).machineIdentifier = PropertyMock( + return_value=MOCK_SERVER_1.clientIdentifier + ) + type(mock_plex_server.return_value).friendlyName = PropertyMock( + return_value=MOCK_SERVER_1.name + ) + type( # pylint: disable=protected-access + mock_plex_server.return_value + )._baseurl = PropertyMock(return_value=mock_connections.connections[0].httpuri) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TOKEN: MOCK_TOKEN, "manual_setup": False}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == MOCK_SERVER_1.name + assert result["data"][config_flow.CONF_SERVER] == MOCK_SERVER_1.name + assert ( + result["data"][config_flow.CONF_SERVER_IDENTIFIER] + == MOCK_SERVER_1.clientIdentifier + ) + assert ( + result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL] + == mock_connections.connections[0].httpuri + ) + assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + + +async def test_multiple_servers_with_selection(hass): + """Test creating an entry with multiple servers available.""" + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + mock_connections = MockConnections() + mm_plex_account = MagicMock() + mm_plex_account.resources = Mock(return_value=[MOCK_SERVER_1, MOCK_SERVER_2]) + mm_plex_account.resource = Mock(return_value=mock_connections) + + with patch("plexapi.myplex.MyPlexAccount", return_value=mm_plex_account), patch( + "plexapi.server.PlexServer" + ) as mock_plex_server: + type(mock_plex_server.return_value).machineIdentifier = PropertyMock( + return_value=MOCK_SERVER_1.clientIdentifier + ) + type(mock_plex_server.return_value).friendlyName = PropertyMock( + return_value=MOCK_SERVER_1.name + ) + type( # pylint: disable=protected-access + mock_plex_server.return_value + )._baseurl = PropertyMock(return_value=mock_connections.connections[0].httpuri) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TOKEN: MOCK_TOKEN, "manual_setup": False}, + ) + + assert result["type"] == "form" + assert result["step_id"] == "select_server" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={config_flow.CONF_SERVER: MOCK_SERVER_1.name} + ) + + assert result["type"] == "create_entry" + assert result["title"] == MOCK_SERVER_1.name + assert result["data"][config_flow.CONF_SERVER] == MOCK_SERVER_1.name + assert ( + result["data"][config_flow.CONF_SERVER_IDENTIFIER] + == MOCK_SERVER_1.clientIdentifier + ) + assert ( + result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL] + == mock_connections.connections[0].httpuri + ) + assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + + +async def test_adding_last_unconfigured_server(hass): + """Test automatically adding last unconfigured server when multiple servers on account.""" + + MockConfigEntry( + domain=config_flow.DOMAIN, + data={ + config_flow.CONF_SERVER_IDENTIFIER: MOCK_ID_2, + config_flow.CONF_SERVER: MOCK_NAME_2, + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + mock_connections = MockConnections() + mm_plex_account = MagicMock() + mm_plex_account.resources = Mock(return_value=[MOCK_SERVER_1, MOCK_SERVER_2]) + mm_plex_account.resource = Mock(return_value=mock_connections) + + with patch("plexapi.myplex.MyPlexAccount", return_value=mm_plex_account), patch( + "plexapi.server.PlexServer" + ) as mock_plex_server: + type(mock_plex_server.return_value).machineIdentifier = PropertyMock( + return_value=MOCK_SERVER_1.clientIdentifier + ) + type(mock_plex_server.return_value).friendlyName = PropertyMock( + return_value=MOCK_SERVER_1.name + ) + type( # pylint: disable=protected-access + mock_plex_server.return_value + )._baseurl = PropertyMock(return_value=mock_connections.connections[0].httpuri) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TOKEN: MOCK_TOKEN, "manual_setup": False}, + ) + + assert result["type"] == "create_entry" + assert result["title"] == MOCK_SERVER_1.name + assert result["data"][config_flow.CONF_SERVER] == MOCK_SERVER_1.name + assert ( + result["data"][config_flow.CONF_SERVER_IDENTIFIER] + == MOCK_SERVER_1.clientIdentifier + ) + assert ( + result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL] + == mock_connections.connections[0].httpuri + ) + assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN + + +async def test_already_configured(hass): + """Test a duplicated successful flow.""" + + flow = init_config_flow(hass) + MockConfigEntry( + domain=config_flow.DOMAIN, data={config_flow.CONF_SERVER_IDENTIFIER: MOCK_ID_1} + ).add_to_hass(hass) + + mock_connections = MockConnections() + + mm_plex_account = MagicMock() + mm_plex_account.resources = Mock(return_value=[MOCK_SERVER_1]) + mm_plex_account.resource = Mock(return_value=mock_connections) + + with patch("plexapi.server.PlexServer") as mock_plex_server: + type(mock_plex_server.return_value).machineIdentifier = PropertyMock( + return_value=MOCK_SERVER_1.clientIdentifier + ) + type(mock_plex_server.return_value).friendlyName = PropertyMock( + return_value=MOCK_SERVER_1.name + ) + type( # pylint: disable=protected-access + mock_plex_server.return_value + )._baseurl = PropertyMock(return_value=mock_connections.connections[0].httpuri) + result = await flow.async_step_import( + {CONF_TOKEN: MOCK_TOKEN, CONF_URL: f"http://{MOCK_HOST_1}:{MOCK_PORT_1}"} + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_all_available_servers_configured(hass): + """Test when all available servers are already configured.""" + + MockConfigEntry( + domain=config_flow.DOMAIN, + data={ + config_flow.CONF_SERVER_IDENTIFIER: MOCK_ID_1, + config_flow.CONF_SERVER: MOCK_NAME_1, + }, + ).add_to_hass(hass) + + MockConfigEntry( + domain=config_flow.DOMAIN, + data={ + config_flow.CONF_SERVER_IDENTIFIER: MOCK_ID_2, + config_flow.CONF_SERVER: MOCK_NAME_2, + }, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + mock_connections = MockConnections() + mm_plex_account = MagicMock() + mm_plex_account.resources = Mock(return_value=[MOCK_SERVER_1, MOCK_SERVER_2]) + mm_plex_account.resource = Mock(return_value=mock_connections) + + with patch("plexapi.myplex.MyPlexAccount", return_value=mm_plex_account): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TOKEN: MOCK_TOKEN, "manual_setup": False}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "all_configured" + + +async def test_manual_config(hass): + """Test creating via manual configuration.""" + + result = await hass.config_entries.flow.async_init( + config_flow.DOMAIN, context={"source": "user"} + ) + + assert result["type"] == "form" + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_TOKEN: "", "manual_setup": True} + ) + + assert result["type"] == "form" + assert result["step_id"] == "manual_setup" + + mock_connections = MockConnections(ssl=True) + + with patch("plexapi.server.PlexServer") as mock_plex_server: + type(mock_plex_server.return_value).machineIdentifier = PropertyMock( + return_value=MOCK_SERVER_1.clientIdentifier + ) + type(mock_plex_server.return_value).friendlyName = PropertyMock( + return_value=MOCK_SERVER_1.name + ) + type( # pylint: disable=protected-access + mock_plex_server.return_value + )._baseurl = PropertyMock(return_value=mock_connections.connections[0].httpuri) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: MOCK_HOST_1, + CONF_PORT: int(MOCK_PORT_1), + CONF_SSL: True, + CONF_VERIFY_SSL: True, + CONF_TOKEN: MOCK_TOKEN, + }, + ) + + assert result["type"] == "create_entry" + assert result["title"] == MOCK_SERVER_1.name + assert result["data"][config_flow.CONF_SERVER] == MOCK_SERVER_1.name + assert ( + result["data"][config_flow.CONF_SERVER_IDENTIFIER] + == MOCK_SERVER_1.clientIdentifier + ) + assert ( + result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_URL] + == mock_connections.connections[0].httpuri + ) + assert result["data"][config_flow.PLEX_SERVER_CONFIG][CONF_TOKEN] == MOCK_TOKEN diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 9e313fd3694583..4ec40731c5d206 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -41,6 +41,11 @@ async def prometheus_client(loop, hass, hass_client): sensor3.entity_id = "sensor.electricity_price" await sensor3.async_update_ha_state() + sensor4 = DemoSensor("Wind Direction", 25, None, "°", None) + sensor4.hass = hass + sensor4.entity_id = "sensor.wind_direction" + await sensor4.async_update_ha_state() + return await hass_client() @@ -103,3 +108,9 @@ def test_view(prometheus_client): # pylint: disable=redefined-outer-name 'entity="sensor.electricity_price",' 'friendly_name="Electricity price"} 0.123' in body ) + + assert ( + 'sensor_unit_u0xb0{domain="sensor",' + 'entity="sensor.wind_direction",' + 'friendly_name="Wind Direction"} 25.0' in body + ) diff --git a/tests/components/qld_bushfire/test_geo_location.py b/tests/components/qld_bushfire/test_geo_location.py index c74630f7cd2274..59de4643cb8e8b 100644 --- a/tests/components/qld_bushfire/test_geo_location.py +++ b/tests/components/qld_bushfire/test_geo_location.py @@ -5,23 +5,24 @@ from homeassistant.components import geo_location from homeassistant.components.geo_location import ATTR_SOURCE from homeassistant.components.qld_bushfire.geo_location import ( - ATTR_EXTERNAL_ID, - SCAN_INTERVAL, ATTR_CATEGORY, - ATTR_STATUS, + ATTR_EXTERNAL_ID, ATTR_PUBLICATION_DATE, + ATTR_STATUS, ATTR_UPDATED_DATE, + SCAN_INTERVAL, ) from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, - CONF_RADIUS, + ATTR_ATTRIBUTION, + ATTR_FRIENDLY_NAME, + ATTR_ICON, ATTR_LATITUDE, ATTR_LONGITUDE, - ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, - ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE, + CONF_RADIUS, + EVENT_HOMEASSISTANT_START, ) from homeassistant.setup import async_setup_component from tests.common import assert_setup_component, async_fire_time_changed @@ -122,6 +123,7 @@ async def test_setup(hass): ATTR_STATUS: "Status 1", ATTR_UNIT_OF_MEASUREMENT: "km", ATTR_SOURCE: "qld_bushfire", + ATTR_ICON: "mdi:fire", } assert float(state.state) == 15.5 @@ -135,6 +137,7 @@ async def test_setup(hass): ATTR_FRIENDLY_NAME: "Title 2", ATTR_UNIT_OF_MEASUREMENT: "km", ATTR_SOURCE: "qld_bushfire", + ATTR_ICON: "mdi:fire", } assert float(state.state) == 20.5 @@ -148,6 +151,7 @@ async def test_setup(hass): ATTR_FRIENDLY_NAME: "Title 3", ATTR_UNIT_OF_MEASUREMENT: "km", ATTR_SOURCE: "qld_bushfire", + ATTR_ICON: "mdi:fire", } assert float(state.state) == 25.5 diff --git a/tests/components/scene/test_init.py b/tests/components/scene/test_init.py index 7047e6e8d92f3c..5c8d46cb727753 100644 --- a/tests/components/scene/test_init.py +++ b/tests/components/scene/test_init.py @@ -24,7 +24,7 @@ def setUp(self): # pylint: disable=invalid-name self.hass, light.DOMAIN, {light.DOMAIN: {"platform": "test"}} ) - self.light_1, self.light_2 = test_light.DEVICES[0:2] + self.light_1, self.light_2 = test_light.ENTITIES[0:2] common_light.turn_off( self.hass, [self.light_1.entity_id, self.light_2.entity_id] diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 5724d7a3bac0be..fce0129a7bf399 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -84,7 +84,10 @@ async def test_token_unauthorized(hass, smartthings_mock): flow = SmartThingsFlowHandler() flow.hass = hass - smartthings_mock.apps.side_effect = ClientResponseError(None, None, status=401) + request_info = Mock(real_url="http://example.com") + smartthings_mock.apps.side_effect = ClientResponseError( + request_info=request_info, history=None, status=401 + ) result = await flow.async_step_user({"access_token": str(uuid4())}) @@ -98,7 +101,10 @@ async def test_token_forbidden(hass, smartthings_mock): flow = SmartThingsFlowHandler() flow.hass = hass - smartthings_mock.apps.side_effect = ClientResponseError(None, None, status=403) + request_info = Mock(real_url="http://example.com") + smartthings_mock.apps.side_effect = ClientResponseError( + request_info=request_info, history=None, status=403 + ) result = await flow.async_step_user({"access_token": str(uuid4())}) @@ -113,7 +119,10 @@ async def test_webhook_error(hass, smartthings_mock): flow.hass = hass data = {"error": {}} - error = APIResponseError(None, None, data=data, status=422) + request_info = Mock(real_url="http://example.com") + error = APIResponseError( + request_info=request_info, history=None, data=data, status=422 + ) error.is_target_error = Mock(return_value=True) smartthings_mock.apps.side_effect = error @@ -131,7 +140,10 @@ async def test_api_error(hass, smartthings_mock): flow.hass = hass data = {"error": {}} - error = APIResponseError(None, None, data=data, status=400) + request_info = Mock(real_url="http://example.com") + error = APIResponseError( + request_info=request_info, history=None, data=data, status=400 + ) smartthings_mock.apps.side_effect = error @@ -147,7 +159,10 @@ async def test_unknown_api_error(hass, smartthings_mock): flow = SmartThingsFlowHandler() flow.hass = hass - smartthings_mock.apps.side_effect = ClientResponseError(None, None, status=404) + request_info = Mock(real_url="http://example.com") + smartthings_mock.apps.side_effect = ClientResponseError( + request_info=request_info, history=None, status=404 + ) result = await flow.async_step_user({"access_token": str(uuid4())}) diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 4e1ffce7e22119..9749ab9bb71e3d 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -54,7 +54,10 @@ async def test_unrecoverable_api_errors_create_new_flow( """ assert await async_setup_component(hass, "persistent_notification", {}) config_entry.add_to_hass(hass) - smartthings_mock.app.side_effect = ClientResponseError(None, None, status=401) + request_info = Mock(real_url="http://example.com") + smartthings_mock.app.side_effect = ClientResponseError( + request_info=request_info, history=None, status=401 + ) # Assert setup returns false result = await smartthings.async_setup_entry(hass, config_entry) @@ -75,7 +78,10 @@ async def test_recoverable_api_errors_raise_not_ready( ): """Test config entry not ready raised for recoverable API errors.""" config_entry.add_to_hass(hass) - smartthings_mock.app.side_effect = ClientResponseError(None, None, status=500) + request_info = Mock(real_url="http://example.com") + smartthings_mock.app.side_effect = ClientResponseError( + request_info=request_info, history=None, status=500 + ) with pytest.raises(ConfigEntryNotReady): await smartthings.async_setup_entry(hass, config_entry) @@ -86,9 +92,12 @@ async def test_scenes_api_errors_raise_not_ready( ): """Test if scenes are unauthorized we continue to load platforms.""" config_entry.add_to_hass(hass) + request_info = Mock(real_url="http://example.com") smartthings_mock.app.return_value = app smartthings_mock.installed_app.return_value = installed_app - smartthings_mock.scenes.side_effect = ClientResponseError(None, None, status=500) + smartthings_mock.scenes.side_effect = ClientResponseError( + request_info=request_info, history=None, status=500 + ) with pytest.raises(ConfigEntryNotReady): await smartthings.async_setup_entry(hass, config_entry) @@ -140,10 +149,13 @@ async def test_scenes_unauthorized_loads_platforms( ): """Test if scenes are unauthorized we continue to load platforms.""" config_entry.add_to_hass(hass) + request_info = Mock(real_url="http://example.com") smartthings_mock.app.return_value = app smartthings_mock.installed_app.return_value = installed_app smartthings_mock.devices.return_value = [device] - smartthings_mock.scenes.side_effect = ClientResponseError(None, None, status=403) + smartthings_mock.scenes.side_effect = ClientResponseError( + request_info=request_info, history=None, status=403 + ) mock_token = Mock() mock_token.access_token.return_value = str(uuid4()) mock_token.refresh_token.return_value = str(uuid4()) @@ -290,12 +302,13 @@ async def test_remove_entry_app_in_use(hass, config_entry, smartthings_mock): async def test_remove_entry_already_deleted(hass, config_entry, smartthings_mock): """Test handles when the apps have already been removed.""" + request_info = Mock(real_url="http://example.com") # Arrange smartthings_mock.delete_installed_app.side_effect = ClientResponseError( - None, None, status=403 + request_info=request_info, history=None, status=403 ) smartthings_mock.delete_app.side_effect = ClientResponseError( - None, None, status=403 + request_info=request_info, history=None, status=403 ) # Act await smartthings.async_remove_entry(hass, config_entry) @@ -308,9 +321,10 @@ async def test_remove_entry_installedapp_api_error( hass, config_entry, smartthings_mock ): """Test raises exceptions removing the installed app.""" + request_info = Mock(real_url="http://example.com") # Arrange smartthings_mock.delete_installed_app.side_effect = ClientResponseError( - None, None, status=500 + request_info=request_info, history=None, status=500 ) # Act with pytest.raises(ClientResponseError): @@ -337,8 +351,9 @@ async def test_remove_entry_installedapp_unknown_error( async def test_remove_entry_app_api_error(hass, config_entry, smartthings_mock): """Test raises exceptions removing the app.""" # Arrange + request_info = Mock(real_url="http://example.com") smartthings_mock.delete_app.side_effect = ClientResponseError( - None, None, status=500 + request_info=request_info, history=None, status=500 ) # Act with pytest.raises(ClientResponseError): diff --git a/tests/components/solaredge/__init__.py b/tests/components/solaredge/__init__.py new file mode 100644 index 00000000000000..c2a54cfafb623c --- /dev/null +++ b/tests/components/solaredge/__init__.py @@ -0,0 +1 @@ +"""Tests for the SolarEdge component.""" diff --git a/tests/components/solaredge/test_config_flow.py b/tests/components/solaredge/test_config_flow.py new file mode 100644 index 00000000000000..c1183147bac5e2 --- /dev/null +++ b/tests/components/solaredge/test_config_flow.py @@ -0,0 +1,132 @@ +"""Tests for the SolarEdge config flow.""" +import pytest +from requests.exceptions import HTTPError, ConnectTimeout +from unittest.mock import patch, Mock + +from homeassistant import data_entry_flow +from homeassistant.components.solaredge import config_flow +from homeassistant.components.solaredge.const import CONF_SITE_ID, DEFAULT_NAME +from homeassistant.const import CONF_NAME, CONF_API_KEY + +from tests.common import MockConfigEntry + +NAME = "solaredge site 1 2 3" +SITE_ID = "1a2b3c4d5e6f7g8h" +API_KEY = "a1b2c3d4e5f6g7h8" + + +@pytest.fixture(name="test_api") +def mock_controller(): + """Mock a successfull Solaredge API.""" + api = Mock() + api.get_details.return_value = {"details": {"status": "active"}} + with patch("solaredge.Solaredge", return_value=api): + yield api + + +def init_config_flow(hass): + """Init a configuration flow.""" + flow = config_flow.SolarEdgeConfigFlow() + flow.hass = hass + return flow + + +async def test_user(hass, test_api): + """Test user config.""" + flow = init_config_flow(hass) + + result = await flow.async_step_user() + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + # tets with all provided + result = await flow.async_step_user( + {CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "solaredge_site_1_2_3" + assert result["data"][CONF_SITE_ID] == SITE_ID + assert result["data"][CONF_API_KEY] == API_KEY + + +async def test_import(hass, test_api): + """Test import step.""" + flow = init_config_flow(hass) + + # import with site_id and api_key + result = await flow.async_step_import( + {CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "solaredge" + assert result["data"][CONF_SITE_ID] == SITE_ID + assert result["data"][CONF_API_KEY] == API_KEY + + # import with all + result = await flow.async_step_import( + {CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID, CONF_NAME: NAME} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "solaredge_site_1_2_3" + assert result["data"][CONF_SITE_ID] == SITE_ID + assert result["data"][CONF_API_KEY] == API_KEY + + +async def test_abort_if_already_setup(hass, test_api): + """Test we abort if the site_id is already setup.""" + flow = init_config_flow(hass) + MockConfigEntry( + domain="solaredge", + data={CONF_NAME: DEFAULT_NAME, CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY}, + ).add_to_hass(hass) + + # import: Should fail, same SITE_ID + result = await flow.async_step_import( + {CONF_NAME: DEFAULT_NAME, CONF_SITE_ID: SITE_ID, CONF_API_KEY: API_KEY} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "site_exists" + + # user: Should fail, same SITE_ID + result = await flow.async_step_user( + {CONF_NAME: "test", CONF_SITE_ID: SITE_ID, CONF_API_KEY: "test"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_SITE_ID: "site_exists"} + + +async def test_asserts(hass, test_api): + """Test the _site_in_configuration_exists method.""" + flow = init_config_flow(hass) + + # test with inactive site + test_api.get_details.return_value = {"details": {"status": "NOK"}} + result = await flow.async_step_user( + {CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_SITE_ID: "site_not_active"} + + # test with api_failure + test_api.get_details.return_value = {} + result = await flow.async_step_user( + {CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_SITE_ID: "api_failure"} + + # test with ConnectionTimeout + test_api.get_details.side_effect = ConnectTimeout() + result = await flow.async_step_user( + {CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_SITE_ID: "could_not_connect"} + + # test with HTTPError + test_api.get_details.side_effect = HTTPError() + result = await flow.async_step_user( + {CONF_NAME: NAME, CONF_API_KEY: API_KEY, CONF_SITE_ID: SITE_ID} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {CONF_SITE_ID: "could_not_connect"} diff --git a/tests/components/spaceapi/test_init.py b/tests/components/spaceapi/test_init.py index 02a6eccc285804..58c417831a966f 100644 --- a/tests/components/spaceapi/test_init.py +++ b/tests/components/spaceapi/test_init.py @@ -25,6 +25,34 @@ "temperature": ["test.temp1", "test.temp2"], "humidity": ["test.hum1"], }, + "spacefed": {"spacenet": True, "spacesaml": False, "spacephone": True}, + "cam": ["https://home-assistant.io/cam1", "https://home-assistant.io/cam2"], + "stream": { + "m4": "https://home-assistant.io/m4", + "mjpeg": "https://home-assistant.io/mjpeg", + "ustream": "https://home-assistant.io/ustream", + }, + "feeds": { + "blog": {"url": "https://home-assistant.io/blog"}, + "wiki": {"type": "mediawiki", "url": "https://home-assistant.io/wiki"}, + "calendar": {"type": "ical", "url": "https://home-assistant.io/calendar"}, + "flicker": {"url": "https://www.flickr.com/photos/home-assistant"}, + }, + "cache": {"schedule": "m.02"}, + "projects": [ + "https://home-assistant.io/projects/1", + "https://home-assistant.io/projects/2", + "https://home-assistant.io/projects/3", + ], + "radio_show": [ + { + "name": "Radioshow", + "url": "https://home-assistant.io/radio", + "type": "ogg", + "start": "2019-09-02T10:00Z", + "end": "2019-09-02T12:00Z", + } + ], } } @@ -61,11 +89,37 @@ async def test_spaceapi_get(hass, mock_client): assert data["space"] == "Home" assert data["contact"]["email"] == "hello@home-assistant.io" assert data["location"]["address"] == "In your Home" - assert data["location"]["latitude"] == 32.87336 - assert data["location"]["longitude"] == -117.22743 + assert data["location"]["lat"] == 32.87336 + assert data["location"]["lon"] == -117.22743 assert data["state"]["open"] == "null" assert data["state"]["icon"]["open"] == "https://home-assistant.io/open.png" assert data["state"]["icon"]["close"] == "https://home-assistant.io/close.png" + assert data["spacefed"]["spacenet"] == bool(1) + assert data["spacefed"]["spacesaml"] == bool(0) + assert data["spacefed"]["spacephone"] == bool(1) + assert data["cam"][0] == "https://home-assistant.io/cam1" + assert data["cam"][1] == "https://home-assistant.io/cam2" + assert data["stream"]["m4"] == "https://home-assistant.io/m4" + assert data["stream"]["mjpeg"] == "https://home-assistant.io/mjpeg" + assert data["stream"]["ustream"] == "https://home-assistant.io/ustream" + assert data["feeds"]["blog"]["url"] == "https://home-assistant.io/blog" + assert data["feeds"]["wiki"]["type"] == "mediawiki" + assert data["feeds"]["wiki"]["url"] == "https://home-assistant.io/wiki" + assert data["feeds"]["calendar"]["type"] == "ical" + assert data["feeds"]["calendar"]["url"] == "https://home-assistant.io/calendar" + assert ( + data["feeds"]["flicker"]["url"] + == "https://www.flickr.com/photos/home-assistant" + ) + assert data["cache"]["schedule"] == "m.02" + assert data["projects"][0] == "https://home-assistant.io/projects/1" + assert data["projects"][1] == "https://home-assistant.io/projects/2" + assert data["projects"][2] == "https://home-assistant.io/projects/3" + assert data["radio_show"][0]["name"] == "Radioshow" + assert data["radio_show"][0]["url"] == "https://home-assistant.io/radio" + assert data["radio_show"][0]["type"] == "ogg" + assert data["radio_show"][0]["start"] == "2019-09-02T10:00Z" + assert data["radio_show"][0]["end"] == "2019-09-02T12:00Z" async def test_spaceapi_state_get(hass, mock_client): diff --git a/tests/components/srp_energy/__init__.py b/tests/components/srp_energy/__init__.py deleted file mode 100644 index 2a278cf1d38de2..00000000000000 --- a/tests/components/srp_energy/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the srp_energy component.""" diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py deleted file mode 100644 index e33902c3fd8c6e..00000000000000 --- a/tests/components/srp_energy/test_sensor.py +++ /dev/null @@ -1,62 +0,0 @@ -"""The tests for the Srp Energy Platform.""" -from unittest.mock import patch -import logging -from homeassistant.setup import async_setup_component - -_LOGGER = logging.getLogger(__name__) - -VALID_CONFIG_MINIMAL = { - "sensor": { - "platform": "srp_energy", - "username": "foo", - "password": "bar", - "id": 1234, - } -} - -PATCH_INIT = "srpenergy.client.SrpEnergyClient.__init__" -PATCH_VALIDATE = "srpenergy.client.SrpEnergyClient.validate" -PATCH_USAGE = "srpenergy.client.SrpEnergyClient.usage" - - -def mock_usage(self, startdate, enddate): # pylint: disable=invalid-name - """Mock srpusage usage.""" - _LOGGER.log(logging.INFO, "Calling mock usage") - usage = [ - ("9/19/2018", "12:00 AM", "2018-09-19T00:00:00-7:00", "1.2", "0.17"), - ("9/19/2018", "1:00 AM", "2018-09-19T01:00:00-7:00", "2.1", "0.30"), - ("9/19/2018", "2:00 AM", "2018-09-19T02:00:00-7:00", "1.5", "0.23"), - ("9/19/2018", "9:00 PM", "2018-09-19T21:00:00-7:00", "1.2", "0.19"), - ("9/19/2018", "10:00 PM", "2018-09-19T22:00:00-7:00", "1.1", "0.18"), - ("9/19/2018", "11:00 PM", "2018-09-19T23:00:00-7:00", "0.4", "0.09"), - ] - return usage - - -async def test_setup_with_config(hass): - """Test the platform setup with configuration.""" - with patch(PATCH_INIT, return_value=None), patch( - PATCH_VALIDATE, return_value=True - ), patch(PATCH_USAGE, new=mock_usage): - - await async_setup_component(hass, "sensor", VALID_CONFIG_MINIMAL) - - state = hass.states.get("sensor.srp_energy") - assert state is not None - - -async def test_daily_usage(hass): - """Test the platform daily usage.""" - with patch(PATCH_INIT, return_value=None), patch( - PATCH_VALIDATE, return_value=True - ), patch(PATCH_USAGE, new=mock_usage): - - await async_setup_component(hass, "sensor", VALID_CONFIG_MINIMAL) - - state = hass.states.get("sensor.srp_energy") - - assert state - assert state.state == "7.50" - - assert state.attributes - assert state.attributes.get("unit_of_measurement") diff --git a/tests/components/switch/test_device_automation.py b/tests/components/switch/test_device_automation.py new file mode 100644 index 00000000000000..1ebe4785761aa5 --- /dev/null +++ b/tests/components/switch/test_device_automation.py @@ -0,0 +1,373 @@ +"""The test for switch device automation.""" +import pytest + +from homeassistant.components.switch import DOMAIN +from homeassistant.const import STATE_ON, STATE_OFF, CONF_PLATFORM +from homeassistant.setup import async_setup_component +import homeassistant.components.automation as automation +from homeassistant.components.device_automation import ( + _async_get_device_automations as async_get_device_automations, +) +from homeassistant.helpers import device_registry + +from tests.common import ( + MockConfigEntry, + async_mock_service, + mock_device_registry, + mock_registry, +) + + +@pytest.fixture +def device_reg(hass): + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass): + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def calls(hass): + """Track calls to a mock serivce.""" + return async_mock_service(hass, "test", "automation") + + +def _same_lists(a, b): + if len(a) != len(b): + return False + + for d in a: + if d not in b: + return False + return True + + +async def test_get_actions(hass, device_reg, entity_reg): + """Test we get the expected actions from a switch.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) + expected_actions = [ + { + "domain": DOMAIN, + "type": "turn_off", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "domain": DOMAIN, + "type": "turn_on", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "domain": DOMAIN, + "type": "toggle", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + ] + actions = await async_get_device_automations( + hass, "async_get_actions", device_entry.id + ) + assert _same_lists(actions, expected_actions) + + +async def test_get_conditions(hass, device_reg, entity_reg): + """Test we get the expected conditions from a switch.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) + expected_conditions = [ + { + "condition": "device", + "domain": DOMAIN, + "type": "is_off", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "condition": "device", + "domain": DOMAIN, + "type": "is_on", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + ] + conditions = await async_get_device_automations( + hass, "async_get_conditions", device_entry.id + ) + assert _same_lists(conditions, expected_conditions) + + +async def test_get_triggers(hass, device_reg, entity_reg): + """Test we get the expected triggers from a switch.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": "turned_off", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "turned_on", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + ] + triggers = await async_get_device_automations( + hass, "async_get_triggers", device_entry.id + ) + assert _same_lists(triggers, expected_triggers) + + +async def test_if_fires_on_state_change(hass, calls): + """Test for turn_on and turn_off triggers firing.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + ent1, ent2, ent3 = platform.ENTITIES + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "turned_on", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_on {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "turned_off", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_ON + assert len(calls) == 0 + + hass.states.async_set(ent1.entity_id, STATE_OFF) + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "turn_off state - {} - on - off - None".format( + ent1.entity_id + ) + + hass.states.async_set(ent1.entity_id, STATE_ON) + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "turn_on state - {} - off - on - None".format( + ent1.entity_id + ) + + +async def test_if_state(hass, calls): + """Test for turn_on and turn_off conditions.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + ent1, ent2, ent3 = platform.ENTITIES + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "is_on", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_on {{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event2"}, + "condition": [ + { + "condition": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "is_off", + } + ], + "action": { + "service": "test.automation", + "data_template": { + "some": "is_off {{ trigger.%s }}" + % "}} - {{ trigger.".join(("platform", "event.event_type")) + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_ON + assert len(calls) == 0 + + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 1 + assert calls[0].data["some"] == "is_on event - test_event1" + + hass.states.async_set(ent1.entity_id, STATE_OFF) + hass.bus.async_fire("test_event1") + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert len(calls) == 2 + assert calls[1].data["some"] == "is_off event - test_event2" + + +async def test_action(hass, calls): + """Test for turn_on and turn_off actions.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + + ent1, ent2, ent3 = platform.ENTITIES + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": {"platform": "event", "event_type": "test_event1"}, + "action": { + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "turn_off", + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event2"}, + "action": { + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "turn_on", + }, + }, + { + "trigger": {"platform": "event", "event_type": "test_event3"}, + "action": { + "domain": DOMAIN, + "device_id": "", + "entity_id": ent1.entity_id, + "type": "toggle", + }, + }, + ] + }, + ) + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_ON + assert len(calls) == 0 + + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_OFF + + hass.bus.async_fire("test_event1") + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_OFF + + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_ON + + hass.bus.async_fire("test_event2") + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_ON + + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_OFF + + hass.bus.async_fire("test_event3") + await hass.async_block_till_done() + assert hass.states.get(ent1.entity_id).state == STATE_ON diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py index c04a30589edd8f..a9463cb78f4fea 100644 --- a/tests/components/switch/test_init.py +++ b/tests/components/switch/test_init.py @@ -21,7 +21,7 @@ def setUp(self): platform = getattr(self.hass.components, "test.switch") platform.init() # Switch 1 is ON, switch 2 is OFF - self.switch_1, self.switch_2, self.switch_3 = platform.DEVICES + self.switch_1, self.switch_2, self.switch_3 = platform.ENTITIES # pylint: disable=invalid-name def tearDown(self): diff --git a/tests/components/switcher_kis/conftest.py b/tests/components/switcher_kis/conftest.py index e06746915335b9..888ffd46c3b136 100644 --- a/tests/components/switcher_kis/conftest.py +++ b/tests/components/switcher_kis/conftest.py @@ -93,7 +93,7 @@ def last_state_change(self) -> datetime: @fixture(name="mock_bridge") def mock_bridge_fixture() -> Generator[None, Any, None]: """Fixture for mocking aioswitcher.bridge.SwitcherV2Bridge.""" - queue = Queue() # type: Queue + queue = Queue() async def mock_queue(): """Mock asyncio's Queue.""" diff --git a/tests/components/tradfri/test_light.py b/tests/components/tradfri/test_light.py index 69fe3bae618dab..4c691f66af872c 100644 --- a/tests/components/tradfri/test_light.py +++ b/tests/components/tradfri/test_light.py @@ -4,7 +4,9 @@ from unittest.mock import Mock, MagicMock, patch, PropertyMock import pytest -from pytradfri.device import Device, LightControl, Light +from pytradfri.device import Device +from pytradfri.device.light import Light +from pytradfri.device.light_control import LightControl from homeassistant.components import tradfri diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 437fabf9689469..969c2a734d3448 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -1,4 +1,4 @@ -"""The tests for the Unifi WAP device tracker platform.""" +"""The tests for the UniFi device tracker platform.""" from collections import deque from copy import copy from unittest.mock import Mock @@ -32,7 +32,6 @@ from homeassistant.setup import async_setup_component import homeassistant.components.device_tracker as device_tracker -import homeassistant.components.unifi.device_tracker as unifi_dt import homeassistant.util.dt as dt_util DEFAULT_DETECTION_TIME = timedelta(seconds=300) @@ -275,14 +274,14 @@ async def test_restoring_client(hass, mock_controller): registry = await entity_registry.async_get_registry(hass) registry.async_get_or_create( device_tracker.DOMAIN, - unifi_dt.UNIFI_DOMAIN, + unifi.DOMAIN, "{}-mock-site".format(CLIENT_1["mac"]), suggested_object_id=CLIENT_1["hostname"], config_entry=config_entry, ) registry.async_get_or_create( device_tracker.DOMAIN, - unifi_dt.UNIFI_DOMAIN, + unifi.DOMAIN, "{}-mock-site".format(CLIENT_2["mac"]), suggested_object_id=CLIENT_2["hostname"], config_entry=config_entry, diff --git a/tests/components/upc_connect/__init__.py b/tests/components/upc_connect/__init__.py deleted file mode 100644 index d491190d111a25..00000000000000 --- a/tests/components/upc_connect/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the upc_connect component.""" diff --git a/tests/components/upc_connect/test_device_tracker.py b/tests/components/upc_connect/test_device_tracker.py deleted file mode 100644 index d04219eb884eee..00000000000000 --- a/tests/components/upc_connect/test_device_tracker.py +++ /dev/null @@ -1,221 +0,0 @@ -"""The tests for the UPC ConnextBox device tracker platform.""" -import asyncio - -from asynctest import patch -import pytest - -from homeassistant.components.device_tracker import DOMAIN -import homeassistant.components.upc_connect.device_tracker as platform -from homeassistant.const import CONF_HOST, CONF_PLATFORM -from homeassistant.setup import async_setup_component - -from tests.common import assert_setup_component, load_fixture, mock_component - -HOST = "127.0.0.1" - - -async def async_scan_devices_mock(scanner): - """Mock async_scan_devices.""" - return [] - - -@pytest.fixture(autouse=True) -def setup_comp_deps(hass, mock_device_tracker_conf): - """Set up component dependencies.""" - mock_component(hass, "zone") - mock_component(hass, "group") - yield - - -async def test_setup_platform_timeout_loginpage(hass, caplog, aioclient_mock): - """Set up a platform with timeout on loginpage.""" - aioclient_mock.get( - "http://{}/common_page/login.html".format(HOST), exc=asyncio.TimeoutError() - ) - aioclient_mock.post("http://{}/xml/getter.xml".format(HOST), content=b"successful") - - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "upc_connect", CONF_HOST: HOST}} - ) - - assert len(aioclient_mock.mock_calls) == 1 - - assert "Error setting up platform" in caplog.text - - -async def test_setup_platform_timeout_webservice(hass, caplog, aioclient_mock): - """Set up a platform with api timeout.""" - aioclient_mock.get( - "http://{}/common_page/login.html".format(HOST), - cookies={"sessionToken": "654321"}, - content=b"successful", - exc=asyncio.TimeoutError(), - ) - - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "upc_connect", CONF_HOST: HOST}} - ) - - assert len(aioclient_mock.mock_calls) == 1 - - assert "Error setting up platform" in caplog.text - - -@patch( - "homeassistant.components.upc_connect.device_tracker." - "UPCDeviceScanner.async_scan_devices", - return_value=async_scan_devices_mock, -) -async def test_setup_platform(scan_mock, hass, aioclient_mock): - """Set up a platform.""" - aioclient_mock.get( - "http://{}/common_page/login.html".format(HOST), - cookies={"sessionToken": "654321"}, - ) - aioclient_mock.post("http://{}/xml/getter.xml".format(HOST), content=b"successful") - - with assert_setup_component(1, DOMAIN): - assert await async_setup_component( - hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "upc_connect", CONF_HOST: HOST}} - ) - - assert len(aioclient_mock.mock_calls) == 1 - - -async def test_scan_devices(hass, aioclient_mock): - """Set up a upc platform and scan device.""" - aioclient_mock.get( - "http://{}/common_page/login.html".format(HOST), - cookies={"sessionToken": "654321"}, - ) - aioclient_mock.post( - "http://{}/xml/getter.xml".format(HOST), - content=b"successful", - cookies={"sessionToken": "654321"}, - ) - - scanner = await platform.async_get_scanner( - hass, {DOMAIN: {CONF_PLATFORM: "upc_connect", CONF_HOST: HOST}} - ) - - assert len(aioclient_mock.mock_calls) == 1 - - aioclient_mock.clear_requests() - aioclient_mock.post( - "http://{}/xml/getter.xml".format(HOST), - text=load_fixture("upc_connect.xml"), - cookies={"sessionToken": "1235678"}, - ) - - mac_list = await scanner.async_scan_devices() - - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == "token=654321&fun=123" - assert mac_list == ["30:D3:2D:0:69:21", "5C:AA:FD:25:32:02", "70:EE:50:27:A1:38"] - - -async def test_scan_devices_without_session(hass, aioclient_mock): - """Set up a upc platform and scan device with no token.""" - aioclient_mock.get( - "http://{}/common_page/login.html".format(HOST), - cookies={"sessionToken": "654321"}, - ) - aioclient_mock.post( - "http://{}/xml/getter.xml".format(HOST), - content=b"successful", - cookies={"sessionToken": "654321"}, - ) - - scanner = await platform.async_get_scanner( - hass, {DOMAIN: {CONF_PLATFORM: "upc_connect", CONF_HOST: HOST}} - ) - - assert len(aioclient_mock.mock_calls) == 1 - - aioclient_mock.clear_requests() - aioclient_mock.get( - "http://{}/common_page/login.html".format(HOST), - cookies={"sessionToken": "654321"}, - ) - aioclient_mock.post( - "http://{}/xml/getter.xml".format(HOST), - text=load_fixture("upc_connect.xml"), - cookies={"sessionToken": "1235678"}, - ) - - scanner.token = None - mac_list = await scanner.async_scan_devices() - - assert len(aioclient_mock.mock_calls) == 2 - assert aioclient_mock.mock_calls[1][2] == "token=654321&fun=123" - assert mac_list == ["30:D3:2D:0:69:21", "5C:AA:FD:25:32:02", "70:EE:50:27:A1:38"] - - -async def test_scan_devices_without_session_wrong_re(hass, aioclient_mock): - """Set up a upc platform and scan device with no token and wrong.""" - aioclient_mock.get( - "http://{}/common_page/login.html".format(HOST), - cookies={"sessionToken": "654321"}, - ) - aioclient_mock.post( - "http://{}/xml/getter.xml".format(HOST), - content=b"successful", - cookies={"sessionToken": "654321"}, - ) - - scanner = await platform.async_get_scanner( - hass, {DOMAIN: {CONF_PLATFORM: "upc_connect", CONF_HOST: HOST}} - ) - - assert len(aioclient_mock.mock_calls) == 1 - - aioclient_mock.clear_requests() - aioclient_mock.get( - "http://{}/common_page/login.html".format(HOST), - cookies={"sessionToken": "654321"}, - ) - aioclient_mock.post( - "http://{}/xml/getter.xml".format(HOST), - status=400, - cookies={"sessionToken": "1235678"}, - ) - - scanner.token = None - mac_list = await scanner.async_scan_devices() - - assert len(aioclient_mock.mock_calls) == 2 - assert aioclient_mock.mock_calls[1][2] == "token=654321&fun=123" - assert mac_list == [] - - -async def test_scan_devices_parse_error(hass, aioclient_mock): - """Set up a upc platform and scan device with parse error.""" - aioclient_mock.get( - "http://{}/common_page/login.html".format(HOST), - cookies={"sessionToken": "654321"}, - ) - aioclient_mock.post( - "http://{}/xml/getter.xml".format(HOST), - content=b"successful", - cookies={"sessionToken": "654321"}, - ) - - scanner = await platform.async_get_scanner( - hass, {DOMAIN: {CONF_PLATFORM: "upc_connect", CONF_HOST: HOST}} - ) - - assert len(aioclient_mock.mock_calls) == 1 - - aioclient_mock.clear_requests() - aioclient_mock.post( - "http://{}/xml/getter.xml".format(HOST), - text="Blablebla blabalble", - cookies={"sessionToken": "1235678"}, - ) - - mac_list = await scanner.async_scan_devices() - - assert len(aioclient_mock.mock_calls) == 1 - assert aioclient_mock.mock_calls[0][2] == "token=654321&fun=123" - assert scanner.token is None - assert mac_list == [] diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 93b9a434b7f1ac..3ae9d11c3b6555 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -3,9 +3,7 @@ from asynctest import CoroutineMock, MagicMock import pytest -from homeassistant import setup, data_entry_flow -import homeassistant.components.api as api -import homeassistant.components.http as http +from homeassistant import data_entry_flow from homeassistant.components.withings import const from homeassistant.components.withings.config_flow import ( register_flow_implementation, @@ -24,27 +22,6 @@ def flow_handler_fixture(hass: HomeAssistantType): return flow_handler -@pytest.fixture(name="setup_hass") -async def setup_hass_fixture(hass: HomeAssistantType): - """Provide hass instance.""" - config = { - http.DOMAIN: {}, - api.DOMAIN: {"base_url": "http://localhost/"}, - const.DOMAIN: { - const.CLIENT_ID: "my_client_id", - const.CLIENT_SECRET: "my_secret", - const.PROFILES: ["Person 1", "Person 2"], - }, - } - - hass.data = {} - - await setup.async_setup_component(hass, "http", config) - await setup.async_setup_component(hass, "api", config) - - return hass - - def test_flow_handler_init(flow_handler: WithingsFlowHandler): """Test the init of the flow handler.""" assert not flow_handler.flow_profile @@ -173,3 +150,13 @@ async def test_auth_callback_view_get(hass: HomeAssistantType): "my_flow_id", {const.PROFILE: "my_profile", const.CODE: "my_code"} ) hass.config_entries.flow.async_configure.reset_mock() + + +async def test_init_without_config(hass): + """Try initializin a configg flow without it being configured.""" + result = await hass.config_entries.flow.async_init( + "withings", context={"source": "user"} + ) + + assert result["type"] == "abort" + assert result["reason"] == "no_flows" diff --git a/tests/components/yandex_transport/__init__.py b/tests/components/yandex_transport/__init__.py new file mode 100644 index 00000000000000..fe6b0db52d3e05 --- /dev/null +++ b/tests/components/yandex_transport/__init__.py @@ -0,0 +1 @@ +"""Tests for the yandex transport platform.""" diff --git a/tests/components/yandex_transport/test_yandex_transport_sensor.py b/tests/components/yandex_transport/test_yandex_transport_sensor.py new file mode 100644 index 00000000000000..50d945e7fae371 --- /dev/null +++ b/tests/components/yandex_transport/test_yandex_transport_sensor.py @@ -0,0 +1,88 @@ +"""Tests for the yandex transport platform.""" + +import json +import pytest + +import homeassistant.components.sensor as sensor +import homeassistant.util.dt as dt_util +from homeassistant.const import CONF_NAME +from tests.common import ( + assert_setup_component, + async_setup_component, + MockDependency, + load_fixture, +) + +REPLY = json.loads(load_fixture("yandex_transport_reply.json")) + + +@pytest.fixture +def mock_requester(): + """Create a mock ya_ma module and YandexMapsRequester.""" + with MockDependency("ya_ma") as ya_ma: + instance = ya_ma.YandexMapsRequester.return_value + instance.get_stop_info.return_value = REPLY + yield instance + + +STOP_ID = 9639579 +ROUTES = ["194", "т36", "т47", "м10"] +NAME = "test_name" +TEST_CONFIG = { + "sensor": { + "platform": "yandex_transport", + "stop_id": 9639579, + "routes": ROUTES, + "name": NAME, + } +} + +FILTERED_ATTRS = { + "т36": ["21:43", "21:47", "22:02"], + "т47": ["21:40", "22:01"], + "м10": ["21:48", "22:00"], + "stop_name": "7-й автобусный парк", + "attribution": "Data provided by maps.yandex.ru", +} + +RESULT_STATE = dt_util.utc_from_timestamp(1568659253).isoformat(timespec="seconds") + + +async def assert_setup_sensor(hass, config, count=1): + """Set up the sensor and assert it's been created.""" + with assert_setup_component(count): + assert await async_setup_component(hass, sensor.DOMAIN, config) + + +async def test_setup_platform_valid_config(hass, mock_requester): + """Test that sensor is set up properly with valid config.""" + await assert_setup_sensor(hass, TEST_CONFIG) + + +async def test_setup_platform_invalid_config(hass, mock_requester): + """Check an invalid configuration.""" + await assert_setup_sensor( + hass, {"sensor": {"platform": "yandex_transport", "stopid": 1234}}, count=0 + ) + + +async def test_name(hass, mock_requester): + """Return the name if set in the configuration.""" + await assert_setup_sensor(hass, TEST_CONFIG) + state = hass.states.get("sensor.test_name") + assert state.name == TEST_CONFIG["sensor"][CONF_NAME] + + +async def test_state(hass, mock_requester): + """Return the contents of _state.""" + await assert_setup_sensor(hass, TEST_CONFIG) + state = hass.states.get("sensor.test_name") + assert state.state == RESULT_STATE + + +async def test_filtered_attributes(hass, mock_requester): + """Return the contents of attributes.""" + await assert_setup_sensor(hass, TEST_CONFIG) + state = hass.states.get("sensor.test_name") + state_attrs = {key: state.attributes[key] for key in FILTERED_ATTRS} + assert state_attrs == FILTERED_ATTRS diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index d34c6983528515..fc29e4012cd6f8 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -50,7 +50,7 @@ def add_input_cluster(self, cluster_id): """Add an input cluster.""" from zigpy.zcl import Cluster - cluster = Cluster.from_id(self, cluster_id) + cluster = Cluster.from_id(self, cluster_id, is_server=True) patch_cluster(cluster) self.in_clusters[cluster_id] = cluster if hasattr(cluster, "ep_attribute"): @@ -60,7 +60,7 @@ def add_output_cluster(self, cluster_id): """Add an output cluster.""" from zigpy.zcl import Cluster - cluster = Cluster.from_id(self, cluster_id) + cluster = Cluster.from_id(self, cluster_id, is_server=False) patch_cluster(cluster) self.out_clusters[cluster_id] = cluster diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py index 1e5ec615088b09..dba187d7b966a0 100644 --- a/tests/components/zwave/test_node_entity.py +++ b/tests/components/zwave/test_node_entity.py @@ -164,6 +164,73 @@ def listener(event): assert events[0].data[const.ATTR_SCENE_DATA] == scene_data +async def test_application_version(hass, mock_openzwave): + """Test application version.""" + mock_receivers = {} + + signal_mocks = [ + mock_zwave.MockNetwork.SIGNAL_VALUE_CHANGED, + mock_zwave.MockNetwork.SIGNAL_VALUE_ADDED, + ] + + def mock_connect(receiver, signal, *args, **kwargs): + if signal in signal_mocks: + mock_receivers[signal] = receiver + + node = mock_zwave.MockNode(node_id=11) + + with patch("pydispatch.dispatcher.connect", new=mock_connect): + entity = node_entity.ZWaveNodeEntity(node, mock_openzwave) + + for signal_mock in signal_mocks: + assert signal_mock in mock_receivers.keys() + + events = [] + + def listener(event): + events.append(event) + + # Make sure application version isn't set before + assert ( + node_entity.ATTR_APPLICATION_VERSION + not in entity.device_state_attributes.keys() + ) + + # Add entity to hass + entity.hass = hass + entity.entity_id = "zwave.mock_node" + + # Fire off an added value + value = mock_zwave.MockValue( + command_class=const.COMMAND_CLASS_VERSION, + label="Application Version", + data="5.10", + ) + hass.async_add_job( + mock_receivers[mock_zwave.MockNetwork.SIGNAL_VALUE_ADDED], node, value + ) + await hass.async_block_till_done() + + assert ( + entity.device_state_attributes[node_entity.ATTR_APPLICATION_VERSION] == "5.10" + ) + + # Fire off a changed + value = mock_zwave.MockValue( + command_class=const.COMMAND_CLASS_VERSION, + label="Application Version", + data="4.14", + ) + hass.async_add_job( + mock_receivers[mock_zwave.MockNetwork.SIGNAL_VALUE_CHANGED], node, value + ) + await hass.async_block_till_done() + + assert ( + entity.device_state_attributes[node_entity.ATTR_APPLICATION_VERSION] == "4.14" + ) + + @pytest.mark.usefixtures("mock_openzwave") class TestZWaveNodeEntity(unittest.TestCase): """Class to test ZWaveNodeEntity.""" diff --git a/tests/fixtures/here_travel_time/attribution_response.json b/tests/fixtures/here_travel_time/attribution_response.json new file mode 100644 index 00000000000000..9b682f6c51fb14 --- /dev/null +++ b/tests/fixtures/here_travel_time/attribution_response.json @@ -0,0 +1,276 @@ +{ + "response": { + "metaInfo": { + "timestamp": "2019-09-21T15:17:31Z", + "mapVersion": "8.30.100.154", + "moduleVersion": "7.2.201937-5251", + "interfaceVersion": "2.6.70", + "availableMapVersion": [ + "8.30.100.154" + ] + }, + "route": [ + { + "waypoint": [ + { + "linkId": "+565790671", + "mappedPosition": { + "latitude": 50.0378591, + "longitude": 14.3924721 + }, + "originalPosition": { + "latitude": 50.0377513, + "longitude": 14.3923344 + }, + "type": "stopOver", + "spot": 0.3, + "sideOfStreet": "left", + "mappedRoadName": "V Bokách III", + "label": "V Bokách III", + "shapeIndex": 0, + "source": "user" + }, + { + "linkId": "+748931502", + "mappedPosition": { + "latitude": 50.0798786, + "longitude": 14.4260037 + }, + "originalPosition": { + "latitude": 50.0799383, + "longitude": 14.4258216 + }, + "type": "stopOver", + "spot": 1.0, + "sideOfStreet": "left", + "mappedRoadName": "Štěpánská", + "label": "Štěpánská", + "shapeIndex": 116, + "source": "user" + } + ], + "mode": { + "type": "shortest", + "transportModes": [ + "publicTransportTimeTable" + ], + "trafficMode": "enabled", + "feature": [] + }, + "leg": [ + { + "start": { + "linkId": "+565790671", + "mappedPosition": { + "latitude": 50.0378591, + "longitude": 14.3924721 + }, + "originalPosition": { + "latitude": 50.0377513, + "longitude": 14.3923344 + }, + "type": "stopOver", + "spot": 0.3, + "sideOfStreet": "left", + "mappedRoadName": "V Bokách III", + "label": "V Bokách III", + "shapeIndex": 0, + "source": "user" + }, + "end": { + "linkId": "+748931502", + "mappedPosition": { + "latitude": 50.0798786, + "longitude": 14.4260037 + }, + "originalPosition": { + "latitude": 50.0799383, + "longitude": 14.4258216 + }, + "type": "stopOver", + "spot": 1.0, + "sideOfStreet": "left", + "mappedRoadName": "Štěpánská", + "label": "Štěpánská", + "shapeIndex": 116, + "source": "user" + }, + "length": 7835, + "travelTime": 2413, + "maneuver": [ + { + "position": { + "latitude": 50.0378591, + "longitude": 14.3924721 + }, + "instruction": "Head northwest on Kosořská. Go for 28 m.", + "travelTime": 32, + "length": 28, + "id": "M1", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 50.0380039, + "longitude": 14.3921542 + }, + "instruction": "Turn left onto Kosořská. Go for 24 m.", + "travelTime": 24, + "length": 24, + "id": "M2", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 50.0380039, + "longitude": 14.3918109 + }, + "instruction": "Take the street on the left, Slivenecká. Go for 343 m.", + "travelTime": 354, + "length": 343, + "id": "M3", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 50.0376499, + "longitude": 14.3871975 + }, + "instruction": "Turn left onto Slivenecká. Go for 64 m.", + "travelTime": 72, + "length": 64, + "id": "M4", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 50.0373602, + "longitude": 14.3879807 + }, + "instruction": "Turn right onto Slivenecká. Go for 91 m.", + "travelTime": 95, + "length": 91, + "id": "M5", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 50.0365448, + "longitude": 14.3878305 + }, + "instruction": "Turn left onto K Barrandovu. Go for 124 m.", + "travelTime": 126, + "length": 124, + "id": "M6", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 50.0363168, + "longitude": 14.3894618 + }, + "instruction": "Go to the Tram station Geologicka and take the rail 5 toward Ústřední dílny DP. Follow for 13 stations.", + "travelTime": 1440, + "length": 6911, + "id": "M7", + "stopName": "Geologicka", + "_type": "PublicTransportManeuverType" + }, + { + "position": { + "latitude": 50.0800508, + "longitude": 14.423403 + }, + "instruction": "Get off at Vodickova.", + "travelTime": 0, + "length": 0, + "id": "M8", + "stopName": "Vodickova", + "nextRoadName": "Vodičkova", + "_type": "PublicTransportManeuverType" + }, + { + "position": { + "latitude": 50.0800508, + "longitude": 14.423403 + }, + "instruction": "Head northeast on Vodičkova. Go for 65 m.", + "travelTime": 74, + "length": 65, + "id": "M9", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 50.0804901, + "longitude": 14.4239759 + }, + "instruction": "Turn right onto V Jámě. Go for 163 m.", + "travelTime": 174, + "length": 163, + "id": "M10", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 50.0796962, + "longitude": 14.4258857 + }, + "instruction": "Turn left onto Štěpánská. Go for 22 m.", + "travelTime": 22, + "length": 22, + "id": "M11", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 50.0798786, + "longitude": 14.4260037 + }, + "instruction": "Arrive at Štěpánská. Your destination is on the left.", + "travelTime": 0, + "length": 0, + "id": "M12", + "_type": "PrivateTransportManeuverType" + } + ] + } + ], + "publicTransportLine": [ + { + "lineName": "5", + "lineForeground": "#F5ADCE", + "lineBackground": "#F5ADCE", + "companyName": "HERE Technologies", + "destination": "Ústřední dílny DP", + "type": "railLight", + "id": "L1" + } + ], + "summary": { + "distance": 7835, + "baseTime": 2413, + "flags": [ + "noThroughRoad", + "builtUpArea" + ], + "text": "The trip takes 7.8 km and 40 mins.", + "travelTime": 2413, + "departure": "2019-09-21T17:16:17+02:00", + "timetableExpiration": "2019-09-21T00:00:00Z", + "_type": "PublicTransportRouteSummaryType" + } + } + ], + "language": "en-us", + "sourceAttribution": { + "attribution": "With the support of HERE Technologies. All information is provided without warranty of any kind.", + "supplier": [ + { + "title": "HERE Technologies", + "href": "https://transit.api.here.com/r?appId=Mt1bOYh3m9uxE7r3wuUx&u=https://wego.here.com" + } + ] + } + } +} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/bike_response.json b/tests/fixtures/here_travel_time/bike_response.json new file mode 100644 index 00000000000000..a3af39129d01fe --- /dev/null +++ b/tests/fixtures/here_travel_time/bike_response.json @@ -0,0 +1,274 @@ +{ + "response": { + "metaInfo": { + "timestamp": "2019-07-24T10:17:40Z", + "mapVersion": "8.30.98.154", + "moduleVersion": "7.2.201929-4522", + "interfaceVersion": "2.6.64", + "availableMapVersion": [ + "8.30.98.154" + ] + }, + "route": [ + { + "waypoint": [ + { + "linkId": "-1230414527", + "mappedPosition": { + "latitude": 41.9797859, + "longitude": -87.8790879 + }, + "originalPosition": { + "latitude": 41.9798, + "longitude": -87.8801 + }, + "type": "stopOver", + "spot": 0.5079365, + "sideOfStreet": "right", + "mappedRoadName": "Mannheim Rd", + "label": "Mannheim Rd - US-12", + "shapeIndex": 0, + "source": "user" + }, + { + "linkId": "+924115108", + "mappedPosition": { + "latitude": 41.90413, + "longitude": -87.9223502 + }, + "originalPosition": { + "latitude": 41.9043, + "longitude": -87.9216001 + }, + "type": "stopOver", + "spot": 0.1925926, + "sideOfStreet": "right", + "mappedRoadName": "", + "label": "", + "shapeIndex": 87, + "source": "user" + } + ], + "mode": { + "type": "fastest", + "transportModes": [ + "bicycle" + ], + "trafficMode": "enabled", + "feature": [] + }, + "leg": [ + { + "start": { + "linkId": "-1230414527", + "mappedPosition": { + "latitude": 41.9797859, + "longitude": -87.8790879 + }, + "originalPosition": { + "latitude": 41.9798, + "longitude": -87.8801 + }, + "type": "stopOver", + "spot": 0.5079365, + "sideOfStreet": "right", + "mappedRoadName": "Mannheim Rd", + "label": "Mannheim Rd - US-12", + "shapeIndex": 0, + "source": "user" + }, + "end": { + "linkId": "+924115108", + "mappedPosition": { + "latitude": 41.90413, + "longitude": -87.9223502 + }, + "originalPosition": { + "latitude": 41.9043, + "longitude": -87.9216001 + }, + "type": "stopOver", + "spot": 0.1925926, + "sideOfStreet": "right", + "mappedRoadName": "", + "label": "", + "shapeIndex": 87, + "source": "user" + }, + "length": 12613, + "travelTime": 3292, + "maneuver": [ + { + "position": { + "latitude": 41.9797859, + "longitude": -87.8790879 + }, + "instruction": "Head south on Mannheim Rd (US-12/US-45). Go for 2.6 km.", + "travelTime": 646, + "length": 2648, + "id": "M1", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9579244, + "longitude": -87.8838551 + }, + "instruction": "Keep left onto Mannheim Rd (US-12/US-45). Go for 2.4 km.", + "travelTime": 621, + "length": 2427, + "id": "M2", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9364238, + "longitude": -87.8849387 + }, + "instruction": "Turn right onto W Belmont Ave. Go for 595 m.", + "travelTime": 158, + "length": 595, + "id": "M3", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9362521, + "longitude": -87.8921163 + }, + "instruction": "Turn left onto Cullerton St. Go for 669 m.", + "travelTime": 180, + "length": 669, + "id": "M4", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9305658, + "longitude": -87.8932428 + }, + "instruction": "Continue on N Landen Dr. Go for 976 m.", + "travelTime": 246, + "length": 976, + "id": "M5", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9217896, + "longitude": -87.8928781 + }, + "instruction": "Turn right onto E Fullerton Ave. Go for 904 m.", + "travelTime": 238, + "length": 904, + "id": "M6", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.921618, + "longitude": -87.9038107 + }, + "instruction": "Turn left onto N Wolf Rd. Go for 1.6 km.", + "travelTime": 417, + "length": 1604, + "id": "M7", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.907177, + "longitude": -87.9032314 + }, + "instruction": "Turn right onto W North Ave (IL-64). Go for 2.0 km.", + "travelTime": 574, + "length": 2031, + "id": "M8", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9065225, + "longitude": -87.9277039 + }, + "instruction": "Turn left onto N Clinton Ave. Go for 275 m.", + "travelTime": 78, + "length": 275, + "id": "M9", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9040549, + "longitude": -87.9277253 + }, + "instruction": "Turn left onto E Third St. Go for 249 m.", + "travelTime": 63, + "length": 249, + "id": "M10", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9040334, + "longitude": -87.9247105 + }, + "instruction": "Continue on N Caroline Ave. Go for 96 m.", + "travelTime": 37, + "length": 96, + "id": "M11", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9038832, + "longitude": -87.9236054 + }, + "instruction": "Turn slightly left. Go for 113 m.", + "travelTime": 28, + "length": 113, + "id": "M12", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9039047, + "longitude": -87.9222536 + }, + "instruction": "Turn left. Go for 26 m.", + "travelTime": 6, + "length": 26, + "id": "M13", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.90413, + "longitude": -87.9223502 + }, + "instruction": "Arrive at your destination on the right.", + "travelTime": 0, + "length": 0, + "id": "M14", + "_type": "PrivateTransportManeuverType" + } + ] + } + ], + "summary": { + "distance": 12613, + "baseTime": 3292, + "flags": [ + "noThroughRoad", + "builtUpArea", + "park" + ], + "text": "The trip takes 12.6 km and 55 mins.", + "travelTime": 3292, + "_type": "RouteSummaryType" + } + } + ], + "language": "en-us" + } +} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/car_enabled_response.json b/tests/fixtures/here_travel_time/car_enabled_response.json new file mode 100644 index 00000000000000..08da738f0464a9 --- /dev/null +++ b/tests/fixtures/here_travel_time/car_enabled_response.json @@ -0,0 +1,298 @@ +{ + "response": { + "metaInfo": { + "timestamp": "2019-07-21T21:21:31Z", + "mapVersion": "8.30.98.154", + "moduleVersion": "7.2.201928-4478", + "interfaceVersion": "2.6.64", + "availableMapVersion": [ + "8.30.98.154" + ] + }, + "route": [ + { + "waypoint": [ + { + "linkId": "-1128310200", + "mappedPosition": { + "latitude": 38.9026523, + "longitude": -77.048338 + }, + "originalPosition": { + "latitude": 38.9029809, + "longitude": -77.048338 + }, + "type": "stopOver", + "spot": 0.3538462, + "sideOfStreet": "right", + "mappedRoadName": "K St NW", + "label": "K St NW", + "shapeIndex": 0, + "source": "user" + }, + { + "linkId": "-18459081", + "mappedPosition": { + "latitude": 39.0422511, + "longitude": -77.1193526 + }, + "originalPosition": { + "latitude": 39.042158, + "longitude": -77.119116 + }, + "type": "stopOver", + "spot": 0.7253521, + "sideOfStreet": "left", + "mappedRoadName": "Commonwealth Dr", + "label": "Commonwealth Dr", + "shapeIndex": 283, + "source": "user" + } + ], + "mode": { + "type": "fastest", + "transportModes": [ + "car" + ], + "trafficMode": "enabled", + "feature": [] + }, + "leg": [ + { + "start": { + "linkId": "-1128310200", + "mappedPosition": { + "latitude": 38.9026523, + "longitude": -77.048338 + }, + "originalPosition": { + "latitude": 38.9029809, + "longitude": -77.048338 + }, + "type": "stopOver", + "spot": 0.3538462, + "sideOfStreet": "right", + "mappedRoadName": "K St NW", + "label": "K St NW", + "shapeIndex": 0, + "source": "user" + }, + "end": { + "linkId": "-18459081", + "mappedPosition": { + "latitude": 39.0422511, + "longitude": -77.1193526 + }, + "originalPosition": { + "latitude": 39.042158, + "longitude": -77.119116 + }, + "type": "stopOver", + "spot": 0.7253521, + "sideOfStreet": "left", + "mappedRoadName": "Commonwealth Dr", + "label": "Commonwealth Dr", + "shapeIndex": 283, + "source": "user" + }, + "length": 23381, + "travelTime": 1817, + "maneuver": [ + { + "position": { + "latitude": 38.9026523, + "longitude": -77.048338 + }, + "instruction": "Head toward 22nd St NW on K St NW. Go for 140 m.", + "travelTime": 36, + "length": 140, + "id": "M1", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9027703, + "longitude": -77.0494902 + }, + "instruction": "Take the 3rd exit from Washington Cir NW roundabout onto K St NW. Go for 325 m.", + "travelTime": 81, + "length": 325, + "id": "M2", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9026523, + "longitude": -77.0529449 + }, + "instruction": "Keep left onto K St NW (US-29). Go for 201 m.", + "travelTime": 29, + "length": 201, + "id": "M3", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9025235, + "longitude": -77.0552516 + }, + "instruction": "Keep right onto Whitehurst Fwy (US-29). Go for 1.4 km.", + "travelTime": 143, + "length": 1381, + "id": "M4", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9050448, + "longitude": -77.0701969 + }, + "instruction": "Turn left onto M St NW. Go for 784 m.", + "travelTime": 80, + "length": 784, + "id": "M5", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9060318, + "longitude": -77.0790696 + }, + "instruction": "Turn slightly left onto Canal Rd NW. Go for 4.2 km.", + "travelTime": 287, + "length": 4230, + "id": "M6", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9303219, + "longitude": -77.1117926 + }, + "instruction": "Continue on Clara Barton Pkwy. Go for 844 m.", + "travelTime": 55, + "length": 844, + "id": "M7", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9368558, + "longitude": -77.1166742 + }, + "instruction": "Continue on Clara Barton Pkwy. Go for 4.7 km.", + "travelTime": 294, + "length": 4652, + "id": "M8", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9706838, + "longitude": -77.1461463 + }, + "instruction": "Keep right onto Cabin John Pkwy N toward I-495 N. Go for 2.1 km.", + "travelTime": 90, + "length": 2069, + "id": "M9", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9858222, + "longitude": -77.1571326 + }, + "instruction": "Take left ramp onto I-495 N (Capital Beltway). Go for 2.9 km.", + "travelTime": 129, + "length": 2890, + "id": "M10", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 39.0104449, + "longitude": -77.1508026 + }, + "instruction": "Keep left onto I-270-SPUR toward I-270/Rockville/Frederick. Go for 1.1 km.", + "travelTime": 48, + "length": 1136, + "id": "M11", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 39.0192747, + "longitude": -77.144773 + }, + "instruction": "Take exit 1 toward Democracy Blvd/Old Georgetown Rd/MD-187 onto Democracy Blvd. Go for 1.8 km.", + "travelTime": 205, + "length": 1818, + "id": "M12", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 39.0247464, + "longitude": -77.1253431 + }, + "instruction": "Turn left onto Old Georgetown Rd (MD-187). Go for 2.3 km.", + "travelTime": 230, + "length": 2340, + "id": "M13", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 39.0447772, + "longitude": -77.1203649 + }, + "instruction": "Turn right onto Nicholson Ln. Go for 208 m.", + "travelTime": 31, + "length": 208, + "id": "M14", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 39.0448952, + "longitude": -77.1179724 + }, + "instruction": "Turn right onto Commonwealth Dr. Go for 341 m.", + "travelTime": 75, + "length": 341, + "id": "M15", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 39.0422511, + "longitude": -77.1193526 + }, + "instruction": "Arrive at Commonwealth Dr. Your destination is on the left.", + "travelTime": 4, + "length": 22, + "id": "M16", + "_type": "PrivateTransportManeuverType" + } + ] + } + ], + "summary": { + "distance": 23381, + "trafficTime": 1782, + "baseTime": 1712, + "flags": [ + "noThroughRoad", + "motorway", + "builtUpArea", + "park" + ], + "text": "The trip takes 23.4 km and 30 mins.", + "travelTime": 1782, + "_type": "RouteSummaryType" + } + } + ], + "language": "en-us" + } +} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/car_response.json b/tests/fixtures/here_travel_time/car_response.json new file mode 100644 index 00000000000000..bda8454f3f3780 --- /dev/null +++ b/tests/fixtures/here_travel_time/car_response.json @@ -0,0 +1,299 @@ +{ + "response": { + "metaInfo": { + "timestamp": "2019-07-19T07:38:39Z", + "mapVersion": "8.30.98.154", + "moduleVersion": "7.2.201928-4446", + "interfaceVersion": "2.6.64", + "availableMapVersion": [ + "8.30.98.154" + ] + }, + "route": [ + { + "waypoint": [ + { + "linkId": "+732182239", + "mappedPosition": { + "latitude": 38.9, + "longitude": -77.0488358 + }, + "originalPosition": { + "latitude": 38.9, + "longitude": -77.0483301 + }, + "type": "stopOver", + "spot": 0.4946237, + "sideOfStreet": "right", + "mappedRoadName": "22nd St NW", + "label": "22nd St NW", + "shapeIndex": 0, + "source": "user" + }, + { + "linkId": "+942865877", + "mappedPosition": { + "latitude": 38.9999735, + "longitude": -77.100141 + }, + "originalPosition": { + "latitude": 38.9999999, + "longitude": -77.1000001 + }, + "type": "stopOver", + "spot": 1, + "sideOfStreet": "left", + "mappedRoadName": "Service Rd S", + "label": "Service Rd S", + "shapeIndex": 279, + "source": "user" + } + ], + "mode": { + "type": "fastest", + "transportModes": [ + "car" + ], + "trafficMode": "enabled", + "feature": [] + }, + "leg": [ + { + "start": { + "linkId": "+732182239", + "mappedPosition": { + "latitude": 38.9, + "longitude": -77.0488358 + }, + "originalPosition": { + "latitude": 38.9, + "longitude": -77.0483301 + }, + "type": "stopOver", + "spot": 0.4946237, + "sideOfStreet": "right", + "mappedRoadName": "22nd St NW", + "label": "22nd St NW", + "shapeIndex": 0, + "source": "user" + }, + "end": { + "linkId": "+942865877", + "mappedPosition": { + "latitude": 38.9999735, + "longitude": -77.100141 + }, + "originalPosition": { + "latitude": 38.9999999, + "longitude": -77.1000001 + }, + "type": "stopOver", + "spot": 1, + "sideOfStreet": "left", + "mappedRoadName": "Service Rd S", + "label": "Service Rd S", + "shapeIndex": 279, + "source": "user" + }, + "length": 23903, + "travelTime": 1884, + "maneuver": [ + { + "position": { + "latitude": 38.9, + "longitude": -77.0488358 + }, + "instruction": "Head toward I St NW on 22nd St NW. Go for 279 m.", + "travelTime": 95, + "length": 279, + "id": "M1", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9021051, + "longitude": -77.048825 + }, + "instruction": "Turn left toward Pennsylvania Ave NW. Go for 71 m.", + "travelTime": 21, + "length": 71, + "id": "M2", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.902545, + "longitude": -77.0494151 + }, + "instruction": "Take the 3rd exit from Washington Cir NW roundabout onto K St NW. Go for 352 m.", + "travelTime": 90, + "length": 352, + "id": "M3", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9026523, + "longitude": -77.0529449 + }, + "instruction": "Keep left onto K St NW (US-29). Go for 201 m.", + "travelTime": 30, + "length": 201, + "id": "M4", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9025235, + "longitude": -77.0552516 + }, + "instruction": "Keep right onto Whitehurst Fwy (US-29). Go for 1.4 km.", + "travelTime": 131, + "length": 1381, + "id": "M5", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9050448, + "longitude": -77.0701969 + }, + "instruction": "Turn left onto M St NW. Go for 784 m.", + "travelTime": 78, + "length": 784, + "id": "M6", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9060318, + "longitude": -77.0790696 + }, + "instruction": "Turn slightly left onto Canal Rd NW. Go for 4.2 km.", + "travelTime": 277, + "length": 4230, + "id": "M7", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9303219, + "longitude": -77.1117926 + }, + "instruction": "Continue on Clara Barton Pkwy. Go for 844 m.", + "travelTime": 55, + "length": 844, + "id": "M8", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9368558, + "longitude": -77.1166742 + }, + "instruction": "Continue on Clara Barton Pkwy. Go for 4.7 km.", + "travelTime": 298, + "length": 4652, + "id": "M9", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9706838, + "longitude": -77.1461463 + }, + "instruction": "Keep right onto Cabin John Pkwy N toward I-495 N. Go for 2.1 km.", + "travelTime": 91, + "length": 2069, + "id": "M10", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9858222, + "longitude": -77.1571326 + }, + "instruction": "Take left ramp onto I-495 N (Capital Beltway). Go for 5.5 km.", + "travelTime": 238, + "length": 5538, + "id": "M11", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 39.0153587, + "longitude": -77.1221781 + }, + "instruction": "Take exit 36 toward Bethesda onto MD-187 S (Old Georgetown Rd). Go for 2.4 km.", + "travelTime": 211, + "length": 2365, + "id": "M12", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9981818, + "longitude": -77.1093571 + }, + "instruction": "Turn left onto Lincoln Dr. Go for 506 m.", + "travelTime": 127, + "length": 506, + "id": "M13", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9987397, + "longitude": -77.1037138 + }, + "instruction": "Turn right onto Service Rd W. Go for 121 m.", + "travelTime": 36, + "length": 121, + "id": "M14", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9976454, + "longitude": -77.1036172 + }, + "instruction": "Turn left onto Service Rd S. Go for 510 m.", + "travelTime": 106, + "length": 510, + "id": "M15", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9999735, + "longitude": -77.100141 + }, + "instruction": "Arrive at Service Rd S. Your destination is on the left.", + "travelTime": 0, + "length": 0, + "id": "M16", + "_type": "PrivateTransportManeuverType" + } + ] + } + ], + "summary": { + "distance": 23903, + "trafficTime": 1861, + "baseTime": 1803, + "flags": [ + "noThroughRoad", + "motorway", + "builtUpArea", + "park", + "privateRoad" + ], + "text": "The trip takes 23.9 km and 31 mins.", + "travelTime": 1861, + "_type": "RouteSummaryType" + } + } + ], + "language": "en-us" + } +} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/car_shortest_response.json b/tests/fixtures/here_travel_time/car_shortest_response.json new file mode 100644 index 00000000000000..765c438c1cd14e --- /dev/null +++ b/tests/fixtures/here_travel_time/car_shortest_response.json @@ -0,0 +1,231 @@ +{ + "response": { + "metaInfo": { + "timestamp": "2019-07-21T21:05:28Z", + "mapVersion": "8.30.98.154", + "moduleVersion": "7.2.201928-4478", + "interfaceVersion": "2.6.64", + "availableMapVersion": [ + "8.30.98.154" + ] + }, + "route": [ + { + "waypoint": [ + { + "linkId": "-1128310200", + "mappedPosition": { + "latitude": 38.9026523, + "longitude": -77.048338 + }, + "originalPosition": { + "latitude": 38.9029809, + "longitude": -77.048338 + }, + "type": "stopOver", + "spot": 0.3538462, + "sideOfStreet": "right", + "mappedRoadName": "K St NW", + "label": "K St NW", + "shapeIndex": 0, + "source": "user" + }, + { + "linkId": "-18459081", + "mappedPosition": { + "latitude": 39.0422511, + "longitude": -77.1193526 + }, + "originalPosition": { + "latitude": 39.042158, + "longitude": -77.119116 + }, + "type": "stopOver", + "spot": 0.7253521, + "sideOfStreet": "left", + "mappedRoadName": "Commonwealth Dr", + "label": "Commonwealth Dr", + "shapeIndex": 162, + "source": "user" + } + ], + "mode": { + "type": "shortest", + "transportModes": [ + "car" + ], + "trafficMode": "enabled", + "feature": [] + }, + "leg": [ + { + "start": { + "linkId": "-1128310200", + "mappedPosition": { + "latitude": 38.9026523, + "longitude": -77.048338 + }, + "originalPosition": { + "latitude": 38.9029809, + "longitude": -77.048338 + }, + "type": "stopOver", + "spot": 0.3538462, + "sideOfStreet": "right", + "mappedRoadName": "K St NW", + "label": "K St NW", + "shapeIndex": 0, + "source": "user" + }, + "end": { + "linkId": "-18459081", + "mappedPosition": { + "latitude": 39.0422511, + "longitude": -77.1193526 + }, + "originalPosition": { + "latitude": 39.042158, + "longitude": -77.119116 + }, + "type": "stopOver", + "spot": 0.7253521, + "sideOfStreet": "left", + "mappedRoadName": "Commonwealth Dr", + "label": "Commonwealth Dr", + "shapeIndex": 162, + "source": "user" + }, + "length": 18388, + "travelTime": 2493, + "maneuver": [ + { + "position": { + "latitude": 38.9026523, + "longitude": -77.048338 + }, + "instruction": "Head west on K St NW. Go for 79 m.", + "travelTime": 22, + "length": 79, + "id": "M1", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9026523, + "longitude": -77.048825 + }, + "instruction": "Turn right onto 22nd St NW. Go for 141 m.", + "travelTime": 79, + "length": 141, + "id": "M2", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9039075, + "longitude": -77.048825 + }, + "instruction": "Keep left onto 22nd St NW. Go for 841 m.", + "travelTime": 256, + "length": 841, + "id": "M3", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9114928, + "longitude": -77.0487821 + }, + "instruction": "Turn left onto Massachusetts Ave NW. Go for 145 m.", + "travelTime": 22, + "length": 145, + "id": "M4", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9120293, + "longitude": -77.0502949 + }, + "instruction": "Take the 1st exit from Massachusetts Ave NW roundabout onto Massachusetts Ave NW. Go for 2.8 km.", + "travelTime": 301, + "length": 2773, + "id": "M5", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9286053, + "longitude": -77.073158 + }, + "instruction": "Turn right onto Wisconsin Ave NW. Go for 3.8 km.", + "travelTime": 610, + "length": 3801, + "id": "M6", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 38.9607918, + "longitude": -77.0857322 + }, + "instruction": "Continue on Wisconsin Ave (MD-355). Go for 9.7 km.", + "travelTime": 1013, + "length": 9686, + "id": "M7", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 39.0447664, + "longitude": -77.1116638 + }, + "instruction": "Turn left onto Nicholson Ln. Go for 559 m.", + "travelTime": 111, + "length": 559, + "id": "M8", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 39.0448952, + "longitude": -77.1179724 + }, + "instruction": "Turn left onto Commonwealth Dr. Go for 341 m.", + "travelTime": 75, + "length": 341, + "id": "M9", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 39.0422511, + "longitude": -77.1193526 + }, + "instruction": "Arrive at Commonwealth Dr. Your destination is on the left.", + "travelTime": 4, + "length": 22, + "id": "M10", + "_type": "PrivateTransportManeuverType" + } + ] + } + ], + "summary": { + "distance": 18388, + "trafficTime": 2427, + "baseTime": 2150, + "flags": [ + "noThroughRoad", + "builtUpArea", + "park" + ], + "text": "The trip takes 18.4 km and 40 mins.", + "travelTime": 2427, + "_type": "RouteSummaryType" + } + } + ], + "language": "en-us" + } +} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/pedestrian_response.json b/tests/fixtures/here_travel_time/pedestrian_response.json new file mode 100644 index 00000000000000..07881e8bd3d06b --- /dev/null +++ b/tests/fixtures/here_travel_time/pedestrian_response.json @@ -0,0 +1,308 @@ +{ + "response": { + "metaInfo": { + "timestamp": "2019-07-21T18:40:10Z", + "mapVersion": "8.30.98.154", + "moduleVersion": "7.2.201928-4478", + "interfaceVersion": "2.6.64", + "availableMapVersion": [ + "8.30.98.154" + ] + }, + "route": [ + { + "waypoint": [ + { + "linkId": "-1230414527", + "mappedPosition": { + "latitude": 41.9797859, + "longitude": -87.8790879 + }, + "originalPosition": { + "latitude": 41.9798, + "longitude": -87.8801 + }, + "type": "stopOver", + "spot": 0.5079365, + "sideOfStreet": "right", + "mappedRoadName": "Mannheim Rd", + "label": "Mannheim Rd - US-12", + "shapeIndex": 0, + "source": "user" + }, + { + "linkId": "+924115108", + "mappedPosition": { + "latitude": 41.90413, + "longitude": -87.9223502 + }, + "originalPosition": { + "latitude": 41.9043, + "longitude": -87.9216001 + }, + "type": "stopOver", + "spot": 0.1925926, + "sideOfStreet": "right", + "mappedRoadName": "", + "label": "", + "shapeIndex": 122, + "source": "user" + } + ], + "mode": { + "type": "fastest", + "transportModes": [ + "pedestrian" + ], + "trafficMode": "disabled", + "feature": [] + }, + "leg": [ + { + "start": { + "linkId": "-1230414527", + "mappedPosition": { + "latitude": 41.9797859, + "longitude": -87.8790879 + }, + "originalPosition": { + "latitude": 41.9798, + "longitude": -87.8801 + }, + "type": "stopOver", + "spot": 0.5079365, + "sideOfStreet": "right", + "mappedRoadName": "Mannheim Rd", + "label": "Mannheim Rd - US-12", + "shapeIndex": 0, + "source": "user" + }, + "end": { + "linkId": "+924115108", + "mappedPosition": { + "latitude": 41.90413, + "longitude": -87.9223502 + }, + "originalPosition": { + "latitude": 41.9043, + "longitude": -87.9216001 + }, + "type": "stopOver", + "spot": 0.1925926, + "sideOfStreet": "right", + "mappedRoadName": "", + "label": "", + "shapeIndex": 122, + "source": "user" + }, + "length": 12533, + "travelTime": 12631, + "maneuver": [ + { + "position": { + "latitude": 41.9797859, + "longitude": -87.8790879 + }, + "instruction": "Head south on Mannheim Rd. Go for 848 m.", + "travelTime": 848, + "length": 848, + "id": "M1", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9722581, + "longitude": -87.8776109 + }, + "instruction": "Take the street on the left, Mannheim Rd. Go for 4.2 km.", + "travelTime": 4239, + "length": 4227, + "id": "M2", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9364238, + "longitude": -87.8849387 + }, + "instruction": "Turn right onto W Belmont Ave. Go for 595 m.", + "travelTime": 605, + "length": 595, + "id": "M3", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9362521, + "longitude": -87.8921163 + }, + "instruction": "Turn left onto Cullerton St. Go for 406 m.", + "travelTime": 411, + "length": 406, + "id": "M4", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9326043, + "longitude": -87.8919983 + }, + "instruction": "Turn right onto Cullerton St. Go for 1.2 km.", + "travelTime": 1249, + "length": 1239, + "id": "M5", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9217896, + "longitude": -87.8928781 + }, + "instruction": "Turn right onto E Fullerton Ave. Go for 786 m.", + "travelTime": 796, + "length": 786, + "id": "M6", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9216394, + "longitude": -87.9023838 + }, + "instruction": "Turn left onto La Porte Ave. Go for 424 m.", + "travelTime": 430, + "length": 424, + "id": "M7", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9180024, + "longitude": -87.9028559 + }, + "instruction": "Turn right onto E Palmer Ave. Go for 864 m.", + "travelTime": 875, + "length": 864, + "id": "M8", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9175196, + "longitude": -87.9132199 + }, + "instruction": "Turn left onto N Railroad Ave. Go for 1.2 km.", + "travelTime": 1180, + "length": 1170, + "id": "M9", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9070268, + "longitude": -87.9130161 + }, + "instruction": "Turn right onto W North Ave. Go for 638 m.", + "travelTime": 638, + "length": 638, + "id": "M10", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9068551, + "longitude": -87.9207087 + }, + "instruction": "Take the street on the left, E North Ave. Go for 354 m.", + "travelTime": 354, + "length": 354, + "id": "M11", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9065869, + "longitude": -87.9249573 + }, + "instruction": "Take the street on the left, E North Ave. Go for 228 m.", + "travelTime": 242, + "length": 228, + "id": "M12", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9065225, + "longitude": -87.9277039 + }, + "instruction": "Turn left. Go for 409 m.", + "travelTime": 419, + "length": 409, + "id": "M13", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9040334, + "longitude": -87.9260409 + }, + "instruction": "Turn left onto E Third St. Go for 206 m.", + "travelTime": 206, + "length": 206, + "id": "M14", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9038832, + "longitude": -87.9236054 + }, + "instruction": "Turn left. Go for 113 m.", + "travelTime": 113, + "length": 113, + "id": "M15", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9039047, + "longitude": -87.9222536 + }, + "instruction": "Turn left. Go for 26 m.", + "travelTime": 26, + "length": 26, + "id": "M16", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.90413, + "longitude": -87.9223502 + }, + "instruction": "Arrive at your destination on the right.", + "travelTime": 0, + "length": 0, + "id": "M17", + "_type": "PrivateTransportManeuverType" + } + ] + } + ], + "summary": { + "distance": 12533, + "baseTime": 12631, + "flags": [ + "noThroughRoad", + "builtUpArea", + "park", + "privateRoad" + ], + "text": "The trip takes 12.5 km and 3:31 h.", + "travelTime": 12631, + "_type": "RouteSummaryType" + } + } + ], + "language": "en-us" + } +} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/public_response.json b/tests/fixtures/here_travel_time/public_response.json new file mode 100644 index 00000000000000..149b4d06c3975e --- /dev/null +++ b/tests/fixtures/here_travel_time/public_response.json @@ -0,0 +1,294 @@ +{ + "response": { + "metaInfo": { + "timestamp": "2019-07-21T18:40:37Z", + "mapVersion": "8.30.98.154", + "moduleVersion": "7.2.201928-4478", + "interfaceVersion": "2.6.64", + "availableMapVersion": [ + "8.30.98.154" + ] + }, + "route": [ + { + "waypoint": [ + { + "linkId": "-1230414527", + "mappedPosition": { + "latitude": 41.9797859, + "longitude": -87.8790879 + }, + "originalPosition": { + "latitude": 41.9798, + "longitude": -87.8801 + }, + "type": "stopOver", + "spot": 0.5079365, + "sideOfStreet": "right", + "mappedRoadName": "Mannheim Rd", + "label": "Mannheim Rd - US-12", + "shapeIndex": 0, + "source": "user" + }, + { + "linkId": "+924115108", + "mappedPosition": { + "latitude": 41.90413, + "longitude": -87.9223502 + }, + "originalPosition": { + "latitude": 41.9043, + "longitude": -87.9216001 + }, + "type": "stopOver", + "spot": 0.1925926, + "sideOfStreet": "right", + "mappedRoadName": "", + "label": "", + "shapeIndex": 191, + "source": "user" + } + ], + "mode": { + "type": "fastest", + "transportModes": [ + "publicTransport" + ], + "trafficMode": "disabled", + "feature": [] + }, + "leg": [ + { + "start": { + "linkId": "-1230414527", + "mappedPosition": { + "latitude": 41.9797859, + "longitude": -87.8790879 + }, + "originalPosition": { + "latitude": 41.9798, + "longitude": -87.8801 + }, + "type": "stopOver", + "spot": 0.5079365, + "sideOfStreet": "right", + "mappedRoadName": "Mannheim Rd", + "label": "Mannheim Rd - US-12", + "shapeIndex": 0, + "source": "user" + }, + "end": { + "linkId": "+924115108", + "mappedPosition": { + "latitude": 41.90413, + "longitude": -87.9223502 + }, + "originalPosition": { + "latitude": 41.9043, + "longitude": -87.9216001 + }, + "type": "stopOver", + "spot": 0.1925926, + "sideOfStreet": "right", + "mappedRoadName": "", + "label": "", + "shapeIndex": 191, + "source": "user" + }, + "length": 22325, + "travelTime": 5350, + "maneuver": [ + { + "position": { + "latitude": 41.9797859, + "longitude": -87.8790879 + }, + "instruction": "Head south on Mannheim Rd. Go for 848 m.", + "travelTime": 848, + "length": 848, + "id": "M1", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9722581, + "longitude": -87.8776109 + }, + "instruction": "Take the street on the left, Mannheim Rd. Go for 825 m.", + "travelTime": 825, + "length": 825, + "id": "M2", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9650483, + "longitude": -87.8769565 + }, + "instruction": "Go to the stop Mannheim/Lawrence and take the bus 332 toward Palmer/Schiller. Follow for 7 stops.", + "travelTime": 475, + "length": 4360, + "id": "M3", + "stopName": "Mannheim/Lawrence", + "_type": "PublicTransportManeuverType" + }, + { + "position": { + "latitude": 41.9541478, + "longitude": -87.9133594 + }, + "instruction": "Get off at Irving Park/Taft.", + "travelTime": 0, + "length": 0, + "id": "M4", + "stopName": "Irving Park/Taft", + "_type": "PublicTransportManeuverType" + }, + { + "position": { + "latitude": 41.9541478, + "longitude": -87.9133594 + }, + "instruction": "Take the bus 332 toward Cargo Rd./Delta Cargo. Follow for 1 stop.", + "travelTime": 155, + "length": 3505, + "id": "M5", + "stopName": "Irving Park/Taft", + "_type": "PublicTransportManeuverType" + }, + { + "position": { + "latitude": 41.9599199, + "longitude": -87.9162776 + }, + "instruction": "Get off at Cargo Rd./S. Access Rd./Lufthansa.", + "travelTime": 0, + "length": 0, + "id": "M6", + "stopName": "Cargo Rd./S. Access Rd./Lufthansa", + "_type": "PublicTransportManeuverType" + }, + { + "position": { + "latitude": 41.9599199, + "longitude": -87.9162776 + }, + "instruction": "Take the bus 332 toward Palmer/Schiller. Follow for 41 stops.", + "travelTime": 1510, + "length": 11261, + "id": "M7", + "stopName": "Cargo Rd./S. Access Rd./Lufthansa", + "_type": "PublicTransportManeuverType" + }, + { + "position": { + "latitude": 41.9041729, + "longitude": -87.9399669 + }, + "instruction": "Get off at York/Third.", + "travelTime": 0, + "length": 0, + "id": "M8", + "stopName": "York/Third", + "nextRoadName": "N York St", + "_type": "PublicTransportManeuverType" + }, + { + "position": { + "latitude": 41.9041729, + "longitude": -87.9399669 + }, + "instruction": "Head east on N York St. Go for 33 m.", + "travelTime": 43, + "length": 33, + "id": "M9", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9039476, + "longitude": -87.9398811 + }, + "instruction": "Turn left onto E Third St. Go for 1.4 km.", + "travelTime": 1355, + "length": 1354, + "id": "M10", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9038832, + "longitude": -87.9236054 + }, + "instruction": "Turn left. Go for 113 m.", + "travelTime": 113, + "length": 113, + "id": "M11", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9039047, + "longitude": -87.9222536 + }, + "instruction": "Turn left. Go for 26 m.", + "travelTime": 26, + "length": 26, + "id": "M12", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.90413, + "longitude": -87.9223502 + }, + "instruction": "Arrive at your destination on the right.", + "travelTime": 0, + "length": 0, + "id": "M13", + "_type": "PrivateTransportManeuverType" + } + ] + } + ], + "publicTransportLine": [ + { + "lineName": "332", + "companyName": "", + "destination": "Palmer/Schiller", + "type": "busPublic", + "id": "L1" + }, + { + "lineName": "332", + "companyName": "", + "destination": "Cargo Rd./Delta Cargo", + "type": "busPublic", + "id": "L2" + }, + { + "lineName": "332", + "companyName": "", + "destination": "Palmer/Schiller", + "type": "busPublic", + "id": "L3" + } + ], + "summary": { + "distance": 22325, + "baseTime": 5350, + "flags": [ + "noThroughRoad", + "builtUpArea", + "park" + ], + "text": "The trip takes 22.3 km and 1:29 h.", + "travelTime": 5350, + "departure": "1970-01-01T00:00:00Z", + "_type": "PublicTransportRouteSummaryType" + } + } + ], + "language": "en-us" + } +} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/public_time_table_response.json b/tests/fixtures/here_travel_time/public_time_table_response.json new file mode 100644 index 00000000000000..52df0d4eb35ad5 --- /dev/null +++ b/tests/fixtures/here_travel_time/public_time_table_response.json @@ -0,0 +1,308 @@ +{ + "response": { + "metaInfo": { + "timestamp": "2019-08-06T06:43:24Z", + "mapVersion": "8.30.99.152", + "moduleVersion": "7.2.201931-4739", + "interfaceVersion": "2.6.66", + "availableMapVersion": [ + "8.30.99.152" + ] + }, + "route": [ + { + "waypoint": [ + { + "linkId": "-1230414527", + "mappedPosition": { + "latitude": 41.9797859, + "longitude": -87.8790879 + }, + "originalPosition": { + "latitude": 41.9798, + "longitude": -87.8801 + }, + "type": "stopOver", + "spot": 0.5079365, + "sideOfStreet": "right", + "mappedRoadName": "Mannheim Rd", + "label": "Mannheim Rd - US-12", + "shapeIndex": 0, + "source": "user" + }, + { + "linkId": "+924115108", + "mappedPosition": { + "latitude": 41.90413, + "longitude": -87.9223502 + }, + "originalPosition": { + "latitude": 41.9043, + "longitude": -87.9216001 + }, + "type": "stopOver", + "spot": 0.1925926, + "sideOfStreet": "right", + "mappedRoadName": "", + "label": "", + "shapeIndex": 111, + "source": "user" + } + ], + "mode": { + "type": "fastest", + "transportModes": [ + "publicTransportTimeTable" + ], + "trafficMode": "disabled", + "feature": [] + }, + "leg": [ + { + "start": { + "linkId": "-1230414527", + "mappedPosition": { + "latitude": 41.9797859, + "longitude": -87.8790879 + }, + "originalPosition": { + "latitude": 41.9798, + "longitude": -87.8801 + }, + "type": "stopOver", + "spot": 0.5079365, + "sideOfStreet": "right", + "mappedRoadName": "Mannheim Rd", + "label": "Mannheim Rd - US-12", + "shapeIndex": 0, + "source": "user" + }, + "end": { + "linkId": "+924115108", + "mappedPosition": { + "latitude": 41.90413, + "longitude": -87.9223502 + }, + "originalPosition": { + "latitude": 41.9043, + "longitude": -87.9216001 + }, + "type": "stopOver", + "spot": 0.1925926, + "sideOfStreet": "right", + "mappedRoadName": "", + "label": "", + "shapeIndex": 111, + "source": "user" + }, + "length": 14775, + "travelTime": 4784, + "maneuver": [ + { + "position": { + "latitude": 41.9797859, + "longitude": -87.8790879 + }, + "instruction": "Head south on Mannheim Rd. Go for 848 m.", + "travelTime": 848, + "length": 848, + "id": "M1", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9722581, + "longitude": -87.8776109 + }, + "instruction": "Take the street on the left, Mannheim Rd. Go for 812 m.", + "travelTime": 812, + "length": 812, + "id": "M2", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.965051, + "longitude": -87.8769591 + }, + "instruction": "Go to the Bus stop Mannheim/Lawrence and take the bus 330 toward Archer/Harlem (Terminal). Follow for 33 stops.", + "travelTime": 900, + "length": 7815, + "id": "M3", + "stopName": "Mannheim/Lawrence", + "_type": "PublicTransportManeuverType" + }, + { + "position": { + "latitude": 41.896836, + "longitude": -87.883771 + }, + "instruction": "Get off at Mannheim/Lake.", + "travelTime": 0, + "length": 0, + "id": "M4", + "stopName": "Mannheim/Lake", + "_type": "PublicTransportManeuverType" + }, + { + "position": { + "latitude": 41.896836, + "longitude": -87.883771 + }, + "instruction": "Walk to Bus Lake/Mannheim.", + "travelTime": 300, + "length": 72, + "id": "M5", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.897263, + "longitude": -87.8842648 + }, + "instruction": "Take the bus 309 toward Elmhurst Metra Station. Follow for 18 stops.", + "travelTime": 1020, + "length": 4362, + "id": "M6", + "stopName": "Lake/Mannheim", + "_type": "PublicTransportManeuverType" + }, + { + "position": { + "latitude": 41.9066347, + "longitude": -87.928671 + }, + "instruction": "Get off at North/Berteau.", + "travelTime": 0, + "length": 0, + "id": "M7", + "stopName": "North/Berteau", + "nextRoadName": "E Berteau Ave", + "_type": "PublicTransportManeuverType" + }, + { + "position": { + "latitude": 41.9066347, + "longitude": -87.928671 + }, + "instruction": "Head north on E Berteau Ave. Go for 23 m.", + "travelTime": 40, + "length": 23, + "id": "M8", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9067693, + "longitude": -87.9284549 + }, + "instruction": "Turn right onto E Berteau Ave. Go for 40 m.", + "travelTime": 44, + "length": 40, + "id": "M9", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9065011, + "longitude": -87.9282939 + }, + "instruction": "Turn left onto E North Ave. Go for 49 m.", + "travelTime": 56, + "length": 49, + "id": "M10", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9065225, + "longitude": -87.9277039 + }, + "instruction": "Turn slightly right. Go for 409 m.", + "travelTime": 419, + "length": 409, + "id": "M11", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9040334, + "longitude": -87.9260409 + }, + "instruction": "Turn left onto E Third St. Go for 206 m.", + "travelTime": 206, + "length": 206, + "id": "M12", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9038832, + "longitude": -87.9236054 + }, + "instruction": "Turn left. Go for 113 m.", + "travelTime": 113, + "length": 113, + "id": "M13", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9039047, + "longitude": -87.9222536 + }, + "instruction": "Turn left. Go for 26 m.", + "travelTime": 26, + "length": 26, + "id": "M14", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.90413, + "longitude": -87.9223502 + }, + "instruction": "Arrive at your destination on the right.", + "travelTime": 0, + "length": 0, + "id": "M15", + "_type": "PrivateTransportManeuverType" + } + ] + } + ], + "publicTransportLine": [ + { + "lineName": "330", + "companyName": "PACE", + "destination": "Archer/Harlem (Terminal)", + "type": "busPublic", + "id": "L1" + }, + { + "lineName": "309", + "companyName": "PACE", + "destination": "Elmhurst Metra Station", + "type": "busPublic", + "id": "L2" + } + ], + "summary": { + "distance": 14775, + "baseTime": 4784, + "flags": [ + "noThroughRoad", + "builtUpArea", + "park" + ], + "text": "The trip takes 14.8 km and 1:20 h.", + "travelTime": 4784, + "departure": "2019-08-06T05:09:20-05:00", + "timetableExpiration": "2019-08-04T00:00:00Z", + "_type": "PublicTransportRouteSummaryType" + } + } + ], + "language": "en-us" + } +} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/routing_error_invalid_credentials.json b/tests/fixtures/here_travel_time/routing_error_invalid_credentials.json new file mode 100644 index 00000000000000..81fb246178c938 --- /dev/null +++ b/tests/fixtures/here_travel_time/routing_error_invalid_credentials.json @@ -0,0 +1,15 @@ +{ + "_type": "ns2:RoutingServiceErrorType", + "type": "PermissionError", + "subtype": "InvalidCredentials", + "details": "This is not a valid app_id and app_code pair. Please verify that the values are not swapped between the app_id and app_code and the values provisioned by HERE (either by your customer representative or via http://developer.here.com/myapps) were copied correctly into the request.", + "metaInfo": { + "timestamp": "2019-07-10T09:43:14Z", + "mapVersion": "8.30.98.152", + "moduleVersion": "7.2.201927-4307", + "interfaceVersion": "2.6.64", + "availableMapVersion": [ + "8.30.98.152" + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/routing_error_no_route_found.json b/tests/fixtures/here_travel_time/routing_error_no_route_found.json new file mode 100644 index 00000000000000..a776fa91c43b2a --- /dev/null +++ b/tests/fixtures/here_travel_time/routing_error_no_route_found.json @@ -0,0 +1,21 @@ +{ + "_type": "ns2:RoutingServiceErrorType", + "type": "ApplicationError", + "subtype": "NoRouteFound", + "details": "Error is NGEO_ERROR_ROUTE_NO_END_POINT", + "additionalData": [ + { + "key": "error_code", + "value": "NGEO_ERROR_ROUTE_NO_END_POINT" + } + ], + "metaInfo": { + "timestamp": "2019-07-10T09:51:04Z", + "mapVersion": "8.30.98.152", + "moduleVersion": "7.2.201927-4307", + "interfaceVersion": "2.6.64", + "availableMapVersion": [ + "8.30.98.152" + ] + } +} \ No newline at end of file diff --git a/tests/fixtures/here_travel_time/truck_response.json b/tests/fixtures/here_travel_time/truck_response.json new file mode 100644 index 00000000000000..a302d564902e81 --- /dev/null +++ b/tests/fixtures/here_travel_time/truck_response.json @@ -0,0 +1,187 @@ +{ + "response": { + "metaInfo": { + "timestamp": "2019-07-21T14:25:00Z", + "mapVersion": "8.30.98.154", + "moduleVersion": "7.2.201928-4478", + "interfaceVersion": "2.6.64", + "availableMapVersion": [ + "8.30.98.154" + ] + }, + "route": [ + { + "waypoint": [ + { + "linkId": "+930461269", + "mappedPosition": { + "latitude": 41.9800687, + "longitude": -87.8805614 + }, + "originalPosition": { + "latitude": 41.9798, + "longitude": -87.8801 + }, + "type": "stopOver", + "spot": 0.5555556, + "sideOfStreet": "right", + "mappedRoadName": "", + "label": "", + "shapeIndex": 0, + "source": "user" + }, + { + "linkId": "-1035319462", + "mappedPosition": { + "latitude": 41.9042909, + "longitude": -87.9216528 + }, + "originalPosition": { + "latitude": 41.9043, + "longitude": -87.9216001 + }, + "type": "stopOver", + "spot": 0, + "sideOfStreet": "left", + "mappedRoadName": "Eisenhower Expy E", + "label": "Eisenhower Expy E - I-290", + "shapeIndex": 135, + "source": "user" + } + ], + "mode": { + "type": "fastest", + "transportModes": [ + "truck" + ], + "trafficMode": "disabled", + "feature": [] + }, + "leg": [ + { + "start": { + "linkId": "+930461269", + "mappedPosition": { + "latitude": 41.9800687, + "longitude": -87.8805614 + }, + "originalPosition": { + "latitude": 41.9798, + "longitude": -87.8801 + }, + "type": "stopOver", + "spot": 0.5555556, + "sideOfStreet": "right", + "mappedRoadName": "", + "label": "", + "shapeIndex": 0, + "source": "user" + }, + "end": { + "linkId": "-1035319462", + "mappedPosition": { + "latitude": 41.9042909, + "longitude": -87.9216528 + }, + "originalPosition": { + "latitude": 41.9043, + "longitude": -87.9216001 + }, + "type": "stopOver", + "spot": 0, + "sideOfStreet": "left", + "mappedRoadName": "Eisenhower Expy E", + "label": "Eisenhower Expy E - I-290", + "shapeIndex": 135, + "source": "user" + }, + "length": 13049, + "travelTime": 812, + "maneuver": [ + { + "position": { + "latitude": 41.9800687, + "longitude": -87.8805614 + }, + "instruction": "Take ramp onto I-190. Go for 631 m.", + "travelTime": 53, + "length": 631, + "id": "M1", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.98259, + "longitude": -87.8744352 + }, + "instruction": "Take exit 1D toward Indiana onto I-294 S (Tri-State Tollway). Go for 10.9 km.", + "travelTime": 573, + "length": 10872, + "id": "M2", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9059324, + "longitude": -87.9199362 + }, + "instruction": "Take exit 33 toward Rockford/US-20/IL-64 onto I-290 W (Eisenhower Expy W). Go for 475 m.", + "travelTime": 54, + "length": 475, + "id": "M3", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9067156, + "longitude": -87.9237771 + }, + "instruction": "Take exit 13B toward North Ave onto IL-64 W (E North Ave). Go for 435 m.", + "travelTime": 51, + "length": 435, + "id": "M4", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9065869, + "longitude": -87.9249573 + }, + "instruction": "Take ramp onto I-290 E (Eisenhower Expy E) toward Chicago/I-294 S. Go for 636 m.", + "travelTime": 81, + "length": 636, + "id": "M5", + "_type": "PrivateTransportManeuverType" + }, + { + "position": { + "latitude": 41.9042909, + "longitude": -87.9216528 + }, + "instruction": "Arrive at Eisenhower Expy E (I-290). Your destination is on the left.", + "travelTime": 0, + "length": 0, + "id": "M6", + "_type": "PrivateTransportManeuverType" + } + ] + } + ], + "summary": { + "distance": 13049, + "trafficTime": 812, + "baseTime": 812, + "flags": [ + "tollroad", + "motorway", + "builtUpArea" + ], + "text": "The trip takes 13.0 km and 14 mins.", + "travelTime": 812, + "_type": "RouteSummaryType" + } + } + ], + "language": "en-us" + } +} \ No newline at end of file diff --git a/tests/fixtures/yandex_transport_reply.json b/tests/fixtures/yandex_transport_reply.json new file mode 100644 index 00000000000000..c5e4857297aa93 --- /dev/null +++ b/tests/fixtures/yandex_transport_reply.json @@ -0,0 +1,2106 @@ +{ + "data": { + "geometries": [ + { + "type": "Point", + "coordinates": [ + 37.565280044, + 55.851959656 + ] + } + ], + "geometry": { + "type": "Point", + "coordinates": [ + 37.565280044, + 55.851959656 + ] + }, + "properties": { + "name": "7-й автобусный парк", + "description": "7-й автобусный парк", + "currentTime": "Mon Sep 16 2019 21:40:40 GMT+0300 (Moscow Standard Time)", + "StopMetaData": { + "id": "stop__9639579", + "name": "7-й автобусный парк", + "type": "urban", + "region": { + "id": 213, + "type": 6, + "parent_id": 1, + "capital_id": 0, + "geo_parent_id": 0, + "city_id": 213, + "name": "moscow", + "native_name": "", + "iso_name": "RU MOW", + "is_main": true, + "en_name": "Moscow", + "short_en_name": "MSK", + "phone_code": "495 499", + "phone_code_old": "095", + "zip_code": "", + "population": 12506468, + "synonyms": "Moskau, Moskva", + "latitude": 55.753215, + "longitude": 37.622504, + "latitude_size": 0.878654, + "longitude_size": 1.164423, + "zoom": 10, + "tzname": "Europe/Moscow", + "official_languages": "ru", + "widespread_languages": "ru", + "suggest_list": [], + "is_eu": false, + "services_names": [ + "bs", + "yaca", + "weather", + "afisha", + "maps", + "tv", + "ad", + "etrain", + "subway", + "delivery", + "route" + ], + "ename": "moscow", + "bounds": [ + [ + 37.0402925, + 55.31141404514547 + ], + [ + 38.2047155, + 56.190068045145466 + ] + ], + "names": { + "ablative": "", + "accusative": "Москву", + "dative": "Москве", + "directional": "", + "genitive": "Москвы", + "instrumental": "Москвой", + "locative": "", + "nominative": "Москва", + "preposition": "в", + "prepositional": "Москве" + }, + "parent": { + "id": 1, + "type": 5, + "parent_id": 3, + "capital_id": 213, + "geo_parent_id": 0, + "city_id": 213, + "name": "moscow-and-moscow-oblast", + "native_name": "", + "iso_name": "RU-MOS", + "is_main": true, + "en_name": "Moscow and Moscow Oblast", + "short_en_name": "RU-MOS", + "phone_code": "495 496 498 499", + "phone_code_old": "", + "zip_code": "", + "population": 7503385, + "synonyms": "Московская область, Подмосковье, Podmoskovye", + "latitude": 55.815792, + "longitude": 37.380031, + "latitude_size": 2.705659, + "longitude_size": 5.060749, + "zoom": 8, + "tzname": "Europe/Moscow", + "official_languages": "ru", + "widespread_languages": "ru", + "suggest_list": [ + 213, + 10716, + 10747, + 10758, + 20728, + 10740, + 10738, + 20523, + 10735, + 10734, + 10743, + 21622 + ], + "is_eu": false, + "services_names": [ + "bs", + "yaca", + "ad" + ], + "ename": "moscow-and-moscow-oblast", + "bounds": [ + [ + 34.8496565, + 54.439456064325434 + ], + [ + 39.9104055, + 57.14511506432543 + ] + ], + "names": { + "ablative": "", + "accusative": "Москву и Московскую область", + "dative": "Москве и Московской области", + "directional": "", + "genitive": "Москвы и Московской области", + "instrumental": "Москвой и Московской областью", + "locative": "", + "nominative": "Москва и Московская область", + "preposition": "в", + "prepositional": "Москве и Московской области" + }, + "parent": { + "id": 225, + "type": 3, + "parent_id": 10001, + "capital_id": 213, + "geo_parent_id": 0, + "city_id": 213, + "name": "russia", + "native_name": "", + "iso_name": "RU", + "is_main": false, + "en_name": "Russia", + "short_en_name": "RU", + "phone_code": "7", + "phone_code_old": "", + "zip_code": "", + "population": 146880432, + "synonyms": "Russian Federation,Российская Федерация", + "latitude": 61.698653, + "longitude": 99.505405, + "latitude_size": 40.700127, + "longitude_size": 171.643239, + "zoom": 3, + "tzname": "", + "official_languages": "ru", + "widespread_languages": "ru", + "suggest_list": [ + 213, + 2, + 65, + 54, + 47, + 43, + 66, + 51, + 56, + 172, + 39, + 62 + ], + "is_eu": false, + "services_names": [ + "bs", + "yaca", + "ad" + ], + "ename": "russia", + "bounds": [ + [ + 13.683785499999999, + 35.290400699917846 + ], + [ + -174.6729755, + 75.99052769991785 + ] + ], + "names": { + "ablative": "", + "accusative": "Россию", + "dative": "России", + "directional": "", + "genitive": "России", + "instrumental": "Россией", + "locative": "", + "nominative": "Россия", + "preposition": "в", + "prepositional": "России" + } + } + } + }, + "Transport": [ + { + "lineId": "2036925416", + "name": "194", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "2036927196", + "EssentialStops": [ + { + "id": "stop__9711780", + "name": "Метро Петровско-Разумовская" + }, + { + "id": "stop__9648742", + "name": "Коровино" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659860", + "tzOffset": 10800, + "text": "21:51" + } + }, + { + "Scheduled": { + "value": "1568660760", + "tzOffset": 10800, + "text": "22:06" + } + }, + { + "Scheduled": { + "value": "1568661840", + "tzOffset": 10800, + "text": "22:24" + } + } + ], + "departureTime": "21:51" + } + } + ], + "threadId": "2036927196", + "EssentialStops": [ + { + "id": "stop__9711780", + "name": "Метро Петровско-Разумовская" + }, + { + "id": "stop__9648742", + "name": "Коровино" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659860", + "tzOffset": 10800, + "text": "21:51" + } + }, + { + "Scheduled": { + "value": "1568660760", + "tzOffset": 10800, + "text": "22:06" + } + }, + { + "Scheduled": { + "value": "1568661840", + "tzOffset": 10800, + "text": "22:24" + } + } + ], + "departureTime": "21:51" + } + }, + { + "lineId": "213_114_bus_mosgortrans", + "name": "114", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213B_114_bus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9647199", + "name": "Метро Войковская" + }, + { + "id": "stop__9639588", + "name": "Коровинское шоссе" + } + ], + "BriefSchedule": { + "Events": [], + "Frequency": { + "text": "15 мин", + "value": 900, + "begin": { + "value": "1568603405", + "tzOffset": 10800, + "text": "6:10" + }, + "end": { + "value": "1568672165", + "tzOffset": 10800, + "text": "1:16" + } + } + } + } + ], + "threadId": "213B_114_bus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9647199", + "name": "Метро Войковская" + }, + { + "id": "stop__9639588", + "name": "Коровинское шоссе" + } + ], + "BriefSchedule": { + "Events": [], + "Frequency": { + "text": "15 мин", + "value": 900, + "begin": { + "value": "1568603405", + "tzOffset": 10800, + "text": "6:10" + }, + "end": { + "value": "1568672165", + "tzOffset": 10800, + "text": "1:16" + } + } + } + }, + { + "lineId": "213_154_bus_mosgortrans", + "name": "154", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213B_154_bus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9642548", + "name": "ВДНХ (южная)" + }, + { + "id": "stop__9711744", + "name": "Станция Ховрино" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659260", + "tzOffset": 10800, + "text": "21:41" + }, + "Estimated": { + "value": "1568659252", + "tzOffset": 10800, + "text": "21:40" + }, + "vehicleId": "codd%5Fnew|1054764%5F191500" + }, + { + "Scheduled": { + "value": "1568660580", + "tzOffset": 10800, + "text": "22:03" + } + }, + { + "Scheduled": { + "value": "1568661900", + "tzOffset": 10800, + "text": "22:25" + } + } + ], + "departureTime": "21:41" + } + } + ], + "threadId": "213B_154_bus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9642548", + "name": "ВДНХ (южная)" + }, + { + "id": "stop__9711744", + "name": "Станция Ховрино" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659260", + "tzOffset": 10800, + "text": "21:41" + }, + "Estimated": { + "value": "1568659252", + "tzOffset": 10800, + "text": "21:40" + }, + "vehicleId": "codd%5Fnew|1054764%5F191500" + }, + { + "Scheduled": { + "value": "1568660580", + "tzOffset": 10800, + "text": "22:03" + } + }, + { + "Scheduled": { + "value": "1568661900", + "tzOffset": 10800, + "text": "22:25" + } + } + ], + "departureTime": "21:41" + } + }, + { + "lineId": "213_179_bus_mosgortrans", + "name": "179", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213B_179_bus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9647199", + "name": "Метро Войковская" + }, + { + "id": "stop__9639480", + "name": "Платформа Лианозово" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659920", + "tzOffset": 10800, + "text": "21:52" + }, + "Estimated": { + "value": "1568659351", + "tzOffset": 10800, + "text": "21:42" + }, + "vehicleId": "codd%5Fnew|59832%5F31359" + }, + { + "Scheduled": { + "value": "1568660760", + "tzOffset": 10800, + "text": "22:06" + } + }, + { + "Scheduled": { + "value": "1568661660", + "tzOffset": 10800, + "text": "22:21" + } + } + ], + "departureTime": "21:52" + } + } + ], + "threadId": "213B_179_bus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9647199", + "name": "Метро Войковская" + }, + { + "id": "stop__9639480", + "name": "Платформа Лианозово" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659920", + "tzOffset": 10800, + "text": "21:52" + }, + "Estimated": { + "value": "1568659351", + "tzOffset": 10800, + "text": "21:42" + }, + "vehicleId": "codd%5Fnew|59832%5F31359" + }, + { + "Scheduled": { + "value": "1568660760", + "tzOffset": 10800, + "text": "22:06" + } + }, + { + "Scheduled": { + "value": "1568661660", + "tzOffset": 10800, + "text": "22:21" + } + } + ], + "departureTime": "21:52" + } + }, + { + "lineId": "213_191m_minibus_default", + "name": "591", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213A_191m_minibus_default", + "EssentialStops": [ + { + "id": "stop__9647199", + "name": "Метро Войковская" + }, + { + "id": "stop__9711744", + "name": "Станция Ховрино" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1568660525", + "tzOffset": 10800, + "text": "22:02" + }, + "vehicleId": "codd%5Fnew|38278%5F9345312" + } + ], + "Frequency": { + "text": "22 мин", + "value": 1320, + "begin": { + "value": "1568602033", + "tzOffset": 10800, + "text": "5:47" + }, + "end": { + "value": "1568672233", + "tzOffset": 10800, + "text": "1:17" + } + } + } + } + ], + "threadId": "213A_191m_minibus_default", + "EssentialStops": [ + { + "id": "stop__9647199", + "name": "Метро Войковская" + }, + { + "id": "stop__9711744", + "name": "Станция Ховрино" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1568660525", + "tzOffset": 10800, + "text": "22:02" + }, + "vehicleId": "codd%5Fnew|38278%5F9345312" + } + ], + "Frequency": { + "text": "22 мин", + "value": 1320, + "begin": { + "value": "1568602033", + "tzOffset": 10800, + "text": "5:47" + }, + "end": { + "value": "1568672233", + "tzOffset": 10800, + "text": "1:17" + } + } + } + }, + { + "lineId": "213_206m_minibus_default", + "name": "206к", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213A_206m_minibus_default", + "EssentialStops": [ + { + "id": "stop__9640756", + "name": "Метро Петровско-Разумовская" + }, + { + "id": "stop__9640553", + "name": "Лобненская улица" + } + ], + "BriefSchedule": { + "Events": [], + "Frequency": { + "text": "22 мин", + "value": 1320, + "begin": { + "value": "1568601239", + "tzOffset": 10800, + "text": "5:33" + }, + "end": { + "value": "1568671439", + "tzOffset": 10800, + "text": "1:03" + } + } + } + } + ], + "threadId": "213A_206m_minibus_default", + "EssentialStops": [ + { + "id": "stop__9640756", + "name": "Метро Петровско-Разумовская" + }, + { + "id": "stop__9640553", + "name": "Лобненская улица" + } + ], + "BriefSchedule": { + "Events": [], + "Frequency": { + "text": "22 мин", + "value": 1320, + "begin": { + "value": "1568601239", + "tzOffset": 10800, + "text": "5:33" + }, + "end": { + "value": "1568671439", + "tzOffset": 10800, + "text": "1:03" + } + } + } + }, + { + "lineId": "213_215_bus_mosgortrans", + "name": "215", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213B_215_bus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9711780", + "name": "Метро Петровско-Разумовская" + }, + { + "id": "stop__9711744", + "name": "Станция Ховрино" + } + ], + "BriefSchedule": { + "Events": [], + "Frequency": { + "text": "27 мин", + "value": 1620, + "begin": { + "value": "1568601276", + "tzOffset": 10800, + "text": "5:34" + }, + "end": { + "value": "1568671476", + "tzOffset": 10800, + "text": "1:04" + } + } + } + } + ], + "threadId": "213B_215_bus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9711780", + "name": "Метро Петровско-Разумовская" + }, + { + "id": "stop__9711744", + "name": "Станция Ховрино" + } + ], + "BriefSchedule": { + "Events": [], + "Frequency": { + "text": "27 мин", + "value": 1620, + "begin": { + "value": "1568601276", + "tzOffset": 10800, + "text": "5:34" + }, + "end": { + "value": "1568671476", + "tzOffset": 10800, + "text": "1:04" + } + } + } + }, + { + "lineId": "213_282_bus_mosgortrans", + "name": "282", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213A_282_bus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9641102", + "name": "Улица Корнейчука" + }, + { + "id": "2532226085", + "name": "Метро Войковская" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1568659888", + "tzOffset": 10800, + "text": "21:51" + }, + "vehicleId": "codd%5Fnew|34874%5F9345408" + } + ], + "Frequency": { + "text": "15 мин", + "value": 900, + "begin": { + "value": "1568602180", + "tzOffset": 10800, + "text": "5:49" + }, + "end": { + "value": "1568673460", + "tzOffset": 10800, + "text": "1:37" + } + } + } + } + ], + "threadId": "213A_282_bus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9641102", + "name": "Улица Корнейчука" + }, + { + "id": "2532226085", + "name": "Метро Войковская" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1568659888", + "tzOffset": 10800, + "text": "21:51" + }, + "vehicleId": "codd%5Fnew|34874%5F9345408" + } + ], + "Frequency": { + "text": "15 мин", + "value": 900, + "begin": { + "value": "1568602180", + "tzOffset": 10800, + "text": "5:49" + }, + "end": { + "value": "1568673460", + "tzOffset": 10800, + "text": "1:37" + } + } + } + }, + { + "lineId": "213_294m_minibus_default", + "name": "994", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213A_294m_minibus_default", + "EssentialStops": [ + { + "id": "stop__9640756", + "name": "Метро Петровско-Разумовская" + }, + { + "id": "stop__9649459", + "name": "Метро Алтуфьево" + } + ], + "BriefSchedule": { + "Events": [], + "Frequency": { + "text": "30 мин", + "value": 1800, + "begin": { + "value": "1568601527", + "tzOffset": 10800, + "text": "5:38" + }, + "end": { + "value": "1568671727", + "tzOffset": 10800, + "text": "1:08" + } + } + } + } + ], + "threadId": "213A_294m_minibus_default", + "EssentialStops": [ + { + "id": "stop__9640756", + "name": "Метро Петровско-Разумовская" + }, + { + "id": "stop__9649459", + "name": "Метро Алтуфьево" + } + ], + "BriefSchedule": { + "Events": [], + "Frequency": { + "text": "30 мин", + "value": 1800, + "begin": { + "value": "1568601527", + "tzOffset": 10800, + "text": "5:38" + }, + "end": { + "value": "1568671727", + "tzOffset": 10800, + "text": "1:08" + } + } + } + }, + { + "lineId": "213_36_trolleybus_mosgortrans", + "name": "т36", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213A_36_trolleybus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9642550", + "name": "ВДНХ (южная)" + }, + { + "id": "stop__9640641", + "name": "Дмитровское шоссе, 155" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659680", + "tzOffset": 10800, + "text": "21:48" + }, + "Estimated": { + "value": "1568659426", + "tzOffset": 10800, + "text": "21:43" + }, + "vehicleId": "codd%5Fnew|1084829%5F430260" + }, + { + "Scheduled": { + "value": "1568660520", + "tzOffset": 10800, + "text": "22:02" + }, + "Estimated": { + "value": "1568659656", + "tzOffset": 10800, + "text": "21:47" + }, + "vehicleId": "codd%5Fnew|1117016%5F430280" + }, + { + "Scheduled": { + "value": "1568661900", + "tzOffset": 10800, + "text": "22:25" + }, + "Estimated": { + "value": "1568660538", + "tzOffset": 10800, + "text": "22:02" + }, + "vehicleId": "codd%5Fnew|1054576%5F430226" + } + ], + "departureTime": "21:48" + } + } + ], + "threadId": "213A_36_trolleybus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9642550", + "name": "ВДНХ (южная)" + }, + { + "id": "stop__9640641", + "name": "Дмитровское шоссе, 155" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659680", + "tzOffset": 10800, + "text": "21:48" + }, + "Estimated": { + "value": "1568659426", + "tzOffset": 10800, + "text": "21:43" + }, + "vehicleId": "codd%5Fnew|1084829%5F430260" + }, + { + "Scheduled": { + "value": "1568660520", + "tzOffset": 10800, + "text": "22:02" + }, + "Estimated": { + "value": "1568659656", + "tzOffset": 10800, + "text": "21:47" + }, + "vehicleId": "codd%5Fnew|1117016%5F430280" + }, + { + "Scheduled": { + "value": "1568661900", + "tzOffset": 10800, + "text": "22:25" + }, + "Estimated": { + "value": "1568660538", + "tzOffset": 10800, + "text": "22:02" + }, + "vehicleId": "codd%5Fnew|1054576%5F430226" + } + ], + "departureTime": "21:48" + } + }, + { + "lineId": "213_47_trolleybus_mosgortrans", + "name": "т47", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213B_47_trolleybus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9639568", + "name": "Бескудниковский переулок" + }, + { + "id": "stop__9641903", + "name": "Бескудниковский переулок" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659980", + "tzOffset": 10800, + "text": "21:53" + }, + "Estimated": { + "value": "1568659253", + "tzOffset": 10800, + "text": "21:40" + }, + "vehicleId": "codd%5Fnew|1112219%5F430329" + }, + { + "Scheduled": { + "value": "1568660940", + "tzOffset": 10800, + "text": "22:09" + }, + "Estimated": { + "value": "1568660519", + "tzOffset": 10800, + "text": "22:01" + }, + "vehicleId": "codd%5Fnew|1139620%5F430382" + }, + { + "Scheduled": { + "value": "1568663580", + "tzOffset": 10800, + "text": "22:53" + } + } + ], + "departureTime": "21:53" + } + } + ], + "threadId": "213B_47_trolleybus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9639568", + "name": "Бескудниковский переулок" + }, + { + "id": "stop__9641903", + "name": "Бескудниковский переулок" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659980", + "tzOffset": 10800, + "text": "21:53" + }, + "Estimated": { + "value": "1568659253", + "tzOffset": 10800, + "text": "21:40" + }, + "vehicleId": "codd%5Fnew|1112219%5F430329" + }, + { + "Scheduled": { + "value": "1568660940", + "tzOffset": 10800, + "text": "22:09" + }, + "Estimated": { + "value": "1568660519", + "tzOffset": 10800, + "text": "22:01" + }, + "vehicleId": "codd%5Fnew|1139620%5F430382" + }, + { + "Scheduled": { + "value": "1568663580", + "tzOffset": 10800, + "text": "22:53" + } + } + ], + "departureTime": "21:53" + } + }, + { + "lineId": "213_56_trolleybus_mosgortrans", + "name": "т56", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213A_56_trolleybus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9639561", + "name": "Коровинское шоссе" + }, + { + "id": "stop__9639588", + "name": "Коровинское шоссе" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1568660675", + "tzOffset": 10800, + "text": "22:04" + }, + "vehicleId": "codd%5Fnew|146304%5F31207" + } + ], + "Frequency": { + "text": "8 мин", + "value": 480, + "begin": { + "value": "1568606244", + "tzOffset": 10800, + "text": "6:57" + }, + "end": { + "value": "1568670144", + "tzOffset": 10800, + "text": "0:42" + } + } + } + } + ], + "threadId": "213A_56_trolleybus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9639561", + "name": "Коровинское шоссе" + }, + { + "id": "stop__9639588", + "name": "Коровинское шоссе" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1568660675", + "tzOffset": 10800, + "text": "22:04" + }, + "vehicleId": "codd%5Fnew|146304%5F31207" + } + ], + "Frequency": { + "text": "8 мин", + "value": 480, + "begin": { + "value": "1568606244", + "tzOffset": 10800, + "text": "6:57" + }, + "end": { + "value": "1568670144", + "tzOffset": 10800, + "text": "0:42" + } + } + } + }, + { + "lineId": "213_63_bus_mosgortrans", + "name": "63", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213A_63_bus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9640554", + "name": "Лобненская улица" + }, + { + "id": "stop__9640553", + "name": "Лобненская улица" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1568659369", + "tzOffset": 10800, + "text": "21:42" + }, + "vehicleId": "codd%5Fnew|38921%5F9215306" + }, + { + "Estimated": { + "value": "1568660136", + "tzOffset": 10800, + "text": "21:55" + }, + "vehicleId": "codd%5Fnew|38918%5F9215303" + } + ], + "Frequency": { + "text": "17 мин", + "value": 1020, + "begin": { + "value": "1568600987", + "tzOffset": 10800, + "text": "5:29" + }, + "end": { + "value": "1568670227", + "tzOffset": 10800, + "text": "0:43" + } + } + } + } + ], + "threadId": "213A_63_bus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9640554", + "name": "Лобненская улица" + }, + { + "id": "stop__9640553", + "name": "Лобненская улица" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1568659369", + "tzOffset": 10800, + "text": "21:42" + }, + "vehicleId": "codd%5Fnew|38921%5F9215306" + }, + { + "Estimated": { + "value": "1568660136", + "tzOffset": 10800, + "text": "21:55" + }, + "vehicleId": "codd%5Fnew|38918%5F9215303" + } + ], + "Frequency": { + "text": "17 мин", + "value": 1020, + "begin": { + "value": "1568600987", + "tzOffset": 10800, + "text": "5:29" + }, + "end": { + "value": "1568670227", + "tzOffset": 10800, + "text": "0:43" + } + } + } + }, + { + "lineId": "213_677_bus_mosgortrans", + "name": "677", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213B_677_bus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9639495", + "name": "Метро Петровско-Разумовская" + }, + { + "id": "stop__9639480", + "name": "Платформа Лианозово" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1568659369", + "tzOffset": 10800, + "text": "21:42" + }, + "vehicleId": "codd%5Fnew|11731%5F31376" + } + ], + "Frequency": { + "text": "4 мин", + "value": 240, + "begin": { + "value": "1568600940", + "tzOffset": 10800, + "text": "5:29" + }, + "end": { + "value": "1568672640", + "tzOffset": 10800, + "text": "1:24" + } + } + } + } + ], + "threadId": "213B_677_bus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9639495", + "name": "Метро Петровско-Разумовская" + }, + { + "id": "stop__9639480", + "name": "Платформа Лианозово" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1568659369", + "tzOffset": 10800, + "text": "21:42" + }, + "vehicleId": "codd%5Fnew|11731%5F31376" + } + ], + "Frequency": { + "text": "4 мин", + "value": 240, + "begin": { + "value": "1568600940", + "tzOffset": 10800, + "text": "5:29" + }, + "end": { + "value": "1568672640", + "tzOffset": 10800, + "text": "1:24" + } + } + } + }, + { + "lineId": "213_692_bus_mosgortrans", + "name": "692", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "2036928706", + "EssentialStops": [ + { + "id": "3163417967", + "name": "Платформа Дегунино" + }, + { + "id": "3163417967", + "name": "Платформа Дегунино" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568660280", + "tzOffset": 10800, + "text": "21:58" + }, + "Estimated": { + "value": "1568660255", + "tzOffset": 10800, + "text": "21:57" + }, + "vehicleId": "codd%5Fnew|63029%5F31485" + }, + { + "Scheduled": { + "value": "1568693340", + "tzOffset": 10800, + "text": "7:09" + } + }, + { + "Scheduled": { + "value": "1568696940", + "tzOffset": 10800, + "text": "8:09" + } + } + ], + "departureTime": "21:58" + } + } + ], + "threadId": "2036928706", + "EssentialStops": [ + { + "id": "3163417967", + "name": "Платформа Дегунино" + }, + { + "id": "3163417967", + "name": "Платформа Дегунино" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568660280", + "tzOffset": 10800, + "text": "21:58" + }, + "Estimated": { + "value": "1568660255", + "tzOffset": 10800, + "text": "21:57" + }, + "vehicleId": "codd%5Fnew|63029%5F31485" + }, + { + "Scheduled": { + "value": "1568693340", + "tzOffset": 10800, + "text": "7:09" + } + }, + { + "Scheduled": { + "value": "1568696940", + "tzOffset": 10800, + "text": "8:09" + } + } + ], + "departureTime": "21:58" + } + }, + { + "lineId": "213_78_trolleybus_mosgortrans", + "name": "т78", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "213A_78_trolleybus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9887464", + "name": "9-я Северная линия" + }, + { + "id": "stop__9887464", + "name": "9-я Северная линия" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659620", + "tzOffset": 10800, + "text": "21:47" + }, + "Estimated": { + "value": "1568659898", + "tzOffset": 10800, + "text": "21:51" + }, + "vehicleId": "codd%5Fnew|147522%5F31184" + }, + { + "Scheduled": { + "value": "1568660760", + "tzOffset": 10800, + "text": "22:06" + } + }, + { + "Scheduled": { + "value": "1568661900", + "tzOffset": 10800, + "text": "22:25" + } + } + ], + "departureTime": "21:47" + } + } + ], + "threadId": "213A_78_trolleybus_mosgortrans", + "EssentialStops": [ + { + "id": "stop__9887464", + "name": "9-я Северная линия" + }, + { + "id": "stop__9887464", + "name": "9-я Северная линия" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659620", + "tzOffset": 10800, + "text": "21:47" + }, + "Estimated": { + "value": "1568659898", + "tzOffset": 10800, + "text": "21:51" + }, + "vehicleId": "codd%5Fnew|147522%5F31184" + }, + { + "Scheduled": { + "value": "1568660760", + "tzOffset": 10800, + "text": "22:06" + } + }, + { + "Scheduled": { + "value": "1568661900", + "tzOffset": 10800, + "text": "22:25" + } + } + ], + "departureTime": "21:47" + } + }, + { + "lineId": "213_82_bus_mosgortrans", + "name": "82", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "2036925244", + "EssentialStops": [ + { + "id": "2310890052", + "name": "Метро Верхние Лихоборы" + }, + { + "id": "2310890052", + "name": "Метро Верхние Лихоборы" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659680", + "tzOffset": 10800, + "text": "21:48" + } + }, + { + "Scheduled": { + "value": "1568661780", + "tzOffset": 10800, + "text": "22:23" + } + }, + { + "Scheduled": { + "value": "1568663760", + "tzOffset": 10800, + "text": "22:56" + } + } + ], + "departureTime": "21:48" + } + } + ], + "threadId": "2036925244", + "EssentialStops": [ + { + "id": "2310890052", + "name": "Метро Верхние Лихоборы" + }, + { + "id": "2310890052", + "name": "Метро Верхние Лихоборы" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659680", + "tzOffset": 10800, + "text": "21:48" + } + }, + { + "Scheduled": { + "value": "1568661780", + "tzOffset": 10800, + "text": "22:23" + } + }, + { + "Scheduled": { + "value": "1568663760", + "tzOffset": 10800, + "text": "22:56" + } + } + ], + "departureTime": "21:48" + } + }, + { + "lineId": "2465131598", + "name": "179к", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "2465131758", + "EssentialStops": [ + { + "id": "stop__9640244", + "name": "Платформа Лианозово" + }, + { + "id": "stop__9639480", + "name": "Платформа Лианозово" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659500", + "tzOffset": 10800, + "text": "21:45" + } + }, + { + "Scheduled": { + "value": "1568659980", + "tzOffset": 10800, + "text": "21:53" + } + }, + { + "Scheduled": { + "value": "1568660880", + "tzOffset": 10800, + "text": "22:08" + } + } + ], + "departureTime": "21:45" + } + } + ], + "threadId": "2465131758", + "EssentialStops": [ + { + "id": "stop__9640244", + "name": "Платформа Лианозово" + }, + { + "id": "stop__9639480", + "name": "Платформа Лианозово" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659500", + "tzOffset": 10800, + "text": "21:45" + } + }, + { + "Scheduled": { + "value": "1568659980", + "tzOffset": 10800, + "text": "21:53" + } + }, + { + "Scheduled": { + "value": "1568660880", + "tzOffset": 10800, + "text": "22:08" + } + } + ], + "departureTime": "21:45" + } + }, + { + "lineId": "466_bus_default", + "name": "466", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "466B_bus_default", + "EssentialStops": [ + { + "id": "stop__9640546", + "name": "Станция Бескудниково" + }, + { + "id": "stop__9640545", + "name": "Станция Бескудниково" + } + ], + "BriefSchedule": { + "Events": [], + "Frequency": { + "text": "22 мин", + "value": 1320, + "begin": { + "value": "1568604647", + "tzOffset": 10800, + "text": "6:30" + }, + "end": { + "value": "1568675447", + "tzOffset": 10800, + "text": "2:10" + } + } + } + } + ], + "threadId": "466B_bus_default", + "EssentialStops": [ + { + "id": "stop__9640546", + "name": "Станция Бескудниково" + }, + { + "id": "stop__9640545", + "name": "Станция Бескудниково" + } + ], + "BriefSchedule": { + "Events": [], + "Frequency": { + "text": "22 мин", + "value": 1320, + "begin": { + "value": "1568604647", + "tzOffset": 10800, + "text": "6:30" + }, + "end": { + "value": "1568675447", + "tzOffset": 10800, + "text": "2:10" + } + } + } + }, + { + "lineId": "677k_bus_default", + "name": "677к", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "677kA_bus_default", + "EssentialStops": [ + { + "id": "stop__9640244", + "name": "Платформа Лианозово" + }, + { + "id": "stop__9639480", + "name": "Платформа Лианозово" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659920", + "tzOffset": 10800, + "text": "21:52" + }, + "Estimated": { + "value": "1568660003", + "tzOffset": 10800, + "text": "21:53" + }, + "vehicleId": "codd%5Fnew|130308%5F31319" + }, + { + "Scheduled": { + "value": "1568661240", + "tzOffset": 10800, + "text": "22:14" + } + }, + { + "Scheduled": { + "value": "1568662500", + "tzOffset": 10800, + "text": "22:35" + } + } + ], + "departureTime": "21:52" + } + } + ], + "threadId": "677kA_bus_default", + "EssentialStops": [ + { + "id": "stop__9640244", + "name": "Платформа Лианозово" + }, + { + "id": "stop__9639480", + "name": "Платформа Лианозово" + } + ], + "BriefSchedule": { + "Events": [ + { + "Scheduled": { + "value": "1568659920", + "tzOffset": 10800, + "text": "21:52" + }, + "Estimated": { + "value": "1568660003", + "tzOffset": 10800, + "text": "21:53" + }, + "vehicleId": "codd%5Fnew|130308%5F31319" + }, + { + "Scheduled": { + "value": "1568661240", + "tzOffset": 10800, + "text": "22:14" + } + }, + { + "Scheduled": { + "value": "1568662500", + "tzOffset": 10800, + "text": "22:35" + } + } + ], + "departureTime": "21:52" + } + }, + { + "lineId": "m10_bus_default", + "name": "м10", + "Types": [ + "bus" + ], + "type": "bus", + "threads": [ + { + "threadId": "2036926048", + "EssentialStops": [ + { + "id": "stop__9640554", + "name": "Лобненская улица" + }, + { + "id": "stop__9640553", + "name": "Лобненская улица" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1568659718", + "tzOffset": 10800, + "text": "21:48" + }, + "vehicleId": "codd%5Fnew|146260%5F31212" + }, + { + "Estimated": { + "value": "1568660422", + "tzOffset": 10800, + "text": "22:00" + }, + "vehicleId": "codd%5Fnew|13997%5F31247" + } + ], + "Frequency": { + "text": "15 мин", + "value": 900, + "begin": { + "value": "1568606903", + "tzOffset": 10800, + "text": "7:08" + }, + "end": { + "value": "1568675183", + "tzOffset": 10800, + "text": "2:06" + } + } + } + } + ], + "threadId": "2036926048", + "EssentialStops": [ + { + "id": "stop__9640554", + "name": "Лобненская улица" + }, + { + "id": "stop__9640553", + "name": "Лобненская улица" + } + ], + "BriefSchedule": { + "Events": [ + { + "Estimated": { + "value": "1568659718", + "tzOffset": 10800, + "text": "21:48" + }, + "vehicleId": "codd%5Fnew|146260%5F31212" + }, + { + "Estimated": { + "value": "1568660422", + "tzOffset": 10800, + "text": "22:00" + }, + "vehicleId": "codd%5Fnew|13997%5F31247" + } + ], + "Frequency": { + "text": "15 мин", + "value": 900, + "begin": { + "value": "1568606903", + "tzOffset": 10800, + "text": "7:08" + }, + "end": { + "value": "1568675183", + "tzOffset": 10800, + "text": "2:06" + } + } + } + } + ] + } + }, + "toponymSeoname": "dmitrovskoye_shosse" + } +} diff --git a/tests/helpers/test_condition.py b/tests/helpers/test_condition.py index ddd22107fa08df..b603f98bb04b93 100644 --- a/tests/helpers/test_condition.py +++ b/tests/helpers/test_condition.py @@ -4,182 +4,175 @@ from homeassistant.helpers import condition from homeassistant.util import dt -from tests.common import get_test_home_assistant - - -class TestConditionHelper: - """Test condition helpers.""" - - def setup_method(self, method): - """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - - def teardown_method(self, method): - """Stop everything that was started.""" - self.hass.stop() - - def test_and_condition(self): - """Test the 'and' condition.""" - test = condition.from_config( - { - "condition": "and", - "conditions": [ - { - "condition": "state", - "entity_id": "sensor.temperature", - "state": "100", - }, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "below": 110, - }, - ], - } - ) - - self.hass.states.set("sensor.temperature", 120) - assert not test(self.hass) - - self.hass.states.set("sensor.temperature", 105) - assert not test(self.hass) - - self.hass.states.set("sensor.temperature", 100) - assert test(self.hass) - - def test_and_condition_with_template(self): - """Test the 'and' condition.""" - test = condition.from_config( - { - "condition": "and", - "conditions": [ - { - "condition": "template", - "value_template": '{{ states.sensor.temperature.state == "100" }}', - }, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "below": 110, - }, - ], - } - ) - - self.hass.states.set("sensor.temperature", 120) - assert not test(self.hass) - - self.hass.states.set("sensor.temperature", 105) - assert not test(self.hass) - - self.hass.states.set("sensor.temperature", 100) - assert test(self.hass) - - def test_or_condition(self): - """Test the 'or' condition.""" - test = condition.from_config( - { - "condition": "or", - "conditions": [ - { - "condition": "state", - "entity_id": "sensor.temperature", - "state": "100", - }, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "below": 110, - }, - ], - } - ) - - self.hass.states.set("sensor.temperature", 120) - assert not test(self.hass) - - self.hass.states.set("sensor.temperature", 105) - assert test(self.hass) - - self.hass.states.set("sensor.temperature", 100) - assert test(self.hass) - - def test_or_condition_with_template(self): - """Test the 'or' condition.""" - test = condition.from_config( - { - "condition": "or", - "conditions": [ - { - "condition": "template", - "value_template": '{{ states.sensor.temperature.state == "100" }}', - }, - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "below": 110, - }, - ], - } - ) - - self.hass.states.set("sensor.temperature", 120) - assert not test(self.hass) - - self.hass.states.set("sensor.temperature", 105) - assert test(self.hass) - - self.hass.states.set("sensor.temperature", 100) - assert test(self.hass) - - def test_time_window(self): - """Test time condition windows.""" - sixam = dt.parse_time("06:00:00") - sixpm = dt.parse_time("18:00:00") - - with patch( - "homeassistant.helpers.condition.dt_util.now", - return_value=dt.now().replace(hour=3), - ): - assert not condition.time(after=sixam, before=sixpm) - assert condition.time(after=sixpm, before=sixam) - - with patch( - "homeassistant.helpers.condition.dt_util.now", - return_value=dt.now().replace(hour=9), - ): - assert condition.time(after=sixam, before=sixpm) - assert not condition.time(after=sixpm, before=sixam) - - with patch( - "homeassistant.helpers.condition.dt_util.now", - return_value=dt.now().replace(hour=15), - ): - assert condition.time(after=sixam, before=sixpm) - assert not condition.time(after=sixpm, before=sixam) - - with patch( - "homeassistant.helpers.condition.dt_util.now", - return_value=dt.now().replace(hour=21), - ): - assert not condition.time(after=sixam, before=sixpm) - assert condition.time(after=sixpm, before=sixam) - - def test_if_numeric_state_not_raise_on_unavailable(self): - """Test numeric_state doesn't raise on unavailable/unknown state.""" - test = condition.from_config( - { - "condition": "numeric_state", - "entity_id": "sensor.temperature", - "below": 42, - } - ) - - with patch("homeassistant.helpers.condition._LOGGER.warning") as logwarn: - self.hass.states.set("sensor.temperature", "unavailable") - assert not test(self.hass) - assert len(logwarn.mock_calls) == 0 - - self.hass.states.set("sensor.temperature", "unknown") - assert not test(self.hass) - assert len(logwarn.mock_calls) == 0 + +async def test_and_condition(hass): + """Test the 'and' condition.""" + test = await condition.async_from_config( + hass, + { + "condition": "and", + "conditions": [ + { + "condition": "state", + "entity_id": "sensor.temperature", + "state": "100", + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 110, + }, + ], + }, + ) + + hass.states.async_set("sensor.temperature", 120) + assert not test(hass) + + hass.states.async_set("sensor.temperature", 105) + assert not test(hass) + + hass.states.async_set("sensor.temperature", 100) + assert test(hass) + + +async def test_and_condition_with_template(hass): + """Test the 'and' condition.""" + test = await condition.async_from_config( + hass, + { + "condition": "and", + "conditions": [ + { + "condition": "template", + "value_template": '{{ states.sensor.temperature.state == "100" }}', + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 110, + }, + ], + }, + ) + + hass.states.async_set("sensor.temperature", 120) + assert not test(hass) + + hass.states.async_set("sensor.temperature", 105) + assert not test(hass) + + hass.states.async_set("sensor.temperature", 100) + assert test(hass) + + +async def test_or_condition(hass): + """Test the 'or' condition.""" + test = await condition.async_from_config( + hass, + { + "condition": "or", + "conditions": [ + { + "condition": "state", + "entity_id": "sensor.temperature", + "state": "100", + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 110, + }, + ], + }, + ) + + hass.states.async_set("sensor.temperature", 120) + assert not test(hass) + + hass.states.async_set("sensor.temperature", 105) + assert test(hass) + + hass.states.async_set("sensor.temperature", 100) + assert test(hass) + + +async def test_or_condition_with_template(hass): + """Test the 'or' condition.""" + test = await condition.async_from_config( + hass, + { + "condition": "or", + "conditions": [ + { + "condition": "template", + "value_template": '{{ states.sensor.temperature.state == "100" }}', + }, + { + "condition": "numeric_state", + "entity_id": "sensor.temperature", + "below": 110, + }, + ], + }, + ) + + hass.states.async_set("sensor.temperature", 120) + assert not test(hass) + + hass.states.async_set("sensor.temperature", 105) + assert test(hass) + + hass.states.async_set("sensor.temperature", 100) + assert test(hass) + + +async def test_time_window(hass): + """Test time condition windows.""" + sixam = dt.parse_time("06:00:00") + sixpm = dt.parse_time("18:00:00") + + with patch( + "homeassistant.helpers.condition.dt_util.now", + return_value=dt.now().replace(hour=3), + ): + assert not condition.time(after=sixam, before=sixpm) + assert condition.time(after=sixpm, before=sixam) + + with patch( + "homeassistant.helpers.condition.dt_util.now", + return_value=dt.now().replace(hour=9), + ): + assert condition.time(after=sixam, before=sixpm) + assert not condition.time(after=sixpm, before=sixam) + + with patch( + "homeassistant.helpers.condition.dt_util.now", + return_value=dt.now().replace(hour=15), + ): + assert condition.time(after=sixam, before=sixpm) + assert not condition.time(after=sixpm, before=sixam) + + with patch( + "homeassistant.helpers.condition.dt_util.now", + return_value=dt.now().replace(hour=21), + ): + assert not condition.time(after=sixam, before=sixpm) + assert condition.time(after=sixpm, before=sixam) + + +async def test_if_numeric_state_not_raise_on_unavailable(hass): + """Test numeric_state doesn't raise on unavailable/unknown state.""" + test = await condition.async_from_config( + hass, + {"condition": "numeric_state", "entity_id": "sensor.temperature", "below": 42}, + ) + + with patch("homeassistant.helpers.condition._LOGGER.warning") as logwarn: + hass.states.async_set("sensor.temperature", "unavailable") + assert not test(hass) + assert len(logwarn.mock_calls) == 0 + + hass.states.async_set("sensor.temperature", "unknown") + assert not test(hass) + assert len(logwarn.mock_calls) == 0 diff --git a/tests/helpers/test_translation.py b/tests/helpers/test_translation.py index f4218fb1a7e52a..1c3748250a5fdb 100644 --- a/tests/helpers/test_translation.py +++ b/tests/helpers/test_translation.py @@ -95,30 +95,23 @@ async def test_get_translations(hass, mock_config_flows): assert await async_setup_component(hass, "switch", {"switch": {"platform": "test"}}) translations = await translation.async_get_translations(hass, "en") - assert translations == { - "component.switch.state.string1": "Value 1", - "component.switch.state.string2": "Value 2", - } + + assert translations["component.switch.state.string1"] == "Value 1" + assert translations["component.switch.state.string2"] == "Value 2" translations = await translation.async_get_translations(hass, "de") - assert translations == { - "component.switch.state.string1": "German Value 1", - "component.switch.state.string2": "German Value 2", - } + assert translations["component.switch.state.string1"] == "German Value 1" + assert translations["component.switch.state.string2"] == "German Value 2" # Test a partial translation translations = await translation.async_get_translations(hass, "es") - assert translations == { - "component.switch.state.string1": "Spanish Value 1", - "component.switch.state.string2": "Value 2", - } + assert translations["component.switch.state.string1"] == "Spanish Value 1" + assert translations["component.switch.state.string2"] == "Value 2" # Test that an untranslated language falls back to English. translations = await translation.async_get_translations(hass, "invalid-language") - assert translations == { - "component.switch.state.string1": "Value 1", - "component.switch.state.string2": "Value 2", - } + assert translations["component.switch.state.string1"] == "Value 1" + assert translations["component.switch.state.string2"] == "Value 2" async def test_get_translations_loads_config_flows(hass, mock_config_flows): diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index 98fc70f3bf51f3..ce13ca5a594aa5 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -244,8 +244,12 @@ def release(self): def raise_for_status(self): """Raise error if status is 400 or higher.""" if self.status >= 400: + request_info = mock.Mock(real_url="http://example.com") raise ClientResponseError( - None, None, code=self.status, headers=self.headers + request_info=request_info, + history=None, + code=self.status, + headers=self.headers, ) def close(self): diff --git a/tests/testing_config/custom_components/test/binary_sensor.py b/tests/testing_config/custom_components/test/binary_sensor.py new file mode 100644 index 00000000000000..5052b8e47f10b2 --- /dev/null +++ b/tests/testing_config/custom_components/test/binary_sensor.py @@ -0,0 +1,50 @@ +""" +Provide a mock binary sensor platform. + +Call init before using it in your tests to ensure clean test data. +""" +from homeassistant.components.binary_sensor import BinarySensorDevice, DEVICE_CLASSES +from tests.common import MockEntity + + +ENTITIES = {} + + +def init(empty=False): + """Initialize the platform with entities.""" + global ENTITIES + + ENTITIES = ( + {} + if empty + else { + device_class: MockBinarySensor( + name=f"{device_class} sensor", + is_on=True, + unique_id=f"unique_{device_class}", + device_class=device_class, + ) + for device_class in DEVICE_CLASSES + } + ) + + +async def async_setup_platform( + hass, config, async_add_entities_callback, discovery_info=None +): + """Return mock entities.""" + async_add_entities_callback(list(ENTITIES.values())) + + +class MockBinarySensor(MockEntity, BinarySensorDevice): + """Mock Binary Sensor class.""" + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._handle("is_on") + + @property + def device_class(self): + """Return the class of this sensor.""" + return self._handle("device_class") diff --git a/tests/testing_config/custom_components/test/light.py b/tests/testing_config/custom_components/test/light.py index 43338c9e14ed1f..0a48388b718b1e 100644 --- a/tests/testing_config/custom_components/test/light.py +++ b/tests/testing_config/custom_components/test/light.py @@ -4,23 +4,23 @@ Call init before using it in your tests to ensure clean test data. """ from homeassistant.const import STATE_ON, STATE_OFF -from tests.common import MockToggleDevice +from tests.common import MockToggleEntity -DEVICES = [] +ENTITIES = [] def init(empty=False): - """Initialize the platform with devices.""" - global DEVICES + """Initialize the platform with entities.""" + global ENTITIES - DEVICES = ( + ENTITIES = ( [] if empty else [ - MockToggleDevice("Ceiling", STATE_ON), - MockToggleDevice("Ceiling", STATE_OFF), - MockToggleDevice(None, STATE_OFF), + MockToggleEntity("Ceiling", STATE_ON), + MockToggleEntity("Ceiling", STATE_OFF), + MockToggleEntity(None, STATE_OFF), ] ) @@ -28,5 +28,5 @@ def init(empty=False): async def async_setup_platform( hass, config, async_add_entities_callback, discovery_info=None ): - """Return mock devices.""" - async_add_entities_callback(DEVICES) + """Return mock entities.""" + async_add_entities_callback(ENTITIES) diff --git a/tests/testing_config/custom_components/test/switch.py b/tests/testing_config/custom_components/test/switch.py index f4226ecc63014f..484c47d1190e3c 100644 --- a/tests/testing_config/custom_components/test/switch.py +++ b/tests/testing_config/custom_components/test/switch.py @@ -4,23 +4,23 @@ Call init before using it in your tests to ensure clean test data. """ from homeassistant.const import STATE_ON, STATE_OFF -from tests.common import MockToggleDevice +from tests.common import MockToggleEntity -DEVICES = [] +ENTITIES = [] def init(empty=False): - """Initialize the platform with devices.""" - global DEVICES + """Initialize the platform with entities.""" + global ENTITIES - DEVICES = ( + ENTITIES = ( [] if empty else [ - MockToggleDevice("AC", STATE_ON), - MockToggleDevice("AC", STATE_OFF), - MockToggleDevice(None, STATE_OFF), + MockToggleEntity("AC", STATE_ON), + MockToggleEntity("AC", STATE_OFF), + MockToggleEntity(None, STATE_OFF), ] ) @@ -28,5 +28,5 @@ def init(empty=False): async def async_setup_platform( hass, config, async_add_entities_callback, discovery_info=None ): - """Find and return test switches.""" - async_add_entities_callback(DEVICES) + """Return mock entities.""" + async_add_entities_callback(ENTITIES)