Compare commits
217 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1f25aa74dd | |||
| c6b285c666 | |||
| ff6f5cc116 | |||
| 2547a235c1 | |||
| fdb698bef0 | |||
| 586e54f8bf | |||
| 431201cb9b | |||
| 9b43388093 | |||
| 45620d6892 | |||
| 7ed21d90aa | |||
| 8a2d7a3e11 | |||
| de4f610540 | |||
| 770f8bd1c3 | |||
| 1ab942e0a2 | |||
| 6541e789fb | |||
| c1b5772f0f | |||
| 6627c352e6 | |||
| 6de403e0ac | |||
| 75f902f57e | |||
| 8db4641455 | |||
| 89042439b8 | |||
| f34ebf733d | |||
| 84271a2dac | |||
| e753c51e34 | |||
| 65de739489 | |||
| 3f9d052218 | |||
| 4314dc251f | |||
| e0de521388 | |||
| 3a282702d9 | |||
| 7759ae26fd | |||
| 4be91a103d | |||
| a4b64dec39 | |||
| 3c0d02f057 | |||
| 29e973d060 | |||
| 12b2cfa9b5 | |||
| c4810da82f | |||
| 0aa22d9d91 | |||
| 670bd0ce48 | |||
| 4803721120 | |||
| 755a2a8291 | |||
| 3bd31b91fb | |||
| 25e00556d0 | |||
| 8a90ad9e28 | |||
| 3f19be9717 | |||
| 13fe5857b3 | |||
| 5327d2dd1a | |||
| da4048a9ec | |||
| b4551cc127 | |||
| 3337107e79 | |||
| f7609e9cb1 | |||
| 52671842d5 | |||
| fc4a21e491 | |||
| 70c8970555 | |||
| fa32411ab1 | |||
| 29c40622d3 | |||
| 80a15977ff | |||
| e406c57ec9 | |||
| 9232fa06e4 | |||
| 94370eda54 | |||
| 52561d4f7c | |||
| 9381f187a4 | |||
| 445b0f6f94 | |||
| 19932bce53 | |||
| cc5256b8fb | |||
| 236d5f8742 | |||
| 2df433eb0a | |||
| 44838937d1 | |||
| 8b6a5eef4c | |||
| 6fb55b363a | |||
| 7c8e7d6eb0 | |||
| eb2338249f | |||
| d499c18e63 | |||
| 312de6b3a3 | |||
| 9d839f1f53 | |||
| 475f6f5f82 | |||
| fd9ceb7381 | |||
| 154b070eae | |||
| 8a3dcbf10f | |||
| bf176c405a | |||
| cf8e6d8d86 | |||
| 4a6a53c1ad | |||
| 896ba7e3fa | |||
| fafc4a6042 | |||
| a298b0790b | |||
| 1baf0da627 | |||
| fc4cd39cdd | |||
| 2486c9af35 | |||
| 515d1bdbd3 | |||
| ff7db218b1 | |||
| 350b8e09e6 | |||
| 1b91218a60 | |||
| aa0fc339c0 | |||
| bbf6e9ea47 | |||
| 84524e0712 | |||
| e2ce1d05ae | |||
| 0d75cd484b | |||
| 499382a9a9 | |||
| f1aef33dd6 | |||
| 6c0f4c35f6 | |||
| 08b0629eca | |||
| 3704a18da5 | |||
| d1d9704292 | |||
| 283cd80a7f | |||
| 7da8cb225f | |||
| a3a73b418a | |||
| a8784f9adf | |||
| e7c08921eb | |||
| 5e35beb41a | |||
| e980ced0b7 | |||
| fee922c4be | |||
| 6fa8c2afe5 | |||
| 675fb2010d | |||
| e4c0cec7f1 | |||
| d978d58436 | |||
| 5fd9220812 | |||
| 7cd7b43d25 | |||
| c26fb9906f | |||
| 58cc3a2d7a | |||
| b8a03f1283 | |||
| 2e66898bec | |||
| 2531d54515 | |||
| 3aa08f6c91 | |||
| 7314ec7a42 | |||
| a5155a2609 | |||
| 3dbf951086 | |||
| 1bbaa00976 | |||
| a5a970709f | |||
| 185ada2354 | |||
| dcaa5fe443 | |||
| 8ea7e4bb55 | |||
| 252ee35d61 | |||
| e41b00fb4d | |||
| a05afd58e9 | |||
| 0f7c35859b | |||
| 15c3ea0d86 | |||
| 392588e519 | |||
| 3996c609b4 | |||
| c44397e257 | |||
| 5851944f80 | |||
| 77fb1baeb6 | |||
| 94dcf36d7c | |||
| ced642c862 | |||
| 71e06c566f | |||
| fd97c23cde | |||
| 2219dcaee5 | |||
| 5f24cc229d | |||
| 811f6b4092 | |||
| 6ccf039c95 | |||
| f2c605ba1b | |||
| c54b2c43d4 | |||
| 8a3f8457e8 | |||
| bda6d2c696 | |||
| 840072e92f | |||
| 258ad8fc16 | |||
| e2866a1339 | |||
| 308152f48c | |||
| c2bbc2f74e | |||
| 73a15ddd64 | |||
| f3fc571cd5 | |||
| 78bb0da5a0 | |||
| 515982a692 | |||
| 7b0628421d | |||
| 04bed51277 | |||
| a7bce5f9e6 | |||
| 26c98512c8 | |||
| 5de39fd118 | |||
| 175b4ae5e0 | |||
| 0100af0fa6 | |||
| 1c8253f762 | |||
| 4126b8bd13 | |||
| 9c603d932d | |||
| 20f3e3dcf9 | |||
| 371d1cc872 | |||
| 3430c1c8bc | |||
| f5dee2c27d | |||
| 5db55b306e | |||
| ba5e8d133d | |||
| c94b3a7bf9 | |||
| 28d312803b | |||
| 07cb7b3d54 | |||
| 5b453ca53a | |||
| b21bfe50d7 | |||
| 411c9620c1 | |||
| 89d6784fa0 | |||
| d90801f6dd | |||
| 2c8967d0d5 | |||
| f5ffef3f72 | |||
| c8da95c1e8 | |||
| fdf2d24a8b | |||
| 05192e678e | |||
| 29b62f814f | |||
| c9fc3fae6e | |||
| 90f9a6bc0a | |||
| 1afdde61e8 | |||
| 2a8620f806 | |||
| 804d06d0d3 | |||
| 202d4d8105 | |||
| 04dccb4246 | |||
| 7f5c4cd1e5 | |||
| c84a099b0f | |||
| 659dc2e557 | |||
| 10c0744c4a | |||
| 51ff6009a3 | |||
| 6d01838632 | |||
| 31f189da82 | |||
| c7ecebfd07 | |||
| cc1979691e | |||
| e2fc9669f0 | |||
| 7307ab878a | |||
| 160c7fc685 | |||
| 313a9e3984 | |||
| ba310d3bd1 | |||
| 2defb85fb2 | |||
| c44972c2c9 | |||
| 5ec5552803 | |||
| d1ef47384d | |||
| 3b2bf1d567 |
+26
-8
@@ -53,6 +53,9 @@ omit =
|
||||
homeassistant/components/digital_ocean.py
|
||||
homeassistant/components/*/digital_ocean.py
|
||||
|
||||
homeassistant/components/doorbird.py
|
||||
homeassistant/components/*/doorbird.py
|
||||
|
||||
homeassistant/components/dweet.py
|
||||
homeassistant/components/*/dweet.py
|
||||
|
||||
@@ -146,6 +149,9 @@ omit =
|
||||
homeassistant/components/rachio.py
|
||||
homeassistant/components/*/rachio.py
|
||||
|
||||
homeassistant/components/raincloud.py
|
||||
homeassistant/components/*/raincloud.py
|
||||
|
||||
homeassistant/components/raspihats.py
|
||||
homeassistant/components/*/raspihats.py
|
||||
|
||||
@@ -158,6 +164,9 @@ omit =
|
||||
homeassistant/components/rpi_pfio.py
|
||||
homeassistant/components/*/rpi_pfio.py
|
||||
|
||||
homeassistant/components/satel_integra.py
|
||||
homeassistant/components/*/satel_integra.py
|
||||
|
||||
homeassistant/components/scsgate.py
|
||||
homeassistant/components/*/scsgate.py
|
||||
|
||||
@@ -173,6 +182,9 @@ omit =
|
||||
homeassistant/components/tesla.py
|
||||
homeassistant/components/*/tesla.py
|
||||
|
||||
homeassistant/components/thethingsnetwork.py
|
||||
homeassistant/components/*/thethingsnetwork.py
|
||||
|
||||
homeassistant/components/*/thinkingcleaner.py
|
||||
|
||||
homeassistant/components/tradfri.py
|
||||
@@ -208,12 +220,12 @@ omit =
|
||||
homeassistant/components/wink.py
|
||||
homeassistant/components/*/wink.py
|
||||
|
||||
homeassistant/components/xiaomi.py
|
||||
homeassistant/components/binary_sensor/xiaomi.py
|
||||
homeassistant/components/cover/xiaomi.py
|
||||
homeassistant/components/light/xiaomi.py
|
||||
homeassistant/components/sensor/xiaomi.py
|
||||
homeassistant/components/switch/xiaomi.py
|
||||
homeassistant/components/xiaomi_aqara.py
|
||||
homeassistant/components/binary_sensor/xiaomi_aqara.py
|
||||
homeassistant/components/cover/xiaomi_aqara.py
|
||||
homeassistant/components/light/xiaomi_aqara.py
|
||||
homeassistant/components/sensor/xiaomi_aqara.py
|
||||
homeassistant/components/switch/xiaomi_aqara.py
|
||||
|
||||
homeassistant/components/zabbix.py
|
||||
homeassistant/components/*/zabbix.py
|
||||
@@ -247,6 +259,7 @@ omit =
|
||||
homeassistant/components/binary_sensor/rest.py
|
||||
homeassistant/components/binary_sensor/tapsaff.py
|
||||
homeassistant/components/browser.py
|
||||
homeassistant/components/calendar/todoist.py
|
||||
homeassistant/components/camera/bloomsky.py
|
||||
homeassistant/components/camera/ffmpeg.py
|
||||
homeassistant/components/camera/foscam.py
|
||||
@@ -283,6 +296,7 @@ omit =
|
||||
homeassistant/components/device_tracker/gpslogger.py
|
||||
homeassistant/components/device_tracker/huawei_router.py
|
||||
homeassistant/components/device_tracker/icloud.py
|
||||
homeassistant/components/device_tracker/keenetic_ndms2.py
|
||||
homeassistant/components/device_tracker/linksys_ap.py
|
||||
homeassistant/components/device_tracker/linksys_smart.py
|
||||
homeassistant/components/device_tracker/luci.py
|
||||
@@ -331,7 +345,7 @@ omit =
|
||||
homeassistant/components/light/tplink.py
|
||||
homeassistant/components/light/tradfri.py
|
||||
homeassistant/components/light/x10.py
|
||||
homeassistant/components/light/xiaomi_philipslight.py
|
||||
homeassistant/components/light/xiaomi_miio.py
|
||||
homeassistant/components/light/yeelight.py
|
||||
homeassistant/components/light/yeelightsunflower.py
|
||||
homeassistant/components/light/zengge.py
|
||||
@@ -391,6 +405,7 @@ omit =
|
||||
homeassistant/components/notify/aws_sqs.py
|
||||
homeassistant/components/notify/ciscospark.py
|
||||
homeassistant/components/notify/clicksend.py
|
||||
homeassistant/components/notify/clicksendaudio.py
|
||||
homeassistant/components/notify/discord.py
|
||||
homeassistant/components/notify/facebook.py
|
||||
homeassistant/components/notify/free_mobile.py
|
||||
@@ -530,6 +545,7 @@ omit =
|
||||
homeassistant/components/sensor/tank_utility.py
|
||||
homeassistant/components/sensor/ted5000.py
|
||||
homeassistant/components/sensor/temper.py
|
||||
homeassistant/components/sensor/tibber.py
|
||||
homeassistant/components/sensor/time_date.py
|
||||
homeassistant/components/sensor/torque.py
|
||||
homeassistant/components/sensor/transmission.py
|
||||
@@ -540,6 +556,7 @@ omit =
|
||||
homeassistant/components/sensor/vasttrafik.py
|
||||
homeassistant/components/sensor/waqi.py
|
||||
homeassistant/components/sensor/worldtidesinfo.py
|
||||
homeassistant/components/sensor/worxlandroid.py
|
||||
homeassistant/components/sensor/xbox_live.py
|
||||
homeassistant/components/sensor/yweather.py
|
||||
homeassistant/components/sensor/zamg.py
|
||||
@@ -565,13 +582,13 @@ omit =
|
||||
homeassistant/components/switch/rest.py
|
||||
homeassistant/components/switch/rpi_rf.py
|
||||
homeassistant/components/switch/tplink.py
|
||||
homeassistant/components/switch/telnet.py
|
||||
homeassistant/components/switch/transmission.py
|
||||
homeassistant/components/switch/wake_on_lan.py
|
||||
homeassistant/components/telegram_bot/*
|
||||
homeassistant/components/thingspeak.py
|
||||
homeassistant/components/tts/amazon_polly.py
|
||||
homeassistant/components/tts/picotts.py
|
||||
homeassistant/components/upnp.py
|
||||
homeassistant/components/vacuum/roomba.py
|
||||
homeassistant/components/weather/bom.py
|
||||
homeassistant/components/weather/buienradar.py
|
||||
@@ -581,6 +598,7 @@ omit =
|
||||
homeassistant/components/weather/zamg.py
|
||||
homeassistant/components/zeroconf.py
|
||||
homeassistant/components/zwave/util.py
|
||||
homeassistant/components/vacuum/mqtt.py
|
||||
|
||||
|
||||
[report]
|
||||
|
||||
@@ -39,3 +39,7 @@ homeassistant/components/*/zwave.py @home-assistant/z-wave
|
||||
homeassistant/components/cover/template.py @PhracturedBlue
|
||||
homeassistant/components/device_tracker/automatic.py @armills
|
||||
homeassistant/components/media_player/kodi.py @armills
|
||||
homeassistant/components/light/tplink.py @rytilahti
|
||||
homeassistant/components/switch/tplink.py @rytilahti
|
||||
homeassistant/components/climate/eq3btsmart.py @rytilahti
|
||||
homeassistant/components/*/xiaomi_miio.py @rytilahti
|
||||
|
||||
+2
-2
@@ -11,9 +11,10 @@ MAINTAINER Paulus Schoutsen <Paulus@PaulusSchoutsen.nl>
|
||||
#ENV INSTALL_FFMPEG no
|
||||
#ENV INSTALL_LIBCEC no
|
||||
#ENV INSTALL_PHANTOMJS no
|
||||
#ENV INSTALL_COAP_CLIENT no
|
||||
#ENV INSTALL_COAP no
|
||||
#ENV INSTALL_SSOCR no
|
||||
|
||||
|
||||
VOLUME /config
|
||||
|
||||
RUN mkdir -p /usr/src/app
|
||||
@@ -25,7 +26,6 @@ RUN virtualization/Docker/setup_docker_prereqs
|
||||
|
||||
# Install hass component dependencies
|
||||
COPY requirements_all.txt requirements_all.txt
|
||||
|
||||
# Uninstall enum34 because some depenndecies install it but breaks Python 3.4+.
|
||||
# See PR #8103 for more info.
|
||||
RUN pip3 install --no-cache-dir -r requirements_all.txt && \
|
||||
|
||||
+5
-1
@@ -117,7 +117,11 @@ def linkcode_resolve(domain, info):
|
||||
linespec = "#L%d" % (lineno + 1)
|
||||
else:
|
||||
linespec = ""
|
||||
fn = relpath(fn, start='../')
|
||||
index = fn.find("/homeassistant/")
|
||||
if index == -1:
|
||||
index = 0
|
||||
|
||||
fn = fn[index:]
|
||||
|
||||
return '{}/blob/{}/{}{}'.format(GITHUB_URL, code_branch, fn, linespec)
|
||||
|
||||
|
||||
@@ -126,6 +126,12 @@ def get_arguments() -> argparse.Namespace:
|
||||
type=int,
|
||||
default=None,
|
||||
help='Enables daily log rotation and keeps up to the specified days')
|
||||
parser.add_argument(
|
||||
'--log-file',
|
||||
type=str,
|
||||
default=None,
|
||||
help='Log file to write to. If not set, CONFIG/home-assistant.log '
|
||||
'is used')
|
||||
parser.add_argument(
|
||||
'--runner',
|
||||
action='store_true',
|
||||
@@ -256,13 +262,14 @@ def setup_and_run_hass(config_dir: str,
|
||||
}
|
||||
hass = bootstrap.from_config_dict(
|
||||
config, config_dir=config_dir, verbose=args.verbose,
|
||||
skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days)
|
||||
skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days,
|
||||
log_file=args.log_file)
|
||||
else:
|
||||
config_file = ensure_config_file(config_dir)
|
||||
print('Config directory:', config_dir)
|
||||
hass = bootstrap.from_config_file(
|
||||
config_file, verbose=args.verbose, skip_pip=args.skip_pip,
|
||||
log_rotate_days=args.log_rotate_days)
|
||||
log_rotate_days=args.log_rotate_days, log_file=args.log_file)
|
||||
|
||||
if hass is None:
|
||||
return None
|
||||
|
||||
+37
-13
@@ -27,6 +27,10 @@ from homeassistant.helpers.signal import async_register_signal_handling
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ERROR_LOG_FILENAME = 'home-assistant.log'
|
||||
|
||||
# hass.data key for logging information.
|
||||
DATA_LOGGING = 'logging'
|
||||
|
||||
FIRST_INIT_COMPONENT = set((
|
||||
'recorder', 'mqtt', 'mqtt_eventstream', 'logger', 'introduction',
|
||||
'frontend', 'history'))
|
||||
@@ -38,7 +42,8 @@ def from_config_dict(config: Dict[str, Any],
|
||||
enable_log: bool=True,
|
||||
verbose: bool=False,
|
||||
skip_pip: bool=False,
|
||||
log_rotate_days: Any=None) \
|
||||
log_rotate_days: Any=None,
|
||||
log_file: Any=None) \
|
||||
-> Optional[core.HomeAssistant]:
|
||||
"""Try to configure Home Assistant from a configuration dictionary.
|
||||
|
||||
@@ -56,7 +61,7 @@ def from_config_dict(config: Dict[str, Any],
|
||||
hass = hass.loop.run_until_complete(
|
||||
async_from_config_dict(
|
||||
config, hass, config_dir, enable_log, verbose, skip_pip,
|
||||
log_rotate_days)
|
||||
log_rotate_days, log_file)
|
||||
)
|
||||
|
||||
return hass
|
||||
@@ -69,7 +74,8 @@ def async_from_config_dict(config: Dict[str, Any],
|
||||
enable_log: bool=True,
|
||||
verbose: bool=False,
|
||||
skip_pip: bool=False,
|
||||
log_rotate_days: Any=None) \
|
||||
log_rotate_days: Any=None,
|
||||
log_file: Any=None) \
|
||||
-> Optional[core.HomeAssistant]:
|
||||
"""Try to configure Home Assistant from a configuration dictionary.
|
||||
|
||||
@@ -77,6 +83,18 @@ def async_from_config_dict(config: Dict[str, Any],
|
||||
This method is a coroutine.
|
||||
"""
|
||||
start = time()
|
||||
|
||||
if enable_log:
|
||||
async_enable_logging(hass, verbose, log_rotate_days, log_file)
|
||||
|
||||
if sys.version_info[:2] < (3, 5):
|
||||
_LOGGER.warning(
|
||||
'Python 3.4 support has been deprecated and will be removed in '
|
||||
'the begining of 2018. Please upgrade Python or your operating '
|
||||
'system. More info: https://home-assistant.io/blog/2017/10/06/'
|
||||
'deprecating-python-3.4-support/'
|
||||
)
|
||||
|
||||
core_config = config.get(core.DOMAIN, {})
|
||||
|
||||
try:
|
||||
@@ -87,9 +105,6 @@ def async_from_config_dict(config: Dict[str, Any],
|
||||
|
||||
yield from hass.async_add_job(conf_util.process_ha_config_upgrade, hass)
|
||||
|
||||
if enable_log:
|
||||
async_enable_logging(hass, verbose, log_rotate_days)
|
||||
|
||||
hass.config.skip_pip = skip_pip
|
||||
if skip_pip:
|
||||
_LOGGER.warning("Skipping pip installation of required modules. "
|
||||
@@ -153,7 +168,8 @@ def from_config_file(config_path: str,
|
||||
hass: Optional[core.HomeAssistant]=None,
|
||||
verbose: bool=False,
|
||||
skip_pip: bool=True,
|
||||
log_rotate_days: Any=None):
|
||||
log_rotate_days: Any=None,
|
||||
log_file: Any=None):
|
||||
"""Read the configuration file and try to start all the functionality.
|
||||
|
||||
Will add functionality to 'hass' parameter if given,
|
||||
@@ -165,7 +181,7 @@ def from_config_file(config_path: str,
|
||||
# run task
|
||||
hass = hass.loop.run_until_complete(
|
||||
async_from_config_file(
|
||||
config_path, hass, verbose, skip_pip, log_rotate_days)
|
||||
config_path, hass, verbose, skip_pip, log_rotate_days, log_file)
|
||||
)
|
||||
|
||||
return hass
|
||||
@@ -176,7 +192,8 @@ def async_from_config_file(config_path: str,
|
||||
hass: core.HomeAssistant,
|
||||
verbose: bool=False,
|
||||
skip_pip: bool=True,
|
||||
log_rotate_days: Any=None):
|
||||
log_rotate_days: Any=None,
|
||||
log_file: Any=None):
|
||||
"""Read the configuration file and try to start all the functionality.
|
||||
|
||||
Will add functionality to 'hass' parameter.
|
||||
@@ -187,7 +204,7 @@ def async_from_config_file(config_path: str,
|
||||
hass.config.config_dir = config_dir
|
||||
yield from async_mount_local_lib_path(config_dir, hass.loop)
|
||||
|
||||
async_enable_logging(hass, verbose, log_rotate_days)
|
||||
async_enable_logging(hass, verbose, log_rotate_days, log_file)
|
||||
|
||||
try:
|
||||
config_dict = yield from hass.async_add_job(
|
||||
@@ -205,7 +222,7 @@ def async_from_config_file(config_path: str,
|
||||
|
||||
@core.callback
|
||||
def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False,
|
||||
log_rotate_days=None) -> None:
|
||||
log_rotate_days=None, log_file=None) -> None:
|
||||
"""Set up the logging.
|
||||
|
||||
This method must be run in the event loop.
|
||||
@@ -239,13 +256,18 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False,
|
||||
pass
|
||||
|
||||
# Log errors to a file if we have write access to file or config dir
|
||||
err_log_path = hass.config.path(ERROR_LOG_FILENAME)
|
||||
if log_file is None:
|
||||
err_log_path = hass.config.path(ERROR_LOG_FILENAME)
|
||||
else:
|
||||
err_log_path = os.path.abspath(log_file)
|
||||
|
||||
err_path_exists = os.path.isfile(err_log_path)
|
||||
err_dir = os.path.dirname(err_log_path)
|
||||
|
||||
# Check if we can write to the error log if it exists or that
|
||||
# we can create files in the containing directory if not.
|
||||
if (err_path_exists and os.access(err_log_path, os.W_OK)) or \
|
||||
(not err_path_exists and os.access(hass.config.config_dir, os.W_OK)):
|
||||
(not err_path_exists and os.access(err_dir, os.W_OK)):
|
||||
|
||||
if log_rotate_days:
|
||||
err_handler = logging.handlers.TimedRotatingFileHandler(
|
||||
@@ -272,6 +294,8 @@ def async_enable_logging(hass: core.HomeAssistant, verbose: bool=False,
|
||||
logger.addHandler(async_handler)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
# Save the log file location for access by other components.
|
||||
hass.data[DATA_LOGGING] = err_log_path
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Unable to setup error log %s (access denied)", err_log_path)
|
||||
|
||||
@@ -6,57 +6,138 @@ https://home-assistant.io/components/abode/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from functools import partial
|
||||
from os import path
|
||||
|
||||
import voluptuous as vol
|
||||
from requests.exceptions import HTTPError, ConnectTimeout
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.const import (ATTR_ATTRIBUTION,
|
||||
CONF_USERNAME, CONF_PASSWORD,
|
||||
CONF_NAME, EVENT_HOMEASSISTANT_STOP,
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.const import (ATTR_ATTRIBUTION, ATTR_DATE, ATTR_TIME,
|
||||
ATTR_ENTITY_ID, CONF_USERNAME, CONF_PASSWORD,
|
||||
CONF_EXCLUDE, CONF_NAME,
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
EVENT_HOMEASSISTANT_START)
|
||||
|
||||
REQUIREMENTS = ['abodepy==0.9.0']
|
||||
REQUIREMENTS = ['abodepy==0.11.9']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_ATTRIBUTION = "Data provided by goabode.com"
|
||||
CONF_LIGHTS = "lights"
|
||||
CONF_POLLING = "polling"
|
||||
|
||||
DOMAIN = 'abode'
|
||||
DEFAULT_NAME = 'Abode'
|
||||
DATA_ABODE = 'abode'
|
||||
|
||||
NOTIFICATION_ID = 'abode_notification'
|
||||
NOTIFICATION_TITLE = 'Abode Security Setup'
|
||||
|
||||
EVENT_ABODE_ALARM = 'abode_alarm'
|
||||
EVENT_ABODE_ALARM_END = 'abode_alarm_end'
|
||||
EVENT_ABODE_AUTOMATION = 'abode_automation'
|
||||
EVENT_ABODE_FAULT = 'abode_panel_fault'
|
||||
EVENT_ABODE_RESTORE = 'abode_panel_restore'
|
||||
|
||||
SERVICE_SETTINGS = 'change_setting'
|
||||
SERVICE_CAPTURE_IMAGE = 'capture_image'
|
||||
SERVICE_TRIGGER = 'trigger_quick_action'
|
||||
|
||||
ATTR_DEVICE_ID = 'device_id'
|
||||
ATTR_DEVICE_NAME = 'device_name'
|
||||
ATTR_DEVICE_TYPE = 'device_type'
|
||||
ATTR_EVENT_CODE = 'event_code'
|
||||
ATTR_EVENT_NAME = 'event_name'
|
||||
ATTR_EVENT_TYPE = 'event_type'
|
||||
ATTR_EVENT_UTC = 'event_utc'
|
||||
ATTR_SETTING = 'setting'
|
||||
ATTR_USER_NAME = 'user_name'
|
||||
ATTR_VALUE = 'value'
|
||||
|
||||
ABODE_DEVICE_ID_LIST_SCHEMA = vol.Schema([str])
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_POLLING, default=False): cv.boolean,
|
||||
vol.Optional(CONF_EXCLUDE, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA,
|
||||
vol.Optional(CONF_LIGHTS, default=[]): ABODE_DEVICE_ID_LIST_SCHEMA
|
||||
}),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
CHANGE_SETTING_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_SETTING): cv.string,
|
||||
vol.Required(ATTR_VALUE): cv.string
|
||||
})
|
||||
|
||||
CAPTURE_IMAGE_SCHEMA = vol.Schema({
|
||||
ATTR_ENTITY_ID: cv.entity_ids,
|
||||
})
|
||||
|
||||
TRIGGER_SCHEMA = vol.Schema({
|
||||
ATTR_ENTITY_ID: cv.entity_ids,
|
||||
})
|
||||
|
||||
ABODE_PLATFORMS = [
|
||||
'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover'
|
||||
'alarm_control_panel', 'binary_sensor', 'lock', 'switch', 'cover',
|
||||
'camera', 'light'
|
||||
]
|
||||
|
||||
|
||||
class AbodeSystem(object):
|
||||
"""Abode System class."""
|
||||
|
||||
def __init__(self, username, password, name, polling, exclude, lights):
|
||||
"""Initialize the system."""
|
||||
import abodepy
|
||||
self.abode = abodepy.Abode(username, password,
|
||||
auto_login=True,
|
||||
get_devices=True,
|
||||
get_automations=True)
|
||||
self.name = name
|
||||
self.polling = polling
|
||||
self.exclude = exclude
|
||||
self.lights = lights
|
||||
self.devices = []
|
||||
|
||||
def is_excluded(self, device):
|
||||
"""Check if a device is configured to be excluded."""
|
||||
return device.device_id in self.exclude
|
||||
|
||||
def is_automation_excluded(self, automation):
|
||||
"""Check if an automation is configured to be excluded."""
|
||||
return automation.automation_id in self.exclude
|
||||
|
||||
def is_light(self, device):
|
||||
"""Check if a switch device is configured as a light."""
|
||||
import abodepy.helpers.constants as CONST
|
||||
|
||||
return (device.generic_type == CONST.TYPE_LIGHT or
|
||||
(device.generic_type == CONST.TYPE_SWITCH and
|
||||
device.device_id in self.lights))
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Set up Abode component."""
|
||||
import abodepy
|
||||
from abodepy.exceptions import AbodeException
|
||||
|
||||
conf = config[DOMAIN]
|
||||
username = conf.get(CONF_USERNAME)
|
||||
password = conf.get(CONF_PASSWORD)
|
||||
name = conf.get(CONF_NAME)
|
||||
polling = conf.get(CONF_POLLING)
|
||||
exclude = conf.get(CONF_EXCLUDE)
|
||||
lights = conf.get(CONF_LIGHTS)
|
||||
|
||||
try:
|
||||
hass.data[DATA_ABODE] = abode = abodepy.Abode(
|
||||
username, password, auto_login=True, get_devices=True)
|
||||
|
||||
except (ConnectTimeout, HTTPError) as ex:
|
||||
hass.data[DOMAIN] = AbodeSystem(
|
||||
username, password, name, polling, exclude, lights)
|
||||
except (AbodeException, ConnectTimeout, HTTPError) as ex:
|
||||
_LOGGER.error("Unable to connect to Abode: %s", str(ex))
|
||||
|
||||
hass.components.persistent_notification.create(
|
||||
'Error: {}<br />'
|
||||
'You will need to restart hass after fixing.'
|
||||
@@ -65,46 +146,144 @@ def setup(hass, config):
|
||||
notification_id=NOTIFICATION_ID)
|
||||
return False
|
||||
|
||||
setup_hass_services(hass)
|
||||
setup_hass_events(hass)
|
||||
setup_abode_events(hass)
|
||||
|
||||
for platform in ABODE_PLATFORMS:
|
||||
discovery.load_platform(hass, platform, DOMAIN, {}, config)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def setup_hass_services(hass):
|
||||
"""Home assistant services."""
|
||||
from abodepy.exceptions import AbodeException
|
||||
|
||||
def change_setting(call):
|
||||
"""Change an Abode system setting."""
|
||||
setting = call.data.get(ATTR_SETTING)
|
||||
value = call.data.get(ATTR_VALUE)
|
||||
|
||||
try:
|
||||
hass.data[DOMAIN].abode.set_setting(setting, value)
|
||||
except AbodeException as ex:
|
||||
_LOGGER.warning(ex)
|
||||
|
||||
def capture_image(call):
|
||||
"""Capture a new image."""
|
||||
entity_ids = call.data.get(ATTR_ENTITY_ID)
|
||||
|
||||
target_devices = [device for device in hass.data[DOMAIN].devices
|
||||
if device.entity_id in entity_ids]
|
||||
|
||||
for device in target_devices:
|
||||
device.capture()
|
||||
|
||||
def trigger_quick_action(call):
|
||||
"""Trigger a quick action."""
|
||||
entity_ids = call.data.get(ATTR_ENTITY_ID, None)
|
||||
|
||||
target_devices = [device for device in hass.data[DOMAIN].devices
|
||||
if device.entity_id in entity_ids]
|
||||
|
||||
for device in target_devices:
|
||||
device.trigger()
|
||||
|
||||
descriptions = load_yaml_config_file(
|
||||
path.join(path.dirname(__file__), 'services.yaml'))[DOMAIN]
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_SETTINGS, change_setting,
|
||||
descriptions.get(SERVICE_SETTINGS),
|
||||
schema=CHANGE_SETTING_SCHEMA)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_CAPTURE_IMAGE, capture_image,
|
||||
descriptions.get(SERVICE_CAPTURE_IMAGE),
|
||||
schema=CAPTURE_IMAGE_SCHEMA)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_TRIGGER, trigger_quick_action,
|
||||
descriptions.get(SERVICE_TRIGGER),
|
||||
schema=TRIGGER_SCHEMA)
|
||||
|
||||
|
||||
def setup_hass_events(hass):
|
||||
"""Home assistant start and stop callbacks."""
|
||||
def startup(event):
|
||||
"""Listen for push events."""
|
||||
hass.data[DOMAIN].abode.events.start()
|
||||
|
||||
def logout(event):
|
||||
"""Logout of Abode."""
|
||||
abode.stop_listener()
|
||||
abode.logout()
|
||||
if not hass.data[DOMAIN].polling:
|
||||
hass.data[DOMAIN].abode.events.stop()
|
||||
|
||||
hass.data[DOMAIN].abode.logout()
|
||||
_LOGGER.info("Logged out of Abode")
|
||||
|
||||
if not hass.data[DOMAIN].polling:
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup)
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, logout)
|
||||
|
||||
def startup(event):
|
||||
"""Listen for push events."""
|
||||
abode.start_listener()
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, startup)
|
||||
def setup_abode_events(hass):
|
||||
"""Event callbacks."""
|
||||
import abodepy.helpers.timeline as TIMELINE
|
||||
|
||||
return True
|
||||
def event_callback(event, event_json):
|
||||
"""Handle an event callback from Abode."""
|
||||
data = {
|
||||
ATTR_DEVICE_ID: event_json.get(ATTR_DEVICE_ID, ''),
|
||||
ATTR_DEVICE_NAME: event_json.get(ATTR_DEVICE_NAME, ''),
|
||||
ATTR_DEVICE_TYPE: event_json.get(ATTR_DEVICE_TYPE, ''),
|
||||
ATTR_EVENT_CODE: event_json.get(ATTR_EVENT_CODE, ''),
|
||||
ATTR_EVENT_NAME: event_json.get(ATTR_EVENT_NAME, ''),
|
||||
ATTR_EVENT_TYPE: event_json.get(ATTR_EVENT_TYPE, ''),
|
||||
ATTR_EVENT_UTC: event_json.get(ATTR_EVENT_UTC, ''),
|
||||
ATTR_USER_NAME: event_json.get(ATTR_USER_NAME, ''),
|
||||
ATTR_DATE: event_json.get(ATTR_DATE, ''),
|
||||
ATTR_TIME: event_json.get(ATTR_TIME, ''),
|
||||
}
|
||||
|
||||
hass.bus.fire(event, data)
|
||||
|
||||
events = [TIMELINE.ALARM_GROUP, TIMELINE.ALARM_END_GROUP,
|
||||
TIMELINE.PANEL_FAULT_GROUP, TIMELINE.PANEL_RESTORE_GROUP,
|
||||
TIMELINE.AUTOMATION_GROUP]
|
||||
|
||||
for event in events:
|
||||
hass.data[DOMAIN].abode.events.add_event_callback(
|
||||
event,
|
||||
partial(event_callback, event))
|
||||
|
||||
|
||||
class AbodeDevice(Entity):
|
||||
"""Representation of an Abode device."""
|
||||
|
||||
def __init__(self, controller, device):
|
||||
def __init__(self, data, device):
|
||||
"""Initialize a sensor for Abode device."""
|
||||
self._controller = controller
|
||||
self._data = data
|
||||
self._device = device
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Subscribe Abode events."""
|
||||
self.hass.async_add_job(
|
||||
self._controller.register, self._device,
|
||||
self._update_callback
|
||||
self._data.abode.events.add_device_callback,
|
||||
self._device.device_id, self._update_callback
|
||||
)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return the polling state."""
|
||||
return False
|
||||
return self._data.polling
|
||||
|
||||
def update(self):
|
||||
"""Update automation state."""
|
||||
self._device.refresh()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@@ -118,9 +297,58 @@ class AbodeDevice(Entity):
|
||||
ATTR_ATTRIBUTION: CONF_ATTRIBUTION,
|
||||
'device_id': self._device.device_id,
|
||||
'battery_low': self._device.battery_low,
|
||||
'no_response': self._device.no_response
|
||||
'no_response': self._device.no_response,
|
||||
'device_type': self._device.type
|
||||
}
|
||||
|
||||
def _update_callback(self, device):
|
||||
"""Update the device state."""
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
|
||||
class AbodeAutomation(Entity):
|
||||
"""Representation of an Abode automation."""
|
||||
|
||||
def __init__(self, data, automation, event=None):
|
||||
"""Initialize for Abode automation."""
|
||||
self._data = data
|
||||
self._automation = automation
|
||||
self._event = event
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Subscribe Abode events."""
|
||||
if self._event:
|
||||
self.hass.async_add_job(
|
||||
self._data.abode.events.add_event_callback,
|
||||
self._event, self._update_callback
|
||||
)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return the polling state."""
|
||||
return self._data.polling
|
||||
|
||||
def update(self):
|
||||
"""Update automation state."""
|
||||
self._automation.refresh()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._automation.name
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
ATTR_ATTRIBUTION: CONF_ATTRIBUTION,
|
||||
'automation_id': self._automation.automation_id,
|
||||
'type': self._automation.type,
|
||||
'sub_type': self._automation.sub_type
|
||||
}
|
||||
|
||||
def _update_callback(self, device):
|
||||
"""Update the device state."""
|
||||
self._automation.refresh()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@@ -7,7 +7,7 @@ https://home-assistant.io/components/alarm_control_panel.abode/
|
||||
import logging
|
||||
|
||||
from homeassistant.components.abode import (
|
||||
AbodeDevice, DATA_ABODE, DEFAULT_NAME, CONF_ATTRIBUTION)
|
||||
AbodeDevice, DOMAIN as ABODE_DOMAIN, CONF_ATTRIBUTION)
|
||||
from homeassistant.components.alarm_control_panel import (AlarmControlPanel)
|
||||
from homeassistant.const import (ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY,
|
||||
STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED)
|
||||
@@ -22,18 +22,22 @@ ICON = 'mdi:security'
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up a sensor for an Abode device."""
|
||||
abode = hass.data[DATA_ABODE]
|
||||
data = hass.data[ABODE_DOMAIN]
|
||||
|
||||
add_devices([AbodeAlarm(abode, abode.get_alarm())])
|
||||
alarm_devices = [AbodeAlarm(data, data.abode.get_alarm(), data.name)]
|
||||
|
||||
data.devices.extend(alarm_devices)
|
||||
|
||||
add_devices(alarm_devices)
|
||||
|
||||
|
||||
class AbodeAlarm(AbodeDevice, AlarmControlPanel):
|
||||
"""An alarm_control_panel implementation for Abode."""
|
||||
|
||||
def __init__(self, controller, device):
|
||||
def __init__(self, data, device, name):
|
||||
"""Initialize the alarm control panel."""
|
||||
AbodeDevice.__init__(self, controller, device)
|
||||
self._name = "{0}".format(DEFAULT_NAME)
|
||||
super().__init__(data, device)
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
@@ -65,6 +69,11 @@ class AbodeAlarm(AbodeDevice, AlarmControlPanel):
|
||||
"""Send arm away command."""
|
||||
self._device.set_away()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the alarm."""
|
||||
return self._name or super().name
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
|
||||
@@ -57,19 +57,19 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel):
|
||||
if message.alarm_sounding or message.fire_alarm:
|
||||
if self._state != STATE_ALARM_TRIGGERED:
|
||||
self._state = STATE_ALARM_TRIGGERED
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
elif message.armed_away:
|
||||
if self._state != STATE_ALARM_ARMED_AWAY:
|
||||
self._state = STATE_ALARM_ARMED_AWAY
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
elif message.armed_home:
|
||||
if self._state != STATE_ALARM_ARMED_HOME:
|
||||
self._state = STATE_ALARM_ARMED_HOME
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
else:
|
||||
if self._state != STATE_ALARM_DISARMED:
|
||||
self._state = STATE_ALARM_DISARMED
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
||||
@@ -107,7 +107,7 @@ class Concord232Alarm(alarm.AlarmControlPanel):
|
||||
newstate = STATE_ALARM_ARMED_AWAY
|
||||
|
||||
if not newstate == self._state:
|
||||
_LOGGER.info("State Chnage from %s to %s", self._state, newstate)
|
||||
_LOGGER.info("State Change from %s to %s", self._state, newstate)
|
||||
self._state = newstate
|
||||
return self._state
|
||||
|
||||
|
||||
@@ -5,10 +5,26 @@ For more details about this platform, please refer to the documentation
|
||||
https://home-assistant.io/components/demo/
|
||||
"""
|
||||
import homeassistant.components.alarm_control_panel.manual as manual
|
||||
from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
|
||||
STATE_ALARM_TRIGGERED, CONF_PENDING_TIME)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the Demo alarm control panel platform."""
|
||||
add_devices([
|
||||
manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False),
|
||||
manual.ManualAlarm(hass, 'Alarm', '1234', 5, 10, False, {
|
||||
STATE_ALARM_ARMED_AWAY: {
|
||||
CONF_PENDING_TIME: 5
|
||||
},
|
||||
STATE_ALARM_ARMED_HOME: {
|
||||
CONF_PENDING_TIME: 5
|
||||
},
|
||||
STATE_ALARM_ARMED_NIGHT: {
|
||||
CONF_PENDING_TIME: 5
|
||||
},
|
||||
STATE_ALARM_TRIGGERED: {
|
||||
CONF_PENDING_TIME: 5
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
@@ -18,13 +18,14 @@ from homeassistant.const import (
|
||||
CONF_NAME, STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED)
|
||||
|
||||
REQUIREMENTS = ['pythonegardia==1.0.20']
|
||||
REQUIREMENTS = ['pythonegardia==1.0.21']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_REPORT_SERVER_CODES = 'report_server_codes'
|
||||
CONF_REPORT_SERVER_ENABLED = 'report_server_enabled'
|
||||
CONF_REPORT_SERVER_PORT = 'report_server_port'
|
||||
CONF_REPORT_SERVER_CODES_IGNORE = 'ignore'
|
||||
|
||||
DEFAULT_NAME = 'Egardia'
|
||||
DEFAULT_PORT = 80
|
||||
@@ -148,9 +149,15 @@ class EgardiaAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
def parsestatus(self, status):
|
||||
"""Parse the status."""
|
||||
newstatus = ([v for k, v in STATES.items()
|
||||
if status.upper() == k][0])
|
||||
self._status = newstatus
|
||||
_LOGGER.debug("Parsing status %s", status)
|
||||
# Ignore the statuscode if it is IGNORE
|
||||
if status.lower().strip() != CONF_REPORT_SERVER_CODES_IGNORE:
|
||||
_LOGGER.debug("Not ignoring status")
|
||||
newstatus = ([v for k, v in STATES.items()
|
||||
if status.upper() == k][0])
|
||||
self._status = newstatus
|
||||
else:
|
||||
_LOGGER.error("Ignoring status")
|
||||
|
||||
def update(self):
|
||||
"""Update the alarm status."""
|
||||
|
||||
@@ -106,7 +106,7 @@ class EnvisalinkAlarm(EnvisalinkDevice, alarm.AlarmControlPanel):
|
||||
def _update_callback(self, partition):
|
||||
"""Update Home Assistant state, if needed."""
|
||||
if partition is None or int(partition) == self._partition_number:
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
|
||||
@@ -4,6 +4,7 @@ Support for manual alarms.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.manual/
|
||||
"""
|
||||
import copy
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
@@ -24,9 +25,28 @@ DEFAULT_PENDING_TIME = 60
|
||||
DEFAULT_TRIGGER_TIME = 120
|
||||
DEFAULT_DISARM_AFTER_TRIGGER = False
|
||||
|
||||
SUPPORTED_PENDING_STATES = [STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED]
|
||||
|
||||
ATTR_POST_PENDING_STATE = 'post_pending_state'
|
||||
|
||||
PLATFORM_SCHEMA = vol.Schema({
|
||||
|
||||
def _state_validator(config):
|
||||
config = copy.deepcopy(config)
|
||||
for state in SUPPORTED_PENDING_STATES:
|
||||
if CONF_PENDING_TIME not in config[state]:
|
||||
config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
STATE_SETTING_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_PENDING_TIME):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=0))
|
||||
})
|
||||
|
||||
|
||||
PLATFORM_SCHEMA = vol.Schema(vol.All({
|
||||
vol.Required(CONF_PLATFORM): 'manual',
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string,
|
||||
vol.Optional(CONF_CODE): cv.string,
|
||||
@@ -36,7 +56,11 @@ PLATFORM_SCHEMA = vol.Schema({
|
||||
vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
vol.Optional(CONF_DISARM_AFTER_TRIGGER,
|
||||
default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean,
|
||||
})
|
||||
vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): STATE_SETTING_SCHEMA,
|
||||
vol.Optional(STATE_ALARM_ARMED_HOME, default={}): STATE_SETTING_SCHEMA,
|
||||
vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): STATE_SETTING_SCHEMA,
|
||||
vol.Optional(STATE_ALARM_TRIGGERED, default={}): STATE_SETTING_SCHEMA,
|
||||
}, _state_validator))
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,7 +73,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
config.get(CONF_CODE),
|
||||
config.get(CONF_PENDING_TIME, DEFAULT_PENDING_TIME),
|
||||
config.get(CONF_TRIGGER_TIME, DEFAULT_TRIGGER_TIME),
|
||||
config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER)
|
||||
config.get(CONF_DISARM_AFTER_TRIGGER, DEFAULT_DISARM_AFTER_TRIGGER),
|
||||
config
|
||||
)])
|
||||
|
||||
|
||||
@@ -63,19 +88,23 @@ class ManualAlarm(alarm.AlarmControlPanel):
|
||||
or disarm if `disarm_after_trigger` is true.
|
||||
"""
|
||||
|
||||
def __init__(self, hass, name, code, pending_time,
|
||||
trigger_time, disarm_after_trigger):
|
||||
def __init__(self, hass, name, code, pending_time, trigger_time,
|
||||
disarm_after_trigger, config):
|
||||
"""Init the manual alarm panel."""
|
||||
self._state = STATE_ALARM_DISARMED
|
||||
self._hass = hass
|
||||
self._name = name
|
||||
self._code = str(code) if code else None
|
||||
self._pending_time = datetime.timedelta(seconds=pending_time)
|
||||
self._trigger_time = datetime.timedelta(seconds=trigger_time)
|
||||
self._disarm_after_trigger = disarm_after_trigger
|
||||
self._pre_trigger_state = self._state
|
||||
self._state_ts = None
|
||||
|
||||
self._pending_time_by_state = {}
|
||||
for state in SUPPORTED_PENDING_STATES:
|
||||
self._pending_time_by_state[state] = datetime.timedelta(
|
||||
seconds=config[state][CONF_PENDING_TIME])
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return the plling state."""
|
||||
@@ -89,17 +118,10 @@ class ManualAlarm(alarm.AlarmControlPanel):
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
if self._state in (STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_ARMED_AWAY,
|
||||
STATE_ALARM_ARMED_NIGHT) and \
|
||||
self._pending_time and self._state_ts + self._pending_time > \
|
||||
dt_util.utcnow():
|
||||
return STATE_ALARM_PENDING
|
||||
|
||||
if self._state == STATE_ALARM_TRIGGERED and self._trigger_time:
|
||||
if self._state_ts + self._pending_time > dt_util.utcnow():
|
||||
if self._within_pending_time(self._state):
|
||||
return STATE_ALARM_PENDING
|
||||
elif (self._state_ts + self._pending_time +
|
||||
elif (self._state_ts + self._pending_time_by_state[self._state] +
|
||||
self._trigger_time) < dt_util.utcnow():
|
||||
if self._disarm_after_trigger:
|
||||
return STATE_ALARM_DISARMED
|
||||
@@ -107,8 +129,16 @@ class ManualAlarm(alarm.AlarmControlPanel):
|
||||
self._state = self._pre_trigger_state
|
||||
return self._state
|
||||
|
||||
if self._state in SUPPORTED_PENDING_STATES and \
|
||||
self._within_pending_time(self._state):
|
||||
return STATE_ALARM_PENDING
|
||||
|
||||
return self._state
|
||||
|
||||
def _within_pending_time(self, state):
|
||||
pending_time = self._pending_time_by_state[state]
|
||||
return self._state_ts + pending_time > dt_util.utcnow()
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""One or more characters."""
|
||||
@@ -128,58 +158,47 @@ class ManualAlarm(alarm.AlarmControlPanel):
|
||||
if not self._validate_code(code, STATE_ALARM_ARMED_HOME):
|
||||
return
|
||||
|
||||
self._state = STATE_ALARM_ARMED_HOME
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
if self._pending_time:
|
||||
track_point_in_time(
|
||||
self._hass, self.async_update_ha_state,
|
||||
self._state_ts + self._pending_time)
|
||||
self._update_state(STATE_ALARM_ARMED_HOME)
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
if not self._validate_code(code, STATE_ALARM_ARMED_AWAY):
|
||||
return
|
||||
|
||||
self._state = STATE_ALARM_ARMED_AWAY
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
if self._pending_time:
|
||||
track_point_in_time(
|
||||
self._hass, self.async_update_ha_state,
|
||||
self._state_ts + self._pending_time)
|
||||
self._update_state(STATE_ALARM_ARMED_AWAY)
|
||||
|
||||
def alarm_arm_night(self, code=None):
|
||||
"""Send arm night command."""
|
||||
if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT):
|
||||
return
|
||||
|
||||
self._state = STATE_ALARM_ARMED_NIGHT
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
if self._pending_time:
|
||||
track_point_in_time(
|
||||
self._hass, self.async_update_ha_state,
|
||||
self._state_ts + self._pending_time)
|
||||
self._update_state(STATE_ALARM_ARMED_NIGHT)
|
||||
|
||||
def alarm_trigger(self, code=None):
|
||||
"""Send alarm trigger command. No code needed."""
|
||||
self._pre_trigger_state = self._state
|
||||
self._state = STATE_ALARM_TRIGGERED
|
||||
|
||||
self._update_state(STATE_ALARM_TRIGGERED)
|
||||
|
||||
def _update_state(self, state):
|
||||
self._state = state
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
if self._trigger_time:
|
||||
pending_time = self._pending_time_by_state[state]
|
||||
|
||||
if state == STATE_ALARM_TRIGGERED and self._trigger_time:
|
||||
track_point_in_time(
|
||||
self._hass, self.async_update_ha_state,
|
||||
self._state_ts + self._pending_time)
|
||||
self._state_ts + pending_time)
|
||||
|
||||
track_point_in_time(
|
||||
self._hass, self.async_update_ha_state,
|
||||
self._state_ts + self._pending_time + self._trigger_time)
|
||||
self._state_ts + self._trigger_time + pending_time)
|
||||
elif state in SUPPORTED_PENDING_STATES and pending_time:
|
||||
track_point_in_time(
|
||||
self._hass, self.async_update_ha_state,
|
||||
self._state_ts + pending_time)
|
||||
|
||||
def _validate_code(self, code, state):
|
||||
"""Validate given code."""
|
||||
|
||||
@@ -5,6 +5,7 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.manual_mqtt/
|
||||
"""
|
||||
import asyncio
|
||||
import copy
|
||||
import datetime
|
||||
import logging
|
||||
|
||||
@@ -13,9 +14,9 @@ import voluptuous as vol
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED,
|
||||
STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, CONF_PLATFORM,
|
||||
CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME,
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT,
|
||||
STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED,
|
||||
CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_PENDING_TIME, CONF_TRIGGER_TIME,
|
||||
CONF_DISARM_AFTER_TRIGGER)
|
||||
import homeassistant.components.mqtt as mqtt
|
||||
|
||||
@@ -28,6 +29,7 @@ from homeassistant.helpers.event import track_point_in_time
|
||||
CONF_PAYLOAD_DISARM = 'payload_disarm'
|
||||
CONF_PAYLOAD_ARM_HOME = 'payload_arm_home'
|
||||
CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away'
|
||||
CONF_PAYLOAD_ARM_NIGHT = 'payload_arm_night'
|
||||
|
||||
DEFAULT_ALARM_NAME = 'HA Alarm'
|
||||
DEFAULT_PENDING_TIME = 60
|
||||
@@ -35,11 +37,32 @@ DEFAULT_TRIGGER_TIME = 120
|
||||
DEFAULT_DISARM_AFTER_TRIGGER = False
|
||||
DEFAULT_ARM_AWAY = 'ARM_AWAY'
|
||||
DEFAULT_ARM_HOME = 'ARM_HOME'
|
||||
DEFAULT_ARM_NIGHT = 'ARM_NIGHT'
|
||||
DEFAULT_DISARM = 'DISARM'
|
||||
|
||||
SUPPORTED_PENDING_STATES = [STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED]
|
||||
|
||||
ATTR_POST_PENDING_STATE = 'post_pending_state'
|
||||
|
||||
|
||||
def _state_validator(config):
|
||||
config = copy.deepcopy(config)
|
||||
for state in SUPPORTED_PENDING_STATES:
|
||||
if CONF_PENDING_TIME not in config[state]:
|
||||
config[state][CONF_PENDING_TIME] = config[CONF_PENDING_TIME]
|
||||
|
||||
return config
|
||||
|
||||
|
||||
STATE_SETTING_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_PENDING_TIME):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=0))
|
||||
})
|
||||
|
||||
DEPENDENCIES = ['mqtt']
|
||||
|
||||
PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({
|
||||
PLATFORM_SCHEMA = vol.Schema(vol.All(mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_PLATFORM): 'manual_mqtt',
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_ALARM_NAME): cv.string,
|
||||
vol.Optional(CONF_CODE): cv.string,
|
||||
@@ -49,12 +72,17 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({
|
||||
vol.All(vol.Coerce(int), vol.Range(min=1)),
|
||||
vol.Optional(CONF_DISARM_AFTER_TRIGGER,
|
||||
default=DEFAULT_DISARM_AFTER_TRIGGER): cv.boolean,
|
||||
vol.Optional(STATE_ALARM_ARMED_AWAY, default={}): STATE_SETTING_SCHEMA,
|
||||
vol.Optional(STATE_ALARM_ARMED_HOME, default={}): STATE_SETTING_SCHEMA,
|
||||
vol.Optional(STATE_ALARM_ARMED_NIGHT, default={}): STATE_SETTING_SCHEMA,
|
||||
vol.Optional(STATE_ALARM_TRIGGERED, default={}): STATE_SETTING_SCHEMA,
|
||||
vol.Required(mqtt.CONF_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Required(mqtt.CONF_STATE_TOPIC): mqtt.valid_subscribe_topic,
|
||||
vol.Optional(CONF_PAYLOAD_ARM_AWAY, default=DEFAULT_ARM_AWAY): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_ARM_HOME, default=DEFAULT_ARM_HOME): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_ARM_NIGHT, default=DEFAULT_ARM_NIGHT): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string,
|
||||
})
|
||||
}), _state_validator))
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -73,7 +101,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
config.get(mqtt.CONF_QOS),
|
||||
config.get(CONF_PAYLOAD_DISARM),
|
||||
config.get(CONF_PAYLOAD_ARM_HOME),
|
||||
config.get(CONF_PAYLOAD_ARM_AWAY))])
|
||||
config.get(CONF_PAYLOAD_ARM_AWAY),
|
||||
config.get(CONF_PAYLOAD_ARM_NIGHT),
|
||||
config)])
|
||||
|
||||
|
||||
class ManualMQTTAlarm(alarm.AlarmControlPanel):
|
||||
@@ -89,7 +119,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel):
|
||||
def __init__(self, hass, name, code, pending_time,
|
||||
trigger_time, disarm_after_trigger,
|
||||
state_topic, command_topic, qos,
|
||||
payload_disarm, payload_arm_home, payload_arm_away):
|
||||
payload_disarm, payload_arm_home, payload_arm_away,
|
||||
payload_arm_night, config):
|
||||
"""Init the manual MQTT alarm panel."""
|
||||
self._state = STATE_ALARM_DISARMED
|
||||
self._hass = hass
|
||||
@@ -101,12 +132,18 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel):
|
||||
self._pre_trigger_state = self._state
|
||||
self._state_ts = None
|
||||
|
||||
self._pending_time_by_state = {}
|
||||
for state in SUPPORTED_PENDING_STATES:
|
||||
self._pending_time_by_state[state] = datetime.timedelta(
|
||||
seconds=config[state][CONF_PENDING_TIME])
|
||||
|
||||
self._state_topic = state_topic
|
||||
self._command_topic = command_topic
|
||||
self._qos = qos
|
||||
self._payload_disarm = payload_disarm
|
||||
self._payload_arm_home = payload_arm_home
|
||||
self._payload_arm_away = payload_arm_away
|
||||
self._payload_arm_night = payload_arm_night
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
@@ -121,23 +158,27 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel):
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
if self._state in (STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_ARMED_AWAY) and \
|
||||
self._pending_time and self._state_ts + self._pending_time > \
|
||||
dt_util.utcnow():
|
||||
return STATE_ALARM_PENDING
|
||||
|
||||
if self._state == STATE_ALARM_TRIGGERED and self._trigger_time:
|
||||
if self._state_ts + self._pending_time > dt_util.utcnow():
|
||||
if self._within_pending_time(self._state):
|
||||
return STATE_ALARM_PENDING
|
||||
elif (self._state_ts + self._pending_time +
|
||||
elif (self._state_ts + self._pending_time_by_state[self._state] +
|
||||
self._trigger_time) < dt_util.utcnow():
|
||||
if self._disarm_after_trigger:
|
||||
return STATE_ALARM_DISARMED
|
||||
return self._pre_trigger_state
|
||||
else:
|
||||
self._state = self._pre_trigger_state
|
||||
return self._state
|
||||
|
||||
if self._state in SUPPORTED_PENDING_STATES and \
|
||||
self._within_pending_time(self._state):
|
||||
return STATE_ALARM_PENDING
|
||||
|
||||
return self._state
|
||||
|
||||
def _within_pending_time(self, state):
|
||||
pending_time = self._pending_time_by_state[state]
|
||||
return self._state_ts + pending_time > dt_util.utcnow()
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""One or more characters."""
|
||||
@@ -157,44 +198,47 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel):
|
||||
if not self._validate_code(code, STATE_ALARM_ARMED_HOME):
|
||||
return
|
||||
|
||||
self._state = STATE_ALARM_ARMED_HOME
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
if self._pending_time:
|
||||
track_point_in_time(
|
||||
self._hass, self.async_update_ha_state,
|
||||
self._state_ts + self._pending_time)
|
||||
self._update_state(STATE_ALARM_ARMED_HOME)
|
||||
|
||||
def alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
if not self._validate_code(code, STATE_ALARM_ARMED_AWAY):
|
||||
return
|
||||
|
||||
self._state = STATE_ALARM_ARMED_AWAY
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.schedule_update_ha_state()
|
||||
self._update_state(STATE_ALARM_ARMED_AWAY)
|
||||
|
||||
if self._pending_time:
|
||||
track_point_in_time(
|
||||
self._hass, self.async_update_ha_state,
|
||||
self._state_ts + self._pending_time)
|
||||
def alarm_arm_night(self, code=None):
|
||||
"""Send arm night command."""
|
||||
if not self._validate_code(code, STATE_ALARM_ARMED_NIGHT):
|
||||
return
|
||||
|
||||
self._update_state(STATE_ALARM_ARMED_NIGHT)
|
||||
|
||||
def alarm_trigger(self, code=None):
|
||||
"""Send alarm trigger command. No code needed."""
|
||||
self._pre_trigger_state = self._state
|
||||
self._state = STATE_ALARM_TRIGGERED
|
||||
|
||||
self._update_state(STATE_ALARM_TRIGGERED)
|
||||
|
||||
def _update_state(self, state):
|
||||
self._state = state
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
if self._trigger_time:
|
||||
pending_time = self._pending_time_by_state[state]
|
||||
|
||||
if state == STATE_ALARM_TRIGGERED and self._trigger_time:
|
||||
track_point_in_time(
|
||||
self._hass, self.async_update_ha_state,
|
||||
self._state_ts + self._pending_time)
|
||||
self._state_ts + pending_time)
|
||||
|
||||
track_point_in_time(
|
||||
self._hass, self.async_update_ha_state,
|
||||
self._state_ts + self._pending_time + self._trigger_time)
|
||||
self._state_ts + self._trigger_time + pending_time)
|
||||
elif state in SUPPORTED_PENDING_STATES and pending_time:
|
||||
track_point_in_time(
|
||||
self._hass, self.async_update_ha_state,
|
||||
self._state_ts + pending_time)
|
||||
|
||||
def _validate_code(self, code, state):
|
||||
"""Validate given code."""
|
||||
@@ -203,6 +247,16 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel):
|
||||
_LOGGER.warning("Invalid code given for %s", state)
|
||||
return check
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
state_attr = {}
|
||||
|
||||
if self.state == STATE_ALARM_PENDING:
|
||||
state_attr[ATTR_POST_PENDING_STATE] = self._state
|
||||
|
||||
return state_attr
|
||||
|
||||
def async_added_to_hass(self):
|
||||
"""Subscribe mqtt events.
|
||||
|
||||
@@ -221,6 +275,8 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel):
|
||||
self.async_alarm_arm_home(self._code)
|
||||
elif payload == self._payload_arm_away:
|
||||
self.async_alarm_arm_away(self._code)
|
||||
elif payload == self._payload_arm_night:
|
||||
self.async_alarm_arm_night(self._code)
|
||||
else:
|
||||
_LOGGER.warning("Received unexpected payload: %s", payload)
|
||||
return
|
||||
|
||||
@@ -87,7 +87,7 @@ class MqttAlarm(alarm.AlarmControlPanel):
|
||||
_LOGGER.warning("Received unexpected payload: %s", payload)
|
||||
return
|
||||
self._state = payload
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
return mqtt.async_subscribe(
|
||||
self.hass, self._state_topic, message_received, self._qos)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Support for Satel Integra alarm, using ETHM module: https://www.satel.pl/en/ .
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.satel_integra/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
from homeassistant.components.satel_integra import (CONF_ARM_HOME_MODE,
|
||||
DATA_SATEL,
|
||||
SIGNAL_PANEL_MESSAGE)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['satel_integra']
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up for AlarmDecoder alarm panels."""
|
||||
if not discovery_info:
|
||||
return
|
||||
|
||||
device = SatelIntegraAlarmPanel("Alarm Panel",
|
||||
discovery_info.get(CONF_ARM_HOME_MODE))
|
||||
async_add_devices([device])
|
||||
|
||||
|
||||
class SatelIntegraAlarmPanel(alarm.AlarmControlPanel):
|
||||
"""Representation of an AlarmDecoder-based alarm panel."""
|
||||
|
||||
def __init__(self, name, arm_home_mode):
|
||||
"""Initialize the alarm panel."""
|
||||
self._name = name
|
||||
self._state = None
|
||||
self._arm_home_mode = arm_home_mode
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback)
|
||||
|
||||
@callback
|
||||
def _message_callback(self, message):
|
||||
|
||||
if message != self._state:
|
||||
self._state = message
|
||||
self.async_schedule_update_ha_state()
|
||||
else:
|
||||
_LOGGER.warning("Ignoring alarm status message, same state")
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return the polling state."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def code_format(self):
|
||||
"""Return the regex for code format or None if no code is required."""
|
||||
return '^\\d{4,6}$'
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
return self._state
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_disarm(self, code=None):
|
||||
"""Send disarm command."""
|
||||
if code:
|
||||
yield from self.hass.data[DATA_SATEL].disarm(code)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_arm_away(self, code=None):
|
||||
"""Send arm away command."""
|
||||
if code:
|
||||
yield from self.hass.data[DATA_SATEL].arm(code)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_alarm_arm_home(self, code=None):
|
||||
"""Send arm home command."""
|
||||
if code:
|
||||
yield from self.hass.data[DATA_SATEL].arm(code,
|
||||
self._arm_home_mode)
|
||||
@@ -27,20 +27,20 @@ def _get_alarm_state(spc_mode):
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_entities,
|
||||
def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up the SPC alarm control panel platform."""
|
||||
if (discovery_info is None or
|
||||
discovery_info[ATTR_DISCOVER_AREAS] is None):
|
||||
return
|
||||
|
||||
entities = [SpcAlarm(hass=hass,
|
||||
area_id=area['id'],
|
||||
name=area['name'],
|
||||
state=_get_alarm_state(area['mode']))
|
||||
for area in discovery_info[ATTR_DISCOVER_AREAS]]
|
||||
devices = [SpcAlarm(hass=hass,
|
||||
area_id=area['id'],
|
||||
name=area['name'],
|
||||
state=_get_alarm_state(area['mode']))
|
||||
for area in discovery_info[ATTR_DISCOVER_AREAS]]
|
||||
|
||||
async_add_entities(entities)
|
||||
async_add_devices(devices)
|
||||
|
||||
|
||||
class SpcAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""
|
||||
Support for Alexa skill service end point.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/alexa/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
DOMAIN, CONF_UID, CONF_TITLE, CONF_AUDIO, CONF_TEXT, CONF_DISPLAY_URL)
|
||||
from . import flash_briefings, intent
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
CONF_FLASH_BRIEFINGS = 'flash_briefings'
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: {
|
||||
CONF_FLASH_BRIEFINGS: {
|
||||
cv.string: vol.All(cv.ensure_list, [{
|
||||
vol.Optional(CONF_UID): cv.string,
|
||||
vol.Required(CONF_TITLE): cv.template,
|
||||
vol.Optional(CONF_AUDIO): cv.template,
|
||||
vol.Required(CONF_TEXT, default=""): cv.template,
|
||||
vol.Optional(CONF_DISPLAY_URL): cv.template,
|
||||
}]),
|
||||
}
|
||||
}
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Activate Alexa component."""
|
||||
config = config.get(DOMAIN, {})
|
||||
flash_briefings_config = config.get(CONF_FLASH_BRIEFINGS)
|
||||
|
||||
intent.async_setup(hass)
|
||||
|
||||
if flash_briefings_config:
|
||||
flash_briefings.async_setup(hass, flash_briefings_config)
|
||||
|
||||
return True
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Constants for the Alexa integration."""
|
||||
DOMAIN = 'alexa'
|
||||
|
||||
# Flash briefing constants
|
||||
CONF_UID = 'uid'
|
||||
CONF_TITLE = 'title'
|
||||
CONF_AUDIO = 'audio'
|
||||
CONF_TEXT = 'text'
|
||||
CONF_DISPLAY_URL = 'display_url'
|
||||
|
||||
ATTR_UID = 'uid'
|
||||
ATTR_UPDATE_DATE = 'updateDate'
|
||||
ATTR_TITLE_TEXT = 'titleText'
|
||||
ATTR_STREAM_URL = 'streamUrl'
|
||||
ATTR_MAIN_TEXT = 'mainText'
|
||||
ATTR_REDIRECTION_URL = 'redirectionURL'
|
||||
|
||||
DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z'
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Support for Alexa skill service end point.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/alexa/
|
||||
"""
|
||||
import copy
|
||||
import logging
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import template
|
||||
from homeassistant.components import http
|
||||
|
||||
from .const import (
|
||||
CONF_UID, CONF_TITLE, CONF_AUDIO, CONF_TEXT, CONF_DISPLAY_URL, ATTR_UID,
|
||||
ATTR_UPDATE_DATE, ATTR_TITLE_TEXT, ATTR_STREAM_URL, ATTR_MAIN_TEXT,
|
||||
ATTR_REDIRECTION_URL, DATE_FORMAT)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}'
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup(hass, flash_briefing_config):
|
||||
"""Activate Alexa component."""
|
||||
hass.http.register_view(
|
||||
AlexaFlashBriefingView(hass, flash_briefing_config))
|
||||
|
||||
|
||||
class AlexaFlashBriefingView(http.HomeAssistantView):
|
||||
"""Handle Alexa Flash Briefing skill requests."""
|
||||
|
||||
url = FLASH_BRIEFINGS_API_ENDPOINT
|
||||
name = 'api:alexa:flash_briefings'
|
||||
|
||||
def __init__(self, hass, flash_briefings):
|
||||
"""Initialize Alexa view."""
|
||||
super().__init__()
|
||||
self.flash_briefings = copy.deepcopy(flash_briefings)
|
||||
template.attach(hass, self.flash_briefings)
|
||||
|
||||
@callback
|
||||
def get(self, request, briefing_id):
|
||||
"""Handle Alexa Flash Briefing request."""
|
||||
_LOGGER.debug('Received Alexa flash briefing request for: %s',
|
||||
briefing_id)
|
||||
|
||||
if self.flash_briefings.get(briefing_id) is None:
|
||||
err = 'No configured Alexa flash briefing was found for: %s'
|
||||
_LOGGER.error(err, briefing_id)
|
||||
return b'', 404
|
||||
|
||||
briefing = []
|
||||
|
||||
for item in self.flash_briefings.get(briefing_id, []):
|
||||
output = {}
|
||||
if item.get(CONF_TITLE) is not None:
|
||||
if isinstance(item.get(CONF_TITLE), template.Template):
|
||||
output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render()
|
||||
else:
|
||||
output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE)
|
||||
|
||||
if item.get(CONF_TEXT) is not None:
|
||||
if isinstance(item.get(CONF_TEXT), template.Template):
|
||||
output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render()
|
||||
else:
|
||||
output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT)
|
||||
|
||||
uid = item.get(CONF_UID)
|
||||
if uid is None:
|
||||
uid = str(uuid.uuid4())
|
||||
output[ATTR_UID] = uid
|
||||
|
||||
if item.get(CONF_AUDIO) is not None:
|
||||
if isinstance(item.get(CONF_AUDIO), template.Template):
|
||||
output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render()
|
||||
else:
|
||||
output[ATTR_STREAM_URL] = item.get(CONF_AUDIO)
|
||||
|
||||
if item.get(CONF_DISPLAY_URL) is not None:
|
||||
if isinstance(item.get(CONF_DISPLAY_URL),
|
||||
template.Template):
|
||||
output[ATTR_REDIRECTION_URL] = \
|
||||
item[CONF_DISPLAY_URL].async_render()
|
||||
else:
|
||||
output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL)
|
||||
|
||||
output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT)
|
||||
|
||||
briefing.append(output)
|
||||
|
||||
return self.json(briefing)
|
||||
@@ -5,52 +5,19 @@ For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/alexa/
|
||||
"""
|
||||
import asyncio
|
||||
import copy
|
||||
import enum
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import HTTP_BAD_REQUEST
|
||||
from homeassistant.helpers import intent, template, config_validation as cv
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.components import http
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
from .const import DOMAIN
|
||||
|
||||
INTENTS_API_ENDPOINT = '/api/alexa'
|
||||
FLASH_BRIEFINGS_API_ENDPOINT = '/api/alexa/flash_briefings/{briefing_id}'
|
||||
|
||||
CONF_ACTION = 'action'
|
||||
CONF_CARD = 'card'
|
||||
CONF_INTENTS = 'intents'
|
||||
CONF_SPEECH = 'speech'
|
||||
|
||||
CONF_TYPE = 'type'
|
||||
CONF_TITLE = 'title'
|
||||
CONF_CONTENT = 'content'
|
||||
CONF_TEXT = 'text'
|
||||
|
||||
CONF_FLASH_BRIEFINGS = 'flash_briefings'
|
||||
CONF_UID = 'uid'
|
||||
CONF_TITLE = 'title'
|
||||
CONF_AUDIO = 'audio'
|
||||
CONF_TEXT = 'text'
|
||||
CONF_DISPLAY_URL = 'display_url'
|
||||
|
||||
ATTR_UID = 'uid'
|
||||
ATTR_UPDATE_DATE = 'updateDate'
|
||||
ATTR_TITLE_TEXT = 'titleText'
|
||||
ATTR_STREAM_URL = 'streamUrl'
|
||||
ATTR_MAIN_TEXT = 'mainText'
|
||||
ATTR_REDIRECTION_URL = 'redirectionURL'
|
||||
|
||||
DATE_FORMAT = '%Y-%m-%dT%H:%M:%S.0Z'
|
||||
|
||||
DOMAIN = 'alexa'
|
||||
DEPENDENCIES = ['http']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SpeechType(enum.Enum):
|
||||
@@ -73,30 +40,10 @@ class CardType(enum.Enum):
|
||||
link_account = "LinkAccount"
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: {
|
||||
CONF_FLASH_BRIEFINGS: {
|
||||
cv.string: vol.All(cv.ensure_list, [{
|
||||
vol.Required(CONF_UID, default=str(uuid.uuid4())): cv.string,
|
||||
vol.Required(CONF_TITLE): cv.template,
|
||||
vol.Optional(CONF_AUDIO): cv.template,
|
||||
vol.Required(CONF_TEXT, default=""): cv.template,
|
||||
vol.Optional(CONF_DISPLAY_URL): cv.template,
|
||||
}]),
|
||||
}
|
||||
}
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
@callback
|
||||
def async_setup(hass):
|
||||
"""Activate Alexa component."""
|
||||
flash_briefings = config[DOMAIN].get(CONF_FLASH_BRIEFINGS, {})
|
||||
|
||||
hass.http.register_view(AlexaIntentsView)
|
||||
hass.http.register_view(AlexaFlashBriefingView(hass, flash_briefings))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class AlexaIntentsView(http.HomeAssistantView):
|
||||
@@ -255,66 +202,3 @@ class AlexaResponse(object):
|
||||
'sessionAttributes': self.session_attributes,
|
||||
'response': response,
|
||||
}
|
||||
|
||||
|
||||
class AlexaFlashBriefingView(http.HomeAssistantView):
|
||||
"""Handle Alexa Flash Briefing skill requests."""
|
||||
|
||||
url = FLASH_BRIEFINGS_API_ENDPOINT
|
||||
name = 'api:alexa:flash_briefings'
|
||||
|
||||
def __init__(self, hass, flash_briefings):
|
||||
"""Initialize Alexa view."""
|
||||
super().__init__()
|
||||
self.flash_briefings = copy.deepcopy(flash_briefings)
|
||||
template.attach(hass, self.flash_briefings)
|
||||
|
||||
@callback
|
||||
def get(self, request, briefing_id):
|
||||
"""Handle Alexa Flash Briefing request."""
|
||||
_LOGGER.debug('Received Alexa flash briefing request for: %s',
|
||||
briefing_id)
|
||||
|
||||
if self.flash_briefings.get(briefing_id) is None:
|
||||
err = 'No configured Alexa flash briefing was found for: %s'
|
||||
_LOGGER.error(err, briefing_id)
|
||||
return b'', 404
|
||||
|
||||
briefing = []
|
||||
|
||||
for item in self.flash_briefings.get(briefing_id, []):
|
||||
output = {}
|
||||
if item.get(CONF_TITLE) is not None:
|
||||
if isinstance(item.get(CONF_TITLE), template.Template):
|
||||
output[ATTR_TITLE_TEXT] = item[CONF_TITLE].async_render()
|
||||
else:
|
||||
output[ATTR_TITLE_TEXT] = item.get(CONF_TITLE)
|
||||
|
||||
if item.get(CONF_TEXT) is not None:
|
||||
if isinstance(item.get(CONF_TEXT), template.Template):
|
||||
output[ATTR_MAIN_TEXT] = item[CONF_TEXT].async_render()
|
||||
else:
|
||||
output[ATTR_MAIN_TEXT] = item.get(CONF_TEXT)
|
||||
|
||||
if item.get(CONF_UID) is not None:
|
||||
output[ATTR_UID] = item.get(CONF_UID)
|
||||
|
||||
if item.get(CONF_AUDIO) is not None:
|
||||
if isinstance(item.get(CONF_AUDIO), template.Template):
|
||||
output[ATTR_STREAM_URL] = item[CONF_AUDIO].async_render()
|
||||
else:
|
||||
output[ATTR_STREAM_URL] = item.get(CONF_AUDIO)
|
||||
|
||||
if item.get(CONF_DISPLAY_URL) is not None:
|
||||
if isinstance(item.get(CONF_DISPLAY_URL),
|
||||
template.Template):
|
||||
output[ATTR_REDIRECTION_URL] = \
|
||||
item[CONF_DISPLAY_URL].async_render()
|
||||
else:
|
||||
output[ATTR_REDIRECTION_URL] = item.get(CONF_DISPLAY_URL)
|
||||
|
||||
output[ATTR_UPDATE_DATE] = datetime.now().strftime(DATE_FORMAT)
|
||||
|
||||
briefing.append(output)
|
||||
|
||||
return self.json(briefing)
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Support for alexa Smart Home Skill API."""
|
||||
import asyncio
|
||||
import logging
|
||||
from uuid import uuid4
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF)
|
||||
from homeassistant.components import switch, light
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
HANDLERS = Registry()
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_HEADER = 'header'
|
||||
ATTR_NAME = 'name'
|
||||
ATTR_NAMESPACE = 'namespace'
|
||||
ATTR_MESSAGE_ID = 'messageId'
|
||||
ATTR_PAYLOAD = 'payload'
|
||||
ATTR_PAYLOAD_VERSION = 'payloadVersion'
|
||||
|
||||
|
||||
MAPPING_COMPONENT = {
|
||||
switch.DOMAIN: ['SWITCH', ('turnOff', 'turnOn'), None],
|
||||
light.DOMAIN: [
|
||||
'LIGHT', ('turnOff', 'turnOn'), {
|
||||
light.SUPPORT_BRIGHTNESS: 'setPercentage'
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_message(hass, message):
|
||||
"""Handle incoming API messages."""
|
||||
assert int(message[ATTR_HEADER][ATTR_PAYLOAD_VERSION]) == 2
|
||||
|
||||
# Do we support this API request?
|
||||
funct_ref = HANDLERS.get(message[ATTR_HEADER][ATTR_NAME])
|
||||
if not funct_ref:
|
||||
_LOGGER.warning(
|
||||
"Unsupported API request %s", message[ATTR_HEADER][ATTR_NAME])
|
||||
return api_error(message)
|
||||
|
||||
return (yield from funct_ref(hass, message))
|
||||
|
||||
|
||||
def api_message(name, namespace, payload=None):
|
||||
"""Create a API formatted response message.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
payload = payload or {}
|
||||
return {
|
||||
ATTR_HEADER: {
|
||||
ATTR_MESSAGE_ID: str(uuid4()),
|
||||
ATTR_NAME: name,
|
||||
ATTR_NAMESPACE: namespace,
|
||||
ATTR_PAYLOAD_VERSION: '2',
|
||||
},
|
||||
ATTR_PAYLOAD: payload,
|
||||
}
|
||||
|
||||
|
||||
def api_error(request, exc='DriverInternalError'):
|
||||
"""Create a API formatted error response.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return api_message(exc, request[ATTR_HEADER][ATTR_NAMESPACE])
|
||||
|
||||
|
||||
@HANDLERS.register('DiscoverAppliancesRequest')
|
||||
@asyncio.coroutine
|
||||
def async_api_discovery(hass, request):
|
||||
"""Create a API formatted discovery response.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
discovered_appliances = []
|
||||
|
||||
for entity in hass.states.async_all():
|
||||
class_data = MAPPING_COMPONENT.get(entity.domain)
|
||||
|
||||
if not class_data:
|
||||
continue
|
||||
|
||||
appliance = {
|
||||
'actions': [],
|
||||
'applianceTypes': [class_data[0]],
|
||||
'additionalApplianceDetails': {},
|
||||
'applianceId': entity.entity_id.replace('.', '#'),
|
||||
'friendlyDescription': '',
|
||||
'friendlyName': entity.name,
|
||||
'isReachable': True,
|
||||
'manufacturerName': 'Unknown',
|
||||
'modelName': 'Unknown',
|
||||
'version': 'Unknown',
|
||||
}
|
||||
|
||||
# static actions
|
||||
if class_data[1]:
|
||||
appliance['actions'].extend(list(class_data[1]))
|
||||
|
||||
# dynamic actions
|
||||
if class_data[2]:
|
||||
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
for feature, action_name in class_data[2].items():
|
||||
if feature & supported > 0:
|
||||
appliance['actions'].append(action_name)
|
||||
|
||||
discovered_appliances.append(appliance)
|
||||
|
||||
return api_message(
|
||||
'DiscoverAppliancesResponse', 'Alexa.ConnectedHome.Discovery',
|
||||
payload={'discoveredAppliances': discovered_appliances})
|
||||
|
||||
|
||||
def extract_entity(funct):
|
||||
"""Decorator for extract entity object from request."""
|
||||
@asyncio.coroutine
|
||||
def async_api_entity_wrapper(hass, request):
|
||||
"""Process a turn on request."""
|
||||
entity_id = \
|
||||
request[ATTR_PAYLOAD]['appliance']['applianceId'].replace('#', '.')
|
||||
|
||||
# extract state object
|
||||
entity = hass.states.get(entity_id)
|
||||
if not entity:
|
||||
_LOGGER.error("Can't process %s for %s",
|
||||
request[ATTR_HEADER][ATTR_NAME], entity_id)
|
||||
return api_error(request)
|
||||
|
||||
return (yield from funct(hass, request, entity))
|
||||
|
||||
return async_api_entity_wrapper
|
||||
|
||||
|
||||
@HANDLERS.register('TurnOnRequest')
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_turn_on(hass, request, entity):
|
||||
"""Process a turn on request."""
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=True)
|
||||
|
||||
return api_message('TurnOnConfirmation', 'Alexa.ConnectedHome.Control')
|
||||
|
||||
|
||||
@HANDLERS.register('TurnOffRequest')
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_turn_off(hass, request, entity):
|
||||
"""Process a turn off request."""
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_OFF, {
|
||||
ATTR_ENTITY_ID: entity.entity_id
|
||||
}, blocking=True)
|
||||
|
||||
return api_message('TurnOffConfirmation', 'Alexa.ConnectedHome.Control')
|
||||
|
||||
|
||||
@HANDLERS.register('SetPercentageRequest')
|
||||
@extract_entity
|
||||
@asyncio.coroutine
|
||||
def async_api_set_percentage(hass, request, entity):
|
||||
"""Process a set percentage request."""
|
||||
if entity.domain == light.DOMAIN:
|
||||
brightness = request[ATTR_PAYLOAD]['percentageState']['value']
|
||||
yield from hass.services.async_call(entity.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: entity.entity_id,
|
||||
light.ATTR_BRIGHTNESS: brightness,
|
||||
}, blocking=True)
|
||||
else:
|
||||
return api_error(request)
|
||||
|
||||
return api_message(
|
||||
'SetPercentageConfirmation', 'Alexa.ConnectedHome.Control')
|
||||
@@ -263,7 +263,7 @@ class AndroidIPCamEntity(Entity):
|
||||
"""Update callback."""
|
||||
if self._host != host:
|
||||
return
|
||||
self.hass.async_add_job(self.async_update_ha_state(True))
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_UPDATE_DATA, async_ipcam_update)
|
||||
|
||||
@@ -13,7 +13,7 @@ import async_timeout
|
||||
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.remote as rem
|
||||
from homeassistant.bootstrap import ERROR_LOG_FILENAME
|
||||
from homeassistant.bootstrap import DATA_LOGGING
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED,
|
||||
HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND,
|
||||
@@ -51,8 +51,9 @@ def setup(hass, config):
|
||||
hass.http.register_view(APIComponentsView)
|
||||
hass.http.register_view(APITemplateView)
|
||||
|
||||
hass.http.register_static_path(
|
||||
URL_API_ERROR_LOG, hass.config.path(ERROR_LOG_FILENAME), False)
|
||||
log_path = hass.data.get(DATA_LOGGING, None)
|
||||
if log_path:
|
||||
hass.http.register_static_path(URL_API_ERROR_LOG, log_path, False)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from typing import Union, TypeVar, Sequence
|
||||
from homeassistant.const import (CONF_HOST, CONF_NAME, ATTR_ENTITY_ID)
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -17,7 +18,7 @@ from homeassistant.helpers import discovery
|
||||
from homeassistant.components.discovery import SERVICE_APPLE_TV
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['pyatv==0.3.4']
|
||||
REQUIREMENTS = ['pyatv==0.3.5']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,8 +46,19 @@ NOTIFICATION_AUTH_TITLE = 'Apple TV Authentication'
|
||||
NOTIFICATION_SCAN_ID = 'apple_tv_scan_notification'
|
||||
NOTIFICATION_SCAN_TITLE = 'Apple TV Scan'
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
# This version of ensure_list interprets an empty dict as no value
|
||||
def ensure_list(value: Union[T, Sequence[T]]) -> Sequence[T]:
|
||||
"""Wrap value in list if it is not one."""
|
||||
if value is None or (isinstance(value, dict) and not value):
|
||||
return []
|
||||
return value if isinstance(value, list) else [value]
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.All(cv.ensure_list, [vol.Schema({
|
||||
DOMAIN: vol.All(ensure_list, [vol.Schema({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_LOGIN_ID): cv.string,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
@@ -133,6 +145,10 @@ def async_setup(hass, config):
|
||||
"""Handler for service calls."""
|
||||
entity_ids = service.data.get(ATTR_ENTITY_ID)
|
||||
|
||||
if service.service == SERVICE_SCAN:
|
||||
hass.async_add_job(scan_for_apple_tvs, hass)
|
||||
return
|
||||
|
||||
if entity_ids:
|
||||
devices = [device for device in hass.data[DATA_ENTITIES]
|
||||
if device.entity_id in entity_ids]
|
||||
@@ -140,16 +156,16 @@ def async_setup(hass, config):
|
||||
devices = hass.data[DATA_ENTITIES]
|
||||
|
||||
for device in devices:
|
||||
if service.service != SERVICE_AUTHENTICATE:
|
||||
continue
|
||||
|
||||
atv = device.atv
|
||||
if service.service == SERVICE_AUTHENTICATE:
|
||||
credentials = yield from atv.airplay.generate_credentials()
|
||||
yield from atv.airplay.load_credentials(credentials)
|
||||
_LOGGER.debug('Generated new credentials: %s', credentials)
|
||||
yield from atv.airplay.start_authentication()
|
||||
hass.async_add_job(request_configuration,
|
||||
hass, config, atv, credentials)
|
||||
elif service.service == SERVICE_SCAN:
|
||||
hass.async_add_job(scan_for_apple_tvs, hass)
|
||||
credentials = yield from atv.airplay.generate_credentials()
|
||||
yield from atv.airplay.load_credentials(credentials)
|
||||
_LOGGER.debug('Generated new credentials: %s', credentials)
|
||||
yield from atv.airplay.start_authentication()
|
||||
hass.async_add_job(request_configuration,
|
||||
hass, config, atv, credentials)
|
||||
|
||||
@asyncio.coroutine
|
||||
def atv_discovered(service, info):
|
||||
|
||||
@@ -12,7 +12,7 @@ from requests.exceptions import HTTPError, ConnectTimeout
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.const import CONF_USERNAME, CONF_PASSWORD
|
||||
|
||||
REQUIREMENTS = ['pyarlo==0.0.4']
|
||||
REQUIREMENTS = ['pyarlo==0.0.6']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import voluptuous as vol
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.const import (ATTR_LOCATION, ATTR_TRIPPED,
|
||||
CONF_HOST, CONF_INCLUDE, CONF_NAME,
|
||||
CONF_PASSWORD, CONF_TRIGGER_TIME,
|
||||
CONF_PASSWORD, CONF_PORT, CONF_TRIGGER_TIME,
|
||||
CONF_USERNAME, EVENT_HOMEASSISTANT_STOP)
|
||||
from homeassistant.components.discovery import SERVICE_AXIS
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
@@ -23,7 +23,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
|
||||
REQUIREMENTS = ['axis==8']
|
||||
REQUIREMENTS = ['axis==12']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -51,6 +51,7 @@ DEVICE_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_USERNAME, default=AXIS_DEFAULT_USERNAME): cv.string,
|
||||
vol.Optional(CONF_PASSWORD, default=AXIS_DEFAULT_PASSWORD): cv.string,
|
||||
vol.Optional(CONF_TRIGGER_TIME, default=0): cv.positive_int,
|
||||
vol.Optional(CONF_PORT, default=80): cv.positive_int,
|
||||
vol.Optional(ATTR_LOCATION, default=''): cv.string,
|
||||
})
|
||||
|
||||
@@ -76,7 +77,7 @@ SERVICE_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
def request_configuration(hass, name, host, serialnumber):
|
||||
def request_configuration(hass, config, name, host, serialnumber):
|
||||
"""Request configuration steps from the user."""
|
||||
configurator = hass.components.configurator
|
||||
|
||||
@@ -91,15 +92,15 @@ def request_configuration(hass, name, host, serialnumber):
|
||||
if CONF_NAME not in callback_data:
|
||||
callback_data[CONF_NAME] = name
|
||||
try:
|
||||
config = DEVICE_SCHEMA(callback_data)
|
||||
device_config = DEVICE_SCHEMA(callback_data)
|
||||
except vol.Invalid:
|
||||
configurator.notify_errors(request_id,
|
||||
"Bad input, please check spelling.")
|
||||
return False
|
||||
|
||||
if setup_device(hass, config):
|
||||
if setup_device(hass, config, device_config):
|
||||
config_file = _read_config(hass)
|
||||
config_file[serialnumber] = dict(config)
|
||||
config_file[serialnumber] = dict(device_config)
|
||||
del config_file[serialnumber]['hass']
|
||||
_write_config(hass, config_file)
|
||||
configurator.request_done(request_id)
|
||||
@@ -132,6 +133,9 @@ def request_configuration(hass, name, host, serialnumber):
|
||||
{'id': ATTR_LOCATION,
|
||||
'name': "Physical location of device (optional)",
|
||||
'type': 'text'},
|
||||
{'id': CONF_PORT,
|
||||
'name': "HTTP port (default=80)",
|
||||
'type': 'number'},
|
||||
{'id': CONF_TRIGGER_TIME,
|
||||
'name': "Sensor update interval (optional)",
|
||||
'type': 'number'},
|
||||
@@ -139,7 +143,7 @@ def request_configuration(hass, name, host, serialnumber):
|
||||
)
|
||||
|
||||
|
||||
def setup(hass, base_config):
|
||||
def setup(hass, config):
|
||||
"""Common setup for Axis devices."""
|
||||
def _shutdown(call): # pylint: disable=unused-argument
|
||||
"""Stop the metadatastream on shutdown."""
|
||||
@@ -160,16 +164,17 @@ def setup(hass, base_config):
|
||||
if serialnumber in config_file:
|
||||
# Device config saved to file
|
||||
try:
|
||||
config = DEVICE_SCHEMA(config_file[serialnumber])
|
||||
config[CONF_HOST] = host
|
||||
device_config = DEVICE_SCHEMA(config_file[serialnumber])
|
||||
device_config[CONF_HOST] = host
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.error("Bad data from %s. %s", CONFIG_FILE, err)
|
||||
return False
|
||||
if not setup_device(hass, config):
|
||||
_LOGGER.error("Couldn\'t set up %s", config[CONF_NAME])
|
||||
if not setup_device(hass, config, device_config):
|
||||
_LOGGER.error("Couldn\'t set up %s",
|
||||
device_config[CONF_NAME])
|
||||
else:
|
||||
# New device, create configuration request for UI
|
||||
request_configuration(hass, name, host, serialnumber)
|
||||
request_configuration(hass, config, name, host, serialnumber)
|
||||
else:
|
||||
# Device already registered, but on a different IP
|
||||
device = AXIS_DEVICES[serialnumber]
|
||||
@@ -181,13 +186,13 @@ def setup(hass, base_config):
|
||||
# Register discovery service
|
||||
discovery.listen(hass, SERVICE_AXIS, axis_device_discovered)
|
||||
|
||||
if DOMAIN in base_config:
|
||||
for device in base_config[DOMAIN]:
|
||||
config = base_config[DOMAIN][device]
|
||||
if CONF_NAME not in config:
|
||||
config[CONF_NAME] = device
|
||||
if not setup_device(hass, config):
|
||||
_LOGGER.error("Couldn\'t set up %s", config[CONF_NAME])
|
||||
if DOMAIN in config:
|
||||
for device in config[DOMAIN]:
|
||||
device_config = config[DOMAIN][device]
|
||||
if CONF_NAME not in device_config:
|
||||
device_config[CONF_NAME] = device
|
||||
if not setup_device(hass, config, device_config):
|
||||
_LOGGER.error("Couldn\'t set up %s", device_config[CONF_NAME])
|
||||
|
||||
# Services to communicate with device.
|
||||
descriptions = load_yaml_config_file(
|
||||
@@ -215,20 +220,20 @@ def setup(hass, base_config):
|
||||
return True
|
||||
|
||||
|
||||
def setup_device(hass, config):
|
||||
def setup_device(hass, config, device_config):
|
||||
"""Set up device."""
|
||||
from axis import AxisDevice
|
||||
|
||||
config['hass'] = hass
|
||||
device = AxisDevice(config) # Initialize device
|
||||
device_config['hass'] = hass
|
||||
device = AxisDevice(device_config) # Initialize device
|
||||
enable_metadatastream = False
|
||||
|
||||
if device.serial_number is None:
|
||||
# If there is no serial number a connection could not be made
|
||||
_LOGGER.error("Couldn\'t connect to %s", config[CONF_HOST])
|
||||
_LOGGER.error("Couldn\'t connect to %s", device_config[CONF_HOST])
|
||||
return False
|
||||
|
||||
for component in config[CONF_INCLUDE]:
|
||||
for component in device_config[CONF_INCLUDE]:
|
||||
if component in EVENT_TYPES:
|
||||
# Sensors are created by device calling event_initialized
|
||||
# when receiving initialize messages on metadatastream
|
||||
@@ -236,7 +241,18 @@ def setup_device(hass, config):
|
||||
if not enable_metadatastream:
|
||||
enable_metadatastream = True
|
||||
else:
|
||||
discovery.load_platform(hass, component, DOMAIN, config)
|
||||
camera_config = {
|
||||
CONF_HOST: device_config[CONF_HOST],
|
||||
CONF_NAME: device_config[CONF_NAME],
|
||||
CONF_PORT: device_config[CONF_PORT],
|
||||
CONF_USERNAME: device_config[CONF_USERNAME],
|
||||
CONF_PASSWORD: device_config[CONF_PASSWORD]
|
||||
}
|
||||
discovery.load_platform(hass,
|
||||
component,
|
||||
DOMAIN,
|
||||
camera_config,
|
||||
config)
|
||||
|
||||
if enable_metadatastream:
|
||||
device.initialize_new_event = event_initialized
|
||||
|
||||
@@ -6,7 +6,8 @@ https://home-assistant.io/components/binary_sensor.abode/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.abode import AbodeDevice, DATA_ABODE
|
||||
from homeassistant.components.abode import (AbodeDevice, AbodeAutomation,
|
||||
DOMAIN as ABODE_DOMAIN)
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
|
||||
|
||||
@@ -17,39 +18,38 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up a sensor for an Abode device."""
|
||||
abode = hass.data[DATA_ABODE]
|
||||
|
||||
device_types = map_abode_device_class().keys()
|
||||
|
||||
sensors = []
|
||||
for sensor in abode.get_devices(type_filter=device_types):
|
||||
sensors.append(AbodeBinarySensor(abode, sensor))
|
||||
|
||||
add_devices(sensors)
|
||||
|
||||
|
||||
def map_abode_device_class():
|
||||
"""Map Abode device types to Home Assistant binary sensor class."""
|
||||
import abodepy.helpers.constants as CONST
|
||||
import abodepy.helpers.timeline as TIMELINE
|
||||
|
||||
return {
|
||||
CONST.DEVICE_GLASS_BREAK: 'connectivity',
|
||||
CONST.DEVICE_KEYPAD: 'connectivity',
|
||||
CONST.DEVICE_DOOR_CONTACT: 'opening',
|
||||
CONST.DEVICE_STATUS_DISPLAY: 'connectivity',
|
||||
CONST.DEVICE_MOTION_CAMERA: 'connectivity',
|
||||
CONST.DEVICE_WATER_SENSOR: 'moisture'
|
||||
}
|
||||
data = hass.data[ABODE_DOMAIN]
|
||||
|
||||
device_types = [CONST.TYPE_CONNECTIVITY, CONST.TYPE_MOISTURE,
|
||||
CONST.TYPE_MOTION, CONST.TYPE_OCCUPANCY,
|
||||
CONST.TYPE_OPENING]
|
||||
|
||||
devices = []
|
||||
for device in data.abode.get_devices(generic_type=device_types):
|
||||
if data.is_excluded(device):
|
||||
continue
|
||||
|
||||
devices.append(AbodeBinarySensor(data, device))
|
||||
|
||||
for automation in data.abode.get_automations(
|
||||
generic_type=CONST.TYPE_QUICK_ACTION):
|
||||
if data.is_automation_excluded(automation):
|
||||
continue
|
||||
|
||||
devices.append(AbodeQuickActionBinarySensor(
|
||||
data, automation, TIMELINE.AUTOMATION_EDIT_GROUP))
|
||||
|
||||
data.devices.extend(devices)
|
||||
|
||||
add_devices(devices)
|
||||
|
||||
|
||||
class AbodeBinarySensor(AbodeDevice, BinarySensorDevice):
|
||||
"""A binary sensor implementation for Abode device."""
|
||||
|
||||
def __init__(self, controller, device):
|
||||
"""Initialize a sensor for Abode device."""
|
||||
AbodeDevice.__init__(self, controller, device)
|
||||
self._device_class = map_abode_device_class().get(self._device.type)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return True if the binary sensor is on."""
|
||||
@@ -58,4 +58,17 @@ class AbodeBinarySensor(AbodeDevice, BinarySensorDevice):
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of the binary sensor."""
|
||||
return self._device_class
|
||||
return self._device.generic_type
|
||||
|
||||
|
||||
class AbodeQuickActionBinarySensor(AbodeAutomation, BinarySensorDevice):
|
||||
"""A binary sensor implementation for Abode quick action automations."""
|
||||
|
||||
def trigger(self):
|
||||
"""Trigger a quick automation."""
|
||||
self._automation.trigger()
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return True if the binary sensor is on."""
|
||||
return self._automation.is_active
|
||||
|
||||
@@ -102,11 +102,11 @@ class AlarmDecoderBinarySensor(BinarySensorDevice):
|
||||
"""Update the zone's state, if needed."""
|
||||
if zone is None or int(zone) == self._zone_number:
|
||||
self._state = 1
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@callback
|
||||
def _restore_callback(self, zone):
|
||||
"""Update the zone's state, if needed."""
|
||||
if zone is None or int(zone) == self._zone_number:
|
||||
self._state = 0
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@@ -102,7 +102,13 @@ class BayesianBinarySensor(BinarySensorDevice):
|
||||
|
||||
self.current_obs = OrderedDict({})
|
||||
|
||||
self.entity_obs = {obs['entity_id']: obs for obs in self._observations}
|
||||
to_observe = set(obs['entity_id'] for obs in self._observations)
|
||||
|
||||
self.entity_obs = dict.fromkeys(to_observe, [])
|
||||
|
||||
for ind, obs in enumerate(self._observations):
|
||||
obs["id"] = ind
|
||||
self.entity_obs[obs['entity_id']].append(obs)
|
||||
|
||||
self.watchers = {
|
||||
'numeric_state': self._process_numeric_state,
|
||||
@@ -120,17 +126,17 @@ class BayesianBinarySensor(BinarySensorDevice):
|
||||
if new_state.state == STATE_UNKNOWN:
|
||||
return
|
||||
|
||||
entity_obs = self.entity_obs[entity]
|
||||
platform = entity_obs['platform']
|
||||
entity_obs_list = self.entity_obs[entity]
|
||||
|
||||
self.watchers[platform](entity_obs)
|
||||
for entity_obs in entity_obs_list:
|
||||
platform = entity_obs['platform']
|
||||
|
||||
self.watchers[platform](entity_obs)
|
||||
|
||||
prior = self.prior
|
||||
print(self.current_obs.values())
|
||||
for obs in self.current_obs.values():
|
||||
prior = update_probability(prior, obs['prob_true'],
|
||||
obs['prob_false'])
|
||||
|
||||
self.probability = prior
|
||||
|
||||
self.hass.async_add_job(self.async_update_ha_state, True)
|
||||
@@ -141,20 +147,20 @@ class BayesianBinarySensor(BinarySensorDevice):
|
||||
|
||||
def _update_current_obs(self, entity_observation, should_trigger):
|
||||
"""Update current observation."""
|
||||
entity = entity_observation['entity_id']
|
||||
obs_id = entity_observation['id']
|
||||
|
||||
if should_trigger:
|
||||
prob_true = entity_observation['prob_given_true']
|
||||
prob_false = entity_observation.get(
|
||||
'prob_given_false', 1 - prob_true)
|
||||
|
||||
self.current_obs[entity] = {
|
||||
self.current_obs[obs_id] = {
|
||||
'prob_true': prob_true,
|
||||
'prob_false': prob_false
|
||||
}
|
||||
|
||||
else:
|
||||
self.current_obs.pop(entity, None)
|
||||
self.current_obs.pop(obs_id, None)
|
||||
|
||||
def _process_numeric_state(self, entity_observation):
|
||||
"""Add entity to current_obs if numeric state conditions are met."""
|
||||
@@ -201,7 +207,7 @@ class BayesianBinarySensor(BinarySensorDevice):
|
||||
"""Return the state attributes of the sensor."""
|
||||
return {
|
||||
'observations': [val for val in self.current_obs.values()],
|
||||
'probability': self.probability,
|
||||
'probability': round(self.probability, 2),
|
||||
'probability_threshold': self._probability_threshold
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Support for reading binary states from a DoorBird video doorbell."""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
DEPENDENCIES = ['doorbird']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_MIN_UPDATE_INTERVAL = timedelta(milliseconds=250)
|
||||
|
||||
SENSOR_TYPES = {
|
||||
"doorbell": {
|
||||
"name": "Doorbell Ringing",
|
||||
"icon": {
|
||||
True: "bell-ring",
|
||||
False: "bell",
|
||||
None: "bell-outline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the DoorBird binary sensor component."""
|
||||
device = hass.data.get(DOORBIRD_DOMAIN)
|
||||
add_devices([DoorBirdBinarySensor(device, "doorbell")], True)
|
||||
|
||||
|
||||
class DoorBirdBinarySensor(BinarySensorDevice):
|
||||
"""A binary sensor of a DoorBird device."""
|
||||
|
||||
def __init__(self, device, sensor_type):
|
||||
"""Initialize a binary sensor on a DoorBird device."""
|
||||
self._device = device
|
||||
self._sensor_type = sensor_type
|
||||
self._state = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Get the name of the sensor."""
|
||||
return SENSOR_TYPES[self._sensor_type]["name"]
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Get an icon to display."""
|
||||
state_icon = SENSOR_TYPES[self._sensor_type]["icon"][self._state]
|
||||
return "mdi:{}".format(state_icon)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Get the state of the binary sensor."""
|
||||
return self._state
|
||||
|
||||
@Throttle(_MIN_UPDATE_INTERVAL)
|
||||
def update(self):
|
||||
"""Pull the latest value from the device."""
|
||||
self._state = self._device.doorbell_state()
|
||||
@@ -80,4 +80,4 @@ class EnvisalinkBinarySensor(EnvisalinkDevice, BinarySensorDevice):
|
||||
def _update_callback(self, zone):
|
||||
"""Update the zone's state, if needed."""
|
||||
if zone is None or int(zone) == self._zone_number:
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@@ -73,7 +73,7 @@ class FFmpegBinarySensor(FFmpegBase, BinarySensorDevice):
|
||||
def _async_callback(self, state):
|
||||
"""HA-FFmpeg callback for noise detection."""
|
||||
self._state = state
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
|
||||
@@ -55,12 +55,12 @@ class InsteonPLMBinarySensorDevice(BinarySensorDevice):
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
"""Return the the address of the node."""
|
||||
"""Return the address of the node."""
|
||||
return self._address
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the the name of the node."""
|
||||
"""Return the name of the node."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
|
||||
@@ -53,7 +53,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, add_devices,
|
||||
def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up binary sensor(s) for KNX platform."""
|
||||
if DATA_KNX not in hass.data \
|
||||
@@ -61,25 +61,25 @@ def async_setup_platform(hass, config, add_devices,
|
||||
return False
|
||||
|
||||
if discovery_info is not None:
|
||||
async_add_devices_discovery(hass, discovery_info, add_devices)
|
||||
async_add_devices_discovery(hass, discovery_info, async_add_devices)
|
||||
else:
|
||||
async_add_devices_config(hass, config, add_devices)
|
||||
async_add_devices_config(hass, config, async_add_devices)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
def async_add_devices_discovery(hass, discovery_info, add_devices):
|
||||
def async_add_devices_discovery(hass, discovery_info, async_add_devices):
|
||||
"""Set up binary sensors for KNX platform configured via xknx.yaml."""
|
||||
entities = []
|
||||
for device_name in discovery_info[ATTR_DISCOVER_DEVICES]:
|
||||
device = hass.data[DATA_KNX].xknx.devices[device_name]
|
||||
entities.append(KNXBinarySensor(hass, device))
|
||||
add_devices(entities)
|
||||
async_add_devices(entities)
|
||||
|
||||
|
||||
@callback
|
||||
def async_add_devices_config(hass, config, add_devices):
|
||||
def async_add_devices_config(hass, config, async_add_devices):
|
||||
"""Set up binary senor for KNX platform configured within plattform."""
|
||||
name = config.get(CONF_NAME)
|
||||
import xknx
|
||||
@@ -101,7 +101,7 @@ def async_add_devices_config(hass, config, add_devices):
|
||||
entity.automations.append(KNXAutomation(
|
||||
hass=hass, device=binary_sensor, hook=hook,
|
||||
action=action, counter=counter))
|
||||
add_devices([entity])
|
||||
async_add_devices([entity])
|
||||
|
||||
|
||||
class KNXBinarySensor(BinarySensorDevice):
|
||||
|
||||
@@ -16,14 +16,21 @@ from homeassistant.components.binary_sensor import (
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF,
|
||||
CONF_DEVICE_CLASS)
|
||||
from homeassistant.components.mqtt import (CONF_STATE_TOPIC, CONF_QOS)
|
||||
from homeassistant.components.mqtt import (
|
||||
CONF_STATE_TOPIC, CONF_AVAILABILITY_TOPIC, CONF_QOS, valid_subscribe_topic)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_PAYLOAD_AVAILABLE = 'payload_available'
|
||||
CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available'
|
||||
|
||||
DEFAULT_NAME = 'MQTT Binary sensor'
|
||||
DEFAULT_PAYLOAD_OFF = 'OFF'
|
||||
DEFAULT_PAYLOAD_ON = 'ON'
|
||||
DEFAULT_PAYLOAD_AVAILABLE = 'online'
|
||||
DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline'
|
||||
|
||||
DEPENDENCIES = ['mqtt']
|
||||
|
||||
PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({
|
||||
@@ -31,6 +38,11 @@ PLATFORM_SCHEMA = mqtt.MQTT_RO_PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string,
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
vol.Optional(CONF_AVAILABILITY_TOPIC): valid_subscribe_topic,
|
||||
vol.Optional(CONF_PAYLOAD_AVAILABLE,
|
||||
default=DEFAULT_PAYLOAD_AVAILABLE): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string,
|
||||
})
|
||||
|
||||
|
||||
@@ -47,10 +59,13 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
async_add_devices([MqttBinarySensor(
|
||||
config.get(CONF_NAME),
|
||||
config.get(CONF_STATE_TOPIC),
|
||||
config.get(CONF_AVAILABILITY_TOPIC),
|
||||
config.get(CONF_DEVICE_CLASS),
|
||||
config.get(CONF_QOS),
|
||||
config.get(CONF_PAYLOAD_ON),
|
||||
config.get(CONF_PAYLOAD_OFF),
|
||||
config.get(CONF_PAYLOAD_AVAILABLE),
|
||||
config.get(CONF_PAYLOAD_NOT_AVAILABLE),
|
||||
value_template
|
||||
)])
|
||||
|
||||
@@ -58,15 +73,20 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
class MqttBinarySensor(BinarySensorDevice):
|
||||
"""Representation a binary sensor that is updated by MQTT."""
|
||||
|
||||
def __init__(self, name, state_topic, device_class, qos, payload_on,
|
||||
payload_off, value_template):
|
||||
def __init__(self, name, state_topic, availability_topic, device_class,
|
||||
qos, payload_on, payload_off, payload_available,
|
||||
payload_not_available, value_template):
|
||||
"""Initialize the MQTT binary sensor."""
|
||||
self._name = name
|
||||
self._state = False
|
||||
self._state = None
|
||||
self._state_topic = state_topic
|
||||
self._availability_topic = availability_topic
|
||||
self._available = True if availability_topic is None else False
|
||||
self._device_class = device_class
|
||||
self._payload_on = payload_on
|
||||
self._payload_off = payload_off
|
||||
self._payload_available = payload_available
|
||||
self._payload_not_available = payload_not_available
|
||||
self._qos = qos
|
||||
self._template = value_template
|
||||
|
||||
@@ -76,8 +96,8 @@ class MqttBinarySensor(BinarySensorDevice):
|
||||
This method must be run in the event loop and returns a coroutine.
|
||||
"""
|
||||
@callback
|
||||
def message_received(topic, payload, qos):
|
||||
"""Handle a new received MQTT message."""
|
||||
def state_message_received(topic, payload, qos):
|
||||
"""Handle a new received MQTT state message."""
|
||||
if self._template is not None:
|
||||
payload = self._template.async_render_with_possible_json_value(
|
||||
payload)
|
||||
@@ -86,10 +106,25 @@ class MqttBinarySensor(BinarySensorDevice):
|
||||
elif payload == self._payload_off:
|
||||
self._state = False
|
||||
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
return mqtt.async_subscribe(
|
||||
self.hass, self._state_topic, message_received, self._qos)
|
||||
yield from mqtt.async_subscribe(
|
||||
self.hass, self._state_topic, state_message_received, self._qos)
|
||||
|
||||
@callback
|
||||
def availability_message_received(topic, payload, qos):
|
||||
"""Handle a new received MQTT availability message."""
|
||||
if payload == self._payload_available:
|
||||
self._available = True
|
||||
elif payload == self._payload_not_available:
|
||||
self._available = False
|
||||
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._availability_topic is not None:
|
||||
yield from mqtt.async_subscribe(
|
||||
self.hass, self._availability_topic,
|
||||
availability_message_received, self._qos)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
@@ -101,6 +136,11 @@ class MqttBinarySensor(BinarySensorDevice):
|
||||
"""Return the name of the binary sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the binary sensor is available."""
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the binary sensor is on."""
|
||||
|
||||
@@ -92,4 +92,4 @@ class MyStromBinarySensor(BinarySensorDevice):
|
||||
def async_on_update(self, value):
|
||||
"""Receive an update."""
|
||||
self._state = value
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Support for Melnor RainCloud sprinkler water timer.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.raincloud/
|
||||
"""
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.raincloud import (
|
||||
BINARY_SENSORS, DATA_RAINCLOUD, ICON_MAP, RainCloudEntity)
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA)
|
||||
from homeassistant.const import CONF_MONITORED_CONDITIONS
|
||||
|
||||
DEPENDENCIES = ['raincloud']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)):
|
||||
vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]),
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up a sensor for a raincloud device."""
|
||||
raincloud = hass.data[DATA_RAINCLOUD].data
|
||||
|
||||
sensors = []
|
||||
for sensor_type in config.get(CONF_MONITORED_CONDITIONS):
|
||||
if sensor_type == 'status':
|
||||
sensors.append(
|
||||
RainCloudBinarySensor(raincloud.controller, sensor_type))
|
||||
sensors.append(
|
||||
RainCloudBinarySensor(raincloud.controller.faucet,
|
||||
sensor_type))
|
||||
|
||||
else:
|
||||
# create an sensor for each zone managed by faucet
|
||||
for zone in raincloud.controller.faucet.zones:
|
||||
sensors.append(RainCloudBinarySensor(zone, sensor_type))
|
||||
|
||||
add_devices(sensors, True)
|
||||
return True
|
||||
|
||||
|
||||
class RainCloudBinarySensor(RainCloudEntity, BinarySensorDevice):
|
||||
"""A sensor implementation for raincloud device."""
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self._state
|
||||
|
||||
def update(self):
|
||||
"""Get the latest data and updates the state."""
|
||||
_LOGGER.debug("Updating RainCloud sensor: %s", self._name)
|
||||
self._state = getattr(self.data, self._sensor_type)
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon of this device."""
|
||||
if self._sensor_type == 'is_watering':
|
||||
return 'mdi:water' if self.is_on else 'mdi:water-off'
|
||||
elif self._sensor_type == 'status':
|
||||
return 'mdi:pipe' if self.is_on else 'mdi:pipe-disconnected'
|
||||
return ICON_MAP.get(self._sensor_type)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Support for Satel Integra zone states- represented as binary sensors.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.satel_integra/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.satel_integra import (CONF_ZONES,
|
||||
CONF_ZONE_NAME,
|
||||
CONF_ZONE_TYPE,
|
||||
SIGNAL_ZONES_UPDATED)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
|
||||
DEPENDENCIES = ['satel_integra']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the Satel Integra binary sensor devices."""
|
||||
if not discovery_info:
|
||||
return
|
||||
|
||||
configured_zones = discovery_info[CONF_ZONES]
|
||||
|
||||
devices = []
|
||||
|
||||
for zone_num, device_config_data in configured_zones.items():
|
||||
zone_type = device_config_data[CONF_ZONE_TYPE]
|
||||
zone_name = device_config_data[CONF_ZONE_NAME]
|
||||
device = SatelIntegraBinarySensor(zone_num, zone_name, zone_type)
|
||||
devices.append(device)
|
||||
|
||||
async_add_devices(devices)
|
||||
|
||||
|
||||
class SatelIntegraBinarySensor(BinarySensorDevice):
|
||||
"""Representation of an Satel Integra binary sensor."""
|
||||
|
||||
def __init__(self, zone_number, zone_name, zone_type):
|
||||
"""Initialize the binary_sensor."""
|
||||
self._zone_number = zone_number
|
||||
self._name = zone_name
|
||||
self._zone_type = zone_type
|
||||
self._state = 0
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Register callbacks."""
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_ZONES_UPDATED, self._zones_updated)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the entity."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Icon for device by its type."""
|
||||
if self._zone_type == 'smoke':
|
||||
return "mdi:fire"
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if sensor is on."""
|
||||
return self._state == 1
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""Return the class of this sensor, from DEVICE_CLASSES."""
|
||||
return self._zone_type
|
||||
|
||||
@callback
|
||||
def _zones_updated(self, zones):
|
||||
"""Update the zone's state, if needed."""
|
||||
if self._zone_number in zones \
|
||||
and self._state != zones[self._zone_number]:
|
||||
self._state = zones[self._zone_number]
|
||||
self.async_schedule_update_ha_state()
|
||||
@@ -41,14 +41,14 @@ def _create_sensor(hass, zone):
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_entities,
|
||||
def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Initialize the platform."""
|
||||
if (discovery_info is None or
|
||||
discovery_info[ATTR_DISCOVER_DEVICES] is None):
|
||||
return
|
||||
|
||||
async_add_entities(
|
||||
async_add_devices(
|
||||
_create_sensor(hass, zone)
|
||||
for zone in discovery_info[ATTR_DISCOVER_DEVICES]
|
||||
if _get_device_class(zone['type']))
|
||||
|
||||
@@ -161,7 +161,7 @@ class BinarySensorTemplate(BinarySensorDevice):
|
||||
def set_state():
|
||||
"""Set state of template binary sensor."""
|
||||
self._state = state
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
# state without delay
|
||||
if (state and not self._delay_on) or \
|
||||
|
||||
@@ -20,15 +20,18 @@ from homeassistant.helpers.event import async_track_state_change
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_HYSTERESIS = 'hysteresis'
|
||||
ATTR_SENSOR_VALUE = 'sensor_value'
|
||||
ATTR_THRESHOLD = 'threshold'
|
||||
ATTR_TYPE = 'type'
|
||||
|
||||
CONF_HYSTERESIS = 'hysteresis'
|
||||
CONF_LOWER = 'lower'
|
||||
CONF_THRESHOLD = 'threshold'
|
||||
CONF_UPPER = 'upper'
|
||||
|
||||
DEFAULT_NAME = 'Threshold'
|
||||
DEFAULT_HYSTERESIS = 0.0
|
||||
|
||||
SENSOR_TYPES = [CONF_LOWER, CONF_UPPER]
|
||||
|
||||
@@ -36,6 +39,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(CONF_THRESHOLD): vol.Coerce(float),
|
||||
vol.Required(CONF_TYPE): vol.In(SENSOR_TYPES),
|
||||
vol.Optional(
|
||||
CONF_HYSTERESIS, default=DEFAULT_HYSTERESIS): vol.Coerce(float),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA,
|
||||
})
|
||||
@@ -47,28 +52,32 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
entity_id = config.get(CONF_ENTITY_ID)
|
||||
name = config.get(CONF_NAME)
|
||||
threshold = config.get(CONF_THRESHOLD)
|
||||
hysteresis = config.get(CONF_HYSTERESIS)
|
||||
limit_type = config.get(CONF_TYPE)
|
||||
device_class = config.get(CONF_DEVICE_CLASS)
|
||||
|
||||
async_add_devices(
|
||||
[ThresholdSensor(hass, entity_id, name, threshold, limit_type,
|
||||
device_class)], True)
|
||||
async_add_devices([ThresholdSensor(
|
||||
hass, entity_id, name, threshold,
|
||||
hysteresis, limit_type, device_class)
|
||||
], True)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class ThresholdSensor(BinarySensorDevice):
|
||||
"""Representation of a Threshold sensor."""
|
||||
|
||||
def __init__(self, hass, entity_id, name, threshold, limit_type,
|
||||
device_class):
|
||||
def __init__(self, hass, entity_id, name, threshold,
|
||||
hysteresis, limit_type, device_class):
|
||||
"""Initialize the Threshold sensor."""
|
||||
self._hass = hass
|
||||
self._entity_id = entity_id
|
||||
self.is_upper = limit_type == 'upper'
|
||||
self._name = name
|
||||
self._threshold = threshold
|
||||
self._hysteresis = hysteresis
|
||||
self._device_class = device_class
|
||||
self._deviation = False
|
||||
self._state = False
|
||||
self.sensor_value = 0
|
||||
|
||||
@callback
|
||||
@@ -97,7 +106,7 @@ class ThresholdSensor(BinarySensorDevice):
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return true if sensor is on."""
|
||||
return self._deviation
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
@@ -116,13 +125,16 @@ class ThresholdSensor(BinarySensorDevice):
|
||||
ATTR_ENTITY_ID: self._entity_id,
|
||||
ATTR_SENSOR_VALUE: self.sensor_value,
|
||||
ATTR_THRESHOLD: self._threshold,
|
||||
ATTR_HYSTERESIS: self._hysteresis,
|
||||
ATTR_TYPE: CONF_UPPER if self.is_upper else CONF_LOWER,
|
||||
}
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_update(self):
|
||||
"""Get the latest data and updates the states."""
|
||||
if self.is_upper:
|
||||
self._deviation = bool(self.sensor_value > self._threshold)
|
||||
else:
|
||||
self._deviation = bool(self.sensor_value < self._threshold)
|
||||
if self._hysteresis == 0 and self.sensor_value == self._threshold:
|
||||
self._state = False
|
||||
elif self.sensor_value > (self._threshold + self._hysteresis):
|
||||
self._state = self.is_upper
|
||||
elif self.sensor_value < (self._threshold - self._hysteresis):
|
||||
self._state = not self.is_upper
|
||||
|
||||
@@ -136,8 +136,9 @@ class WinkHub(WinkBinarySensorDevice):
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
'update needed': self.wink.update_needed(),
|
||||
'firmware version': self.wink.firmware_version()
|
||||
'update_needed': self.wink.update_needed(),
|
||||
'firmware_version': self.wink.firmware_version(),
|
||||
'pairing_mode': self.wink.pairing_mode()
|
||||
}
|
||||
|
||||
|
||||
|
||||
+3
-2
@@ -1,8 +1,9 @@
|
||||
"""Support for Xiaomi binary sensors."""
|
||||
"""Support for Xiaomi aqara binary sensors."""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice)
|
||||
from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY,
|
||||
XiaomiDevice)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -12,6 +12,7 @@ import re
|
||||
from homeassistant.components.google import (
|
||||
CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME)
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
|
||||
from homeassistant.helpers.config_validation import time_period_str
|
||||
from homeassistant.helpers.entity import Entity, generate_entity_id
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
todoist:
|
||||
new_task:
|
||||
description: Create a new task and add it to a project.
|
||||
fields:
|
||||
content:
|
||||
description: The name of the task. [Required]
|
||||
example: Pick up the mail
|
||||
project:
|
||||
description: The name of the project this task should belong to. Defaults to Inbox. [Optional]
|
||||
example: Errands
|
||||
labels:
|
||||
description: Any labels that you want to apply to this task, separated by a comma. [Optional]
|
||||
example: Chores,Deliveries
|
||||
priority:
|
||||
description: The priority of this task, from 1 (normal) to 4 (urgent). [Optional]
|
||||
example: 2
|
||||
due_date:
|
||||
description: The day this task is due, in format YYYY-MM-DD. [Optional]
|
||||
example: "2018-04-01"
|
||||
@@ -0,0 +1,544 @@
|
||||
"""
|
||||
Support for Todoist task management (https://todoist.com).
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/calendar.todoist/
|
||||
"""
|
||||
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import os
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.calendar import (
|
||||
CalendarEventDevice, PLATFORM_SCHEMA)
|
||||
from homeassistant.components.google import (
|
||||
CONF_DEVICE_ID)
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.const import (
|
||||
CONF_ID, CONF_NAME, CONF_TOKEN)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.template import DATE_STR_FORMAT
|
||||
from homeassistant.util import dt
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
REQUIREMENTS = ['todoist-python==7.0.17']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
DOMAIN = 'todoist'
|
||||
|
||||
# Calendar Platform: Does this calendar event last all day?
|
||||
ALL_DAY = 'all_day'
|
||||
# Attribute: All tasks in this project
|
||||
ALL_TASKS = 'all_tasks'
|
||||
# Todoist API: "Completed" flag -- 1 if complete, else 0
|
||||
CHECKED = 'checked'
|
||||
# Attribute: Is this task complete?
|
||||
COMPLETED = 'completed'
|
||||
# Todoist API: What is this task about?
|
||||
# Service Call: What is this task about?
|
||||
CONTENT = 'content'
|
||||
# Calendar Platform: Get a calendar event's description
|
||||
DESCRIPTION = 'description'
|
||||
# Calendar Platform: Used in the '_get_date()' method
|
||||
DATETIME = 'dateTime'
|
||||
# Attribute: When is this task due?
|
||||
# Service Call: When is this task due?
|
||||
DUE_DATE = 'due_date'
|
||||
# Todoist API: Look up a task's due date
|
||||
DUE_DATE_UTC = 'due_date_utc'
|
||||
# Attribute: Is this task due today?
|
||||
DUE_TODAY = 'due_today'
|
||||
# Calendar Platform: When a calendar event ends
|
||||
END = 'end'
|
||||
# Todoist API: Look up a Project/Label/Task ID
|
||||
ID = 'id'
|
||||
# Todoist API: Fetch all labels
|
||||
# Service Call: What are the labels attached to this task?
|
||||
LABELS = 'labels'
|
||||
# Todoist API: "Name" value
|
||||
NAME = 'name'
|
||||
# Attribute: Is this task overdue?
|
||||
OVERDUE = 'overdue'
|
||||
# Attribute: What is this task's priority?
|
||||
# Todoist API: Get a task's priority
|
||||
# Service Call: What is this task's priority?
|
||||
PRIORITY = 'priority'
|
||||
# Todoist API: Look up the Project ID a Task belongs to
|
||||
PROJECT_ID = 'project_id'
|
||||
# Service Call: What Project do you want a Task added to?
|
||||
PROJECT_NAME = 'project'
|
||||
# Todoist API: Fetch all Projects
|
||||
PROJECTS = 'projects'
|
||||
# Calendar Platform: When does a calendar event start?
|
||||
START = 'start'
|
||||
# Calendar Platform: What is the next calendar event about?
|
||||
SUMMARY = 'summary'
|
||||
# Todoist API: Fetch all Tasks
|
||||
TASKS = 'items'
|
||||
|
||||
SERVICE_NEW_TASK = 'new_task'
|
||||
NEW_TASK_SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Required(CONTENT): cv.string,
|
||||
vol.Optional(PROJECT_NAME, default='inbox'): vol.All(cv.string, vol.Lower),
|
||||
vol.Optional(LABELS): cv.ensure_list_csv,
|
||||
vol.Optional(PRIORITY): vol.All(vol.Coerce(int),
|
||||
vol.Range(min=1, max=4)),
|
||||
vol.Optional(DUE_DATE): cv.string
|
||||
})
|
||||
|
||||
CONF_EXTRA_PROJECTS = 'custom_projects'
|
||||
CONF_PROJECT_DUE_DATE = 'due_date_days'
|
||||
CONF_PROJECT_WHITELIST = 'include_projects'
|
||||
CONF_PROJECT_LABEL_WHITELIST = 'labels'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_TOKEN): cv.string,
|
||||
vol.Optional(CONF_EXTRA_PROJECTS, default=[]):
|
||||
vol.All(cv.ensure_list, vol.Schema([
|
||||
vol.Schema({
|
||||
vol.Required(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_PROJECT_DUE_DATE): vol.Coerce(int),
|
||||
vol.Optional(CONF_PROJECT_WHITELIST, default=[]):
|
||||
vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)]),
|
||||
vol.Optional(CONF_PROJECT_LABEL_WHITELIST, default=[]):
|
||||
vol.All(cv.ensure_list, [vol.All(cv.string, vol.Lower)])
|
||||
})
|
||||
]))
|
||||
})
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup the Todoist platform."""
|
||||
# Check token:
|
||||
token = config.get(CONF_TOKEN)
|
||||
|
||||
# Look up IDs based on (lowercase) names.
|
||||
project_id_lookup = {}
|
||||
label_id_lookup = {}
|
||||
|
||||
from todoist.api import TodoistAPI
|
||||
api = TodoistAPI(token)
|
||||
api.sync()
|
||||
|
||||
# Setup devices:
|
||||
# Grab all projects.
|
||||
projects = api.state[PROJECTS]
|
||||
|
||||
# Grab all labels
|
||||
labels = api.state[LABELS]
|
||||
|
||||
# Add all Todoist-defined projects.
|
||||
project_devices = []
|
||||
for project in projects:
|
||||
# Project is an object, not a dict!
|
||||
# Because of that, we convert what we need to a dict.
|
||||
project_data = {
|
||||
CONF_NAME: project[NAME],
|
||||
CONF_ID: project[ID]
|
||||
}
|
||||
project_devices.append(
|
||||
TodoistProjectDevice(hass, project_data, labels, api)
|
||||
)
|
||||
# Cache the names so we can easily look up name->ID.
|
||||
project_id_lookup[project[NAME].lower()] = project[ID]
|
||||
|
||||
# Cache all label names
|
||||
for label in labels:
|
||||
label_id_lookup[label[NAME].lower()] = label[ID]
|
||||
|
||||
# Check config for more projects.
|
||||
extra_projects = config.get(CONF_EXTRA_PROJECTS)
|
||||
for project in extra_projects:
|
||||
# Special filter: By date
|
||||
project_due_date = project.get(CONF_PROJECT_DUE_DATE)
|
||||
|
||||
# Special filter: By label
|
||||
project_label_filter = project.get(CONF_PROJECT_LABEL_WHITELIST)
|
||||
|
||||
# Special filter: By name
|
||||
# Names must be converted into IDs.
|
||||
project_name_filter = project.get(CONF_PROJECT_WHITELIST)
|
||||
project_id_filter = [
|
||||
project_id_lookup[project_name.lower()]
|
||||
for project_name in project_name_filter]
|
||||
|
||||
# Create the custom project and add it to the devices array.
|
||||
project_devices.append(
|
||||
TodoistProjectDevice(
|
||||
hass, project, labels, api, project_due_date,
|
||||
project_label_filter, project_id_filter
|
||||
)
|
||||
)
|
||||
|
||||
add_devices(project_devices)
|
||||
|
||||
# Services:
|
||||
descriptions = load_yaml_config_file(
|
||||
os.path.join(os.path.dirname(__file__), 'services.yaml'))
|
||||
|
||||
def handle_new_task(call):
|
||||
"""Called when a user creates a new Todoist Task from HASS."""
|
||||
project_name = call.data[PROJECT_NAME]
|
||||
project_id = project_id_lookup[project_name]
|
||||
|
||||
# Create the task
|
||||
item = api.items.add(call.data[CONTENT], project_id)
|
||||
|
||||
if LABELS in call.data:
|
||||
task_labels = call.data[LABELS]
|
||||
label_ids = [
|
||||
label_id_lookup[label.lower()]
|
||||
for label in task_labels]
|
||||
item.update(labels=label_ids)
|
||||
|
||||
if PRIORITY in call.data:
|
||||
item.update(priority=call.data[PRIORITY])
|
||||
|
||||
if DUE_DATE in call.data:
|
||||
due_date = dt.parse_datetime(call.data[DUE_DATE])
|
||||
if due_date is None:
|
||||
due = dt.parse_date(call.data[DUE_DATE])
|
||||
due_date = datetime(due.year, due.month, due.day)
|
||||
# Format it in the manner Todoist expects
|
||||
due_date = dt.as_utc(due_date)
|
||||
date_format = '%Y-%m-%dT%H:%M'
|
||||
due_date = datetime.strftime(due_date, date_format)
|
||||
item.update(due_date_utc=due_date)
|
||||
# Commit changes
|
||||
api.commit()
|
||||
_LOGGER.debug("Created Todoist task: %s", call.data[CONTENT])
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_NEW_TASK, handle_new_task,
|
||||
descriptions[DOMAIN][SERVICE_NEW_TASK],
|
||||
schema=NEW_TASK_SERVICE_SCHEMA)
|
||||
|
||||
|
||||
class TodoistProjectDevice(CalendarEventDevice):
|
||||
"""A device for getting the next Task from a Todoist Project."""
|
||||
|
||||
def __init__(self, hass, data, labels, token,
|
||||
latest_task_due_date=None, whitelisted_labels=None,
|
||||
whitelisted_projects=None):
|
||||
"""Create the Todoist Calendar Event Device."""
|
||||
self.data = TodoistProjectData(
|
||||
data, labels, token, latest_task_due_date,
|
||||
whitelisted_labels, whitelisted_projects
|
||||
)
|
||||
|
||||
# Set up the calendar side of things
|
||||
calendar_format = {
|
||||
CONF_NAME: data[CONF_NAME],
|
||||
# Set Entity ID to use the name so we can identify calendars
|
||||
CONF_DEVICE_ID: data[CONF_NAME]
|
||||
}
|
||||
|
||||
super().__init__(hass, calendar_format)
|
||||
|
||||
def update(self):
|
||||
"""Update all Todoist Calendars."""
|
||||
# Set basic calendar data
|
||||
super().update()
|
||||
|
||||
# Set Todoist-specific data that can't easily be grabbed
|
||||
self._cal_data[ALL_TASKS] = [
|
||||
task[SUMMARY] for task in self.data.all_project_tasks]
|
||||
|
||||
def cleanup(self):
|
||||
"""Clean up all calendar data."""
|
||||
super().cleanup()
|
||||
self._cal_data[ALL_TASKS] = []
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the device state attributes."""
|
||||
if self.data.event is None:
|
||||
# No tasks, we don't REALLY need to show anything.
|
||||
return {}
|
||||
|
||||
attributes = super().device_state_attributes
|
||||
|
||||
# Add additional attributes.
|
||||
attributes[DUE_TODAY] = self.data.event[DUE_TODAY]
|
||||
attributes[OVERDUE] = self.data.event[OVERDUE]
|
||||
attributes[ALL_TASKS] = self._cal_data[ALL_TASKS]
|
||||
attributes[PRIORITY] = self.data.event[PRIORITY]
|
||||
attributes[LABELS] = self.data.event[LABELS]
|
||||
|
||||
return attributes
|
||||
|
||||
|
||||
class TodoistProjectData(object):
|
||||
"""
|
||||
Class used by the Task Device service object to hold all Todoist Tasks.
|
||||
|
||||
This is analogous to the GoogleCalendarData found in the Google Calendar
|
||||
component.
|
||||
|
||||
Takes an object with a 'name' field and optionally an 'id' field (either
|
||||
user-defined or from the Todoist API), a Todoist API token, and an optional
|
||||
integer specifying the latest number of days from now a task can be due (7
|
||||
means everything due in the next week, 0 means today, etc.).
|
||||
|
||||
This object has an exposed 'event' property (used by the Calendar platform
|
||||
to determine the next calendar event) and an exposed 'update' method (used
|
||||
by the Calendar platform to poll for new calendar events).
|
||||
|
||||
The 'event' is a representation of a Todoist Task, with defined parameters
|
||||
of 'due_today' (is the task due today?), 'all_day' (does the task have a
|
||||
due date?), 'task_labels' (all labels assigned to the task), 'message'
|
||||
(the content of the task, e.g. 'Fetch Mail'), 'description' (a URL pointing
|
||||
to the task on the Todoist website), 'end_time' (what time the event is
|
||||
due), 'start_time' (what time this event was last updated), 'overdue' (is
|
||||
the task past its due date?), 'priority' (1-4, how important the task is,
|
||||
with 4 being the most important), and 'all_tasks' (all tasks in this
|
||||
project, sorted by how important they are).
|
||||
|
||||
'offset_reached', 'location', and 'friendly_name' are defined by the
|
||||
platform itself, but are not used by this component at all.
|
||||
|
||||
The 'update' method polls the Todoist API for new projects/tasks, as well
|
||||
as any updates to current projects/tasks. This is throttled to every
|
||||
MIN_TIME_BETWEEN_UPDATES minutes.
|
||||
"""
|
||||
|
||||
def __init__(self, project_data, labels, api,
|
||||
latest_task_due_date=None, whitelisted_labels=None,
|
||||
whitelisted_projects=None):
|
||||
"""Initialize a Todoist Project."""
|
||||
self.event = None
|
||||
|
||||
self._api = api
|
||||
self._name = project_data.get(CONF_NAME)
|
||||
# If no ID is defined, fetch all tasks.
|
||||
self._id = project_data.get(CONF_ID)
|
||||
|
||||
# All labels the user has defined, for easy lookup.
|
||||
self._labels = labels
|
||||
# Not tracked: order, indent, comment_count.
|
||||
|
||||
self.all_project_tasks = []
|
||||
|
||||
# The latest date a task can be due (for making lists of everything
|
||||
# due today, or everything due in the next week, for example).
|
||||
if latest_task_due_date is not None:
|
||||
self._latest_due_date = dt.utcnow() + timedelta(
|
||||
days=latest_task_due_date)
|
||||
else:
|
||||
self._latest_due_date = None
|
||||
|
||||
# Only tasks with one of these labels will be included.
|
||||
if whitelisted_labels is not None:
|
||||
self._label_whitelist = whitelisted_labels
|
||||
else:
|
||||
self._label_whitelist = []
|
||||
|
||||
# This project includes only projects with these names.
|
||||
if whitelisted_projects is not None:
|
||||
self._project_id_whitelist = whitelisted_projects
|
||||
else:
|
||||
self._project_id_whitelist = []
|
||||
|
||||
def create_todoist_task(self, data):
|
||||
"""
|
||||
Create a dictionary based on a Task passed from the Todoist API.
|
||||
|
||||
Will return 'None' if the task is to be filtered out.
|
||||
"""
|
||||
task = {}
|
||||
# Fields are required to be in all returned task objects.
|
||||
task[SUMMARY] = data[CONTENT]
|
||||
task[COMPLETED] = data[CHECKED] == 1
|
||||
task[PRIORITY] = data[PRIORITY]
|
||||
task[DESCRIPTION] = 'https://todoist.com/showTask?id={}'.format(
|
||||
data[ID])
|
||||
|
||||
# All task Labels (optional parameter).
|
||||
task[LABELS] = [
|
||||
label[NAME].lower() for label in self._labels
|
||||
if label[ID] in data[LABELS]]
|
||||
|
||||
if self._label_whitelist and (
|
||||
not any(label in task[LABELS]
|
||||
for label in self._label_whitelist)):
|
||||
# We're not on the whitelist, return invalid task.
|
||||
return None
|
||||
|
||||
# Due dates (optional parameter).
|
||||
# The due date is the END date -- the task cannot be completed
|
||||
# past this time.
|
||||
# That means that the START date is the earliest time one can
|
||||
# complete the task.
|
||||
# Generally speaking, that means right now.
|
||||
task[START] = dt.utcnow()
|
||||
if data[DUE_DATE_UTC] is not None:
|
||||
due_date = data[DUE_DATE_UTC]
|
||||
|
||||
# Due dates are represented in RFC3339 format, in UTC.
|
||||
# Home Assistant exclusively uses UTC, so it'll
|
||||
# handle the conversion.
|
||||
time_format = '%a %d %b %Y %H:%M:%S %z'
|
||||
# HASS' built-in parse time function doesn't like
|
||||
# Todoist's time format; strptime has to be used.
|
||||
task[END] = datetime.strptime(due_date, time_format)
|
||||
|
||||
if self._latest_due_date is not None and (
|
||||
task[END] > self._latest_due_date):
|
||||
# This task is out of range of our due date;
|
||||
# it shouldn't be counted.
|
||||
return None
|
||||
|
||||
task[DUE_TODAY] = task[END].date() == datetime.today().date()
|
||||
|
||||
# Special case: Task is overdue.
|
||||
if task[END] <= task[START]:
|
||||
task[OVERDUE] = True
|
||||
# Set end time to the current time plus 1 hour.
|
||||
# We're pretty much guaranteed to update within that 1 hour,
|
||||
# so it should be fine.
|
||||
task[END] = task[START] + timedelta(hours=1)
|
||||
else:
|
||||
task[OVERDUE] = False
|
||||
else:
|
||||
# If we ask for everything due before a certain date, don't count
|
||||
# things which have no due dates.
|
||||
if self._latest_due_date is not None:
|
||||
return None
|
||||
|
||||
# Define values for tasks without due dates
|
||||
task[END] = None
|
||||
task[ALL_DAY] = True
|
||||
task[DUE_TODAY] = False
|
||||
task[OVERDUE] = False
|
||||
|
||||
# Not tracked: id, comments, project_id order, indent, recurring.
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
def select_best_task(project_tasks):
|
||||
"""
|
||||
Search through a list of events for the "best" event to select.
|
||||
|
||||
The "best" event is determined by the following criteria:
|
||||
* A proposed event must not be completed
|
||||
* A proposed event must have a end date (otherwise we go with
|
||||
the event at index 0, selected above)
|
||||
* A proposed event must be on the same day or earlier as our
|
||||
current event
|
||||
* If a proposed event is an earlier day than what we have so
|
||||
far, select it
|
||||
* If a proposed event is on the same day as our current event
|
||||
and the proposed event has a higher priority than our current
|
||||
event, select it
|
||||
* If a proposed event is on the same day as our current event,
|
||||
has the same priority as our current event, but is due earlier
|
||||
in the day, select it
|
||||
"""
|
||||
# Start at the end of the list, so if tasks don't have a due date
|
||||
# the newest ones are the most important.
|
||||
|
||||
event = project_tasks[-1]
|
||||
|
||||
for proposed_event in project_tasks:
|
||||
if event == proposed_event:
|
||||
continue
|
||||
if proposed_event[COMPLETED]:
|
||||
# Event is complete!
|
||||
continue
|
||||
if proposed_event[END] is None:
|
||||
# No end time:
|
||||
if event[END] is None and (
|
||||
proposed_event[PRIORITY] < event[PRIORITY]):
|
||||
# They also have no end time,
|
||||
# but we have a higher priority.
|
||||
event = proposed_event
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
elif event[END] is None:
|
||||
# We have an end time, they do not.
|
||||
event = proposed_event
|
||||
continue
|
||||
if proposed_event[END].date() > event[END].date():
|
||||
# Event is too late.
|
||||
continue
|
||||
elif proposed_event[END].date() < event[END].date():
|
||||
# Event is earlier than current, select it.
|
||||
event = proposed_event
|
||||
continue
|
||||
else:
|
||||
if proposed_event[PRIORITY] > event[PRIORITY]:
|
||||
# Proposed event has a higher priority.
|
||||
event = proposed_event
|
||||
continue
|
||||
elif proposed_event[PRIORITY] == event[PRIORITY] and (
|
||||
proposed_event[END] < event[END]):
|
||||
event = proposed_event
|
||||
continue
|
||||
return event
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
def update(self):
|
||||
"""Get the latest data."""
|
||||
if self._id is None:
|
||||
project_task_data = [
|
||||
task for task in self._api.state[TASKS]
|
||||
if not self._project_id_whitelist or
|
||||
task[PROJECT_ID] in self._project_id_whitelist]
|
||||
else:
|
||||
project_task_data = self._api.projects.get_data(self._id)[TASKS]
|
||||
|
||||
# If we have no data, we can just return right away.
|
||||
if not project_task_data:
|
||||
self.event = None
|
||||
return True
|
||||
|
||||
# Keep an updated list of all tasks in this project.
|
||||
project_tasks = []
|
||||
|
||||
for task in project_task_data:
|
||||
todoist_task = self.create_todoist_task(task)
|
||||
if todoist_task is not None:
|
||||
# A None task means it is invalid for this project
|
||||
project_tasks.append(todoist_task)
|
||||
|
||||
if not project_tasks:
|
||||
# We had no valid tasks
|
||||
return True
|
||||
|
||||
# Organize the best tasks (so users can see all the tasks
|
||||
# they have, organized)
|
||||
while len(project_tasks) > 0:
|
||||
best_task = self.select_best_task(project_tasks)
|
||||
_LOGGER.debug("Found Todoist Task: %s", best_task[SUMMARY])
|
||||
project_tasks.remove(best_task)
|
||||
self.all_project_tasks.append(best_task)
|
||||
|
||||
self.event = self.all_project_tasks[0]
|
||||
|
||||
# Convert datetime to a string again
|
||||
if self.event is not None:
|
||||
if self.event[START] is not None:
|
||||
self.event[START] = {
|
||||
DATETIME: self.event[START].strftime(DATE_STR_FORMAT)
|
||||
}
|
||||
if self.event[END] is not None:
|
||||
self.event[END] = {
|
||||
DATETIME: self.event[END].strftime(DATE_STR_FORMAT)
|
||||
}
|
||||
else:
|
||||
# HASS gets cranky if a calendar event never ends
|
||||
# Let's set our "due date" to tomorrow
|
||||
self.event[END] = {
|
||||
DATETIME: (
|
||||
datetime.utcnow() +
|
||||
timedelta(days=1)
|
||||
).strftime(DATE_STR_FORMAT)
|
||||
}
|
||||
_LOGGER.debug("Updated %s", self._name)
|
||||
return True
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
This component provides HA camera support for Abode Security System.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera.abode/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from datetime import timedelta
|
||||
import requests
|
||||
|
||||
from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN
|
||||
from homeassistant.components.camera import Camera
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
|
||||
DEPENDENCIES = ['abode']
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discoveryy_info=None):
|
||||
"""Set up Abode camera devices."""
|
||||
import abodepy.helpers.constants as CONST
|
||||
import abodepy.helpers.timeline as TIMELINE
|
||||
|
||||
data = hass.data[ABODE_DOMAIN]
|
||||
|
||||
devices = []
|
||||
for device in data.abode.get_devices(generic_type=CONST.TYPE_CAMERA):
|
||||
if data.is_excluded(device):
|
||||
continue
|
||||
|
||||
devices.append(AbodeCamera(data, device, TIMELINE.CAPTURE_IMAGE))
|
||||
|
||||
data.devices.extend(devices)
|
||||
|
||||
add_devices(devices)
|
||||
|
||||
|
||||
class AbodeCamera(AbodeDevice, Camera):
|
||||
"""Representation of an Abode camera."""
|
||||
|
||||
def __init__(self, data, device, event):
|
||||
"""Initialize the Abode device."""
|
||||
AbodeDevice.__init__(self, data, device)
|
||||
Camera.__init__(self)
|
||||
self._event = event
|
||||
self._response = None
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Subscribe Abode events."""
|
||||
yield from super().async_added_to_hass()
|
||||
|
||||
self.hass.async_add_job(
|
||||
self._data.abode.events.add_timeline_callback,
|
||||
self._event, self._capture_callback
|
||||
)
|
||||
|
||||
def capture(self):
|
||||
"""Request a new image capture."""
|
||||
return self._device.capture()
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_UPDATES)
|
||||
def refresh_image(self):
|
||||
"""Find a new image on the timeline."""
|
||||
if self._device.refresh_image():
|
||||
self.get_image()
|
||||
|
||||
def get_image(self):
|
||||
"""Attempt to download the most recent capture."""
|
||||
if self._device.image_url:
|
||||
try:
|
||||
self._response = requests.get(
|
||||
self._device.image_url, stream=True)
|
||||
|
||||
self._response.raise_for_status()
|
||||
except requests.HTTPError as err:
|
||||
_LOGGER.warning("Failed to get camera image: %s", err)
|
||||
self._response = None
|
||||
else:
|
||||
self._response = None
|
||||
|
||||
def camera_image(self):
|
||||
"""Get a camera image."""
|
||||
self.refresh_image()
|
||||
|
||||
if self._response:
|
||||
return self._response.content
|
||||
|
||||
return None
|
||||
|
||||
def _capture_callback(self, capture):
|
||||
"""Update the image with the device then refresh device."""
|
||||
self._device.update_image_location(capture)
|
||||
self.get_image()
|
||||
self.schedule_update_ha_state()
|
||||
@@ -62,7 +62,7 @@ class AmcrestCam(Camera):
|
||||
self._token = self._auth = authentication
|
||||
|
||||
def camera_image(self):
|
||||
"""Return a still image reponse from the camera."""
|
||||
"""Return a still image response from the camera."""
|
||||
# Send the request to snap a picture and return raw jpg data
|
||||
response = self._camera.snapshot(channel=self._resolution)
|
||||
return response.data
|
||||
|
||||
@@ -14,15 +14,31 @@ from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream
|
||||
from homeassistant.components.arlo import DEFAULT_BRAND, DATA_ARLO
|
||||
from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
|
||||
from homeassistant.components.ffmpeg import DATA_FFMPEG
|
||||
from homeassistant.const import ATTR_BATTERY_LEVEL
|
||||
|
||||
DEPENDENCIES = ['arlo', 'ffmpeg']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_BRIGHTNESS = 'brightness'
|
||||
ATTR_FLIPPED = 'flipped'
|
||||
ATTR_MIRRORED = 'mirrored'
|
||||
ATTR_MOTION_SENSITIVITY = 'motion_detection_sensitivity'
|
||||
ATTR_POWER_SAVE_MODE = 'power_save_mode'
|
||||
ATTR_SIGNAL_STRENGTH = 'signal_strength'
|
||||
ATTR_UNSEEN_VIDEOS = 'unseen_videos'
|
||||
|
||||
CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments'
|
||||
|
||||
ARLO_MODE_ARMED = 'armed'
|
||||
ARLO_MODE_DISARMED = 'disarmed'
|
||||
|
||||
POWERSAVE_MODE_MAPPING = {
|
||||
1: 'best_battery_life',
|
||||
2: 'optimized',
|
||||
3: 'best_video'
|
||||
}
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string,
|
||||
})
|
||||
@@ -80,6 +96,28 @@ class ArloCam(Camera):
|
||||
"""Return the name of this camera."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return {
|
||||
ATTR_BATTERY_LEVEL:
|
||||
self._camera.get_battery_level,
|
||||
ATTR_BRIGHTNESS:
|
||||
self._camera.get_brightness,
|
||||
ATTR_FLIPPED:
|
||||
self._camera.get_flip_state,
|
||||
ATTR_MIRRORED:
|
||||
self._camera.get_mirror_state,
|
||||
ATTR_MOTION_SENSITIVITY:
|
||||
self._camera.get_motion_detection_sensitivity,
|
||||
ATTR_POWER_SAVE_MODE:
|
||||
POWERSAVE_MODE_MAPPING[self._camera.get_powersave_mode],
|
||||
ATTR_SIGNAL_STRENGTH:
|
||||
self._camera.get_signal_strength,
|
||||
ATTR_UNSEEN_VIDEOS:
|
||||
self._camera.unseen_videos
|
||||
}
|
||||
|
||||
@property
|
||||
def model(self):
|
||||
"""Camera model."""
|
||||
|
||||
@@ -7,7 +7,7 @@ https://home-assistant.io/components/camera.axis/
|
||||
import logging
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD,
|
||||
CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_PORT,
|
||||
CONF_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION)
|
||||
from homeassistant.components.camera.mjpeg import (
|
||||
CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera)
|
||||
@@ -19,38 +19,44 @@ DOMAIN = 'axis'
|
||||
DEPENDENCIES = [DOMAIN]
|
||||
|
||||
|
||||
def _get_image_url(host, mode):
|
||||
def _get_image_url(host, port, mode):
|
||||
if mode == 'mjpeg':
|
||||
return 'http://{}/axis-cgi/mjpg/video.cgi'.format(host)
|
||||
return 'http://{}:{}/axis-cgi/mjpg/video.cgi'.format(host, port)
|
||||
elif mode == 'single':
|
||||
return 'http://{}/axis-cgi/jpg/image.cgi'.format(host)
|
||||
return 'http://{}:{}/axis-cgi/jpg/image.cgi'.format(host, port)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup Axis camera."""
|
||||
config = {
|
||||
camera_config = {
|
||||
CONF_NAME: discovery_info[CONF_NAME],
|
||||
CONF_USERNAME: discovery_info[CONF_USERNAME],
|
||||
CONF_PASSWORD: discovery_info[CONF_PASSWORD],
|
||||
CONF_MJPEG_URL: _get_image_url(discovery_info[CONF_HOST], 'mjpeg'),
|
||||
CONF_MJPEG_URL: _get_image_url(discovery_info[CONF_HOST],
|
||||
str(discovery_info[CONF_PORT]),
|
||||
'mjpeg'),
|
||||
CONF_STILL_IMAGE_URL: _get_image_url(discovery_info[CONF_HOST],
|
||||
str(discovery_info[CONF_PORT]),
|
||||
'single'),
|
||||
CONF_AUTHENTICATION: HTTP_DIGEST_AUTHENTICATION,
|
||||
}
|
||||
add_devices([AxisCamera(hass, config)])
|
||||
add_devices([AxisCamera(hass,
|
||||
camera_config,
|
||||
str(discovery_info[CONF_PORT]))])
|
||||
|
||||
|
||||
class AxisCamera(MjpegCamera):
|
||||
"""AxisCamera class."""
|
||||
|
||||
def __init__(self, hass, config):
|
||||
def __init__(self, hass, config, port):
|
||||
"""Initialize Axis Communications camera component."""
|
||||
super().__init__(hass, config)
|
||||
self.port = port
|
||||
async_dispatcher_connect(hass,
|
||||
DOMAIN + '_' + config[CONF_NAME] + '_new_ip',
|
||||
self._new_ip)
|
||||
|
||||
def _new_ip(self, host):
|
||||
"""Set new IP for video stream."""
|
||||
self._mjpeg_url = _get_image_url(host, 'mjpeg')
|
||||
self._still_image_url = _get_image_url(host, 'mjpeg')
|
||||
self._mjpeg_url = _get_image_url(host, self.port, 'mjpeg')
|
||||
self._still_image_url = _get_image_url(host, self.port, 'single')
|
||||
|
||||
@@ -76,6 +76,6 @@ class BlinkCamera(Camera):
|
||||
return self.data.camera_thumbs[self._name]
|
||||
|
||||
def camera_image(self):
|
||||
"""Return a still image reponse from the camera."""
|
||||
"""Return a still image response from the camera."""
|
||||
self.request_image()
|
||||
return self.response.content
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Support for viewing the camera feed from a DoorBird video doorbell."""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import voluptuous as vol
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.components.camera import PLATFORM_SCHEMA, Camera
|
||||
from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
DEPENDENCIES = ['doorbird']
|
||||
|
||||
_CAMERA_LIVE = "DoorBird Live"
|
||||
_CAMERA_LAST_VISITOR = "DoorBird Last Ring"
|
||||
_LIVE_INTERVAL = datetime.timedelta(seconds=1)
|
||||
_LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_TIMEOUT = 10 # seconds
|
||||
|
||||
CONF_SHOW_LAST_VISITOR = 'last_visitor'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_SHOW_LAST_VISITOR, default=False): cv.boolean
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the DoorBird camera platform."""
|
||||
device = hass.data.get(DOORBIRD_DOMAIN)
|
||||
|
||||
_LOGGER.debug("Adding DoorBird camera %s", _CAMERA_LIVE)
|
||||
entities = [DoorBirdCamera(device.live_image_url, _CAMERA_LIVE,
|
||||
_LIVE_INTERVAL)]
|
||||
|
||||
if config.get(CONF_SHOW_LAST_VISITOR):
|
||||
_LOGGER.debug("Adding DoorBird camera %s", _CAMERA_LAST_VISITOR)
|
||||
entities.append(DoorBirdCamera(device.history_image_url(1),
|
||||
_CAMERA_LAST_VISITOR,
|
||||
_LAST_VISITOR_INTERVAL))
|
||||
|
||||
async_add_devices(entities)
|
||||
_LOGGER.info("Added DoorBird camera(s)")
|
||||
|
||||
|
||||
class DoorBirdCamera(Camera):
|
||||
"""The camera on a DoorBird device."""
|
||||
|
||||
def __init__(self, url, name, interval=None):
|
||||
"""Initialize the camera on a DoorBird device."""
|
||||
self._url = url
|
||||
self._name = name
|
||||
self._last_image = None
|
||||
self._interval = interval or datetime.timedelta
|
||||
self._last_update = datetime.datetime.min
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Get the name of the camera."""
|
||||
return self._name
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_camera_image(self):
|
||||
"""Pull a still image from the camera."""
|
||||
now = datetime.datetime.now()
|
||||
|
||||
if self._last_image and now - self._last_update < self._interval:
|
||||
return self._last_image
|
||||
|
||||
try:
|
||||
websession = async_get_clientsession(self.hass)
|
||||
|
||||
with async_timeout.timeout(_TIMEOUT, loop=self.hass.loop):
|
||||
response = yield from websession.get(self._url)
|
||||
|
||||
self._last_image = yield from response.read()
|
||||
self._last_update = now
|
||||
return self._last_image
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.error("Camera image timed out")
|
||||
return self._last_image
|
||||
except aiohttp.ClientError as error:
|
||||
_LOGGER.error("Error getting camera image: %s", error)
|
||||
return self._last_image
|
||||
@@ -15,7 +15,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
REQUIREMENTS = ['pyfoscam==1.2']
|
||||
REQUIREMENTS = ['libpyfoscam==1.0']
|
||||
|
||||
CONF_IP = 'ip'
|
||||
|
||||
@@ -53,13 +53,13 @@ class FoscamCam(Camera):
|
||||
self._name = device_info.get(CONF_NAME)
|
||||
self._motion_status = False
|
||||
|
||||
from foscam.foscam import FoscamCamera
|
||||
from libpyfoscam import FoscamCamera
|
||||
|
||||
self._foscam_session = FoscamCamera(ip_address, port, self._username,
|
||||
self._password, verbose=False)
|
||||
|
||||
def camera_image(self):
|
||||
"""Return a still image reponse from the camera."""
|
||||
"""Return a still image response from the camera."""
|
||||
# Send the request to snap a picture and return raw jpg data
|
||||
# Handle exception if host is not reachable or url failed
|
||||
result, response = self._foscam_session.snap_picture_2()
|
||||
|
||||
@@ -7,44 +7,25 @@ https://home-assistant.io/components/camera.synology/
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, CONF_USERNAME, CONF_PASSWORD,
|
||||
CONF_URL, CONF_WHITELIST, CONF_VERIFY_SSL, CONF_TIMEOUT)
|
||||
from homeassistant.components.camera import (
|
||||
Camera, PLATFORM_SCHEMA)
|
||||
from homeassistant.helpers.aiohttp_client import (
|
||||
async_get_clientsession, async_create_clientsession,
|
||||
async_create_clientsession,
|
||||
async_aiohttp_proxy_web)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.util.async import run_coroutine_threadsafe
|
||||
|
||||
REQUIREMENTS = ['py-synology==0.1.3']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = 'Synology Camera'
|
||||
DEFAULT_STREAM_ID = '0'
|
||||
DEFAULT_TIMEOUT = 5
|
||||
CONF_CAMERA_NAME = 'camera_name'
|
||||
CONF_STREAM_ID = 'stream_id'
|
||||
|
||||
QUERY_CGI = 'query.cgi'
|
||||
QUERY_API = 'SYNO.API.Info'
|
||||
AUTH_API = 'SYNO.API.Auth'
|
||||
CAMERA_API = 'SYNO.SurveillanceStation.Camera'
|
||||
STREAMING_API = 'SYNO.SurveillanceStation.VideoStream'
|
||||
SESSION_ID = '0'
|
||||
|
||||
WEBAPI_PATH = '/webapi/'
|
||||
AUTH_PATH = 'auth.cgi'
|
||||
CAMERA_PATH = 'camera.cgi'
|
||||
STREAMING_PATH = 'SurveillanceStation/videoStreaming.cgi'
|
||||
CONTENT_TYPE_HEADER = 'Content-Type'
|
||||
|
||||
SYNO_API_URL = '{0}{1}{2}'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
@@ -62,189 +43,89 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up a Synology IP Camera."""
|
||||
verify_ssl = config.get(CONF_VERIFY_SSL)
|
||||
timeout = config.get(CONF_TIMEOUT)
|
||||
websession_init = async_get_clientsession(hass, verify_ssl)
|
||||
|
||||
# Determine API to use for authentication
|
||||
syno_api_url = SYNO_API_URL.format(
|
||||
config.get(CONF_URL), WEBAPI_PATH, QUERY_CGI)
|
||||
|
||||
query_payload = {
|
||||
'api': QUERY_API,
|
||||
'method': 'Query',
|
||||
'version': '1',
|
||||
'query': 'SYNO.'
|
||||
}
|
||||
try:
|
||||
with async_timeout.timeout(timeout, loop=hass.loop):
|
||||
query_req = yield from websession_init.get(
|
||||
syno_api_url,
|
||||
params=query_payload
|
||||
)
|
||||
|
||||
# Skip content type check because Synology doesn't return JSON with
|
||||
# right content type
|
||||
query_resp = yield from query_req.json(content_type=None)
|
||||
auth_path = query_resp['data'][AUTH_API]['path']
|
||||
camera_api = query_resp['data'][CAMERA_API]['path']
|
||||
camera_path = query_resp['data'][CAMERA_API]['path']
|
||||
streaming_path = query_resp['data'][STREAMING_API]['path']
|
||||
|
||||
except (asyncio.TimeoutError, aiohttp.ClientError):
|
||||
_LOGGER.exception("Error on %s", syno_api_url)
|
||||
from synology.surveillance_station import SurveillanceStation
|
||||
surveillance = SurveillanceStation(
|
||||
config.get(CONF_URL),
|
||||
config.get(CONF_USERNAME),
|
||||
config.get(CONF_PASSWORD),
|
||||
verify_ssl=verify_ssl,
|
||||
timeout=timeout
|
||||
)
|
||||
except (requests.exceptions.RequestException, ValueError):
|
||||
_LOGGER.exception("Error when initializing SurveillanceStation")
|
||||
return False
|
||||
|
||||
# Authticate to NAS to get a session id
|
||||
syno_auth_url = SYNO_API_URL.format(
|
||||
config.get(CONF_URL), WEBAPI_PATH, auth_path)
|
||||
|
||||
session_id = yield from get_session_id(
|
||||
hass,
|
||||
websession_init,
|
||||
config.get(CONF_USERNAME),
|
||||
config.get(CONF_PASSWORD),
|
||||
syno_auth_url,
|
||||
timeout
|
||||
)
|
||||
|
||||
# init websession
|
||||
websession = async_create_clientsession(
|
||||
hass, verify_ssl, cookies={'id': session_id})
|
||||
|
||||
# Use SessionID to get cameras in system
|
||||
syno_camera_url = SYNO_API_URL.format(
|
||||
config.get(CONF_URL), WEBAPI_PATH, camera_api)
|
||||
|
||||
camera_payload = {
|
||||
'api': CAMERA_API,
|
||||
'method': 'List',
|
||||
'version': '1'
|
||||
}
|
||||
try:
|
||||
with async_timeout.timeout(timeout, loop=hass.loop):
|
||||
camera_req = yield from websession.get(
|
||||
syno_camera_url,
|
||||
params=camera_payload
|
||||
)
|
||||
except (asyncio.TimeoutError, aiohttp.ClientError):
|
||||
_LOGGER.exception("Error on %s", syno_camera_url)
|
||||
return False
|
||||
|
||||
camera_resp = yield from camera_req.json(content_type=None)
|
||||
cameras = camera_resp['data']['cameras']
|
||||
cameras = surveillance.get_all_cameras()
|
||||
websession = async_create_clientsession(hass, verify_ssl)
|
||||
|
||||
# add cameras
|
||||
devices = []
|
||||
for camera in cameras:
|
||||
if not config.get(CONF_WHITELIST):
|
||||
camera_id = camera['id']
|
||||
snapshot_path = camera['snapshot_path']
|
||||
|
||||
device = SynologyCamera(
|
||||
hass, websession, config, camera_id, camera['name'],
|
||||
snapshot_path, streaming_path, camera_path, auth_path, timeout
|
||||
)
|
||||
device = SynologyCamera(websession, surveillance, camera.camera_id)
|
||||
devices.append(device)
|
||||
|
||||
async_add_devices(devices)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def get_session_id(hass, websession, username, password, login_url, timeout):
|
||||
"""Get a session id."""
|
||||
auth_payload = {
|
||||
'api': AUTH_API,
|
||||
'method': 'Login',
|
||||
'version': '2',
|
||||
'account': username,
|
||||
'passwd': password,
|
||||
'session': 'SurveillanceStation',
|
||||
'format': 'sid'
|
||||
}
|
||||
try:
|
||||
with async_timeout.timeout(timeout, loop=hass.loop):
|
||||
auth_req = yield from websession.get(
|
||||
login_url,
|
||||
params=auth_payload
|
||||
)
|
||||
auth_resp = yield from auth_req.json(content_type=None)
|
||||
return auth_resp['data']['sid']
|
||||
|
||||
except (asyncio.TimeoutError, aiohttp.ClientError):
|
||||
_LOGGER.exception("Error on %s", login_url)
|
||||
return False
|
||||
|
||||
|
||||
class SynologyCamera(Camera):
|
||||
"""An implementation of a Synology NAS based IP camera."""
|
||||
|
||||
def __init__(self, hass, websession, config, camera_id,
|
||||
camera_name, snapshot_path, streaming_path, camera_path,
|
||||
auth_path, timeout):
|
||||
def __init__(self, websession, surveillance, camera_id):
|
||||
"""Initialize a Synology Surveillance Station camera."""
|
||||
super().__init__()
|
||||
self.hass = hass
|
||||
self._websession = websession
|
||||
self._name = camera_name
|
||||
self._synology_url = config.get(CONF_URL)
|
||||
self._camera_name = config.get(CONF_CAMERA_NAME)
|
||||
self._stream_id = config.get(CONF_STREAM_ID)
|
||||
self._surveillance = surveillance
|
||||
self._camera_id = camera_id
|
||||
self._snapshot_path = snapshot_path
|
||||
self._streaming_path = streaming_path
|
||||
self._camera_path = camera_path
|
||||
self._auth_path = auth_path
|
||||
self._timeout = timeout
|
||||
self._camera = self._surveillance.get_camera(camera_id)
|
||||
self._motion_setting = self._surveillance.get_motion_setting(camera_id)
|
||||
self.is_streaming = self._camera.is_enabled
|
||||
|
||||
def camera_image(self):
|
||||
"""Return bytes of camera image."""
|
||||
return run_coroutine_threadsafe(
|
||||
self.async_camera_image(), self.hass.loop).result()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
image_url = SYNO_API_URL.format(
|
||||
self._synology_url, WEBAPI_PATH, self._camera_path)
|
||||
|
||||
image_payload = {
|
||||
'api': CAMERA_API,
|
||||
'method': 'GetSnapshot',
|
||||
'version': '1',
|
||||
'cameraId': self._camera_id
|
||||
}
|
||||
try:
|
||||
with async_timeout.timeout(self._timeout, loop=self.hass.loop):
|
||||
response = yield from self._websession.get(
|
||||
image_url,
|
||||
params=image_payload
|
||||
)
|
||||
except (asyncio.TimeoutError, aiohttp.ClientError):
|
||||
_LOGGER.error("Error fetching %s", image_url)
|
||||
return None
|
||||
|
||||
image = yield from response.read()
|
||||
|
||||
return image
|
||||
return self._surveillance.get_camera_image(self._camera_id)
|
||||
|
||||
@asyncio.coroutine
|
||||
def handle_async_mjpeg_stream(self, request):
|
||||
"""Return a MJPEG stream image response directly from the camera."""
|
||||
streaming_url = SYNO_API_URL.format(
|
||||
self._synology_url, WEBAPI_PATH, self._streaming_path)
|
||||
|
||||
streaming_payload = {
|
||||
'api': STREAMING_API,
|
||||
'method': 'Stream',
|
||||
'version': '1',
|
||||
'cameraId': self._camera_id,
|
||||
'format': 'mjpeg'
|
||||
}
|
||||
stream_coro = self._websession.get(
|
||||
streaming_url, params=streaming_payload)
|
||||
streaming_url = self._camera.video_stream_url
|
||||
stream_coro = self._websession.get(streaming_url)
|
||||
|
||||
yield from async_aiohttp_proxy_web(self.hass, request, stream_coro)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of this device."""
|
||||
return self._name
|
||||
return self._camera.name
|
||||
|
||||
@property
|
||||
def is_recording(self):
|
||||
"""Return true if the device is recording."""
|
||||
return self._camera.is_recording
|
||||
|
||||
def should_poll(self):
|
||||
"""Update the recording state periodically."""
|
||||
return True
|
||||
|
||||
def update(self):
|
||||
"""Update the status of the camera."""
|
||||
self._surveillance.update()
|
||||
self._camera = self._surveillance.get_camera(self._camera.camera_id)
|
||||
self._motion_setting = self._surveillance.get_motion_setting(
|
||||
self._camera.camera_id)
|
||||
self.is_streaming = self._camera.is_enabled
|
||||
|
||||
@property
|
||||
def motion_detection_enabled(self):
|
||||
"""Return the camera motion detection status."""
|
||||
return self._motion_setting.is_enabled
|
||||
|
||||
def enable_motion_detection(self):
|
||||
"""Enable motion detection in the camera."""
|
||||
self._surveillance.enable_motion_detection(self._camera_id)
|
||||
|
||||
def disable_motion_detection(self):
|
||||
"""Disable motion detection in camera."""
|
||||
self._surveillance.disable_motion_detection(self._camera_id)
|
||||
|
||||
@@ -77,7 +77,7 @@ class USPSCamera(Camera):
|
||||
def model(self):
|
||||
"""Return date of mail as model."""
|
||||
try:
|
||||
return 'Date: {}'.format(self._usps.mail[0]['date'])
|
||||
return 'Date: {}'.format(str(self._usps.mail[0]['date']))
|
||||
except IndexError:
|
||||
return None
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.const import CONF_PORT
|
||||
from homeassistant.components.camera import Camera, PLATFORM_SCHEMA
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['uvcclient==0.10.0']
|
||||
REQUIREMENTS = ['uvcclient==0.10.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -44,6 +44,12 @@ STATE_IDLE = 'idle'
|
||||
STATE_AUTO = 'auto'
|
||||
STATE_DRY = 'dry'
|
||||
STATE_FAN_ONLY = 'fan_only'
|
||||
STATE_ECO = 'eco'
|
||||
STATE_ELECTRIC = 'electric'
|
||||
STATE_PERFORMANCE = 'performance'
|
||||
STATE_HIGH_DEMAND = 'high_demand'
|
||||
STATE_HEAT_PUMP = 'heat_pump'
|
||||
STATE_GAS = 'gas'
|
||||
|
||||
ATTR_CURRENT_TEMPERATURE = 'current_temperature'
|
||||
ATTR_MAX_TEMP = 'max_temp'
|
||||
@@ -147,7 +153,7 @@ def set_hold_mode(hass, hold_mode, entity_id=None):
|
||||
|
||||
@bind_hass
|
||||
def set_aux_heat(hass, aux_heat, entity_id=None):
|
||||
"""Turn all or specified climate devices auxillary heater on."""
|
||||
"""Turn all or specified climate devices auxiliary heater on."""
|
||||
data = {
|
||||
ATTR_AUX_HEAT: aux_heat
|
||||
}
|
||||
@@ -661,22 +667,22 @@ class ClimateDevice(Entity):
|
||||
return self.hass.async_add_job(self.set_hold_mode, hold_mode)
|
||||
|
||||
def turn_aux_heat_on(self):
|
||||
"""Turn auxillary heater on."""
|
||||
"""Turn auxiliary heater on."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def async_turn_aux_heat_on(self):
|
||||
"""Turn auxillary heater on.
|
||||
"""Turn auxiliary heater on.
|
||||
|
||||
This method must be run in the event loop and returns a coroutine.
|
||||
"""
|
||||
return self.hass.async_add_job(self.turn_aux_heat_on)
|
||||
|
||||
def turn_aux_heat_off(self):
|
||||
"""Turn auxillary heater off."""
|
||||
"""Turn auxiliary heater off."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def async_turn_aux_heat_off(self):
|
||||
"""Turn auxillary heater off.
|
||||
"""Turn auxiliary heater off.
|
||||
|
||||
This method must be run in the event loop and returns a coroutine.
|
||||
"""
|
||||
|
||||
@@ -114,7 +114,7 @@ class DemoClimate(ClimateDevice):
|
||||
|
||||
@property
|
||||
def is_aux_heat_on(self):
|
||||
"""Return true if away mode is on."""
|
||||
"""Return true if aux heat is on."""
|
||||
return self._aux
|
||||
|
||||
@property
|
||||
@@ -183,11 +183,11 @@ class DemoClimate(ClimateDevice):
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def turn_aux_heat_on(self):
|
||||
"""Turn away auxillary heater on."""
|
||||
"""Turn auxillary heater on."""
|
||||
self._aux = True
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
def turn_aux_heat_off(self):
|
||||
"""Turn auxillary heater off."""
|
||||
"""Turn auxiliary heater off."""
|
||||
self._aux = False
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@@ -27,6 +27,7 @@ ATTR_RESUME_ALL = 'resume_all'
|
||||
DEFAULT_RESUME_ALL = False
|
||||
TEMPERATURE_HOLD = 'temp'
|
||||
VACATION_HOLD = 'vacation'
|
||||
AWAY_MODE = 'awayMode'
|
||||
|
||||
DEPENDENCIES = ['ecobee']
|
||||
|
||||
@@ -144,20 +145,20 @@ class Thermostat(ClimateDevice):
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self.thermostat['runtime']['actualTemperature'] / 10
|
||||
return self.thermostat['runtime']['actualTemperature'] / 10.0
|
||||
|
||||
@property
|
||||
def target_temperature_low(self):
|
||||
"""Return the lower bound temperature we try to reach."""
|
||||
if self.current_operation == STATE_AUTO:
|
||||
return int(self.thermostat['runtime']['desiredHeat'] / 10)
|
||||
return self.thermostat['runtime']['desiredHeat'] / 10.0
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_high(self):
|
||||
"""Return the upper bound temperature we try to reach."""
|
||||
if self.current_operation == STATE_AUTO:
|
||||
return int(self.thermostat['runtime']['desiredCool'] / 10)
|
||||
return self.thermostat['runtime']['desiredCool'] / 10.0
|
||||
return None
|
||||
|
||||
@property
|
||||
@@ -166,9 +167,9 @@ class Thermostat(ClimateDevice):
|
||||
if self.current_operation == STATE_AUTO:
|
||||
return None
|
||||
if self.current_operation == STATE_HEAT:
|
||||
return int(self.thermostat['runtime']['desiredHeat'] / 10)
|
||||
return self.thermostat['runtime']['desiredHeat'] / 10.0
|
||||
elif self.current_operation == STATE_COOL:
|
||||
return int(self.thermostat['runtime']['desiredCool'] / 10)
|
||||
return self.thermostat['runtime']['desiredCool'] / 10.0
|
||||
return None
|
||||
|
||||
@property
|
||||
@@ -186,6 +187,11 @@ class Thermostat(ClimateDevice):
|
||||
@property
|
||||
def current_hold_mode(self):
|
||||
"""Return current hold mode."""
|
||||
mode = self._current_hold_mode
|
||||
return None if mode == AWAY_MODE else mode
|
||||
|
||||
@property
|
||||
def _current_hold_mode(self):
|
||||
events = self.thermostat['events']
|
||||
for event in events:
|
||||
if event['running']:
|
||||
@@ -195,8 +201,8 @@ class Thermostat(ClimateDevice):
|
||||
int(event['startDate'][0:4]) <= 1:
|
||||
# A temporary hold from away climate is a hold
|
||||
return 'away'
|
||||
# A permanent hold from away climate is away_mode
|
||||
return None
|
||||
# A permanent hold from away climate
|
||||
return AWAY_MODE
|
||||
elif event['holdClimateRef'] != "":
|
||||
# Any other hold based on climate
|
||||
return event['holdClimateRef']
|
||||
@@ -269,7 +275,7 @@ class Thermostat(ClimateDevice):
|
||||
@property
|
||||
def is_away_mode_on(self):
|
||||
"""Return true if away mode is on."""
|
||||
return self.current_hold_mode == 'away'
|
||||
return self._current_hold_mode == AWAY_MODE
|
||||
|
||||
@property
|
||||
def is_aux_heat_on(self):
|
||||
@@ -277,12 +283,17 @@ class Thermostat(ClimateDevice):
|
||||
return 'auxHeat' in self.thermostat['equipmentStatus']
|
||||
|
||||
def turn_away_mode_on(self):
|
||||
"""Turn away on."""
|
||||
self.set_hold_mode('away')
|
||||
"""Turn away mode on by setting it on away hold indefinitely."""
|
||||
if self._current_hold_mode != AWAY_MODE:
|
||||
self.data.ecobee.set_climate_hold(self.thermostat_index, 'away',
|
||||
'indefinite')
|
||||
self.update_without_throttle = True
|
||||
|
||||
def turn_away_mode_off(self):
|
||||
"""Turn away off."""
|
||||
self.set_hold_mode(None)
|
||||
if self._current_hold_mode == AWAY_MODE:
|
||||
self.data.ecobee.resume_program(self.thermostat_index)
|
||||
self.update_without_throttle = True
|
||||
|
||||
def set_hold_mode(self, hold_mode):
|
||||
"""Set hold mode (away, home, temp, sleep, etc.)."""
|
||||
@@ -299,7 +310,7 @@ class Thermostat(ClimateDevice):
|
||||
self.data.ecobee.resume_program(self.thermostat_index)
|
||||
else:
|
||||
if hold_mode == TEMPERATURE_HOLD:
|
||||
self.set_temp_hold(int(self.current_temperature))
|
||||
self.set_temp_hold(self.current_temperature)
|
||||
else:
|
||||
self.data.ecobee.set_climate_hold(
|
||||
self.thermostat_index, hold_mode, self.hold_preference())
|
||||
@@ -325,15 +336,11 @@ class Thermostat(ClimateDevice):
|
||||
elif self.current_operation == STATE_COOL:
|
||||
heat_temp = temp - 20
|
||||
cool_temp = temp
|
||||
|
||||
self.data.ecobee.set_hold_temp(self.thermostat_index, cool_temp,
|
||||
heat_temp, self.hold_preference())
|
||||
_LOGGER.debug("Setting ecobee hold_temp to: low=%s, is=%s, "
|
||||
"cool=%s, is=%s", heat_temp, isinstance(
|
||||
heat_temp, (int, float)), cool_temp,
|
||||
isinstance(cool_temp, (int, float)))
|
||||
|
||||
self.update_without_throttle = True
|
||||
else:
|
||||
# In auto mode set temperature between
|
||||
heat_temp = temp - 10
|
||||
cool_temp = temp + 10
|
||||
self.set_auto_temp_hold(heat_temp, cool_temp)
|
||||
|
||||
def set_temperature(self, **kwargs):
|
||||
"""Set new target temperature."""
|
||||
@@ -343,9 +350,9 @@ class Thermostat(ClimateDevice):
|
||||
|
||||
if self.current_operation == STATE_AUTO and low_temp is not None \
|
||||
and high_temp is not None:
|
||||
self.set_auto_temp_hold(int(low_temp), int(high_temp))
|
||||
self.set_auto_temp_hold(low_temp, high_temp)
|
||||
elif temp is not None:
|
||||
self.set_temp_hold(int(temp))
|
||||
self.set_temp_hold(temp)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Missing valid arguments for set_temperature in %s", kwargs)
|
||||
@@ -364,7 +371,7 @@ class Thermostat(ClimateDevice):
|
||||
def resume_program(self, resume_all):
|
||||
"""Resume the thermostat schedule program."""
|
||||
self.data.ecobee.resume_program(
|
||||
self.thermostat_index, str(resume_all).lower())
|
||||
self.thermostat_index, 'true' if resume_all else 'false')
|
||||
self.update_without_throttle = True
|
||||
|
||||
def hold_preference(self):
|
||||
|
||||
@@ -211,7 +211,7 @@ class GenericThermostat(ClimateDevice):
|
||||
"""Handle heater switch state changes."""
|
||||
if new_state is None:
|
||||
return
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@callback
|
||||
def _async_keep_alive(self, time):
|
||||
|
||||
@@ -44,7 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, add_devices,
|
||||
def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up climate(s) for KNX platform."""
|
||||
if DATA_KNX not in hass.data \
|
||||
@@ -52,25 +52,25 @@ def async_setup_platform(hass, config, add_devices,
|
||||
return False
|
||||
|
||||
if discovery_info is not None:
|
||||
async_add_devices_discovery(hass, discovery_info, add_devices)
|
||||
async_add_devices_discovery(hass, discovery_info, async_add_devices)
|
||||
else:
|
||||
async_add_devices_config(hass, config, add_devices)
|
||||
async_add_devices_config(hass, config, async_add_devices)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
def async_add_devices_discovery(hass, discovery_info, add_devices):
|
||||
def async_add_devices_discovery(hass, discovery_info, async_add_devices):
|
||||
"""Set up climates for KNX platform configured within plattform."""
|
||||
entities = []
|
||||
for device_name in discovery_info[ATTR_DISCOVER_DEVICES]:
|
||||
device = hass.data[DATA_KNX].xknx.devices[device_name]
|
||||
entities.append(KNXClimate(hass, device))
|
||||
add_devices(entities)
|
||||
async_add_devices(entities)
|
||||
|
||||
|
||||
@callback
|
||||
def async_add_devices_config(hass, config, add_devices):
|
||||
def async_add_devices_config(hass, config, async_add_devices):
|
||||
"""Set up climate for KNX platform configured within plattform."""
|
||||
import xknx
|
||||
climate = xknx.devices.Climate(
|
||||
@@ -97,7 +97,7 @@ def async_add_devices_config(hass, config, add_devices):
|
||||
group_address_operation_mode_comfort=config.get(
|
||||
CONF_OPERATION_MODE_COMFORT_ADDRESS))
|
||||
hass.data[DATA_KNX].xknx.devices.add(climate)
|
||||
add_devices([KNXClimate(hass, climate)])
|
||||
async_add_devices([KNXClimate(hass, climate)])
|
||||
|
||||
|
||||
class KNXClimate(ClimateDevice):
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
"""
|
||||
Support for MQTT climate devices.
|
||||
|
||||
For more details about this platform, please refer to the documentation
|
||||
https://home-assistant.io/components/climate.mqtt/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
import homeassistant.components.mqtt as mqtt
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
STATE_HEAT, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, ClimateDevice,
|
||||
PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, STATE_AUTO,
|
||||
ATTR_OPERATION_MODE)
|
||||
from homeassistant.const import (
|
||||
STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME)
|
||||
from homeassistant.components.mqtt import (CONF_QOS, CONF_RETAIN)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM,
|
||||
SPEED_HIGH)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['mqtt']
|
||||
|
||||
DEFAULT_NAME = 'MQTT HVAC'
|
||||
|
||||
CONF_POWER_COMMAND_TOPIC = 'power_command_topic'
|
||||
CONF_POWER_STATE_TOPIC = 'power_state_topic'
|
||||
CONF_MODE_COMMAND_TOPIC = 'mode_command_topic'
|
||||
CONF_MODE_STATE_TOPIC = 'mode_state_topic'
|
||||
CONF_TEMPERATURE_COMMAND_TOPIC = 'temperature_command_topic'
|
||||
CONF_TEMPERATURE_STATE_TOPIC = 'temperature_state_topic'
|
||||
CONF_FAN_MODE_COMMAND_TOPIC = 'fan_mode_command_topic'
|
||||
CONF_FAN_MODE_STATE_TOPIC = 'fan_mode_state_topic'
|
||||
CONF_SWING_MODE_COMMAND_TOPIC = 'swing_mode_command_topic'
|
||||
CONF_SWING_MODE_STATE_TOPIC = 'swing_mode_state_topic'
|
||||
CONF_AWAY_MODE_COMMAND_TOPIC = 'away_mode_command_topic'
|
||||
CONF_AWAY_MODE_STATE_TOPIC = 'away_mode_state_topic'
|
||||
CONF_HOLD_COMMAND_TOPIC = 'hold_command_topic'
|
||||
CONF_HOLD_STATE_TOPIC = 'hold_state_topic'
|
||||
CONF_AUX_COMMAND_TOPIC = 'aux_command_topic'
|
||||
CONF_AUX_STATE_TOPIC = 'aux_state_topic'
|
||||
|
||||
CONF_CURRENT_TEMPERATURE_TOPIC = 'current_temperature_topic'
|
||||
|
||||
CONF_PAYLOAD_ON = 'payload_on'
|
||||
CONF_PAYLOAD_OFF = 'payload_off'
|
||||
|
||||
CONF_FAN_MODE_LIST = 'fan_modes'
|
||||
CONF_MODE_LIST = 'modes'
|
||||
CONF_SWING_MODE_LIST = 'swing_modes'
|
||||
CONF_INITIAL = 'initial'
|
||||
CONF_SEND_IF_OFF = 'send_if_off'
|
||||
|
||||
PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_POWER_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(CONF_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(CONF_TEMPERATURE_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(CONF_FAN_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(CONF_SWING_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(CONF_AWAY_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(CONF_HOLD_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(CONF_AUX_COMMAND_TOPIC): mqtt.valid_publish_topic,
|
||||
vol.Optional(CONF_POWER_STATE_TOPIC): mqtt.valid_subscribe_topic,
|
||||
vol.Optional(CONF_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic,
|
||||
vol.Optional(CONF_TEMPERATURE_STATE_TOPIC): mqtt.valid_subscribe_topic,
|
||||
vol.Optional(CONF_FAN_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic,
|
||||
vol.Optional(CONF_SWING_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic,
|
||||
vol.Optional(CONF_AWAY_MODE_STATE_TOPIC): mqtt.valid_subscribe_topic,
|
||||
vol.Optional(CONF_HOLD_STATE_TOPIC): mqtt.valid_subscribe_topic,
|
||||
vol.Optional(CONF_AUX_STATE_TOPIC): mqtt.valid_subscribe_topic,
|
||||
vol.Optional(CONF_CURRENT_TEMPERATURE_TOPIC):
|
||||
mqtt.valid_subscribe_topic,
|
||||
vol.Optional(CONF_FAN_MODE_LIST,
|
||||
default=[STATE_AUTO, SPEED_LOW,
|
||||
SPEED_MEDIUM, SPEED_HIGH]): cv.ensure_list,
|
||||
vol.Optional(CONF_SWING_MODE_LIST,
|
||||
default=[STATE_ON, STATE_OFF]): cv.ensure_list,
|
||||
vol.Optional(CONF_MODE_LIST,
|
||||
default=[STATE_AUTO, STATE_OFF, STATE_COOL, STATE_HEAT,
|
||||
STATE_DRY, STATE_FAN_ONLY]): cv.ensure_list,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_INITIAL, default=21): cv.positive_int,
|
||||
vol.Optional(CONF_SEND_IF_OFF, default=True): cv.boolean,
|
||||
vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string,
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the MQTT climate devices."""
|
||||
async_add_devices([
|
||||
MqttClimate(
|
||||
hass,
|
||||
config.get(CONF_NAME),
|
||||
{
|
||||
key: config.get(key) for key in (
|
||||
CONF_POWER_COMMAND_TOPIC,
|
||||
CONF_MODE_COMMAND_TOPIC,
|
||||
CONF_TEMPERATURE_COMMAND_TOPIC,
|
||||
CONF_FAN_MODE_COMMAND_TOPIC,
|
||||
CONF_SWING_MODE_COMMAND_TOPIC,
|
||||
CONF_AWAY_MODE_COMMAND_TOPIC,
|
||||
CONF_HOLD_COMMAND_TOPIC,
|
||||
CONF_AUX_COMMAND_TOPIC,
|
||||
CONF_POWER_STATE_TOPIC,
|
||||
CONF_MODE_STATE_TOPIC,
|
||||
CONF_TEMPERATURE_STATE_TOPIC,
|
||||
CONF_FAN_MODE_STATE_TOPIC,
|
||||
CONF_SWING_MODE_STATE_TOPIC,
|
||||
CONF_AWAY_MODE_STATE_TOPIC,
|
||||
CONF_HOLD_STATE_TOPIC,
|
||||
CONF_AUX_STATE_TOPIC,
|
||||
CONF_CURRENT_TEMPERATURE_TOPIC
|
||||
)
|
||||
},
|
||||
config.get(CONF_QOS),
|
||||
config.get(CONF_RETAIN),
|
||||
config.get(CONF_MODE_LIST),
|
||||
config.get(CONF_FAN_MODE_LIST),
|
||||
config.get(CONF_SWING_MODE_LIST),
|
||||
config.get(CONF_INITIAL),
|
||||
False, None, SPEED_LOW,
|
||||
STATE_OFF, STATE_OFF, False,
|
||||
config.get(CONF_SEND_IF_OFF),
|
||||
config.get(CONF_PAYLOAD_ON),
|
||||
config.get(CONF_PAYLOAD_OFF))
|
||||
])
|
||||
|
||||
|
||||
class MqttClimate(ClimateDevice):
|
||||
"""Representation of a demo climate device."""
|
||||
|
||||
def __init__(self, hass, name, topic, qos, retain, mode_list,
|
||||
fan_mode_list, swing_mode_list, target_temperature, away,
|
||||
hold, current_fan_mode, current_swing_mode,
|
||||
current_operation, aux, send_if_off, payload_on,
|
||||
payload_off):
|
||||
"""Initialize the climate device."""
|
||||
self.hass = hass
|
||||
self._name = name
|
||||
self._topic = topic
|
||||
self._qos = qos
|
||||
self._retain = retain
|
||||
self._target_temperature = target_temperature
|
||||
self._unit_of_measurement = hass.config.units.temperature_unit
|
||||
self._away = away
|
||||
self._hold = hold
|
||||
self._current_temperature = None
|
||||
self._current_fan_mode = current_fan_mode
|
||||
self._current_operation = current_operation
|
||||
self._aux = aux
|
||||
self._current_swing_mode = current_swing_mode
|
||||
self._fan_list = fan_mode_list
|
||||
self._operation_list = mode_list
|
||||
self._swing_list = swing_mode_list
|
||||
self._target_temperature_step = 1
|
||||
self._send_if_off = send_if_off
|
||||
self._payload_on = payload_on
|
||||
self._payload_off = payload_off
|
||||
|
||||
def async_added_to_hass(self):
|
||||
"""Handle being added to home assistant."""
|
||||
@callback
|
||||
def handle_current_temp_received(topic, payload, qos):
|
||||
"""Handle current temperature coming via MQTT."""
|
||||
try:
|
||||
self._current_temperature = float(payload)
|
||||
self.async_schedule_update_ha_state()
|
||||
except ValueError:
|
||||
_LOGGER.error("Could not parse temperature from %s", payload)
|
||||
|
||||
if self._topic[CONF_CURRENT_TEMPERATURE_TOPIC] is not None:
|
||||
yield from mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_CURRENT_TEMPERATURE_TOPIC],
|
||||
handle_current_temp_received, self._qos)
|
||||
|
||||
@callback
|
||||
def handle_mode_received(topic, payload, qos):
|
||||
"""Handle receiving mode via MQTT."""
|
||||
if payload not in self._operation_list:
|
||||
_LOGGER.error("Invalid mode: %s", payload)
|
||||
else:
|
||||
self._current_operation = payload
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_MODE_STATE_TOPIC] is not None:
|
||||
yield from mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_MODE_STATE_TOPIC],
|
||||
handle_mode_received, self._qos)
|
||||
|
||||
@callback
|
||||
def handle_temperature_received(topic, payload, qos):
|
||||
"""Handle target temperature coming via MQTT."""
|
||||
try:
|
||||
self._target_temperature = float(payload)
|
||||
self.async_schedule_update_ha_state()
|
||||
except ValueError:
|
||||
_LOGGER.error("Could not parse temperature from %s", payload)
|
||||
|
||||
if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is not None:
|
||||
yield from mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_TEMPERATURE_STATE_TOPIC],
|
||||
handle_temperature_received, self._qos)
|
||||
|
||||
@callback
|
||||
def handle_fan_mode_received(topic, payload, qos):
|
||||
"""Handle receiving fan mode via MQTT."""
|
||||
if payload not in self._fan_list:
|
||||
_LOGGER.error("Invalid fan mode: %s", payload)
|
||||
else:
|
||||
self._current_fan_mode = payload
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_FAN_MODE_STATE_TOPIC] is not None:
|
||||
yield from mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_FAN_MODE_STATE_TOPIC],
|
||||
handle_fan_mode_received, self._qos)
|
||||
|
||||
@callback
|
||||
def handle_swing_mode_received(topic, payload, qos):
|
||||
"""Handle receiving swing mode via MQTT."""
|
||||
if payload not in self._swing_list:
|
||||
_LOGGER.error("Invalid swing mode: %s", payload)
|
||||
else:
|
||||
self._current_swing_mode = payload
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None:
|
||||
yield from mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_SWING_MODE_STATE_TOPIC],
|
||||
handle_swing_mode_received, self._qos)
|
||||
|
||||
@callback
|
||||
def handle_away_mode_received(topic, payload, qos):
|
||||
"""Handle receiving away mode via MQTT."""
|
||||
if payload == self._payload_on:
|
||||
self._away = True
|
||||
elif payload == self._payload_off:
|
||||
self._away = False
|
||||
else:
|
||||
_LOGGER.error("Invalid away mode: %s", payload)
|
||||
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None:
|
||||
yield from mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_AWAY_MODE_STATE_TOPIC],
|
||||
handle_away_mode_received, self._qos)
|
||||
|
||||
@callback
|
||||
def handle_aux_mode_received(topic, payload, qos):
|
||||
"""Handle receiving aux mode via MQTT."""
|
||||
if payload == self._payload_on:
|
||||
self._aux = True
|
||||
elif payload == self._payload_off:
|
||||
self._aux = False
|
||||
else:
|
||||
_LOGGER.error("Invalid aux mode: %s", payload)
|
||||
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_AUX_STATE_TOPIC] is not None:
|
||||
yield from mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_AUX_STATE_TOPIC],
|
||||
handle_aux_mode_received, self._qos)
|
||||
|
||||
@callback
|
||||
def handle_hold_mode_received(topic, payload, qos):
|
||||
"""Handle receiving hold mode via MQTT."""
|
||||
self._hold = payload
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_HOLD_STATE_TOPIC] is not None:
|
||||
yield from mqtt.async_subscribe(
|
||||
self.hass, self._topic[CONF_HOLD_STATE_TOPIC],
|
||||
handle_hold_mode_received, self._qos)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Return the polling state."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the climate device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement."""
|
||||
return self._unit_of_measurement
|
||||
|
||||
@property
|
||||
def current_temperature(self):
|
||||
"""Return the current temperature."""
|
||||
return self._current_temperature
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
return self._target_temperature
|
||||
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""Return current operation ie. heat, cool, idle."""
|
||||
return self._current_operation
|
||||
|
||||
@property
|
||||
def operation_list(self):
|
||||
"""Return the list of available operation modes."""
|
||||
return self._operation_list
|
||||
|
||||
@property
|
||||
def target_temperature_step(self):
|
||||
"""Return the supported step of target temperature."""
|
||||
return self._target_temperature_step
|
||||
|
||||
@property
|
||||
def is_away_mode_on(self):
|
||||
"""Return if away mode is on."""
|
||||
return self._away
|
||||
|
||||
@property
|
||||
def current_hold_mode(self):
|
||||
"""Return hold mode setting."""
|
||||
return self._hold
|
||||
|
||||
@property
|
||||
def is_aux_heat_on(self):
|
||||
"""Return true if away mode is on."""
|
||||
return self._aux
|
||||
|
||||
@property
|
||||
def current_fan_mode(self):
|
||||
"""Return the fan setting."""
|
||||
return self._current_fan_mode
|
||||
|
||||
@property
|
||||
def fan_list(self):
|
||||
"""Return the list of available fan modes."""
|
||||
return self._fan_list
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_temperature(self, **kwargs):
|
||||
"""Set new target temperatures."""
|
||||
if kwargs.get(ATTR_OPERATION_MODE) is not None:
|
||||
operation_mode = kwargs.get(ATTR_OPERATION_MODE)
|
||||
yield from self.async_set_operation_mode(operation_mode)
|
||||
|
||||
if kwargs.get(ATTR_TEMPERATURE) is not None:
|
||||
if self._topic[CONF_TEMPERATURE_STATE_TOPIC] is None:
|
||||
# optimistic mode
|
||||
self._target_temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
|
||||
if self._send_if_off or self._current_operation != STATE_OFF:
|
||||
mqtt.async_publish(
|
||||
self.hass, self._topic[CONF_TEMPERATURE_COMMAND_TOPIC],
|
||||
kwargs.get(ATTR_TEMPERATURE), self._qos, self._retain)
|
||||
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_swing_mode(self, swing_mode):
|
||||
"""Set new swing mode."""
|
||||
if self._send_if_off or self._current_operation != STATE_OFF:
|
||||
mqtt.async_publish(
|
||||
self.hass, self._topic[CONF_SWING_MODE_COMMAND_TOPIC],
|
||||
swing_mode, self._qos, self._retain)
|
||||
|
||||
if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None:
|
||||
self._current_swing_mode = swing_mode
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_fan_mode(self, fan):
|
||||
"""Set new target temperature."""
|
||||
if self._send_if_off or self._current_operation != STATE_OFF:
|
||||
mqtt.async_publish(
|
||||
self.hass, self._topic[CONF_FAN_MODE_COMMAND_TOPIC],
|
||||
fan, self._qos, self._retain)
|
||||
|
||||
if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None:
|
||||
self._current_fan_mode = fan
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_operation_mode(self, operation_mode) -> None:
|
||||
"""Set new operation mode."""
|
||||
if self._topic[CONF_POWER_COMMAND_TOPIC] is not None:
|
||||
if (self._current_operation == STATE_OFF and
|
||||
operation_mode != STATE_OFF):
|
||||
mqtt.async_publish(
|
||||
self.hass, self._topic[CONF_POWER_COMMAND_TOPIC],
|
||||
self._payload_on, self._qos, self._retain)
|
||||
elif (self._current_operation != STATE_OFF and
|
||||
operation_mode == STATE_OFF):
|
||||
mqtt.async_publish(
|
||||
self.hass, self._topic[CONF_POWER_COMMAND_TOPIC],
|
||||
self._payload_off, self._qos, self._retain)
|
||||
|
||||
if self._topic[CONF_MODE_COMMAND_TOPIC] is not None:
|
||||
mqtt.async_publish(
|
||||
self.hass, self._topic[CONF_MODE_COMMAND_TOPIC],
|
||||
operation_mode, self._qos, self._retain)
|
||||
|
||||
if self._topic[CONF_MODE_STATE_TOPIC] is None:
|
||||
self._current_operation = operation_mode
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@property
|
||||
def current_swing_mode(self):
|
||||
"""Return the swing setting."""
|
||||
return self._current_swing_mode
|
||||
|
||||
@property
|
||||
def swing_list(self):
|
||||
"""List of available swing modes."""
|
||||
return self._swing_list
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_away_mode_on(self):
|
||||
"""Turn away mode on."""
|
||||
if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None:
|
||||
mqtt.async_publish(self.hass,
|
||||
self._topic[CONF_AWAY_MODE_COMMAND_TOPIC],
|
||||
self._payload_on, self._qos, self._retain)
|
||||
|
||||
if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None:
|
||||
self._away = True
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_away_mode_off(self):
|
||||
"""Turn away mode off."""
|
||||
if self._topic[CONF_AWAY_MODE_COMMAND_TOPIC] is not None:
|
||||
mqtt.async_publish(self.hass,
|
||||
self._topic[CONF_AWAY_MODE_COMMAND_TOPIC],
|
||||
self._payload_off, self._qos, self._retain)
|
||||
|
||||
if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None:
|
||||
self._away = False
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_hold_mode(self, hold):
|
||||
"""Update hold mode on."""
|
||||
if self._topic[CONF_HOLD_COMMAND_TOPIC] is not None:
|
||||
mqtt.async_publish(self.hass,
|
||||
self._topic[CONF_HOLD_COMMAND_TOPIC],
|
||||
hold, self._qos, self._retain)
|
||||
|
||||
if self._topic[CONF_HOLD_STATE_TOPIC] is None:
|
||||
self._hold = hold
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_aux_heat_on(self):
|
||||
"""Turn auxillary heater on."""
|
||||
if self._topic[CONF_AUX_COMMAND_TOPIC] is not None:
|
||||
mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC],
|
||||
self._payload_on, self._qos, self._retain)
|
||||
|
||||
if self._topic[CONF_AUX_STATE_TOPIC] is None:
|
||||
self._aux = True
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_aux_heat_off(self):
|
||||
"""Turn auxillary heater off."""
|
||||
if self._topic[CONF_AUX_COMMAND_TOPIC] is not None:
|
||||
mqtt.async_publish(self.hass, self._topic[CONF_AUX_COMMAND_TOPIC],
|
||||
self._payload_off, self._qos, self._retain)
|
||||
|
||||
if self._topic[CONF_AUX_STATE_TOPIC] is None:
|
||||
self._aux = False
|
||||
self.async_schedule_update_ha_state()
|
||||
@@ -1,5 +1,5 @@
|
||||
set_aux_heat:
|
||||
description: Turn auxillary heater on/off for climate device
|
||||
description: Turn auxiliary heater on/off for climate device
|
||||
|
||||
fields:
|
||||
entity_id:
|
||||
|
||||
@@ -1,30 +1,45 @@
|
||||
"""
|
||||
Support for Wink thermostats.
|
||||
Support for Wink thermostats, Air Conditioners, and Water Heaters.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/climate.wink/
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
from homeassistant.components.wink import WinkDevice, DOMAIN
|
||||
from homeassistant.components.climate import (
|
||||
STATE_AUTO, STATE_COOL, STATE_HEAT, ClimateDevice,
|
||||
ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW,
|
||||
ATTR_TEMPERATURE,
|
||||
ATTR_CURRENT_HUMIDITY)
|
||||
ATTR_TEMPERATURE, STATE_FAN_ONLY,
|
||||
ATTR_CURRENT_HUMIDITY, STATE_ECO, STATE_ELECTRIC,
|
||||
STATE_PERFORMANCE, STATE_HIGH_DEMAND,
|
||||
STATE_HEAT_PUMP, STATE_GAS)
|
||||
from homeassistant.const import (
|
||||
TEMP_CELSIUS, STATE_ON,
|
||||
STATE_OFF, STATE_UNKNOWN)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['wink']
|
||||
|
||||
STATE_AUX = 'aux'
|
||||
STATE_ECO = 'eco'
|
||||
STATE_FAN = 'fan'
|
||||
SPEED_LOW = 'low'
|
||||
SPEED_MEDIUM = 'medium'
|
||||
SPEED_HIGH = 'high'
|
||||
|
||||
HA_STATE_TO_WINK = {STATE_AUTO: 'auto',
|
||||
STATE_ECO: 'eco',
|
||||
STATE_FAN_ONLY: 'fan_only',
|
||||
STATE_HEAT: 'heat_only',
|
||||
STATE_COOL: 'cool_only',
|
||||
STATE_PERFORMANCE: 'performance',
|
||||
STATE_HIGH_DEMAND: 'high_demand',
|
||||
STATE_HEAT_PUMP: 'heat_pump',
|
||||
STATE_ELECTRIC: 'electric_only',
|
||||
STATE_GAS: 'gas',
|
||||
STATE_OFF: 'off'}
|
||||
WINK_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_WINK.items()}
|
||||
|
||||
ATTR_EXTERNAL_TEMPERATURE = "external_temperature"
|
||||
ATTR_SMART_TEMPERATURE = "smart_temperature"
|
||||
ATTR_ECO_TARGET = "eco_target"
|
||||
@@ -32,28 +47,26 @@ ATTR_OCCUPIED = "occupied"
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the Wink thermostat."""
|
||||
"""Set up the Wink climate devices."""
|
||||
import pywink
|
||||
temp_unit = hass.config.units.temperature_unit
|
||||
for climate in pywink.get_thermostats():
|
||||
_id = climate.object_id() + climate.name()
|
||||
if _id not in hass.data[DOMAIN]['unique_ids']:
|
||||
add_devices([WinkThermostat(climate, hass, temp_unit)])
|
||||
add_devices([WinkThermostat(climate, hass)])
|
||||
for climate in pywink.get_air_conditioners():
|
||||
_id = climate.object_id() + climate.name()
|
||||
if _id not in hass.data[DOMAIN]['unique_ids']:
|
||||
add_devices([WinkAC(climate, hass, temp_unit)])
|
||||
add_devices([WinkAC(climate, hass)])
|
||||
for water_heater in pywink.get_water_heaters():
|
||||
_id = water_heater.object_id() + water_heater.name()
|
||||
if _id not in hass.data[DOMAIN]['unique_ids']:
|
||||
add_devices([WinkWaterHeater(water_heater, hass)])
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class WinkThermostat(WinkDevice, ClimateDevice):
|
||||
"""Representation of a Wink thermostat."""
|
||||
|
||||
def __init__(self, wink, hass, temp_unit):
|
||||
"""Initialize the Wink device."""
|
||||
super().__init__(wink, hass)
|
||||
self._config_temp_unit = temp_unit
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Callback when entity is added to hass."""
|
||||
@@ -139,18 +152,12 @@ class WinkThermostat(WinkDevice, ClimateDevice):
|
||||
"""Return current operation ie. heat, cool, idle."""
|
||||
if not self.wink.is_on():
|
||||
current_op = STATE_OFF
|
||||
elif self.wink.current_hvac_mode() == 'cool_only':
|
||||
current_op = STATE_COOL
|
||||
elif self.wink.current_hvac_mode() == 'heat_only':
|
||||
current_op = STATE_HEAT
|
||||
elif self.wink.current_hvac_mode() == 'aux':
|
||||
current_op = STATE_HEAT
|
||||
elif self.wink.current_hvac_mode() == 'auto':
|
||||
current_op = STATE_AUTO
|
||||
elif self.wink.current_hvac_mode() == 'eco':
|
||||
current_op = STATE_ECO
|
||||
else:
|
||||
current_op = STATE_UNKNOWN
|
||||
current_op = WINK_STATE_TO_HA.get(self.wink.current_hvac_mode())
|
||||
if current_op == 'aux':
|
||||
return STATE_HEAT
|
||||
if current_op is None:
|
||||
current_op = STATE_UNKNOWN
|
||||
return current_op
|
||||
|
||||
@property
|
||||
@@ -199,11 +206,12 @@ class WinkThermostat(WinkDevice, ClimateDevice):
|
||||
@property
|
||||
def is_aux_heat_on(self):
|
||||
"""Return true if aux heater."""
|
||||
if self.wink.current_hvac_mode() == 'aux' and self.wink.is_on():
|
||||
if 'aux' not in self.wink.hvac_modes():
|
||||
return None
|
||||
|
||||
if self.wink.current_hvac_mode() == 'aux':
|
||||
return True
|
||||
elif self.wink.current_hvac_mode() == 'aux' and not self.wink.is_on():
|
||||
return False
|
||||
return None
|
||||
return False
|
||||
|
||||
def set_temperature(self, **kwargs):
|
||||
"""Set new target temperature."""
|
||||
@@ -223,32 +231,27 @@ class WinkThermostat(WinkDevice, ClimateDevice):
|
||||
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set operation mode."""
|
||||
if operation_mode == STATE_HEAT:
|
||||
self.wink.set_operation_mode('heat_only')
|
||||
elif operation_mode == STATE_COOL:
|
||||
self.wink.set_operation_mode('cool_only')
|
||||
elif operation_mode == STATE_AUTO:
|
||||
self.wink.set_operation_mode('auto')
|
||||
elif operation_mode == STATE_OFF:
|
||||
self.wink.set_operation_mode('off')
|
||||
elif operation_mode == STATE_AUX:
|
||||
self.wink.set_operation_mode('aux')
|
||||
elif operation_mode == STATE_ECO:
|
||||
self.wink.set_operation_mode('eco')
|
||||
op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode)
|
||||
# The only way to disable aux heat is with the toggle
|
||||
if self.is_aux_heat_on and op_mode_to_set == STATE_HEAT:
|
||||
return
|
||||
self.wink.set_operation_mode(op_mode_to_set)
|
||||
|
||||
@property
|
||||
def operation_list(self):
|
||||
"""List of available operation modes."""
|
||||
op_list = ['off']
|
||||
modes = self.wink.hvac_modes()
|
||||
if 'cool_only' in modes:
|
||||
op_list.append(STATE_COOL)
|
||||
if 'heat_only' in modes or 'aux' in modes:
|
||||
op_list.append(STATE_HEAT)
|
||||
if 'auto' in modes:
|
||||
op_list.append(STATE_AUTO)
|
||||
if 'eco' in modes:
|
||||
op_list.append(STATE_ECO)
|
||||
for mode in modes:
|
||||
if mode == 'aux':
|
||||
continue
|
||||
ha_mode = WINK_STATE_TO_HA.get(mode)
|
||||
if ha_mode is not None:
|
||||
op_list.append(ha_mode)
|
||||
else:
|
||||
error = "Invaid operation mode mapping. " + mode + \
|
||||
" doesn't map. Please report this."
|
||||
_LOGGER.error(error)
|
||||
return op_list
|
||||
|
||||
def turn_away_mode_on(self):
|
||||
@@ -281,12 +284,12 @@ class WinkThermostat(WinkDevice, ClimateDevice):
|
||||
self.wink.set_fan_mode(fan.lower())
|
||||
|
||||
def turn_aux_heat_on(self):
|
||||
"""Turn auxillary heater on."""
|
||||
self.set_operation_mode(STATE_AUX)
|
||||
"""Turn auxiliary heater on."""
|
||||
self.wink.set_operation_mode('aux')
|
||||
|
||||
def turn_aux_heat_off(self):
|
||||
"""Turn auxillary heater off."""
|
||||
self.set_operation_mode(STATE_AUTO)
|
||||
"""Turn auxiliary heater off."""
|
||||
self.set_operation_mode(STATE_HEAT)
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
@@ -344,11 +347,6 @@ class WinkThermostat(WinkDevice, ClimateDevice):
|
||||
class WinkAC(WinkDevice, ClimateDevice):
|
||||
"""Representation of a Wink air conditioner."""
|
||||
|
||||
def __init__(self, wink, hass, temp_unit):
|
||||
"""Initialize the Wink device."""
|
||||
super().__init__(wink, hass)
|
||||
self._config_temp_unit = temp_unit
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement."""
|
||||
@@ -382,14 +380,10 @@ class WinkAC(WinkDevice, ClimateDevice):
|
||||
"""Return current operation ie. heat, cool, idle."""
|
||||
if not self.wink.is_on():
|
||||
current_op = STATE_OFF
|
||||
elif self.wink.current_mode() == 'cool_only':
|
||||
current_op = STATE_COOL
|
||||
elif self.wink.current_mode() == 'auto_eco':
|
||||
current_op = STATE_ECO
|
||||
elif self.wink.current_mode() == 'fan_only':
|
||||
current_op = STATE_FAN
|
||||
else:
|
||||
current_op = STATE_UNKNOWN
|
||||
current_op = WINK_STATE_TO_HA.get(self.wink.current_hvac_mode())
|
||||
if current_op is None:
|
||||
current_op = STATE_UNKNOWN
|
||||
return current_op
|
||||
|
||||
@property
|
||||
@@ -397,12 +391,14 @@ class WinkAC(WinkDevice, ClimateDevice):
|
||||
"""List of available operation modes."""
|
||||
op_list = ['off']
|
||||
modes = self.wink.modes()
|
||||
if 'cool_only' in modes:
|
||||
op_list.append(STATE_COOL)
|
||||
if 'auto_eco' in modes:
|
||||
op_list.append(STATE_ECO)
|
||||
if 'fan_only' in modes:
|
||||
op_list.append(STATE_FAN)
|
||||
for mode in modes:
|
||||
ha_mode = WINK_STATE_TO_HA.get(mode)
|
||||
if ha_mode is not None:
|
||||
op_list.append(ha_mode)
|
||||
else:
|
||||
error = "Invaid operation mode mapping. " + mode + \
|
||||
" doesn't map. Please report this."
|
||||
_LOGGER.error(error)
|
||||
return op_list
|
||||
|
||||
def set_temperature(self, **kwargs):
|
||||
@@ -412,30 +408,16 @@ class WinkAC(WinkDevice, ClimateDevice):
|
||||
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set operation mode."""
|
||||
if operation_mode == STATE_COOL:
|
||||
self.wink.set_operation_mode('cool_only')
|
||||
elif operation_mode == STATE_ECO:
|
||||
self.wink.set_operation_mode('auto_eco')
|
||||
elif operation_mode == STATE_OFF:
|
||||
self.wink.set_operation_mode('off')
|
||||
elif operation_mode == STATE_FAN:
|
||||
self.wink.set_operation_mode('fan_only')
|
||||
op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode)
|
||||
if op_mode_to_set == 'eco':
|
||||
op_mode_to_set = 'auto_eco'
|
||||
self.wink.set_operation_mode(op_mode_to_set)
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
return self.wink.current_max_set_point()
|
||||
|
||||
@property
|
||||
def target_temperature_low(self):
|
||||
"""Only supports cool."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def target_temperature_high(self):
|
||||
"""Only supports cool."""
|
||||
return None
|
||||
|
||||
@property
|
||||
def current_fan_mode(self):
|
||||
"""Return the current fan mode."""
|
||||
@@ -453,12 +435,97 @@ class WinkAC(WinkDevice, ClimateDevice):
|
||||
"""Return a list of available fan modes."""
|
||||
return [SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
|
||||
|
||||
def set_fan_mode(self, mode):
|
||||
def set_fan_mode(self, fan):
|
||||
"""Set fan speed."""
|
||||
if mode == SPEED_LOW:
|
||||
if fan == SPEED_LOW:
|
||||
speed = 0.4
|
||||
elif mode == SPEED_MEDIUM:
|
||||
elif fan == SPEED_MEDIUM:
|
||||
speed = 0.8
|
||||
elif mode == SPEED_HIGH:
|
||||
elif fan == SPEED_HIGH:
|
||||
speed = 1.0
|
||||
self.wink.set_ac_fan_speed(speed)
|
||||
|
||||
|
||||
class WinkWaterHeater(WinkDevice, ClimateDevice):
|
||||
"""Representation of a Wink water heater."""
|
||||
|
||||
@property
|
||||
def temperature_unit(self):
|
||||
"""Return the unit of measurement."""
|
||||
# The Wink API always returns temp in Celsius
|
||||
return TEMP_CELSIUS
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Return the optional state attributes."""
|
||||
data = {}
|
||||
data["vacation_mode"] = self.wink.vacation_mode_enabled()
|
||||
data["rheem_type"] = self.wink.rheem_type()
|
||||
|
||||
return data
|
||||
|
||||
@property
|
||||
def current_operation(self):
|
||||
"""
|
||||
Return current operation one of the following.
|
||||
|
||||
["eco", "performance", "heat_pump",
|
||||
"high_demand", "electric_only", "gas]
|
||||
"""
|
||||
if not self.wink.is_on():
|
||||
current_op = STATE_OFF
|
||||
else:
|
||||
current_op = WINK_STATE_TO_HA.get(self.wink.current_mode())
|
||||
if current_op is None:
|
||||
current_op = STATE_UNKNOWN
|
||||
return current_op
|
||||
|
||||
@property
|
||||
def operation_list(self):
|
||||
"""List of available operation modes."""
|
||||
op_list = ['off']
|
||||
modes = self.wink.modes()
|
||||
for mode in modes:
|
||||
if mode == 'aux':
|
||||
continue
|
||||
ha_mode = WINK_STATE_TO_HA.get(mode)
|
||||
if ha_mode is not None:
|
||||
op_list.append(ha_mode)
|
||||
else:
|
||||
error = "Invaid operation mode mapping. " + mode + \
|
||||
" doesn't map. Please report this."
|
||||
_LOGGER.error(error)
|
||||
return op_list
|
||||
|
||||
def set_temperature(self, **kwargs):
|
||||
"""Set new target temperature."""
|
||||
target_temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
self.wink.set_temperature(target_temp)
|
||||
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set operation mode."""
|
||||
op_mode_to_set = HA_STATE_TO_WINK.get(operation_mode)
|
||||
self.wink.set_operation_mode(op_mode_to_set)
|
||||
|
||||
@property
|
||||
def target_temperature(self):
|
||||
"""Return the temperature we try to reach."""
|
||||
return self.wink.current_set_point()
|
||||
|
||||
def turn_away_mode_on(self):
|
||||
"""Turn away on."""
|
||||
self.wink.set_vacation_mode(True)
|
||||
|
||||
def turn_away_mode_off(self):
|
||||
"""Turn away off."""
|
||||
self.wink.set_vacation_mode(False)
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
"""Return the minimum temperature."""
|
||||
return self.wink.min_set_point()
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
"""Return the maximum temperature."""
|
||||
return self.wink.max_set_point()
|
||||
|
||||
@@ -4,10 +4,11 @@ import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from . import http_api, cloud_api
|
||||
from . import http_api, auth_api
|
||||
from .const import DOMAIN
|
||||
|
||||
|
||||
REQUIREMENTS = ['warrant==0.2.0']
|
||||
DEPENDENCIES = ['http']
|
||||
CONF_MODE = 'mode'
|
||||
MODE_DEV = 'development'
|
||||
@@ -40,10 +41,7 @@ def async_setup(hass, config):
|
||||
'mode': mode
|
||||
}
|
||||
|
||||
cloud = yield from cloud_api.async_load_auth(hass)
|
||||
|
||||
if cloud is not None:
|
||||
data['cloud'] = cloud
|
||||
data['auth'] = yield from hass.async_add_job(auth_api.load_auth, hass)
|
||||
|
||||
yield from http_api.async_setup(hass)
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
"""Package to offer tools to authenticate with the cloud."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from .const import AUTH_FILE, SERVERS
|
||||
from .util import get_mode
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CloudError(Exception):
|
||||
"""Base class for cloud related errors."""
|
||||
|
||||
|
||||
class Unauthenticated(CloudError):
|
||||
"""Raised when authentication failed."""
|
||||
|
||||
|
||||
class UserNotFound(CloudError):
|
||||
"""Raised when a user is not found."""
|
||||
|
||||
|
||||
class UserNotConfirmed(CloudError):
|
||||
"""Raised when a user has not confirmed email yet."""
|
||||
|
||||
|
||||
class ExpiredCode(CloudError):
|
||||
"""Raised when an expired code is encoutered."""
|
||||
|
||||
|
||||
class InvalidCode(CloudError):
|
||||
"""Raised when an invalid code is submitted."""
|
||||
|
||||
|
||||
class PasswordChangeRequired(CloudError):
|
||||
"""Raised when a password change is required."""
|
||||
|
||||
def __init__(self, message='Password change required.'):
|
||||
"""Initialize a password change required error."""
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class UnknownError(CloudError):
|
||||
"""Raised when an unknown error occurrs."""
|
||||
|
||||
|
||||
AWS_EXCEPTIONS = {
|
||||
'UserNotFoundException': UserNotFound,
|
||||
'NotAuthorizedException': Unauthenticated,
|
||||
'ExpiredCodeException': ExpiredCode,
|
||||
'UserNotConfirmedException': UserNotConfirmed,
|
||||
'PasswordResetRequiredException': PasswordChangeRequired,
|
||||
'CodeMismatchException': InvalidCode,
|
||||
}
|
||||
|
||||
|
||||
def _map_aws_exception(err):
|
||||
"""Map AWS exception to our exceptions."""
|
||||
ex = AWS_EXCEPTIONS.get(err.response['Error']['Code'], UnknownError)
|
||||
return ex(err.response['Error']['Message'])
|
||||
|
||||
|
||||
def load_auth(hass):
|
||||
"""Load authentication from disk and verify it."""
|
||||
info = _read_info(hass)
|
||||
|
||||
if info is None:
|
||||
return Auth(hass)
|
||||
|
||||
auth = Auth(hass, _cognito(
|
||||
hass,
|
||||
id_token=info['id_token'],
|
||||
access_token=info['access_token'],
|
||||
refresh_token=info['refresh_token'],
|
||||
))
|
||||
|
||||
if auth.validate_auth():
|
||||
return auth
|
||||
|
||||
return Auth(hass)
|
||||
|
||||
|
||||
def register(hass, email, password):
|
||||
"""Register a new account."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
cognito = _cognito(hass, username=email)
|
||||
try:
|
||||
cognito.register(email, password)
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def confirm_register(hass, confirmation_code, email):
|
||||
"""Confirm confirmation code after registration."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
cognito = _cognito(hass, username=email)
|
||||
try:
|
||||
cognito.confirm_sign_up(confirmation_code, email)
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def forgot_password(hass, email):
|
||||
"""Initiate forgotten password flow."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
cognito = _cognito(hass, username=email)
|
||||
try:
|
||||
cognito.initiate_forgot_password()
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def confirm_forgot_password(hass, confirmation_code, email, new_password):
|
||||
"""Confirm forgotten password code and change password."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
cognito = _cognito(hass, username=email)
|
||||
try:
|
||||
cognito.confirm_forgot_password(confirmation_code, new_password)
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
class Auth(object):
|
||||
"""Class that holds Cloud authentication."""
|
||||
|
||||
def __init__(self, hass, cognito=None):
|
||||
"""Initialize Hass cloud info object."""
|
||||
self.hass = hass
|
||||
self.cognito = cognito
|
||||
self.account = None
|
||||
|
||||
@property
|
||||
def is_logged_in(self):
|
||||
"""Return if user is logged in."""
|
||||
return self.account is not None
|
||||
|
||||
def validate_auth(self):
|
||||
"""Validate that the contained auth is valid."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
try:
|
||||
self._refresh_account_info()
|
||||
except ClientError as err:
|
||||
if err.response['Error']['Code'] != 'NotAuthorizedException':
|
||||
_LOGGER.error('Unexpected error verifying auth: %s', err)
|
||||
return False
|
||||
|
||||
try:
|
||||
self.renew_access_token()
|
||||
self._refresh_account_info()
|
||||
except ClientError:
|
||||
_LOGGER.error('Unable to refresh auth token: %s', err)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def login(self, username, password):
|
||||
"""Login using a username and password."""
|
||||
from botocore.exceptions import ClientError
|
||||
from warrant.exceptions import ForceChangePasswordException
|
||||
|
||||
cognito = _cognito(self.hass, username=username)
|
||||
|
||||
try:
|
||||
cognito.authenticate(password=password)
|
||||
self.cognito = cognito
|
||||
self._refresh_account_info()
|
||||
_write_info(self.hass, self)
|
||||
|
||||
except ForceChangePasswordException as err:
|
||||
raise PasswordChangeRequired
|
||||
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
def _refresh_account_info(self):
|
||||
"""Refresh the account info.
|
||||
|
||||
Raises boto3 exceptions.
|
||||
"""
|
||||
self.account = self.cognito.get_user()
|
||||
|
||||
def renew_access_token(self):
|
||||
"""Refresh token."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
try:
|
||||
self.cognito.renew_access_token()
|
||||
_write_info(self.hass, self)
|
||||
return True
|
||||
except ClientError as err:
|
||||
_LOGGER.error('Error refreshing token: %s', err)
|
||||
return False
|
||||
|
||||
def logout(self):
|
||||
"""Invalidate token."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
try:
|
||||
self.cognito.logout()
|
||||
self.account = None
|
||||
_write_info(self.hass, self)
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def _read_info(hass):
|
||||
"""Read auth file."""
|
||||
path = hass.config.path(AUTH_FILE)
|
||||
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
|
||||
with open(path) as file:
|
||||
return json.load(file).get(get_mode(hass))
|
||||
|
||||
|
||||
def _write_info(hass, auth):
|
||||
"""Write auth info for specified mode.
|
||||
|
||||
Pass in None for data to remove authentication for that mode.
|
||||
"""
|
||||
path = hass.config.path(AUTH_FILE)
|
||||
mode = get_mode(hass)
|
||||
|
||||
if os.path.isfile(path):
|
||||
with open(path) as file:
|
||||
content = json.load(file)
|
||||
else:
|
||||
content = {}
|
||||
|
||||
if auth.is_logged_in:
|
||||
content[mode] = {
|
||||
'id_token': auth.cognito.id_token,
|
||||
'access_token': auth.cognito.access_token,
|
||||
'refresh_token': auth.cognito.refresh_token,
|
||||
}
|
||||
else:
|
||||
content.pop(mode, None)
|
||||
|
||||
with open(path, 'wt') as file:
|
||||
file.write(json.dumps(content, indent=4, sort_keys=True))
|
||||
|
||||
|
||||
def _cognito(hass, **kwargs):
|
||||
"""Get the client credentials."""
|
||||
from warrant import Cognito
|
||||
|
||||
mode = get_mode(hass)
|
||||
|
||||
info = SERVERS.get(mode)
|
||||
|
||||
if info is None:
|
||||
raise ValueError('Mode {} is not supported.'.format(mode))
|
||||
|
||||
cognito = Cognito(
|
||||
user_pool_id=info['identity_pool_id'],
|
||||
client_id=info['client_id'],
|
||||
user_pool_region=info['region'],
|
||||
access_key=info['access_key_id'],
|
||||
secret_key=info['secret_access_key'],
|
||||
**kwargs
|
||||
)
|
||||
|
||||
return cognito
|
||||
@@ -1,297 +0,0 @@
|
||||
"""Package to offer tools to communicate with the cloud."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .const import AUTH_FILE, REQUEST_TIMEOUT, SERVERS
|
||||
from .util import get_mode
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
URL_CREATE_TOKEN = 'o/token/'
|
||||
URL_REVOKE_TOKEN = 'o/revoke_token/'
|
||||
URL_ACCOUNT = 'account.json'
|
||||
|
||||
|
||||
class CloudError(Exception):
|
||||
"""Base class for cloud related errors."""
|
||||
|
||||
def __init__(self, reason=None, status=None):
|
||||
"""Initialize a cloud error."""
|
||||
super().__init__(reason)
|
||||
self.status = status
|
||||
|
||||
|
||||
class Unauthenticated(CloudError):
|
||||
"""Raised when authentication failed."""
|
||||
|
||||
|
||||
class UnknownError(CloudError):
|
||||
"""Raised when an unknown error occurred."""
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_load_auth(hass):
|
||||
"""Load authentication from disk and verify it."""
|
||||
auth = yield from hass.async_add_job(_read_auth, hass)
|
||||
|
||||
if not auth:
|
||||
return None
|
||||
|
||||
cloud = Cloud(hass, auth)
|
||||
|
||||
try:
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
auth_check = yield from cloud.async_refresh_account_info()
|
||||
|
||||
if not auth_check:
|
||||
_LOGGER.error('Unable to validate credentials.')
|
||||
return None
|
||||
|
||||
return cloud
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.error('Unable to reach server to validate credentials.')
|
||||
return None
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_login(hass, username, password, scope=None):
|
||||
"""Get a token using a username and password.
|
||||
|
||||
Returns a coroutine.
|
||||
"""
|
||||
data = {
|
||||
'grant_type': 'password',
|
||||
'username': username,
|
||||
'password': password
|
||||
}
|
||||
if scope is not None:
|
||||
data['scope'] = scope
|
||||
|
||||
auth = yield from _async_get_token(hass, data)
|
||||
|
||||
yield from hass.async_add_job(_write_auth, hass, auth)
|
||||
|
||||
return Cloud(hass, auth)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def _async_get_token(hass, data):
|
||||
"""Get a new token and return it as a dictionary.
|
||||
|
||||
Raises exceptions when errors occur:
|
||||
- Unauthenticated
|
||||
- UnknownError
|
||||
"""
|
||||
session = async_get_clientsession(hass)
|
||||
auth = aiohttp.BasicAuth(*_client_credentials(hass))
|
||||
|
||||
try:
|
||||
req = yield from session.post(
|
||||
_url(hass, URL_CREATE_TOKEN),
|
||||
data=data,
|
||||
auth=auth
|
||||
)
|
||||
|
||||
if req.status == 401:
|
||||
_LOGGER.error('Cloud login failed: %d', req.status)
|
||||
raise Unauthenticated(status=req.status)
|
||||
elif req.status != 200:
|
||||
_LOGGER.error('Cloud login failed: %d', req.status)
|
||||
raise UnknownError(status=req.status)
|
||||
|
||||
response = yield from req.json()
|
||||
response['expires_at'] = \
|
||||
(utcnow() + timedelta(seconds=response['expires_in'])).isoformat()
|
||||
|
||||
return response
|
||||
|
||||
except aiohttp.ClientError:
|
||||
raise UnknownError()
|
||||
|
||||
|
||||
class Cloud:
|
||||
"""Store Hass Cloud info."""
|
||||
|
||||
def __init__(self, hass, auth):
|
||||
"""Initialize Hass cloud info object."""
|
||||
self.hass = hass
|
||||
self.auth = auth
|
||||
self.account = None
|
||||
|
||||
@property
|
||||
def access_token(self):
|
||||
"""Return access token."""
|
||||
return self.auth['access_token']
|
||||
|
||||
@property
|
||||
def refresh_token(self):
|
||||
"""Get refresh token."""
|
||||
return self.auth['refresh_token']
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_refresh_account_info(self):
|
||||
"""Refresh the account info."""
|
||||
req = yield from self.async_request('get', URL_ACCOUNT)
|
||||
|
||||
if req.status != 200:
|
||||
return False
|
||||
|
||||
self.account = yield from req.json()
|
||||
return True
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_refresh_access_token(self):
|
||||
"""Get a token using a refresh token."""
|
||||
try:
|
||||
self.auth = yield from _async_get_token(self.hass, {
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': self.refresh_token,
|
||||
})
|
||||
|
||||
yield from self.hass.async_add_job(
|
||||
_write_auth, self.hass, self.auth)
|
||||
|
||||
return True
|
||||
except CloudError:
|
||||
return False
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_revoke_access_token(self):
|
||||
"""Revoke active access token."""
|
||||
session = async_get_clientsession(self.hass)
|
||||
client_id, client_secret = _client_credentials(self.hass)
|
||||
data = {
|
||||
'token': self.access_token,
|
||||
'client_id': client_id,
|
||||
'client_secret': client_secret
|
||||
}
|
||||
try:
|
||||
req = yield from session.post(
|
||||
_url(self.hass, URL_REVOKE_TOKEN),
|
||||
data=data,
|
||||
)
|
||||
|
||||
if req.status != 200:
|
||||
_LOGGER.error('Cloud logout failed: %d', req.status)
|
||||
raise UnknownError(status=req.status)
|
||||
|
||||
self.auth = None
|
||||
yield from self.hass.async_add_job(
|
||||
_write_auth, self.hass, None)
|
||||
|
||||
except aiohttp.ClientError:
|
||||
raise UnknownError()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_request(self, method, path, **kwargs):
|
||||
"""Make a request to Home Assistant cloud.
|
||||
|
||||
Will refresh the token if necessary.
|
||||
"""
|
||||
session = async_get_clientsession(self.hass)
|
||||
url = _url(self.hass, path)
|
||||
|
||||
if 'headers' not in kwargs:
|
||||
kwargs['headers'] = {}
|
||||
|
||||
kwargs['headers']['authorization'] = \
|
||||
'Bearer {}'.format(self.access_token)
|
||||
|
||||
request = yield from session.request(method, url, **kwargs)
|
||||
|
||||
if request.status != 403:
|
||||
return request
|
||||
|
||||
# Maybe token expired. Try refreshing it.
|
||||
reauth = yield from self.async_refresh_access_token()
|
||||
|
||||
if not reauth:
|
||||
return request
|
||||
|
||||
# Release old connection back to the pool.
|
||||
yield from request.release()
|
||||
|
||||
kwargs['headers']['authorization'] = \
|
||||
'Bearer {}'.format(self.access_token)
|
||||
|
||||
# If we are not already fetching the account info,
|
||||
# refresh the account info.
|
||||
|
||||
if path != URL_ACCOUNT:
|
||||
yield from self.async_refresh_account_info()
|
||||
|
||||
request = yield from session.request(method, url, **kwargs)
|
||||
|
||||
return request
|
||||
|
||||
|
||||
def _read_auth(hass):
|
||||
"""Read auth file."""
|
||||
path = hass.config.path(AUTH_FILE)
|
||||
|
||||
if not os.path.isfile(path):
|
||||
return None
|
||||
|
||||
with open(path) as file:
|
||||
return json.load(file).get(get_mode(hass))
|
||||
|
||||
|
||||
def _write_auth(hass, data):
|
||||
"""Write auth info for specified mode.
|
||||
|
||||
Pass in None for data to remove authentication for that mode.
|
||||
"""
|
||||
path = hass.config.path(AUTH_FILE)
|
||||
mode = get_mode(hass)
|
||||
|
||||
if os.path.isfile(path):
|
||||
with open(path) as file:
|
||||
content = json.load(file)
|
||||
else:
|
||||
content = {}
|
||||
|
||||
if data is None:
|
||||
content.pop(mode, None)
|
||||
else:
|
||||
content[mode] = data
|
||||
|
||||
with open(path, 'wt') as file:
|
||||
file.write(json.dumps(content, indent=4, sort_keys=True))
|
||||
|
||||
|
||||
def _client_credentials(hass):
|
||||
"""Get the client credentials.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
mode = get_mode(hass)
|
||||
|
||||
if mode not in SERVERS:
|
||||
raise ValueError('Mode {} is not supported.'.format(mode))
|
||||
|
||||
return SERVERS[mode]['client_id'], SERVERS[mode]['client_secret']
|
||||
|
||||
|
||||
def _url(hass, path):
|
||||
"""Generate a url for the cloud.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
mode = get_mode(hass)
|
||||
|
||||
if mode not in SERVERS:
|
||||
raise ValueError('Mode {} is not supported.'.format(mode))
|
||||
|
||||
return urljoin(SERVERS[mode]['host'], path)
|
||||
@@ -5,10 +5,10 @@ AUTH_FILE = '.cloud'
|
||||
|
||||
SERVERS = {
|
||||
'development': {
|
||||
'host': 'http://localhost:8000',
|
||||
'client_id': 'HBhQxeV8H4aFBcs7jrZUeeDud0FjGEJJSZ9G6gNu',
|
||||
'client_secret': ('V1qw2NhB32cSAlP7DOezjgWNgn7ZKgq0jvVZoYSI0KCmg9rg7q4'
|
||||
'BSzoebnQnX6tuHCJiZjm2479mZmmtf2LOUdnSqOqkSpjc3js7Wu'
|
||||
'VBJrRyfgTVd43kbrEQtuOiaUpK')
|
||||
'client_id': '3k755iqfcgv8t12o4pl662mnos',
|
||||
'identity_pool_id': 'us-west-2_vDOfweDJo',
|
||||
'region': 'us-west-2',
|
||||
'access_key_id': 'AKIAJGRK7MILPRJTT2ZQ',
|
||||
'secret_access_key': 'lscdYBApxrLWL0HKuVqVXWv3ou8ZVXgG7rZBu/Sz'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"""The HTTP api to control the cloud integration."""
|
||||
import asyncio
|
||||
from functools import wraps
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
import async_timeout
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.http import (
|
||||
HomeAssistantView, RequestDataValidator)
|
||||
|
||||
from . import cloud_api
|
||||
from .const import DOMAIN, REQUEST_TIMEOUT
|
||||
from . import auth_api
|
||||
from .const import REQUEST_TIMEOUT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,6 +21,42 @@ def async_setup(hass):
|
||||
hass.http.register_view(CloudLoginView)
|
||||
hass.http.register_view(CloudLogoutView)
|
||||
hass.http.register_view(CloudAccountView)
|
||||
hass.http.register_view(CloudRegisterView)
|
||||
hass.http.register_view(CloudConfirmRegisterView)
|
||||
hass.http.register_view(CloudForgotPasswordView)
|
||||
hass.http.register_view(CloudConfirmForgotPasswordView)
|
||||
|
||||
|
||||
_CLOUD_ERRORS = {
|
||||
auth_api.UserNotFound: (400, "User does not exist."),
|
||||
auth_api.UserNotConfirmed: (400, 'Email not confirmed.'),
|
||||
auth_api.Unauthenticated: (401, 'Authentication failed.'),
|
||||
auth_api.PasswordChangeRequired: (400, 'Password change required.'),
|
||||
auth_api.ExpiredCode: (400, 'Confirmation code has expired.'),
|
||||
auth_api.InvalidCode: (400, 'Invalid confirmation code.'),
|
||||
asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.')
|
||||
}
|
||||
|
||||
|
||||
def _handle_cloud_errors(handler):
|
||||
"""Helper method to handle auth errors."""
|
||||
@asyncio.coroutine
|
||||
@wraps(handler)
|
||||
def error_handler(view, request, *args, **kwargs):
|
||||
"""Handle exceptions that raise from the wrapped request handler."""
|
||||
try:
|
||||
result = yield from handler(view, request, *args, **kwargs)
|
||||
return result
|
||||
|
||||
except (auth_api.CloudError, asyncio.TimeoutError) as err:
|
||||
err_info = _CLOUD_ERRORS.get(err.__class__)
|
||||
if err_info is None:
|
||||
err_info = (502, 'Unexpected error: {}'.format(err))
|
||||
status, msg = err_info
|
||||
return view.json_message(msg, status_code=status,
|
||||
message_code=err.__class__.__name__)
|
||||
|
||||
return error_handler
|
||||
|
||||
|
||||
class CloudLoginView(HomeAssistantView):
|
||||
@@ -26,52 +64,23 @@ class CloudLoginView(HomeAssistantView):
|
||||
|
||||
url = '/api/cloud/login'
|
||||
name = 'api:cloud:login'
|
||||
schema = vol.Schema({
|
||||
vol.Required('username'): str,
|
||||
vol.Required('password'): str,
|
||||
})
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request):
|
||||
"""Validate config and return results."""
|
||||
try:
|
||||
data = yield from request.json()
|
||||
except ValueError:
|
||||
_LOGGER.error('Login with invalid JSON')
|
||||
return self.json_message('Invalid JSON.', 400)
|
||||
|
||||
try:
|
||||
self.schema(data)
|
||||
except vol.Invalid as err:
|
||||
_LOGGER.error('Login with invalid formatted data')
|
||||
return self.json_message(
|
||||
'Message format incorrect: {}'.format(err), 400)
|
||||
|
||||
@_handle_cloud_errors
|
||||
@RequestDataValidator(vol.Schema({
|
||||
vol.Required('email'): str,
|
||||
vol.Required('password'): str,
|
||||
}))
|
||||
def post(self, request, data):
|
||||
"""Handle login request."""
|
||||
hass = request.app['hass']
|
||||
phase = 1
|
||||
try:
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
cloud = yield from cloud_api.async_login(
|
||||
hass, data['username'], data['password'])
|
||||
auth = hass.data['cloud']['auth']
|
||||
|
||||
phase += 1
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(auth.login, data['email'],
|
||||
data['password'])
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from cloud.async_refresh_account_info()
|
||||
|
||||
except cloud_api.Unauthenticated:
|
||||
return self.json_message(
|
||||
'Authentication failed (phase {}).'.format(phase), 401)
|
||||
except cloud_api.UnknownError:
|
||||
return self.json_message(
|
||||
'Unknown error occurred (phase {}).'.format(phase), 500)
|
||||
except asyncio.TimeoutError:
|
||||
return self.json_message(
|
||||
'Unable to reach Home Assistant cloud '
|
||||
'(phase {}).'.format(phase), 502)
|
||||
|
||||
hass.data[DOMAIN]['cloud'] = cloud
|
||||
return self.json(cloud.account)
|
||||
return self.json(_auth_data(auth))
|
||||
|
||||
|
||||
class CloudLogoutView(HomeAssistantView):
|
||||
@@ -81,39 +90,133 @@ class CloudLogoutView(HomeAssistantView):
|
||||
name = 'api:cloud:logout'
|
||||
|
||||
@asyncio.coroutine
|
||||
@_handle_cloud_errors
|
||||
def post(self, request):
|
||||
"""Validate config and return results."""
|
||||
"""Handle logout request."""
|
||||
hass = request.app['hass']
|
||||
try:
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from \
|
||||
hass.data[DOMAIN]['cloud'].async_revoke_access_token()
|
||||
auth = hass.data['cloud']['auth']
|
||||
|
||||
hass.data[DOMAIN].pop('cloud')
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(auth.logout)
|
||||
|
||||
return self.json({
|
||||
'result': 'ok',
|
||||
})
|
||||
except asyncio.TimeoutError:
|
||||
return self.json_message("Could not reach the server.", 502)
|
||||
except cloud_api.UnknownError as err:
|
||||
return self.json_message(
|
||||
"Error communicating with the server ({}).".format(err.status),
|
||||
502)
|
||||
return self.json_message('ok')
|
||||
|
||||
|
||||
class CloudAccountView(HomeAssistantView):
|
||||
"""Log out of the Home Assistant cloud."""
|
||||
"""View to retrieve account info."""
|
||||
|
||||
url = '/api/cloud/account'
|
||||
name = 'api:cloud:account'
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request):
|
||||
"""Validate config and return results."""
|
||||
"""Get account info."""
|
||||
hass = request.app['hass']
|
||||
auth = hass.data['cloud']['auth']
|
||||
|
||||
if 'cloud' not in hass.data[DOMAIN]:
|
||||
if not auth.is_logged_in:
|
||||
return self.json_message('Not logged in', 400)
|
||||
|
||||
return self.json(hass.data[DOMAIN]['cloud'].account)
|
||||
return self.json(_auth_data(auth))
|
||||
|
||||
|
||||
class CloudRegisterView(HomeAssistantView):
|
||||
"""Register on the Home Assistant cloud."""
|
||||
|
||||
url = '/api/cloud/register'
|
||||
name = 'api:cloud:register'
|
||||
|
||||
@asyncio.coroutine
|
||||
@_handle_cloud_errors
|
||||
@RequestDataValidator(vol.Schema({
|
||||
vol.Required('email'): str,
|
||||
vol.Required('password'): vol.All(str, vol.Length(min=6)),
|
||||
}))
|
||||
def post(self, request, data):
|
||||
"""Handle registration request."""
|
||||
hass = request.app['hass']
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(
|
||||
auth_api.register, hass, data['email'], data['password'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
|
||||
class CloudConfirmRegisterView(HomeAssistantView):
|
||||
"""Confirm registration on the Home Assistant cloud."""
|
||||
|
||||
url = '/api/cloud/confirm_register'
|
||||
name = 'api:cloud:confirm_register'
|
||||
|
||||
@asyncio.coroutine
|
||||
@_handle_cloud_errors
|
||||
@RequestDataValidator(vol.Schema({
|
||||
vol.Required('confirmation_code'): str,
|
||||
vol.Required('email'): str,
|
||||
}))
|
||||
def post(self, request, data):
|
||||
"""Handle registration confirmation request."""
|
||||
hass = request.app['hass']
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(
|
||||
auth_api.confirm_register, hass, data['confirmation_code'],
|
||||
data['email'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
|
||||
class CloudForgotPasswordView(HomeAssistantView):
|
||||
"""View to start Forgot Password flow.."""
|
||||
|
||||
url = '/api/cloud/forgot_password'
|
||||
name = 'api:cloud:forgot_password'
|
||||
|
||||
@asyncio.coroutine
|
||||
@_handle_cloud_errors
|
||||
@RequestDataValidator(vol.Schema({
|
||||
vol.Required('email'): str,
|
||||
}))
|
||||
def post(self, request, data):
|
||||
"""Handle forgot password request."""
|
||||
hass = request.app['hass']
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(
|
||||
auth_api.forgot_password, hass, data['email'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
|
||||
class CloudConfirmForgotPasswordView(HomeAssistantView):
|
||||
"""View to finish Forgot Password flow.."""
|
||||
|
||||
url = '/api/cloud/confirm_forgot_password'
|
||||
name = 'api:cloud:confirm_forgot_password'
|
||||
|
||||
@asyncio.coroutine
|
||||
@_handle_cloud_errors
|
||||
@RequestDataValidator(vol.Schema({
|
||||
vol.Required('confirmation_code'): str,
|
||||
vol.Required('email'): str,
|
||||
vol.Required('new_password'): vol.All(str, vol.Length(min=6))
|
||||
}))
|
||||
def post(self, request, data):
|
||||
"""Handle forgot password confirm request."""
|
||||
hass = request.app['hass']
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(
|
||||
auth_api.confirm_forgot_password, hass,
|
||||
data['confirmation_code'], data['email'],
|
||||
data['new_password'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
|
||||
def _auth_data(auth):
|
||||
"""Generate the auth data JSON response."""
|
||||
return {
|
||||
'email': auth.account.email
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ class ZWaveNodeValueView(HomeAssistantView):
|
||||
'label': entity_values.primary.label,
|
||||
'index': entity_values.primary.index,
|
||||
'instance': entity_values.primary.instance,
|
||||
'poll_intensity': entity_values.primary.poll_intensity,
|
||||
}
|
||||
return self.json(values_data)
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ https://home-assistant.io/components/cover.abode/
|
||||
"""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.abode import AbodeDevice, DATA_ABODE
|
||||
from homeassistant.components.abode import AbodeDevice, DOMAIN as ABODE_DOMAIN
|
||||
from homeassistant.components.cover import CoverDevice
|
||||
|
||||
|
||||
@@ -19,31 +19,32 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up Abode cover devices."""
|
||||
import abodepy.helpers.constants as CONST
|
||||
|
||||
abode = hass.data[DATA_ABODE]
|
||||
data = hass.data[ABODE_DOMAIN]
|
||||
|
||||
sensors = []
|
||||
for sensor in abode.get_devices(type_filter=(CONST.DEVICE_SECURE_BARRIER)):
|
||||
sensors.append(AbodeCover(abode, sensor))
|
||||
devices = []
|
||||
for device in data.abode.get_devices(generic_type=CONST.TYPE_COVER):
|
||||
if data.is_excluded(device):
|
||||
continue
|
||||
|
||||
add_devices(sensors)
|
||||
devices.append(AbodeCover(data, device))
|
||||
|
||||
data.devices.extend(devices)
|
||||
|
||||
add_devices(devices)
|
||||
|
||||
|
||||
class AbodeCover(AbodeDevice, CoverDevice):
|
||||
"""Representation of an Abode cover."""
|
||||
|
||||
def __init__(self, controller, device):
|
||||
"""Initialize the Abode device."""
|
||||
AbodeDevice.__init__(self, controller, device)
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
"""Return true if cover is closed, else False."""
|
||||
return self._device.is_open is False
|
||||
return not self._device.is_open
|
||||
|
||||
def close_cover(self):
|
||||
def close_cover(self, **kwargs):
|
||||
"""Issue close command to cover."""
|
||||
self._device.close_cover()
|
||||
|
||||
def open_cover(self):
|
||||
def open_cover(self, **kwargs):
|
||||
"""Issue open command to cover."""
|
||||
self._device.open_cover()
|
||||
|
||||
@@ -50,7 +50,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, add_devices,
|
||||
def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up cover(s) for KNX platform."""
|
||||
if DATA_KNX not in hass.data \
|
||||
@@ -58,25 +58,25 @@ def async_setup_platform(hass, config, add_devices,
|
||||
return False
|
||||
|
||||
if discovery_info is not None:
|
||||
async_add_devices_discovery(hass, discovery_info, add_devices)
|
||||
async_add_devices_discovery(hass, discovery_info, async_add_devices)
|
||||
else:
|
||||
async_add_devices_config(hass, config, add_devices)
|
||||
async_add_devices_config(hass, config, async_add_devices)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
def async_add_devices_discovery(hass, discovery_info, add_devices):
|
||||
def async_add_devices_discovery(hass, discovery_info, async_add_devices):
|
||||
"""Set up covers for KNX platform configured via xknx.yaml."""
|
||||
entities = []
|
||||
for device_name in discovery_info[ATTR_DISCOVER_DEVICES]:
|
||||
device = hass.data[DATA_KNX].xknx.devices[device_name]
|
||||
entities.append(KNXCover(hass, device))
|
||||
add_devices(entities)
|
||||
async_add_devices(entities)
|
||||
|
||||
|
||||
@callback
|
||||
def async_add_devices_config(hass, config, add_devices):
|
||||
def async_add_devices_config(hass, config, async_add_devices):
|
||||
"""Set up cover for KNX platform configured within plattform."""
|
||||
import xknx
|
||||
cover = xknx.devices.Cover(
|
||||
@@ -90,23 +90,20 @@ def async_add_devices_config(hass, config, add_devices):
|
||||
group_address_angle_state=config.get(CONF_ANGLE_STATE_ADDRESS),
|
||||
group_address_position=config.get(CONF_POSITION_ADDRESS),
|
||||
travel_time_down=config.get(CONF_TRAVELLING_TIME_DOWN),
|
||||
travel_time_up=config.get(CONF_TRAVELLING_TIME_UP))
|
||||
travel_time_up=config.get(CONF_TRAVELLING_TIME_UP),
|
||||
invert_position=config.get(CONF_INVERT_POSITION),
|
||||
invert_angle=config.get(CONF_INVERT_ANGLE))
|
||||
|
||||
invert_position = config.get(CONF_INVERT_POSITION)
|
||||
invert_angle = config.get(CONF_INVERT_ANGLE)
|
||||
hass.data[DATA_KNX].xknx.devices.add(cover)
|
||||
add_devices([KNXCover(hass, cover, invert_position, invert_angle)])
|
||||
async_add_devices([KNXCover(hass, cover)])
|
||||
|
||||
|
||||
class KNXCover(CoverDevice):
|
||||
"""Representation of a KNX cover."""
|
||||
|
||||
def __init__(self, hass, device, invert_position=False,
|
||||
invert_angle=False):
|
||||
def __init__(self, hass, device):
|
||||
"""Initialize the cover."""
|
||||
self.device = device
|
||||
self.invert_position = invert_position
|
||||
self.invert_angle = invert_angle
|
||||
self.hass = hass
|
||||
self.async_register_callbacks()
|
||||
|
||||
@@ -144,9 +141,7 @@ class KNXCover(CoverDevice):
|
||||
@property
|
||||
def current_cover_position(self):
|
||||
"""Return the current position of the cover."""
|
||||
return int(self.from_knx_position(
|
||||
self.device.current_position(),
|
||||
self.invert_position))
|
||||
return self.device.current_position()
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
@@ -172,8 +167,7 @@ class KNXCover(CoverDevice):
|
||||
"""Move the cover to a specific position."""
|
||||
if ATTR_POSITION in kwargs:
|
||||
position = kwargs[ATTR_POSITION]
|
||||
knx_position = self.to_knx_position(position, self.invert_position)
|
||||
yield from self.device.set_position(knx_position)
|
||||
yield from self.device.set_position(position)
|
||||
self.start_auto_updater()
|
||||
|
||||
@asyncio.coroutine
|
||||
@@ -187,17 +181,14 @@ class KNXCover(CoverDevice):
|
||||
"""Return current tilt position of cover."""
|
||||
if not self.device.supports_angle:
|
||||
return None
|
||||
return int(self.from_knx_position(
|
||||
self.device.angle,
|
||||
self.invert_angle))
|
||||
return self.device.current_angle()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_cover_tilt_position(self, **kwargs):
|
||||
"""Move the cover tilt to a specific position."""
|
||||
if ATTR_TILT_POSITION in kwargs:
|
||||
position = kwargs[ATTR_TILT_POSITION]
|
||||
knx_position = self.to_knx_position(position, self.invert_angle)
|
||||
yield from self.device.set_angle(knx_position)
|
||||
tilt_position = kwargs[ATTR_TILT_POSITION]
|
||||
yield from self.device.set_angle(tilt_position)
|
||||
|
||||
def start_auto_updater(self):
|
||||
"""Start the autoupdater to update HASS while cover is moving."""
|
||||
@@ -215,25 +206,8 @@ class KNXCover(CoverDevice):
|
||||
def auto_updater_hook(self, now):
|
||||
"""Callback for autoupdater."""
|
||||
# pylint: disable=unused-argument
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
if self.device.position_reached():
|
||||
self.stop_auto_updater()
|
||||
|
||||
self.hass.add_job(self.device.auto_stop_if_necessary())
|
||||
|
||||
@staticmethod
|
||||
def from_knx_position(raw, invert):
|
||||
"""Convert KNX position [0...255] to hass position [100...0]."""
|
||||
position = round((raw/256)*100)
|
||||
if not invert:
|
||||
position = 100 - position
|
||||
return position
|
||||
|
||||
@staticmethod
|
||||
def to_knx_position(value, invert):
|
||||
"""Convert hass position [100...0] to KNX position [0...255]."""
|
||||
knx_position = round(value/100*255.4)
|
||||
if not invert:
|
||||
knx_position = 255-knx_position
|
||||
print(value, " -> ", knx_position)
|
||||
return knx_position
|
||||
|
||||
@@ -21,8 +21,8 @@ from homeassistant.const import (
|
||||
CONF_NAME, CONF_VALUE_TEMPLATE, CONF_OPTIMISTIC, STATE_OPEN,
|
||||
STATE_CLOSED, STATE_UNKNOWN)
|
||||
from homeassistant.components.mqtt import (
|
||||
CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_QOS, CONF_RETAIN,
|
||||
valid_publish_topic, valid_subscribe_topic)
|
||||
CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_AVAILABILITY_TOPIC,
|
||||
CONF_QOS, CONF_RETAIN, valid_publish_topic, valid_subscribe_topic)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -37,6 +37,8 @@ CONF_SET_POSITION_TEMPLATE = 'set_position_template'
|
||||
CONF_PAYLOAD_OPEN = 'payload_open'
|
||||
CONF_PAYLOAD_CLOSE = 'payload_close'
|
||||
CONF_PAYLOAD_STOP = 'payload_stop'
|
||||
CONF_PAYLOAD_AVAILABLE = 'payload_available'
|
||||
CONF_PAYLOAD_NOT_AVAILABLE = 'payload_not_available'
|
||||
CONF_STATE_OPEN = 'state_open'
|
||||
CONF_STATE_CLOSED = 'state_closed'
|
||||
CONF_TILT_CLOSED_POSITION = 'tilt_closed_value'
|
||||
@@ -50,6 +52,8 @@ DEFAULT_NAME = 'MQTT Cover'
|
||||
DEFAULT_PAYLOAD_OPEN = 'OPEN'
|
||||
DEFAULT_PAYLOAD_CLOSE = 'CLOSE'
|
||||
DEFAULT_PAYLOAD_STOP = 'STOP'
|
||||
DEFAULT_PAYLOAD_AVAILABLE = 'online'
|
||||
DEFAULT_PAYLOAD_NOT_AVAILABLE = 'offline'
|
||||
DEFAULT_OPTIMISTIC = False
|
||||
DEFAULT_RETAIN = False
|
||||
DEFAULT_TILT_CLOSED_POSITION = 0
|
||||
@@ -69,11 +73,16 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_SET_POSITION_TEMPLATE, default=None): cv.template,
|
||||
vol.Optional(CONF_RETAIN, default=DEFAULT_RETAIN): cv.boolean,
|
||||
vol.Optional(CONF_STATE_TOPIC): valid_subscribe_topic,
|
||||
vol.Optional(CONF_AVAILABILITY_TOPIC, default=None): valid_subscribe_topic,
|
||||
vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_OPEN, default=DEFAULT_PAYLOAD_OPEN): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_CLOSE, default=DEFAULT_PAYLOAD_CLOSE): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_STOP, default=DEFAULT_PAYLOAD_STOP): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_AVAILABLE,
|
||||
default=DEFAULT_PAYLOAD_AVAILABLE): cv.string,
|
||||
vol.Optional(CONF_PAYLOAD_NOT_AVAILABLE,
|
||||
default=DEFAULT_PAYLOAD_NOT_AVAILABLE): cv.string,
|
||||
vol.Optional(CONF_STATE_OPEN, default=STATE_OPEN): cv.string,
|
||||
vol.Optional(CONF_STATE_CLOSED, default=STATE_CLOSED): cv.string,
|
||||
vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean,
|
||||
@@ -106,6 +115,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
config.get(CONF_NAME),
|
||||
config.get(CONF_STATE_TOPIC),
|
||||
config.get(CONF_COMMAND_TOPIC),
|
||||
config.get(CONF_AVAILABILITY_TOPIC),
|
||||
config.get(CONF_TILT_COMMAND_TOPIC),
|
||||
config.get(CONF_TILT_STATUS_TOPIC),
|
||||
config.get(CONF_QOS),
|
||||
@@ -115,6 +125,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
config.get(CONF_PAYLOAD_OPEN),
|
||||
config.get(CONF_PAYLOAD_CLOSE),
|
||||
config.get(CONF_PAYLOAD_STOP),
|
||||
config.get(CONF_PAYLOAD_AVAILABLE),
|
||||
config.get(CONF_PAYLOAD_NOT_AVAILABLE),
|
||||
config.get(CONF_OPTIMISTIC),
|
||||
value_template,
|
||||
config.get(CONF_TILT_OPEN_POSITION),
|
||||
@@ -131,9 +143,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
class MqttCover(CoverDevice):
|
||||
"""Representation of a cover that can be controlled using MQTT."""
|
||||
|
||||
def __init__(self, name, state_topic, command_topic, tilt_command_topic,
|
||||
tilt_status_topic, qos, retain, state_open, state_closed,
|
||||
payload_open, payload_close, payload_stop,
|
||||
def __init__(self, name, state_topic, command_topic, availability_topic,
|
||||
tilt_command_topic, tilt_status_topic, qos, retain,
|
||||
state_open, state_closed, payload_open, payload_close,
|
||||
payload_stop, payload_available, payload_not_available,
|
||||
optimistic, value_template, tilt_open_position,
|
||||
tilt_closed_position, tilt_min, tilt_max, tilt_optimistic,
|
||||
tilt_invert, position_topic, set_position_template):
|
||||
@@ -143,12 +156,16 @@ class MqttCover(CoverDevice):
|
||||
self._name = name
|
||||
self._state_topic = state_topic
|
||||
self._command_topic = command_topic
|
||||
self._availability_topic = availability_topic
|
||||
self._available = True if availability_topic is None else False
|
||||
self._tilt_command_topic = tilt_command_topic
|
||||
self._tilt_status_topic = tilt_status_topic
|
||||
self._qos = qos
|
||||
self._payload_open = payload_open
|
||||
self._payload_close = payload_close
|
||||
self._payload_stop = payload_stop
|
||||
self._payload_available = payload_available
|
||||
self._payload_not_available = payload_not_available
|
||||
self._state_open = state_open
|
||||
self._state_closed = state_closed
|
||||
self._retain = retain
|
||||
@@ -178,11 +195,11 @@ class MqttCover(CoverDevice):
|
||||
|
||||
level = self.find_percentage_in_range(float(payload))
|
||||
self._tilt_value = level
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@callback
|
||||
def message_received(topic, payload, qos):
|
||||
"""Handle new MQTT message."""
|
||||
def state_message_received(topic, payload, qos):
|
||||
"""Handle new MQTT state messages."""
|
||||
if self._template is not None:
|
||||
payload = self._template.async_render_with_possible_json_value(
|
||||
payload)
|
||||
@@ -203,14 +220,30 @@ class MqttCover(CoverDevice):
|
||||
payload)
|
||||
return
|
||||
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@callback
|
||||
def availability_message_received(topic, payload, qos):
|
||||
"""Handle new MQTT availability messages."""
|
||||
if payload == self._payload_available:
|
||||
self._available = True
|
||||
elif payload == self._payload_not_available:
|
||||
self._available = False
|
||||
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._state_topic is None:
|
||||
# Force into optimistic mode.
|
||||
self._optimistic = True
|
||||
else:
|
||||
yield from mqtt.async_subscribe(
|
||||
self.hass, self._state_topic, message_received, self._qos)
|
||||
self.hass, self._state_topic,
|
||||
state_message_received, self._qos)
|
||||
|
||||
if self._availability_topic is not None:
|
||||
yield from mqtt.async_subscribe(
|
||||
self.hass, self._availability_topic,
|
||||
availability_message_received, self._qos)
|
||||
|
||||
if self._tilt_status_topic is None:
|
||||
self._tilt_optimistic = True
|
||||
@@ -230,6 +263,11 @@ class MqttCover(CoverDevice):
|
||||
"""Return the name of the cover."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if cover is available."""
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
"""Return if the cover is closed."""
|
||||
@@ -275,7 +313,7 @@ class MqttCover(CoverDevice):
|
||||
if self._optimistic:
|
||||
# Optimistically assume that cover has changed state.
|
||||
self._state = False
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_close_cover(self, **kwargs):
|
||||
@@ -289,7 +327,7 @@ class MqttCover(CoverDevice):
|
||||
if self._optimistic:
|
||||
# Optimistically assume that cover has changed state.
|
||||
self._state = True
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_stop_cover(self, **kwargs):
|
||||
@@ -309,7 +347,7 @@ class MqttCover(CoverDevice):
|
||||
self._retain)
|
||||
if self._tilt_optimistic:
|
||||
self._tilt_value = self._tilt_open_position
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_close_cover_tilt(self, **kwargs):
|
||||
@@ -319,7 +357,7 @@ class MqttCover(CoverDevice):
|
||||
self._retain)
|
||||
if self._tilt_optimistic:
|
||||
self._tilt_value = self._tilt_closed_position
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_cover_tilt_position(self, **kwargs):
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Support for Rflink Cover devices.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/cover.rflink/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.rflink import (
|
||||
DATA_ENTITY_GROUP_LOOKUP, DATA_ENTITY_LOOKUP,
|
||||
DEVICE_DEFAULTS_SCHEMA, EVENT_KEY_COMMAND, RflinkCommand)
|
||||
from homeassistant.components.cover import (
|
||||
CoverDevice, PLATFORM_SCHEMA)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.const import CONF_NAME
|
||||
|
||||
|
||||
DEPENDENCIES = ['rflink']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
CONF_ALIASES = 'aliases'
|
||||
CONF_GROUP_ALIASES = 'group_aliases'
|
||||
CONF_GROUP = 'group'
|
||||
CONF_NOGROUP_ALIASES = 'nogroup_aliases'
|
||||
CONF_DEVICE_DEFAULTS = 'device_defaults'
|
||||
CONF_DEVICES = 'devices'
|
||||
CONF_AUTOMATIC_ADD = 'automatic_add'
|
||||
CONF_FIRE_EVENT = 'fire_event'
|
||||
CONF_IGNORE_DEVICES = 'ignore_devices'
|
||||
CONF_RECONNECT_INTERVAL = 'reconnect_interval'
|
||||
CONF_SIGNAL_REPETITIONS = 'signal_repetitions'
|
||||
CONF_WAIT_FOR_ACK = 'wait_for_ack'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_DEVICE_DEFAULTS, default=DEVICE_DEFAULTS_SCHEMA({})):
|
||||
DEVICE_DEFAULTS_SCHEMA,
|
||||
vol.Optional(CONF_DEVICES, default={}): vol.Schema({
|
||||
cv.string: {
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_ALIASES, default=[]):
|
||||
vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_GROUP_ALIASES, default=[]):
|
||||
vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_NOGROUP_ALIASES, default=[]):
|
||||
vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean,
|
||||
vol.Optional(CONF_SIGNAL_REPETITIONS): vol.Coerce(int),
|
||||
vol.Optional(CONF_GROUP, default=True): cv.boolean,
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
|
||||
def devices_from_config(domain_config, hass=None):
|
||||
"""Parse configuration and add Rflink cover devices."""
|
||||
devices = []
|
||||
for device_id, config in domain_config[CONF_DEVICES].items():
|
||||
device_config = dict(domain_config[CONF_DEVICE_DEFAULTS], **config)
|
||||
device = RflinkCover(device_id, hass, **device_config)
|
||||
devices.append(device)
|
||||
|
||||
# Register entity (and aliases) to listen to incoming rflink events
|
||||
# Device id and normal aliases respond to normal and group command
|
||||
hass.data[DATA_ENTITY_LOOKUP][
|
||||
EVENT_KEY_COMMAND][device_id].append(device)
|
||||
if config[CONF_GROUP]:
|
||||
hass.data[DATA_ENTITY_GROUP_LOOKUP][
|
||||
EVENT_KEY_COMMAND][device_id].append(device)
|
||||
for _id in config[CONF_ALIASES]:
|
||||
hass.data[DATA_ENTITY_LOOKUP][
|
||||
EVENT_KEY_COMMAND][_id].append(device)
|
||||
hass.data[DATA_ENTITY_GROUP_LOOKUP][
|
||||
EVENT_KEY_COMMAND][_id].append(device)
|
||||
return devices
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the Rflink cover platform."""
|
||||
async_add_devices(devices_from_config(config, hass))
|
||||
|
||||
|
||||
class RflinkCover(RflinkCommand, CoverDevice):
|
||||
"""Rflink entity which can switch on/stop/off (eg: cover)."""
|
||||
|
||||
def _handle_event(self, event):
|
||||
"""Adjust state if Rflink picks up a remote command for this device."""
|
||||
self.cancel_queued_send_commands()
|
||||
|
||||
command = event['command']
|
||||
if command in ['on', 'allon']:
|
||||
self._state = True
|
||||
elif command in ['off', 'alloff']:
|
||||
self._state = False
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling available in RFlink cover."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
"""Return if the cover is closed."""
|
||||
return None
|
||||
|
||||
def async_close_cover(self, **kwargs):
|
||||
"""Turn the device close."""
|
||||
return self._async_handle_command("close_cover")
|
||||
|
||||
def async_open_cover(self, **kwargs):
|
||||
"""Turn the device open."""
|
||||
return self._async_handle_command("open_cover")
|
||||
|
||||
def async_stop_cover(self, **kwargs):
|
||||
"""Turn the device stop."""
|
||||
return self._async_handle_command("stop_cover")
|
||||
@@ -197,7 +197,7 @@ class CoverTemplate(CoverDevice):
|
||||
@callback
|
||||
def template_cover_state_listener(entity, old_state, new_state):
|
||||
"""Handle target device state changes."""
|
||||
self.hass.async_add_job(self.async_update_ha_state(True))
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
@callback
|
||||
def template_cover_startup(event):
|
||||
@@ -205,7 +205,7 @@ class CoverTemplate(CoverDevice):
|
||||
async_track_state_change(
|
||||
self.hass, self._entities, template_cover_state_listener)
|
||||
|
||||
self.hass.async_add_job(self.async_update_ha_state(True))
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
self.hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_START, template_cover_startup)
|
||||
@@ -271,7 +271,7 @@ class CoverTemplate(CoverDevice):
|
||||
yield from self._position_script.async_run({"position": 100})
|
||||
if self._optimistic:
|
||||
self._position = 100
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_close_cover(self, **kwargs):
|
||||
@@ -282,7 +282,7 @@ class CoverTemplate(CoverDevice):
|
||||
yield from self._position_script.async_run({"position": 0})
|
||||
if self._optimistic:
|
||||
self._position = 0
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_stop_cover(self, **kwargs):
|
||||
@@ -297,7 +297,7 @@ class CoverTemplate(CoverDevice):
|
||||
yield from self._position_script.async_run(
|
||||
{"position": self._position})
|
||||
if self._optimistic:
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_open_cover_tilt(self, **kwargs):
|
||||
@@ -305,7 +305,7 @@ class CoverTemplate(CoverDevice):
|
||||
self._tilt_value = 100
|
||||
yield from self._tilt_script.async_run({"tilt": self._tilt_value})
|
||||
if self._tilt_optimistic:
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_close_cover_tilt(self, **kwargs):
|
||||
@@ -314,7 +314,7 @@ class CoverTemplate(CoverDevice):
|
||||
yield from self._tilt_script.async_run(
|
||||
{"tilt": self._tilt_value})
|
||||
if self._tilt_optimistic:
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_cover_tilt_position(self, **kwargs):
|
||||
@@ -322,7 +322,7 @@ class CoverTemplate(CoverDevice):
|
||||
self._tilt_value = kwargs[ATTR_TILT_POSITION]
|
||||
yield from self._tilt_script.async_run({"tilt": self._tilt_value})
|
||||
if self._tilt_optimistic:
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_update(self):
|
||||
|
||||
+2
-1
@@ -2,7 +2,8 @@
|
||||
import logging
|
||||
|
||||
from homeassistant.components.cover import CoverDevice
|
||||
from homeassistant.components.xiaomi import (PY_XIAOMI_GATEWAY, XiaomiDevice)
|
||||
from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY,
|
||||
XiaomiDevice)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -87,8 +87,8 @@ def async_setup(hass, config):
|
||||
|
||||
# Set up input boolean
|
||||
tasks.append(bootstrap.async_setup_component(
|
||||
hass, 'input_slider',
|
||||
{'input_slider': {
|
||||
hass, 'input_number',
|
||||
{'input_number': {
|
||||
'noise_allowance': {'icon': 'mdi:bell-ring',
|
||||
'min': 0,
|
||||
'max': 10,
|
||||
@@ -163,7 +163,7 @@ def async_setup(hass, config):
|
||||
'scene.romantic_lights']))
|
||||
tasks2.append(group.Group.async_create_group(hass, 'Bedroom', [
|
||||
lights[0], switches[1], media_players[0],
|
||||
'input_slider.noise_allowance']))
|
||||
'input_number.noise_allowance']))
|
||||
tasks2.append(group.Group.async_create_group(hass, 'Kitchen', [
|
||||
lights[2], 'cover.kitchen_window', 'lock.kitchen_door']))
|
||||
tasks2.append(group.Group.async_create_group(hass, 'Doors', [
|
||||
|
||||
@@ -19,9 +19,9 @@ _LOGGER = logging.getLogger(__name__)
|
||||
REQUIREMENTS = ['pexpect==4.0.1']
|
||||
|
||||
_DEVICES_REGEX = re.compile(
|
||||
r'(?P<name>([^\s]+))\s+' +
|
||||
r'(?P<name>([^\s]+)?)\s+' +
|
||||
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s+' +
|
||||
r'(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s+')
|
||||
r'(?P<mac>([0-9a-f]{2}[:-]){5}([0-9a-f]{2}))\s+')
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
REQUIREMENTS = ['aioautomatic==0.6.2']
|
||||
REQUIREMENTS = ['aioautomatic==0.6.3']
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -248,7 +248,7 @@ class Icloud(DeviceScanner):
|
||||
self._trusted_device, self._verification_code):
|
||||
raise PyiCloudException('Unknown failure')
|
||||
except PyiCloudException as error:
|
||||
# Reset to the inital 2FA state to allow the user to retry
|
||||
# Reset to the initial 2FA state to allow the user to retry
|
||||
_LOGGER.error("Failed to verify verification code: %s", error)
|
||||
self._trusted_device = None
|
||||
self._verification_code = None
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
Support for Zyxel Keenetic NDMS2 based routers.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.keenetic_ndms2/
|
||||
"""
|
||||
import logging
|
||||
from collections import namedtuple
|
||||
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.device_tracker import (
|
||||
DOMAIN, PLATFORM_SCHEMA, DeviceScanner)
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_PASSWORD, CONF_USERNAME
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Interface name to track devices for. Most likely one will not need to
|
||||
# change it from default 'Home'. This is needed not to track Guest WI-FI-
|
||||
# clients and router itself
|
||||
CONF_INTERFACE = 'interface'
|
||||
|
||||
DEFAULT_INTERFACE = 'Home'
|
||||
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string,
|
||||
})
|
||||
|
||||
|
||||
def get_scanner(_hass, config):
|
||||
"""Validate the configuration and return a Nmap scanner."""
|
||||
scanner = KeeneticNDMS2DeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
|
||||
Device = namedtuple('Device', ['mac', 'name'])
|
||||
|
||||
|
||||
class KeeneticNDMS2DeviceScanner(DeviceScanner):
|
||||
"""This class scans for devices using keenetic NDMS2 web interface."""
|
||||
|
||||
def __init__(self, config):
|
||||
"""Initialize the scanner."""
|
||||
self.last_results = []
|
||||
|
||||
self._url = 'http://%s/rci/show/ip/arp' % config[CONF_HOST]
|
||||
self._interface = config[CONF_INTERFACE]
|
||||
|
||||
self._username = config.get(CONF_USERNAME)
|
||||
self._password = config.get(CONF_PASSWORD)
|
||||
|
||||
self.success_init = self._update_info()
|
||||
_LOGGER.info("Scanner initialized")
|
||||
|
||||
def scan_devices(self):
|
||||
"""Scan for new devices and return a list with found device IDs."""
|
||||
self._update_info()
|
||||
|
||||
return [device.mac for device in self.last_results]
|
||||
|
||||
def get_device_name(self, mac):
|
||||
"""Return the name of the given device or None if we don't know."""
|
||||
filter_named = [device.name for device in self.last_results
|
||||
if device.mac == mac]
|
||||
|
||||
if filter_named:
|
||||
return filter_named[0]
|
||||
return None
|
||||
|
||||
def _update_info(self):
|
||||
"""Get ARP from keenetic router."""
|
||||
_LOGGER.info("Fetching...")
|
||||
|
||||
last_results = []
|
||||
|
||||
# doing a request
|
||||
try:
|
||||
from requests.auth import HTTPDigestAuth
|
||||
res = requests.get(self._url, timeout=10, auth=HTTPDigestAuth(
|
||||
self._username, self._password
|
||||
))
|
||||
except requests.exceptions.Timeout:
|
||||
_LOGGER.error(
|
||||
"Connection to the router timed out at URL %s", self._url)
|
||||
return False
|
||||
if res.status_code != 200:
|
||||
_LOGGER.error(
|
||||
"Connection failed with http code %s", res.status_code)
|
||||
return False
|
||||
try:
|
||||
result = res.json()
|
||||
except ValueError:
|
||||
# If json decoder could not parse the response
|
||||
_LOGGER.error("Failed to parse response from router")
|
||||
return False
|
||||
|
||||
# parsing response
|
||||
for info in result:
|
||||
if info.get('interface') != self._interface:
|
||||
continue
|
||||
mac = info.get('mac')
|
||||
name = info.get('name')
|
||||
# No address = no item :)
|
||||
if mac is None:
|
||||
continue
|
||||
|
||||
last_results.append(Device(mac.upper(), name))
|
||||
|
||||
self.last_results = last_results
|
||||
|
||||
_LOGGER.info("Request successful")
|
||||
return True
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
Support the OwnTracks platform.
|
||||
Device tracker platform that adds support for OwnTracks over MQTT.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.owntracks/
|
||||
@@ -16,7 +16,7 @@ from homeassistant.core import callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.components.mqtt as mqtt
|
||||
from homeassistant.const import STATE_HOME
|
||||
from homeassistant.util import convert, slugify
|
||||
from homeassistant.util import slugify, decorator
|
||||
from homeassistant.components import zone as zone_comp
|
||||
from homeassistant.components.device_tracker import PLATFORM_SCHEMA
|
||||
|
||||
@@ -25,6 +25,8 @@ REQUIREMENTS = ['libnacl==1.5.2']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
HANDLERS = decorator.Registry()
|
||||
|
||||
BEACON_DEV_ID = 'beacon'
|
||||
|
||||
CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy'
|
||||
@@ -32,17 +34,7 @@ CONF_SECRET = 'secret'
|
||||
CONF_WAYPOINT_IMPORT = 'waypoints'
|
||||
CONF_WAYPOINT_WHITELIST = 'waypoint_whitelist'
|
||||
|
||||
EVENT_TOPIC = 'owntracks/+/+/event'
|
||||
|
||||
LOCATION_TOPIC = 'owntracks/+/+'
|
||||
|
||||
VALIDATE_LOCATION = 'location'
|
||||
VALIDATE_TRANSITION = 'transition'
|
||||
VALIDATE_WAYPOINTS = 'waypoints'
|
||||
|
||||
WAYPOINT_LAT_KEY = 'lat'
|
||||
WAYPOINT_LON_KEY = 'lon'
|
||||
WAYPOINT_TOPIC = 'owntracks/{}/{}/waypoint'
|
||||
OWNTRACKS_TOPIC = 'owntracks/#'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_MAX_GPS_ACCURACY): vol.Coerce(float),
|
||||
@@ -72,300 +64,60 @@ def get_cipher():
|
||||
@asyncio.coroutine
|
||||
def async_setup_scanner(hass, config, async_see, discovery_info=None):
|
||||
"""Set up an OwnTracks tracker."""
|
||||
max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY)
|
||||
waypoint_import = config.get(CONF_WAYPOINT_IMPORT)
|
||||
waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST)
|
||||
secret = config.get(CONF_SECRET)
|
||||
context = context_from_config(async_see, config)
|
||||
|
||||
mobile_beacons_active = defaultdict(list)
|
||||
regions_entered = defaultdict(list)
|
||||
|
||||
def decrypt_payload(topic, ciphertext):
|
||||
"""Decrypt encrypted payload."""
|
||||
@asyncio.coroutine
|
||||
def async_handle_mqtt_message(topic, payload, qos):
|
||||
"""Handle incoming OwnTracks message."""
|
||||
try:
|
||||
keylen, decrypt = get_cipher()
|
||||
except OSError:
|
||||
_LOGGER.warning(
|
||||
"Ignoring encrypted payload because libsodium not installed")
|
||||
return None
|
||||
|
||||
if isinstance(secret, dict):
|
||||
key = secret.get(topic)
|
||||
else:
|
||||
key = secret
|
||||
|
||||
if key is None:
|
||||
_LOGGER.warning(
|
||||
"Ignoring encrypted payload because no decryption key known "
|
||||
"for topic %s", topic)
|
||||
return None
|
||||
|
||||
key = key.encode("utf-8")
|
||||
key = key[:keylen]
|
||||
key = key.ljust(keylen, b'\0')
|
||||
|
||||
try:
|
||||
ciphertext = base64.b64decode(ciphertext)
|
||||
message = decrypt(ciphertext, key)
|
||||
message = message.decode("utf-8")
|
||||
_LOGGER.debug("Decrypted payload: %s", message)
|
||||
return message
|
||||
except ValueError:
|
||||
_LOGGER.warning(
|
||||
"Ignoring encrypted payload because unable to decrypt using "
|
||||
"key for topic %s", topic)
|
||||
return None
|
||||
|
||||
def validate_payload(topic, payload, data_type):
|
||||
"""Validate the OwnTracks payload."""
|
||||
try:
|
||||
data = json.loads(payload)
|
||||
message = json.loads(payload)
|
||||
except ValueError:
|
||||
# If invalid JSON
|
||||
_LOGGER.error("Unable to parse payload as JSON: %s", payload)
|
||||
return None
|
||||
|
||||
if isinstance(data, dict) and \
|
||||
data.get('_type') == 'encrypted' and \
|
||||
'data' in data:
|
||||
plaintext_payload = decrypt_payload(topic, data['data'])
|
||||
if plaintext_payload is None:
|
||||
return None
|
||||
return validate_payload(topic, plaintext_payload, data_type)
|
||||
message['topic'] = topic
|
||||
|
||||
if not isinstance(data, dict) or data.get('_type') != data_type:
|
||||
_LOGGER.debug("Skipping %s update for following data "
|
||||
"because of missing or malformatted data: %s",
|
||||
data_type, data)
|
||||
return None
|
||||
if data_type == VALIDATE_TRANSITION or data_type == VALIDATE_WAYPOINTS:
|
||||
return data
|
||||
if max_gps_accuracy is not None and \
|
||||
convert(data.get('acc'), float, 0.0) > max_gps_accuracy:
|
||||
_LOGGER.info("Ignoring %s update because expected GPS "
|
||||
"accuracy %s is not met: %s",
|
||||
data_type, max_gps_accuracy, payload)
|
||||
return None
|
||||
if convert(data.get('acc'), float, 1.0) == 0.0:
|
||||
_LOGGER.warning(
|
||||
"Ignoring %s update because GPS accuracy is zero: %s",
|
||||
data_type, payload)
|
||||
return None
|
||||
|
||||
return data
|
||||
|
||||
@callback
|
||||
def async_owntracks_location_update(topic, payload, qos):
|
||||
"""MQTT message received."""
|
||||
# Docs on available data:
|
||||
# http://owntracks.org/booklet/tech/json/#_typelocation
|
||||
data = validate_payload(topic, payload, VALIDATE_LOCATION)
|
||||
if not data:
|
||||
return
|
||||
|
||||
dev_id, kwargs = _parse_see_args(topic, data)
|
||||
|
||||
if regions_entered[dev_id]:
|
||||
_LOGGER.debug(
|
||||
"Location update ignored, inside region %s",
|
||||
regions_entered[-1])
|
||||
return
|
||||
|
||||
hass.async_add_job(async_see(**kwargs))
|
||||
async_see_beacons(dev_id, kwargs)
|
||||
|
||||
@callback
|
||||
def async_owntracks_event_update(topic, payload, qos):
|
||||
"""Handle MQTT event (geofences)."""
|
||||
# Docs on available data:
|
||||
# http://owntracks.org/booklet/tech/json/#_typetransition
|
||||
data = validate_payload(topic, payload, VALIDATE_TRANSITION)
|
||||
if not data:
|
||||
return
|
||||
|
||||
if data.get('desc') is None:
|
||||
_LOGGER.error(
|
||||
"Location missing from `Entering/Leaving` message - "
|
||||
"please turn `Share` on in OwnTracks app")
|
||||
return
|
||||
# OwnTracks uses - at the start of a beacon zone
|
||||
# to switch on 'hold mode' - ignore this
|
||||
location = data['desc'].lstrip("-")
|
||||
if location.lower() == 'home':
|
||||
location = STATE_HOME
|
||||
|
||||
dev_id, kwargs = _parse_see_args(topic, data)
|
||||
|
||||
def enter_event():
|
||||
"""Execute enter event."""
|
||||
zone = hass.states.get("zone.{}".format(slugify(location)))
|
||||
if zone is None and data.get('t') == 'b':
|
||||
# Not a HA zone, and a beacon so assume mobile
|
||||
beacons = mobile_beacons_active[dev_id]
|
||||
if location not in beacons:
|
||||
beacons.append(location)
|
||||
_LOGGER.info("Added beacon %s", location)
|
||||
else:
|
||||
# Normal region
|
||||
regions = regions_entered[dev_id]
|
||||
if location not in regions:
|
||||
regions.append(location)
|
||||
_LOGGER.info("Enter region %s", location)
|
||||
_set_gps_from_zone(kwargs, location, zone)
|
||||
|
||||
hass.async_add_job(async_see(**kwargs))
|
||||
async_see_beacons(dev_id, kwargs)
|
||||
|
||||
def leave_event():
|
||||
"""Execute leave event."""
|
||||
regions = regions_entered[dev_id]
|
||||
if location in regions:
|
||||
regions.remove(location)
|
||||
new_region = regions[-1] if regions else None
|
||||
|
||||
if new_region:
|
||||
# Exit to previous region
|
||||
zone = hass.states.get(
|
||||
"zone.{}".format(slugify(new_region)))
|
||||
_set_gps_from_zone(kwargs, new_region, zone)
|
||||
_LOGGER.info("Exit to %s", new_region)
|
||||
hass.async_add_job(async_see(**kwargs))
|
||||
async_see_beacons(dev_id, kwargs)
|
||||
|
||||
else:
|
||||
_LOGGER.info("Exit to GPS")
|
||||
# Check for GPS accuracy
|
||||
valid_gps = True
|
||||
if 'acc' in data:
|
||||
if data['acc'] == 0.0:
|
||||
valid_gps = False
|
||||
_LOGGER.warning(
|
||||
"Ignoring GPS in region exit because accuracy"
|
||||
"is zero: %s", payload)
|
||||
if (max_gps_accuracy is not None and
|
||||
data['acc'] > max_gps_accuracy):
|
||||
valid_gps = False
|
||||
_LOGGER.info(
|
||||
"Ignoring GPS in region exit because expected "
|
||||
"GPS accuracy %s is not met: %s",
|
||||
max_gps_accuracy, payload)
|
||||
if valid_gps:
|
||||
hass.async_add_job(async_see(**kwargs))
|
||||
async_see_beacons(dev_id, kwargs)
|
||||
|
||||
beacons = mobile_beacons_active[dev_id]
|
||||
if location in beacons:
|
||||
beacons.remove(location)
|
||||
_LOGGER.info("Remove beacon %s", location)
|
||||
|
||||
if data['event'] == 'enter':
|
||||
enter_event()
|
||||
elif data['event'] == 'leave':
|
||||
leave_event()
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Misformatted mqtt msgs, _type=transition, event=%s",
|
||||
data['event'])
|
||||
return
|
||||
|
||||
@callback
|
||||
def async_owntracks_waypoint_update(topic, payload, qos):
|
||||
"""List of waypoints published by a user."""
|
||||
# Docs on available data:
|
||||
# http://owntracks.org/booklet/tech/json/#_typewaypoints
|
||||
data = validate_payload(topic, payload, VALIDATE_WAYPOINTS)
|
||||
if not data:
|
||||
return
|
||||
|
||||
wayps = data['waypoints']
|
||||
_LOGGER.info("Got %d waypoints from %s", len(wayps), topic)
|
||||
for wayp in wayps:
|
||||
name = wayp['desc']
|
||||
pretty_name = parse_topic(topic, True)[1] + ' - ' + name
|
||||
lat = wayp[WAYPOINT_LAT_KEY]
|
||||
lon = wayp[WAYPOINT_LON_KEY]
|
||||
rad = wayp['rad']
|
||||
|
||||
# check zone exists
|
||||
entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name))
|
||||
|
||||
# Check if state already exists
|
||||
if hass.states.get(entity_id) is not None:
|
||||
continue
|
||||
|
||||
zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad,
|
||||
zone_comp.ICON_IMPORT, False)
|
||||
zone.entity_id = entity_id
|
||||
hass.async_add_job(zone.async_update_ha_state())
|
||||
|
||||
@callback
|
||||
def async_see_beacons(dev_id, kwargs_param):
|
||||
"""Set active beacons to the current location."""
|
||||
kwargs = kwargs_param.copy()
|
||||
# the battery state applies to the tracking device, not the beacon
|
||||
kwargs.pop('battery', None)
|
||||
for beacon in mobile_beacons_active[dev_id]:
|
||||
kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon)
|
||||
kwargs['host_name'] = beacon
|
||||
hass.async_add_job(async_see(**kwargs))
|
||||
yield from async_handle_message(hass, context, message)
|
||||
|
||||
yield from mqtt.async_subscribe(
|
||||
hass, LOCATION_TOPIC, async_owntracks_location_update, 1)
|
||||
yield from mqtt.async_subscribe(
|
||||
hass, EVENT_TOPIC, async_owntracks_event_update, 1)
|
||||
|
||||
if waypoint_import:
|
||||
if waypoint_whitelist is None:
|
||||
yield from mqtt.async_subscribe(
|
||||
hass, WAYPOINT_TOPIC.format('+', '+'),
|
||||
async_owntracks_waypoint_update, 1)
|
||||
else:
|
||||
for whitelist_user in waypoint_whitelist:
|
||||
yield from mqtt.async_subscribe(
|
||||
hass, WAYPOINT_TOPIC.format(whitelist_user, '+'),
|
||||
async_owntracks_waypoint_update, 1)
|
||||
hass, OWNTRACKS_TOPIC, async_handle_mqtt_message, 1)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def parse_topic(topic, pretty=False):
|
||||
def _parse_topic(topic):
|
||||
"""Parse an MQTT topic owntracks/user/dev, return (user, dev) tuple.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
parts = topic.split('/')
|
||||
dev_id_format = ''
|
||||
if pretty:
|
||||
dev_id_format = '{} {}'
|
||||
else:
|
||||
dev_id_format = '{}_{}'
|
||||
dev_id = slugify(dev_id_format.format(parts[1], parts[2]))
|
||||
host_name = parts[1]
|
||||
return (host_name, dev_id)
|
||||
_, user, device, *_ = topic.split('/', 3)
|
||||
|
||||
return user, device
|
||||
|
||||
|
||||
def _parse_see_args(topic, data):
|
||||
def _parse_see_args(message):
|
||||
"""Parse the OwnTracks location parameters, into the format see expects.
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
(host_name, dev_id) = parse_topic(topic, False)
|
||||
user, device = _parse_topic(message['topic'])
|
||||
dev_id = slugify('{}_{}'.format(user, device))
|
||||
kwargs = {
|
||||
'dev_id': dev_id,
|
||||
'host_name': host_name,
|
||||
'gps': (data[WAYPOINT_LAT_KEY], data[WAYPOINT_LON_KEY]),
|
||||
'host_name': user,
|
||||
'gps': (message['lat'], message['lon']),
|
||||
'attributes': {}
|
||||
}
|
||||
if 'acc' in data:
|
||||
kwargs['gps_accuracy'] = data['acc']
|
||||
if 'batt' in data:
|
||||
kwargs['battery'] = data['batt']
|
||||
if 'vel' in data:
|
||||
kwargs['attributes']['velocity'] = data['vel']
|
||||
if 'tid' in data:
|
||||
kwargs['attributes']['tid'] = data['tid']
|
||||
if 'addr' in data:
|
||||
kwargs['attributes']['address'] = data['addr']
|
||||
if 'acc' in message:
|
||||
kwargs['gps_accuracy'] = message['acc']
|
||||
if 'batt' in message:
|
||||
kwargs['battery'] = message['batt']
|
||||
if 'vel' in message:
|
||||
kwargs['attributes']['velocity'] = message['vel']
|
||||
if 'tid' in message:
|
||||
kwargs['attributes']['tid'] = message['tid']
|
||||
if 'addr' in message:
|
||||
kwargs['attributes']['address'] = message['addr']
|
||||
|
||||
return dev_id, kwargs
|
||||
|
||||
@@ -382,3 +134,281 @@ def _set_gps_from_zone(kwargs, location, zone):
|
||||
kwargs['gps_accuracy'] = zone.attributes['radius']
|
||||
kwargs['location_name'] = location
|
||||
return kwargs
|
||||
|
||||
|
||||
def _decrypt_payload(secret, topic, ciphertext):
|
||||
"""Decrypt encrypted payload."""
|
||||
try:
|
||||
keylen, decrypt = get_cipher()
|
||||
except OSError:
|
||||
_LOGGER.warning(
|
||||
"Ignoring encrypted payload because libsodium not installed")
|
||||
return None
|
||||
|
||||
if isinstance(secret, dict):
|
||||
key = secret.get(topic)
|
||||
else:
|
||||
key = secret
|
||||
|
||||
if key is None:
|
||||
_LOGGER.warning(
|
||||
"Ignoring encrypted payload because no decryption key known "
|
||||
"for topic %s", topic)
|
||||
return None
|
||||
|
||||
key = key.encode("utf-8")
|
||||
key = key[:keylen]
|
||||
key = key.ljust(keylen, b'\0')
|
||||
|
||||
try:
|
||||
ciphertext = base64.b64decode(ciphertext)
|
||||
message = decrypt(ciphertext, key)
|
||||
message = message.decode("utf-8")
|
||||
_LOGGER.debug("Decrypted payload: %s", message)
|
||||
return message
|
||||
except ValueError:
|
||||
_LOGGER.warning(
|
||||
"Ignoring encrypted payload because unable to decrypt using "
|
||||
"key for topic %s", topic)
|
||||
return None
|
||||
|
||||
|
||||
def context_from_config(async_see, config):
|
||||
"""Create an async context from Home Assistant config."""
|
||||
max_gps_accuracy = config.get(CONF_MAX_GPS_ACCURACY)
|
||||
waypoint_import = config.get(CONF_WAYPOINT_IMPORT)
|
||||
waypoint_whitelist = config.get(CONF_WAYPOINT_WHITELIST)
|
||||
secret = config.get(CONF_SECRET)
|
||||
|
||||
return OwnTracksContext(async_see, secret, max_gps_accuracy,
|
||||
waypoint_import, waypoint_whitelist)
|
||||
|
||||
|
||||
class OwnTracksContext:
|
||||
"""Hold the current OwnTracks context."""
|
||||
|
||||
def __init__(self, async_see, secret, max_gps_accuracy, import_waypoints,
|
||||
waypoint_whitelist):
|
||||
"""Initialize an OwnTracks context."""
|
||||
self.async_see = async_see
|
||||
self.secret = secret
|
||||
self.max_gps_accuracy = max_gps_accuracy
|
||||
self.mobile_beacons_active = defaultdict(list)
|
||||
self.regions_entered = defaultdict(list)
|
||||
self.import_waypoints = import_waypoints
|
||||
self.waypoint_whitelist = waypoint_whitelist
|
||||
|
||||
@callback
|
||||
def async_valid_accuracy(self, message):
|
||||
"""Check if we should ignore this message."""
|
||||
acc = message.get('acc')
|
||||
|
||||
if acc is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
acc = float(acc)
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
if acc == 0:
|
||||
_LOGGER.warning(
|
||||
"Ignoring %s update because GPS accuracy is zero: %s",
|
||||
message['_type'], message)
|
||||
return False
|
||||
|
||||
if self.max_gps_accuracy is not None and \
|
||||
acc > self.max_gps_accuracy:
|
||||
_LOGGER.info("Ignoring %s update because expected GPS "
|
||||
"accuracy %s is not met: %s",
|
||||
message['_type'], self.max_gps_accuracy,
|
||||
message)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_see_beacons(self, dev_id, kwargs_param):
|
||||
"""Set active beacons to the current location."""
|
||||
kwargs = kwargs_param.copy()
|
||||
# the battery state applies to the tracking device, not the beacon
|
||||
kwargs.pop('battery', None)
|
||||
for beacon in self.mobile_beacons_active[dev_id]:
|
||||
kwargs['dev_id'] = "{}_{}".format(BEACON_DEV_ID, beacon)
|
||||
kwargs['host_name'] = beacon
|
||||
yield from self.async_see(**kwargs)
|
||||
|
||||
|
||||
@HANDLERS.register('location')
|
||||
@asyncio.coroutine
|
||||
def async_handle_location_message(hass, context, message):
|
||||
"""Handle a location message."""
|
||||
if not context.async_valid_accuracy(message):
|
||||
return
|
||||
|
||||
dev_id, kwargs = _parse_see_args(message)
|
||||
|
||||
if context.regions_entered[dev_id]:
|
||||
_LOGGER.debug(
|
||||
"Location update ignored, inside region %s",
|
||||
context.regions_entered[-1])
|
||||
return
|
||||
|
||||
yield from context.async_see(**kwargs)
|
||||
yield from context.async_see_beacons(dev_id, kwargs)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def _async_transition_message_enter(hass, context, message, location):
|
||||
"""Execute enter event."""
|
||||
zone = hass.states.get("zone.{}".format(slugify(location)))
|
||||
dev_id, kwargs = _parse_see_args(message)
|
||||
|
||||
if zone is None and message.get('t') == 'b':
|
||||
# Not a HA zone, and a beacon so assume mobile
|
||||
beacons = context.mobile_beacons_active[dev_id]
|
||||
if location not in beacons:
|
||||
beacons.append(location)
|
||||
_LOGGER.info("Added beacon %s", location)
|
||||
else:
|
||||
# Normal region
|
||||
regions = context.regions_entered[dev_id]
|
||||
if location not in regions:
|
||||
regions.append(location)
|
||||
_LOGGER.info("Enter region %s", location)
|
||||
_set_gps_from_zone(kwargs, location, zone)
|
||||
|
||||
yield from context.async_see(**kwargs)
|
||||
yield from context.async_see_beacons(dev_id, kwargs)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def _async_transition_message_leave(hass, context, message, location):
|
||||
"""Execute leave event."""
|
||||
dev_id, kwargs = _parse_see_args(message)
|
||||
regions = context.regions_entered[dev_id]
|
||||
|
||||
if location in regions:
|
||||
regions.remove(location)
|
||||
|
||||
new_region = regions[-1] if regions else None
|
||||
|
||||
if new_region:
|
||||
# Exit to previous region
|
||||
zone = hass.states.get(
|
||||
"zone.{}".format(slugify(new_region)))
|
||||
_set_gps_from_zone(kwargs, new_region, zone)
|
||||
_LOGGER.info("Exit to %s", new_region)
|
||||
yield from context.async_see(**kwargs)
|
||||
yield from context.async_see_beacons(dev_id, kwargs)
|
||||
return
|
||||
|
||||
else:
|
||||
_LOGGER.info("Exit to GPS")
|
||||
|
||||
# Check for GPS accuracy
|
||||
if context.async_valid_accuracy(message):
|
||||
yield from context.async_see(**kwargs)
|
||||
yield from context.async_see_beacons(dev_id, kwargs)
|
||||
|
||||
beacons = context.mobile_beacons_active[dev_id]
|
||||
if location in beacons:
|
||||
beacons.remove(location)
|
||||
_LOGGER.info("Remove beacon %s", location)
|
||||
|
||||
|
||||
@HANDLERS.register('transition')
|
||||
@asyncio.coroutine
|
||||
def async_handle_transition_message(hass, context, message):
|
||||
"""Handle a transition message."""
|
||||
if message.get('desc') is None:
|
||||
_LOGGER.error(
|
||||
"Location missing from `Entering/Leaving` message - "
|
||||
"please turn `Share` on in OwnTracks app")
|
||||
return
|
||||
# OwnTracks uses - at the start of a beacon zone
|
||||
# to switch on 'hold mode' - ignore this
|
||||
location = message['desc'].lstrip("-")
|
||||
if location.lower() == 'home':
|
||||
location = STATE_HOME
|
||||
|
||||
if message['event'] == 'enter':
|
||||
yield from _async_transition_message_enter(
|
||||
hass, context, message, location)
|
||||
elif message['event'] == 'leave':
|
||||
yield from _async_transition_message_leave(
|
||||
hass, context, message, location)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
"Misformatted mqtt msgs, _type=transition, event=%s",
|
||||
message['event'])
|
||||
|
||||
|
||||
@HANDLERS.register('waypoints')
|
||||
@asyncio.coroutine
|
||||
def async_handle_waypoints_message(hass, context, message):
|
||||
"""Handle a waypoints message."""
|
||||
if not context.import_waypoints:
|
||||
return
|
||||
|
||||
if context.waypoint_whitelist is not None:
|
||||
user = _parse_topic(message['topic'])[0]
|
||||
|
||||
if user not in context.waypoint_whitelist:
|
||||
return
|
||||
|
||||
wayps = message['waypoints']
|
||||
|
||||
_LOGGER.info("Got %d waypoints from %s", len(wayps), message['topic'])
|
||||
|
||||
name_base = ' '.join(_parse_topic(message['topic']))
|
||||
|
||||
for wayp in wayps:
|
||||
name = wayp['desc']
|
||||
pretty_name = '{} - {}'.format(name_base, name)
|
||||
lat = wayp['lat']
|
||||
lon = wayp['lon']
|
||||
rad = wayp['rad']
|
||||
|
||||
# check zone exists
|
||||
entity_id = zone_comp.ENTITY_ID_FORMAT.format(slugify(pretty_name))
|
||||
|
||||
# Check if state already exists
|
||||
if hass.states.get(entity_id) is not None:
|
||||
continue
|
||||
|
||||
zone = zone_comp.Zone(hass, pretty_name, lat, lon, rad,
|
||||
zone_comp.ICON_IMPORT, False)
|
||||
zone.entity_id = entity_id
|
||||
yield from zone.async_update_ha_state()
|
||||
|
||||
|
||||
@HANDLERS.register('encrypted')
|
||||
@asyncio.coroutine
|
||||
def async_handle_encrypted_message(hass, context, message):
|
||||
"""Handle an encrypted message."""
|
||||
plaintext_payload = _decrypt_payload(context.secret, message['topic'],
|
||||
message['data'])
|
||||
|
||||
if plaintext_payload is None:
|
||||
return
|
||||
|
||||
decrypted = json.loads(plaintext_payload)
|
||||
decrypted['topic'] = message['topic']
|
||||
|
||||
yield from async_handle_message(hass, context, decrypted)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_message(hass, context, message):
|
||||
"""Handle an OwnTracks message."""
|
||||
msgtype = message.get('_type')
|
||||
|
||||
handler = HANDLERS.get(msgtype)
|
||||
|
||||
if handler is None:
|
||||
_LOGGER.warning(
|
||||
'Received unsupported message type: %s.', msgtype)
|
||||
return
|
||||
|
||||
yield from handler(hass, context, message)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""
|
||||
Device tracker platform that adds support for OwnTracks over HTTP.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.owntracks_http/
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from aiohttp.web_exceptions import HTTPInternalServerError
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
# pylint: disable=unused-import
|
||||
from .owntracks import ( # NOQA
|
||||
REQUIREMENTS, PLATFORM_SCHEMA, context_from_config, async_handle_message)
|
||||
|
||||
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_scanner(hass, config, async_see, discovery_info=None):
|
||||
"""Set up an OwnTracks tracker."""
|
||||
context = context_from_config(async_see, config)
|
||||
|
||||
hass.http.register_view(OwnTracksView(context))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class OwnTracksView(HomeAssistantView):
|
||||
"""View to handle OwnTracks HTTP requests."""
|
||||
|
||||
url = '/api/owntracks/{user}/{device}'
|
||||
name = 'api:owntracks'
|
||||
|
||||
def __init__(self, context):
|
||||
"""Initialize OwnTracks URL endpoints."""
|
||||
self.context = context
|
||||
|
||||
@asyncio.coroutine
|
||||
def post(self, request, user, device):
|
||||
"""Handle an OwnTracks message."""
|
||||
hass = request.app['hass']
|
||||
|
||||
message = yield from request.json()
|
||||
message['topic'] = 'owntracks/{}/{}'.format(user, device)
|
||||
|
||||
try:
|
||||
yield from async_handle_message(hass, self.context, message)
|
||||
return self.json([])
|
||||
|
||||
except ValueError:
|
||||
raise HTTPInternalServerError
|
||||
@@ -75,7 +75,7 @@ class SnmpScanner(DeviceScanner):
|
||||
return [client['mac'] for client in self.last_results
|
||||
if client.get('mac')]
|
||||
|
||||
# Supressing no-self-use warning
|
||||
# Suppressing no-self-use warning
|
||||
# pylint: disable=R0201
|
||||
def get_device_name(self, device):
|
||||
"""Return the name of the given device or None if we don't know."""
|
||||
|
||||
@@ -69,7 +69,7 @@ class XiaomiDeviceScanner(DeviceScanner):
|
||||
return self.mac2name.get(device.upper(), None)
|
||||
|
||||
def _update_info(self):
|
||||
"""Ensure the informations from the router are up to date.
|
||||
"""Ensure the information from the router are up to date.
|
||||
|
||||
Returns true if scanning successful.
|
||||
"""
|
||||
|
||||
@@ -21,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time
|
||||
from homeassistant.helpers.discovery import async_load_platform, async_discover
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
REQUIREMENTS = ['netdisco==1.1.0']
|
||||
REQUIREMENTS = ['netdisco==1.2.2']
|
||||
|
||||
DOMAIN = 'discovery'
|
||||
|
||||
@@ -34,6 +34,7 @@ SERVICE_HASSIO = 'hassio'
|
||||
SERVICE_AXIS = 'axis'
|
||||
SERVICE_APPLE_TV = 'apple_tv'
|
||||
SERVICE_WINK = 'wink'
|
||||
SERVICE_XIAOMI_GW = 'xiaomi_gw'
|
||||
|
||||
SERVICE_HANDLERS = {
|
||||
SERVICE_HASS_IOS_APP: ('ios', None),
|
||||
@@ -44,6 +45,7 @@ SERVICE_HANDLERS = {
|
||||
SERVICE_AXIS: ('axis', None),
|
||||
SERVICE_APPLE_TV: ('apple_tv', None),
|
||||
SERVICE_WINK: ('wink', None),
|
||||
SERVICE_XIAOMI_GW: ('xiaomi_aqara', None),
|
||||
'philips_hue': ('light', 'hue'),
|
||||
'google_cast': ('media_player', 'cast'),
|
||||
'panasonic_viera': ('media_player', 'panasonic_viera'),
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Support for a DoorBird video doorbell."""
|
||||
|
||||
import logging
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['DoorBirdPy==0.0.4']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = 'doorbird'
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string
|
||||
})
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Set up the DoorBird component."""
|
||||
device_ip = config[DOMAIN].get(CONF_HOST)
|
||||
username = config[DOMAIN].get(CONF_USERNAME)
|
||||
password = config[DOMAIN].get(CONF_PASSWORD)
|
||||
|
||||
from doorbirdpy import DoorBird
|
||||
device = DoorBird(device_ip, username, password)
|
||||
status = device.ready()
|
||||
|
||||
if status[0]:
|
||||
_LOGGER.info("Connected to DoorBird at %s as %s", device_ip, username)
|
||||
hass.data[DOMAIN] = device
|
||||
return True
|
||||
elif status[1] == 401:
|
||||
_LOGGER.error("Authorization rejected by DoorBird at %s", device_ip)
|
||||
return False
|
||||
else:
|
||||
_LOGGER.error("Could not connect to DoorBird at %s: Error %s",
|
||||
device_ip, str(status[1]))
|
||||
return False
|
||||
@@ -122,7 +122,7 @@ def setup(hass, config):
|
||||
_LOGGER.info("Downloading of %s done", url)
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
_LOGGER.exception("ConnectionError occured for %s", url)
|
||||
_LOGGER.exception("ConnectionError occurred for %s", url)
|
||||
|
||||
# Remove file if we started downloading but failed
|
||||
if final_path and os.path.isfile(final_path):
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Integrate with DuckDNS."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_DOMAIN
|
||||
from homeassistant.loader import bind_hass
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
DOMAIN = 'duckdns'
|
||||
UPDATE_URL = 'https://www.duckdns.org/update'
|
||||
INTERVAL = timedelta(minutes=5)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
SERVICE_SET_TXT = 'set_txt'
|
||||
ATTR_TXT = 'txt'
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_DOMAIN): cv.string,
|
||||
vol.Required(CONF_ACCESS_TOKEN): cv.string,
|
||||
})
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
SERVICE_TXT_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_TXT): vol.Any(None, cv.string)
|
||||
})
|
||||
|
||||
|
||||
@bind_hass
|
||||
@asyncio.coroutine
|
||||
def async_set_txt(hass, txt):
|
||||
"""Set the txt record. Pass in None to remove it."""
|
||||
yield from hass.services.async_call(DOMAIN, SERVICE_SET_TXT, {
|
||||
ATTR_TXT: txt
|
||||
}, blocking=True)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Initialize the DuckDNS component."""
|
||||
domain = config[DOMAIN][CONF_DOMAIN]
|
||||
token = config[DOMAIN][CONF_ACCESS_TOKEN]
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
result = yield from _update_duckdns(session, domain, token)
|
||||
|
||||
if not result:
|
||||
return False
|
||||
|
||||
@asyncio.coroutine
|
||||
def update_domain_interval(now):
|
||||
"""Update the DuckDNS entry."""
|
||||
yield from _update_duckdns(session, domain, token)
|
||||
|
||||
@asyncio.coroutine
|
||||
def update_domain_service(call):
|
||||
"""Update the DuckDNS entry."""
|
||||
yield from _update_duckdns(session, domain, token,
|
||||
txt=call.data[ATTR_TXT])
|
||||
|
||||
async_track_time_interval(hass, update_domain_interval, INTERVAL)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_TXT, update_domain_service,
|
||||
schema=SERVICE_TXT_SCHEMA)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
_SENTINEL = object()
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def _update_duckdns(session, domain, token, *, txt=_SENTINEL, clear=False):
|
||||
"""Update DuckDNS."""
|
||||
params = {
|
||||
'domains': domain,
|
||||
'token': token,
|
||||
}
|
||||
|
||||
if txt is not _SENTINEL:
|
||||
if txt is None:
|
||||
# Pass in empty txt value to indicate it's clearing txt record
|
||||
params['txt'] = ''
|
||||
clear = True
|
||||
else:
|
||||
params['txt'] = txt
|
||||
|
||||
if clear:
|
||||
params['clear'] = 'true'
|
||||
|
||||
resp = yield from session.get(UPDATE_URL, params=params)
|
||||
body = yield from resp.text()
|
||||
|
||||
if body != 'OK':
|
||||
_LOGGER.warning('Updating DuckDNS domain %s failed', domain)
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -15,7 +15,7 @@ from homeassistant.helpers import discovery
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
REQUIREMENTS = ['python-ecobee-api==0.0.9']
|
||||
REQUIREMENTS = ['python-ecobee-api==0.0.10']
|
||||
|
||||
_CONFIGURING = {}
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -209,7 +209,7 @@ class EightSleepUserEntity(Entity):
|
||||
@callback
|
||||
def async_eight_user_update():
|
||||
"""Update callback."""
|
||||
self.hass.async_add_job(self.async_update_ha_state(True))
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_UPDATE_USER, async_eight_user_update)
|
||||
@@ -233,7 +233,7 @@ class EightSleepHeatEntity(Entity):
|
||||
@callback
|
||||
def async_eight_heat_update():
|
||||
"""Update callback."""
|
||||
self.hass.async_add_job(self.async_update_ha_state(True))
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
async_dispatcher_connect(
|
||||
self.hass, SIGNAL_UPDATE_HEAT, async_eight_heat_update)
|
||||
|
||||
@@ -16,6 +16,7 @@ from homeassistant.const import (
|
||||
)
|
||||
from homeassistant.components.http import REQUIREMENTS # NOQA
|
||||
from homeassistant.components.http import HomeAssistantWSGI
|
||||
from homeassistant.helpers.deprecation import get_deprecated
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from .hue_api import (
|
||||
HueUsernameView, HueAllLightsStateView, HueOneLightStateView,
|
||||
@@ -66,6 +67,7 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
ATTR_EMULATED_HUE = 'emulated_hue'
|
||||
ATTR_EMULATED_HUE_HIDDEN = 'emulated_hue_hidden'
|
||||
|
||||
|
||||
def setup(hass, yaml_config):
|
||||
@@ -129,7 +131,7 @@ class Config(object):
|
||||
|
||||
if self.type == TYPE_ALEXA:
|
||||
_LOGGER.warning("Alexa type is deprecated and will be removed in a"
|
||||
"future version")
|
||||
" future version")
|
||||
|
||||
# Get the IP address that will be passed to the Echo during discovery
|
||||
self.host_ip_addr = conf.get(CONF_HOST_IP)
|
||||
@@ -148,7 +150,7 @@ class Config(object):
|
||||
self.listen_port)
|
||||
|
||||
if self.type == TYPE_GOOGLE and self.listen_port != 80:
|
||||
_LOGGER.warning("When targetting Google Home, listening port has "
|
||||
_LOGGER.warning("When targeting Google Home, listening port has "
|
||||
"to be port 80")
|
||||
|
||||
# Get whether or not UPNP binds to multicast address (239.255.255.250)
|
||||
@@ -223,7 +225,15 @@ class Config(object):
|
||||
|
||||
domain = entity.domain.lower()
|
||||
explicit_expose = entity.attributes.get(ATTR_EMULATED_HUE, None)
|
||||
|
||||
explicit_hidden = entity.attributes.get(ATTR_EMULATED_HUE_HIDDEN, None)
|
||||
if explicit_expose is True or explicit_hidden is False:
|
||||
expose = True
|
||||
elif explicit_expose is False or explicit_hidden is True:
|
||||
expose = False
|
||||
else:
|
||||
expose = None
|
||||
get_deprecated(entity.attributes, ATTR_EMULATED_HUE_HIDDEN,
|
||||
ATTR_EMULATED_HUE, None)
|
||||
domain_exposed_by_default = \
|
||||
self.expose_by_default and domain in self.exposed_domains
|
||||
|
||||
@@ -231,9 +241,9 @@ class Config(object):
|
||||
# the configuration doesn't explicitly exclude it from being
|
||||
# exposed, or if the entity is explicitly exposed
|
||||
is_default_exposed = \
|
||||
domain_exposed_by_default and explicit_expose is not False
|
||||
domain_exposed_by_default and expose is not False
|
||||
|
||||
return is_default_exposed or explicit_expose
|
||||
return is_default_exposed or expose
|
||||
|
||||
def _load_numbers_json(self):
|
||||
"""Set up helper method to load numbers json."""
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Provides a UPNP discovery method that mimicks Hue hubs."""
|
||||
"""Provides a UPNP discovery method that mimics Hue hubs."""
|
||||
import threading
|
||||
import socket
|
||||
import logging
|
||||
@@ -123,20 +123,20 @@ USN: uuid:Socket-1_0-221438K0100073::urn:schemas-upnp-org:device:basic:1
|
||||
if ssdp_socket in read:
|
||||
data, addr = ssdp_socket.recvfrom(1024)
|
||||
else:
|
||||
# most likely the timeout, so check for interupt
|
||||
# most likely the timeout, so check for interrupt
|
||||
continue
|
||||
except socket.error as ex:
|
||||
if self._interrupted:
|
||||
clean_socket_close(ssdp_socket)
|
||||
return
|
||||
|
||||
_LOGGER.error("UPNP Responder socket exception occured: %s",
|
||||
_LOGGER.error("UPNP Responder socket exception occurred: %s",
|
||||
ex.__str__)
|
||||
# without the following continue, a second exception occurs
|
||||
# because the data object has not been initialized
|
||||
continue
|
||||
|
||||
if "M-SEARCH" in data.decode('utf-8'):
|
||||
if "M-SEARCH" in data.decode('utf-8', errors='ignore'):
|
||||
# SSDP M-SEARCH method received, respond to it with our info
|
||||
resp_socket = socket.socket(
|
||||
socket.AF_INET, socket.SOCK_DGRAM)
|
||||
|
||||
@@ -137,7 +137,7 @@ class InsteonLocalFanDevice(FanEntity):
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the the name of the node."""
|
||||
"""Return the name of the node."""
|
||||
return self.node.deviceName
|
||||
|
||||
@property
|
||||
|
||||
@@ -78,6 +78,9 @@ PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the MQTT fan platform."""
|
||||
if discovery_info is not None:
|
||||
config = PLATFORM_SCHEMA(discovery_info)
|
||||
|
||||
async_add_devices([MqttFan(
|
||||
config.get(CONF_NAME),
|
||||
{
|
||||
@@ -160,7 +163,7 @@ class MqttFan(FanEntity):
|
||||
self._state = True
|
||||
elif payload == self._payload[STATE_OFF]:
|
||||
self._state = False
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_STATE_TOPIC] is not None:
|
||||
yield from mqtt.async_subscribe(
|
||||
@@ -177,7 +180,7 @@ class MqttFan(FanEntity):
|
||||
self._speed = SPEED_MEDIUM
|
||||
elif payload == self._payload[SPEED_HIGH]:
|
||||
self._speed = SPEED_HIGH
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_SPEED_STATE_TOPIC] is not None:
|
||||
yield from mqtt.async_subscribe(
|
||||
@@ -193,7 +196,7 @@ class MqttFan(FanEntity):
|
||||
self._oscillation = True
|
||||
elif payload == self._payload[OSCILLATE_OFF_PAYLOAD]:
|
||||
self._oscillation = False
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None:
|
||||
yield from mqtt.async_subscribe(
|
||||
@@ -287,7 +290,7 @@ class MqttFan(FanEntity):
|
||||
|
||||
if self._optimistic_speed:
|
||||
self._speed = speed
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_oscillate(self, oscillating: bool) -> None:
|
||||
@@ -309,4 +312,4 @@ class MqttFan(FanEntity):
|
||||
|
||||
if self._optimistic_oscillation:
|
||||
self._oscillation = oscillating
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@@ -242,7 +242,7 @@ class FFmpegBase(Entity):
|
||||
def async_start_handle(event):
|
||||
"""Start FFmpeg process."""
|
||||
yield from self._async_start_ffmpeg(None)
|
||||
self.hass.async_add_job(self.async_update_ha_state())
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
self.hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_START, async_start_handle)
|
||||
|
||||
@@ -329,6 +329,7 @@ class IndexView(HomeAssistantView):
|
||||
from jinja2 import FileSystemLoader, Environment
|
||||
|
||||
self.templates = Environment(
|
||||
autoescape=True,
|
||||
loader=FileSystemLoader(
|
||||
os.path.join(os.path.dirname(__file__), 'templates/')
|
||||
)
|
||||
|
||||
@@ -92,6 +92,18 @@
|
||||
{% if not dev_mode %}
|
||||
<script src='/static/custom-elements-es5-adapter.js'></script>
|
||||
{% endif %}
|
||||
<script>
|
||||
var webComponentsSupported = (
|
||||
'customElements' in window &&
|
||||
'import' in document.createElement('link') &&
|
||||
'content' in document.createElement('template'));
|
||||
if (!webComponentsSupported) {
|
||||
var e = document.createElement('script');
|
||||
e.onerror = initError;
|
||||
e.src = '/static/webcomponents-lite.js';
|
||||
document.head.appendChild(e);
|
||||
}
|
||||
</script>
|
||||
<link rel='import' href='{{ ui_url }}' onerror='initError()'>
|
||||
{% if panel_url -%}
|
||||
<link rel='import' href='{{ panel_url }}' onerror='initError()' async>
|
||||
@@ -100,19 +112,5 @@
|
||||
{% for extra_url in extra_urls -%}
|
||||
<link rel='import' href='{{ extra_url }}' async>
|
||||
{% endfor -%}
|
||||
|
||||
<script>
|
||||
var webComponentsSupported = (
|
||||
'registerElement' in document &&
|
||||
'import' in document.createElement('link') &&
|
||||
'content' in document.createElement('template'));
|
||||
if (!webComponentsSupported) {
|
||||
var e = document.createElement('script');
|
||||
e.async = true;
|
||||
e.onerror = initError;
|
||||
e.src = '/static/webcomponents-lite.js';
|
||||
document.head.appendChild(e);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user