Merge pull request #24776 from home-assistant/rc

0.95.0
This commit is contained in:
Paulus Schoutsen 2019-06-26 10:41:11 -07:00 committed by GitHub
commit 4dbfafa8ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
606 changed files with 19719 additions and 6577 deletions

View File

@ -13,6 +13,10 @@ omit =
homeassistant/components/abode/*
homeassistant/components/acer_projector/switch.py
homeassistant/components/actiontec/device_tracker.py
homeassistant/components/adguard/__init__.py
homeassistant/components/adguard/const.py
homeassistant/components/adguard/sensor.py
homeassistant/components/adguard/switch.py
homeassistant/components/ads/*
homeassistant/components/aftership/sensor.py
homeassistant/components/airvisual/sensor.py
@ -153,6 +157,7 @@ omit =
homeassistant/components/eight_sleep/*
homeassistant/components/eliqonline/sensor.py
homeassistant/components/elkm1/*
homeassistant/components/elv/switch.py
homeassistant/components/emby/media_player.py
homeassistant/components/emoncms/sensor.py
homeassistant/components/emoncms_history/*
@ -161,6 +166,7 @@ omit =
homeassistant/components/enocean/*
homeassistant/components/enphase_envoy/sensor.py
homeassistant/components/entur_public_transport/*
homeassistant/components/environment_canada/*
homeassistant/components/envirophat/sensor.py
homeassistant/components/envisalink/*
homeassistant/components/ephember/climate.py
@ -223,6 +229,7 @@ omit =
homeassistant/components/goalfeed/*
homeassistant/components/gogogate2/cover.py
homeassistant/components/google/*
homeassistant/components/google_cloud/tts.py
homeassistant/components/google_maps/device_tracker.py
homeassistant/components/google_travel_time/sensor.py
homeassistant/components/googlehome/*
@ -312,6 +319,7 @@ omit =
homeassistant/components/lcn/*
homeassistant/components/lg_netcast/media_player.py
homeassistant/components/lg_soundbar/media_player.py
homeassistant/components/life360/*
homeassistant/components/lifx/*
homeassistant/components/lifx_cloud/scene.py
homeassistant/components/lifx_legacy/light.py
@ -444,6 +452,7 @@ omit =
homeassistant/components/ping/device_tracker.py
homeassistant/components/pioneer/media_player.py
homeassistant/components/pjlink/media_player.py
homeassistant/components/plaato/*
homeassistant/components/plex/media_player.py
homeassistant/components/plex/sensor.py
homeassistant/components/plum_lightpad/*
@ -544,6 +553,7 @@ omit =
homeassistant/components/slack/notify.py
homeassistant/components/sma/sensor.py
homeassistant/components/smappee/*
homeassistant/components/smarty/*
homeassistant/components/smarthab/*
homeassistant/components/smtp/notify.py
homeassistant/components/snapcast/media_player.py
@ -551,7 +561,9 @@ omit =
homeassistant/components/sochain/sensor.py
homeassistant/components/socialblade/sensor.py
homeassistant/components/solaredge/sensor.py
homeassistant/components/solaredge_local/sensor.py
homeassistant/components/solax/sensor.py
homeassistant/components/somfy/*
homeassistant/components/somfy_mylink/*
homeassistant/components/sonarr/sensor.py
homeassistant/components/songpal/media_player.py
@ -567,6 +579,7 @@ omit =
homeassistant/components/starlingbank/sensor.py
homeassistant/components/steam_online/sensor.py
homeassistant/components/stiebel_eltron/*
homeassistant/components/streamlabswater/*
homeassistant/components/stride/notify.py
homeassistant/components/supervisord/sensor.py
homeassistant/components/swiss_hydrological_data/sensor.py
@ -620,6 +633,7 @@ omit =
homeassistant/components/tplink/switch.py
homeassistant/components/tplink_lte/*
homeassistant/components/traccar/device_tracker.py
homeassistant/components/traccar/const.py
homeassistant/components/trackr/device_tracker.py
homeassistant/components/tradfri/*
homeassistant/components/tradfri/light.py
@ -651,6 +665,7 @@ omit =
homeassistant/components/viaggiatreno/sensor.py
homeassistant/components/vizio/media_player.py
homeassistant/components/vlc/media_player.py
homeassistant/components/vlc_telnet/media_player.py
homeassistant/components/volkszaehler/sensor.py
homeassistant/components/volumio/media_player.py
homeassistant/components/volvooncall/*

1
.gitignore vendored
View File

@ -95,6 +95,7 @@ virtualization/vagrant/config
# Visual Studio Code
.vscode
.devcontainer
# Built docs
docs/build

View File

@ -17,6 +17,7 @@ virtualization/Docker/* @home-assistant/docker
homeassistant/scripts/check_config.py @kellerza
# Integrations
homeassistant/components/adguard/* @frenck
homeassistant/components/airvisual/* @bachya
homeassistant/components/alarm_control_panel/* @colinodell
homeassistant/components/alpha_vantage/* @fabaff
@ -24,12 +25,14 @@ homeassistant/components/amazon_polly/* @robbiet480
homeassistant/components/ambiclimate/* @danielhiversen
homeassistant/components/ambient_station/* @bachya
homeassistant/components/api/* @home-assistant/core
homeassistant/components/aprs/* @PhilRW
homeassistant/components/arduino/* @fabaff
homeassistant/components/arest/* @fabaff
homeassistant/components/asuswrt/* @kennedyshead
homeassistant/components/auth/* @home-assistant/core
homeassistant/components/automatic/* @armills
homeassistant/components/automation/* @home-assistant/core
homeassistant/components/awair/* @danielsjf
homeassistant/components/aws/* @awarecan @robbiet480
homeassistant/components/axis/* @kane610
homeassistant/components/azure_event_hub/* @eavanvalkenburg
@ -41,6 +44,7 @@ homeassistant/components/braviatv/* @robbiet480
homeassistant/components/broadlink/* @danielhiversen
homeassistant/components/brunt/* @eavanvalkenburg
homeassistant/components/bt_smarthub/* @jxwolstenholme
homeassistant/components/buienradar/* @ties
homeassistant/components/cisco_ios/* @fbradyirl
homeassistant/components/cisco_mobility_express/* @fbradyirl
homeassistant/components/cisco_webex_teams/* @fbradyirl
@ -59,6 +63,7 @@ homeassistant/components/daikin/* @fredrike @rofrantz
homeassistant/components/darksky/* @fabaff
homeassistant/components/deconz/* @kane610
homeassistant/components/demo/* @home-assistant/core
homeassistant/components/device_automation/* @home-assistant/core
homeassistant/components/digital_ocean/* @fabaff
homeassistant/components/discogs/* @thibmaek
homeassistant/components/doorbird/* @oblogic7
@ -67,9 +72,11 @@ homeassistant/components/ecovacs/* @OverloadUT
homeassistant/components/edp_redy/* @abmantis
homeassistant/components/egardia/* @jeroenterheerdt
homeassistant/components/eight_sleep/* @mezz64
homeassistant/components/elv/* @majuss
homeassistant/components/emby/* @mezz64
homeassistant/components/enigma2/* @fbradyirl
homeassistant/components/enocean/* @bdurrer
homeassistant/components/environment_canada/* @michaeldavie
homeassistant/components/ephember/* @ttroy50
homeassistant/components/epsonworkforce/* @ThaStealth
homeassistant/components/eq3btsmart/* @rytilahti
@ -90,6 +97,7 @@ homeassistant/components/geniushub/* @zxdavb
homeassistant/components/gitter/* @fabaff
homeassistant/components/glances/* @fabaff
homeassistant/components/gntp/* @robbiet480
homeassistant/components/google_cloud/* @lufton
homeassistant/components/google_translate/* @awarecan
homeassistant/components/google_travel_time/* @robbiet480
homeassistant/components/googlehome/* @ludeeus
@ -108,6 +116,7 @@ homeassistant/components/homeassistant/* @home-assistant/core
homeassistant/components/homekit/* @cdce8p
homeassistant/components/homekit_controller/* @Jc2k
homeassistant/components/homematic/* @pvizeli @danielperna84
homeassistant/components/honeywell/* @zxdavb
homeassistant/components/html5/* @robbiet480
homeassistant/components/http/* @home-assistant/core
homeassistant/components/huawei_lte/* @scop
@ -133,9 +142,11 @@ homeassistant/components/konnected/* @heythisisnate
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/* @tiste @Quentame
homeassistant/components/linux_battery/* @fabaff
homeassistant/components/liveboxplaytv/* @pschmitt
homeassistant/components/logger/* @home-assistant/core
@ -149,6 +160,7 @@ homeassistant/components/mcp23017/* @jardiamj
homeassistant/components/mediaroom/* @dgomes
homeassistant/components/melissa/* @kennedyshead
homeassistant/components/met/* @danielhiversen
homeassistant/components/meteo_france/* @victorcerutti @oncleben31
homeassistant/components/meteoalarm/* @rolfberkenbosch
homeassistant/components/miflora/* @danielhiversen @ChristianKuehnel
homeassistant/components/mill/* @danielhiversen
@ -181,12 +193,14 @@ 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/plaato/* @JohNan
homeassistant/components/plant/* @ChristianKuehnel
homeassistant/components/point/* @fredrike
homeassistant/components/ps4/* @ktnrg45
homeassistant/components/ptvsd/* @swamp-ig
homeassistant/components/push/* @dgomes
homeassistant/components/pvoutput/* @fabaff
homeassistant/components/qld_bushfire/* @exxamalte
homeassistant/components/qnap/* @colinodell
homeassistant/components/quantum_gateway/* @cisasteelersfan
homeassistant/components/qwikswitch/* @kellerza
@ -201,6 +215,7 @@ homeassistant/components/ruter/* @ludeeus
homeassistant/components/scene/* @home-assistant/core
homeassistant/components/scrape/* @fabaff
homeassistant/components/script/* @home-assistant/core
homeassistant/components/sense/* @kbickar
homeassistant/components/sensibo/* @andrey-git
homeassistant/components/serial/* @fabaff
homeassistant/components/seventeentrack/* @bachya
@ -211,8 +226,11 @@ homeassistant/components/simplisafe/* @bachya
homeassistant/components/sma/* @kellerza
homeassistant/components/smarthab/* @outadoc
homeassistant/components/smartthings/* @andrewsayre
homeassistant/components/smarty/* @z0mbieprocess
homeassistant/components/smtp/* @fabaff
homeassistant/components/solaredge_local/* @drobtravels
homeassistant/components/solax/* @squishykid
homeassistant/components/somfy/* @tetienne
homeassistant/components/sonos/* @amelchio
homeassistant/components/spaceapi/* @fabaff
homeassistant/components/spider/* @peternijssen
@ -248,7 +266,6 @@ homeassistant/components/tradfri/* @ggravlingen
homeassistant/components/tts/* @robbiet480
homeassistant/components/twilio_call/* @robbiet480
homeassistant/components/twilio_sms/* @robbiet480
homeassistant/components/uber/* @robbiet480
homeassistant/components/unifi/* @kane610
homeassistant/components/upcloud/* @scop
homeassistant/components/updater/* @home-assistant/core
@ -258,6 +275,7 @@ homeassistant/components/utility_meter/* @dgomes
homeassistant/components/velux/* @Julius2342
homeassistant/components/version/* @fabaff
homeassistant/components/vizio/* @raman325
homeassistant/components/vlc_telnet/* @rodripf
homeassistant/components/waqi/* @andrey-git
homeassistant/components/watson_tts/* @rutkai
homeassistant/components/weather/* @fabaff
@ -275,6 +293,7 @@ homeassistant/components/yeelight/* @rytilahti @zewelor
homeassistant/components/yeelightsunflower/* @lindsaymarkward
homeassistant/components/yessssms/* @flowolf
homeassistant/components/yi/* @bachya
homeassistant/components/yr/* @danielhiversen
homeassistant/components/zeroconf/* @robbiet480 @Kane610
homeassistant/components/zha/* @dmulcahey @adminiuga
homeassistant/components/zone/* @home-assistant/core

150
azure-pipelines-ci.yml Normal file
View File

@ -0,0 +1,150 @@
# https://dev.azure.com/home-assistant
trigger:
batch: true
branches:
include:
- dev
pr: none
resources:
containers:
- container: 35
image: homeassistant/ci-azure:3.5
- container: 36
image: homeassistant/ci-azure:3.6
- container: 37
image: homeassistant/ci-azure:3.7
variables:
- name: ArtifactFeed
value: '2df3ae11-3bf6-49bc-a809-ba0d340d6a6d'
- name: PythonMain
value: '35'
jobs:
- job: 'Lint'
pool:
vmImage: 'ubuntu-latest'
container: $[ variables['PythonMain'] ]
steps:
- script: |
python -m venv lint
. lint/bin/activate
pip install flake8
flake8 homeassistant tests script
displayName: 'Run flake8'
- job: 'Check'
dependsOn:
- Lint
pool:
vmImage: 'ubuntu-latest'
strategy:
maxParallel: 1
matrix:
Python35:
python.version: '3.5'
python.container: '35'
Python36:
python.version: '3.6'
python.container: '36'
Python37:
python.version: '3.7'
python.container: '37'
container: $[ variables['python.container'] ]
steps:
- script: |
echo "$(python.version)" > .cache
displayName: 'Set python $(python.version) for requirement cache'
- task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1
displayName: 'Restore artifacts based on Requirements'
inputs:
keyfile: 'requirements_test_all.txt, .cache'
targetfolder: './venv'
vstsFeed: '$(ArtifactFeed)'
- script: |
set -e
python -m venv venv
. venv/bin/activate
pip install -U pip setuptools
pip install -r requirements_test_all.txt -c homeassistant/package_constraints.txt
displayName: 'Create Virtual Environment & Install Requirements'
condition: and(succeeded(), ne(variables['CacheRestored'], 'true'))
- task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1
displayName: 'Save artifacts based on Requirements'
inputs:
keyfile: 'requirements_test_all.txt, .cache'
targetfolder: './venv'
vstsFeed: '$(ArtifactFeed)'
- script: |
. venv/bin/activate
pip install -e .
displayName: 'Install Home Assistant for python $(python.version)'
- script: |
. venv/bin/activate
pytest --timeout=9 --durations=10 --junitxml=junit/test-results.xml -qq -o console_output_style=count -p no:sugar tests
displayName: 'Run pytest for python $(python.version)'
- task: PublishTestResults@2
condition: succeededOrFailed()
inputs:
testResultsFiles: '**/test-*.xml'
testRunTitle: 'Publish test results for Python $(python.version)'
- job: 'FullCheck'
dependsOn:
- Check
pool:
vmImage: 'ubuntu-latest'
container: $[ variables['PythonMain'] ]
steps:
- script: |
echo "$(PythonMain)" > .cache
displayName: 'Set python $(python.version) for requirement cache'
- task: 1ESLighthouseEng.PipelineArtifactCaching.RestoreCacheV1.RestoreCache@1
displayName: 'Restore artifacts based on Requirements'
inputs:
keyfile: 'requirements_all.txt, requirements_test.txt, .cache'
targetfolder: './venv'
vstsFeed: '$(ArtifactFeed)'
- script: |
set -e
python -m venv venv
. venv/bin/activate
pip install -U pip setuptools
pip install -r requirements_all.txt -c homeassistant/package_constraints.txt
pip install -r requirements_test.txt -c homeassistant/package_constraints.txt
displayName: 'Create Virtual Environment & Install Requirements'
condition: and(succeeded(), ne(variables['CacheRestored'], 'true'))
- task: 1ESLighthouseEng.PipelineArtifactCaching.SaveCacheV1.SaveCache@1
displayName: 'Save artifacts based on Requirements'
inputs:
keyfile: 'requirements_all.txt, requirements_test.txt, .cache'
targetfolder: './venv'
vstsFeed: '$(ArtifactFeed)'
- script: |
. venv/bin/activate
pip install -e .
displayName: 'Install Home Assistant for python $(python.version)'
- script: |
. venv/bin/activate
pylint homeassistant
displayName: 'Run pylint'

View File

@ -8,7 +8,7 @@ trigger:
pr: none
variables:
- name: versionBuilder
value: '3.2'
value: '4.2'
- group: docker
- group: github
- group: twine

100
azure-pipelines-wheels.yml Normal file
View File

@ -0,0 +1,100 @@
# https://dev.azure.com/home-assistant
trigger:
batch: true
branches:
include:
- dev
paths:
include:
- requirements_all.txt
pr: none
variables:
- name: versionWheels
value: '0.7'
- group: wheels
jobs:
- job: 'Wheels'
condition: or(eq(variables['Build.SourceBranchName'], 'dev'), eq(variables['Build.SourceBranchName'], 'master'))
timeoutInMinutes: 360
pool:
vmImage: 'ubuntu-latest'
strategy:
maxParallel: 3
matrix:
amd64:
buildArch: 'amd64'
i386:
buildArch: 'i386'
armhf:
buildArch: 'armhf'
armv7:
buildArch: 'armv7'
aarch64:
buildArch: 'aarch64'
steps:
- script: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
qemu-user-static \
binfmt-support \
curl
sudo mount binfmt_misc -t binfmt_misc /proc/sys/fs/binfmt_misc
sudo update-binfmts --enable qemu-arm
sudo update-binfmts --enable qemu-aarch64
displayName: 'Initial cross build'
- script: |
mkdir -p .ssh
echo -e "-----BEGIN RSA PRIVATE KEY-----\n$(wheelsSSH)\n-----END RSA PRIVATE KEY-----" >> .ssh/id_rsa
ssh-keyscan -H $(wheelsHost) >> .ssh/known_hosts
chmod 600 .ssh/*
displayName: 'Install ssh key'
- script: sudo docker pull homeassistant/$(buildArch)-wheels:$(versionWheels)
displayName: 'Install wheels builder'
- script: |
cp requirements_all.txt requirements_wheels.txt
if [[ "$(Build.Reason)" =~ (Schedule|Manual) ]]; then
touch requirements_diff.txt
else
curl -s -o requirements_diff.txt https://raw.githubusercontent.com/home-assistant/home-assistant/master/requirements_all.txt
fi
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}
sed -i "s|# RPi.GPIO|RPi.GPIO|g" ${requirement_file}
sed -i "s|# raspihats|raspihats|g" ${requirement_file}
sed -i "s|# rpi-rf|rpi-rf|g" ${requirement_file}
sed -i "s|# blinkt|blinkt|g" ${requirement_file}
sed -i "s|# fritzconnection|fritzconnection|g" ${requirement_file}
sed -i "s|# pyuserinput|pyuserinput|g" ${requirement_file}
sed -i "s|# evdev|evdev|g" ${requirement_file}
sed -i "s|# smbus-cffi|smbus-cffi|g" ${requirement_file}
sed -i "s|# i2csense|i2csense|g" ${requirement_file}
sed -i "s|# python-eq3bt|python-eq3bt|g" ${requirement_file}
sed -i "s|# pycups|pycups|g" ${requirement_file}
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|# PySwitchbot|PySwitchbot|g" ${requirement_file}
sed -i "s|# pySwitchmate|pySwitchmate|g" ${requirement_file}
sed -i "s|# face_recognition|face_recognition|g" ${requirement_file}
done
displayName: 'Prepare requirements files for Hass.io'
- script: |
sudo docker run --rm -v $(pwd):/data:ro -v $(pwd)/.ssh:/root/.ssh:rw \
homeassistant/$(buildArch)-wheels:$(versionWheels) \
--apk "build-base;cmake;git;linux-headers;bluez-dev;libffi-dev;openssl-dev;glib-dev;eudev-dev;libxml2-dev;libxslt-dev;libpng-dev;libjpeg-turbo-dev;tiff-dev;autoconf;automake;cups-dev;linux-headers;gmp-dev;mpfr-dev;mpc1-dev;ffmpeg-dev" \
--index $(wheelsIndex) \
--requirement requirements_wheels.txt \
--requirement-diff requirements_diff.txt \
--upload rsync \
--remote wheels@$(wheelsHost):/opt/wheels
displayName: 'Run wheels build'

View File

@ -17,7 +17,6 @@ from homeassistant.util.logging import AsyncHandler
from homeassistant.util.package import async_get_user_site, is_virtual_env
from homeassistant.util.yaml import clear_secret_cache
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
_LOGGER = logging.getLogger(__name__)
@ -101,51 +100,6 @@ async def async_from_config_dict(config: Dict[str, Any],
"upgrade Python.", "Python version", "python_version"
)
# TEMP: warn users for invalid slugs
# Remove after 0.94 or 1.0
if cv.INVALID_SLUGS_FOUND or cv.INVALID_ENTITY_IDS_FOUND:
msg = []
if cv.INVALID_ENTITY_IDS_FOUND:
msg.append(
"Your configuration contains invalid entity ID references. "
"Please find and update the following. "
"This will become a breaking change."
)
msg.append('\n'.join('- {} -> {}'.format(*item)
for item
in cv.INVALID_ENTITY_IDS_FOUND.items()))
if cv.INVALID_SLUGS_FOUND:
msg.append(
"Your configuration contains invalid slugs. "
"Please find and update the following. "
"This will become a breaking change."
)
msg.append('\n'.join('- {} -> {}'.format(*item)
for item in cv.INVALID_SLUGS_FOUND.items()))
hass.components.persistent_notification.async_create(
'\n\n'.join(msg), "Config Warning", "config_warning"
)
# TEMP: warn users of invalid extra keys
# Remove after 0.92
if cv.INVALID_EXTRA_KEYS_FOUND:
msg = []
msg.append(
"Your configuration contains extra keys "
"that the platform does not support (but were silently "
"accepted before 0.88). Please find and remove the following."
"This will become a breaking change."
)
msg.append('\n'.join('- {}'.format(it)
for it in cv.INVALID_EXTRA_KEYS_FOUND))
hass.components.persistent_notification.async_create(
'\n\n'.join(msg), "Config Warning", "config_warning"
)
return hass

View File

@ -0,0 +1,29 @@
{
"config": {
"abort": {
"single_instance_allowed": "Nom\u00e9s es permet una \u00fanica configuraci\u00f3 d'AdGuard Home."
},
"error": {
"connection_error": "No s'ha pogut connectar."
},
"step": {
"hassio_confirm": {
"description": "Vols configurar Home Assistant perqu\u00e8 es connecti amb l'AdGuard Home proporcionat pel complement de Hass.io: {addon}?",
"title": "AdGuard Home (complement de Hass.io)"
},
"user": {
"data": {
"host": "Amfitri\u00f3",
"password": "Contrasenya",
"port": "Port",
"ssl": "AdGuard Home utilitza un certificat SSL",
"username": "Nom d'usuari",
"verify_ssl": "AdGuard Home utilitza un certificat adequat"
},
"description": "Configuraci\u00f3 de la inst\u00e0ncia d'AdGuard Home, permet el control i la monitoritzaci\u00f3.",
"title": "Enlla\u00e7ar AdGuard Home."
}
},
"title": "AdGuard Home"
}
}

View File

@ -0,0 +1,28 @@
{
"config": {
"abort": {
"single_instance_allowed": "Es ist nur eine einzige Konfiguration von AdGuard Home zul\u00e4ssig."
},
"error": {
"connection_error": "Fehler beim Herstellen einer Verbindung."
},
"step": {
"hassio_confirm": {
"description": "M\u00f6chtest du Home Assistant so konfigurieren, dass eine Verbindung mit AdGuard Home als Hass.io-Add-On hergestellt wird: {addon}?",
"title": "AdGuard Home \u00fcber das Hass.io Add-on"
},
"user": {
"data": {
"host": "Host",
"password": "Passwort",
"port": "Port",
"ssl": "AdGuard Home verwendet ein SSL-Zertifikat",
"username": "Benutzername",
"verify_ssl": "AdGuard Home verwendet ein richtiges Zertifikat"
},
"description": "Richte deine AdGuard Home-Instanz ein um sie zu \u00dcberwachen und zu Steuern.",
"title": "Verkn\u00fcpfe AdGuard Home."
}
}
}
}

View File

@ -0,0 +1,29 @@
{
"config": {
"abort": {
"single_instance_allowed": "Only a single configuration of AdGuard Home is allowed."
},
"error": {
"connection_error": "Failed to connect."
},
"step": {
"hassio_confirm": {
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the Hass.io add-on: {addon}?",
"title": "AdGuard Home via Hass.io add-on"
},
"user": {
"data": {
"host": "Host",
"password": "Password",
"port": "Port",
"ssl": "AdGuard Home uses a SSL certificate",
"username": "Username",
"verify_ssl": "AdGuard Home uses a proper certificate"
},
"description": "Set up your AdGuard Home instance to allow monitoring and control.",
"title": "Link your AdGuard Home."
}
},
"title": "AdGuard Home"
}
}

View File

@ -0,0 +1,27 @@
{
"config": {
"abort": {
"single_instance_allowed": "Solo se permite una \u00fanica configuraci\u00f3n de AdGuard Home."
},
"error": {
"connection_error": "Error al conectar."
},
"step": {
"hassio_confirm": {
"description": "\u00bfDesea configurar Home Assistant para conectarse a la p\u00e1gina principal de AdGuard proporcionada por el complemento Hass.io: {addon}?",
"title": "AdGuard Home a trav\u00e9s del complemento Hass.io"
},
"user": {
"data": {
"password": "Contrase\u00f1a",
"port": "Puerto",
"ssl": "AdGuard Home utiliza un certificado SSL",
"username": "Nombre de usuario",
"verify_ssl": "AdGuard Home utiliza un certificado adecuado"
},
"description": "Configure su instancia de AdGuard Home para permitir la supervisi\u00f3n y el control."
}
},
"title": "AdGuard Home"
}
}

View File

@ -0,0 +1,21 @@
{
"config": {
"abort": {
"single_instance_allowed": "\u00c8 consentita solo una singola configurazione di AdGuard Home."
},
"error": {
"connection_error": "Impossibile connettersi."
},
"step": {
"user": {
"data": {
"host": "Host",
"password": "Password",
"port": "Porta",
"ssl": "AdGuard Home utilizza un certificato SSL",
"username": "Nome utente"
}
}
}
}
}

View File

@ -0,0 +1,29 @@
{
"config": {
"abort": {
"single_instance_allowed": "\ud558\ub098\uc758 AdGuard Home \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4."
},
"error": {
"connection_error": "\uc5f0\uacb0\ud558\uc9c0 \ubabb\ud588\uc2b5\ub2c8\ub2e4."
},
"step": {
"hassio_confirm": {
"description": "Hass.io {addon} \uc560\ub4dc\uc628\uc73c\ub85c AdGuard Home \uc5d0 \uc5f0\uacb0\ud558\ub3c4\ub85d Home Assistant \ub97c \uad6c\uc131 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?",
"title": "Hass.io \uc560\ub4dc\uc628\uc758 AdGuard Home"
},
"user": {
"data": {
"host": "\ud638\uc2a4\ud2b8",
"password": "\ube44\ubc00\ubc88\ud638",
"port": "\ud3ec\ud2b8",
"ssl": "AdGuard Home \uc740 SSL \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud569\ub2c8\ub2e4",
"username": "\uc0ac\uc6a9\uc790 \uc774\ub984",
"verify_ssl": "AdGuard Home \uc740 \uc62c\ubc14\ub978 \uc778\uc99d\uc11c\ub97c \uc0ac\uc6a9\ud558\uace0 \uc788\uc2b5\ub2c8\ub2e4"
},
"description": "\ubaa8\ub2c8\ud130\ub9c1 \ubc0f \uc81c\uc5b4\uac00 \uac00\ub2a5\ud558\ub3c4\ub85d AdGuard Home \uc778\uc2a4\ud134\uc2a4\ub97c \uc124\uc815\ud574\uc8fc\uc138\uc694.",
"title": "AdGuard Home \uc5f0\uacb0"
}
},
"title": "AdGuard Home"
}
}

View File

@ -0,0 +1,29 @@
{
"config": {
"abort": {
"single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun AdGuard Home ass erlaabt."
},
"error": {
"connection_error": "Feeler beim verbannen."
},
"step": {
"hassio_confirm": {
"description": "W\u00ebllt dir Home Assistant konfigur\u00e9iere fir sech mam AdGuard Home ze verbannen dee vum hass.io add-on {addon} bereet gestallt g\u00ebtt?",
"title": "AdGuard Home via Hass.io add-on"
},
"user": {
"data": {
"host": "Apparat",
"password": "Passwuert",
"port": "Port",
"ssl": "AdGuard Home benotzt een SSL Zertifikat",
"username": "Benotzernumm",
"verify_ssl": "AdGuard Home benotzt een eegenen Zertifikat"
},
"description": "Konfigur\u00e9iert \u00e4r AdGuard Home Instanz fir d'Iwwerwaachung an d'Kontroll z'erlaben.",
"title": "Verbannt \u00e4ren AdGuard Home"
}
},
"title": "AdGuard Home"
}
}

View File

@ -0,0 +1,29 @@
{
"config": {
"abort": {
"single_instance_allowed": "Slechts \u00e9\u00e9n configuratie van AdGuard Home is toegestaan."
},
"error": {
"connection_error": "Kon niet verbinden."
},
"step": {
"hassio_confirm": {
"description": "Wilt u Home Assistant configureren om verbinding te maken met AdGuard Home van de Hass.io-add-on: {addon}?",
"title": "AdGuard Home via Hass.io add-on"
},
"user": {
"data": {
"host": "Host",
"password": "Wachtwoord",
"port": "Poort",
"ssl": "AdGuard Home maakt gebruik van een SSL certificaat",
"username": "Gebruikersnaam",
"verify_ssl": "AdGuard Home maakt gebruik van een goed certificaat"
},
"description": "Stel uw AdGuard Home-instantie in om toezicht en controle mogelijk te maken.",
"title": "Link uw AdGuard Home."
}
},
"title": "AdGuard Home"
}
}

View File

@ -0,0 +1,29 @@
{
"config": {
"abort": {
"single_instance_allowed": "Kun \u00e9n enkelt konfigurasjon av AdGuard Hjemer tillatt."
},
"error": {
"connection_error": "Tilkobling mislyktes."
},
"step": {
"hassio_confirm": {
"description": "Vil du konfigurere Home Assistant til \u00e5 koble til AdGuard Hjem gitt av hass.io tillegget {addon}?",
"title": "AdGuard Hjem via Hass.io tillegg"
},
"user": {
"data": {
"host": "Vert",
"password": "Passord",
"port": "Port",
"ssl": "AdGuard Hjem bruker et SSL-sertifikat",
"username": "Brukernavn",
"verify_ssl": "AdGuard Home bruker et riktig sertifikat"
},
"description": "Sett opp din AdGuard Hjem instans for \u00e5 tillate overv\u00e5king og kontroll.",
"title": "Koble til ditt AdGuard Hjem."
}
},
"title": "AdGuard Hjem"
}
}

View File

@ -0,0 +1,29 @@
{
"config": {
"abort": {
"single_instance_allowed": "Dozwolona jest tylko jedna konfiguracja AdGuard Home."
},
"error": {
"connection_error": "Po\u0142\u0105czenie nieudane."
},
"step": {
"hassio_confirm": {
"description": "Czy chcesz skonfigurowa\u0107 Home Assistant'a, aby po\u0142\u0105czy\u0142 si\u0119 z AdGuard Home przez dodatek Hass.io {addon}?",
"title": "AdGuard Home przez dodatek Hass.io"
},
"user": {
"data": {
"host": "Host",
"password": "Has\u0142o",
"port": "Port",
"ssl": "AdGuard Home u\u017cywa certyfikatu SSL",
"username": "Nazwa u\u017cytkownika",
"verify_ssl": "AdGuard Home u\u017cywa odpowiedniego certyfikatu."
},
"description": "Skonfiguruj swoj\u0105 instancj\u0119 AdGuard Home, aby umo\u017cliwi\u0107 monitorowanie i nadz\u00f3r sieci.",
"title": "Po\u0142\u0105cz sw\u00f3j AdGuard Home"
}
},
"title": "AdGuard Home"
}
}

View File

@ -0,0 +1,29 @@
{
"config": {
"abort": {
"single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do AdGuard Home \u00e9 permitida."
},
"error": {
"connection_error": "Falhou ao conectar."
},
"step": {
"hassio_confirm": {
"description": "Deseja configurar o Home Assistant para se conectar ao AdGuard Home fornecido pelo complemento Hass.io: {addon} ?",
"title": "AdGuard Home via add-on Hass.io"
},
"user": {
"data": {
"host": "Host",
"password": "Senha",
"port": "Porta",
"ssl": "O AdGuard Home usa um certificado SSL",
"username": "Nome de usu\u00e1rio",
"verify_ssl": "O AdGuard Home usa um certificado apropriado"
},
"description": "Configure sua inst\u00e2ncia do AdGuard Home para permitir o monitoramento e o controle.",
"title": "Vincule o seu AdGuard Home."
}
},
"title": "AdGuard Home"
}
}

View File

@ -0,0 +1,29 @@
{
"config": {
"abort": {
"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."
},
"error": {
"connection_error": "\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f."
},
"step": {
"hassio_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 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043a AdGuard Home (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io \"{addon}\")?",
"title": "AdGuard Home (\u0440\u0430\u0441\u0448\u0438\u0440\u0435\u043d\u0438\u0435 \u0434\u043b\u044f Hass.io)"
},
"user": {
"data": {
"host": "\u0425\u043e\u0441\u0442",
"password": "\u041f\u0430\u0440\u043e\u043b\u044c",
"port": "\u041f\u043e\u0440\u0442",
"ssl": "AdGuard Home \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442 SSL",
"username": "\u041b\u043e\u0433\u0438\u043d",
"verify_ssl": "AdGuard Home \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442 \u0441\u043e\u0431\u0441\u0442\u0432\u0435\u043d\u043d\u044b\u0439 \u0441\u0435\u0440\u0442\u0438\u0444\u0438\u043a\u0430\u0442"
},
"description": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u0442\u0435 \u044d\u0442\u043e\u0442 \u043a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u0434\u043b\u044f \u043c\u043e\u043d\u0438\u0442\u043e\u0440\u0438\u043d\u0433\u0430 \u0438 \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u044f AdGuard Home.",
"title": "AdGuard Home"
}
},
"title": "AdGuard Home"
}
}

View File

@ -0,0 +1,29 @@
{
"config": {
"abort": {
"single_instance_allowed": "Dovoljena je samo ena konfiguracija AdGuard Home."
},
"error": {
"connection_error": "Povezava ni uspela."
},
"step": {
"hassio_confirm": {
"description": "\u017delite konfigurirati Home Assistant-a za povezavo z AdGuard Home, ki ga ponuja hass.io add-on {addon} ?",
"title": "AdGuard Home preko dodatka Hass.io"
},
"user": {
"data": {
"host": "Gostitelj",
"password": "Geslo",
"port": "Vrata",
"ssl": "AdGuard Home uporablja SSL certifikat",
"username": "Uporabni\u0161ko ime",
"verify_ssl": "AdGuard Home uporablja ustrezen certifikat"
},
"description": "Nastavite primerek AdGuard Home, da omogo\u010dite spremljanje in nadzor.",
"title": "Pove\u017eite svoj AdGuard Home."
}
},
"title": "AdGuard Home"
}
}

View File

@ -0,0 +1,29 @@
{
"config": {
"abort": {
"single_instance_allowed": "Endast en enda konfiguration av AdGuard Home \u00e4r till\u00e5ten."
},
"error": {
"connection_error": "Det gick inte att ansluta."
},
"step": {
"hassio_confirm": {
"description": "Vill du konfigurera Home Assistant f\u00f6r att ansluta till AdGuard Home som tillhandah\u00e5lls av Hass.io Add-on: {addon}?",
"title": "AdGuard Home via Hass.io-till\u00e4gget"
},
"user": {
"data": {
"host": "V\u00e4rd",
"password": "L\u00f6senord",
"port": "Port",
"ssl": "AdGuard Home anv\u00e4nder ett SSL-certifikat",
"username": "Anv\u00e4ndarnamn",
"verify_ssl": "AdGuard Home anv\u00e4nder ett korrekt certifikat"
},
"description": "St\u00e4ll in din AdGuard Home-instans f\u00f6r att till\u00e5ta \u00f6vervakning och kontroll.",
"title": "L\u00e4nka din AdGuard Home."
}
},
"title": "AdGuard Home"
}
}

View File

@ -0,0 +1,14 @@
{
"config": {
"step": {
"user": {
"data": {
"host": "\u0110\u1ecba ch\u1ec9",
"password": "M\u1eadt kh\u1ea9u",
"port": "C\u1ed5ng",
"username": "T\u00ean \u0111\u0103ng nh\u1eadp"
}
}
}
}
}

View File

@ -0,0 +1,29 @@
{
"config": {
"abort": {
"single_instance_allowed": "\u50c5\u5141\u8a31\u8a2d\u5b9a\u4e00\u7d44 AdGuard Home\u3002"
},
"error": {
"connection_error": "\u9023\u7dda\u5931\u6557\u3002"
},
"step": {
"hassio_confirm": {
"description": "\u662f\u5426\u8981\u8a2d\u5b9a Home Assistant \u4ee5\u4f7f\u7528 Hass.io \u9644\u52a0\u7d44\u4ef6\uff1a{addon} \u9023\u7dda\u81f3 AdGuard Home\uff1f",
"title": "\u4f7f\u7528 Hass.io \u9644\u52a0\u7d44\u4ef6 AdGuard Home"
},
"user": {
"data": {
"host": "\u4e3b\u6a5f\u7aef",
"password": "\u5bc6\u78bc",
"port": "\u901a\u8a0a\u57e0",
"ssl": "AdGuard Home \u4f7f\u7528 SSL \u8a8d\u8b49",
"username": "\u4f7f\u7528\u8005\u540d\u7a31",
"verify_ssl": "AdGuard Home \u4f7f\u7528\u5c0d\u61c9\u8a8d\u8b49"
},
"description": "\u8a2d\u5b9a AdGuard Home \u4ee5\u9032\u884c\u76e3\u63a7\u3002",
"title": "\u9023\u7d50 AdGuard Home\u3002"
}
},
"title": "AdGuard Home"
}
}

View File

@ -0,0 +1,180 @@
"""Support for AdGuard Home."""
import logging
from typing import Any, Dict
from adguardhome import AdGuardHome, AdGuardHomeError
import voluptuous as vol
from homeassistant.components.adguard.const import (
CONF_FORCE, DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN,
SERVICE_ADD_URL, SERVICE_DISABLE_URL, SERVICE_ENABLE_URL, SERVICE_REFRESH,
SERVICE_REMOVE_URL)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_URL,
CONF_USERNAME, CONF_VERIFY_SSL)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.typing import ConfigType, HomeAssistantType
_LOGGER = logging.getLogger(__name__)
SERVICE_URL_SCHEMA = vol.Schema({vol.Required(CONF_URL): cv.url})
SERVICE_ADD_URL_SCHEMA = vol.Schema(
{vol.Required(CONF_NAME): cv.string, vol.Required(CONF_URL): cv.url}
)
SERVICE_REFRESH_SCHEMA = vol.Schema(
{vol.Optional(CONF_FORCE, default=False): cv.boolean}
)
async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool:
"""Set up the AdGuard Home components."""
return True
async def async_setup_entry(
hass: HomeAssistantType, entry: ConfigEntry
) -> bool:
"""Set up AdGuard Home from a config entry."""
session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL])
adguard = AdGuardHome(
entry.data[CONF_HOST],
port=entry.data[CONF_PORT],
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
tls=entry.data[CONF_SSL],
verify_ssl=entry.data[CONF_VERIFY_SSL],
loop=hass.loop,
session=session,
)
hass.data.setdefault(DOMAIN, {})[DATA_ADGUARD_CLIENT] = adguard
for component in 'sensor', 'switch':
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
async def add_url(call) -> None:
"""Service call to add a new filter subscription to AdGuard Home."""
await adguard.filtering.add_url(
call.data.get(CONF_NAME), call.data.get(CONF_URL)
)
async def remove_url(call) -> None:
"""Service call to remove a filter subscription from AdGuard Home."""
await adguard.filtering.remove_url(call.data.get(CONF_URL))
async def enable_url(call) -> None:
"""Service call to enable a filter subscription in AdGuard Home."""
await adguard.filtering.enable_url(call.data.get(CONF_URL))
async def disable_url(call) -> None:
"""Service call to disable a filter subscription in AdGuard Home."""
await adguard.filtering.disable_url(call.data.get(CONF_URL))
async def refresh(call) -> None:
"""Service call to refresh the filter subscriptions in AdGuard Home."""
await adguard.filtering.refresh(call.data.get(CONF_FORCE))
hass.services.async_register(
DOMAIN, SERVICE_ADD_URL, add_url, schema=SERVICE_ADD_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_REMOVE_URL, remove_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_ENABLE_URL, enable_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_DISABLE_URL, disable_url, schema=SERVICE_URL_SCHEMA
)
hass.services.async_register(
DOMAIN, SERVICE_REFRESH, refresh, schema=SERVICE_REFRESH_SCHEMA
)
return True
async def async_unload_entry(
hass: HomeAssistantType, entry: ConfigType
) -> bool:
"""Unload AdGuard Home config entry."""
hass.services.async_remove(DOMAIN, SERVICE_ADD_URL)
hass.services.async_remove(DOMAIN, SERVICE_REMOVE_URL)
hass.services.async_remove(DOMAIN, SERVICE_ENABLE_URL)
hass.services.async_remove(DOMAIN, SERVICE_DISABLE_URL)
hass.services.async_remove(DOMAIN, SERVICE_REFRESH)
for component in 'sensor', 'switch':
await hass.config_entries.async_forward_entry_unload(entry, component)
del hass.data[DOMAIN]
return True
class AdGuardHomeEntity(Entity):
"""Defines a base AdGuard Home entity."""
def __init__(self, adguard, name: str, icon: str) -> None:
"""Initialize the AdGuard Home entity."""
self._name = name
self._icon = icon
self._available = True
self.adguard = adguard
@property
def name(self) -> str:
"""Return the name of the entity."""
return self._name
@property
def icon(self) -> str:
"""Return the mdi icon of the entity."""
return self._icon
@property
def available(self) -> bool:
"""Return True if entity is available."""
return self._available
async def async_update(self) -> None:
"""Update AdGuard Home entity."""
try:
await self._adguard_update()
self._available = True
except AdGuardHomeError:
if self._available:
_LOGGER.debug(
"An error occurred while updating AdGuard Home sensor.",
exc_info=True,
)
self._available = False
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
raise NotImplementedError()
class AdGuardHomeDeviceEntity(AdGuardHomeEntity):
"""Defines a AdGuard Home device entity."""
@property
def device_info(self) -> Dict[str, Any]:
"""Return device information about this AdGuard Home instance."""
return {
'identifiers': {
(
DOMAIN,
self.adguard.host,
self.adguard.port,
self.adguard.base_path,
)
},
'name': 'AdGuard Home',
'manufacturer': 'AdGuard Team',
'sw_version': self.hass.data[DOMAIN].get(DATA_ADGUARD_VERION),
}

View File

@ -0,0 +1,168 @@
"""Config flow to configure the AdGuard Home integration."""
import logging
from adguardhome import AdGuardHome, AdGuardHomeConnectionError
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.components.adguard.const import DOMAIN
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import (
CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_USERNAME,
CONF_VERIFY_SSL)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
_LOGGER = logging.getLogger(__name__)
@config_entries.HANDLERS.register(DOMAIN)
class AdGuardHomeFlowHandler(ConfigFlow):
"""Handle a AdGuard Home config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL
_hassio_discovery = None
def __init__(self):
"""Initialize AgGuard Home flow."""
pass
async def _show_setup_form(self, errors=None):
"""Show the setup form to the user."""
return self.async_show_form(
step_id='user',
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=3000): vol.Coerce(int),
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
vol.Required(CONF_SSL, default=True): bool,
vol.Required(CONF_VERIFY_SSL, default=True): bool,
}
),
errors=errors or {},
)
async def _show_hassio_form(self, errors=None):
"""Show the Hass.io confirmation form to the user."""
return self.async_show_form(
step_id='hassio_confirm',
description_placeholders={
'addon': self._hassio_discovery['addon']
},
data_schema=vol.Schema({}),
errors=errors or {},
)
async def async_step_user(self, user_input=None):
"""Handle a flow initiated by the user."""
if self._async_current_entries():
return self.async_abort(reason='single_instance_allowed')
if user_input is None:
return await self._show_setup_form(user_input)
errors = {}
session = async_get_clientsession(
self.hass, user_input[CONF_VERIFY_SSL]
)
adguard = AdGuardHome(
user_input[CONF_HOST],
port=user_input[CONF_PORT],
username=user_input.get(CONF_USERNAME),
password=user_input.get(CONF_PASSWORD),
tls=user_input[CONF_SSL],
verify_ssl=user_input[CONF_VERIFY_SSL],
loop=self.hass.loop,
session=session,
)
try:
await adguard.version()
except AdGuardHomeConnectionError:
errors['base'] = 'connection_error'
return await self._show_setup_form(errors)
return self.async_create_entry(
title=user_input[CONF_HOST],
data={
CONF_HOST: user_input[CONF_HOST],
CONF_PASSWORD: user_input.get(CONF_PASSWORD),
CONF_PORT: user_input[CONF_PORT],
CONF_SSL: user_input[CONF_SSL],
CONF_USERNAME: user_input.get(CONF_USERNAME),
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
},
)
async def async_step_hassio(self, user_input=None):
"""Prepare configuration for a Hass.io AdGuard Home add-on.
This flow is triggered by the discovery component.
"""
entries = self._async_current_entries()
if not entries:
self._hassio_discovery = user_input
return await self.async_step_hassio_confirm()
cur_entry = entries[0]
if (cur_entry.data[CONF_HOST] == user_input[CONF_HOST] and
cur_entry.data[CONF_PORT] == user_input[CONF_PORT]):
return self.async_abort(reason='single_instance_allowed')
is_loaded = cur_entry.state == config_entries.ENTRY_STATE_LOADED
if is_loaded:
await self.hass.config_entries.async_unload(cur_entry.entry_id)
self.hass.config_entries.async_update_entry(cur_entry, data={
**cur_entry.data,
CONF_HOST: user_input[CONF_HOST],
CONF_PORT: user_input[CONF_PORT],
})
if is_loaded:
await self.hass.config_entries.async_setup(cur_entry.entry_id)
return self.async_abort(reason='existing_instance_updated')
async def async_step_hassio_confirm(self, user_input=None):
"""Confirm Hass.io discovery."""
if user_input is None:
return await self._show_hassio_form()
errors = {}
session = async_get_clientsession(self.hass, False)
adguard = AdGuardHome(
self._hassio_discovery[CONF_HOST],
port=self._hassio_discovery[CONF_PORT],
tls=False,
loop=self.hass.loop,
session=session,
)
try:
await adguard.version()
except AdGuardHomeConnectionError:
errors['base'] = 'connection_error'
return await self._show_hassio_form(errors)
return self.async_create_entry(
title=self._hassio_discovery['addon'],
data={
CONF_HOST: self._hassio_discovery[CONF_HOST],
CONF_PORT: self._hassio_discovery[CONF_PORT],
CONF_PASSWORD: None,
CONF_SSL: False,
CONF_USERNAME: None,
CONF_VERIFY_SSL: True,
},
)

View File

@ -0,0 +1,14 @@
"""Constants for the AdGuard Home integration."""
DOMAIN = 'adguard'
DATA_ADGUARD_CLIENT = 'adguard_client'
DATA_ADGUARD_VERION = 'adguard_version'
CONF_FORCE = 'force'
SERVICE_ADD_URL = 'add_url'
SERVICE_DISABLE_URL = 'disable_url'
SERVICE_ENABLE_URL = 'enable_url'
SERVICE_REFRESH = 'refresh'
SERVICE_REMOVE_URL = 'remove_url'

View File

@ -0,0 +1,13 @@
{
"domain": "adguard",
"name": "AdGuard Home",
"config_flow": true,
"documentation": "https://www.home-assistant.io/components/adguard",
"requirements": [
"adguardhome==0.2.1"
],
"dependencies": [],
"codeowners": [
"@frenck"
]
}

View File

@ -0,0 +1,232 @@
"""Support for AdGuard Home sensors."""
from datetime import timedelta
import logging
from adguardhome import AdGuardHomeConnectionError
from homeassistant.components.adguard import AdGuardHomeDeviceEntity
from homeassistant.components.adguard.const import (
DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN)
from homeassistant.config_entries import ConfigEntry
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.typing import HomeAssistantType
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=300)
PARALLEL_UPDATES = 4
async def async_setup_entry(
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
) -> None:
"""Set up AdGuard Home sensor based on a config entry."""
adguard = hass.data[DOMAIN][DATA_ADGUARD_CLIENT]
try:
version = await adguard.version()
except AdGuardHomeConnectionError as exception:
raise PlatformNotReady from exception
hass.data[DOMAIN][DATA_ADGUARD_VERION] = version
sensors = [
AdGuardHomeDNSQueriesSensor(adguard),
AdGuardHomeBlockedFilteringSensor(adguard),
AdGuardHomePercentageBlockedSensor(adguard),
AdGuardHomeReplacedParentalSensor(adguard),
AdGuardHomeReplacedSafeBrowsingSensor(adguard),
AdGuardHomeReplacedSafeSearchSensor(adguard),
AdGuardHomeAverageProcessingTimeSensor(adguard),
AdGuardHomeRulesCountSensor(adguard),
]
async_add_entities(sensors, True)
class AdGuardHomeSensor(AdGuardHomeDeviceEntity):
"""Defines a AdGuard Home sensor."""
def __init__(
self,
adguard,
name: str,
icon: str,
measurement: str,
unit_of_measurement: str,
) -> None:
"""Initialize AdGuard Home sensor."""
self._state = None
self._unit_of_measurement = unit_of_measurement
self.measurement = measurement
super().__init__(adguard, name, icon)
@property
def unique_id(self) -> str:
"""Return the unique ID for this sensor."""
return '_'.join(
[
DOMAIN,
self.adguard.host,
str(self.adguard.port),
'sensor',
self.measurement,
]
)
@property
def state(self):
"""Return the state of the sensor."""
return self._state
@property
def unit_of_measurement(self) -> str:
"""Return the unit this state is expressed in."""
return self._unit_of_measurement
class AdGuardHomeDNSQueriesSensor(AdGuardHomeSensor):
"""Defines a AdGuard Home DNS Queries sensor."""
def __init__(self, adguard):
"""Initialize AdGuard Home sensor."""
super().__init__(
adguard,
'AdGuard DNS Queries',
'mdi:magnify',
'dns_queries',
'queries',
)
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
self._state = await self.adguard.stats.dns_queries()
class AdGuardHomeBlockedFilteringSensor(AdGuardHomeSensor):
"""Defines a AdGuard Home blocked by filtering sensor."""
def __init__(self, adguard):
"""Initialize AdGuard Home sensor."""
super().__init__(
adguard,
'AdGuard DNS Queries Blocked',
'mdi:magnify-close',
'blocked_filtering',
'queries',
)
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
self._state = await self.adguard.stats.blocked_filtering()
class AdGuardHomePercentageBlockedSensor(AdGuardHomeSensor):
"""Defines a AdGuard Home blocked percentage sensor."""
def __init__(self, adguard):
"""Initialize AdGuard Home sensor."""
super().__init__(
adguard,
'AdGuard DNS Queries Blocked Ratio',
'mdi:magnify-close',
'blocked_percentage',
'%',
)
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
percentage = await self.adguard.stats.blocked_percentage()
self._state = "{:.2f}".format(percentage)
class AdGuardHomeReplacedParentalSensor(AdGuardHomeSensor):
"""Defines a AdGuard Home replaced by parental control sensor."""
def __init__(self, adguard):
"""Initialize AdGuard Home sensor."""
super().__init__(
adguard,
'AdGuard Parental Control Blocked',
'mdi:human-male-girl',
'blocked_parental',
'requests',
)
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
self._state = await self.adguard.stats.replaced_parental()
class AdGuardHomeReplacedSafeBrowsingSensor(AdGuardHomeSensor):
"""Defines a AdGuard Home replaced by safe browsing sensor."""
def __init__(self, adguard):
"""Initialize AdGuard Home sensor."""
super().__init__(
adguard,
'AdGuard Safe Browsing Blocked',
'mdi:shield-half-full',
'blocked_safebrowsing',
'requests',
)
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
self._state = await self.adguard.stats.replaced_safebrowsing()
class AdGuardHomeReplacedSafeSearchSensor(AdGuardHomeSensor):
"""Defines a AdGuard Home replaced by safe search sensor."""
def __init__(self, adguard):
"""Initialize AdGuard Home sensor."""
super().__init__(
adguard,
'Searches Safe Search Enforced',
'mdi:shield-search',
'enforced_safesearch',
'requests',
)
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
self._state = await self.adguard.stats.replaced_safesearch()
class AdGuardHomeAverageProcessingTimeSensor(AdGuardHomeSensor):
"""Defines a AdGuard Home average processing time sensor."""
def __init__(self, adguard):
"""Initialize AdGuard Home sensor."""
super().__init__(
adguard,
'AdGuard Average Processing Speed',
'mdi:speedometer',
'average_speed',
'ms',
)
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
average = await self.adguard.stats.avg_processing_time()
self._state = "{:.2f}".format(average)
class AdGuardHomeRulesCountSensor(AdGuardHomeSensor):
"""Defines a AdGuard Home rules count sensor."""
def __init__(self, adguard):
"""Initialize AdGuard Home sensor."""
super().__init__(
adguard,
'AdGuard Rules Count',
'mdi:counter',
'rules_count',
'rules',
)
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
self._state = await self.adguard.filtering.rules_count()

View File

@ -0,0 +1,37 @@
add_url:
description: Add a new filter subscription to AdGuard Home.
fields:
name:
description: The name of the filter subscription.
example: Example
url:
description: The filter URL to subscribe to, containing the filter rules.
example: https://www.example.com/filter/1.txt
remove_url:
description: Removes a filter subscription from AdGuard Home.
fields:
url:
description: The filter subscription URL to remove.
example: https://www.example.com/filter/1.txt
enable_url:
description: Enables a filter subscription in AdGuard Home.
fields:
url:
description: The filter subscription URL to enable.
example: https://www.example.com/filter/1.txt
disable_url:
description: Disables a filter subscription in AdGuard Home.
fields:
url:
description: The filter subscription URL to disable.
example: https://www.example.com/filter/1.txt
refresh:
description: Refresh all filter subscriptions in AdGuard Home.
fields:
force:
description: Force update (by passes AdGuard Home throttling).
example: '"true" to force, "false" or omit for a regular refresh.'

View File

@ -0,0 +1,30 @@
{
"config": {
"title": "AdGuard Home",
"step": {
"user": {
"title": "Link your AdGuard Home.",
"description": "Set up your AdGuard Home instance to allow monitoring and control.",
"data": {
"host": "Host",
"password": "Password",
"port": "Port",
"username": "Username",
"ssl": "AdGuard Home uses a SSL certificate",
"verify_ssl": "AdGuard Home uses a proper certificate"
}
},
"hassio_confirm": {
"title": "AdGuard Home via Hass.io add-on",
"description": "Do you want to configure Home Assistant to connect to the AdGuard Home provided by the Hass.io add-on: {addon}?"
}
},
"error": {
"connection_error": "Failed to connect."
},
"abort": {
"single_instance_allowed": "Only a single configuration of AdGuard Home is allowed.",
"existing_instance_updated": "Updated existing configuration."
}
}
}

View File

@ -0,0 +1,233 @@
"""Support for AdGuard Home switches."""
from datetime import timedelta
import logging
from adguardhome import AdGuardHomeConnectionError, AdGuardHomeError
from homeassistant.components.adguard import AdGuardHomeDeviceEntity
from homeassistant.components.adguard.const import (
DATA_ADGUARD_CLIENT, DATA_ADGUARD_VERION, DOMAIN)
from homeassistant.config_entries import ConfigEntry
from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.entity import ToggleEntity
from homeassistant.helpers.typing import HomeAssistantType
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=10)
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
) -> None:
"""Set up AdGuard Home switch based on a config entry."""
adguard = hass.data[DOMAIN][DATA_ADGUARD_CLIENT]
try:
version = await adguard.version()
except AdGuardHomeConnectionError as exception:
raise PlatformNotReady from exception
hass.data[DOMAIN][DATA_ADGUARD_VERION] = version
switches = [
AdGuardHomeProtectionSwitch(adguard),
AdGuardHomeFilteringSwitch(adguard),
AdGuardHomeParentalSwitch(adguard),
AdGuardHomeSafeBrowsingSwitch(adguard),
AdGuardHomeSafeSearchSwitch(adguard),
AdGuardHomeQueryLogSwitch(adguard),
]
async_add_entities(switches, True)
class AdGuardHomeSwitch(ToggleEntity, AdGuardHomeDeviceEntity):
"""Defines a AdGuard Home switch."""
def __init__(self, adguard, name: str, icon: str, key: str):
"""Initialize AdGuard Home switch."""
self._state = False
self._key = key
super().__init__(adguard, name, icon)
@property
def unique_id(self) -> str:
"""Return the unique ID for this sensor."""
return '_'.join(
[
DOMAIN,
self.adguard.host,
str(self.adguard.port),
'switch',
self._key,
]
)
@property
def is_on(self) -> bool:
"""Return the state of the switch."""
return self._state
async def async_turn_off(self, **kwargs) -> None:
"""Turn off the switch."""
try:
await self._adguard_turn_off()
except AdGuardHomeError:
_LOGGER.error(
"An error occurred while turning off AdGuard Home switch."
)
self._available = False
async def _adguard_turn_off(self) -> None:
"""Turn off the switch."""
raise NotImplementedError()
async def async_turn_on(self, **kwargs) -> None:
"""Turn on the switch."""
try:
await self._adguard_turn_on()
except AdGuardHomeError:
_LOGGER.error(
"An error occurred while turning on AdGuard Home switch."
)
self._available = False
async def _adguard_turn_on(self) -> None:
"""Turn on the switch."""
raise NotImplementedError()
class AdGuardHomeProtectionSwitch(AdGuardHomeSwitch):
"""Defines a AdGuard Home protection switch."""
def __init__(self, adguard) -> None:
"""Initialize AdGuard Home switch."""
super().__init__(
adguard, "AdGuard Protection", 'mdi:shield-check', 'protection'
)
async def _adguard_turn_off(self) -> None:
"""Turn off the switch."""
await self.adguard.disable_protection()
async def _adguard_turn_on(self) -> None:
"""Turn on the switch."""
await self.adguard.enable_protection()
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
self._state = await self.adguard.protection_enabled()
class AdGuardHomeParentalSwitch(AdGuardHomeSwitch):
"""Defines a AdGuard Home parental control switch."""
def __init__(self, adguard) -> None:
"""Initialize AdGuard Home switch."""
super().__init__(
adguard, "AdGuard Parental Control", 'mdi:shield-check', 'parental'
)
async def _adguard_turn_off(self) -> None:
"""Turn off the switch."""
await self.adguard.parental.disable()
async def _adguard_turn_on(self) -> None:
"""Turn on the switch."""
await self.adguard.parental.enable()
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
self._state = await self.adguard.parental.enabled()
class AdGuardHomeSafeSearchSwitch(AdGuardHomeSwitch):
"""Defines a AdGuard Home safe search switch."""
def __init__(self, adguard) -> None:
"""Initialize AdGuard Home switch."""
super().__init__(
adguard, "AdGuard Safe Search", 'mdi:shield-check', 'safesearch'
)
async def _adguard_turn_off(self) -> None:
"""Turn off the switch."""
await self.adguard.safesearch.disable()
async def _adguard_turn_on(self) -> None:
"""Turn on the switch."""
await self.adguard.safesearch.enable()
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
self._state = await self.adguard.safesearch.enabled()
class AdGuardHomeSafeBrowsingSwitch(AdGuardHomeSwitch):
"""Defines a AdGuard Home safe search switch."""
def __init__(self, adguard) -> None:
"""Initialize AdGuard Home switch."""
super().__init__(
adguard,
"AdGuard Safe Browsing",
'mdi:shield-check',
'safebrowsing',
)
async def _adguard_turn_off(self) -> None:
"""Turn off the switch."""
await self.adguard.safebrowsing.disable()
async def _adguard_turn_on(self) -> None:
"""Turn on the switch."""
await self.adguard.safebrowsing.enable()
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
self._state = await self.adguard.safebrowsing.enabled()
class AdGuardHomeFilteringSwitch(AdGuardHomeSwitch):
"""Defines a AdGuard Home filtering switch."""
def __init__(self, adguard) -> None:
"""Initialize AdGuard Home switch."""
super().__init__(
adguard, "AdGuard Filtering", 'mdi:shield-check', 'filtering'
)
async def _adguard_turn_off(self) -> None:
"""Turn off the switch."""
await self.adguard.filtering.disable()
async def _adguard_turn_on(self) -> None:
"""Turn on the switch."""
await self.adguard.filtering.enable()
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
self._state = await self.adguard.filtering.enabled()
class AdGuardHomeQueryLogSwitch(AdGuardHomeSwitch):
"""Defines a AdGuard Home query log switch."""
def __init__(self, adguard) -> None:
"""Initialize AdGuard Home switch."""
super().__init__(
adguard, "AdGuard Query Log", 'mdi:shield-check', 'querylog'
)
async def _adguard_turn_off(self) -> None:
"""Turn off the switch."""
await self.adguard.querylog.disable()
async def _adguard_turn_on(self) -> None:
"""Turn on the switch."""
await self.adguard.querylog.enable()
async def _adguard_update(self) -> None:
"""Update AdGuard Home entity."""
self._state = await self.adguard.querylog.enabled()

View File

@ -177,6 +177,11 @@ class AfterShipSensor(Entity):
if track['title'] is None
else track['title']
)
last_checkpoint = (
"Shipment pending"
if track['tag'] == "Pending"
else track['checkpoints'][-1]
)
status_counts[status] = status_counts.get(status, 0) + 1
trackings.append({
'name': name,
@ -187,7 +192,7 @@ class AfterShipSensor(Entity):
'last_update': track['updated_at'],
'expected_delivery': track['expected_delivery'],
'status': track['tag'],
'last_checkpoint': track['checkpoints'][-1]
'last_checkpoint': last_checkpoint
})
if status not in status_to_ignore:

View File

@ -19,6 +19,7 @@ SCAN_INTERVAL = timedelta(seconds=30)
ATTR_CHANGED_BY = 'changed_by'
FORMAT_TEXT = 'text'
FORMAT_NUMBER = 'number'
ATTR_CODE_ARM_REQUIRED = 'code_arm_required'
ENTITY_ID_FORMAT = DOMAIN + '.{}'
@ -87,6 +88,11 @@ class AlarmControlPanel(Entity):
"""Last change triggered by."""
return None
@property
def code_arm_required(self):
"""Whether the code is required for arm actions."""
return True
def alarm_disarm(self, code=None):
"""Send disarm command."""
raise NotImplementedError()
@ -159,6 +165,7 @@ class AlarmControlPanel(Entity):
"""Return the state attributes."""
state_attr = {
ATTR_CODE_FORMAT: self.code_format,
ATTR_CHANGED_BY: self.changed_by
ATTR_CHANGED_BY: self.changed_by,
ATTR_CODE_ARM_REQUIRED: self.code_arm_required
}
return state_attr

View File

@ -5,12 +5,13 @@ import voluptuous as vol
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers import entityfilter
from homeassistant.const import CONF_NAME
from . import flash_briefings, intent, smart_home
from . import flash_briefings, intent, smart_home_http
from .const import (
CONF_AUDIO, CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY_URL,
CONF_ENDPOINT, CONF_TEXT, CONF_TITLE, CONF_UID, DOMAIN, CONF_FILTER,
CONF_ENTITY_CONFIG)
CONF_ENTITY_CONFIG, CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES)
_LOGGER = logging.getLogger(__name__)
@ -18,9 +19,9 @@ CONF_FLASH_BRIEFINGS = 'flash_briefings'
CONF_SMART_HOME = 'smart_home'
ALEXA_ENTITY_SCHEMA = vol.Schema({
vol.Optional(smart_home.CONF_DESCRIPTION): cv.string,
vol.Optional(smart_home.CONF_DISPLAY_CATEGORIES): cv.string,
vol.Optional(smart_home.CONF_NAME): cv.string,
vol.Optional(CONF_DESCRIPTION): cv.string,
vol.Optional(CONF_DISPLAY_CATEGORIES): cv.string,
vol.Optional(CONF_NAME): cv.string,
})
SMART_HOME_SCHEMA = vol.Schema({
@ -65,6 +66,6 @@ async def async_setup(hass, config):
pass
else:
smart_home_config = smart_home_config or SMART_HOME_SCHEMA({})
await smart_home.async_setup(hass, smart_home_config)
await smart_home_http.async_setup(hass, smart_home_config)
return True

View File

@ -9,7 +9,6 @@ import async_timeout
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
from homeassistant.util import dt
from .const import DEFAULT_TIMEOUT
_LOGGER = logging.getLogger(__name__)
@ -97,7 +96,7 @@ class Auth:
try:
session = aiohttp_client.async_get_clientsession(self.hass)
with async_timeout.timeout(DEFAULT_TIMEOUT):
with async_timeout.timeout(10):
response = await session.post(LWA_TOKEN_URI,
headers=LWA_HEADERS,
data=lwa_params,

View File

@ -0,0 +1,597 @@
"""Alexa capabilities."""
from datetime import datetime
import logging
from homeassistant.const import (
ATTR_SUPPORTED_FEATURES,
ATTR_TEMPERATURE,
ATTR_UNIT_OF_MEASUREMENT,
STATE_LOCKED,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
STATE_UNLOCKED,
)
import homeassistant.components.climate.const as climate
from homeassistant.components import (
light,
fan,
cover,
)
import homeassistant.util.color as color_util
from .const import (
API_TEMP_UNITS,
API_THERMOSTAT_MODES,
DATE_FORMAT,
PERCENTAGE_FAN_MAP,
)
from .errors import UnsupportedProperty
_LOGGER = logging.getLogger(__name__)
class AlexaCapibility:
"""Base class for Alexa capability interfaces.
The Smart Home Skills API defines a number of "capability interfaces",
roughly analogous to domains in Home Assistant. The supported interfaces
describe what actions can be performed on a particular device.
https://developer.amazon.com/docs/device-apis/message-guide.html
"""
def __init__(self, entity):
"""Initialize an Alexa capibility."""
self.entity = entity
def name(self):
"""Return the Alexa API name of this interface."""
raise NotImplementedError
@staticmethod
def properties_supported():
"""Return what properties this entity supports."""
return []
@staticmethod
def properties_proactively_reported():
"""Return True if properties asynchronously reported."""
return False
@staticmethod
def properties_retrievable():
"""Return True if properties can be retrieved."""
return False
@staticmethod
def get_property(name):
"""Read and return a property.
Return value should be a dict, or raise UnsupportedProperty.
Properties can also have a timeOfSample and uncertaintyInMilliseconds,
but returning those metadata is not yet implemented.
"""
raise UnsupportedProperty(name)
@staticmethod
def supports_deactivation():
"""Applicable only to scenes."""
return None
def serialize_discovery(self):
"""Serialize according to the Discovery API."""
result = {
'type': 'AlexaInterface',
'interface': self.name(),
'version': '3',
'properties': {
'supported': self.properties_supported(),
'proactivelyReported': self.properties_proactively_reported(),
'retrievable': self.properties_retrievable(),
},
}
# pylint: disable=assignment-from-none
supports_deactivation = self.supports_deactivation()
if supports_deactivation is not None:
result['supportsDeactivation'] = supports_deactivation
return result
def serialize_properties(self):
"""Return properties serialized for an API response."""
for prop in self.properties_supported():
prop_name = prop['name']
# pylint: disable=assignment-from-no-return
prop_value = self.get_property(prop_name)
if prop_value is not None:
yield {
'name': prop_name,
'namespace': self.name(),
'value': prop_value,
'timeOfSample': datetime.now().strftime(DATE_FORMAT),
'uncertaintyInMilliseconds': 0
}
class AlexaEndpointHealth(AlexaCapibility):
"""Implements Alexa.EndpointHealth.
https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-when-alexa-requests-it
"""
def __init__(self, hass, entity):
"""Initialize the entity."""
super().__init__(entity)
self.hass = hass
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.EndpointHealth'
def properties_supported(self):
"""Return what properties this entity supports."""
return [{'name': 'connectivity'}]
def properties_proactively_reported(self):
"""Return True if properties asynchronously reported."""
return False
def properties_retrievable(self):
"""Return True if properties can be retrieved."""
return True
def get_property(self, name):
"""Read and return a property."""
if name != 'connectivity':
raise UnsupportedProperty(name)
if self.entity.state == STATE_UNAVAILABLE:
return {'value': 'UNREACHABLE'}
return {'value': 'OK'}
class AlexaPowerController(AlexaCapibility):
"""Implements Alexa.PowerController.
https://developer.amazon.com/docs/device-apis/alexa-powercontroller.html
"""
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.PowerController'
def properties_supported(self):
"""Return what properties this entity supports."""
return [{'name': 'powerState'}]
def properties_proactively_reported(self):
"""Return True if properties asynchronously reported."""
return True
def properties_retrievable(self):
"""Return True if properties can be retrieved."""
return True
def get_property(self, name):
"""Read and return a property."""
if name != 'powerState':
raise UnsupportedProperty(name)
if self.entity.state == STATE_OFF:
return 'OFF'
return 'ON'
class AlexaLockController(AlexaCapibility):
"""Implements Alexa.LockController.
https://developer.amazon.com/docs/device-apis/alexa-lockcontroller.html
"""
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.LockController'
def properties_supported(self):
"""Return what properties this entity supports."""
return [{'name': 'lockState'}]
def properties_retrievable(self):
"""Return True if properties can be retrieved."""
return True
def properties_proactively_reported(self):
"""Return True if properties asynchronously reported."""
return True
def get_property(self, name):
"""Read and return a property."""
if name != 'lockState':
raise UnsupportedProperty(name)
if self.entity.state == STATE_LOCKED:
return 'LOCKED'
if self.entity.state == STATE_UNLOCKED:
return 'UNLOCKED'
return 'JAMMED'
class AlexaSceneController(AlexaCapibility):
"""Implements Alexa.SceneController.
https://developer.amazon.com/docs/device-apis/alexa-scenecontroller.html
"""
def __init__(self, entity, supports_deactivation):
"""Initialize the entity."""
super().__init__(entity)
self.supports_deactivation = lambda: supports_deactivation
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.SceneController'
class AlexaBrightnessController(AlexaCapibility):
"""Implements Alexa.BrightnessController.
https://developer.amazon.com/docs/device-apis/alexa-brightnesscontroller.html
"""
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.BrightnessController'
def properties_supported(self):
"""Return what properties this entity supports."""
return [{'name': 'brightness'}]
def properties_proactively_reported(self):
"""Return True if properties asynchronously reported."""
return True
def properties_retrievable(self):
"""Return True if properties can be retrieved."""
return True
def get_property(self, name):
"""Read and return a property."""
if name != 'brightness':
raise UnsupportedProperty(name)
if 'brightness' in self.entity.attributes:
return round(self.entity.attributes['brightness'] / 255.0 * 100)
return 0
class AlexaColorController(AlexaCapibility):
"""Implements Alexa.ColorController.
https://developer.amazon.com/docs/device-apis/alexa-colorcontroller.html
"""
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.ColorController'
def properties_supported(self):
"""Return what properties this entity supports."""
return [{'name': 'color'}]
def properties_retrievable(self):
"""Return True if properties can be retrieved."""
return True
def get_property(self, name):
"""Read and return a property."""
if name != 'color':
raise UnsupportedProperty(name)
hue, saturation = self.entity.attributes.get(
light.ATTR_HS_COLOR, (0, 0))
return {
'hue': hue,
'saturation': saturation / 100.0,
'brightness': self.entity.attributes.get(
light.ATTR_BRIGHTNESS, 0) / 255.0,
}
class AlexaColorTemperatureController(AlexaCapibility):
"""Implements Alexa.ColorTemperatureController.
https://developer.amazon.com/docs/device-apis/alexa-colortemperaturecontroller.html
"""
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.ColorTemperatureController'
def properties_supported(self):
"""Return what properties this entity supports."""
return [{'name': 'colorTemperatureInKelvin'}]
def properties_retrievable(self):
"""Return True if properties can be retrieved."""
return True
def get_property(self, name):
"""Read and return a property."""
if name != 'colorTemperatureInKelvin':
raise UnsupportedProperty(name)
if 'color_temp' in self.entity.attributes:
return color_util.color_temperature_mired_to_kelvin(
self.entity.attributes['color_temp'])
return 0
class AlexaPercentageController(AlexaCapibility):
"""Implements Alexa.PercentageController.
https://developer.amazon.com/docs/device-apis/alexa-percentagecontroller.html
"""
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.PercentageController'
def properties_supported(self):
"""Return what properties this entity supports."""
return [{'name': 'percentage'}]
def properties_retrievable(self):
"""Return True if properties can be retrieved."""
return True
def get_property(self, name):
"""Read and return a property."""
if name != 'percentage':
raise UnsupportedProperty(name)
if self.entity.domain == fan.DOMAIN:
speed = self.entity.attributes.get(fan.ATTR_SPEED)
return PERCENTAGE_FAN_MAP.get(speed, 0)
if self.entity.domain == cover.DOMAIN:
return self.entity.attributes.get(cover.ATTR_CURRENT_POSITION, 0)
return 0
class AlexaSpeaker(AlexaCapibility):
"""Implements Alexa.Speaker.
https://developer.amazon.com/docs/device-apis/alexa-speaker.html
"""
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.Speaker'
class AlexaStepSpeaker(AlexaCapibility):
"""Implements Alexa.StepSpeaker.
https://developer.amazon.com/docs/device-apis/alexa-stepspeaker.html
"""
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.StepSpeaker'
class AlexaPlaybackController(AlexaCapibility):
"""Implements Alexa.PlaybackController.
https://developer.amazon.com/docs/device-apis/alexa-playbackcontroller.html
"""
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.PlaybackController'
class AlexaInputController(AlexaCapibility):
"""Implements Alexa.InputController.
https://developer.amazon.com/docs/device-apis/alexa-inputcontroller.html
"""
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.InputController'
class AlexaTemperatureSensor(AlexaCapibility):
"""Implements Alexa.TemperatureSensor.
https://developer.amazon.com/docs/device-apis/alexa-temperaturesensor.html
"""
def __init__(self, hass, entity):
"""Initialize the entity."""
super().__init__(entity)
self.hass = hass
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.TemperatureSensor'
def properties_supported(self):
"""Return what properties this entity supports."""
return [{'name': 'temperature'}]
def properties_proactively_reported(self):
"""Return True if properties asynchronously reported."""
return True
def properties_retrievable(self):
"""Return True if properties can be retrieved."""
return True
def get_property(self, name):
"""Read and return a property."""
if name != 'temperature':
raise UnsupportedProperty(name)
unit = self.entity.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
temp = self.entity.state
if self.entity.domain == climate.DOMAIN:
unit = self.hass.config.units.temperature_unit
temp = self.entity.attributes.get(
climate.ATTR_CURRENT_TEMPERATURE)
return {
'value': float(temp),
'scale': API_TEMP_UNITS[unit],
}
class AlexaContactSensor(AlexaCapibility):
"""Implements Alexa.ContactSensor.
The Alexa.ContactSensor interface describes the properties and events used
to report the state of an endpoint that detects contact between two
surfaces. For example, a contact sensor can report whether a door or window
is open.
https://developer.amazon.com/docs/device-apis/alexa-contactsensor.html
"""
def __init__(self, hass, entity):
"""Initialize the entity."""
super().__init__(entity)
self.hass = hass
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.ContactSensor'
def properties_supported(self):
"""Return what properties this entity supports."""
return [{'name': 'detectionState'}]
def properties_proactively_reported(self):
"""Return True if properties asynchronously reported."""
return True
def properties_retrievable(self):
"""Return True if properties can be retrieved."""
return True
def get_property(self, name):
"""Read and return a property."""
if name != 'detectionState':
raise UnsupportedProperty(name)
if self.entity.state == STATE_ON:
return 'DETECTED'
return 'NOT_DETECTED'
class AlexaMotionSensor(AlexaCapibility):
"""Implements Alexa.MotionSensor.
https://developer.amazon.com/docs/device-apis/alexa-motionsensor.html
"""
def __init__(self, hass, entity):
"""Initialize the entity."""
super().__init__(entity)
self.hass = hass
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.MotionSensor'
def properties_supported(self):
"""Return what properties this entity supports."""
return [{'name': 'detectionState'}]
def properties_proactively_reported(self):
"""Return True if properties asynchronously reported."""
return True
def properties_retrievable(self):
"""Return True if properties can be retrieved."""
return True
def get_property(self, name):
"""Read and return a property."""
if name != 'detectionState':
raise UnsupportedProperty(name)
if self.entity.state == STATE_ON:
return 'DETECTED'
return 'NOT_DETECTED'
class AlexaThermostatController(AlexaCapibility):
"""Implements Alexa.ThermostatController.
https://developer.amazon.com/docs/device-apis/alexa-thermostatcontroller.html
"""
def __init__(self, hass, entity):
"""Initialize the entity."""
super().__init__(entity)
self.hass = hass
def name(self):
"""Return the Alexa API name of this interface."""
return 'Alexa.ThermostatController'
def properties_supported(self):
"""Return what properties this entity supports."""
properties = []
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & climate.SUPPORT_TARGET_TEMPERATURE:
properties.append({'name': 'targetSetpoint'})
if supported & climate.SUPPORT_TARGET_TEMPERATURE_LOW:
properties.append({'name': 'lowerSetpoint'})
if supported & climate.SUPPORT_TARGET_TEMPERATURE_HIGH:
properties.append({'name': 'upperSetpoint'})
if supported & climate.SUPPORT_OPERATION_MODE:
properties.append({'name': 'thermostatMode'})
return properties
def properties_proactively_reported(self):
"""Return True if properties asynchronously reported."""
return True
def properties_retrievable(self):
"""Return True if properties can be retrieved."""
return True
def get_property(self, name):
"""Read and return a property."""
if name == 'thermostatMode':
ha_mode = self.entity.attributes.get(climate.ATTR_OPERATION_MODE)
mode = API_THERMOSTAT_MODES.get(ha_mode)
if mode is None:
_LOGGER.error("%s (%s) has unsupported %s value '%s'",
self.entity.entity_id, type(self.entity),
climate.ATTR_OPERATION_MODE, ha_mode)
raise UnsupportedProperty(name)
return mode
unit = self.hass.config.units.temperature_unit
if name == 'targetSetpoint':
temp = self.entity.attributes.get(ATTR_TEMPERATURE)
elif name == 'lowerSetpoint':
temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW)
elif name == 'upperSetpoint':
temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH)
else:
raise UnsupportedProperty(name)
if temp is None:
return None
return {
'value': float(temp),
'scale': API_TEMP_UNITS[unit],
}

View File

@ -0,0 +1,69 @@
"""Config helpers for Alexa."""
from .state_report import async_enable_proactive_mode
class AbstractConfig:
"""Hold the configuration for Alexa."""
_unsub_proactive_report = None
def __init__(self, hass):
"""Initialize abstract config."""
self.hass = hass
@property
def supports_auth(self):
"""Return if config supports auth."""
return False
@property
def should_report_state(self):
"""Return if states should be proactively reported."""
return False
@property
def endpoint(self):
"""Endpoint for report state."""
return None
@property
def entity_config(self):
"""Return entity config."""
return {}
@property
def is_reporting_states(self):
"""Return if proactive mode is enabled."""
return self._unsub_proactive_report is not None
async def async_enable_proactive_mode(self):
"""Enable proactive mode."""
if self._unsub_proactive_report is None:
self._unsub_proactive_report = self.hass.async_create_task(
async_enable_proactive_mode(self.hass, self)
)
try:
await self._unsub_proactive_report
except Exception: # pylint: disable=broad-except
self._unsub_proactive_report = None
raise
async def async_disable_proactive_mode(self):
"""Disable proactive mode."""
unsub_func = await self._unsub_proactive_report
if unsub_func:
unsub_func()
self._unsub_proactive_report = None
def should_expose(self, entity_id):
"""If an entity should be exposed."""
# pylint: disable=no-self-use
return False
async def async_get_access_token(self):
"""Get an access token."""
raise NotImplementedError
async def async_accept_grant(self, code):
"""Accept a grant."""
raise NotImplementedError

View File

@ -1,4 +1,15 @@
"""Constants for the Alexa integration."""
from collections import OrderedDict
from homeassistant.const import (
STATE_OFF,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
from homeassistant.components.climate import const as climate
from homeassistant.components import fan
DOMAIN = 'alexa'
# Flash briefing constants
@ -25,4 +36,73 @@ SYN_RESOLUTION_MATCH = 'ER_SUCCESS_MATCH'
DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z'
DEFAULT_TIMEOUT = 30
API_DIRECTIVE = 'directive'
API_ENDPOINT = 'endpoint'
API_EVENT = 'event'
API_CONTEXT = 'context'
API_HEADER = 'header'
API_PAYLOAD = 'payload'
API_SCOPE = 'scope'
API_CHANGE = 'change'
CONF_DESCRIPTION = 'description'
CONF_DISPLAY_CATEGORIES = 'display_categories'
API_TEMP_UNITS = {
TEMP_FAHRENHEIT: 'FAHRENHEIT',
TEMP_CELSIUS: 'CELSIUS',
}
# Needs to be ordered dict for `async_api_set_thermostat_mode` which does a
# reverse mapping of this dict and we want to map the first occurrance of OFF
# back to HA state.
API_THERMOSTAT_MODES = OrderedDict([
(climate.STATE_HEAT, 'HEAT'),
(climate.STATE_COOL, 'COOL'),
(climate.STATE_AUTO, 'AUTO'),
(climate.STATE_ECO, 'ECO'),
(climate.STATE_MANUAL, 'AUTO'),
(STATE_OFF, 'OFF'),
(climate.STATE_IDLE, 'OFF'),
(climate.STATE_FAN_ONLY, 'OFF'),
(climate.STATE_DRY, 'OFF'),
])
PERCENTAGE_FAN_MAP = {
fan.SPEED_LOW: 33,
fan.SPEED_MEDIUM: 66,
fan.SPEED_HIGH: 100,
}
class Cause:
"""Possible causes for property changes.
https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#cause-object
"""
# Indicates that the event was caused by a customer interaction with an
# application. For example, a customer switches on a light, or locks a door
# using the Alexa app or an app provided by a device vendor.
APP_INTERACTION = 'APP_INTERACTION'
# Indicates that the event was caused by a physical interaction with an
# endpoint. For example manually switching on a light or manually locking a
# door lock
PHYSICAL_INTERACTION = 'PHYSICAL_INTERACTION'
# Indicates that the event was caused by the periodic poll of an appliance,
# which found a change in value. For example, you might poll a temperature
# sensor every hour, and send the updated temperature to Alexa.
PERIODIC_POLL = 'PERIODIC_POLL'
# Indicates that the event was caused by the application of a device rule.
# For example, a customer configures a rule to switch on a light if a
# motion sensor detects motion. In this case, Alexa receives an event from
# the motion sensor, and another event from the light to indicate that its
# state change was caused by the rule.
RULE_TRIGGER = 'RULE_TRIGGER'
# Indicates that the event was caused by a voice interaction with Alexa.
# For example a user speaking to their Echo device.
VOICE_INTERACTION = 'VOICE_INTERACTION'

View File

@ -0,0 +1,459 @@
"""Alexa entity adapters."""
from typing import List
from homeassistant.core import callback
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_SUPPORTED_FEATURES,
ATTR_UNIT_OF_MEASUREMENT,
CLOUD_NEVER_EXPOSED_ENTITIES,
CONF_NAME,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
from homeassistant.util.decorator import Registry
from homeassistant.components.climate import const as climate
from homeassistant.components import (
alert, automation, binary_sensor, cover, fan, group,
input_boolean, light, lock, media_player, scene, script, sensor, switch)
from .const import CONF_DESCRIPTION, CONF_DISPLAY_CATEGORIES
from .capabilities import (
AlexaBrightnessController,
AlexaColorController,
AlexaColorTemperatureController,
AlexaContactSensor,
AlexaEndpointHealth,
AlexaInputController,
AlexaLockController,
AlexaMotionSensor,
AlexaPercentageController,
AlexaPlaybackController,
AlexaPowerController,
AlexaSceneController,
AlexaSpeaker,
AlexaStepSpeaker,
AlexaTemperatureSensor,
AlexaThermostatController,
)
ENTITY_ADAPTERS = Registry()
class DisplayCategory:
"""Possible display categories for Discovery response.
https://developer.amazon.com/docs/device-apis/alexa-discovery.html#display-categories
"""
# Describes a combination of devices set to a specific state, when the
# state change must occur in a specific order. For example, a "watch
# Netflix" scene might require the: 1. TV to be powered on & 2. Input set
# to HDMI1. Applies to Scenes
ACTIVITY_TRIGGER = "ACTIVITY_TRIGGER"
# Indicates media devices with video or photo capabilities.
CAMERA = "CAMERA"
# Indicates an endpoint that detects and reports contact.
CONTACT_SENSOR = "CONTACT_SENSOR"
# Indicates a door.
DOOR = "DOOR"
# Indicates light sources or fixtures.
LIGHT = "LIGHT"
# Indicates an endpoint that detects and reports motion.
MOTION_SENSOR = "MOTION_SENSOR"
# An endpoint that cannot be described in on of the other categories.
OTHER = "OTHER"
# Describes a combination of devices set to a specific state, when the
# order of the state change is not important. For example a bedtime scene
# might include turning off lights and lowering the thermostat, but the
# order is unimportant. Applies to Scenes
SCENE_TRIGGER = "SCENE_TRIGGER"
# Indicates an endpoint that locks.
SMARTLOCK = "SMARTLOCK"
# Indicates modules that are plugged into an existing electrical outlet.
# Can control a variety of devices.
SMARTPLUG = "SMARTPLUG"
# Indicates the endpoint is a speaker or speaker system.
SPEAKER = "SPEAKER"
# Indicates in-wall switches wired to the electrical system. Can control a
# variety of devices.
SWITCH = "SWITCH"
# Indicates endpoints that report the temperature only.
TEMPERATURE_SENSOR = "TEMPERATURE_SENSOR"
# Indicates endpoints that control temperature, stand-alone air
# conditioners, or heaters with direct temperature control.
THERMOSTAT = "THERMOSTAT"
# Indicates the endpoint is a television.
TV = "TV"
class AlexaEntity:
"""An adaptation of an entity, expressed in Alexa's terms.
The API handlers should manipulate entities only through this interface.
"""
def __init__(self, hass, config, entity):
"""Initialize Alexa Entity."""
self.hass = hass
self.config = config
self.entity = entity
self.entity_conf = config.entity_config.get(entity.entity_id, {})
@property
def entity_id(self):
"""Return the Entity ID."""
return self.entity.entity_id
def friendly_name(self):
"""Return the Alexa API friendly name."""
return self.entity_conf.get(CONF_NAME, self.entity.name)
def description(self):
"""Return the Alexa API description."""
return self.entity_conf.get(CONF_DESCRIPTION, self.entity.entity_id)
def alexa_id(self):
"""Return the Alexa API entity id."""
return self.entity.entity_id.replace('.', '#')
def display_categories(self):
"""Return a list of display categories."""
entity_conf = self.config.entity_config.get(self.entity.entity_id, {})
if CONF_DISPLAY_CATEGORIES in entity_conf:
return [entity_conf[CONF_DISPLAY_CATEGORIES]]
return self.default_display_categories()
def default_display_categories(self):
"""Return a list of default display categories.
This can be overridden by the user in the Home Assistant configuration.
See also DisplayCategory.
"""
raise NotImplementedError
def get_interface(self, capability):
"""Return the given AlexaInterface.
Raises _UnsupportedInterface.
"""
pass
def interfaces(self):
"""Return a list of supported interfaces.
Used for discovery. The list should contain AlexaInterface instances.
If the list is empty, this entity will not be discovered.
"""
raise NotImplementedError
def serialize_properties(self):
"""Yield each supported property in API format."""
for interface in self.interfaces():
for prop in interface.serialize_properties():
yield prop
def serialize_discovery(self):
"""Serialize the entity for discovery."""
return {
'displayCategories': self.display_categories(),
'cookie': {},
'endpointId': self.alexa_id(),
'friendlyName': self.friendly_name(),
'description': self.description(),
'manufacturerName': 'Home Assistant',
'capabilities': [
i.serialize_discovery() for i in self.interfaces()
]
}
@callback
def async_get_entities(hass, config) -> List[AlexaEntity]:
"""Return all entities that are supported by Alexa."""
entities = []
for state in hass.states.async_all():
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
continue
if state.domain not in ENTITY_ADAPTERS:
continue
alexa_entity = ENTITY_ADAPTERS[state.domain](hass, config, state)
if not list(alexa_entity.interfaces()):
continue
entities.append(alexa_entity)
return entities
@ENTITY_ADAPTERS.register(alert.DOMAIN)
@ENTITY_ADAPTERS.register(automation.DOMAIN)
@ENTITY_ADAPTERS.register(group.DOMAIN)
@ENTITY_ADAPTERS.register(input_boolean.DOMAIN)
class GenericCapabilities(AlexaEntity):
"""A generic, on/off device.
The choice of last resort.
"""
def default_display_categories(self):
"""Return the display categories for this entity."""
return [DisplayCategory.OTHER]
def interfaces(self):
"""Yield the supported interfaces."""
return [AlexaPowerController(self.entity),
AlexaEndpointHealth(self.hass, self.entity)]
@ENTITY_ADAPTERS.register(switch.DOMAIN)
class SwitchCapabilities(AlexaEntity):
"""Class to represent Switch capabilities."""
def default_display_categories(self):
"""Return the display categories for this entity."""
return [DisplayCategory.SWITCH]
def interfaces(self):
"""Yield the supported interfaces."""
return [AlexaPowerController(self.entity),
AlexaEndpointHealth(self.hass, self.entity)]
@ENTITY_ADAPTERS.register(climate.DOMAIN)
class ClimateCapabilities(AlexaEntity):
"""Class to represent Climate capabilities."""
def default_display_categories(self):
"""Return the display categories for this entity."""
return [DisplayCategory.THERMOSTAT]
def interfaces(self):
"""Yield the supported interfaces."""
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & climate.SUPPORT_ON_OFF:
yield AlexaPowerController(self.entity)
yield AlexaThermostatController(self.hass, self.entity)
yield AlexaTemperatureSensor(self.hass, self.entity)
yield AlexaEndpointHealth(self.hass, self.entity)
@ENTITY_ADAPTERS.register(cover.DOMAIN)
class CoverCapabilities(AlexaEntity):
"""Class to represent Cover capabilities."""
def default_display_categories(self):
"""Return the display categories for this entity."""
return [DisplayCategory.DOOR]
def interfaces(self):
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & cover.SUPPORT_SET_POSITION:
yield AlexaPercentageController(self.entity)
yield AlexaEndpointHealth(self.hass, self.entity)
@ENTITY_ADAPTERS.register(light.DOMAIN)
class LightCapabilities(AlexaEntity):
"""Class to represent Light capabilities."""
def default_display_categories(self):
"""Return the display categories for this entity."""
return [DisplayCategory.LIGHT]
def interfaces(self):
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & light.SUPPORT_BRIGHTNESS:
yield AlexaBrightnessController(self.entity)
if supported & light.SUPPORT_COLOR:
yield AlexaColorController(self.entity)
if supported & light.SUPPORT_COLOR_TEMP:
yield AlexaColorTemperatureController(self.entity)
yield AlexaEndpointHealth(self.hass, self.entity)
@ENTITY_ADAPTERS.register(fan.DOMAIN)
class FanCapabilities(AlexaEntity):
"""Class to represent Fan capabilities."""
def default_display_categories(self):
"""Return the display categories for this entity."""
return [DisplayCategory.OTHER]
def interfaces(self):
"""Yield the supported interfaces."""
yield AlexaPowerController(self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & fan.SUPPORT_SET_SPEED:
yield AlexaPercentageController(self.entity)
yield AlexaEndpointHealth(self.hass, self.entity)
@ENTITY_ADAPTERS.register(lock.DOMAIN)
class LockCapabilities(AlexaEntity):
"""Class to represent Lock capabilities."""
def default_display_categories(self):
"""Return the display categories for this entity."""
return [DisplayCategory.SMARTLOCK]
def interfaces(self):
"""Yield the supported interfaces."""
return [AlexaLockController(self.entity),
AlexaEndpointHealth(self.hass, self.entity)]
@ENTITY_ADAPTERS.register(media_player.const.DOMAIN)
class MediaPlayerCapabilities(AlexaEntity):
"""Class to represent MediaPlayer capabilities."""
def default_display_categories(self):
"""Return the display categories for this entity."""
return [DisplayCategory.TV]
def interfaces(self):
"""Yield the supported interfaces."""
yield AlexaEndpointHealth(self.hass, self.entity)
supported = self.entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
if supported & media_player.const.SUPPORT_VOLUME_SET:
yield AlexaSpeaker(self.entity)
power_features = (media_player.SUPPORT_TURN_ON |
media_player.SUPPORT_TURN_OFF)
if supported & power_features:
yield AlexaPowerController(self.entity)
step_volume_features = (media_player.const.SUPPORT_VOLUME_MUTE |
media_player.const.SUPPORT_VOLUME_STEP)
if supported & step_volume_features:
yield AlexaStepSpeaker(self.entity)
playback_features = (media_player.const.SUPPORT_PLAY |
media_player.const.SUPPORT_PAUSE |
media_player.const.SUPPORT_STOP |
media_player.const.SUPPORT_NEXT_TRACK |
media_player.const.SUPPORT_PREVIOUS_TRACK)
if supported & playback_features:
yield AlexaPlaybackController(self.entity)
if supported & media_player.SUPPORT_SELECT_SOURCE:
yield AlexaInputController(self.entity)
@ENTITY_ADAPTERS.register(scene.DOMAIN)
class SceneCapabilities(AlexaEntity):
"""Class to represent Scene capabilities."""
def description(self):
"""Return the description of the entity."""
# Required description as per Amazon Scene docs
scene_fmt = '{} (Scene connected via Home Assistant)'
return scene_fmt.format(AlexaEntity.description(self))
def default_display_categories(self):
"""Return the display categories for this entity."""
return [DisplayCategory.SCENE_TRIGGER]
def interfaces(self):
"""Yield the supported interfaces."""
return [AlexaSceneController(self.entity,
supports_deactivation=False)]
@ENTITY_ADAPTERS.register(script.DOMAIN)
class ScriptCapabilities(AlexaEntity):
"""Class to represent Script capabilities."""
def default_display_categories(self):
"""Return the display categories for this entity."""
return [DisplayCategory.ACTIVITY_TRIGGER]
def interfaces(self):
"""Yield the supported interfaces."""
can_cancel = bool(self.entity.attributes.get('can_cancel'))
return [AlexaSceneController(self.entity,
supports_deactivation=can_cancel)]
@ENTITY_ADAPTERS.register(sensor.DOMAIN)
class SensorCapabilities(AlexaEntity):
"""Class to represent Sensor capabilities."""
def default_display_categories(self):
"""Return the display categories for this entity."""
# although there are other kinds of sensors, all but temperature
# sensors are currently ignored.
return [DisplayCategory.TEMPERATURE_SENSOR]
def interfaces(self):
"""Yield the supported interfaces."""
attrs = self.entity.attributes
if attrs.get(ATTR_UNIT_OF_MEASUREMENT) in (
TEMP_FAHRENHEIT,
TEMP_CELSIUS,
):
yield AlexaTemperatureSensor(self.hass, self.entity)
yield AlexaEndpointHealth(self.hass, self.entity)
@ENTITY_ADAPTERS.register(binary_sensor.DOMAIN)
class BinarySensorCapabilities(AlexaEntity):
"""Class to represent BinarySensor capabilities."""
TYPE_CONTACT = 'contact'
TYPE_MOTION = 'motion'
def default_display_categories(self):
"""Return the display categories for this entity."""
sensor_type = self.get_type()
if sensor_type is self.TYPE_CONTACT:
return [DisplayCategory.CONTACT_SENSOR]
if sensor_type is self.TYPE_MOTION:
return [DisplayCategory.MOTION_SENSOR]
def interfaces(self):
"""Yield the supported interfaces."""
sensor_type = self.get_type()
if sensor_type is self.TYPE_CONTACT:
yield AlexaContactSensor(self.hass, self.entity)
elif sensor_type is self.TYPE_MOTION:
yield AlexaMotionSensor(self.hass, self.entity)
yield AlexaEndpointHealth(self.hass, self.entity)
def get_type(self):
"""Return the type of binary sensor."""
attrs = self.entity.attributes
if attrs.get(ATTR_DEVICE_CLASS) in (
'door',
'garage_door',
'opening',
'window',
):
return self.TYPE_CONTACT
if attrs.get(ATTR_DEVICE_CLASS) == 'motion':
return self.TYPE_MOTION

View File

@ -0,0 +1,91 @@
"""Alexa related errors."""
from homeassistant.exceptions import HomeAssistantError
from .const import API_TEMP_UNITS
class UnsupportedInterface(HomeAssistantError):
"""This entity does not support the requested Smart Home API interface."""
class UnsupportedProperty(HomeAssistantError):
"""This entity does not support the requested Smart Home API property."""
class NoTokenAvailable(HomeAssistantError):
"""There is no access token available."""
class AlexaError(Exception):
"""Base class for errors that can be serialized for the Alexa API.
A handler can raise subclasses of this to return an error to the request.
"""
namespace = None
error_type = None
def __init__(self, error_message, payload=None):
"""Initialize an alexa error."""
Exception.__init__(self)
self.error_message = error_message
self.payload = None
class AlexaInvalidEndpointError(AlexaError):
"""The endpoint in the request does not exist."""
namespace = 'Alexa'
error_type = 'NO_SUCH_ENDPOINT'
def __init__(self, endpoint_id):
"""Initialize invalid endpoint error."""
msg = 'The endpoint {} does not exist'.format(endpoint_id)
AlexaError.__init__(self, msg)
self.endpoint_id = endpoint_id
class AlexaInvalidValueError(AlexaError):
"""Class to represent InvalidValue errors."""
namespace = 'Alexa'
error_type = 'INVALID_VALUE'
class AlexaUnsupportedThermostatModeError(AlexaError):
"""Class to represent UnsupportedThermostatMode errors."""
namespace = 'Alexa.ThermostatController'
error_type = 'UNSUPPORTED_THERMOSTAT_MODE'
class AlexaTempRangeError(AlexaError):
"""Class to represent TempRange errors."""
namespace = 'Alexa'
error_type = 'TEMPERATURE_VALUE_OUT_OF_RANGE'
def __init__(self, hass, temp, min_temp, max_temp):
"""Initialize TempRange error."""
unit = hass.config.units.temperature_unit
temp_range = {
'minimumValue': {
'value': min_temp,
'scale': API_TEMP_UNITS[unit],
},
'maximumValue': {
'value': max_temp,
'scale': API_TEMP_UNITS[unit],
},
}
payload = {'validRange': temp_range}
msg = 'The requested temperature {} is out of range'.format(temp)
AlexaError.__init__(self, msg, payload)
class AlexaBridgeUnreachableError(AlexaError):
"""Class to represent BridgeUnreachable errors."""
namespace = 'Alexa'
error_type = 'BRIDGE_UNREACHABLE'

View File

@ -0,0 +1,719 @@
"""Alexa message handlers."""
from datetime import datetime
import logging
import math
from homeassistant import core as ha
from homeassistant.util.decorator import Registry
import homeassistant.util.color as color_util
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
SERVICE_LOCK,
SERVICE_MEDIA_NEXT_TRACK,
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_PREVIOUS_TRACK,
SERVICE_MEDIA_STOP,
SERVICE_SET_COVER_POSITION,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
SERVICE_UNLOCK,
SERVICE_VOLUME_DOWN,
SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_SET,
SERVICE_VOLUME_UP,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
from homeassistant.components.climate import const as climate
from homeassistant.components import cover, fan, group, light, media_player
from homeassistant.util.temperature import convert as convert_temperature
from .const import (
API_TEMP_UNITS,
API_THERMOSTAT_MODES,
Cause,
)
from .entities import async_get_entities
from .state_report import async_enable_proactive_mode
from .errors import (
AlexaInvalidValueError,
AlexaTempRangeError,
AlexaUnsupportedThermostatModeError,
)
_LOGGER = logging.getLogger(__name__)
HANDLERS = Registry()
@HANDLERS.register(('Alexa.Discovery', 'Discover'))
async def async_api_discovery(hass, config, directive, context):
"""Create a API formatted discovery response.
Async friendly.
"""
discovery_endpoints = [
alexa_entity.serialize_discovery()
for alexa_entity in async_get_entities(hass, config)
if config.should_expose(alexa_entity.entity_id)
]
return directive.response(
name='Discover.Response',
namespace='Alexa.Discovery',
payload={'endpoints': discovery_endpoints},
)
@HANDLERS.register(('Alexa.Authorization', 'AcceptGrant'))
async def async_api_accept_grant(hass, config, directive, context):
"""Create a API formatted AcceptGrant response.
Async friendly.
"""
auth_code = directive.payload['grant']['code']
_LOGGER.debug("AcceptGrant code: %s", auth_code)
if config.supports_auth:
await config.async_accept_grant(auth_code)
if config.should_report_state:
await async_enable_proactive_mode(hass, config)
return directive.response(
name='AcceptGrant.Response',
namespace='Alexa.Authorization',
payload={})
@HANDLERS.register(('Alexa.PowerController', 'TurnOn'))
async def async_api_turn_on(hass, config, directive, context):
"""Process a turn on request."""
entity = directive.entity
domain = entity.domain
if domain == group.DOMAIN:
domain = ha.DOMAIN
service = SERVICE_TURN_ON
if domain == cover.DOMAIN:
service = cover.SERVICE_OPEN_COVER
await hass.services.async_call(domain, service, {
ATTR_ENTITY_ID: entity.entity_id
}, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.PowerController', 'TurnOff'))
async def async_api_turn_off(hass, config, directive, context):
"""Process a turn off request."""
entity = directive.entity
domain = entity.domain
if entity.domain == group.DOMAIN:
domain = ha.DOMAIN
service = SERVICE_TURN_OFF
if entity.domain == cover.DOMAIN:
service = cover.SERVICE_CLOSE_COVER
await hass.services.async_call(domain, service, {
ATTR_ENTITY_ID: entity.entity_id
}, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.BrightnessController', 'SetBrightness'))
async def async_api_set_brightness(hass, config, directive, context):
"""Process a set brightness request."""
entity = directive.entity
brightness = int(directive.payload['brightness'])
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
ATTR_ENTITY_ID: entity.entity_id,
light.ATTR_BRIGHTNESS_PCT: brightness,
}, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.BrightnessController', 'AdjustBrightness'))
async def async_api_adjust_brightness(hass, config, directive, context):
"""Process an adjust brightness request."""
entity = directive.entity
brightness_delta = int(directive.payload['brightnessDelta'])
# read current state
try:
current = math.floor(
int(entity.attributes.get(light.ATTR_BRIGHTNESS)) / 255 * 100)
except ZeroDivisionError:
current = 0
# set brightness
brightness = max(0, brightness_delta + current)
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
ATTR_ENTITY_ID: entity.entity_id,
light.ATTR_BRIGHTNESS_PCT: brightness,
}, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.ColorController', 'SetColor'))
async def async_api_set_color(hass, config, directive, context):
"""Process a set color request."""
entity = directive.entity
rgb = color_util.color_hsb_to_RGB(
float(directive.payload['color']['hue']),
float(directive.payload['color']['saturation']),
float(directive.payload['color']['brightness'])
)
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
ATTR_ENTITY_ID: entity.entity_id,
light.ATTR_RGB_COLOR: rgb,
}, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.ColorTemperatureController', 'SetColorTemperature'))
async def async_api_set_color_temperature(hass, config, directive, context):
"""Process a set color temperature request."""
entity = directive.entity
kelvin = int(directive.payload['colorTemperatureInKelvin'])
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
ATTR_ENTITY_ID: entity.entity_id,
light.ATTR_KELVIN: kelvin,
}, blocking=False, context=context)
return directive.response()
@HANDLERS.register(
('Alexa.ColorTemperatureController', 'DecreaseColorTemperature'))
async def async_api_decrease_color_temp(hass, config, directive, context):
"""Process a decrease color temperature request."""
entity = directive.entity
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
max_mireds = int(entity.attributes.get(light.ATTR_MAX_MIREDS))
value = min(max_mireds, current + 50)
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
ATTR_ENTITY_ID: entity.entity_id,
light.ATTR_COLOR_TEMP: value,
}, blocking=False, context=context)
return directive.response()
@HANDLERS.register(
('Alexa.ColorTemperatureController', 'IncreaseColorTemperature'))
async def async_api_increase_color_temp(hass, config, directive, context):
"""Process an increase color temperature request."""
entity = directive.entity
current = int(entity.attributes.get(light.ATTR_COLOR_TEMP))
min_mireds = int(entity.attributes.get(light.ATTR_MIN_MIREDS))
value = max(min_mireds, current - 50)
await hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
ATTR_ENTITY_ID: entity.entity_id,
light.ATTR_COLOR_TEMP: value,
}, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.SceneController', 'Activate'))
async def async_api_activate(hass, config, directive, context):
"""Process an activate request."""
entity = directive.entity
domain = entity.domain
await hass.services.async_call(domain, SERVICE_TURN_ON, {
ATTR_ENTITY_ID: entity.entity_id
}, blocking=False, context=context)
payload = {
'cause': {'type': Cause.VOICE_INTERACTION},
'timestamp': '%sZ' % (datetime.utcnow().isoformat(),)
}
return directive.response(
name='ActivationStarted',
namespace='Alexa.SceneController',
payload=payload,
)
@HANDLERS.register(('Alexa.SceneController', 'Deactivate'))
async def async_api_deactivate(hass, config, directive, context):
"""Process a deactivate request."""
entity = directive.entity
domain = entity.domain
await hass.services.async_call(domain, SERVICE_TURN_OFF, {
ATTR_ENTITY_ID: entity.entity_id
}, blocking=False, context=context)
payload = {
'cause': {'type': Cause.VOICE_INTERACTION},
'timestamp': '%sZ' % (datetime.utcnow().isoformat(),)
}
return directive.response(
name='DeactivationStarted',
namespace='Alexa.SceneController',
payload=payload,
)
@HANDLERS.register(('Alexa.PercentageController', 'SetPercentage'))
async def async_api_set_percentage(hass, config, directive, context):
"""Process a set percentage request."""
entity = directive.entity
percentage = int(directive.payload['percentage'])
service = None
data = {ATTR_ENTITY_ID: entity.entity_id}
if entity.domain == fan.DOMAIN:
service = fan.SERVICE_SET_SPEED
speed = "off"
if percentage <= 33:
speed = "low"
elif percentage <= 66:
speed = "medium"
elif percentage <= 100:
speed = "high"
data[fan.ATTR_SPEED] = speed
elif entity.domain == cover.DOMAIN:
service = SERVICE_SET_COVER_POSITION
data[cover.ATTR_POSITION] = percentage
await hass.services.async_call(
entity.domain, service, data, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.PercentageController', 'AdjustPercentage'))
async def async_api_adjust_percentage(hass, config, directive, context):
"""Process an adjust percentage request."""
entity = directive.entity
percentage_delta = int(directive.payload['percentageDelta'])
service = None
data = {ATTR_ENTITY_ID: entity.entity_id}
if entity.domain == fan.DOMAIN:
service = fan.SERVICE_SET_SPEED
speed = entity.attributes.get(fan.ATTR_SPEED)
if speed == "off":
current = 0
elif speed == "low":
current = 33
elif speed == "medium":
current = 66
elif speed == "high":
current = 100
# set percentage
percentage = max(0, percentage_delta + current)
speed = "off"
if percentage <= 33:
speed = "low"
elif percentage <= 66:
speed = "medium"
elif percentage <= 100:
speed = "high"
data[fan.ATTR_SPEED] = speed
elif entity.domain == cover.DOMAIN:
service = SERVICE_SET_COVER_POSITION
current = entity.attributes.get(cover.ATTR_POSITION)
data[cover.ATTR_POSITION] = max(0, percentage_delta + current)
await hass.services.async_call(
entity.domain, service, data, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.LockController', 'Lock'))
async def async_api_lock(hass, config, directive, context):
"""Process a lock request."""
entity = directive.entity
await hass.services.async_call(entity.domain, SERVICE_LOCK, {
ATTR_ENTITY_ID: entity.entity_id
}, blocking=False, context=context)
response = directive.response()
response.add_context_property({
'name': 'lockState',
'namespace': 'Alexa.LockController',
'value': 'LOCKED'
})
return response
# Not supported by Alexa yet
@HANDLERS.register(('Alexa.LockController', 'Unlock'))
async def async_api_unlock(hass, config, directive, context):
"""Process an unlock request."""
entity = directive.entity
await hass.services.async_call(entity.domain, SERVICE_UNLOCK, {
ATTR_ENTITY_ID: entity.entity_id
}, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.Speaker', 'SetVolume'))
async def async_api_set_volume(hass, config, directive, context):
"""Process a set volume request."""
volume = round(float(directive.payload['volume'] / 100), 2)
entity = directive.entity
data = {
ATTR_ENTITY_ID: entity.entity_id,
media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume,
}
await hass.services.async_call(
entity.domain, SERVICE_VOLUME_SET,
data, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.InputController', 'SelectInput'))
async def async_api_select_input(hass, config, directive, context):
"""Process a set input request."""
media_input = directive.payload['input']
entity = directive.entity
# attempt to map the ALL UPPERCASE payload name to a source
source_list = entity.attributes[
media_player.const.ATTR_INPUT_SOURCE_LIST] or []
for source in source_list:
# response will always be space separated, so format the source in the
# most likely way to find a match
formatted_source = source.lower().replace('-', ' ').replace('_', ' ')
if formatted_source in media_input.lower():
media_input = source
break
else:
msg = 'failed to map input {} to a media source on {}'.format(
media_input, entity.entity_id)
raise AlexaInvalidValueError(msg)
data = {
ATTR_ENTITY_ID: entity.entity_id,
media_player.const.ATTR_INPUT_SOURCE: media_input,
}
await hass.services.async_call(
entity.domain, media_player.SERVICE_SELECT_SOURCE,
data, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.Speaker', 'AdjustVolume'))
async def async_api_adjust_volume(hass, config, directive, context):
"""Process an adjust volume request."""
volume_delta = int(directive.payload['volume'])
entity = directive.entity
current_level = entity.attributes.get(
media_player.const.ATTR_MEDIA_VOLUME_LEVEL)
# read current state
try:
current = math.floor(int(current_level * 100))
except ZeroDivisionError:
current = 0
volume = float(max(0, volume_delta + current) / 100)
data = {
ATTR_ENTITY_ID: entity.entity_id,
media_player.const.ATTR_MEDIA_VOLUME_LEVEL: volume,
}
await hass.services.async_call(
entity.domain, SERVICE_VOLUME_SET,
data, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.StepSpeaker', 'AdjustVolume'))
async def async_api_adjust_volume_step(hass, config, directive, context):
"""Process an adjust volume step request."""
# media_player volume up/down service does not support specifying steps
# each component handles it differently e.g. via config.
# For now we use the volumeSteps returned to figure out if we
# should step up/down
volume_step = directive.payload['volumeSteps']
entity = directive.entity
data = {
ATTR_ENTITY_ID: entity.entity_id,
}
if volume_step > 0:
await hass.services.async_call(
entity.domain, SERVICE_VOLUME_UP,
data, blocking=False, context=context)
elif volume_step < 0:
await hass.services.async_call(
entity.domain, SERVICE_VOLUME_DOWN,
data, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.StepSpeaker', 'SetMute'))
@HANDLERS.register(('Alexa.Speaker', 'SetMute'))
async def async_api_set_mute(hass, config, directive, context):
"""Process a set mute request."""
mute = bool(directive.payload['mute'])
entity = directive.entity
data = {
ATTR_ENTITY_ID: entity.entity_id,
media_player.const.ATTR_MEDIA_VOLUME_MUTED: mute,
}
await hass.services.async_call(
entity.domain, SERVICE_VOLUME_MUTE,
data, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.PlaybackController', 'Play'))
async def async_api_play(hass, config, directive, context):
"""Process a play request."""
entity = directive.entity
data = {
ATTR_ENTITY_ID: entity.entity_id
}
await hass.services.async_call(
entity.domain, SERVICE_MEDIA_PLAY,
data, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.PlaybackController', 'Pause'))
async def async_api_pause(hass, config, directive, context):
"""Process a pause request."""
entity = directive.entity
data = {
ATTR_ENTITY_ID: entity.entity_id
}
await hass.services.async_call(
entity.domain, SERVICE_MEDIA_PAUSE,
data, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.PlaybackController', 'Stop'))
async def async_api_stop(hass, config, directive, context):
"""Process a stop request."""
entity = directive.entity
data = {
ATTR_ENTITY_ID: entity.entity_id
}
await hass.services.async_call(
entity.domain, SERVICE_MEDIA_STOP,
data, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.PlaybackController', 'Next'))
async def async_api_next(hass, config, directive, context):
"""Process a next request."""
entity = directive.entity
data = {
ATTR_ENTITY_ID: entity.entity_id
}
await hass.services.async_call(
entity.domain, SERVICE_MEDIA_NEXT_TRACK,
data, blocking=False, context=context)
return directive.response()
@HANDLERS.register(('Alexa.PlaybackController', 'Previous'))
async def async_api_previous(hass, config, directive, context):
"""Process a previous request."""
entity = directive.entity
data = {
ATTR_ENTITY_ID: entity.entity_id
}
await hass.services.async_call(
entity.domain, SERVICE_MEDIA_PREVIOUS_TRACK,
data, blocking=False, context=context)
return directive.response()
def temperature_from_object(hass, temp_obj, interval=False):
"""Get temperature from Temperature object in requested unit."""
to_unit = hass.config.units.temperature_unit
from_unit = TEMP_CELSIUS
temp = float(temp_obj['value'])
if temp_obj['scale'] == 'FAHRENHEIT':
from_unit = TEMP_FAHRENHEIT
elif temp_obj['scale'] == 'KELVIN':
# convert to Celsius if absolute temperature
if not interval:
temp -= 273.15
return convert_temperature(temp, from_unit, to_unit, interval)
@HANDLERS.register(('Alexa.ThermostatController', 'SetTargetTemperature'))
async def async_api_set_target_temp(hass, config, directive, context):
"""Process a set target temperature request."""
entity = directive.entity
min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
unit = hass.config.units.temperature_unit
data = {
ATTR_ENTITY_ID: entity.entity_id
}
payload = directive.payload
response = directive.response()
if 'targetSetpoint' in payload:
temp = temperature_from_object(hass, payload['targetSetpoint'])
if temp < min_temp or temp > max_temp:
raise AlexaTempRangeError(hass, temp, min_temp, max_temp)
data[ATTR_TEMPERATURE] = temp
response.add_context_property({
'name': 'targetSetpoint',
'namespace': 'Alexa.ThermostatController',
'value': {'value': temp, 'scale': API_TEMP_UNITS[unit]},
})
if 'lowerSetpoint' in payload:
temp_low = temperature_from_object(hass, payload['lowerSetpoint'])
if temp_low < min_temp or temp_low > max_temp:
raise AlexaTempRangeError(hass, temp_low, min_temp, max_temp)
data[climate.ATTR_TARGET_TEMP_LOW] = temp_low
response.add_context_property({
'name': 'lowerSetpoint',
'namespace': 'Alexa.ThermostatController',
'value': {'value': temp_low, 'scale': API_TEMP_UNITS[unit]},
})
if 'upperSetpoint' in payload:
temp_high = temperature_from_object(hass, payload['upperSetpoint'])
if temp_high < min_temp or temp_high > max_temp:
raise AlexaTempRangeError(hass, temp_high, min_temp, max_temp)
data[climate.ATTR_TARGET_TEMP_HIGH] = temp_high
response.add_context_property({
'name': 'upperSetpoint',
'namespace': 'Alexa.ThermostatController',
'value': {'value': temp_high, 'scale': API_TEMP_UNITS[unit]},
})
await hass.services.async_call(
entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False,
context=context)
return response
@HANDLERS.register(('Alexa.ThermostatController', 'AdjustTargetTemperature'))
async def async_api_adjust_target_temp(hass, config, directive, context):
"""Process an adjust target temperature request."""
entity = directive.entity
min_temp = entity.attributes.get(climate.ATTR_MIN_TEMP)
max_temp = entity.attributes.get(climate.ATTR_MAX_TEMP)
unit = hass.config.units.temperature_unit
temp_delta = temperature_from_object(
hass, directive.payload['targetSetpointDelta'], interval=True)
target_temp = float(entity.attributes.get(ATTR_TEMPERATURE)) + temp_delta
if target_temp < min_temp or target_temp > max_temp:
raise AlexaTempRangeError(hass, target_temp, min_temp, max_temp)
data = {
ATTR_ENTITY_ID: entity.entity_id,
ATTR_TEMPERATURE: target_temp,
}
response = directive.response()
await hass.services.async_call(
entity.domain, climate.SERVICE_SET_TEMPERATURE, data, blocking=False,
context=context)
response.add_context_property({
'name': 'targetSetpoint',
'namespace': 'Alexa.ThermostatController',
'value': {'value': target_temp, 'scale': API_TEMP_UNITS[unit]},
})
return response
@HANDLERS.register(('Alexa.ThermostatController', 'SetThermostatMode'))
async def async_api_set_thermostat_mode(hass, config, directive, context):
"""Process a set thermostat mode request."""
entity = directive.entity
mode = directive.payload['thermostatMode']
mode = mode if isinstance(mode, str) else mode['value']
operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST)
ha_mode = next(
(k for k, v in API_THERMOSTAT_MODES.items() if v == mode),
None
)
if ha_mode not in operation_list:
msg = 'The requested thermostat mode {} is not supported'.format(mode)
raise AlexaUnsupportedThermostatModeError(msg)
data = {
ATTR_ENTITY_ID: entity.entity_id,
climate.ATTR_OPERATION_MODE: ha_mode,
}
response = directive.response()
await hass.services.async_call(
entity.domain, climate.SERVICE_SET_OPERATION_MODE, data,
blocking=False, context=context)
response.add_context_property({
'name': 'thermostatMode',
'namespace': 'Alexa.ThermostatController',
'value': mode,
})
return response
@HANDLERS.register(('Alexa', 'ReportState'))
async def async_api_reportstate(hass, config, directive, context):
"""Process a ReportState request."""
return directive.response(name='StateReport')

View File

@ -0,0 +1,200 @@
"""Alexa models."""
import logging
from uuid import uuid4
from .const import (
API_CONTEXT,
API_DIRECTIVE,
API_ENDPOINT,
API_EVENT,
API_HEADER,
API_PAYLOAD,
API_SCOPE,
)
from .entities import ENTITY_ADAPTERS
from .errors import AlexaInvalidEndpointError
_LOGGER = logging.getLogger(__name__)
class AlexaDirective:
"""An incoming Alexa directive."""
def __init__(self, request):
"""Initialize a directive."""
self._directive = request[API_DIRECTIVE]
self.namespace = self._directive[API_HEADER]['namespace']
self.name = self._directive[API_HEADER]['name']
self.payload = self._directive[API_PAYLOAD]
self.has_endpoint = API_ENDPOINT in self._directive
self.entity = self.entity_id = self.endpoint = None
def load_entity(self, hass, config):
"""Set attributes related to the entity for this request.
Sets these attributes when self.has_endpoint is True:
- entity
- entity_id
- endpoint
Behavior when self.has_endpoint is False is undefined.
Will raise AlexaInvalidEndpointError if the endpoint in the request is
malformed or nonexistant.
"""
_endpoint_id = self._directive[API_ENDPOINT]['endpointId']
self.entity_id = _endpoint_id.replace('#', '.')
self.entity = hass.states.get(self.entity_id)
if not self.entity or not config.should_expose(self.entity_id):
raise AlexaInvalidEndpointError(_endpoint_id)
self.endpoint = ENTITY_ADAPTERS[self.entity.domain](
hass, config, self.entity)
def response(self,
name='Response',
namespace='Alexa',
payload=None):
"""Create an API formatted response.
Async friendly.
"""
response = AlexaResponse(name, namespace, payload)
token = self._directive[API_HEADER].get('correlationToken')
if token:
response.set_correlation_token(token)
if self.has_endpoint:
response.set_endpoint(self._directive[API_ENDPOINT].copy())
return response
def error(
self,
namespace='Alexa',
error_type='INTERNAL_ERROR',
error_message="",
payload=None
):
"""Create a API formatted error response.
Async friendly.
"""
payload = payload or {}
payload['type'] = error_type
payload['message'] = error_message
_LOGGER.info("Request %s/%s error %s: %s",
self._directive[API_HEADER]['namespace'],
self._directive[API_HEADER]['name'],
error_type, error_message)
return self.response(
name='ErrorResponse',
namespace=namespace,
payload=payload
)
class AlexaResponse:
"""Class to hold a response."""
def __init__(self, name, namespace, payload=None):
"""Initialize the response."""
payload = payload or {}
self._response = {
API_EVENT: {
API_HEADER: {
'namespace': namespace,
'name': name,
'messageId': str(uuid4()),
'payloadVersion': '3',
},
API_PAYLOAD: payload,
}
}
@property
def name(self):
"""Return the name of this response."""
return self._response[API_EVENT][API_HEADER]['name']
@property
def namespace(self):
"""Return the namespace of this response."""
return self._response[API_EVENT][API_HEADER]['namespace']
def set_correlation_token(self, token):
"""Set the correlationToken.
This should normally mirror the value from a request, and is set by
AlexaDirective.response() usually.
"""
self._response[API_EVENT][API_HEADER]['correlationToken'] = token
def set_endpoint_full(self, bearer_token, endpoint_id, cookie=None):
"""Set the endpoint dictionary.
This is used to send proactive messages to Alexa.
"""
self._response[API_EVENT][API_ENDPOINT] = {
API_SCOPE: {
'type': 'BearerToken',
'token': bearer_token
}
}
if endpoint_id is not None:
self._response[API_EVENT][API_ENDPOINT]['endpointId'] = endpoint_id
if cookie is not None:
self._response[API_EVENT][API_ENDPOINT]['cookie'] = cookie
def set_endpoint(self, endpoint):
"""Set the endpoint.
This should normally mirror the value from a request, and is set by
AlexaDirective.response() usually.
"""
self._response[API_EVENT][API_ENDPOINT] = endpoint
def _properties(self):
context = self._response.setdefault(API_CONTEXT, {})
return context.setdefault('properties', [])
def add_context_property(self, prop):
"""Add a property to the response context.
The Alexa response includes a list of properties which provides
feedback on how states have changed. For example if a user asks,
"Alexa, set theromstat to 20 degrees", the API expects a response with
the new value of the property, and Alexa will respond to the user
"Thermostat set to 20 degrees".
async_handle_message() will call .merge_context_properties() for every
request automatically, however often handlers will call services to
change state but the effects of those changes are applied
asynchronously. Thus, handlers should call this method to confirm
changes before returning.
"""
self._properties().append(prop)
def merge_context_properties(self, endpoint):
"""Add all properties from given endpoint if not already set.
Handlers should be using .add_context_property().
"""
properties = self._properties()
already_set = {(p['namespace'], p['name']) for p in properties}
for prop in endpoint.serialize_properties():
if (prop['namespace'], prop['name']) not in already_set:
self.add_context_property(prop)
def serialize(self):
"""Return response as a JSON-able data structure."""
return self._response

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,114 @@
"""Alexa HTTP interface."""
import logging
from homeassistant import core
from homeassistant.components.http.view import HomeAssistantView
from .auth import Auth
from .config import AbstractConfig
from .const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
CONF_ENDPOINT,
CONF_ENTITY_CONFIG,
CONF_FILTER
)
from .state_report import async_enable_proactive_mode
from .smart_home import async_handle_message
_LOGGER = logging.getLogger(__name__)
SMART_HOME_HTTP_ENDPOINT = '/api/alexa/smart_home'
class AlexaConfig(AbstractConfig):
"""Alexa config."""
def __init__(self, hass, config):
"""Initialize Alexa config."""
super().__init__(hass)
self._config = config
if config.get(CONF_CLIENT_ID) and config.get(CONF_CLIENT_SECRET):
self._auth = Auth(hass, config[CONF_CLIENT_ID],
config[CONF_CLIENT_SECRET])
else:
self._auth = None
@property
def supports_auth(self):
"""Return if config supports auth."""
return self._auth is not None
@property
def should_report_state(self):
"""Return if we should proactively report states."""
return self._auth is not None
@property
def endpoint(self):
"""Endpoint for report state."""
return self._config.get(CONF_ENDPOINT)
@property
def entity_config(self):
"""Return entity config."""
return self._config.get(CONF_ENTITY_CONFIG, {})
def should_expose(self, entity_id):
"""If an entity should be exposed."""
return self._config[CONF_FILTER](entity_id)
async def async_get_access_token(self):
"""Get an access token."""
return await self._auth.async_get_access_token()
async def async_accept_grant(self, code):
"""Accept a grant."""
return await self._auth.async_do_auth(code)
async def async_setup(hass, config):
"""Activate Smart Home functionality of Alexa component.
This is optional, triggered by having a `smart_home:` sub-section in the
alexa configuration.
Even if that's disabled, the functionality in this module may still be used
by the cloud component which will call async_handle_message directly.
"""
smart_home_config = AlexaConfig(hass, config)
hass.http.register_view(SmartHomeView(smart_home_config))
if smart_home_config.should_report_state:
await async_enable_proactive_mode(hass, smart_home_config)
class SmartHomeView(HomeAssistantView):
"""Expose Smart Home v3 payload interface via HTTP POST."""
url = SMART_HOME_HTTP_ENDPOINT
name = 'api:alexa:smart_home'
def __init__(self, smart_home_config):
"""Initialize."""
self.smart_home_config = smart_home_config
async def post(self, request):
"""Handle Alexa Smart Home requests.
The Smart Home API requires the endpoint to be implemented in AWS
Lambda, which will need to forward the requests to here and pass back
the response.
"""
hass = request.app['hass']
user = request['hass_user']
message = await request.json()
_LOGGER.debug("Received Alexa Smart Home request: %s", message)
response = await async_handle_message(
hass, self.smart_home_config, message,
context=core.Context(user_id=user.id)
)
_LOGGER.debug("Sending Alexa Smart Home response: %s", response)
return b'' if response is None else self.json(response)

View File

@ -0,0 +1,185 @@
"""Alexa state report code."""
import asyncio
import json
import logging
import aiohttp
import async_timeout
from homeassistant.const import MATCH_ALL
from .const import API_CHANGE, Cause
from .entities import ENTITY_ADAPTERS
from .messages import AlexaResponse
_LOGGER = logging.getLogger(__name__)
DEFAULT_TIMEOUT = 10
async def async_enable_proactive_mode(hass, smart_home_config):
"""Enable the proactive mode.
Proactive mode makes this component report state changes to Alexa.
"""
# Validate we can get access token.
await smart_home_config.async_get_access_token()
async def async_entity_state_listener(changed_entity, old_state,
new_state):
if not new_state:
return
if new_state.domain not in ENTITY_ADAPTERS:
return
if not smart_home_config.should_expose(changed_entity):
_LOGGER.debug("Not exposing %s because filtered by config",
changed_entity)
return
alexa_changed_entity = \
ENTITY_ADAPTERS[new_state.domain](hass, smart_home_config,
new_state)
for interface in alexa_changed_entity.interfaces():
if interface.properties_proactively_reported():
await async_send_changereport_message(hass, smart_home_config,
alexa_changed_entity)
return
return hass.helpers.event.async_track_state_change(
MATCH_ALL, async_entity_state_listener
)
async def async_send_changereport_message(hass, config, alexa_entity):
"""Send a ChangeReport message for an Alexa entity.
https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#report-state-with-changereport-events
"""
token = await config.async_get_access_token()
headers = {
"Authorization": "Bearer {}".format(token)
}
endpoint = alexa_entity.alexa_id()
# this sends all the properties of the Alexa Entity, whether they have
# changed or not. this should be improved, and properties that have not
# changed should be moved to the 'context' object
properties = list(alexa_entity.serialize_properties())
payload = {
API_CHANGE: {
'cause': {'type': Cause.APP_INTERACTION},
'properties': properties
}
}
message = AlexaResponse(name='ChangeReport', namespace='Alexa',
payload=payload)
message.set_endpoint_full(token, endpoint)
message_serialized = message.serialize()
session = hass.helpers.aiohttp_client.async_get_clientsession()
try:
with async_timeout.timeout(DEFAULT_TIMEOUT):
response = await session.post(config.endpoint,
headers=headers,
json=message_serialized,
allow_redirects=True)
except (asyncio.TimeoutError, aiohttp.ClientError):
_LOGGER.error("Timeout sending report to Alexa.")
return None
response_text = await response.text()
_LOGGER.debug("Sent: %s", json.dumps(message_serialized))
_LOGGER.debug("Received (%s): %s", response.status, response_text)
if response.status != 202:
response_json = json.loads(response_text)
_LOGGER.error("Error when sending ChangeReport to Alexa: %s: %s",
response_json["payload"]["code"],
response_json["payload"]["description"])
async def async_send_add_or_update_message(hass, config, entity_ids):
"""Send an AddOrUpdateReport message for entities.
https://developer.amazon.com/docs/device-apis/alexa-discovery.html#add-or-update-report
"""
token = await config.async_get_access_token()
headers = {
"Authorization": "Bearer {}".format(token)
}
endpoints = []
for entity_id in entity_ids:
domain = entity_id.split('.', 1)[0]
alexa_entity = ENTITY_ADAPTERS[domain](
hass, config, hass.states.get(entity_id)
)
endpoints.append(alexa_entity.serialize_discovery())
payload = {
'endpoints': endpoints,
'scope': {
'type': 'BearerToken',
'token': token,
}
}
message = AlexaResponse(
name='AddOrUpdateReport', namespace='Alexa.Discovery', payload=payload)
message_serialized = message.serialize()
session = hass.helpers.aiohttp_client.async_get_clientsession()
return await session.post(config.endpoint, headers=headers,
json=message_serialized, allow_redirects=True)
async def async_send_delete_message(hass, config, entity_ids):
"""Send an DeleteReport message for entities.
https://developer.amazon.com/docs/device-apis/alexa-discovery.html#deletereport-event
"""
token = await config.async_get_access_token()
headers = {
"Authorization": "Bearer {}".format(token)
}
endpoints = []
for entity_id in entity_ids:
domain = entity_id.split('.', 1)[0]
alexa_entity = ENTITY_ADAPTERS[domain](
hass, config, hass.states.get(entity_id)
)
endpoints.append({
'endpointId': alexa_entity.alexa_id()
})
payload = {
'endpoints': endpoints,
'scope': {
'type': 'BearerToken',
'token': token,
}
}
message = AlexaResponse(name='DeleteReport', namespace='Alexa.Discovery',
payload=payload)
message_serialized = message.serialize()
session = hass.helpers.aiohttp_client.async_get_clientsession()
return await session.post(config.endpoint, headers=headers,
json=message_serialized, allow_redirects=True)

View File

@ -0,0 +1,12 @@
{
"config": {
"abort": {
"access_token": "Error desconocido al generar un token de acceso.",
"already_setup": "La cuenta de Ambiclimate est\u00e1 configurada.",
"no_config": "Es necesario configurar Ambiclimate antes de poder autenticarse con \u00e9l. Por favor, lea las instrucciones](https://www.home-assistant.io/components/ambiclimate/)."
},
"create_entry": {
"default": "Autenticaci\u00f3n exitosa con Ambiclimate"
}
}
}

View File

@ -0,0 +1,8 @@
{
"config": {
"abort": {
"already_setup": "L'account Ambiclimate \u00e8 configurato."
},
"title": "Ambiclimate"
}
}

View File

@ -0,0 +1,23 @@
{
"config": {
"abort": {
"access_token": "Onbekende fout bij het genereren van een toegangstoken.",
"already_setup": "Het Ambiclimate-account is geconfigureerd.",
"no_config": "U moet Ambiclimate configureren voordat u zich ermee kunt authenticeren. (Lees de instructies) (https://www.home-assistant.io/components/ambiclimate/)."
},
"create_entry": {
"default": "Succesvol geverifieerd met Ambiclimate"
},
"error": {
"follow_link": "Gelieve de link te volgen en te verifi\u00ebren voordat u op Verzenden drukt.",
"no_token": "Niet geverifieerd met Ambiclimate"
},
"step": {
"auth": {
"description": "Volg deze [link] ( {authorization_url} ) en <b> Toestaan </b> toegang tot uw Ambiclimate-account, kom dan terug en druk hieronder op <b> Verzenden </b> . \n (Zorg ervoor dat de opgegeven callback-URL {cb_url} )",
"title": "Authenticatie Ambiclimate"
}
},
"title": "Ambiclimate"
}
}

View File

@ -0,0 +1,23 @@
{
"config": {
"abort": {
"access_token": "Erro desconhecido ao gerar um token de acesso.",
"already_setup": "A conta Ambiclimate est\u00e1 configurada.",
"no_config": "Voc\u00ea precisa configurar o Ambiclimate antes de poder autenticar com ele. [Por favor, leia as instru\u00e7\u00f5es] (https://www.home-assistant.io/components/ambiclimate/)."
},
"create_entry": {
"default": "Autenticado com sucesso no Ambiclimate"
},
"error": {
"follow_link": "Por favor, siga o link e autentique-se antes de pressionar Enviar",
"no_token": "N\u00e3o autenticado com o Ambiclimate"
},
"step": {
"auth": {
"description": "Por favor, siga este [link]({authorization_url}) e <b>Permitir</b> acesso \u00e0 sua conta Ambiclimate, em seguida, volte e pressione <b>Enviar</b> abaixo. \n (Verifique se a URL de retorno de chamada especificada \u00e9 {cb_url})",
"title": "Autenticar Ambiclimate"
}
},
"title": "Ambiclimate"
}
}

View File

@ -56,14 +56,15 @@ async def async_setup_entry(hass, entry, async_add_entities):
websession)
try:
_token_info = await oauth.refresh_access_token(token_info)
token_info = await oauth.refresh_access_token(token_info)
except ambiclimate.AmbiclimateOauthError:
token_info = None
if not token_info:
_LOGGER.error("Failed to refresh access token")
return
if _token_info:
await store.async_save(_token_info)
token_info = _token_info
await store.async_save(token_info)
data_connection = ambiclimate.AmbiclimateConnection(oauth,
token_info=token_info,

View File

@ -4,7 +4,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/components/ambiclimate",
"requirements": [
"ambiclimate==0.1.2"
"ambiclimate==0.2.0"
],
"dependencies": [],
"codeowners": [

View File

@ -327,7 +327,7 @@ class AmbientStation:
"""Define a handler to fire when the websocket is connected."""
_LOGGER.info('Connected to websocket')
_LOGGER.debug('Watchdog starting')
if self._watchdog_listener:
if self._watchdog_listener is not None:
self._watchdog_listener()
self._watchdog_listener = async_call_later(
self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect)

View File

@ -4,7 +4,7 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/components/ambient_station",
"requirements": [
"aioambient==0.3.0"
"aioambient==0.3.1"
],
"dependencies": [],
"codeowners": [

View File

@ -1,8 +1,10 @@
"""Support for Amcrest IP cameras."""
import logging
from datetime import timedelta
import threading
import aiohttp
from amcrest import AmcrestError, Http, LoginError
import voluptuous as vol
from homeassistant.auth.permissions.const import POLICY_CONTROL
@ -17,12 +19,14 @@ from homeassistant.const import (
from homeassistant.exceptions import Unauthorized, UnknownUser
from homeassistant.helpers import discovery
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.dispatcher import (
async_dispatcher_send, dispatcher_send)
from homeassistant.helpers.event import track_time_interval
from homeassistant.helpers.service import async_extract_entity_ids
from .binary_sensor import BINARY_SENSORS
from .binary_sensor import BINARY_SENSOR_MOTION_DETECTED, BINARY_SENSORS
from .camera import CAMERA_SERVICES, STREAM_SOURCE_LIST
from .const import DOMAIN, DATA_AMCREST
from .const import CAMERAS, DOMAIN, DATA_AMCREST, DEVICES, SERVICE_UPDATE
from .helpers import service_signal
from .sensor import SENSOR_MOTION_DETECTOR, SENSORS
from .switch import SWITCHES
@ -32,11 +36,14 @@ _LOGGER = logging.getLogger(__name__)
CONF_RESOLUTION = 'resolution'
CONF_STREAM_SOURCE = 'stream_source'
CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments'
CONF_CONTROL_LIGHT = 'control_light'
DEFAULT_NAME = 'Amcrest Camera'
DEFAULT_PORT = 80
DEFAULT_RESOLUTION = 'high'
DEFAULT_ARGUMENTS = '-pred 1'
MAX_ERRORS = 5
RECHECK_INTERVAL = timedelta(minutes=1)
NOTIFICATION_ID = 'amcrest_notification'
NOTIFICATION_TITLE = 'Amcrest Camera Setup'
@ -56,20 +63,21 @@ AUTHENTICATION_LIST = {
def _deprecated_sensor_values(sensors):
if SENSOR_MOTION_DETECTOR in sensors:
_LOGGER.warning(
"The 'sensors' option value '%s' is deprecated, "
"The '%s' option value '%s' is deprecated, "
"please remove it from your configuration and use "
"the 'binary_sensors' option with value 'motion_detected' "
"instead.", SENSOR_MOTION_DETECTOR)
"the '%s' option with value '%s' instead",
CONF_SENSORS, SENSOR_MOTION_DETECTOR, CONF_BINARY_SENSORS,
BINARY_SENSOR_MOTION_DETECTED)
return sensors
def _deprecated_switches(config):
if CONF_SWITCHES in config:
_LOGGER.warning(
"The 'switches' option (with value %s) is deprecated, "
"The '%s' option (with value %s) is deprecated, "
"please remove it from your configuration and use "
"camera services and attributes instead.",
config[CONF_SWITCHES])
"services and attributes instead",
CONF_SWITCHES, config[CONF_SWITCHES])
return config
@ -103,6 +111,7 @@ AMCREST_SCHEMA = vol.All(
_deprecated_sensor_values),
vol.Optional(CONF_SWITCHES):
vol.All(cv.ensure_list, [vol.In(SWITCHES)]),
vol.Optional(CONF_CONTROL_LIGHT, default=True): cv.boolean,
}),
_deprecated_switches
)
@ -112,35 +121,81 @@ CONFIG_SCHEMA = vol.Schema({
}, extra=vol.ALLOW_EXTRA)
# pylint: disable=too-many-ancestors
class AmcrestChecker(Http):
"""amcrest.Http wrapper for catching errors."""
def __init__(self, hass, name, host, port, user, password):
"""Initialize."""
self._hass = hass
self._wrap_name = name
self._wrap_errors = 0
self._wrap_lock = threading.Lock()
self._unsub_recheck = None
super().__init__(host, port, user, password, retries_connection=1,
timeout_protocol=3.05)
@property
def available(self):
"""Return if camera's API is responding."""
return self._wrap_errors <= MAX_ERRORS
def command(self, cmd, retries=None, timeout_cmd=None, stream=False):
"""amcrest.Http.command wrapper to catch errors."""
try:
ret = super().command(cmd, retries, timeout_cmd, stream)
except AmcrestError:
with self._wrap_lock:
was_online = self.available
self._wrap_errors += 1
_LOGGER.debug('%s camera errs: %i', self._wrap_name,
self._wrap_errors)
offline = not self.available
if offline and was_online:
_LOGGER.error(
'%s camera offline: Too many errors', self._wrap_name)
dispatcher_send(
self._hass,
service_signal(SERVICE_UPDATE, self._wrap_name))
self._unsub_recheck = track_time_interval(
self._hass, self._wrap_test_online, RECHECK_INTERVAL)
raise
with self._wrap_lock:
was_offline = not self.available
self._wrap_errors = 0
if was_offline:
self._unsub_recheck()
self._unsub_recheck = None
_LOGGER.error('%s camera back online', self._wrap_name)
dispatcher_send(
self._hass, service_signal(SERVICE_UPDATE, self._wrap_name))
return ret
def _wrap_test_online(self, now):
"""Test if camera is back online."""
try:
self.current_time
except AmcrestError:
pass
def setup(hass, config):
"""Set up the Amcrest IP Camera component."""
from amcrest import AmcrestCamera, AmcrestError
hass.data.setdefault(DATA_AMCREST, {DEVICES: {}, CAMERAS: []})
hass.data.setdefault(DATA_AMCREST, {'devices': {}, 'cameras': []})
devices = config[DOMAIN]
for device in devices:
for device in config[DOMAIN]:
name = device[CONF_NAME]
username = device[CONF_USERNAME]
password = device[CONF_PASSWORD]
try:
api = AmcrestCamera(device[CONF_HOST],
device[CONF_PORT],
username,
password).camera
# pylint: disable=pointless-statement
# Test camera communications.
api.current_time
api = AmcrestChecker(
hass, name,
device[CONF_HOST], device[CONF_PORT],
username, password)
except AmcrestError as ex:
_LOGGER.error("Unable to connect to %s camera: %s", name, str(ex))
hass.components.persistent_notification.create(
'Error: {}<br />'
'You will need to restart hass after fixing.'
''.format(ex),
title=NOTIFICATION_TITLE,
notification_id=NOTIFICATION_ID)
except LoginError as ex:
_LOGGER.error("Login error for %s camera: %s", name, ex)
continue
ffmpeg_arguments = device[CONF_FFMPEG_ARGUMENTS]
@ -149,6 +204,7 @@ def setup(hass, config):
sensors = device.get(CONF_SENSORS)
switches = device.get(CONF_SWITCHES)
stream_source = device[CONF_STREAM_SOURCE]
control_light = device.get(CONF_CONTROL_LIGHT)
# currently aiohttp only works with basic authentication
# only valid for mjpeg streaming
@ -157,9 +213,9 @@ def setup(hass, config):
else:
authentication = None
hass.data[DATA_AMCREST]['devices'][name] = AmcrestDevice(
hass.data[DATA_AMCREST][DEVICES][name] = AmcrestDevice(
api, authentication, ffmpeg_arguments, stream_source,
resolution)
resolution, control_light)
discovery.load_platform(
hass, CAMERA, DOMAIN, {
@ -187,7 +243,7 @@ def setup(hass, config):
CONF_SWITCHES: switches
}, config)
if not hass.data[DATA_AMCREST]['devices']:
if not hass.data[DATA_AMCREST][DEVICES]:
return False
def have_permission(user, entity_id):
@ -205,13 +261,13 @@ def setup(hass, config):
if call.data.get(ATTR_ENTITY_ID) == ENTITY_MATCH_ALL:
# Return all entity_ids user has permission to control.
return [
entity_id for entity_id in hass.data[DATA_AMCREST]['cameras']
entity_id for entity_id in hass.data[DATA_AMCREST][CAMERAS]
if have_permission(user, entity_id)
]
call_ids = await async_extract_entity_ids(hass, call)
entity_ids = []
for entity_id in hass.data[DATA_AMCREST]['cameras']:
for entity_id in hass.data[DATA_AMCREST][CAMERAS]:
if entity_id not in call_ids:
continue
if not have_permission(user, entity_id):
@ -245,10 +301,11 @@ class AmcrestDevice:
"""Representation of a base Amcrest discovery device."""
def __init__(self, api, authentication, ffmpeg_arguments,
stream_source, resolution):
stream_source, resolution, control_light):
"""Initialize the entity."""
self.api = api
self.authentication = authentication
self.ffmpeg_arguments = ffmpeg_arguments
self.stream_source = stream_source
self.resolution = resolution
self.control_light = control_light

View File

@ -2,18 +2,27 @@
from datetime import timedelta
import logging
from homeassistant.components.binary_sensor import (
BinarySensorDevice, DEVICE_CLASS_MOTION)
from homeassistant.const import CONF_NAME, CONF_BINARY_SENSORS
from amcrest import AmcrestError
from .const import BINARY_SENSOR_SCAN_INTERVAL_SECS, DATA_AMCREST
from homeassistant.components.binary_sensor import (
BinarySensorDevice, DEVICE_CLASS_CONNECTIVITY, DEVICE_CLASS_MOTION)
from homeassistant.const import CONF_NAME, CONF_BINARY_SENSORS
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import (
BINARY_SENSOR_SCAN_INTERVAL_SECS, DATA_AMCREST, DEVICES, SERVICE_UPDATE)
from .helpers import log_update_error, service_signal
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=BINARY_SENSOR_SCAN_INTERVAL_SECS)
BINARY_SENSOR_MOTION_DETECTED = 'motion_detected'
BINARY_SENSOR_ONLINE = 'online'
# Binary sensor types are defined like: Name, device class
BINARY_SENSORS = {
'motion_detected': 'Motion Detected'
BINARY_SENSOR_MOTION_DETECTED: ('Motion Detected', DEVICE_CLASS_MOTION),
BINARY_SENSOR_ONLINE: ('Online', DEVICE_CLASS_CONNECTIVITY),
}
@ -24,7 +33,7 @@ async def async_setup_platform(hass, config, async_add_entities,
return
name = discovery_info[CONF_NAME]
device = hass.data[DATA_AMCREST]['devices'][name]
device = hass.data[DATA_AMCREST][DEVICES][name]
async_add_entities(
[AmcrestBinarySensor(name, device, sensor_type)
for sensor_type in discovery_info[CONF_BINARY_SENSORS]],
@ -36,10 +45,18 @@ class AmcrestBinarySensor(BinarySensorDevice):
def __init__(self, name, device, sensor_type):
"""Initialize entity."""
self._name = '{} {}'.format(name, BINARY_SENSORS[sensor_type])
self._name = '{} {}'.format(name, BINARY_SENSORS[sensor_type][0])
self._signal_name = name
self._api = device.api
self._sensor_type = sensor_type
self._state = None
self._device_class = BINARY_SENSORS[sensor_type][1]
self._unsub_dispatcher = None
@property
def should_poll(self):
"""Return True if entity has to be polled for state."""
return self._sensor_type != BINARY_SENSOR_ONLINE
@property
def name(self):
@ -54,17 +71,39 @@ class AmcrestBinarySensor(BinarySensorDevice):
@property
def device_class(self):
"""Return device class."""
return DEVICE_CLASS_MOTION
return self._device_class
@property
def available(self):
"""Return True if entity is available."""
return self._sensor_type == BINARY_SENSOR_ONLINE or self._api.available
def update(self):
"""Update entity."""
from amcrest import AmcrestError
_LOGGER.debug('Pulling data from %s binary sensor', self._name)
if not self.available:
return
_LOGGER.debug('Updating %s binary sensor', self._name)
try:
if self._sensor_type == BINARY_SENSOR_MOTION_DETECTED:
self._state = self._api.is_motion_detected
elif self._sensor_type == BINARY_SENSOR_ONLINE:
self._state = self._api.available
except AmcrestError as error:
_LOGGER.error(
'Could not update %s binary sensor due to error: %s',
self.name, error)
log_update_error(
_LOGGER, 'update', self.name, 'binary sensor', error)
async def async_on_demand_update(self):
"""Update state."""
self.async_schedule_update_ha_state(True)
async def async_added_to_hass(self):
"""Subscribe to update signal."""
self._unsub_dispatcher = async_dispatcher_connect(
self.hass, service_signal(SERVICE_UPDATE, self._signal_name),
self.async_on_demand_update)
async def async_will_remove_from_hass(self):
"""Disconnect from update signal."""
self._unsub_dispatcher()

View File

@ -1,7 +1,10 @@
"""Support for Amcrest IP cameras."""
import asyncio
from datetime import timedelta
import logging
from urllib3.exceptions import HTTPError
from amcrest import AmcrestError
import voluptuous as vol
from homeassistant.components.camera import (
@ -14,11 +17,14 @@ from homeassistant.helpers.aiohttp_client import (
async_get_clientsession)
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from .const import CAMERA_WEB_SESSION_TIMEOUT, DATA_AMCREST
from .helpers import service_signal
from .const import (
CAMERA_WEB_SESSION_TIMEOUT, CAMERAS, DATA_AMCREST, DEVICES, SERVICE_UPDATE)
from .helpers import log_update_error, service_signal
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=15)
STREAM_SOURCE_LIST = [
'snapshot',
'mjpeg',
@ -76,7 +82,7 @@ async def async_setup_platform(hass, config, async_add_entities,
return
name = discovery_info[CONF_NAME]
device = hass.data[DATA_AMCREST]['devices'][name]
device = hass.data[DATA_AMCREST][DEVICES][name]
async_add_entities([
AmcrestCam(name, device, hass.data[DATA_FFMPEG])], True)
@ -94,33 +100,36 @@ class AmcrestCam(Camera):
self._stream_source = device.stream_source
self._resolution = device.resolution
self._token = self._auth = device.authentication
self._control_light = device.control_light
self._is_recording = False
self._motion_detection_enabled = None
self._brand = None
self._model = None
self._audio_enabled = None
self._motion_recording_enabled = None
self._color_bw = None
self._rtsp_url = None
self._snapshot_lock = asyncio.Lock()
self._unsub_dispatcher = []
self._update_succeeded = False
async def async_camera_image(self):
"""Return a still image response from the camera."""
from amcrest import AmcrestError
if not self.is_on:
_LOGGER.error(
'Attempt to take snaphot when %s camera is off', self.name)
available = self.available
if not available or not self.is_on:
_LOGGER.warning(
'Attempt to take snaphot when %s camera is %s', self.name,
'offline' if not available else 'off')
return None
async with self._snapshot_lock:
try:
# Send the request to snap a picture and return raw jpg data
response = await self.hass.async_add_executor_job(
self._api.snapshot, self._resolution)
self._api.snapshot)
return response.data
except AmcrestError as error:
_LOGGER.error(
'Could not get image from %s camera due to error: %s',
self.name, error)
except (AmcrestError, HTTPError) as error:
log_update_error(
_LOGGER, 'get image from', self.name, 'camera', error)
return None
async def handle_async_mjpeg_stream(self, request):
@ -129,6 +138,12 @@ class AmcrestCam(Camera):
if self._stream_source == 'snapshot':
return await super().handle_async_mjpeg_stream(request)
if not self.available:
_LOGGER.warning(
'Attempt to stream %s when %s camera is offline',
self._stream_source, self.name)
return None
if self._stream_source == 'mjpeg':
# stream an MJPEG image stream directly from the camera
websession = async_get_clientsession(self.hass)
@ -143,7 +158,7 @@ class AmcrestCam(Camera):
# streaming via ffmpeg
from haffmpeg.camera import CameraMjpeg
streaming_url = self._api.rtsp_url(typeno=self._resolution)
streaming_url = self._rtsp_url
stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop)
await stream.open_camera(
streaming_url, extra_cmd=self._ffmpeg_arguments)
@ -158,6 +173,14 @@ class AmcrestCam(Camera):
# Entity property overrides
@property
def should_poll(self) -> bool:
"""Return True if entity has to be polled for state.
False if entity pushes its state to HA.
"""
return True
@property
def name(self):
"""Return the name of this camera."""
@ -176,6 +199,11 @@ class AmcrestCam(Camera):
attr[_ATTR_COLOR_BW] = self._color_bw
return attr
@property
def available(self):
"""Return True if entity is available."""
return self._api.available
@property
def supported_features(self):
"""Return supported features."""
@ -191,7 +219,7 @@ class AmcrestCam(Camera):
@property
def brand(self):
"""Return the camera brand."""
return 'Amcrest'
return self._brand
@property
def motion_detection_enabled(self):
@ -205,7 +233,7 @@ class AmcrestCam(Camera):
async def stream_source(self):
"""Return the source of the stream."""
return self._api.rtsp_url(typeno=self._resolution)
return self._rtsp_url
@property
def is_on(self):
@ -214,6 +242,10 @@ class AmcrestCam(Camera):
# Other Entity method overrides
async def async_on_demand_update(self):
"""Update state."""
self.async_schedule_update_ha_state(True)
async def async_added_to_hass(self):
"""Subscribe to signals and add camera to list."""
for service, params in CAMERA_SERVICES.items():
@ -221,28 +253,37 @@ class AmcrestCam(Camera):
self.hass,
service_signal(service, self.entity_id),
getattr(self, params[1])))
self.hass.data[DATA_AMCREST]['cameras'].append(self.entity_id)
self._unsub_dispatcher.append(async_dispatcher_connect(
self.hass, service_signal(SERVICE_UPDATE, self._name),
self.async_on_demand_update))
self.hass.data[DATA_AMCREST][CAMERAS].append(self.entity_id)
async def async_will_remove_from_hass(self):
"""Remove camera from list and disconnect from signals."""
self.hass.data[DATA_AMCREST]['cameras'].remove(self.entity_id)
self.hass.data[DATA_AMCREST][CAMERAS].remove(self.entity_id)
for unsub_dispatcher in self._unsub_dispatcher:
unsub_dispatcher()
def update(self):
"""Update entity status."""
from amcrest import AmcrestError
_LOGGER.debug('Pulling data from %s camera', self.name)
if not self.available or self._update_succeeded:
if not self.available:
self._update_succeeded = False
return
_LOGGER.debug('Updating %s camera', self.name)
try:
if self._brand is None:
resp = self._api.vendor_information.strip()
if resp.startswith('vendor='):
self._brand = resp.split('=')[-1]
else:
self._brand = 'unknown'
if self._model is None:
try:
self._model = self._api.device_type.split('=')[-1].strip()
except AmcrestError as error:
_LOGGER.error(
'Could not get %s camera model due to error: %s',
self.name, error)
self._model = ''
try:
resp = self._api.device_type.strip()
if resp.startswith('type='):
self._model = resp.split('=')[-1]
else:
self._model = 'unknown'
self.is_streaming = self._api.video_enabled
self._is_recording = self._api.record_mode == 'Manual'
self._motion_detection_enabled = (
@ -251,10 +292,13 @@ class AmcrestCam(Camera):
self._motion_recording_enabled = (
self._api.is_record_on_motion_detection())
self._color_bw = _CBW[self._api.day_night_color]
self._rtsp_url = self._api.rtsp_url(typeno=self._resolution)
except AmcrestError as error:
_LOGGER.error(
'Could not get %s camera attributes due to error: %s',
self.name, error)
log_update_error(
_LOGGER, 'get', self.name, 'camera attributes', error)
self._update_succeeded = False
else:
self._update_succeeded = True
# Other Camera method overrides
@ -322,8 +366,6 @@ class AmcrestCam(Camera):
def _enable_video_stream(self, enable):
"""Enable or disable camera video stream."""
from amcrest import AmcrestError
# Given the way the camera's state is determined by
# is_streaming and is_recording, we can't leave
# recording on if video stream is being turned off.
@ -332,17 +374,17 @@ class AmcrestCam(Camera):
try:
self._api.video_enabled = enable
except AmcrestError as error:
_LOGGER.error(
'Could not %s %s camera video stream due to error: %s',
'enable' if enable else 'disable', self.name, error)
log_update_error(
_LOGGER, 'enable' if enable else 'disable', self.name,
'camera video stream', error)
else:
self.is_streaming = enable
self.schedule_update_ha_state()
if self._control_light:
self._enable_light(self._audio_enabled or self.is_streaming)
def _enable_recording(self, enable):
"""Turn recording on or off."""
from amcrest import AmcrestError
# Given the way the camera's state is determined by
# is_streaming and is_recording, we can't leave
# video stream off if recording is being turned on.
@ -353,88 +395,89 @@ class AmcrestCam(Camera):
self._api.record_mode = rec_mode[
'Manual' if enable else 'Automatic']
except AmcrestError as error:
_LOGGER.error(
'Could not %s %s camera recording due to error: %s',
'enable' if enable else 'disable', self.name, error)
log_update_error(
_LOGGER, 'enable' if enable else 'disable', self.name,
'camera recording', error)
else:
self._is_recording = enable
self.schedule_update_ha_state()
def _enable_motion_detection(self, enable):
"""Enable or disable motion detection."""
from amcrest import AmcrestError
try:
self._api.motion_detection = str(enable).lower()
except AmcrestError as error:
_LOGGER.error(
'Could not %s %s camera motion detection due to error: %s',
'enable' if enable else 'disable', self.name, error)
log_update_error(
_LOGGER, 'enable' if enable else 'disable', self.name,
'camera motion detection', error)
else:
self._motion_detection_enabled = enable
self.schedule_update_ha_state()
def _enable_audio(self, enable):
"""Enable or disable audio stream."""
from amcrest import AmcrestError
try:
self._api.audio_enabled = enable
except AmcrestError as error:
_LOGGER.error(
'Could not %s %s camera audio stream due to error: %s',
'enable' if enable else 'disable', self.name, error)
log_update_error(
_LOGGER, 'enable' if enable else 'disable', self.name,
'camera audio stream', error)
else:
self._audio_enabled = enable
self.schedule_update_ha_state()
if self._control_light:
self._enable_light(self._audio_enabled or self.is_streaming)
def _enable_light(self, enable):
"""Enable or disable indicator light."""
try:
self._api.command(
'configManager.cgi?action=setConfig&LightGlobal[0].Enable={}'
.format(str(enable).lower()))
except AmcrestError as error:
log_update_error(
_LOGGER, 'enable' if enable else 'disable', self.name,
'indicator light', error)
def _enable_motion_recording(self, enable):
"""Enable or disable motion recording."""
from amcrest import AmcrestError
try:
self._api.motion_recording = str(enable).lower()
except AmcrestError as error:
_LOGGER.error(
'Could not %s %s camera motion recording due to error: %s',
'enable' if enable else 'disable', self.name, error)
log_update_error(
_LOGGER, 'enable' if enable else 'disable', self.name,
'camera motion recording', error)
else:
self._motion_recording_enabled = enable
self.schedule_update_ha_state()
def _goto_preset(self, preset):
"""Move camera position and zoom to preset."""
from amcrest import AmcrestError
try:
self._api.go_to_preset(
action='start', preset_point_number=preset)
except AmcrestError as error:
_LOGGER.error(
'Could not move %s camera to preset %i due to error: %s',
self.name, preset, error)
log_update_error(
_LOGGER, 'move', self.name,
'camera to preset {}'.format(preset), error)
def _set_color_bw(self, cbw):
"""Set camera color mode."""
from amcrest import AmcrestError
try:
self._api.day_night_color = _CBW.index(cbw)
except AmcrestError as error:
_LOGGER.error(
'Could not set %s camera color mode to %s due to error: %s',
self.name, cbw, error)
log_update_error(
_LOGGER, 'set', self.name,
'camera color mode to {}'.format(cbw), error)
else:
self._color_bw = cbw
self.schedule_update_ha_state()
def _start_tour(self, start):
"""Start camera tour."""
from amcrest import AmcrestError
try:
self._api.tour(start=start)
except AmcrestError as error:
_LOGGER.error(
'Could not %s %s camera tour due to error: %s',
'start' if start else 'stop', self.name, error)
log_update_error(
_LOGGER, 'start' if start else 'stop', self.name,
'camera tour', error)

View File

@ -1,7 +1,11 @@
"""Constants for amcrest component."""
DOMAIN = 'amcrest'
DATA_AMCREST = DOMAIN
CAMERAS = 'cameras'
DEVICES = 'devices'
BINARY_SENSOR_SCAN_INTERVAL_SECS = 5
CAMERA_WEB_SESSION_TIMEOUT = 10
SENSOR_SCAN_INTERVAL_SECS = 10
SERVICE_UPDATE = 'update'

View File

@ -2,9 +2,16 @@
from .const import DOMAIN
def service_signal(service, entity_id=None):
"""Encode service and entity_id into signal."""
def service_signal(service, ident=None):
"""Encode service and identifier into signal."""
signal = '{}_{}'.format(DOMAIN, service)
if entity_id:
signal += '_{}'.format(entity_id.replace('.', '_'))
if ident:
signal += '_{}'.format(ident.replace('.', '_'))
return signal
def log_update_error(logger, action, name, entity_type, error):
"""Log an update error."""
logger.error(
'Could not %s %s %s due to error: %s',
action, name, entity_type, error.__class__.__name__)

View File

@ -3,7 +3,7 @@
"name": "Amcrest",
"documentation": "https://www.home-assistant.io/components/amcrest",
"requirements": [
"amcrest==1.4.0"
"amcrest==1.5.3"
],
"dependencies": [
"ffmpeg"

View File

@ -2,21 +2,28 @@
from datetime import timedelta
import logging
from amcrest import AmcrestError
from homeassistant.const import CONF_NAME, CONF_SENSORS
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from .const import DATA_AMCREST, SENSOR_SCAN_INTERVAL_SECS
from .const import (
DATA_AMCREST, DEVICES, SENSOR_SCAN_INTERVAL_SECS, SERVICE_UPDATE)
from .helpers import log_update_error, service_signal
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=SENSOR_SCAN_INTERVAL_SECS)
# Sensor types are defined like: Name, units, icon
SENSOR_MOTION_DETECTOR = 'motion_detector'
SENSOR_PTZ_PRESET = 'ptz_preset'
SENSOR_SDCARD = 'sdcard'
# Sensor types are defined like: Name, units, icon
SENSORS = {
SENSOR_MOTION_DETECTOR: ['Motion Detected', None, 'mdi:run'],
'sdcard': ['SD Used', '%', 'mdi:sd'],
'ptz_preset': ['PTZ Preset', None, 'mdi:camera-iris'],
SENSOR_PTZ_PRESET: ['PTZ Preset', None, 'mdi:camera-iris'],
SENSOR_SDCARD: ['SD Used', '%', 'mdi:sd'],
}
@ -27,7 +34,7 @@ async def async_setup_platform(
return
name = discovery_info[CONF_NAME]
device = hass.data[DATA_AMCREST]['devices'][name]
device = hass.data[DATA_AMCREST][DEVICES][name]
async_add_entities(
[AmcrestSensor(name, device, sensor_type)
for sensor_type in discovery_info[CONF_SENSORS]],
@ -40,12 +47,14 @@ class AmcrestSensor(Entity):
def __init__(self, name, device, sensor_type):
"""Initialize a sensor for Amcrest camera."""
self._name = '{} {}'.format(name, SENSORS[sensor_type][0])
self._signal_name = name
self._api = device.api
self._sensor_type = sensor_type
self._state = None
self._attrs = {}
self._unit_of_measurement = SENSORS[sensor_type][1]
self._icon = SENSORS[sensor_type][2]
self._unsub_dispatcher = None
@property
def name(self):
@ -72,21 +81,30 @@ class AmcrestSensor(Entity):
"""Return the units of measurement."""
return self._unit_of_measurement
@property
def available(self):
"""Return True if entity is available."""
return self._api.available
def update(self):
"""Get the latest data and updates the state."""
_LOGGER.debug("Pulling data from %s sensor.", self._name)
if not self.available:
return
_LOGGER.debug("Updating %s sensor", self._name)
if self._sensor_type == 'motion_detector':
try:
if self._sensor_type == SENSOR_MOTION_DETECTOR:
self._state = self._api.is_motion_detected
self._attrs['Record Mode'] = self._api.record_mode
elif self._sensor_type == 'ptz_preset':
elif self._sensor_type == SENSOR_PTZ_PRESET:
self._state = self._api.ptz_presets_count
elif self._sensor_type == 'sdcard':
elif self._sensor_type == SENSOR_SDCARD:
storage = self._api.storage_all
try:
self._attrs['Total'] = '{:.2f} {}'.format(*storage['total'])
self._attrs['Total'] = '{:.2f} {}'.format(
*storage['total'])
except ValueError:
self._attrs['Total'] = '{} {}'.format(*storage['total'])
try:
@ -97,3 +115,19 @@ class AmcrestSensor(Entity):
self._state = '{:.2f}'.format(storage['used_percent'])
except ValueError:
self._state = storage['used_percent']
except AmcrestError as error:
log_update_error(_LOGGER, 'update', self.name, 'sensor', error)
async def async_on_demand_update(self):
"""Update state."""
self.async_schedule_update_ha_state(True)
async def async_added_to_hass(self):
"""Subscribe to update signal."""
self._unsub_dispatcher = async_dispatcher_connect(
self.hass, service_signal(SERVICE_UPDATE, self._signal_name),
self.async_on_demand_update)
async def async_will_remove_from_hass(self):
"""Disconnect from update signal."""
self._unsub_dispatcher()

View File

@ -1,17 +1,23 @@
"""Support for toggling Amcrest IP camera settings."""
import logging
from amcrest import AmcrestError
from homeassistant.const import CONF_NAME, CONF_SWITCHES
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import ToggleEntity
from .const import DATA_AMCREST
from .const import DATA_AMCREST, DEVICES, SERVICE_UPDATE
from .helpers import log_update_error, service_signal
_LOGGER = logging.getLogger(__name__)
MOTION_DETECTION = 'motion_detection'
MOTION_RECORDING = 'motion_recording'
# Switch types are defined like: Name, icon
SWITCHES = {
'motion_detection': ['Motion Detection', 'mdi:run-fast'],
'motion_recording': ['Motion Recording', 'mdi:record-rec']
MOTION_DETECTION: ['Motion Detection', 'mdi:run-fast'],
MOTION_RECORDING: ['Motion Recording', 'mdi:record-rec']
}
@ -22,7 +28,7 @@ async def async_setup_platform(
return
name = discovery_info[CONF_NAME]
device = hass.data[DATA_AMCREST]['devices'][name]
device = hass.data[DATA_AMCREST][DEVICES][name]
async_add_entities(
[AmcrestSwitch(name, device, setting)
for setting in discovery_info[CONF_SWITCHES]],
@ -35,10 +41,12 @@ class AmcrestSwitch(ToggleEntity):
def __init__(self, name, device, setting):
"""Initialize the Amcrest switch."""
self._name = '{} {}'.format(name, SWITCHES[setting][0])
self._signal_name = name
self._api = device.api
self._setting = setting
self._state = False
self._icon = SWITCHES[setting][1]
self._unsub_dispatcher = None
@property
def name(self):
@ -52,30 +60,63 @@ class AmcrestSwitch(ToggleEntity):
def turn_on(self, **kwargs):
"""Turn setting on."""
if self._setting == 'motion_detection':
if not self.available:
return
try:
if self._setting == MOTION_DETECTION:
self._api.motion_detection = 'true'
elif self._setting == 'motion_recording':
elif self._setting == MOTION_RECORDING:
self._api.motion_recording = 'true'
except AmcrestError as error:
log_update_error(_LOGGER, 'turn on', self.name, 'switch', error)
def turn_off(self, **kwargs):
"""Turn setting off."""
if self._setting == 'motion_detection':
if not self.available:
return
try:
if self._setting == MOTION_DETECTION:
self._api.motion_detection = 'false'
elif self._setting == 'motion_recording':
elif self._setting == MOTION_RECORDING:
self._api.motion_recording = 'false'
except AmcrestError as error:
log_update_error(_LOGGER, 'turn off', self.name, 'switch', error)
@property
def available(self):
"""Return True if entity is available."""
return self._api.available
def update(self):
"""Update setting state."""
_LOGGER.debug("Polling state for setting: %s ", self._name)
if not self.available:
return
_LOGGER.debug("Updating %s switch", self._name)
if self._setting == 'motion_detection':
try:
if self._setting == MOTION_DETECTION:
detection = self._api.is_motion_detector_on()
elif self._setting == 'motion_recording':
elif self._setting == MOTION_RECORDING:
detection = self._api.is_record_on_motion_detection()
self._state = detection
except AmcrestError as error:
log_update_error(_LOGGER, 'update', self.name, 'switch', error)
@property
def icon(self):
"""Return the icon for the switch."""
return self._icon
async def async_on_demand_update(self):
"""Update state."""
self.async_schedule_update_ha_state(True)
async def async_added_to_hass(self):
"""Subscribe to update signal."""
self._unsub_dispatcher = async_dispatcher_connect(
self.hass, service_signal(SERVICE_UPDATE, self._signal_name),
self.async_on_demand_update)
async def async_will_remove_from_hass(self):
"""Disconnect from update signal."""
self._unsub_dispatcher()

View File

@ -3,7 +3,7 @@
"name": "Androidtv",
"documentation": "https://www.home-assistant.io/components/androidtv",
"requirements": [
"androidtv==0.0.15"
"androidtv==0.0.16"
],
"dependencies": [],
"codeowners": []

View File

@ -0,0 +1 @@
"""The APRS component."""

View File

@ -0,0 +1,187 @@
"""Support for APRS device tracking."""
import logging
import threading
import voluptuous as vol
from homeassistant.components.device_tracker import PLATFORM_SCHEMA
from homeassistant.const import (
ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE,
CONF_HOST, CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME,
EVENT_HOMEASSISTANT_STOP)
import homeassistant.helpers.config_validation as cv
from homeassistant.util import slugify
DOMAIN = 'aprs'
_LOGGER = logging.getLogger(__name__)
ATTR_ALTITUDE = 'altitude'
ATTR_COURSE = 'course'
ATTR_COMMENT = 'comment'
ATTR_FROM = 'from'
ATTR_FORMAT = 'format'
ATTR_POS_AMBIGUITY = 'posambiguity'
ATTR_SPEED = 'speed'
CONF_CALLSIGNS = 'callsigns'
DEFAULT_HOST = 'rotate.aprs2.net'
DEFAULT_PASSWORD = '-1'
DEFAULT_TIMEOUT = 30.0
FILTER_PORT = 14580
MSG_FORMATS = ['compressed', 'uncompressed', 'mic-e']
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Required(CONF_CALLSIGNS): cv.ensure_list,
vol.Required(CONF_USERNAME): cv.string,
vol.Optional(CONF_PASSWORD,
default=DEFAULT_PASSWORD): cv.string,
vol.Optional(CONF_HOST,
default=DEFAULT_HOST): cv.string,
vol.Optional(CONF_TIMEOUT,
default=DEFAULT_TIMEOUT): vol.Coerce(float),
})
def make_filter(callsigns: list) -> str:
"""Make a server-side filter from a list of callsigns."""
return ' '.join('b/{0}'.format(cs.upper()) for cs in callsigns)
def gps_accuracy(gps, posambiguity: int) -> int:
"""Calculate the GPS accuracy based on APRS posambiguity."""
import geopy.distance
pos_a_map = {0: 0,
1: 1 / 600,
2: 1 / 60,
3: 1 / 6,
4: 1}
if posambiguity in pos_a_map:
degrees = pos_a_map[posambiguity]
gps2 = (gps[0], gps[1] + degrees)
dist_m = geopy.distance.distance(gps, gps2).m
accuracy = round(dist_m)
else:
message = "APRS position ambiguity must be 0-4, not '{0}'.".format(
posambiguity)
raise ValueError(message)
return accuracy
def setup_scanner(hass, config, see, discovery_info=None):
"""Set up the APRS tracker."""
callsigns = config.get(CONF_CALLSIGNS)
server_filter = make_filter(callsigns)
callsign = config.get(CONF_USERNAME)
password = config.get(CONF_PASSWORD)
host = config.get(CONF_HOST)
timeout = config.get(CONF_TIMEOUT)
aprs_listener = AprsListenerThread(
callsign, password, host, server_filter, see)
def aprs_disconnect(event):
"""Stop the APRS connection."""
aprs_listener.stop()
aprs_listener.start()
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, aprs_disconnect)
if not aprs_listener.start_event.wait(timeout):
_LOGGER.error("Timeout waiting for APRS to connect.")
return
if not aprs_listener.start_success:
_LOGGER.error(aprs_listener.start_message)
return
_LOGGER.debug(aprs_listener.start_message)
return True
class AprsListenerThread(threading.Thread):
"""APRS message listener."""
def __init__(self, callsign: str, password: str, host: str,
server_filter: str, see):
"""Initialize the class."""
super().__init__()
import aprslib
self.callsign = callsign
self.host = host
self.start_event = threading.Event()
self.see = see
self.server_filter = server_filter
self.start_message = ""
self.start_success = False
self.ais = aprslib.IS(
self.callsign, passwd=password, host=self.host, port=FILTER_PORT)
def start_complete(self, success: bool, message: str):
"""Complete startup process."""
self.start_message = message
self.start_success = success
self.start_event.set()
def run(self):
"""Connect to APRS and listen for data."""
self.ais.set_filter(self.server_filter)
from aprslib import ConnectionError as AprsConnectionError
from aprslib import LoginError
try:
_LOGGER.info("Opening connection to %s with callsign %s.",
self.host, self.callsign)
self.ais.connect()
self.start_complete(
True,
"Connected to {0} with callsign {1}.".format(
self.host, self.callsign))
self.ais.consumer(callback=self.rx_msg, immortal=True)
except (AprsConnectionError, LoginError) as err:
self.start_complete(False, str(err))
except OSError:
_LOGGER.info("Closing connection to %s with callsign %s.",
self.host, self.callsign)
def stop(self):
"""Close the connection to the APRS network."""
self.ais.close()
def rx_msg(self, msg: dict):
"""Receive message and process if position."""
_LOGGER.debug("APRS message received: %s", str(msg))
if msg[ATTR_FORMAT] in MSG_FORMATS:
dev_id = slugify(msg[ATTR_FROM])
lat = msg[ATTR_LATITUDE]
lon = msg[ATTR_LONGITUDE]
attrs = {}
if ATTR_POS_AMBIGUITY in msg:
pos_amb = msg[ATTR_POS_AMBIGUITY]
try:
attrs[ATTR_GPS_ACCURACY] = gps_accuracy((lat, lon),
pos_amb)
except ValueError:
_LOGGER.warning(
"APRS message contained invalid posambiguity: %s",
str(pos_amb))
for attr in [ATTR_ALTITUDE,
ATTR_COMMENT,
ATTR_COURSE,
ATTR_SPEED]:
if attr in msg:
attrs[attr] = msg[attr]
self.see(dev_id=dev_id, gps=(lat, lon), attributes=attrs)

View File

@ -0,0 +1,11 @@
{
"domain": "aprs",
"name": "APRS",
"documentation": "https://www.home-assistant.io/components/aprs",
"dependencies": [],
"codeowners": ["@PhilRW"],
"requirements": [
"aprslib==0.6.46",
"geopy==1.19.0"
]
}

View File

@ -47,6 +47,6 @@ class AsusWrtDeviceScanner(DeviceScanner):
Return boolean if scanning successful.
"""
_LOGGER.info('Checking Devices')
_LOGGER.debug('Checking Devices')
self.last_results = await self.connection.async_get_connected_devices()

View File

@ -223,22 +223,24 @@ class AutomationEntity(ToggleEntity, RestoreEntity):
async def async_added_to_hass(self) -> None:
"""Startup with initial state or previous state."""
await super().async_added_to_hass()
if self._initial_state is not None:
enable_automation = self._initial_state
_LOGGER.debug("Automation %s initial state %s from config "
"initial_state", self.entity_id, enable_automation)
else:
state = await self.async_get_last_state()
if state:
enable_automation = state.state == STATE_ON
self._last_triggered = state.attributes.get('last_triggered')
_LOGGER.debug("Automation %s initial state %s from recorder "
"last state %s", self.entity_id,
_LOGGER.debug("Loaded automation %s with state %s from state "
" storage last state %s", self.entity_id,
enable_automation, state)
else:
enable_automation = DEFAULT_INITIAL_STATE
_LOGGER.debug("Automation %s initial state %s from default "
"initial state", self.entity_id,
_LOGGER.debug("Automation %s not in state storage, state %s from "
"default is used.", self.entity_id,
enable_automation)
if self._initial_state is not None:
enable_automation = self._initial_state
_LOGGER.debug("Automation %s initial state %s overridden from "
"config initial_state", self.entity_id,
enable_automation)
if enable_automation:

View File

@ -0,0 +1,18 @@
"""Offer device oriented automation."""
import voluptuous as vol
from homeassistant.const import CONF_DOMAIN, CONF_PLATFORM
from homeassistant.loader import async_get_integration
TRIGGER_SCHEMA = vol.Schema({
vol.Required(CONF_PLATFORM): 'device',
vol.Required(CONF_DOMAIN): str,
}, extra=vol.ALLOW_EXTRA)
async def async_trigger(hass, config, action, automation_info):
"""Listen for trigger."""
integration = await async_get_integration(hass, config[CONF_DOMAIN])
platform = integration.get_platform('device_automation')
return await platform.async_trigger(hass, config, action, automation_info)

View File

@ -4,8 +4,10 @@ import logging
import voluptuous as vol
from homeassistant.core import callback
from homeassistant.const import CONF_VALUE_TEMPLATE, CONF_PLATFORM
from homeassistant.helpers.event import async_track_template
from homeassistant.const import CONF_VALUE_TEMPLATE, CONF_PLATFORM, CONF_FOR
from homeassistant.helpers import condition
from homeassistant.helpers.event import (
async_track_same_state, async_track_template)
import homeassistant.helpers.config_validation as cv
_LOGGER = logging.getLogger(__name__)
@ -13,6 +15,7 @@ _LOGGER = logging.getLogger(__name__)
TRIGGER_SCHEMA = IF_ACTION_SCHEMA = vol.Schema({
vol.Required(CONF_PLATFORM): 'template',
vol.Required(CONF_VALUE_TEMPLATE): cv.template,
vol.Optional(CONF_FOR): vol.All(cv.time_period, cv.positive_timedelta),
})
@ -20,10 +23,17 @@ async def async_trigger(hass, config, action, automation_info):
"""Listen for state changes based on configuration."""
value_template = config.get(CONF_VALUE_TEMPLATE)
value_template.hass = hass
time_delta = config.get(CONF_FOR)
unsub_track_same = None
@callback
def template_listener(entity_id, from_s, to_s):
"""Listen for state changes and calls action."""
nonlocal unsub_track_same
@callback
def call_action():
"""Call action with right context."""
hass.async_run_job(action({
'trigger': {
'platform': 'template',
@ -33,4 +43,24 @@ async def async_trigger(hass, config, action, automation_info):
},
}, context=(to_s.context if to_s else None)))
return async_track_template(hass, value_template, template_listener)
if not time_delta:
call_action()
return
unsub_track_same = async_track_same_state(
hass, time_delta, call_action,
lambda _, _2, _3: condition.async_template(hass, value_template),
value_template.extract_entities())
unsub = async_track_template(
hass, value_template, template_listener)
@callback
def async_remove():
"""Remove state listeners async."""
unsub()
if unsub_track_same:
# pylint: disable=not-callable
unsub_track_same()
return async_remove

View File

@ -6,5 +6,7 @@
"python_awair==0.0.4"
],
"dependencies": [],
"codeowners": []
"codeowners": [
"@danielsjf"
]
}

View File

@ -219,6 +219,6 @@ class AwairData:
# The air_data_latest call only returns one item, so this should
# be safe to only process one entry.
for sensor in resp[0][ATTR_SENSORS]:
self.data[sensor[ATTR_COMPONENT]] = sensor[ATTR_VALUE]
self.data[sensor[ATTR_COMPONENT]] = round(sensor[ATTR_VALUE], 1)
_LOGGER.debug("Got Awair Data for %s: %s", self._uuid, self.data)

View File

@ -3,10 +3,12 @@
"abort": {
"already_configured": "El dispositiu ja est\u00e0 configurat",
"bad_config_file": "Dades incorrectes del fitxer de configuraci\u00f3",
"link_local_address": "L'enlla\u00e7 d'adreces locals no est\u00e0 disponible"
"link_local_address": "L'enlla\u00e7 d'adreces locals no est\u00e0 disponible",
"not_axis_device": "El dispositiu descobert no \u00e9s un dispositiu Axis"
},
"error": {
"already_configured": "El dispositiu ja est\u00e0 configurat",
"already_in_progress": "El flux de dades pel dispositiu ja est\u00e0 en curs.",
"device_unavailable": "El dispositiu no est\u00e0 disponible",
"faulty_credentials": "Credencials d'usuari incorrectes"
},

View File

@ -3,10 +3,12 @@
"abort": {
"already_configured": "Ger\u00e4t ist bereits konfiguriert",
"bad_config_file": "Fehlerhafte Daten aus der Konfigurationsdatei",
"link_local_address": "Link-local Adressen werden nicht unterst\u00fctzt"
"link_local_address": "Link-local Adressen werden nicht unterst\u00fctzt",
"not_axis_device": "Erkanntes Ger\u00e4t ist kein Axis-Ger\u00e4t"
},
"error": {
"already_configured": "Ger\u00e4t ist bereits konfiguriert",
"already_in_progress": "Der Konfigurationsablauf f\u00fcr das Ger\u00e4t wird bereits ausgef\u00fchrt.",
"device_unavailable": "Ger\u00e4t ist nicht verf\u00fcgbar",
"faulty_credentials": "Ung\u00fcltige Anmeldeinformationen"
},

View File

@ -3,10 +3,12 @@
"abort": {
"already_configured": "Device is already configured",
"bad_config_file": "Bad data from config file",
"link_local_address": "Link local addresses are not supported"
"link_local_address": "Link local addresses are not supported",
"not_axis_device": "Discovered device not an Axis device"
},
"error": {
"already_configured": "Device is already configured",
"already_in_progress": "Config flow for device is already in progress.",
"device_unavailable": "Device is not available",
"faulty_credentials": "Bad user credentials"
},

View File

@ -1,9 +1,17 @@
{
"config": {
"error": {
"already_configured": "Az eszk\u00f6zt m\u00e1r konfigur\u00e1ltuk",
"device_unavailable": "Az eszk\u00f6z nem \u00e9rhet\u0151 el",
"faulty_credentials": "Rossz felhaszn\u00e1l\u00f3i hiteles\u00edt\u0151 adatok"
},
"step": {
"user": {
"data": {
"host": "Hoszt"
"host": "Hoszt",
"password": "Jelsz\u00f3",
"port": "Port",
"username": "Felhaszn\u00e1l\u00f3n\u00e9v"
}
}
}

View File

@ -2,10 +2,12 @@
"config": {
"abort": {
"already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato",
"bad_config_file": "Dati errati dal file di configurazione"
"bad_config_file": "Dati errati dal file di configurazione",
"not_axis_device": "Il dispositivo rilevato non \u00e8 un dispositivo Axis"
},
"error": {
"already_configured": "Il dispositivo \u00e8 gi\u00e0 configurato",
"already_in_progress": "Il flusso di configurazione per il dispositivo \u00e8 gi\u00e0 in corso.",
"device_unavailable": "Il dispositivo non \u00e8 disponibile",
"faulty_credentials": "Credenziali utente non valide"
},

View File

@ -3,10 +3,12 @@
"abort": {
"already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
"bad_config_file": "\uad6c\uc131 \ud30c\uc77c\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
"link_local_address": "\ub85c\uceec \uc8fc\uc18c \uc5f0\uacb0\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4"
"link_local_address": "\ub85c\uceec \uc8fc\uc18c \uc5f0\uacb0\uc740 \uc9c0\uc6d0\ub418\uc9c0 \uc54a\uc2b5\ub2c8\ub2e4",
"not_axis_device": "\ubc1c\uacac\ub41c \uae30\uae30\ub294 Axis \uae30\uae30\uac00 \uc544\ub2d9\ub2c8\ub2e4"
},
"error": {
"already_configured": "\uae30\uae30\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4",
"already_in_progress": "\uae30\uae30 \uad6c\uc131\uc774 \uc774\ubbf8 \uc9c4\ud589\uc911\uc785\ub2c8\ub2e4.",
"device_unavailable": "\uae30\uae30\ub97c \uc0ac\uc6a9\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4",
"faulty_credentials": "\uc0ac\uc6a9\uc790 \uc774\ub984 \ud639\uc740 \ube44\ubc00\ubc88\ud638\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4"
},

View File

@ -3,10 +3,12 @@
"abort": {
"already_configured": "Apparat ass scho konfigur\u00e9iert",
"bad_config_file": "Feelerhaft Donn\u00e9e\u00eb aus der Konfiguratioun's Datei",
"link_local_address": "Lokal Link Adressen ginn net \u00ebnnerst\u00ebtzt"
"link_local_address": "Lokal Link Adressen ginn net \u00ebnnerst\u00ebtzt",
"not_axis_device": "Entdeckten Apparat ass keen Axis Apparat"
},
"error": {
"already_configured": "Apparat ass scho konfigur\u00e9iert",
"already_in_progress": "Konfiguratioun fir d\u00ebsen Apparat ass schonn am gaang.",
"device_unavailable": "Apparat ass net erreechbar",
"faulty_credentials": "Ong\u00eblteg Login Informatioune"
},

View File

@ -1,6 +1,14 @@
{
"config": {
"abort": {
"already_configured": "Apparaat is al geconfigureerd",
"bad_config_file": "Slechte gegevens van het configuratiebestand",
"link_local_address": "Link-lokale adressen worden niet ondersteund",
"not_axis_device": "Ontdekte apparaat, is geen Axis-apparaat"
},
"error": {
"already_configured": "Apparaat is al geconfigureerd",
"already_in_progress": "De configuratiestroom voor het apparaat is al in volle gang.",
"device_unavailable": "Apparaat is niet beschikbaar",
"faulty_credentials": "Ongeldige gebruikersreferenties"
},
@ -11,8 +19,10 @@
"password": "Wachtwoord",
"port": "Poort",
"username": "Gebruikersnaam"
},
"title": "Stel het Axis-apparaat in"
}
}
}
},
"title": "Axis-apparaat"
}
}

View File

@ -7,6 +7,7 @@
},
"error": {
"already_configured": "Enheten er allerede konfigurert",
"already_in_progress": "Konfigurasjonsflyt for enhet p\u00e5g\u00e5r allerede.",
"device_unavailable": "Enheten er ikke tilgjengelig",
"faulty_credentials": "Ugyldig brukerlegitimasjon"
},

View File

@ -3,10 +3,12 @@
"abort": {
"already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane",
"bad_config_file": "B\u0142\u0119dne dane z pliku konfiguracyjnego",
"link_local_address": "Po\u0142\u0105czenie lokalnego adresu nie jest obs\u0142ugiwane"
"link_local_address": "Po\u0142\u0105czenie lokalnego adresu nie jest obs\u0142ugiwane",
"not_axis_device": "Wykryte urz\u0105dzenie nie jest urz\u0105dzeniem Axis"
},
"error": {
"already_configured": "Urz\u0105dzenie jest ju\u017c skonfigurowane",
"already_in_progress": "Konfigurowanie urz\u0105dzenia jest ju\u017c w toku.",
"device_unavailable": "Urz\u0105dzenie jest niedost\u0119pne",
"faulty_credentials": "B\u0142\u0119dne dane uwierzytelniaj\u0105ce"
},

View File

@ -0,0 +1,22 @@
{
"config": {
"abort": {
"not_axis_device": "Dispositivo descoberto n\u00e3o \u00e9 um dispositivo Axis"
},
"error": {
"already_in_progress": "O fluxo de configura\u00e7\u00e3o para o dispositivo j\u00e1 est\u00e1 em andamento.",
"faulty_credentials": "Credenciais do usu\u00e1rio inv\u00e1lidas"
},
"step": {
"user": {
"data": {
"host": "Host",
"password": "Senha",
"port": "Porta",
"username": "Nome de usu\u00e1rio"
}
}
},
"title": "Dispositivo Axis"
}
}

View File

@ -3,10 +3,12 @@
"abort": {
"already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430 \u0443\u0436\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0435\u043d\u0430",
"bad_config_file": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0438\u0437 \u0444\u0430\u0439\u043b\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438",
"link_local_address": "\u0421\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f"
"link_local_address": "\u0421\u0441\u044b\u043b\u043a\u0430 \u043d\u0430 \u043b\u043e\u043a\u0430\u043b\u044c\u043d\u044b\u0435 \u0430\u0434\u0440\u0435\u0441\u0430 \u043d\u0435 \u043f\u043e\u0434\u0434\u0435\u0440\u0436\u0438\u0432\u0430\u0435\u0442\u0441\u044f",
"not_axis_device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435 \u044f\u0432\u043b\u044f\u0435\u0442\u0441\u044f \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c Axis"
},
"error": {
"already_configured": "\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\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 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 \u0443\u0436\u0435 \u043d\u0430\u0447\u0430\u0442\u0430.",
"device_unavailable": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u043d\u043e",
"faulty_credentials": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0435 \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435"
},

View File

@ -7,6 +7,7 @@
},
"error": {
"already_configured": "Naprava je \u017ee konfigurirana",
"already_in_progress": "Konfiguracijski tok za to napravo je \u017ee v teku.",
"device_unavailable": "Naprava ni na voljo",
"faulty_credentials": "Napa\u010dni uporabni\u0161ki podatki"
},

View File

@ -7,6 +7,7 @@
},
"error": {
"already_configured": "Enheten \u00e4r redan konfigurerad",
"already_in_progress": "Konfigurations fl\u00f6det f\u00f6r enheten p\u00e5g\u00e5r redan.",
"device_unavailable": "Enheten \u00e4r inte tillg\u00e4nglig",
"faulty_credentials": "Felaktiga anv\u00e4ndaruppgifter"
},

View File

@ -3,10 +3,12 @@
"abort": {
"already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
"bad_config_file": "\u8a2d\u5b9a\u6a94\u6848\u8cc7\u6599\u7121\u6548",
"link_local_address": "\u4e0d\u652f\u63f4\u9023\u7d50\u672c\u5730\u7aef\u4f4d\u5740"
"link_local_address": "\u4e0d\u652f\u63f4\u9023\u7d50\u672c\u5730\u7aef\u4f4d\u5740",
"not_axis_device": "\u6240\u767c\u73fe\u7684\u88dd\u7f6e\u4e26\u975e Axis \u88dd\u7f6e"
},
"error": {
"already_configured": "\u88dd\u7f6e\u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210",
"already_in_progress": "\u88dd\u7f6e\u8a2d\u5b9a\u5df2\u7d93\u9032\u884c\u4e2d\u3002",
"device_unavailable": "\u88dd\u7f6e\u7121\u6cd5\u4f7f\u7528",
"faulty_credentials": "\u4f7f\u7528\u8005\u6191\u8b49\u7121\u6548"
},

View File

@ -3,7 +3,7 @@
"name": "Blink",
"documentation": "https://www.home-assistant.io/components/blink",
"requirements": [
"blinkpy==0.14.0"
"blinkpy==0.14.1"
],
"dependencies": [],
"codeowners": [

View File

@ -3,7 +3,7 @@
"name": "Broadlink",
"documentation": "https://www.home-assistant.io/components/broadlink",
"requirements": [
"broadlink==0.10.0"
"broadlink==0.11.1"
],
"dependencies": [],
"codeowners": [

View File

@ -322,6 +322,8 @@ class BroadlinkMP1Switch:
def get_outlet_status(self, slot):
"""Get status of outlet from cached status list."""
if self._states is None:
return None
return self._states['s{}'.format(slot)]
@Throttle(TIME_BETWEEN_UPDATES)

View File

@ -0,0 +1,178 @@
"""Provide animated GIF loops of Buienradar imagery."""
import asyncio
import logging
from datetime import datetime, timedelta
from typing import Optional
import aiohttp
import voluptuous as vol
from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
from homeassistant.const import CONF_NAME
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.util import dt as dt_util
CONF_DIMENSION = 'dimension'
CONF_DELTA = 'delta'
RADAR_MAP_URL_TEMPLATE = ('https://api.buienradar.nl/image/1.0/'
'RadarMapNL?w={w}&h={h}')
_LOG = logging.getLogger(__name__)
# Maximum range according to docs
DIM_RANGE = vol.All(vol.Coerce(int), vol.Range(min=120, max=700))
PLATFORM_SCHEMA = vol.All(
PLATFORM_SCHEMA.extend({
vol.Optional(CONF_DIMENSION, default=512): DIM_RANGE,
vol.Optional(CONF_DELTA, default=600.0): vol.All(vol.Coerce(float),
vol.Range(min=0)),
vol.Optional(CONF_NAME, default="Buienradar loop"): cv.string,
}))
async def async_setup_platform(hass, config, async_add_entities,
discovery_info=None):
"""Set up buienradar radar-loop camera component."""
dimension = config[CONF_DIMENSION]
delta = config[CONF_DELTA]
name = config[CONF_NAME]
async_add_entities([BuienradarCam(name, dimension, delta)])
class BuienradarCam(Camera):
"""
A camera component producing animated buienradar radar-imagery GIFs.
Rain radar imagery camera based on image URL taken from [0].
[0]: https://www.buienradar.nl/overbuienradar/gratis-weerdata
"""
def __init__(self, name: str, dimension: int, delta: float):
"""
Initialize the component.
This constructor must be run in the event loop.
"""
super().__init__()
self._name = name
# dimension (x and y) of returned radar image
self._dimension = dimension
# time a cached image stays valid for
self._delta = delta
# Condition that guards the loading indicator.
#
# Ensures that only one reader can cause an http request at the same
# time, and that all readers are notified after this request completes.
#
# invariant: this condition is private to and owned by this instance.
self._condition = asyncio.Condition()
self._last_image = None # type: Optional[bytes]
# value of the last seen last modified header
self._last_modified = None # type: Optional[str]
# loading status
self._loading = False
# deadline for image refresh - self.delta after last successful load
self._deadline = None # type: Optional[datetime]
@property
def name(self) -> str:
"""Return the component name."""
return self._name
def __needs_refresh(self) -> bool:
if not (self._delta and self._deadline and self._last_image):
return True
return dt_util.utcnow() > self._deadline
async def __retrieve_radar_image(self) -> bool:
"""Retrieve new radar image and return whether this succeeded."""
session = async_get_clientsession(self.hass)
url = RADAR_MAP_URL_TEMPLATE.format(w=self._dimension,
h=self._dimension)
if self._last_modified:
headers = {'If-Modified-Since': self._last_modified}
else:
headers = {}
try:
async with session.get(url, timeout=5, headers=headers) as res:
res.raise_for_status()
if res.status == 304:
_LOG.debug("HTTP 304 - success")
return True
last_modified = res.headers.get('Last-Modified', None)
if last_modified:
self._last_modified = last_modified
self._last_image = await res.read()
_LOG.debug("HTTP 200 - Last-Modified: %s", last_modified)
return True
except (asyncio.TimeoutError, aiohttp.ClientError) as err:
_LOG.error("Failed to fetch image, %s", type(err))
return False
async def async_camera_image(self) -> Optional[bytes]:
"""
Return a still image response from the camera.
Uses ayncio conditions to make sure only one task enters the critical
section at the same time. Otherwise, two http requests would start
when two tabs with home assistant are open.
The condition is entered in two sections because otherwise the lock
would be held while doing the http request.
A boolean (_loading) is used to indicate the loading status instead of
_last_image since that is initialized to None.
For reference:
* :func:`asyncio.Condition.wait` releases the lock and acquires it
again before continuing.
* :func:`asyncio.Condition.notify_all` requires the lock to be held.
"""
if not self.__needs_refresh():
return self._last_image
# get lock, check iff loading, await notification if loading
async with self._condition:
# can not be tested - mocked http response returns immediately
if self._loading:
_LOG.debug("already loading - waiting for notification")
await self._condition.wait()
return self._last_image
# Set loading status **while holding lock**, makes other tasks wait
self._loading = True
try:
now = dt_util.utcnow()
was_updated = await self.__retrieve_radar_image()
# was updated? Set new deadline relative to now before loading
if was_updated:
self._deadline = now + timedelta(seconds=self._delta)
return self._last_image
finally:
# get lock, unset loading status, notify all waiting tasks
async with self._condition:
self._loading = False
self._condition.notify_all()

View File

@ -6,5 +6,5 @@
"buienradar==0.91"
],
"dependencies": [],
"codeowners": []
"codeowners": ["@ties"]
}

View File

@ -5,7 +5,8 @@ import voluptuous as vol
from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, ATTR_FORECAST_TEMP_LOW,
ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity)
ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity,
ATTR_FORECAST_PRECIPITATION)
from homeassistant.const import (
CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS)
from homeassistant.helpers import config_validation as cv
@ -149,7 +150,7 @@ class BrWeather(WeatherEntity):
@property
def forecast(self):
"""Return the forecast array."""
from buienradar.buienradar import (CONDITION, CONDCODE, DATETIME,
from buienradar.buienradar import (CONDITION, CONDCODE, RAIN, DATETIME,
MIN_TEMP, MAX_TEMP)
if self._forecast:
@ -166,6 +167,7 @@ class BrWeather(WeatherEntity):
data_out[ATTR_FORECAST_CONDITION] = cond[condcode]
data_out[ATTR_FORECAST_TEMP_LOW] = data_in.get(MIN_TEMP)
data_out[ATTR_FORECAST_TEMP] = data_in.get(MAX_TEMP)
data_out[ATTR_FORECAST_PRECIPITATION] = data_in.get(RAIN)
fcdata_out.append(data_out)

View File

@ -4,8 +4,9 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/components/cast",
"requirements": [
"pychromecast==3.2.1"
"pychromecast==3.2.2"
],
"dependencies": [],
"zeroconf": ["_googlecast._tcp.local."],
"codeowners": []
}

View File

@ -4,7 +4,7 @@ import logging
import voluptuous as vol
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.components.alexa import smart_home as alexa_sh
from homeassistant.components.alexa import const as alexa_const
from homeassistant.components.google_assistant import const as ga_c
from homeassistant.const import (
CONF_MODE, CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_START,
@ -21,7 +21,8 @@ from .const import (
CONF_CLOUDHOOK_CREATE_URL, CONF_COGNITO_CLIENT_ID, CONF_ENTITY_CONFIG,
CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_GOOGLE_ACTIONS_SYNC_URL,
CONF_RELAYER, CONF_REMOTE_API_URL, CONF_SUBSCRIPTION_INFO_URL,
CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD)
CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD, CONF_ALEXA_ACCESS_TOKEN_URL
)
from .prefs import CloudPreferences
_LOGGER = logging.getLogger(__name__)
@ -33,9 +34,9 @@ SERVICE_REMOTE_DISCONNECT = 'remote_disconnect'
ALEXA_ENTITY_SCHEMA = vol.Schema({
vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string,
vol.Optional(alexa_sh.CONF_DISPLAY_CATEGORIES): cv.string,
vol.Optional(alexa_sh.CONF_NAME): cv.string,
vol.Optional(alexa_const.CONF_DESCRIPTION): cv.string,
vol.Optional(alexa_const.CONF_DISPLAY_CATEGORIES): cv.string,
vol.Optional(CONF_NAME): cv.string,
})
GOOGLE_ENTITY_SCHEMA = vol.Schema({
@ -61,7 +62,6 @@ CONFIG_SCHEMA = vol.Schema({
DOMAIN: vol.Schema({
vol.Optional(CONF_MODE, default=DEFAULT_MODE):
vol.In([MODE_DEV, MODE_PROD]),
# Change to optional when we include real servers
vol.Optional(CONF_COGNITO_CLIENT_ID): str,
vol.Optional(CONF_USER_POOL_ID): str,
vol.Optional(CONF_REGION): str,
@ -73,6 +73,7 @@ CONFIG_SCHEMA = vol.Schema({
vol.Optional(CONF_ACME_DIRECTORY_SERVER): vol.Url(),
vol.Optional(CONF_ALEXA): ALEXA_SCHEMA,
vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA,
vol.Optional(CONF_ALEXA_ACCESS_TOKEN_URL): str,
}),
}, extra=vol.ALLOW_EXTRA)
@ -192,8 +193,16 @@ async def async_setup(hass, config):
hass.helpers.service.async_register_admin_service(
DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler)
loaded_binary_sensor = False
async def _on_connect():
"""Discover RemoteUI binary sensor."""
nonlocal loaded_binary_sensor
if loaded_binary_sensor:
return
loaded_binary_sensor = True
hass.async_create_task(hass.helpers.discovery.async_load_platform(
'binary_sensor', DOMAIN, {}, config))

View File

@ -0,0 +1,259 @@
"""Alexa configuration for Home Assistant Cloud."""
import asyncio
from datetime import timedelta
import logging
import aiohttp
import async_timeout
from hass_nabucasa import cloud_api
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.helpers import entity_registry
from homeassistant.helpers.event import async_call_later
from homeassistant.util.dt import utcnow
from homeassistant.components.alexa import (
config as alexa_config,
errors as alexa_errors,
entities as alexa_entities,
state_report as alexa_state_report,
)
from .const import (
CONF_ENTITY_CONFIG, CONF_FILTER, PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE,
RequireRelink
)
_LOGGER = logging.getLogger(__name__)
# Time to wait when entity preferences have changed before syncing it to
# the cloud.
SYNC_DELAY = 1
class AlexaConfig(alexa_config.AbstractConfig):
"""Alexa Configuration."""
def __init__(self, hass, config, prefs, cloud):
"""Initialize the Alexa config."""
super().__init__(hass)
self._config = config
self._prefs = prefs
self._cloud = cloud
self._token = None
self._token_valid = None
self._cur_entity_prefs = prefs.alexa_entity_configs
self._alexa_sync_unsub = None
self._endpoint = None
prefs.async_listen_updates(self._async_prefs_updated)
hass.bus.async_listen(
entity_registry.EVENT_ENTITY_REGISTRY_UPDATED,
self._handle_entity_registry_updated
)
@property
def enabled(self):
"""Return if Alexa is enabled."""
return self._prefs.alexa_enabled
@property
def supports_auth(self):
"""Return if config supports auth."""
return True
@property
def should_report_state(self):
"""Return if states should be proactively reported."""
return self._prefs.alexa_report_state
@property
def endpoint(self):
"""Endpoint for report state."""
if self._endpoint is None:
raise ValueError("No endpoint available. Fetch access token first")
return self._endpoint
@property
def entity_config(self):
"""Return entity config."""
return self._config.get(CONF_ENTITY_CONFIG, {})
def should_expose(self, entity_id):
"""If an entity should be exposed."""
if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
if not self._config[CONF_FILTER].empty_filter:
return self._config[CONF_FILTER](entity_id)
entity_configs = self._prefs.alexa_entity_configs
entity_config = entity_configs.get(entity_id, {})
return entity_config.get(
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
async def async_get_access_token(self):
"""Get an access token."""
if self._token_valid is not None and self._token_valid < utcnow():
return self._token
resp = await cloud_api.async_alexa_access_token(self._cloud)
body = await resp.json()
if resp.status == 400:
if body['reason'] in ('RefreshTokenNotFound', 'UnknownRegion'):
if self.should_report_state:
await self._prefs.async_update(alexa_report_state=False)
self.hass.components.persistent_notification.async_create(
"There was an error reporting state to Alexa ({}). "
"Please re-link your Alexa skill via the Alexa app to "
"continue using it.".format(body['reason']),
"Alexa state reporting disabled",
"cloud_alexa_report",
)
raise RequireRelink
raise alexa_errors.NoTokenAvailable
self._token = body['access_token']
self._endpoint = body['event_endpoint']
self._token_valid = utcnow() + timedelta(seconds=body['expires_in'])
return self._token
async def _async_prefs_updated(self, prefs):
"""Handle updated preferences."""
if self.should_report_state != self.is_reporting_states:
if self.should_report_state:
await self.async_enable_proactive_mode()
else:
await self.async_disable_proactive_mode()
# If entity prefs are the same or we have filter in config.yaml,
# don't sync.
if (self._cur_entity_prefs is prefs.alexa_entity_configs or
not self._config[CONF_FILTER].empty_filter):
return
if self._alexa_sync_unsub:
self._alexa_sync_unsub()
self._alexa_sync_unsub = async_call_later(
self.hass, SYNC_DELAY, self._sync_prefs)
async def _sync_prefs(self, _now):
"""Sync the updated preferences to Alexa."""
self._alexa_sync_unsub = None
old_prefs = self._cur_entity_prefs
new_prefs = self._prefs.alexa_entity_configs
seen = set()
to_update = []
to_remove = []
for entity_id, info in old_prefs.items():
seen.add(entity_id)
old_expose = info.get(PREF_SHOULD_EXPOSE)
if entity_id in new_prefs:
new_expose = new_prefs[entity_id].get(PREF_SHOULD_EXPOSE)
else:
new_expose = None
if old_expose == new_expose:
continue
if new_expose:
to_update.append(entity_id)
else:
to_remove.append(entity_id)
# Now all the ones that are in new prefs but never were in old prefs
for entity_id, info in new_prefs.items():
if entity_id in seen:
continue
new_expose = info.get(PREF_SHOULD_EXPOSE)
if new_expose is None:
continue
# Only test if we should expose. It can never be a remove action,
# as it didn't exist in old prefs object.
if new_expose:
to_update.append(entity_id)
# We only set the prefs when update is successful, that way we will
# retry when next change comes in.
if await self._sync_helper(to_update, to_remove):
self._cur_entity_prefs = new_prefs
async def async_sync_entities(self):
"""Sync all entities to Alexa."""
to_update = []
to_remove = []
for entity in alexa_entities.async_get_entities(self.hass, self):
if self.should_expose(entity.entity_id):
to_update.append(entity.entity_id)
else:
to_remove.append(entity.entity_id)
return await self._sync_helper(to_update, to_remove)
async def _sync_helper(self, to_update, to_remove) -> bool:
"""Sync entities to Alexa.
Return boolean if it was successful.
"""
if not to_update and not to_remove:
return True
# Make sure it's valid.
await self.async_get_access_token()
tasks = []
if to_update:
tasks.append(alexa_state_report.async_send_add_or_update_message(
self.hass, self, to_update
))
if to_remove:
tasks.append(alexa_state_report.async_send_delete_message(
self.hass, self, to_remove
))
try:
with async_timeout.timeout(10):
await asyncio.wait(tasks, return_when=asyncio.ALL_COMPLETED)
return True
except asyncio.TimeoutError:
_LOGGER.warning("Timeout trying to sync entitites to Alexa")
return False
except aiohttp.ClientError as err:
_LOGGER.warning("Error trying to sync entities to Alexa: %s", err)
return False
async def _handle_entity_registry_updated(self, event):
"""Handle when entity registry updated."""
if not self.enabled or not self._cloud.is_logged_in:
return
action = event.data['action']
entity_id = event.data['entity_id']
to_update = []
to_remove = []
if action == 'create' and self.should_expose(entity_id):
to_update.append(entity_id)
elif action == 'remove' and self.should_expose(entity_id):
to_remove.append(entity_id)
try:
await self._sync_helper(to_update, to_remove)
except alexa_errors.NoTokenAvailable:
pass

View File

@ -2,42 +2,45 @@
import asyncio
from pathlib import Path
from typing import Any, Dict
import logging
import aiohttp
from hass_nabucasa.client import CloudClient as Interface
from homeassistant.core import callback
from homeassistant.components.alexa import smart_home as alexa_sh
from homeassistant.components.google_assistant import (
helpers as ga_h, smart_home as ga)
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.components.google_assistant import smart_home as ga
from homeassistant.helpers.typing import HomeAssistantType
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.util.aiohttp import MockRequest
from homeassistant.components.alexa import (
smart_home as alexa_sh,
errors as alexa_errors,
)
from . import utils
from .const import (
CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE,
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE,
PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
from . import utils, alexa_config, google_config
from .const import DISPATCHER_REMOTE_UPDATE
from .prefs import CloudPreferences
_LOGGER = logging.getLogger(__name__)
class CloudClient(Interface):
"""Interface class for Home Assistant Cloud."""
def __init__(self, hass: HomeAssistantType, prefs: CloudPreferences,
websession: aiohttp.ClientSession,
alexa_config: Dict[str, Any], google_config: Dict[str, Any]):
alexa_user_config: Dict[str, Any],
google_user_config: Dict[str, Any]):
"""Initialize client interface to Cloud."""
self._hass = hass
self._prefs = prefs
self._websession = websession
self._alexa_user_config = alexa_config
self._google_user_config = google_config
self.google_user_config = google_user_config
self.alexa_user_config = alexa_user_config
self._alexa_config = None
self._google_config = None
self.cloud = None
@property
def base_path(self) -> Path:
@ -75,70 +78,40 @@ class CloudClient(Interface):
return self._prefs.remote_enabled
@property
def alexa_config(self) -> alexa_sh.Config:
def alexa_config(self) -> alexa_config.AlexaConfig:
"""Return Alexa config."""
if not self._alexa_config:
alexa_conf = self._alexa_user_config
self._alexa_config = alexa_sh.Config(
endpoint=None,
async_get_access_token=None,
should_expose=alexa_conf[CONF_FILTER],
entity_config=alexa_conf.get(CONF_ENTITY_CONFIG),
)
if self._alexa_config is None:
assert self.cloud is not None
self._alexa_config = alexa_config.AlexaConfig(
self._hass, self.alexa_user_config, self._prefs, self.cloud)
return self._alexa_config
@property
def google_config(self) -> ga_h.Config:
def google_config(self) -> google_config.CloudGoogleConfig:
"""Return Google config."""
if not self._google_config:
google_conf = self._google_user_config
def should_expose(entity):
"""If an entity should be exposed."""
if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
if not google_conf['filter'].empty_filter:
return google_conf['filter'](entity.entity_id)
entity_configs = self.prefs.google_entity_configs
entity_config = entity_configs.get(entity.entity_id, {})
return entity_config.get(
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
def should_2fa(entity):
"""If an entity should be checked for 2FA."""
entity_configs = self.prefs.google_entity_configs
entity_config = entity_configs.get(entity.entity_id, {})
return not entity_config.get(
PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
username = self._hass.data[DOMAIN].claims["cognito:username"]
self._google_config = ga_h.Config(
should_expose=should_expose,
should_2fa=should_2fa,
secure_devices_pin=self._prefs.google_secure_devices_pin,
entity_config=google_conf.get(CONF_ENTITY_CONFIG),
agent_user_id=username,
)
# Set it to the latest.
self._google_config.secure_devices_pin = \
self._prefs.google_secure_devices_pin
assert self.cloud is not None
self._google_config = google_config.CloudGoogleConfig(
self.google_user_config, self._prefs, self.cloud)
return self._google_config
@property
def google_user_config(self) -> Dict[str, Any]:
"""Return google action user config."""
return self._google_user_config
async def async_initialize(self, cloud) -> None:
"""Initialize the client."""
self.cloud = cloud
if (not self.alexa_config.should_report_state or
not self.cloud.is_logged_in):
return
try:
await self.alexa_config.async_enable_proactive_mode()
except alexa_errors.NoTokenAvailable:
pass
async def cleanups(self) -> None:
"""Cleanup some stuff after logout."""
self._alexa_config = None
self._google_config = None
@callback

View File

@ -9,12 +9,15 @@ PREF_GOOGLE_SECURE_DEVICES_PIN = 'google_secure_devices_pin'
PREF_CLOUDHOOKS = 'cloudhooks'
PREF_CLOUD_USER = 'cloud_user'
PREF_GOOGLE_ENTITY_CONFIGS = 'google_entity_configs'
PREF_ALEXA_ENTITY_CONFIGS = 'alexa_entity_configs'
PREF_ALEXA_REPORT_STATE = 'alexa_report_state'
PREF_OVERRIDE_NAME = 'override_name'
PREF_DISABLE_2FA = 'disable_2fa'
PREF_ALIASES = 'aliases'
PREF_SHOULD_EXPOSE = 'should_expose'
DEFAULT_SHOULD_EXPOSE = True
DEFAULT_DISABLE_2FA = False
DEFAULT_ALEXA_REPORT_STATE = False
CONF_ALEXA = 'alexa'
CONF_ALIASES = 'aliases'
@ -29,6 +32,7 @@ CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url'
CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url'
CONF_REMOTE_API_URL = 'remote_api_url'
CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server'
CONF_ALEXA_ACCESS_TOKEN_URL = 'alexa_access_token_url'
MODE_DEV = "development"
MODE_PROD = "production"
@ -42,3 +46,7 @@ class InvalidTrustedNetworks(Exception):
class InvalidTrustedProxies(Exception):
"""Raised when invalid trusted proxies config."""
class RequireRelink(Exception):
"""The skill needs to be relinked."""

View File

@ -0,0 +1,52 @@
"""Google config for Cloud."""
from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES
from homeassistant.components.google_assistant.helpers import AbstractConfig
from .const import (
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE, CONF_ENTITY_CONFIG,
PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)
class CloudGoogleConfig(AbstractConfig):
"""HA Cloud Configuration for Google Assistant."""
def __init__(self, config, prefs, cloud):
"""Initialize the Alexa config."""
self._config = config
self._prefs = prefs
self._cloud = cloud
@property
def agent_user_id(self):
"""Return Agent User Id to use for query responses."""
return self._cloud.claims["cognito:username"]
@property
def entity_config(self):
"""Return entity config."""
return self._config.get(CONF_ENTITY_CONFIG)
@property
def secure_devices_pin(self):
"""Return entity config."""
return self._prefs.google_secure_devices_pin
def should_expose(self, state):
"""If an entity should be exposed."""
if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES:
return False
if not self._config['filter'].empty_filter:
return self._config['filter'](state.entity_id)
entity_configs = self._prefs.google_entity_configs
entity_config = entity_configs.get(state.entity_id, {})
return entity_config.get(
PREF_SHOULD_EXPOSE, DEFAULT_SHOULD_EXPOSE)
def should_2fa(self, state):
"""If an entity should be checked for 2FA."""
entity_configs = self._prefs.google_entity_configs
entity_config = entity_configs.get(state.entity_id, {})
return not entity_config.get(
PREF_DISABLE_2FA, DEFAULT_DISABLE_2FA)

View File

@ -13,13 +13,17 @@ from homeassistant.components.http import HomeAssistantView
from homeassistant.components.http.data_validator import (
RequestDataValidator)
from homeassistant.components import websocket_api
from homeassistant.components.alexa import smart_home as alexa_sh
from homeassistant.components.websocket_api import const as ws_const
from homeassistant.components.alexa import (
entities as alexa_entities,
errors as alexa_errors,
)
from homeassistant.components.google_assistant import helpers as google_helpers
from .const import (
DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE,
PREF_GOOGLE_SECURE_DEVICES_PIN, InvalidTrustedNetworks,
InvalidTrustedProxies)
InvalidTrustedProxies, PREF_ALEXA_REPORT_STATE)
_LOGGER = logging.getLogger(__name__)
@ -90,6 +94,10 @@ async def async_setup(hass):
hass.components.websocket_api.async_register_command(
google_assistant_update)
hass.components.websocket_api.async_register_command(alexa_list)
hass.components.websocket_api.async_register_command(alexa_update)
hass.components.websocket_api.async_register_command(alexa_sync)
hass.http.register_view(GoogleActionsSyncView)
hass.http.register_view(CloudLoginView)
hass.http.register_view(CloudLogoutView)
@ -360,6 +368,7 @@ async def websocket_subscription(hass, connection, msg):
vol.Required('type'): 'cloud/update_prefs',
vol.Optional(PREF_ENABLE_GOOGLE): bool,
vol.Optional(PREF_ENABLE_ALEXA): bool,
vol.Optional(PREF_ALEXA_REPORT_STATE): bool,
vol.Optional(PREF_GOOGLE_SECURE_DEVICES_PIN): vol.Any(None, str),
})
async def websocket_update_prefs(hass, connection, msg):
@ -369,6 +378,24 @@ async def websocket_update_prefs(hass, connection, msg):
changes = dict(msg)
changes.pop('id')
changes.pop('type')
# If we turn alexa linking on, validate that we can fetch access token
if changes.get(PREF_ALEXA_REPORT_STATE):
try:
with async_timeout.timeout(10):
await cloud.client.alexa_config.async_get_access_token()
except asyncio.TimeoutError:
connection.send_error(msg['id'], 'alexa_timeout',
'Timeout validating Alexa access token.')
return
except alexa_errors.NoTokenAvailable:
connection.send_error(
msg['id'], 'alexa_relink',
'Please go to the Alexa app and re-link the Home Assistant '
'skill and then try to enable state reporting.'
)
return
await cloud.client.prefs.async_update(**changes)
connection.send_message(websocket_api.result_message(msg['id']))
@ -420,8 +447,7 @@ def _account_data(cloud):
'cloud': cloud.iot.state,
'prefs': client.prefs.as_dict(),
'google_entities': client.google_user_config['filter'].config,
'alexa_entities': client.alexa_config.should_expose.config,
'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS),
'alexa_entities': client.alexa_user_config['filter'].config,
'remote_domain': remote.instance_domain,
'remote_connected': remote.is_connected,
'remote_certificate': certificate,
@ -497,7 +523,7 @@ async def google_assistant_list(hass, connection, msg):
vol.Optional('disable_2fa'): bool,
})
async def google_assistant_update(hass, connection, msg):
"""List all google assistant entities."""
"""Update google assistant config."""
cloud = hass.data[DOMAIN]
changes = dict(msg)
changes.pop('type')
@ -508,3 +534,80 @@ async def google_assistant_update(hass, connection, msg):
connection.send_result(
msg['id'],
cloud.client.prefs.google_entity_configs.get(msg['entity_id']))
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.async_response
@_ws_handle_cloud_errors
@websocket_api.websocket_command({
'type': 'cloud/alexa/entities'
})
async def alexa_list(hass, connection, msg):
"""List all alexa entities."""
cloud = hass.data[DOMAIN]
entities = alexa_entities.async_get_entities(
hass, cloud.client.alexa_config
)
result = []
for entity in entities:
result.append({
'entity_id': entity.entity_id,
'display_categories': entity.default_display_categories(),
'interfaces': [ifc.name() for ifc in entity.interfaces()],
})
connection.send_result(msg['id'], result)
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.async_response
@_ws_handle_cloud_errors
@websocket_api.websocket_command({
'type': 'cloud/alexa/entities/update',
'entity_id': str,
vol.Optional('should_expose'): bool,
})
async def alexa_update(hass, connection, msg):
"""Update alexa entity config."""
cloud = hass.data[DOMAIN]
changes = dict(msg)
changes.pop('type')
changes.pop('id')
await cloud.client.prefs.async_update_alexa_entity_config(**changes)
connection.send_result(
msg['id'],
cloud.client.prefs.alexa_entity_configs.get(msg['entity_id']))
@websocket_api.require_admin
@_require_cloud_login
@websocket_api.async_response
@websocket_api.websocket_command({
'type': 'cloud/alexa/sync',
})
async def alexa_sync(hass, connection, msg):
"""Sync with Alexa."""
cloud = hass.data[DOMAIN]
with async_timeout.timeout(10):
try:
success = await cloud.client.alexa_config.async_sync_entities()
except alexa_errors.NoTokenAvailable:
connection.send_error(
msg['id'], 'alexa_relink',
'Please go to the Alexa app and re-link the Home Assistant '
'skill.'
)
return
if success:
connection.send_result(msg['id'])
else:
connection.send_error(
msg['id'], ws_const.ERR_UNKNOWN_ERROR, 'Unknown error')

View File

@ -3,7 +3,7 @@
"name": "Cloud",
"documentation": "https://www.home-assistant.io/components/cloud",
"requirements": [
"hass-nabucasa==0.14"
"hass-nabucasa==0.15"
],
"dependencies": [
"http",

Some files were not shown because too many files have changed in this diff Show More