mirror of
https://github.com/home-assistant/core.git
synced 2026-01-12 18:47:17 +01:00
Compare commits
244 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7b0929a32 | ||
|
|
31489a56db | ||
|
|
3e09a7360e | ||
|
|
0cdd752d6c | ||
|
|
027c0b3168 | ||
|
|
271546d101 | ||
|
|
d1ed17e7db | ||
|
|
fb2fb5ea73 | ||
|
|
202a8dba8e | ||
|
|
042a482ef1 | ||
|
|
fff413e04e | ||
|
|
e29459a1ae | ||
|
|
49de55e75b | ||
|
|
ee4b1e2b78 | ||
|
|
ed44d28fc0 | ||
|
|
d5f9c1bc01 | ||
|
|
f69c900977 | ||
|
|
9a7ea72fa0 | ||
|
|
fd4a9cf7c5 | ||
|
|
0fe375049a | ||
|
|
69f2f0f34a | ||
|
|
076fdc3f8b | ||
|
|
8887c2a8af | ||
|
|
f4594027fd | ||
|
|
59a0005e5c | ||
|
|
9157f722a4 | ||
|
|
7f2a1c61da | ||
|
|
0eb9516ea7 | ||
|
|
3bb3a70347 | ||
|
|
0262269b00 | ||
|
|
780d62ac5c | ||
|
|
5fca9e170e | ||
|
|
ca7415e935 | ||
|
|
26d3c3b0d6 | ||
|
|
81f8764bb8 | ||
|
|
24d2eaa6ca | ||
|
|
f868df1035 | ||
|
|
d0988422d4 | ||
|
|
f522d95328 | ||
|
|
c856c67790 | ||
|
|
6c5efd5b7e | ||
|
|
c3b6086d80 | ||
|
|
0d93369154 | ||
|
|
4e064f91fd | ||
|
|
f9e53ca22f | ||
|
|
f8bdc835f8 | ||
|
|
1f602be80a | ||
|
|
fe4d971427 | ||
|
|
e5efc2e430 | ||
|
|
537a2a6ef6 | ||
|
|
11cc065845 | ||
|
|
3ac31b2c1b | ||
|
|
a91f937245 | ||
|
|
fed2584d8a | ||
|
|
eaa8e5f29d | ||
|
|
65fbba0e79 | ||
|
|
19522b1f39 | ||
|
|
afe84c2a8b | ||
|
|
952436aa0b | ||
|
|
8a577c8e0d | ||
|
|
bf940bd1f3 | ||
|
|
03e8627b12 | ||
|
|
e886303f08 | ||
|
|
4b0df51b40 | ||
|
|
8494ac7cef | ||
|
|
5076ebe43c | ||
|
|
05b2559df8 | ||
|
|
70b74da3eb | ||
|
|
9e0b107991 | ||
|
|
92d05ccb5c | ||
|
|
bfdb51a558 | ||
|
|
e10b00f341 | ||
|
|
cd87c40bbf | ||
|
|
d02bc3deaa | ||
|
|
1798df7686 | ||
|
|
d505398917 | ||
|
|
70d6ce5b79 | ||
|
|
71452c11c1 | ||
|
|
e30f2bf912 | ||
|
|
262d95b7b1 | ||
|
|
ca3da0e53e | ||
|
|
49882255c4 | ||
|
|
3db31cb951 | ||
|
|
415cfc2537 | ||
|
|
88bb136813 | ||
|
|
4cecc626f4 | ||
|
|
644d5de890 | ||
|
|
148b8c5055 | ||
|
|
09161ae615 | ||
|
|
c1f96aabb0 | ||
|
|
343625d539 | ||
|
|
2e10b4bf67 | ||
|
|
dc8e55fb8b | ||
|
|
d86a5a1e91 | ||
|
|
1327051277 | ||
|
|
712c51e283 | ||
|
|
c96f73d1be | ||
|
|
b3afb386b7 | ||
|
|
2544635921 | ||
|
|
4e5b5f2204 | ||
|
|
98de7c9287 | ||
|
|
80e60efd8f | ||
|
|
b3e9e1dfcd | ||
|
|
40bc49aaae | ||
|
|
3c364fa7e9 | ||
|
|
05946ae5a2 | ||
|
|
ceb0ec5fa4 | ||
|
|
a28196df9a | ||
|
|
c7cc045acd | ||
|
|
225a672a92 | ||
|
|
4d5eb0e3fc | ||
|
|
a68ab07e72 | ||
|
|
ec4fe7e6e6 | ||
|
|
0b4b46d80b | ||
|
|
3bbdd9fedd | ||
|
|
2ed135439a | ||
|
|
1750b22e59 | ||
|
|
9c5e7a9584 | ||
|
|
9b03848a2e | ||
|
|
548d415f94 | ||
|
|
18be276b08 | ||
|
|
9aa9e57890 | ||
|
|
8fe2654862 | ||
|
|
794ff20987 | ||
|
|
fe794d7fd8 | ||
|
|
585fbb1c02 | ||
|
|
e4b697b1ed | ||
|
|
de5533e3c2 | ||
|
|
5aa0158761 | ||
|
|
4d7555957c | ||
|
|
aa34fe15b2 | ||
|
|
1096232e17 | ||
|
|
54ecab7590 | ||
|
|
15e329a588 | ||
|
|
768c98d359 | ||
|
|
6490378de3 | ||
|
|
d0320a9099 | ||
|
|
9116eb166b | ||
|
|
37bd93a975 | ||
|
|
b78765a41f | ||
|
|
ab60b32326 | ||
|
|
3ea179cc0b | ||
|
|
5bedf5d604 | ||
|
|
d8c1959715 | ||
|
|
0f1c4d2f8c | ||
|
|
3ce6c732ab | ||
|
|
191fc8f8d4 | ||
|
|
31c2d45a7a | ||
|
|
dee6355cc5 | ||
|
|
c9b5ea97da | ||
|
|
eaebe83429 | ||
|
|
7f0b8c5e70 | ||
|
|
53d51a467d | ||
|
|
7eeb623b8f | ||
|
|
6b724f7da4 | ||
|
|
a4409da700 | ||
|
|
1eb3181c14 | ||
|
|
f7b401a20e | ||
|
|
5f92ceeea9 | ||
|
|
f0f1fadee1 | ||
|
|
32f97dc578 | ||
|
|
631ba2ef0d | ||
|
|
62de16804b | ||
|
|
3d919f1235 | ||
|
|
8ff9506138 | ||
|
|
dd1703469e | ||
|
|
5f98a70c21 | ||
|
|
bfd64ce96e | ||
|
|
a032e649f5 | ||
|
|
c96a5d5b2b | ||
|
|
a565cc4b73 | ||
|
|
8d34b76d51 | ||
|
|
15f89fc636 | ||
|
|
a431277de1 | ||
|
|
7208ff515e | ||
|
|
0a79a5e964 | ||
|
|
8e766daa11 | ||
|
|
4ded795740 | ||
|
|
84cb7a4f20 | ||
|
|
cbf0caa88a | ||
|
|
88d13f0ac9 | ||
|
|
3ed6be5b4e | ||
|
|
0340710e5c | ||
|
|
49acdaa8fd | ||
|
|
ffbc99fac2 | ||
|
|
6dae005b65 | ||
|
|
0adc853741 | ||
|
|
6254d4a983 | ||
|
|
a7db208b8a | ||
|
|
429bf2c143 | ||
|
|
8df91e6a17 | ||
|
|
8656bbbc79 | ||
|
|
630b7377bd | ||
|
|
0626a80186 | ||
|
|
24788b106b | ||
|
|
c5401b21c2 | ||
|
|
954b56475e | ||
|
|
53d7e0730c | ||
|
|
cba85cad8d | ||
|
|
96b73684eb | ||
|
|
aa7fa7b550 | ||
|
|
d229cb46b1 | ||
|
|
8682e2def8 | ||
|
|
65ac1ae84a | ||
|
|
93fd6fa11b | ||
|
|
67b0365f62 | ||
|
|
f1eda430cd | ||
|
|
69929f15fb | ||
|
|
d553c7c8e7 | ||
|
|
4d0b9f1e94 | ||
|
|
fca4ec2b3e | ||
|
|
b75aa6ac08 | ||
|
|
894ceacd40 | ||
|
|
fbe940139a | ||
|
|
c341ae0a39 | ||
|
|
f9d97c4356 | ||
|
|
b8a5d392c5 | ||
|
|
3c9e493494 | ||
|
|
2f118c5327 | ||
|
|
a7d1f52ac8 | ||
|
|
c7cfa8d245 | ||
|
|
16933abce9 | ||
|
|
8163b986c9 | ||
|
|
d5a1c52359 | ||
|
|
26ea4e41cb | ||
|
|
ec9544b9c3 | ||
|
|
1d0bc1ee66 | ||
|
|
9729c44d53 | ||
|
|
a7292af3b1 | ||
|
|
c8cbc528eb | ||
|
|
8735bfe926 | ||
|
|
25e8c7bc5f | ||
|
|
499257c8e1 | ||
|
|
6856283896 | ||
|
|
20dad9f194 | ||
|
|
09483e3be4 | ||
|
|
ab2e85840f | ||
|
|
8257e3f384 | ||
|
|
e40908d67c | ||
|
|
e67729b2f4 | ||
|
|
bf3b77e1f2 | ||
|
|
d5ca97b1f6 | ||
|
|
fd48fc5f83 | ||
|
|
c89cd6a68c |
18
.coveragerc
18
.coveragerc
@@ -38,6 +38,9 @@ omit =
|
||||
homeassistant/components/octoprint.py
|
||||
homeassistant/components/*/octoprint.py
|
||||
|
||||
homeassistant/components/qwikswitch.py
|
||||
homeassistant/components/*/qwikswitch.py
|
||||
|
||||
homeassistant/components/rpi_gpio.py
|
||||
homeassistant/components/*/rpi_gpio.py
|
||||
|
||||
@@ -72,6 +75,9 @@ omit =
|
||||
homeassistant/components/zwave.py
|
||||
homeassistant/components/*/zwave.py
|
||||
|
||||
homeassistant/components/enocean.py
|
||||
homeassistant/components/*/enocean.py
|
||||
|
||||
homeassistant/components/alarm_control_panel/alarmdotcom.py
|
||||
homeassistant/components/alarm_control_panel/nx584.py
|
||||
homeassistant/components/binary_sensor/arest.py
|
||||
@@ -108,21 +114,29 @@ omit =
|
||||
homeassistant/components/light/hyperion.py
|
||||
homeassistant/components/light/lifx.py
|
||||
homeassistant/components/light/limitlessled.py
|
||||
homeassistant/components/light/osramlightify.py
|
||||
homeassistant/components/lirc.py
|
||||
homeassistant/components/media_player/cast.py
|
||||
homeassistant/components/media_player/denon.py
|
||||
homeassistant/components/media_player/firetv.py
|
||||
homeassistant/components/media_player/gpmdp.py
|
||||
homeassistant/components/media_player/itunes.py
|
||||
homeassistant/components/media_player/kodi.py
|
||||
homeassistant/components/media_player/lg_netcast.py
|
||||
homeassistant/components/media_player/mpd.py
|
||||
homeassistant/components/media_player/onkyo.py
|
||||
homeassistant/components/media_player/panasonic_viera.py
|
||||
homeassistant/components/media_player/pioneer.py
|
||||
homeassistant/components/media_player/plex.py
|
||||
homeassistant/components/media_player/roku.py
|
||||
homeassistant/components/media_player/samsungtv.py
|
||||
homeassistant/components/media_player/snapcast.py
|
||||
homeassistant/components/media_player/sonos.py
|
||||
homeassistant/components/media_player/squeezebox.py
|
||||
homeassistant/components/media_player/yamaha.py
|
||||
homeassistant/components/notify/aws_lambda.py
|
||||
homeassistant/components/notify/aws_sns.py
|
||||
homeassistant/components/notify/aws_sqs.py
|
||||
homeassistant/components/notify/free_mobile.py
|
||||
homeassistant/components/notify/gntp.py
|
||||
homeassistant/components/notify/googlevoice.py
|
||||
@@ -138,6 +152,7 @@ omit =
|
||||
homeassistant/components/notify/smtp.py
|
||||
homeassistant/components/notify/syslog.py
|
||||
homeassistant/components/notify/telegram.py
|
||||
homeassistant/components/notify/twilio_sms.py
|
||||
homeassistant/components/notify/twitter.py
|
||||
homeassistant/components/notify/xmpp.py
|
||||
homeassistant/components/scene/hunterdouglas_powerview.py
|
||||
@@ -146,6 +161,7 @@ omit =
|
||||
homeassistant/components/sensor/cpuspeed.py
|
||||
homeassistant/components/sensor/deutsche_bahn.py
|
||||
homeassistant/components/sensor/dht.py
|
||||
homeassistant/components/sensor/dte_energy_bridge.py
|
||||
homeassistant/components/sensor/efergy.py
|
||||
homeassistant/components/sensor/eliqonline.py
|
||||
homeassistant/components/sensor/fitbit.py
|
||||
@@ -153,6 +169,7 @@ omit =
|
||||
homeassistant/components/sensor/glances.py
|
||||
homeassistant/components/sensor/google_travel_time.py
|
||||
homeassistant/components/sensor/gtfs.py
|
||||
homeassistant/components/sensor/lastfm.py
|
||||
homeassistant/components/sensor/loopenergy.py
|
||||
homeassistant/components/sensor/netatmo.py
|
||||
homeassistant/components/sensor/neurio_energy.py
|
||||
@@ -163,6 +180,7 @@ omit =
|
||||
homeassistant/components/sensor/sabnzbd.py
|
||||
homeassistant/components/sensor/speedtest.py
|
||||
homeassistant/components/sensor/steam_online.py
|
||||
homeassistant/components/sensor/supervisord.py
|
||||
homeassistant/components/sensor/swiss_public_transport.py
|
||||
homeassistant/components/sensor/systemmonitor.py
|
||||
homeassistant/components/sensor/temper.py
|
||||
|
||||
4
.github/ISSUE_TEMPLATE.md
vendored
4
.github/ISSUE_TEMPLATE.md
vendored
@@ -1,4 +1,6 @@
|
||||
Make sure you run the latest version before reporting an issue. Feature requests should go in the forum: https://community.home-assistant.io/c/feature-requests
|
||||
Make sure you are running the latest version of Home Assistant before reporting an issue.
|
||||
|
||||
You should only file an issue if you found a bug. Feature and enhancement requests should go in [the Feature Requests section](https://community.home-assistant.io/c/feature-requests) of our community forum:
|
||||
|
||||
**Home Assistant release (`hass --version`):**
|
||||
|
||||
|
||||
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -1,7 +1,9 @@
|
||||
**Description:**
|
||||
|
||||
|
||||
**Related issue (if applicable):** #
|
||||
**Related issue (if applicable):** fixes #
|
||||
|
||||
**Pull request in [home-assistant.io](https://github.com/home-assistant/home-assistant.io) with documentation (if applicable):** home-assistant/home-assistant.io#
|
||||
|
||||
**Example entry for `configuration.yaml` (if applicable):**
|
||||
```yaml
|
||||
@@ -10,6 +12,9 @@
|
||||
|
||||
**Checklist:**
|
||||
|
||||
If user exposed functionality or configuration variables are added/changed:
|
||||
- [ ] Documentation added/updated in [home-assistant.io](https://github.com/home-assistant/home-assistant.io)
|
||||
|
||||
If code communicates with devices:
|
||||
- [ ] Local tests with `tox` run successfully. **Your PR cannot be merged unless tests pass**
|
||||
- [ ] New dependencies have been added to the `REQUIREMENTS` variable ([example][ex-requir]).
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -83,3 +83,10 @@ venv
|
||||
# vimmy stuff
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
ctags.tmp
|
||||
|
||||
# vagrant stuff
|
||||
virtualization/vagrant/setup_done
|
||||
virtualization/vagrant/.vagrant
|
||||
virtualization/vagrant/config
|
||||
|
||||
@@ -19,7 +19,8 @@ RUN script/build_python_openzwave && \
|
||||
ln -sf /usr/src/app/build/python-openzwave/openzwave/config /usr/local/share/python-openzwave/config
|
||||
|
||||
COPY requirements_all.txt requirements_all.txt
|
||||
RUN pip3 install --no-cache-dir -r requirements_all.txt
|
||||
# certifi breaks Debian based installs
|
||||
RUN pip3 install --no-cache-dir -r requirements_all.txt && pip3 uninstall -y certifi
|
||||
|
||||
# Copy source
|
||||
COPY . .
|
||||
|
||||
@@ -3,11 +3,11 @@ from __future__ import print_function
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import signal
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from multiprocessing import Process
|
||||
|
||||
from homeassistant.const import (
|
||||
__version__,
|
||||
@@ -87,8 +87,7 @@ def get_arguments():
|
||||
parser.add_argument(
|
||||
'--debug',
|
||||
action='store_true',
|
||||
help='Start Home Assistant in debug mode. Runs in single process to '
|
||||
'enable use of interactive debuggers.')
|
||||
help='Start Home Assistant in debug mode')
|
||||
parser.add_argument(
|
||||
'--open-ui',
|
||||
action='store_true',
|
||||
@@ -123,15 +122,20 @@ def get_arguments():
|
||||
'--restart-osx',
|
||||
action='store_true',
|
||||
help='Restarts on OS X.')
|
||||
if os.name != "nt":
|
||||
parser.add_argument(
|
||||
'--runner',
|
||||
action='store_true',
|
||||
help='On restart exit with code {}'.format(RESTART_EXIT_CODE))
|
||||
if os.name == "posix":
|
||||
parser.add_argument(
|
||||
'--daemon',
|
||||
action='store_true',
|
||||
help='Run Home Assistant as daemon')
|
||||
|
||||
arguments = parser.parse_args()
|
||||
if os.name == "nt":
|
||||
if os.name != "posix" or arguments.debug or arguments.runner:
|
||||
arguments.daemon = False
|
||||
|
||||
return arguments
|
||||
|
||||
|
||||
@@ -144,13 +148,21 @@ def daemonize():
|
||||
|
||||
# Decouple fork
|
||||
os.setsid()
|
||||
os.umask(0)
|
||||
|
||||
# Create second fork
|
||||
pid = os.fork()
|
||||
if pid > 0:
|
||||
sys.exit(0)
|
||||
|
||||
# redirect standard file descriptors to devnull
|
||||
infd = open(os.devnull, 'r')
|
||||
outfd = open(os.devnull, 'a+')
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
os.dup2(infd.fileno(), sys.stdin.fileno())
|
||||
os.dup2(outfd.fileno(), sys.stdout.fileno())
|
||||
os.dup2(outfd.fileno(), sys.stderr.fileno())
|
||||
|
||||
|
||||
def check_pid(pid_file):
|
||||
"""Check that HA is not already running."""
|
||||
@@ -161,6 +173,10 @@ def check_pid(pid_file):
|
||||
# PID File does not exist
|
||||
return
|
||||
|
||||
# If we just restarted, we just found our own pidfile.
|
||||
if pid == os.getpid():
|
||||
return
|
||||
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except OSError:
|
||||
@@ -220,29 +236,61 @@ def uninstall_osx():
|
||||
print("Home Assistant has been uninstalled.")
|
||||
|
||||
|
||||
def setup_and_run_hass(config_dir, args, top_process=False):
|
||||
"""Setup HASS and run.
|
||||
def closefds_osx(min_fd, max_fd):
|
||||
"""Make sure file descriptors get closed when we restart.
|
||||
|
||||
Block until stopped. Will assume it is running in a subprocess unless
|
||||
top_process is set to true.
|
||||
We cannot call close on guarded fds, and we cannot easily test which fds
|
||||
are guarded. But we can set the close-on-exec flag on everything we want to
|
||||
get rid of.
|
||||
"""
|
||||
from fcntl import fcntl, F_GETFD, F_SETFD, FD_CLOEXEC
|
||||
|
||||
for _fd in range(min_fd, max_fd):
|
||||
try:
|
||||
val = fcntl(_fd, F_GETFD)
|
||||
if not val & FD_CLOEXEC:
|
||||
fcntl(_fd, F_SETFD, val | FD_CLOEXEC)
|
||||
except IOError:
|
||||
pass
|
||||
|
||||
|
||||
def cmdline():
|
||||
"""Collect path and arguments to re-execute the current hass instance."""
|
||||
if sys.argv[0].endswith('/__main__.py'):
|
||||
modulepath = os.path.dirname(sys.argv[0])
|
||||
os.environ['PYTHONPATH'] = os.path.dirname(modulepath)
|
||||
return [sys.executable] + [arg for arg in sys.argv if arg != '--daemon']
|
||||
|
||||
|
||||
def setup_and_run_hass(config_dir, args):
|
||||
"""Setup HASS and run."""
|
||||
from homeassistant import bootstrap
|
||||
|
||||
# Run a simple daemon runner process on Windows to handle restarts
|
||||
if os.name == 'nt' and '--runner' not in sys.argv:
|
||||
args = cmdline() + ['--runner']
|
||||
while True:
|
||||
try:
|
||||
subprocess.check_call(args)
|
||||
sys.exit(0)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
if exc.returncode != RESTART_EXIT_CODE:
|
||||
sys.exit(exc.returncode)
|
||||
|
||||
if args.demo_mode:
|
||||
config = {
|
||||
'frontend': {},
|
||||
'demo': {}
|
||||
}
|
||||
hass = bootstrap.from_config_dict(
|
||||
config, config_dir=config_dir, daemon=args.daemon,
|
||||
verbose=args.verbose, skip_pip=args.skip_pip,
|
||||
log_rotate_days=args.log_rotate_days)
|
||||
config, config_dir=config_dir, verbose=args.verbose,
|
||||
skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days)
|
||||
else:
|
||||
config_file = ensure_config_file(config_dir)
|
||||
print('Config directory:', config_dir)
|
||||
hass = bootstrap.from_config_file(
|
||||
config_file, daemon=args.daemon, verbose=args.verbose,
|
||||
skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days)
|
||||
config_file, verbose=args.verbose, skip_pip=args.skip_pip,
|
||||
log_rotate_days=args.log_rotate_days)
|
||||
|
||||
if hass is None:
|
||||
return
|
||||
@@ -259,39 +307,49 @@ def setup_and_run_hass(config_dir, args, top_process=False):
|
||||
hass.start()
|
||||
exit_code = int(hass.block_till_stopped())
|
||||
|
||||
if not top_process:
|
||||
sys.exit(exit_code)
|
||||
return exit_code
|
||||
|
||||
|
||||
def run_hass_process(hass_proc):
|
||||
"""Run a child hass process. Returns True if it should be restarted."""
|
||||
requested_stop = threading.Event()
|
||||
hass_proc.daemon = True
|
||||
|
||||
def request_stop(*args):
|
||||
"""Request hass stop, *args is for signal handler callback."""
|
||||
requested_stop.set()
|
||||
hass_proc.terminate()
|
||||
def try_to_restart():
|
||||
"""Attempt to clean up state and start a new homeassistant instance."""
|
||||
# Things should be mostly shut down already at this point, now just try
|
||||
# to clean up things that may have been left behind.
|
||||
sys.stderr.write('Home Assistant attempting to restart.\n')
|
||||
|
||||
# Count remaining threads, ideally there should only be one non-daemonized
|
||||
# thread left (which is us). Nothing we really do with it, but it might be
|
||||
# useful when debugging shutdown/restart issues.
|
||||
try:
|
||||
signal.signal(signal.SIGTERM, request_stop)
|
||||
nthreads = sum(thread.isAlive() and not thread.isDaemon()
|
||||
for thread in threading.enumerate())
|
||||
if nthreads > 1:
|
||||
sys.stderr.write(
|
||||
"Found {} non-daemonic threads.\n".format(nthreads))
|
||||
|
||||
# Somehow we sometimes seem to trigger an assertion in the python threading
|
||||
# module. It seems we find threads that have no associated OS level thread
|
||||
# which are not marked as stopped at the python level.
|
||||
except AssertionError:
|
||||
sys.stderr.write("Failed to count non-daemonic threads.\n")
|
||||
|
||||
# Try to not leave behind open filedescriptors with the emphasis on try.
|
||||
try:
|
||||
max_fd = os.sysconf("SC_OPEN_MAX")
|
||||
except ValueError:
|
||||
print('Could not bind to SIGTERM. Are you running in a thread?')
|
||||
max_fd = 256
|
||||
|
||||
hass_proc.start()
|
||||
try:
|
||||
hass_proc.join()
|
||||
except KeyboardInterrupt:
|
||||
request_stop()
|
||||
try:
|
||||
hass_proc.join()
|
||||
except KeyboardInterrupt:
|
||||
return False
|
||||
if platform.system() == 'Darwin':
|
||||
closefds_osx(3, max_fd)
|
||||
else:
|
||||
os.closerange(3, max_fd)
|
||||
|
||||
return (not requested_stop.isSet() and
|
||||
hass_proc.exitcode == RESTART_EXIT_CODE,
|
||||
hass_proc.exitcode)
|
||||
# Now launch into a new instance of Home-Assistant. If this fails we
|
||||
# fall through and exit with error 100 (RESTART_EXIT_CODE) in which case
|
||||
# systemd will restart us when RestartForceExitStatus=100 is set in the
|
||||
# systemd.service file.
|
||||
sys.stderr.write("Restarting Home-Assistant\n")
|
||||
args = cmdline()
|
||||
os.execv(args[0], args)
|
||||
|
||||
|
||||
def main():
|
||||
@@ -325,21 +383,10 @@ def main():
|
||||
if args.pid_file:
|
||||
write_pid(args.pid_file)
|
||||
|
||||
# Run hass in debug mode if requested
|
||||
if args.debug:
|
||||
sys.stderr.write('Running in debug mode. '
|
||||
'Home Assistant will not be able to restart.\n')
|
||||
exit_code = setup_and_run_hass(config_dir, args, top_process=True)
|
||||
if exit_code == RESTART_EXIT_CODE:
|
||||
sys.stderr.write('Home Assistant requested a '
|
||||
'restart in debug mode.\n')
|
||||
return exit_code
|
||||
exit_code = setup_and_run_hass(config_dir, args)
|
||||
if exit_code == RESTART_EXIT_CODE and not args.runner:
|
||||
try_to_restart()
|
||||
|
||||
# Run hass as child process. Restart if necessary.
|
||||
keep_running = True
|
||||
while keep_running:
|
||||
hass_proc = Process(target=setup_and_run_hass, args=(config_dir, args))
|
||||
keep_running, exit_code = run_hass_process(hass_proc)
|
||||
return exit_code
|
||||
|
||||
|
||||
|
||||
@@ -215,7 +215,7 @@ def mount_local_lib_path(config_dir):
|
||||
|
||||
# pylint: disable=too-many-branches, too-many-statements, too-many-arguments
|
||||
def from_config_dict(config, hass=None, config_dir=None, enable_log=True,
|
||||
verbose=False, daemon=False, skip_pip=False,
|
||||
verbose=False, skip_pip=False,
|
||||
log_rotate_days=None):
|
||||
"""Try to configure Home Assistant from a config dict.
|
||||
|
||||
@@ -240,7 +240,7 @@ def from_config_dict(config, hass=None, config_dir=None, enable_log=True,
|
||||
process_ha_config_upgrade(hass)
|
||||
|
||||
if enable_log:
|
||||
enable_logging(hass, verbose, daemon, log_rotate_days)
|
||||
enable_logging(hass, verbose, log_rotate_days)
|
||||
|
||||
hass.config.skip_pip = skip_pip
|
||||
if skip_pip:
|
||||
@@ -278,8 +278,8 @@ def from_config_dict(config, hass=None, config_dir=None, enable_log=True,
|
||||
return hass
|
||||
|
||||
|
||||
def from_config_file(config_path, hass=None, verbose=False, daemon=False,
|
||||
skip_pip=True, log_rotate_days=None):
|
||||
def from_config_file(config_path, hass=None, verbose=False, skip_pip=True,
|
||||
log_rotate_days=None):
|
||||
"""Read the configuration file and try to start all the functionality.
|
||||
|
||||
Will add functionality to 'hass' parameter if given,
|
||||
@@ -293,7 +293,7 @@ def from_config_file(config_path, hass=None, verbose=False, daemon=False,
|
||||
hass.config.config_dir = config_dir
|
||||
mount_local_lib_path(config_dir)
|
||||
|
||||
enable_logging(hass, verbose, daemon, log_rotate_days)
|
||||
enable_logging(hass, verbose, log_rotate_days)
|
||||
|
||||
try:
|
||||
config_dict = config_util.load_yaml_config_file(config_path)
|
||||
@@ -304,28 +304,27 @@ def from_config_file(config_path, hass=None, verbose=False, daemon=False,
|
||||
skip_pip=skip_pip)
|
||||
|
||||
|
||||
def enable_logging(hass, verbose=False, daemon=False, log_rotate_days=None):
|
||||
def enable_logging(hass, verbose=False, log_rotate_days=None):
|
||||
"""Setup the logging."""
|
||||
if not daemon:
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
fmt = ("%(log_color)s%(asctime)s %(levelname)s (%(threadName)s) "
|
||||
"[%(name)s] %(message)s%(reset)s")
|
||||
try:
|
||||
from colorlog import ColoredFormatter
|
||||
logging.getLogger().handlers[0].setFormatter(ColoredFormatter(
|
||||
fmt,
|
||||
datefmt='%y-%m-%d %H:%M:%S',
|
||||
reset=True,
|
||||
log_colors={
|
||||
'DEBUG': 'cyan',
|
||||
'INFO': 'green',
|
||||
'WARNING': 'yellow',
|
||||
'ERROR': 'red',
|
||||
'CRITICAL': 'red',
|
||||
}
|
||||
))
|
||||
except ImportError:
|
||||
pass
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
fmt = ("%(log_color)s%(asctime)s %(levelname)s (%(threadName)s) "
|
||||
"[%(name)s] %(message)s%(reset)s")
|
||||
try:
|
||||
from colorlog import ColoredFormatter
|
||||
logging.getLogger().handlers[0].setFormatter(ColoredFormatter(
|
||||
fmt,
|
||||
datefmt='%y-%m-%d %H:%M:%S',
|
||||
reset=True,
|
||||
log_colors={
|
||||
'DEBUG': 'cyan',
|
||||
'INFO': 'green',
|
||||
'WARNING': 'yellow',
|
||||
'ERROR': 'red',
|
||||
'CRITICAL': 'red',
|
||||
}
|
||||
))
|
||||
except ImportError:
|
||||
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)
|
||||
|
||||
@@ -7,14 +7,14 @@ https://home-assistant.io/components/alexa/
|
||||
import enum
|
||||
import logging
|
||||
|
||||
from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY
|
||||
from homeassistant.const import HTTP_BAD_REQUEST
|
||||
from homeassistant.helpers import template, script
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
DOMAIN = 'alexa'
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_CONFIG = {}
|
||||
|
||||
API_ENDPOINT = '/api/alexa'
|
||||
|
||||
@@ -26,80 +26,88 @@ CONF_ACTION = 'action'
|
||||
|
||||
def setup(hass, config):
|
||||
"""Activate Alexa component."""
|
||||
intents = config[DOMAIN].get(CONF_INTENTS, {})
|
||||
|
||||
for name, intent in intents.items():
|
||||
if CONF_ACTION in intent:
|
||||
intent[CONF_ACTION] = script.Script(hass, intent[CONF_ACTION],
|
||||
"Alexa intent {}".format(name))
|
||||
|
||||
_CONFIG.update(intents)
|
||||
|
||||
hass.http.register_path('POST', API_ENDPOINT, _handle_alexa, True)
|
||||
hass.wsgi.register_view(AlexaView(hass,
|
||||
config[DOMAIN].get(CONF_INTENTS, {})))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_alexa(handler, path_match, data):
|
||||
"""Handle Alexa."""
|
||||
_LOGGER.debug('Received Alexa request: %s', data)
|
||||
class AlexaView(HomeAssistantView):
|
||||
"""Handle Alexa requests."""
|
||||
|
||||
req = data.get('request')
|
||||
url = API_ENDPOINT
|
||||
name = 'api:alexa'
|
||||
|
||||
if req is None:
|
||||
_LOGGER.error('Received invalid data from Alexa: %s', data)
|
||||
handler.write_json_message(
|
||||
"Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
def __init__(self, hass, intents):
|
||||
"""Initialize Alexa view."""
|
||||
super().__init__(hass)
|
||||
|
||||
req_type = req['type']
|
||||
for name, intent in intents.items():
|
||||
if CONF_ACTION in intent:
|
||||
intent[CONF_ACTION] = script.Script(
|
||||
hass, intent[CONF_ACTION], "Alexa intent {}".format(name))
|
||||
|
||||
if req_type == 'SessionEndedRequest':
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.end_headers()
|
||||
return
|
||||
self.intents = intents
|
||||
|
||||
intent = req.get('intent')
|
||||
response = AlexaResponse(handler.server.hass, intent)
|
||||
def post(self, request):
|
||||
"""Handle Alexa."""
|
||||
data = request.json
|
||||
|
||||
if req_type == 'LaunchRequest':
|
||||
response.add_speech(
|
||||
SpeechType.plaintext,
|
||||
"Hello, and welcome to the future. How may I help?")
|
||||
handler.write_json(response.as_dict())
|
||||
return
|
||||
_LOGGER.debug('Received Alexa request: %s', data)
|
||||
|
||||
if req_type != 'IntentRequest':
|
||||
_LOGGER.warning('Received unsupported request: %s', req_type)
|
||||
return
|
||||
req = data.get('request')
|
||||
|
||||
intent_name = intent['name']
|
||||
config = _CONFIG.get(intent_name)
|
||||
if req is None:
|
||||
_LOGGER.error('Received invalid data from Alexa: %s', data)
|
||||
return self.json_message('Expected request value not received',
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
if config is None:
|
||||
_LOGGER.warning('Received unknown intent %s', intent_name)
|
||||
response.add_speech(
|
||||
SpeechType.plaintext,
|
||||
"This intent is not yet configured within Home Assistant.")
|
||||
handler.write_json(response.as_dict())
|
||||
return
|
||||
req_type = req['type']
|
||||
|
||||
speech = config.get(CONF_SPEECH)
|
||||
card = config.get(CONF_CARD)
|
||||
action = config.get(CONF_ACTION)
|
||||
if req_type == 'SessionEndedRequest':
|
||||
return None
|
||||
|
||||
# pylint: disable=unsubscriptable-object
|
||||
if speech is not None:
|
||||
response.add_speech(SpeechType[speech['type']], speech['text'])
|
||||
intent = req.get('intent')
|
||||
response = AlexaResponse(self.hass, intent)
|
||||
|
||||
if card is not None:
|
||||
response.add_card(CardType[card['type']], card['title'],
|
||||
card['content'])
|
||||
if req_type == 'LaunchRequest':
|
||||
response.add_speech(
|
||||
SpeechType.plaintext,
|
||||
"Hello, and welcome to the future. How may I help?")
|
||||
return self.json(response)
|
||||
|
||||
if action is not None:
|
||||
action.run(response.variables)
|
||||
if req_type != 'IntentRequest':
|
||||
_LOGGER.warning('Received unsupported request: %s', req_type)
|
||||
return self.json_message(
|
||||
'Received unsupported request: {}'.format(req_type),
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
handler.write_json(response.as_dict())
|
||||
intent_name = intent['name']
|
||||
config = self.intents.get(intent_name)
|
||||
|
||||
if config is None:
|
||||
_LOGGER.warning('Received unknown intent %s', intent_name)
|
||||
response.add_speech(
|
||||
SpeechType.plaintext,
|
||||
"This intent is not yet configured within Home Assistant.")
|
||||
return self.json(response)
|
||||
|
||||
speech = config.get(CONF_SPEECH)
|
||||
card = config.get(CONF_CARD)
|
||||
action = config.get(CONF_ACTION)
|
||||
|
||||
# pylint: disable=unsubscriptable-object
|
||||
if speech is not None:
|
||||
response.add_speech(SpeechType[speech['type']], speech['text'])
|
||||
|
||||
if card is not None:
|
||||
response.add_card(CardType[card['type']], card['title'],
|
||||
card['content'])
|
||||
|
||||
if action is not None:
|
||||
action.run(response.variables)
|
||||
|
||||
return self.json(response)
|
||||
|
||||
|
||||
class SpeechType(enum.Enum):
|
||||
|
||||
@@ -6,23 +6,23 @@ https://home-assistant.io/developers/api/
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
from time import time
|
||||
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.remote as rem
|
||||
from homeassistant.bootstrap import ERROR_LOG_FILENAME
|
||||
from homeassistant.const import (
|
||||
CONTENT_TYPE_TEXT_PLAIN, EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED,
|
||||
HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_HEADER_CONTENT_TYPE, HTTP_NOT_FOUND,
|
||||
HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, MATCH_ALL, URL_API, URL_API_COMPONENTS,
|
||||
EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED,
|
||||
HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND,
|
||||
HTTP_UNPROCESSABLE_ENTITY, MATCH_ALL, URL_API, URL_API_COMPONENTS,
|
||||
URL_API_CONFIG, URL_API_DISCOVERY_INFO, URL_API_ERROR_LOG,
|
||||
URL_API_EVENT_FORWARD, URL_API_EVENTS, URL_API_LOG_OUT, URL_API_SERVICES,
|
||||
URL_API_EVENT_FORWARD, URL_API_EVENTS, URL_API_SERVICES,
|
||||
URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE,
|
||||
__version__)
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers.state import TrackStates
|
||||
from homeassistant.helpers import template
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
DOMAIN = 'api'
|
||||
DEPENDENCIES = ['http']
|
||||
@@ -35,372 +35,365 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
def setup(hass, config):
|
||||
"""Register the API with the HTTP interface."""
|
||||
# /api - for validation purposes
|
||||
hass.http.register_path('GET', URL_API, _handle_get_api)
|
||||
|
||||
# /api/config
|
||||
hass.http.register_path('GET', URL_API_CONFIG, _handle_get_api_config)
|
||||
|
||||
# /api/discovery_info
|
||||
hass.http.register_path('GET', URL_API_DISCOVERY_INFO,
|
||||
_handle_get_api_discovery_info,
|
||||
require_auth=False)
|
||||
|
||||
# /api/stream
|
||||
hass.http.register_path('GET', URL_API_STREAM, _handle_get_api_stream)
|
||||
|
||||
# /api/states
|
||||
hass.http.register_path('GET', URL_API_STATES, _handle_get_api_states)
|
||||
hass.http.register_path(
|
||||
'GET', re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_handle_get_api_states_entity)
|
||||
hass.http.register_path(
|
||||
'POST', re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_handle_post_state_entity)
|
||||
hass.http.register_path(
|
||||
'PUT', re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_handle_post_state_entity)
|
||||
hass.http.register_path(
|
||||
'DELETE', re.compile(r'/api/states/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_handle_delete_state_entity)
|
||||
|
||||
# /api/events
|
||||
hass.http.register_path('GET', URL_API_EVENTS, _handle_get_api_events)
|
||||
hass.http.register_path(
|
||||
'POST', re.compile(r'/api/events/(?P<event_type>[a-zA-Z\._0-9]+)'),
|
||||
_handle_api_post_events_event)
|
||||
|
||||
# /api/services
|
||||
hass.http.register_path('GET', URL_API_SERVICES, _handle_get_api_services)
|
||||
hass.http.register_path(
|
||||
'POST',
|
||||
re.compile((r'/api/services/'
|
||||
r'(?P<domain>[a-zA-Z\._0-9]+)/'
|
||||
r'(?P<service>[a-zA-Z\._0-9]+)')),
|
||||
_handle_post_api_services_domain_service)
|
||||
|
||||
# /api/event_forwarding
|
||||
hass.http.register_path(
|
||||
'POST', URL_API_EVENT_FORWARD, _handle_post_api_event_forward)
|
||||
hass.http.register_path(
|
||||
'DELETE', URL_API_EVENT_FORWARD, _handle_delete_api_event_forward)
|
||||
|
||||
# /api/components
|
||||
hass.http.register_path(
|
||||
'GET', URL_API_COMPONENTS, _handle_get_api_components)
|
||||
|
||||
# /api/error_log
|
||||
hass.http.register_path('GET', URL_API_ERROR_LOG,
|
||||
_handle_get_api_error_log)
|
||||
|
||||
hass.http.register_path('POST', URL_API_LOG_OUT, _handle_post_api_log_out)
|
||||
|
||||
# /api/template
|
||||
hass.http.register_path('POST', URL_API_TEMPLATE,
|
||||
_handle_post_api_template)
|
||||
hass.wsgi.register_view(APIStatusView)
|
||||
hass.wsgi.register_view(APIEventStream)
|
||||
hass.wsgi.register_view(APIConfigView)
|
||||
hass.wsgi.register_view(APIDiscoveryView)
|
||||
hass.wsgi.register_view(APIStatesView)
|
||||
hass.wsgi.register_view(APIEntityStateView)
|
||||
hass.wsgi.register_view(APIEventListenersView)
|
||||
hass.wsgi.register_view(APIEventView)
|
||||
hass.wsgi.register_view(APIServicesView)
|
||||
hass.wsgi.register_view(APIDomainServicesView)
|
||||
hass.wsgi.register_view(APIEventForwardingView)
|
||||
hass.wsgi.register_view(APIComponentsView)
|
||||
hass.wsgi.register_view(APIErrorLogView)
|
||||
hass.wsgi.register_view(APITemplateView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_get_api(handler, path_match, data):
|
||||
"""Render the debug interface."""
|
||||
handler.write_json_message("API running.")
|
||||
|
||||
|
||||
def _handle_get_api_stream(handler, path_match, data):
|
||||
"""Provide a streaming interface for the event bus."""
|
||||
gracefully_closed = False
|
||||
hass = handler.server.hass
|
||||
wfile = handler.wfile
|
||||
write_lock = threading.Lock()
|
||||
block = threading.Event()
|
||||
session_id = None
|
||||
class APIStatusView(HomeAssistantView):
|
||||
"""View to handle Status requests."""
|
||||
|
||||
restrict = data.get('restrict')
|
||||
if restrict:
|
||||
restrict = restrict.split(',')
|
||||
url = URL_API
|
||||
name = "api:status"
|
||||
|
||||
def write_message(payload):
|
||||
"""Write a message to the output."""
|
||||
with write_lock:
|
||||
msg = "data: {}\n\n".format(payload)
|
||||
def get(self, request):
|
||||
"""Retrieve if API is running."""
|
||||
return self.json_message('API running.')
|
||||
|
||||
try:
|
||||
wfile.write(msg.encode("UTF-8"))
|
||||
wfile.flush()
|
||||
except (IOError, ValueError):
|
||||
# IOError: socket errors
|
||||
# ValueError: raised when 'I/O operation on closed file'
|
||||
block.set()
|
||||
|
||||
def forward_events(event):
|
||||
"""Forward events to the open request."""
|
||||
nonlocal gracefully_closed
|
||||
class APIEventStream(HomeAssistantView):
|
||||
"""View to handle EventStream requests."""
|
||||
|
||||
if block.is_set() or event.event_type == EVENT_TIME_CHANGED:
|
||||
return
|
||||
elif event.event_type == EVENT_HOMEASSISTANT_STOP:
|
||||
gracefully_closed = True
|
||||
block.set()
|
||||
return
|
||||
url = URL_API_STREAM
|
||||
name = "api:stream"
|
||||
|
||||
handler.server.sessions.extend_validation(session_id)
|
||||
write_message(json.dumps(event, cls=rem.JSONEncoder))
|
||||
def get(self, request):
|
||||
"""Provide a streaming interface for the event bus."""
|
||||
from eventlet.queue import LightQueue, Empty
|
||||
import eventlet
|
||||
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.send_header('Content-type', 'text/event-stream')
|
||||
session_id = handler.set_session_cookie_header()
|
||||
handler.end_headers()
|
||||
cur_hub = eventlet.hubs.get_hub()
|
||||
request.environ['eventlet.minimum_write_chunk_size'] = 0
|
||||
to_write = LightQueue()
|
||||
stop_obj = object()
|
||||
|
||||
if restrict:
|
||||
for event in restrict:
|
||||
hass.bus.listen(event, forward_events)
|
||||
else:
|
||||
hass.bus.listen(MATCH_ALL, forward_events)
|
||||
restrict = request.args.get('restrict')
|
||||
if restrict:
|
||||
restrict = restrict.split(',')
|
||||
|
||||
while True:
|
||||
write_message(STREAM_PING_PAYLOAD)
|
||||
def thread_forward_events(event):
|
||||
"""Forward events to the open request."""
|
||||
if event.event_type == EVENT_TIME_CHANGED:
|
||||
return
|
||||
|
||||
block.wait(STREAM_PING_INTERVAL)
|
||||
if restrict and event.event_type not in restrict:
|
||||
return
|
||||
|
||||
if block.is_set():
|
||||
break
|
||||
_LOGGER.debug('STREAM %s FORWARDING %s', id(stop_obj), event)
|
||||
|
||||
if not gracefully_closed:
|
||||
_LOGGER.info("Found broken event stream to %s, cleaning up",
|
||||
handler.client_address[0])
|
||||
if event.event_type == EVENT_HOMEASSISTANT_STOP:
|
||||
data = stop_obj
|
||||
else:
|
||||
data = json.dumps(event, cls=rem.JSONEncoder)
|
||||
|
||||
if restrict:
|
||||
for event in restrict:
|
||||
hass.bus.remove_listener(event, forward_events)
|
||||
else:
|
||||
hass.bus.remove_listener(MATCH_ALL, forward_events)
|
||||
cur_hub.schedule_call_global(0, lambda: to_write.put(data))
|
||||
|
||||
def stream():
|
||||
"""Stream events to response."""
|
||||
self.hass.bus.listen(MATCH_ALL, thread_forward_events)
|
||||
|
||||
def _handle_get_api_config(handler, path_match, data):
|
||||
"""Return the Home Assistant configuration."""
|
||||
handler.write_json(handler.server.hass.config.as_dict())
|
||||
_LOGGER.debug('STREAM %s ATTACHED', id(stop_obj))
|
||||
|
||||
last_msg = time()
|
||||
# Fire off one message right away to have browsers fire open event
|
||||
to_write.put(STREAM_PING_PAYLOAD)
|
||||
|
||||
def _handle_get_api_discovery_info(handler, path_match, data):
|
||||
needs_auth = (handler.server.hass.config.api.api_password is not None)
|
||||
params = {
|
||||
'base_url': handler.server.hass.config.api.base_url,
|
||||
'location_name': handler.server.hass.config.location_name,
|
||||
'requires_api_password': needs_auth,
|
||||
'version': __version__
|
||||
}
|
||||
handler.write_json(params)
|
||||
while True:
|
||||
try:
|
||||
# Somehow our queue.get sometimes takes too long to
|
||||
# be notified of arrival of data. Probably
|
||||
# because of our spawning on hub in other thread
|
||||
# hack. Because current goal is to get this out,
|
||||
# We just timeout every second because it will
|
||||
# return right away if qsize() > 0.
|
||||
# So yes, we're basically polling :(
|
||||
payload = to_write.get(timeout=1)
|
||||
|
||||
if payload is stop_obj:
|
||||
break
|
||||
|
||||
def _handle_get_api_states(handler, path_match, data):
|
||||
"""Return a dict containing all entity ids and their state."""
|
||||
handler.write_json(handler.server.hass.states.all())
|
||||
msg = "data: {}\n\n".format(payload)
|
||||
_LOGGER.debug('STREAM %s WRITING %s', id(stop_obj),
|
||||
msg.strip())
|
||||
yield msg.encode("UTF-8")
|
||||
last_msg = time()
|
||||
except Empty:
|
||||
if time() - last_msg > 50:
|
||||
to_write.put(STREAM_PING_PAYLOAD)
|
||||
except GeneratorExit:
|
||||
_LOGGER.debug('STREAM %s RESPONSE CLOSED', id(stop_obj))
|
||||
break
|
||||
|
||||
self.hass.bus.remove_listener(MATCH_ALL, thread_forward_events)
|
||||
|
||||
def _handle_get_api_states_entity(handler, path_match, data):
|
||||
"""Return the state of a specific entity."""
|
||||
entity_id = path_match.group('entity_id')
|
||||
return self.Response(stream(), mimetype='text/event-stream')
|
||||
|
||||
state = handler.server.hass.states.get(entity_id)
|
||||
|
||||
if state:
|
||||
handler.write_json(state)
|
||||
else:
|
||||
handler.write_json_message("State does not exist.", HTTP_NOT_FOUND)
|
||||
class APIConfigView(HomeAssistantView):
|
||||
"""View to handle Config requests."""
|
||||
|
||||
url = URL_API_CONFIG
|
||||
name = "api:config"
|
||||
|
||||
def _handle_post_state_entity(handler, path_match, data):
|
||||
"""Handle updating the state of an entity.
|
||||
def get(self, request):
|
||||
"""Get current configuration."""
|
||||
return self.json(self.hass.config.as_dict())
|
||||
|
||||
This handles the following paths:
|
||||
/api/states/<entity_id>
|
||||
"""
|
||||
entity_id = path_match.group('entity_id')
|
||||
|
||||
try:
|
||||
new_state = data['state']
|
||||
except KeyError:
|
||||
handler.write_json_message("state not specified", HTTP_BAD_REQUEST)
|
||||
return
|
||||
class APIDiscoveryView(HomeAssistantView):
|
||||
"""View to provide discovery info."""
|
||||
|
||||
attributes = data['attributes'] if 'attributes' in data else None
|
||||
requires_auth = False
|
||||
url = URL_API_DISCOVERY_INFO
|
||||
name = "api:discovery"
|
||||
|
||||
is_new_state = handler.server.hass.states.get(entity_id) is None
|
||||
def get(self, request):
|
||||
"""Get discovery info."""
|
||||
needs_auth = self.hass.config.api.api_password is not None
|
||||
return self.json({
|
||||
'base_url': self.hass.config.api.base_url,
|
||||
'location_name': self.hass.config.location_name,
|
||||
'requires_api_password': needs_auth,
|
||||
'version': __version__
|
||||
})
|
||||
|
||||
# Write state
|
||||
handler.server.hass.states.set(entity_id, new_state, attributes)
|
||||
|
||||
state = handler.server.hass.states.get(entity_id)
|
||||
class APIStatesView(HomeAssistantView):
|
||||
"""View to handle States requests."""
|
||||
|
||||
status_code = HTTP_CREATED if is_new_state else HTTP_OK
|
||||
url = URL_API_STATES
|
||||
name = "api:states"
|
||||
|
||||
handler.write_json(
|
||||
state.as_dict(),
|
||||
status_code=status_code,
|
||||
location=URL_API_STATES_ENTITY.format(entity_id))
|
||||
def get(self, request):
|
||||
"""Get current states."""
|
||||
return self.json(self.hass.states.all())
|
||||
|
||||
|
||||
def _handle_delete_state_entity(handler, path_match, data):
|
||||
"""Handle request to delete an entity from state machine.
|
||||
class APIEntityStateView(HomeAssistantView):
|
||||
"""View to handle EntityState requests."""
|
||||
|
||||
This handles the following paths:
|
||||
/api/states/<entity_id>
|
||||
"""
|
||||
entity_id = path_match.group('entity_id')
|
||||
url = "/api/states/<entity(exist=False):entity_id>"
|
||||
name = "api:entity-state"
|
||||
|
||||
if handler.server.hass.states.remove(entity_id):
|
||||
handler.write_json_message(
|
||||
"Entity not found", HTTP_NOT_FOUND)
|
||||
else:
|
||||
handler.write_json_message(
|
||||
"Entity removed", HTTP_OK)
|
||||
def get(self, request, entity_id):
|
||||
"""Retrieve state of entity."""
|
||||
state = self.hass.states.get(entity_id)
|
||||
if state:
|
||||
return self.json(state)
|
||||
else:
|
||||
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||
|
||||
def post(self, request, entity_id):
|
||||
"""Update state of entity."""
|
||||
try:
|
||||
new_state = request.json['state']
|
||||
except KeyError:
|
||||
return self.json_message('No state specified', HTTP_BAD_REQUEST)
|
||||
|
||||
def _handle_get_api_events(handler, path_match, data):
|
||||
"""Handle getting overview of event listeners."""
|
||||
handler.write_json(events_json(handler.server.hass))
|
||||
attributes = request.json.get('attributes')
|
||||
|
||||
is_new_state = self.hass.states.get(entity_id) is None
|
||||
|
||||
def _handle_api_post_events_event(handler, path_match, event_data):
|
||||
"""Handle firing of an event.
|
||||
# Write state
|
||||
self.hass.states.set(entity_id, new_state, attributes)
|
||||
|
||||
This handles the following paths: /api/events/<event_type>
|
||||
# Read the state back for our response
|
||||
resp = self.json(self.hass.states.get(entity_id))
|
||||
|
||||
Events from /api are threated as remote events.
|
||||
"""
|
||||
event_type = path_match.group('event_type')
|
||||
if is_new_state:
|
||||
resp.status_code = HTTP_CREATED
|
||||
|
||||
if event_data is not None and not isinstance(event_data, dict):
|
||||
handler.write_json_message(
|
||||
"event_data should be an object", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
resp.headers.add('Location', URL_API_STATES_ENTITY.format(entity_id))
|
||||
|
||||
event_origin = ha.EventOrigin.remote
|
||||
return resp
|
||||
|
||||
# Special case handling for event STATE_CHANGED
|
||||
# We will try to convert state dicts back to State objects
|
||||
if event_type == ha.EVENT_STATE_CHANGED and event_data:
|
||||
for key in ('old_state', 'new_state'):
|
||||
state = ha.State.from_dict(event_data.get(key))
|
||||
def delete(self, request, entity_id):
|
||||
"""Remove entity."""
|
||||
if self.hass.states.remove(entity_id):
|
||||
return self.json_message('Entity removed')
|
||||
else:
|
||||
return self.json_message('Entity not found', HTTP_NOT_FOUND)
|
||||
|
||||
if state:
|
||||
event_data[key] = state
|
||||
|
||||
handler.server.hass.bus.fire(event_type, event_data, event_origin)
|
||||
class APIEventListenersView(HomeAssistantView):
|
||||
"""View to handle EventListeners requests."""
|
||||
|
||||
handler.write_json_message("Event {} fired.".format(event_type))
|
||||
url = URL_API_EVENTS
|
||||
name = "api:event-listeners"
|
||||
|
||||
def get(self, request):
|
||||
"""Get event listeners."""
|
||||
return self.json(events_json(self.hass))
|
||||
|
||||
def _handle_get_api_services(handler, path_match, data):
|
||||
"""Handle getting overview of services."""
|
||||
handler.write_json(services_json(handler.server.hass))
|
||||
|
||||
class APIEventView(HomeAssistantView):
|
||||
"""View to handle Event requests."""
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def _handle_post_api_services_domain_service(handler, path_match, data):
|
||||
"""Handle calling a service.
|
||||
url = '/api/events/<event_type>'
|
||||
name = "api:event"
|
||||
|
||||
This handles the following paths: /api/services/<domain>/<service>
|
||||
"""
|
||||
domain = path_match.group('domain')
|
||||
service = path_match.group('service')
|
||||
def post(self, request, event_type):
|
||||
"""Fire events."""
|
||||
event_data = request.json
|
||||
|
||||
with TrackStates(handler.server.hass) as changed_states:
|
||||
handler.server.hass.services.call(domain, service, data, True)
|
||||
if event_data is not None and not isinstance(event_data, dict):
|
||||
return self.json_message('Event data should be a JSON object',
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
handler.write_json(changed_states)
|
||||
# Special case handling for event STATE_CHANGED
|
||||
# We will try to convert state dicts back to State objects
|
||||
if event_type == ha.EVENT_STATE_CHANGED and event_data:
|
||||
for key in ('old_state', 'new_state'):
|
||||
state = ha.State.from_dict(event_data.get(key))
|
||||
|
||||
if state:
|
||||
event_data[key] = state
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
def _handle_post_api_event_forward(handler, path_match, data):
|
||||
"""Handle adding an event forwarding target."""
|
||||
try:
|
||||
host = data['host']
|
||||
api_password = data['api_password']
|
||||
except KeyError:
|
||||
handler.write_json_message(
|
||||
"No host or api_password received.", HTTP_BAD_REQUEST)
|
||||
return
|
||||
self.hass.bus.fire(event_type, event_data, ha.EventOrigin.remote)
|
||||
|
||||
try:
|
||||
port = int(data['port']) if 'port' in data else None
|
||||
except ValueError:
|
||||
handler.write_json_message(
|
||||
"Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
return self.json_message("Event {} fired.".format(event_type))
|
||||
|
||||
api = rem.API(host, api_password, port)
|
||||
|
||||
if not api.validate_api():
|
||||
handler.write_json_message(
|
||||
"Unable to validate API", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
class APIServicesView(HomeAssistantView):
|
||||
"""View to handle Services requests."""
|
||||
|
||||
if handler.server.event_forwarder is None:
|
||||
handler.server.event_forwarder = \
|
||||
rem.EventForwarder(handler.server.hass)
|
||||
url = URL_API_SERVICES
|
||||
name = "api:services"
|
||||
|
||||
handler.server.event_forwarder.connect(api)
|
||||
def get(self, request):
|
||||
"""Get registered services."""
|
||||
return self.json(services_json(self.hass))
|
||||
|
||||
handler.write_json_message("Event forwarding setup.")
|
||||
|
||||
class APIDomainServicesView(HomeAssistantView):
|
||||
"""View to handle DomainServices requests."""
|
||||
|
||||
def _handle_delete_api_event_forward(handler, path_match, data):
|
||||
"""Handle deleting an event forwarding target."""
|
||||
try:
|
||||
host = data['host']
|
||||
except KeyError:
|
||||
handler.write_json_message("No host received.", HTTP_BAD_REQUEST)
|
||||
return
|
||||
url = "/api/services/<domain>/<service>"
|
||||
name = "api:domain-services"
|
||||
|
||||
try:
|
||||
port = int(data['port']) if 'port' in data else None
|
||||
except ValueError:
|
||||
handler.write_json_message(
|
||||
"Invalid value received for port", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
def post(self, request, domain, service):
|
||||
"""Call a service.
|
||||
|
||||
if handler.server.event_forwarder is not None:
|
||||
api = rem.API(host, None, port)
|
||||
Returns a list of changed states.
|
||||
"""
|
||||
with TrackStates(self.hass) as changed_states:
|
||||
self.hass.services.call(domain, service, request.json, True)
|
||||
|
||||
handler.server.event_forwarder.disconnect(api)
|
||||
return self.json(changed_states)
|
||||
|
||||
handler.write_json_message("Event forwarding cancelled.")
|
||||
|
||||
class APIEventForwardingView(HomeAssistantView):
|
||||
"""View to handle EventForwarding requests."""
|
||||
|
||||
def _handle_get_api_components(handler, path_match, data):
|
||||
"""Return all the loaded components."""
|
||||
handler.write_json(handler.server.hass.config.components)
|
||||
url = URL_API_EVENT_FORWARD
|
||||
name = "api:event-forward"
|
||||
event_forwarder = None
|
||||
|
||||
def post(self, request):
|
||||
"""Setup an event forwarder."""
|
||||
data = request.json
|
||||
if data is None:
|
||||
return self.json_message("No data received.", HTTP_BAD_REQUEST)
|
||||
try:
|
||||
host = data['host']
|
||||
api_password = data['api_password']
|
||||
except KeyError:
|
||||
return self.json_message("No host or api_password received.",
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
def _handle_get_api_error_log(handler, path_match, data):
|
||||
"""Return the logged errors for this session."""
|
||||
handler.write_file(handler.server.hass.config.path(ERROR_LOG_FILENAME),
|
||||
False)
|
||||
try:
|
||||
port = int(data['port']) if 'port' in data else None
|
||||
except ValueError:
|
||||
return self.json_message("Invalid value received for port.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
api = rem.API(host, api_password, port)
|
||||
|
||||
def _handle_post_api_log_out(handler, path_match, data):
|
||||
"""Log user out."""
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.destroy_session()
|
||||
handler.end_headers()
|
||||
if not api.validate_api():
|
||||
return self.json_message("Unable to validate API.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
if self.event_forwarder is None:
|
||||
self.event_forwarder = rem.EventForwarder(self.hass)
|
||||
|
||||
def _handle_post_api_template(handler, path_match, data):
|
||||
"""Log user out."""
|
||||
template_string = data.get('template', '')
|
||||
self.event_forwarder.connect(api)
|
||||
|
||||
try:
|
||||
rendered = template.render(handler.server.hass, template_string)
|
||||
return self.json_message("Event forwarding setup.")
|
||||
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.send_header(HTTP_HEADER_CONTENT_TYPE, CONTENT_TYPE_TEXT_PLAIN)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(rendered.encode('utf-8'))
|
||||
except TemplateError as e:
|
||||
handler.write_json_message(str(e), HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
def delete(self, request):
|
||||
"""Remove event forwarer."""
|
||||
data = request.json
|
||||
if data is None:
|
||||
return self.json_message("No data received.", HTTP_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
host = data['host']
|
||||
except KeyError:
|
||||
return self.json_message("No host received.", HTTP_BAD_REQUEST)
|
||||
|
||||
try:
|
||||
port = int(data['port']) if 'port' in data else None
|
||||
except ValueError:
|
||||
return self.json_message("Invalid value received for port.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
if self.event_forwarder is not None:
|
||||
api = rem.API(host, None, port)
|
||||
|
||||
self.event_forwarder.disconnect(api)
|
||||
|
||||
return self.json_message("Event forwarding cancelled.")
|
||||
|
||||
|
||||
class APIComponentsView(HomeAssistantView):
|
||||
"""View to handle Components requests."""
|
||||
|
||||
url = URL_API_COMPONENTS
|
||||
name = "api:components"
|
||||
|
||||
def get(self, request):
|
||||
"""Get current loaded components."""
|
||||
return self.json(self.hass.config.components)
|
||||
|
||||
|
||||
class APIErrorLogView(HomeAssistantView):
|
||||
"""View to handle ErrorLog requests."""
|
||||
|
||||
url = URL_API_ERROR_LOG
|
||||
name = "api:error-log"
|
||||
|
||||
def get(self, request):
|
||||
"""Serve error log."""
|
||||
return self.file(request, self.hass.config.path(ERROR_LOG_FILENAME))
|
||||
|
||||
|
||||
class APITemplateView(HomeAssistantView):
|
||||
"""View to handle requests."""
|
||||
|
||||
url = URL_API_TEMPLATE
|
||||
name = "api:template"
|
||||
|
||||
def post(self, request):
|
||||
"""Render a template."""
|
||||
try:
|
||||
return template.render(self.hass, request.json['template'],
|
||||
request.json.get('variables'))
|
||||
except TemplateError as ex:
|
||||
return self.json_message('Error rendering template: {}'.format(ex),
|
||||
HTTP_BAD_REQUEST)
|
||||
|
||||
|
||||
def services_json(hass):
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.helpers import condition, config_validation as cv
|
||||
|
||||
TRIGGER_SCHEMA = vol.All(vol.Schema({
|
||||
vol.Required(CONF_PLATFORM): 'numeric_state',
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_ids,
|
||||
CONF_BELOW: vol.Coerce(float),
|
||||
CONF_ABOVE: vol.Coerce(float),
|
||||
vol.Optional(CONF_VALUE_TEMPLATE): cv.template,
|
||||
@@ -41,7 +41,7 @@ def trigger(hass, config, action):
|
||||
variables = {
|
||||
'trigger': {
|
||||
'platform': 'numeric_state',
|
||||
'entity_id': entity_id,
|
||||
'entity_id': entity,
|
||||
'below': below,
|
||||
'above': above,
|
||||
}
|
||||
|
||||
63
homeassistant/components/binary_sensor/enocean.py
Normal file
63
homeassistant/components/binary_sensor/enocean.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Support for EnOcean binary sensors.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.enocean/
|
||||
"""
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.components import enocean
|
||||
from homeassistant.const import CONF_NAME
|
||||
|
||||
DEPENDENCIES = ["enocean"]
|
||||
|
||||
CONF_ID = "id"
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup the Binary Sensor platform fo EnOcean."""
|
||||
dev_id = config.get(CONF_ID, None)
|
||||
devname = config.get(CONF_NAME, "EnOcean binary sensor")
|
||||
add_devices([EnOceanBinarySensor(dev_id, devname)])
|
||||
|
||||
|
||||
class EnOceanBinarySensor(enocean.EnOceanDevice, BinarySensorDevice):
|
||||
"""Representation of EnOcean binary sensors such as wall switches."""
|
||||
|
||||
def __init__(self, dev_id, devname):
|
||||
"""Initialize the EnOcean binary sensor."""
|
||||
enocean.EnOceanDevice.__init__(self)
|
||||
self.stype = "listener"
|
||||
self.dev_id = dev_id
|
||||
self.which = -1
|
||||
self.onoff = -1
|
||||
self.devname = devname
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""The default name for the binary sensor."""
|
||||
return self.devname
|
||||
|
||||
def value_changed(self, value, value2):
|
||||
"""Fire an event with the data that have changed.
|
||||
|
||||
This method is called when there is an incoming packet associated
|
||||
with this platform.
|
||||
"""
|
||||
self.update_ha_state()
|
||||
if value2 == 0x70:
|
||||
self.which = 0
|
||||
self.onoff = 0
|
||||
elif value2 == 0x50:
|
||||
self.which = 0
|
||||
self.onoff = 1
|
||||
elif value2 == 0x30:
|
||||
self.which = 1
|
||||
self.onoff = 0
|
||||
elif value2 == 0x10:
|
||||
self.which = 1
|
||||
self.onoff = 1
|
||||
self.hass.bus.fire('button_pressed', {"id": self.dev_id,
|
||||
'pushed': value,
|
||||
'which': self.which,
|
||||
'onoff': self.onoff})
|
||||
@@ -9,11 +9,12 @@ import logging
|
||||
from homeassistant.components.binary_sensor import (BinarySensorDevice,
|
||||
ENTITY_ID_FORMAT,
|
||||
SENSOR_CLASSES)
|
||||
from homeassistant.const import ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE
|
||||
from homeassistant.core import EVENT_STATE_CHANGED
|
||||
from homeassistant.const import (ATTR_FRIENDLY_NAME, CONF_VALUE_TEMPLATE,
|
||||
ATTR_ENTITY_ID, MATCH_ALL)
|
||||
from homeassistant.exceptions import TemplateError
|
||||
from homeassistant.helpers.entity import generate_entity_id
|
||||
from homeassistant.helpers import template
|
||||
from homeassistant.helpers.event import track_state_change
|
||||
from homeassistant.util import slugify
|
||||
|
||||
CONF_SENSORS = 'sensors'
|
||||
@@ -52,13 +53,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
'Missing %s for sensor %s', CONF_VALUE_TEMPLATE, device)
|
||||
continue
|
||||
|
||||
entity_ids = device_config.get(ATTR_ENTITY_ID, MATCH_ALL)
|
||||
|
||||
sensors.append(
|
||||
BinarySensorTemplate(
|
||||
hass,
|
||||
device,
|
||||
friendly_name,
|
||||
sensor_class,
|
||||
value_template)
|
||||
value_template,
|
||||
entity_ids)
|
||||
)
|
||||
if not sensors:
|
||||
_LOGGER.error('No sensors added')
|
||||
@@ -73,7 +77,7 @@ class BinarySensorTemplate(BinarySensorDevice):
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(self, hass, device, friendly_name, sensor_class,
|
||||
value_template):
|
||||
value_template, entity_ids):
|
||||
"""Initialize the Template binary sensor."""
|
||||
self.hass = hass
|
||||
self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, device,
|
||||
@@ -85,12 +89,12 @@ class BinarySensorTemplate(BinarySensorDevice):
|
||||
|
||||
self.update()
|
||||
|
||||
def template_bsensor_event_listener(event):
|
||||
def template_bsensor_state_listener(entity, old_state, new_state):
|
||||
"""Called when the target device changes state."""
|
||||
self.update_ha_state(True)
|
||||
|
||||
hass.bus.listen(EVENT_STATE_CHANGED,
|
||||
template_bsensor_event_listener)
|
||||
track_state_change(hass, entity_ids,
|
||||
template_bsensor_state_listener)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
||||
@@ -6,17 +6,12 @@ For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/camera/
|
||||
"""
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
|
||||
import requests
|
||||
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.components import bloomsky
|
||||
from homeassistant.const import HTTP_OK, HTTP_NOT_FOUND, ATTR_ENTITY_ID
|
||||
from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
|
||||
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
DOMAIN = 'camera'
|
||||
DEPENDENCIES = ['http']
|
||||
@@ -32,10 +27,7 @@ STATE_RECORDING = 'recording'
|
||||
STATE_STREAMING = 'streaming'
|
||||
STATE_IDLE = 'idle'
|
||||
|
||||
ENTITY_IMAGE_URL = '/api/camera_proxy/{0}'
|
||||
|
||||
MULTIPART_BOUNDARY = '--jpgboundary'
|
||||
MJPEG_START_HEADER = 'Content-type: {0}\r\n\r\n'
|
||||
ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}'
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
@@ -45,57 +37,11 @@ def setup(hass, config):
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL,
|
||||
DISCOVERY_PLATFORMS)
|
||||
|
||||
hass.wsgi.register_view(CameraImageView(hass, component.entities))
|
||||
hass.wsgi.register_view(CameraMjpegStream(hass, component.entities))
|
||||
|
||||
component.setup(config)
|
||||
|
||||
def _proxy_camera_image(handler, path_match, data):
|
||||
"""Serve the camera image via the HA server."""
|
||||
entity_id = path_match.group(ATTR_ENTITY_ID)
|
||||
camera = component.entities.get(entity_id)
|
||||
|
||||
if camera is None:
|
||||
handler.send_response(HTTP_NOT_FOUND)
|
||||
handler.end_headers()
|
||||
return
|
||||
|
||||
response = camera.camera_image()
|
||||
|
||||
if response is None:
|
||||
handler.send_response(HTTP_NOT_FOUND)
|
||||
handler.end_headers()
|
||||
return
|
||||
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.write_content(response)
|
||||
|
||||
hass.http.register_path(
|
||||
'GET',
|
||||
re.compile(r'/api/camera_proxy/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_proxy_camera_image)
|
||||
|
||||
def _proxy_camera_mjpeg_stream(handler, path_match, data):
|
||||
"""Proxy the camera image as an mjpeg stream via the HA server."""
|
||||
entity_id = path_match.group(ATTR_ENTITY_ID)
|
||||
camera = component.entities.get(entity_id)
|
||||
|
||||
if camera is None:
|
||||
handler.send_response(HTTP_NOT_FOUND)
|
||||
handler.end_headers()
|
||||
return
|
||||
|
||||
try:
|
||||
camera.is_streaming = True
|
||||
camera.update_ha_state()
|
||||
camera.mjpeg_stream(handler)
|
||||
|
||||
except (requests.RequestException, IOError):
|
||||
camera.is_streaming = False
|
||||
camera.update_ha_state()
|
||||
|
||||
hass.http.register_path(
|
||||
'GET',
|
||||
re.compile(r'/api/camera_proxy_stream/(?P<entity_id>[a-zA-Z\._0-9]+)'),
|
||||
_proxy_camera_mjpeg_stream)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -106,6 +52,11 @@ class Camera(Entity):
|
||||
"""Initialize a camera."""
|
||||
self.is_streaming = False
|
||||
|
||||
@property
|
||||
def access_token(self):
|
||||
"""Access token for this camera."""
|
||||
return str(id(self))
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No need to poll cameras."""
|
||||
@@ -114,7 +65,7 @@ class Camera(Entity):
|
||||
@property
|
||||
def entity_picture(self):
|
||||
"""Return a link to the camera feed as entity picture."""
|
||||
return ENTITY_IMAGE_URL.format(self.entity_id)
|
||||
return ENTITY_IMAGE_URL.format(self.entity_id, self.access_token)
|
||||
|
||||
@property
|
||||
def is_recording(self):
|
||||
@@ -135,32 +86,35 @@ class Camera(Entity):
|
||||
"""Return bytes of camera image."""
|
||||
raise NotImplementedError()
|
||||
|
||||
def mjpeg_stream(self, handler):
|
||||
def mjpeg_stream(self, response):
|
||||
"""Generate an HTTP MJPEG stream from camera images."""
|
||||
def write_string(text):
|
||||
"""Helper method to write a string to the stream."""
|
||||
handler.request.sendall(bytes(text + '\r\n', 'utf-8'))
|
||||
import eventlet
|
||||
response.content_type = ('multipart/x-mixed-replace; '
|
||||
'boundary=--jpegboundary')
|
||||
|
||||
write_string('HTTP/1.1 200 OK')
|
||||
write_string('Content-type: multipart/x-mixed-replace; '
|
||||
'boundary={}'.format(MULTIPART_BOUNDARY))
|
||||
write_string('')
|
||||
write_string(MULTIPART_BOUNDARY)
|
||||
def stream():
|
||||
"""Stream images as mjpeg stream."""
|
||||
try:
|
||||
last_image = None
|
||||
while True:
|
||||
img_bytes = self.camera_image()
|
||||
|
||||
while True:
|
||||
img_bytes = self.camera_image()
|
||||
if img_bytes is not None and img_bytes != last_image:
|
||||
yield bytes(
|
||||
'--jpegboundary\r\n'
|
||||
'Content-Type: image/jpeg\r\n'
|
||||
'Content-Length: {}\r\n\r\n'.format(
|
||||
len(img_bytes)), 'utf-8') + img_bytes + b'\r\n'
|
||||
|
||||
if img_bytes is None:
|
||||
continue
|
||||
last_image = img_bytes
|
||||
|
||||
write_string('Content-length: {}'.format(len(img_bytes)))
|
||||
write_string('Content-type: image/jpeg')
|
||||
write_string('')
|
||||
handler.request.sendall(img_bytes)
|
||||
write_string('')
|
||||
write_string(MULTIPART_BOUNDARY)
|
||||
eventlet.sleep(0.5)
|
||||
except GeneratorExit:
|
||||
pass
|
||||
|
||||
time.sleep(0.5)
|
||||
response.response = stream()
|
||||
|
||||
return response
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
@@ -175,7 +129,9 @@ class Camera(Entity):
|
||||
@property
|
||||
def state_attributes(self):
|
||||
"""Camera state attributes."""
|
||||
attr = {}
|
||||
attr = {
|
||||
'access_token': self.access_token,
|
||||
}
|
||||
|
||||
if self.model:
|
||||
attr['model_name'] = self.model
|
||||
@@ -184,3 +140,60 @@ class Camera(Entity):
|
||||
attr['brand'] = self.brand
|
||||
|
||||
return attr
|
||||
|
||||
|
||||
class CameraView(HomeAssistantView):
|
||||
"""Base CameraView."""
|
||||
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, hass, entities):
|
||||
"""Initialize a basic camera view."""
|
||||
super().__init__(hass)
|
||||
self.entities = entities
|
||||
|
||||
def get(self, request, entity_id):
|
||||
"""Start a get request."""
|
||||
camera = self.entities.get(entity_id)
|
||||
|
||||
if camera is None:
|
||||
return self.Response(status=404)
|
||||
|
||||
authenticated = (request.authenticated or
|
||||
request.args.get('token') == camera.access_token)
|
||||
|
||||
if not authenticated:
|
||||
return self.Response(status=401)
|
||||
|
||||
return self.handle(camera)
|
||||
|
||||
def handle(self, camera):
|
||||
"""Hanlde the camera request."""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class CameraImageView(CameraView):
|
||||
"""Camera view to serve an image."""
|
||||
|
||||
url = "/api/camera_proxy/<entity(domain=camera):entity_id>"
|
||||
name = "api:camera:image"
|
||||
|
||||
def handle(self, camera):
|
||||
"""Serve camera image."""
|
||||
response = camera.camera_image()
|
||||
|
||||
if response is None:
|
||||
return self.Response(status=500)
|
||||
|
||||
return self.Response(response)
|
||||
|
||||
|
||||
class CameraMjpegStream(CameraView):
|
||||
"""Camera View to serve an MJPEG stream."""
|
||||
|
||||
url = "/api/camera_proxy_stream/<entity(domain=camera):entity_id>"
|
||||
name = "api:camera:stream"
|
||||
|
||||
def handle(self, camera):
|
||||
"""Serve camera image."""
|
||||
return camera.mjpeg_stream(self.Response())
|
||||
|
||||
@@ -11,7 +11,6 @@ import requests
|
||||
from requests.auth import HTTPBasicAuth
|
||||
|
||||
from homeassistant.components.camera import DOMAIN, Camera
|
||||
from homeassistant.const import HTTP_OK
|
||||
from homeassistant.helpers import validate_config
|
||||
|
||||
CONTENT_TYPE_HEADER = 'Content-Type'
|
||||
@@ -68,19 +67,12 @@ class MjpegCamera(Camera):
|
||||
with closing(self.camera_stream()) as response:
|
||||
return process_response(response)
|
||||
|
||||
def mjpeg_stream(self, handler):
|
||||
def mjpeg_stream(self, response):
|
||||
"""Generate an HTTP MJPEG stream from the camera."""
|
||||
response = self.camera_stream()
|
||||
content_type = response.headers[CONTENT_TYPE_HEADER]
|
||||
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.send_header(CONTENT_TYPE_HEADER, content_type)
|
||||
handler.end_headers()
|
||||
|
||||
for chunk in response.iter_content(chunk_size=1024):
|
||||
if not chunk:
|
||||
break
|
||||
handler.wfile.write(chunk)
|
||||
stream = self.camera_stream()
|
||||
response.mimetype = stream.headers[CONTENT_TYPE_HEADER]
|
||||
response.response = stream.iter_content(chunk_size=1024)
|
||||
return response
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
|
||||
@@ -12,7 +12,7 @@ import requests
|
||||
from homeassistant.components.camera import DOMAIN, Camera
|
||||
from homeassistant.helpers import validate_config
|
||||
|
||||
REQUIREMENTS = ['uvcclient==0.8']
|
||||
REQUIREMENTS = ['uvcclient==0.9.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -45,13 +45,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
_LOGGER.error('Unable to connect to NVR: %s', str(ex))
|
||||
return False
|
||||
|
||||
identifier = nvrconn.server_version >= (3, 2, 0) and 'id' or 'uuid'
|
||||
# Filter out airCam models, which are not supported in the latest
|
||||
# version of UnifiVideo and which are EOL by Ubiquiti
|
||||
cameras = [camera for camera in cameras
|
||||
if 'airCam' not in nvrconn.get_camera(camera['uuid'])['model']]
|
||||
cameras = [
|
||||
camera for camera in cameras
|
||||
if 'airCam' not in nvrconn.get_camera(camera[identifier])['model']]
|
||||
|
||||
add_devices([UnifiVideoCamera(nvrconn,
|
||||
camera['uuid'],
|
||||
camera[identifier],
|
||||
camera['name'])
|
||||
for camera in cameras])
|
||||
return True
|
||||
@@ -110,12 +112,17 @@ class UnifiVideoCamera(Camera):
|
||||
dict(name=self._name))
|
||||
password = 'ubnt'
|
||||
|
||||
if self._nvr.server_version >= (3, 2, 0):
|
||||
client_cls = uvc_camera.UVCCameraClientV320
|
||||
else:
|
||||
client_cls = uvc_camera.UVCCameraClient
|
||||
|
||||
camera = None
|
||||
for addr in addrs:
|
||||
try:
|
||||
camera = uvc_camera.UVCCameraClient(addr,
|
||||
caminfo['username'],
|
||||
password)
|
||||
camera = client_cls(addr,
|
||||
caminfo['username'],
|
||||
password)
|
||||
camera.login()
|
||||
_LOGGER.debug('Logged into UVC camera %(name)s via %(addr)s',
|
||||
dict(name=self._name, addr=addr))
|
||||
|
||||
@@ -19,6 +19,8 @@ SERVICE_CONFIGURE = "configure"
|
||||
STATE_CONFIGURE = "configure"
|
||||
STATE_CONFIGURED = "configured"
|
||||
|
||||
ATTR_LINK_NAME = "link_name"
|
||||
ATTR_LINK_URL = "link_url"
|
||||
ATTR_CONFIGURE_ID = "configure_id"
|
||||
ATTR_DESCRIPTION = "description"
|
||||
ATTR_DESCRIPTION_IMAGE = "description_image"
|
||||
@@ -34,7 +36,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
# pylint: disable=too-many-arguments
|
||||
def request_config(
|
||||
hass, name, callback, description=None, description_image=None,
|
||||
submit_caption=None, fields=None):
|
||||
submit_caption=None, fields=None, link_name=None, link_url=None):
|
||||
"""Create a new request for configuration.
|
||||
|
||||
Will return an ID to be used for sequent calls.
|
||||
@@ -43,7 +45,8 @@ def request_config(
|
||||
|
||||
request_id = instance.request_config(
|
||||
name, callback,
|
||||
description, description_image, submit_caption, fields)
|
||||
description, description_image, submit_caption,
|
||||
fields, link_name, link_url)
|
||||
|
||||
_REQUESTS[request_id] = instance
|
||||
|
||||
@@ -100,7 +103,8 @@ class Configurator(object):
|
||||
# pylint: disable=too-many-arguments
|
||||
def request_config(
|
||||
self, name, callback,
|
||||
description, description_image, submit_caption, fields):
|
||||
description, description_image, submit_caption,
|
||||
fields, link_name, link_url):
|
||||
"""Setup a request for configuration."""
|
||||
entity_id = generate_entity_id(ENTITY_ID_FORMAT, name, hass=self.hass)
|
||||
|
||||
@@ -121,6 +125,8 @@ class Configurator(object):
|
||||
(ATTR_DESCRIPTION, description),
|
||||
(ATTR_DESCRIPTION_IMAGE, description_image),
|
||||
(ATTR_SUBMIT_CAPTION, submit_caption),
|
||||
(ATTR_LINK_NAME, link_name),
|
||||
(ATTR_LINK_URL, link_url),
|
||||
] if value is not None
|
||||
})
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ SERVICE_PROCESS_SCHEMA = vol.Schema({
|
||||
|
||||
REGEX_TURN_COMMAND = re.compile(r'turn (?P<name>(?: |\w)+) (?P<command>\w+)')
|
||||
|
||||
REQUIREMENTS = ['fuzzywuzzy==0.8.0']
|
||||
REQUIREMENTS = ['fuzzywuzzy==0.10.0']
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
|
||||
@@ -21,6 +21,7 @@ COMPONENTS_WITH_DEMO_PLATFORM = [
|
||||
'camera',
|
||||
'device_tracker',
|
||||
'garage_door',
|
||||
'hvac',
|
||||
'light',
|
||||
'lock',
|
||||
'media_player',
|
||||
|
||||
@@ -17,7 +17,7 @@ from homeassistant.config import load_yaml_config_file
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_per_platform
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.util as util
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
@@ -26,6 +26,7 @@ from homeassistant.const import (
|
||||
ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE,
|
||||
DEVICE_DEFAULT_NAME, STATE_HOME, STATE_NOT_HOME)
|
||||
|
||||
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||
DOMAIN = "device_tracker"
|
||||
DEPENDENCIES = ['zone']
|
||||
|
||||
@@ -193,7 +194,7 @@ class DeviceTracker(object):
|
||||
if not device:
|
||||
dev_id = util.slugify(host_name or '') or util.slugify(mac)
|
||||
else:
|
||||
dev_id = str(dev_id).lower()
|
||||
dev_id = cv.slug(str(dev_id).lower())
|
||||
device = self.devices.get(dev_id)
|
||||
|
||||
if device:
|
||||
|
||||
@@ -19,13 +19,16 @@ from homeassistant.util import Throttle
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=5)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REQUIREMENTS = ['pexpect==4.0.1']
|
||||
|
||||
_LEASES_CMD = 'cat /var/lib/misc/dnsmasq.leases'
|
||||
_LEASES_REGEX = re.compile(
|
||||
r'\w+\s' +
|
||||
r'(?P<mac>(([0-9a-f]{2}[:-]){5}([0-9a-f]{2})))\s' +
|
||||
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' +
|
||||
r'(?P<host>([^\s]+))')
|
||||
|
||||
_IP_NEIGH_CMD = 'ip neigh'
|
||||
_IP_NEIGH_REGEX = re.compile(
|
||||
r'(?P<ip>([0-9]{1,3}[\.]){3}[0-9]{1,3})\s' +
|
||||
r'\w+\s' +
|
||||
@@ -55,6 +58,7 @@ class AsusWrtDeviceScanner(object):
|
||||
self.host = config[CONF_HOST]
|
||||
self.username = str(config[CONF_USERNAME])
|
||||
self.password = str(config[CONF_PASSWORD])
|
||||
self.protocol = config.get('protocol')
|
||||
|
||||
self.lock = threading.Lock()
|
||||
|
||||
@@ -100,8 +104,26 @@ class AsusWrtDeviceScanner(object):
|
||||
self.last_results = active_clients
|
||||
return True
|
||||
|
||||
def get_asuswrt_data(self):
|
||||
"""Retrieve data from ASUSWRT and return parsed result."""
|
||||
def ssh_connection(self):
|
||||
"""Retrieve data from ASUSWRT via the ssh protocol."""
|
||||
from pexpect import pxssh
|
||||
try:
|
||||
ssh = pxssh.pxssh()
|
||||
ssh.login(self.host, self.username, self.password)
|
||||
ssh.sendline(_IP_NEIGH_CMD)
|
||||
ssh.prompt()
|
||||
neighbors = ssh.before.split(b'\n')[1:-1]
|
||||
ssh.sendline(_LEASES_CMD)
|
||||
ssh.prompt()
|
||||
leases_result = ssh.before.split(b'\n')[1:-1]
|
||||
ssh.logout()
|
||||
return (neighbors, leases_result)
|
||||
except pxssh.ExceptionPxssh as exc:
|
||||
_LOGGER.exception('Unexpected response from router: %s', exc)
|
||||
return ('', '')
|
||||
|
||||
def telnet_connection(self):
|
||||
"""Retrieve data from ASUSWRT via the telnet protocol."""
|
||||
try:
|
||||
telnet = telnetlib.Telnet(self.host)
|
||||
telnet.read_until(b'login: ')
|
||||
@@ -109,18 +131,26 @@ class AsusWrtDeviceScanner(object):
|
||||
telnet.read_until(b'Password: ')
|
||||
telnet.write((self.password + '\n').encode('ascii'))
|
||||
prompt_string = telnet.read_until(b'#').split(b'\n')[-1]
|
||||
telnet.write('ip neigh\n'.encode('ascii'))
|
||||
telnet.write('{}\n'.format(_IP_NEIGH_CMD).encode('ascii'))
|
||||
neighbors = telnet.read_until(prompt_string).split(b'\n')[1:-1]
|
||||
telnet.write('cat /var/lib/misc/dnsmasq.leases\n'.encode('ascii'))
|
||||
telnet.write('{}\n'.format(_LEASES_CMD).encode('ascii'))
|
||||
leases_result = telnet.read_until(prompt_string).split(b'\n')[1:-1]
|
||||
telnet.write('exit\n'.encode('ascii'))
|
||||
return (neighbors, leases_result)
|
||||
except EOFError:
|
||||
_LOGGER.exception("Unexpected response from router")
|
||||
return
|
||||
return ('', '')
|
||||
except ConnectionRefusedError:
|
||||
_LOGGER.exception("Connection refused by router," +
|
||||
_LOGGER.exception("Connection refused by router,"
|
||||
" is telnet enabled?")
|
||||
return
|
||||
return ('', '')
|
||||
|
||||
def get_asuswrt_data(self):
|
||||
"""Retrieve data from ASUSWRT and return parsed result."""
|
||||
if self.protocol == 'telnet':
|
||||
neighbors, leases_result = self.telnet_connection()
|
||||
else:
|
||||
neighbors, leases_result = self.ssh_connection()
|
||||
|
||||
devices = {}
|
||||
for lease in leases_result:
|
||||
|
||||
@@ -5,95 +5,92 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/device_tracker.locative/
|
||||
"""
|
||||
import logging
|
||||
from functools import partial
|
||||
|
||||
from homeassistant.components.device_tracker import DOMAIN
|
||||
from homeassistant.const import HTTP_UNPROCESSABLE_ENTITY, STATE_NOT_HOME
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['http']
|
||||
|
||||
URL_API_LOCATIVE_ENDPOINT = "/api/locative"
|
||||
|
||||
|
||||
def setup_scanner(hass, config, see):
|
||||
"""Setup an endpoint for the Locative application."""
|
||||
# POST would be semantically better, but that currently does not work
|
||||
# since Locative sends the data as key1=value1&key2=value2
|
||||
# in the request body, while Home Assistant expects json there.
|
||||
hass.http.register_path(
|
||||
'GET', URL_API_LOCATIVE_ENDPOINT,
|
||||
partial(_handle_get_api_locative, hass, see))
|
||||
hass.wsgi.register_view(LocativeView(hass, see))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_get_api_locative(hass, see, handler, path_match, data):
|
||||
"""Locative message received."""
|
||||
if not _check_data(handler, data):
|
||||
return
|
||||
class LocativeView(HomeAssistantView):
|
||||
"""View to handle locative requests."""
|
||||
|
||||
device = data['device'].replace('-', '')
|
||||
location_name = data['id'].lower()
|
||||
direction = data['trigger']
|
||||
url = "/api/locative"
|
||||
name = "api:bootstrap"
|
||||
|
||||
if direction == 'enter':
|
||||
see(dev_id=device, location_name=location_name)
|
||||
handler.write_text("Setting location to {}".format(location_name))
|
||||
def __init__(self, hass, see):
|
||||
"""Initialize Locative url endpoints."""
|
||||
super().__init__(hass)
|
||||
self.see = see
|
||||
|
||||
elif direction == 'exit':
|
||||
current_state = hass.states.get("{}.{}".format(DOMAIN, device))
|
||||
def get(self, request):
|
||||
"""Locative message received as GET."""
|
||||
return self.post(request)
|
||||
|
||||
def post(self, request):
|
||||
"""Locative message received."""
|
||||
# pylint: disable=too-many-return-statements
|
||||
data = request.values
|
||||
|
||||
if 'latitude' not in data or 'longitude' not in data:
|
||||
return ("Latitude and longitude not specified.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
if 'device' not in data:
|
||||
_LOGGER.error("Device id not specified.")
|
||||
return ("Device id not specified.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
if 'id' not in data:
|
||||
_LOGGER.error("Location id not specified.")
|
||||
return ("Location id not specified.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
if 'trigger' not in data:
|
||||
_LOGGER.error("Trigger is not specified.")
|
||||
return ("Trigger is not specified.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
device = data['device'].replace('-', '')
|
||||
location_name = data['id'].lower()
|
||||
direction = data['trigger']
|
||||
|
||||
if direction == 'enter':
|
||||
self.see(dev_id=device, location_name=location_name)
|
||||
return "Setting location to {}".format(location_name)
|
||||
|
||||
elif direction == 'exit':
|
||||
current_state = self.hass.states.get(
|
||||
"{}.{}".format(DOMAIN, device))
|
||||
|
||||
if current_state is None or current_state.state == location_name:
|
||||
self.see(dev_id=device, location_name=STATE_NOT_HOME)
|
||||
return "Setting location to not home"
|
||||
else:
|
||||
# Ignore the message if it is telling us to exit a zone that we
|
||||
# aren't currently in. This occurs when a zone is entered
|
||||
# before the previous zone was exited. The enter message will
|
||||
# be sent first, then the exit message will be sent second.
|
||||
return 'Ignoring exit from {} (already in {})'.format(
|
||||
location_name, current_state)
|
||||
|
||||
elif direction == 'test':
|
||||
# In the app, a test message can be sent. Just return something to
|
||||
# the user to let them know that it works.
|
||||
return "Received test message."
|
||||
|
||||
if current_state is None or current_state.state == location_name:
|
||||
see(dev_id=device, location_name=STATE_NOT_HOME)
|
||||
handler.write_text("Setting location to not home")
|
||||
else:
|
||||
# Ignore the message if it is telling us to exit a zone that we
|
||||
# aren't currently in. This occurs when a zone is entered before
|
||||
# the previous zone was exited. The enter message will be sent
|
||||
# first, then the exit message will be sent second.
|
||||
handler.write_text(
|
||||
'Ignoring exit from {} (already in {})'.format(
|
||||
location_name, current_state))
|
||||
|
||||
elif direction == 'test':
|
||||
# In the app, a test message can be sent. Just return something to
|
||||
# the user to let them know that it works.
|
||||
handler.write_text("Received test message.")
|
||||
|
||||
else:
|
||||
handler.write_text(
|
||||
"Received unidentified message: {}".format(direction),
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
_LOGGER.error("Received unidentified message from Locative: %s",
|
||||
direction)
|
||||
|
||||
|
||||
def _check_data(handler, data):
|
||||
"""Check the data."""
|
||||
if 'latitude' not in data or 'longitude' not in data:
|
||||
handler.write_text("Latitude and longitude not specified.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
_LOGGER.error("Latitude and longitude not specified.")
|
||||
return False
|
||||
|
||||
if 'device' not in data:
|
||||
handler.write_text("Device id not specified.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
_LOGGER.error("Device id not specified.")
|
||||
return False
|
||||
|
||||
if 'id' not in data:
|
||||
handler.write_text("Location id not specified.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
_LOGGER.error("Location id not specified.")
|
||||
return False
|
||||
|
||||
if 'trigger' not in data:
|
||||
handler.write_text("Trigger is not specified.",
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
_LOGGER.error("Trigger is not specified.")
|
||||
return False
|
||||
|
||||
return True
|
||||
_LOGGER.error("Received unidentified message from Locative: %s",
|
||||
direction)
|
||||
return ("Received unidentified message: {}".format(direction),
|
||||
HTTP_UNPROCESSABLE_ENTITY)
|
||||
|
||||
@@ -11,7 +11,7 @@ from collections import defaultdict
|
||||
|
||||
import homeassistant.components.mqtt as mqtt
|
||||
from homeassistant.const import STATE_HOME
|
||||
from homeassistant.util import convert
|
||||
from homeassistant.util import convert, slugify
|
||||
|
||||
DEPENDENCIES = ['mqtt']
|
||||
|
||||
@@ -53,6 +53,12 @@ def setup_scanner(hass, config, see):
|
||||
'accuracy %s is not met: %s',
|
||||
data_type, max_gps_accuracy, data)
|
||||
return None
|
||||
if convert(data.get('acc'), float, 1.0) == 0.0:
|
||||
_LOGGER.debug('Skipping %s update because GPS accuracy'
|
||||
'is zero',
|
||||
data_type)
|
||||
return None
|
||||
|
||||
return data
|
||||
|
||||
def owntracks_location_update(topic, payload, qos):
|
||||
@@ -91,7 +97,7 @@ def setup_scanner(hass, config, see):
|
||||
return
|
||||
# OwnTracks uses - at the start of a beacon zone
|
||||
# to switch on 'hold mode' - ignore this
|
||||
location = data['desc'].lstrip("-")
|
||||
location = slugify(data['desc'].lstrip("-"))
|
||||
if location.lower() == 'home':
|
||||
location = STATE_HOME
|
||||
|
||||
@@ -180,7 +186,7 @@ def setup_scanner(hass, config, see):
|
||||
def _parse_see_args(topic, data):
|
||||
"""Parse the OwnTracks location parameters, into the format see expects."""
|
||||
parts = topic.split('/')
|
||||
dev_id = '{}_{}'.format(parts[1], parts[2])
|
||||
dev_id = slugify('{}_{}'.format(parts[1], parts[2]))
|
||||
host_name = parts[1]
|
||||
kwargs = {
|
||||
'dev_id': dev_id,
|
||||
|
||||
@@ -18,7 +18,7 @@ from homeassistant.util import Throttle
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REQUIREMENTS = ['pysnmp==4.2.5']
|
||||
REQUIREMENTS = ['pysnmp==4.3.2']
|
||||
|
||||
CONF_COMMUNITY = "community"
|
||||
CONF_BASEOID = "baseoid"
|
||||
@@ -72,7 +72,7 @@ class SnmpScanner(object):
|
||||
|
||||
@Throttle(MIN_TIME_BETWEEN_SCANS)
|
||||
def _update_info(self):
|
||||
"""Ensure the information from the WAP is up to date.
|
||||
"""Ensure the information from the device is up to date.
|
||||
|
||||
Return boolean if scanning successful.
|
||||
"""
|
||||
@@ -88,7 +88,7 @@ class SnmpScanner(object):
|
||||
return True
|
||||
|
||||
def get_snmp_data(self):
|
||||
"""Fetch MAC addresses from WAP via SNMP."""
|
||||
"""Fetch MAC addresses from access point via SNMP."""
|
||||
devices = []
|
||||
|
||||
errindication, errstatus, errindex, restable = self.snmp.nextCmd(
|
||||
@@ -97,9 +97,10 @@ class SnmpScanner(object):
|
||||
if errindication:
|
||||
_LOGGER.error("SNMPLIB error: %s", errindication)
|
||||
return
|
||||
# pylint: disable=no-member
|
||||
if errstatus:
|
||||
_LOGGER.error('SNMP error: %s at %s', errstatus.prettyPrint(),
|
||||
errindex and restable[-1][int(errindex)-1] or '?')
|
||||
errindex and restable[int(errindex) - 1][0] or '?')
|
||||
return
|
||||
|
||||
for resrow in restable:
|
||||
|
||||
@@ -15,10 +15,12 @@ from homeassistant.const import (
|
||||
EVENT_PLATFORM_DISCOVERED)
|
||||
|
||||
DOMAIN = "discovery"
|
||||
REQUIREMENTS = ['netdisco==0.6.6']
|
||||
REQUIREMENTS = ['netdisco==0.6.7']
|
||||
|
||||
SCAN_INTERVAL = 300 # seconds
|
||||
|
||||
LOAD_PLATFORM = 'load_platform'
|
||||
|
||||
SERVICE_WEMO = 'belkin_wemo'
|
||||
SERVICE_HUE = 'philips_hue'
|
||||
SERVICE_CAST = 'google_cast'
|
||||
@@ -27,6 +29,7 @@ SERVICE_SONOS = 'sonos'
|
||||
SERVICE_PLEX = 'plex_mediaserver'
|
||||
SERVICE_SQUEEZEBOX = 'logitech_mediaserver'
|
||||
SERVICE_PANASONIC_VIERA = 'panasonic_viera'
|
||||
SERVICE_ROKU = 'roku'
|
||||
|
||||
SERVICE_HANDLERS = {
|
||||
SERVICE_WEMO: "wemo",
|
||||
@@ -37,6 +40,7 @@ SERVICE_HANDLERS = {
|
||||
SERVICE_PLEX: 'media_player',
|
||||
SERVICE_SQUEEZEBOX: 'media_player',
|
||||
SERVICE_PANASONIC_VIERA: 'media_player',
|
||||
SERVICE_ROKU: 'media_player',
|
||||
}
|
||||
|
||||
|
||||
@@ -52,7 +56,7 @@ def listen(hass, service, callback):
|
||||
|
||||
def discovery_event_listener(event):
|
||||
"""Listen for discovery events."""
|
||||
if event.data[ATTR_SERVICE] in service:
|
||||
if ATTR_SERVICE in event.data and event.data[ATTR_SERVICE] in service:
|
||||
callback(event.data[ATTR_SERVICE], event.data.get(ATTR_DISCOVERED))
|
||||
|
||||
hass.bus.listen(EVENT_PLATFORM_DISCOVERED, discovery_event_listener)
|
||||
@@ -73,6 +77,32 @@ def discover(hass, service, discovered=None, component=None, hass_config=None):
|
||||
hass.bus.fire(EVENT_PLATFORM_DISCOVERED, data)
|
||||
|
||||
|
||||
def load_platform(hass, component, platform, info=None, hass_config=None):
|
||||
"""Helper method for generic platform loading.
|
||||
|
||||
This method allows a platform to be loaded dynamically without it being
|
||||
known at runtime (in the DISCOVERY_PLATFORMS list of the component).
|
||||
Advantages of using this method:
|
||||
- Any component & platforms combination can be dynamically added
|
||||
- A component (i.e. light) does not have to import every component
|
||||
that can dynamically add a platform (e.g. wemo, wink, insteon_hub)
|
||||
- Custom user components can take advantage of discovery/loading
|
||||
|
||||
Target components will be loaded and an EVENT_PLATFORM_DISCOVERED will be
|
||||
fired to load the platform. The event will contain:
|
||||
{ ATTR_SERVICE = LOAD_PLATFORM + '.' + <<component>>
|
||||
ATTR_DISCOVERED = {LOAD_PLATFORM: <<platform>>} }
|
||||
|
||||
* dev note: This listener can be found in entity_component.py
|
||||
"""
|
||||
if info is None:
|
||||
info = {LOAD_PLATFORM: platform}
|
||||
else:
|
||||
info[LOAD_PLATFORM] = platform
|
||||
discover(hass, LOAD_PLATFORM + '.' + component, info, component,
|
||||
hass_config)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Start a discovery service."""
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,7 +22,7 @@ HOLD_TEMP = 'hold_temp'
|
||||
|
||||
REQUIREMENTS = [
|
||||
'https://github.com/nkgilley/python-ecobee-api/archive/'
|
||||
'92a2f330cbaf601d0618456fdd97e5a8c42c1c47.zip#python-ecobee==0.0.4']
|
||||
'4a884bc146a93991b4210f868f3d6aecf0a181e6.zip#python-ecobee==0.0.5']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
117
homeassistant/components/enocean.py
Normal file
117
homeassistant/components/enocean.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""
|
||||
EnOcean Component.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/EnOcean/
|
||||
"""
|
||||
|
||||
DOMAIN = "enocean"
|
||||
|
||||
REQUIREMENTS = ['enocean==0.31']
|
||||
|
||||
CONF_DEVICE = "device"
|
||||
|
||||
ENOCEAN_DONGLE = None
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Setup the EnOcean component."""
|
||||
global ENOCEAN_DONGLE
|
||||
|
||||
serial_dev = config[DOMAIN].get(CONF_DEVICE, "/dev/ttyUSB0")
|
||||
|
||||
ENOCEAN_DONGLE = EnOceanDongle(hass, serial_dev)
|
||||
return True
|
||||
|
||||
|
||||
class EnOceanDongle:
|
||||
"""Representation of an EnOcean dongle."""
|
||||
|
||||
def __init__(self, hass, ser):
|
||||
"""Initialize the EnOcean dongle."""
|
||||
from enocean.communicators.serialcommunicator import SerialCommunicator
|
||||
self.__communicator = SerialCommunicator(port=ser,
|
||||
callback=self.callback)
|
||||
self.__communicator.start()
|
||||
self.__devices = []
|
||||
|
||||
def register_device(self, dev):
|
||||
"""Register another device."""
|
||||
self.__devices.append(dev)
|
||||
|
||||
def send_command(self, command):
|
||||
"""Send a command from the EnOcean dongle."""
|
||||
self.__communicator.send(command)
|
||||
|
||||
def _combine_hex(self, data): # pylint: disable=no-self-use
|
||||
"""Combine list of integer values to one big integer."""
|
||||
output = 0x00
|
||||
for i, j in enumerate(reversed(data)):
|
||||
output |= (j << i * 8)
|
||||
return output
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
def callback(self, temp):
|
||||
"""Callback function for EnOcean Device.
|
||||
|
||||
This is the callback function called by
|
||||
python-enocan whenever there is an incoming
|
||||
packet.
|
||||
"""
|
||||
from enocean.protocol.packet import RadioPacket
|
||||
if isinstance(temp, RadioPacket):
|
||||
rxtype = None
|
||||
value = None
|
||||
if temp.data[6] == 0x30:
|
||||
rxtype = "wallswitch"
|
||||
value = 1
|
||||
elif temp.data[6] == 0x20:
|
||||
rxtype = "wallswitch"
|
||||
value = 0
|
||||
elif temp.data[4] == 0x0c:
|
||||
rxtype = "power"
|
||||
value = temp.data[3] + (temp.data[2] << 8)
|
||||
elif temp.data[2] == 0x60:
|
||||
rxtype = "switch_status"
|
||||
if temp.data[3] == 0xe4:
|
||||
value = 1
|
||||
elif temp.data[3] == 0x80:
|
||||
value = 0
|
||||
elif temp.data[0] == 0xa5 and temp.data[1] == 0x02:
|
||||
rxtype = "dimmerstatus"
|
||||
value = temp.data[2]
|
||||
for device in self.__devices:
|
||||
if rxtype == "wallswitch" and device.stype == "listener":
|
||||
if temp.sender == self._combine_hex(device.dev_id):
|
||||
device.value_changed(value, temp.data[1])
|
||||
if rxtype == "power" and device.stype == "powersensor":
|
||||
if temp.sender == self._combine_hex(device.dev_id):
|
||||
device.value_changed(value)
|
||||
if rxtype == "power" and device.stype == "switch":
|
||||
if temp.sender == self._combine_hex(device.dev_id):
|
||||
if value > 10:
|
||||
device.value_changed(1)
|
||||
if rxtype == "switch_status" and device.stype == "switch":
|
||||
if temp.sender == self._combine_hex(device.dev_id):
|
||||
device.value_changed(value)
|
||||
if rxtype == "dimmerstatus" and device.stype == "dimmer":
|
||||
if temp.sender == self._combine_hex(device.dev_id):
|
||||
device.value_changed(value)
|
||||
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
class EnOceanDevice():
|
||||
"""Parent class for all devices associated with the EnOcean component."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the device."""
|
||||
ENOCEAN_DONGLE.register_device(self)
|
||||
self.stype = ""
|
||||
self.sensorid = [0x00, 0x00, 0x00, 0x00]
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def send_command(self, data, optional, packet_type):
|
||||
"""Send a command via the EnOcean dongle."""
|
||||
from enocean.protocol.packet import Packet
|
||||
packet = Packet(packet_type, data=data, optional=optional)
|
||||
ENOCEAN_DONGLE.send_command(packet)
|
||||
@@ -6,7 +6,11 @@ https://home-assistant.io/components/feedreader/
|
||||
"""
|
||||
from datetime import datetime
|
||||
from logging import getLogger
|
||||
from os.path import exists
|
||||
from threading import Lock
|
||||
import pickle
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_START
|
||||
from homeassistant.helpers.event import track_utc_time_change
|
||||
|
||||
@@ -27,14 +31,15 @@ MAX_ENTRIES = 20
|
||||
class FeedManager(object):
|
||||
"""Abstraction over feedparser module."""
|
||||
|
||||
def __init__(self, url, hass):
|
||||
def __init__(self, url, hass, storage):
|
||||
"""Initialize the FeedManager object, poll every hour."""
|
||||
self._url = url
|
||||
self._feed = None
|
||||
self._hass = hass
|
||||
self._firstrun = True
|
||||
# Initialize last entry timestamp as epoch time
|
||||
self._last_entry_timestamp = datetime.utcfromtimestamp(0).timetuple()
|
||||
self._storage = storage
|
||||
self._last_entry_timestamp = None
|
||||
self._has_published_parsed = False
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START,
|
||||
lambda _: self._update())
|
||||
track_utc_time_change(hass, lambda now: self._update(),
|
||||
@@ -42,7 +47,7 @@ class FeedManager(object):
|
||||
|
||||
def _log_no_entries(self):
|
||||
"""Send no entries log at debug level."""
|
||||
_LOGGER.debug('No new entries in feed "%s"', self._url)
|
||||
_LOGGER.debug('No new entries to be published in feed "%s"', self._url)
|
||||
|
||||
def _update(self):
|
||||
"""Update the feed and publish new entries to the event bus."""
|
||||
@@ -65,10 +70,13 @@ class FeedManager(object):
|
||||
len(self._feed.entries),
|
||||
self._url)
|
||||
if len(self._feed.entries) > MAX_ENTRIES:
|
||||
_LOGGER.debug('Publishing only the first %s entries '
|
||||
_LOGGER.debug('Processing only the first %s entries '
|
||||
'in feed "%s"', MAX_ENTRIES, self._url)
|
||||
self._feed.entries = self._feed.entries[0:MAX_ENTRIES]
|
||||
self._publish_new_entries()
|
||||
if self._has_published_parsed:
|
||||
self._storage.put_timestamp(self._url,
|
||||
self._last_entry_timestamp)
|
||||
else:
|
||||
self._log_no_entries()
|
||||
_LOGGER.info('Fetch from feed "%s" completed', self._url)
|
||||
@@ -79,9 +87,11 @@ class FeedManager(object):
|
||||
# let's make use of it to publish only new available
|
||||
# entries since the last run
|
||||
if 'published_parsed' in entry.keys():
|
||||
self._has_published_parsed = True
|
||||
self._last_entry_timestamp = max(entry.published_parsed,
|
||||
self._last_entry_timestamp)
|
||||
else:
|
||||
self._has_published_parsed = False
|
||||
_LOGGER.debug('No `published_parsed` info available '
|
||||
'for entry "%s"', entry.title)
|
||||
entry.update({'feed_url': self._url})
|
||||
@@ -90,6 +100,13 @@ class FeedManager(object):
|
||||
def _publish_new_entries(self):
|
||||
"""Publish new entries to the event bus."""
|
||||
new_entries = False
|
||||
self._last_entry_timestamp = self._storage.get_timestamp(self._url)
|
||||
if self._last_entry_timestamp:
|
||||
self._firstrun = False
|
||||
else:
|
||||
# Set last entry timestamp as epoch time if not available
|
||||
self._last_entry_timestamp = \
|
||||
datetime.utcfromtimestamp(0).timetuple()
|
||||
for entry in self._feed.entries:
|
||||
if self._firstrun or (
|
||||
'published_parsed' in entry.keys() and
|
||||
@@ -103,8 +120,55 @@ class FeedManager(object):
|
||||
self._firstrun = False
|
||||
|
||||
|
||||
class StoredData(object):
|
||||
"""Abstraction over pickle data storage."""
|
||||
|
||||
def __init__(self, data_file):
|
||||
"""Initialize pickle data storage."""
|
||||
self._data_file = data_file
|
||||
self._lock = Lock()
|
||||
self._cache_outdated = True
|
||||
self._data = {}
|
||||
self._fetch_data()
|
||||
|
||||
def _fetch_data(self):
|
||||
"""Fetch data stored into pickle file."""
|
||||
if self._cache_outdated and exists(self._data_file):
|
||||
try:
|
||||
_LOGGER.debug('Fetching data from file %s', self._data_file)
|
||||
with self._lock, open(self._data_file, 'rb') as myfile:
|
||||
self._data = pickle.load(myfile) or {}
|
||||
self._cache_outdated = False
|
||||
# pylint: disable=bare-except
|
||||
except:
|
||||
_LOGGER.error('Error loading data from pickled file %s',
|
||||
self._data_file)
|
||||
|
||||
def get_timestamp(self, url):
|
||||
"""Return stored timestamp for given url."""
|
||||
self._fetch_data()
|
||||
return self._data.get(url)
|
||||
|
||||
def put_timestamp(self, url, timestamp):
|
||||
"""Update timestamp for given url."""
|
||||
self._fetch_data()
|
||||
with self._lock, open(self._data_file, 'wb') as myfile:
|
||||
self._data.update({url: timestamp})
|
||||
_LOGGER.debug('Overwriting feed "%s" timestamp in storage file %s',
|
||||
url, self._data_file)
|
||||
try:
|
||||
pickle.dump(self._data, myfile)
|
||||
# pylint: disable=bare-except
|
||||
except:
|
||||
_LOGGER.error('Error saving pickled data to %s',
|
||||
self._data_file)
|
||||
self._cache_outdated = True
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Setup the feedreader component."""
|
||||
urls = config.get(DOMAIN)['urls']
|
||||
feeds = [FeedManager(url, hass) for url in urls]
|
||||
data_file = hass.config.path("{}.pickle".format(DOMAIN))
|
||||
storage = StoredData(data_file)
|
||||
feeds = [FeedManager(url, hass, storage) for url in urls]
|
||||
return len(feeds) > 0
|
||||
|
||||
@@ -1,121 +1,101 @@
|
||||
"""Handle the frontend for Home Assistant."""
|
||||
import re
|
||||
import os
|
||||
import logging
|
||||
|
||||
from . import version, mdi_version
|
||||
import homeassistant.util as util
|
||||
from homeassistant.const import URL_ROOT, HTTP_OK
|
||||
from homeassistant.components import api
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
DOMAIN = 'frontend'
|
||||
DEPENDENCIES = ['api']
|
||||
|
||||
INDEX_PATH = os.path.join(os.path.dirname(__file__), 'index.html.template')
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
FRONTEND_URLS = [
|
||||
URL_ROOT, '/logbook', '/history', '/map', '/devService', '/devState',
|
||||
'/devEvent', '/devInfo', '/devTemplate',
|
||||
re.compile(r'/states(/([a-zA-Z\._\-0-9/]+)|)'),
|
||||
]
|
||||
|
||||
URL_API_BOOTSTRAP = "/api/bootstrap"
|
||||
|
||||
_FINGERPRINT = re.compile(r'^(\w+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Setup serving the frontend."""
|
||||
for url in FRONTEND_URLS:
|
||||
hass.http.register_path('GET', url, _handle_get_root, False)
|
||||
hass.wsgi.register_view(IndexView)
|
||||
hass.wsgi.register_view(BootstrapView)
|
||||
|
||||
hass.http.register_path('GET', '/service_worker.js',
|
||||
_handle_get_service_worker, False)
|
||||
|
||||
# Bootstrap API
|
||||
hass.http.register_path(
|
||||
'GET', URL_API_BOOTSTRAP, _handle_get_api_bootstrap)
|
||||
|
||||
# Static files
|
||||
hass.http.register_path(
|
||||
'GET', re.compile(r'/static/(?P<file>[a-zA-Z\._\-0-9/]+)'),
|
||||
_handle_get_static, False)
|
||||
hass.http.register_path(
|
||||
'HEAD', re.compile(r'/static/(?P<file>[a-zA-Z\._\-0-9/]+)'),
|
||||
_handle_get_static, False)
|
||||
hass.http.register_path(
|
||||
'GET', re.compile(r'/local/(?P<file>[a-zA-Z\._\-0-9/]+)'),
|
||||
_handle_get_local, False)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_get_api_bootstrap(handler, path_match, data):
|
||||
"""Return all data needed to bootstrap Home Assistant."""
|
||||
hass = handler.server.hass
|
||||
|
||||
handler.write_json({
|
||||
'config': hass.config.as_dict(),
|
||||
'states': hass.states.all(),
|
||||
'events': api.events_json(hass),
|
||||
'services': api.services_json(hass),
|
||||
})
|
||||
|
||||
|
||||
def _handle_get_root(handler, path_match, data):
|
||||
"""Render the frontend."""
|
||||
if handler.server.development:
|
||||
app_url = "home-assistant-polymer/src/home-assistant.html"
|
||||
else:
|
||||
app_url = "frontend-{}.html".format(version.VERSION)
|
||||
|
||||
# auto login if no password was set, else check api_password param
|
||||
auth = ('no_password_set' if handler.server.api_password is None
|
||||
else data.get('api_password', ''))
|
||||
|
||||
with open(INDEX_PATH) as template_file:
|
||||
template_html = template_file.read()
|
||||
|
||||
template_html = template_html.replace('{{ app_url }}', app_url)
|
||||
template_html = template_html.replace('{{ auth }}', auth)
|
||||
template_html = template_html.replace('{{ icons }}', mdi_version.VERSION)
|
||||
|
||||
handler.send_response(HTTP_OK)
|
||||
handler.write_content(template_html.encode("UTF-8"),
|
||||
'text/html; charset=utf-8')
|
||||
|
||||
|
||||
def _handle_get_service_worker(handler, path_match, data):
|
||||
"""Return service worker for the frontend."""
|
||||
if handler.server.development:
|
||||
www_static_path = os.path.join(os.path.dirname(__file__), 'www_static')
|
||||
if hass.wsgi.development:
|
||||
sw_path = "home-assistant-polymer/build/service_worker.js"
|
||||
else:
|
||||
sw_path = "service_worker.js"
|
||||
|
||||
handler.write_file(os.path.join(os.path.dirname(__file__), 'www_static',
|
||||
sw_path))
|
||||
hass.wsgi.register_static_path(
|
||||
"/service_worker.js",
|
||||
os.path.join(www_static_path, sw_path),
|
||||
0
|
||||
)
|
||||
hass.wsgi.register_static_path(
|
||||
"/robots.txt",
|
||||
os.path.join(www_static_path, "robots.txt")
|
||||
)
|
||||
hass.wsgi.register_static_path("/static", www_static_path)
|
||||
hass.wsgi.register_static_path("/local", hass.config.path('www'))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _handle_get_static(handler, path_match, data):
|
||||
"""Return a static file for the frontend."""
|
||||
req_file = util.sanitize_path(path_match.group('file'))
|
||||
class BootstrapView(HomeAssistantView):
|
||||
"""View to bootstrap frontend with all needed data."""
|
||||
|
||||
# Strip md5 hash out
|
||||
fingerprinted = _FINGERPRINT.match(req_file)
|
||||
if fingerprinted:
|
||||
req_file = "{}.{}".format(*fingerprinted.groups())
|
||||
url = "/api/bootstrap"
|
||||
name = "api:bootstrap"
|
||||
|
||||
path = os.path.join(os.path.dirname(__file__), 'www_static', req_file)
|
||||
|
||||
handler.write_file(path)
|
||||
def get(self, request):
|
||||
"""Return all data needed to bootstrap Home Assistant."""
|
||||
return self.json({
|
||||
'config': self.hass.config.as_dict(),
|
||||
'states': self.hass.states.all(),
|
||||
'events': api.events_json(self.hass),
|
||||
'services': api.services_json(self.hass),
|
||||
})
|
||||
|
||||
|
||||
def _handle_get_local(handler, path_match, data):
|
||||
"""Return a static file from the hass.config.path/www for the frontend."""
|
||||
req_file = util.sanitize_path(path_match.group('file'))
|
||||
class IndexView(HomeAssistantView):
|
||||
"""Serve the frontend."""
|
||||
|
||||
path = handler.server.hass.config.path('www', req_file)
|
||||
url = '/'
|
||||
name = "frontend:index"
|
||||
requires_auth = False
|
||||
extra_urls = ['/logbook', '/history', '/map', '/devService', '/devState',
|
||||
'/devEvent', '/devInfo', '/devTemplate',
|
||||
'/states', '/states/<entity:entity_id>']
|
||||
|
||||
handler.write_file(path)
|
||||
def __init__(self, hass):
|
||||
"""Initialize the frontend view."""
|
||||
super().__init__(hass)
|
||||
|
||||
from jinja2 import FileSystemLoader, Environment
|
||||
|
||||
self.templates = Environment(
|
||||
loader=FileSystemLoader(
|
||||
os.path.join(os.path.dirname(__file__), 'templates/')
|
||||
)
|
||||
)
|
||||
|
||||
def get(self, request, entity_id=None):
|
||||
"""Serve the index view."""
|
||||
if self.hass.wsgi.development:
|
||||
core_url = 'home-assistant-polymer/build/_core_compiled.js'
|
||||
ui_url = 'home-assistant-polymer/src/home-assistant.html'
|
||||
else:
|
||||
core_url = 'core-{}.js'.format(version.CORE)
|
||||
ui_url = 'frontend-{}.html'.format(version.UI)
|
||||
|
||||
# auto login if no password was set
|
||||
if self.hass.config.api.api_password is None:
|
||||
auth = 'true'
|
||||
else:
|
||||
auth = 'false'
|
||||
|
||||
icons_url = 'mdi-{}.html'.format(mdi_version.VERSION)
|
||||
|
||||
template = self.templates.get_template('index.html')
|
||||
|
||||
# pylint is wrong
|
||||
# pylint: disable=no-member
|
||||
resp = template.render(
|
||||
core_url=core_url, ui_url=ui_url, auth=auth,
|
||||
icons_url=icons_url, icons=mdi_version.VERSION)
|
||||
|
||||
return self.Response(resp, mimetype='text/html')
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
"""DO NOT MODIFY. Auto-generated by update_mdi script."""
|
||||
VERSION = "1baebe8155deb447230866d7ae854bd9"
|
||||
VERSION = "9ee3d4466a65bef35c2c8974e91b37c0"
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
<link rel='apple-touch-icon' sizes='180x180'
|
||||
href='/static/favicon-apple-180x180.png'>
|
||||
<meta name='apple-mobile-web-app-capable' content='yes'>
|
||||
<meta name="msapplication-square70x70logo" content="/static/tile-win-70x70.png"/>
|
||||
<meta name="msapplication-square150x150logo" content="/static/tile-win-150x150.png"/>
|
||||
<meta name="msapplication-wide310x150logo" content="/static/tile-win-310x150.png"/>
|
||||
<meta name="msapplication-square310x310logo" content="/static/tile-win-310x310.png"/>
|
||||
<meta name="msapplication-TileColor" content="#3fbbf4ff"/>
|
||||
<meta name='mobile-web-app-capable' content='yes'>
|
||||
<meta name='viewport' content='width=device-width, user-scalable=no'>
|
||||
<meta name='theme-color' content='#03a9f4'>
|
||||
@@ -28,7 +33,7 @@
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
margin-bottom: 97px;
|
||||
margin-bottom: 83px;
|
||||
font-family: Roboto, sans-serif;
|
||||
font-size: 0pt;
|
||||
transition: font-size 2s;
|
||||
@@ -36,6 +41,7 @@
|
||||
|
||||
#ha-init-skeleton paper-spinner {
|
||||
height: 28px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
#ha-init-skeleton a {
|
||||
@@ -59,8 +65,8 @@
|
||||
.getElementById('ha-init-skeleton')
|
||||
.classList.add('error');
|
||||
}
|
||||
window.noAuth = {{ auth }}
|
||||
</script>
|
||||
<link rel='import' href='/static/{{ app_url }}' onerror='initError()' async>
|
||||
</head>
|
||||
<body fullbleed>
|
||||
<div id='ha-init-skeleton'>
|
||||
@@ -68,6 +74,10 @@
|
||||
<paper-spinner active></paper-spinner>
|
||||
Home Assistant had trouble<br>connecting to the server.<br><br><a href='/'>TRY AGAIN</a>
|
||||
</div>
|
||||
<home-assistant icons='{{ icons }}'></home-assistant>
|
||||
<script src='/static/{{ core_url }}'></script>
|
||||
<link rel='import' href='/static/{{ ui_url }}' onerror='initError()' async>
|
||||
<link rel='import' href='/static/{{ icons_url }}' async>
|
||||
<script>
|
||||
var webComponentsSupported = (
|
||||
'registerElement' in document &&
|
||||
@@ -81,6 +91,5 @@
|
||||
document.head.appendChild(script)
|
||||
}
|
||||
</script>
|
||||
<home-assistant auth='{{ auth }}' icons='{{ icons }}'></home-assistant>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,2 +1,3 @@
|
||||
"""DO NOT MODIFY. Auto-generated by build_frontend script."""
|
||||
VERSION = "77c51c270b0241ce7ba0d1df2d254d6f"
|
||||
CORE = "d0b415dac66c8056d81380b258af5767"
|
||||
UI = "b0ea2672fff86b1ab86dd86135d4b43a"
|
||||
|
||||
5
homeassistant/components/frontend/www_static/core.js
Normal file
5
homeassistant/components/frontend/www_static/core.js
Normal file
File diff suppressed because one or more lines are too long
BIN
homeassistant/components/frontend/www_static/core.js.gz
Normal file
BIN
homeassistant/components/frontend/www_static/core.js.gz
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
homeassistant/components/frontend/www_static/frontend.html.gz
Normal file
BIN
homeassistant/components/frontend/www_static/frontend.html.gz
Normal file
Binary file not shown.
@@ -8,12 +8,12 @@
|
||||
{
|
||||
"src": "/static/favicon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/static/favicon-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png",
|
||||
"type": "image/png"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
homeassistant/components/frontend/www_static/mdi.html.gz
Normal file
BIN
homeassistant/components/frontend/www_static/mdi.html.gz
Normal file
Binary file not shown.
2
homeassistant/components/frontend/www_static/robots.txt
Normal file
2
homeassistant/components/frontend/www_static/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
User-agent: *
|
||||
Disallow: /
|
||||
@@ -1 +1,258 @@
|
||||
!function(e){function t(r){if(n[r])return n[r].exports;var s=n[r]={i:r,l:!1,exports:{}};return e[r].call(s.exports,s,s.exports,t),s.l=!0,s.exports}var n={};return t.m=e,t.c=n,t.p="",t(t.s=192)}({192:function(e,t,n){var r="0.10",s="/",c=["/","/logbook","/history","/map","/devService","/devState","/devEvent","/devInfo","/states"],i=["/static/favicon-192x192.png"];self.addEventListener("install",function(e){e.waitUntil(caches.open(r).then(function(e){return e.addAll(i.concat(s))}))}),self.addEventListener("activate",function(e){}),self.addEventListener("message",function(e){}),self.addEventListener("fetch",function(e){var t=e.request.url.substr(e.request.url.indexOf("/",8));i.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(e.request)})),c.includes(t)&&e.respondWith(caches.open(r).then(function(t){return t.match(s).then(function(n){return n||fetch(e.request).then(function(e){return t.put(s,e.clone()),e})})}))})}});
|
||||
/**
|
||||
* Copyright 2016 Google Inc. All rights reserved.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// This generated service worker JavaScript will precache your site's resources.
|
||||
// The code needs to be saved in a .js file at the top-level of your site, and registered
|
||||
// from your pages in order to be used. See
|
||||
// https://github.com/googlechrome/sw-precache/blob/master/demo/app/js/service-worker-registration.js
|
||||
// for an example of how you can register this script and handle various service worker events.
|
||||
|
||||
/* eslint-env worker, serviceworker */
|
||||
/* eslint-disable indent, no-unused-vars, no-multiple-empty-lines, max-nested-callbacks, space-before-function-paren */
|
||||
'use strict';
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
/* eslint-disable quotes, comma-spacing */
|
||||
var PrecacheConfig = [["/","69818e2c5b6f4ca764c46ac78d2fea04"],["/devEvent","69818e2c5b6f4ca764c46ac78d2fea04"],["/devInfo","69818e2c5b6f4ca764c46ac78d2fea04"],["/devService","69818e2c5b6f4ca764c46ac78d2fea04"],["/devState","69818e2c5b6f4ca764c46ac78d2fea04"],["/devTemplate","69818e2c5b6f4ca764c46ac78d2fea04"],["/history","69818e2c5b6f4ca764c46ac78d2fea04"],["/logbook","69818e2c5b6f4ca764c46ac78d2fea04"],["/map","69818e2c5b6f4ca764c46ac78d2fea04"],["/states","69818e2c5b6f4ca764c46ac78d2fea04"],["/static/core-d0b415dac66c8056d81380b258af5767.js","dfafa8e9e34f53e8c36dd8b3f7299b2a"],["/static/frontend-b0ea2672fff86b1ab86dd86135d4b43a.html","69818e2c5b6f4ca764c46ac78d2fea04"],["/static/mdi-9ee3d4466a65bef35c2c8974e91b37c0.html","9a6846935116cd29279c91e0ee0a26d0"],["static/favicon-192x192.png","419903b8422586a7e28021bbe9011175"],["static/fonts/roboto/Roboto-Bold.ttf","d329cc8b34667f114a95422aaad1b063"],["static/fonts/roboto/Roboto-Light.ttf","7b5fb88f12bec8143f00e21bc3222124"],["static/fonts/roboto/Roboto-Medium.ttf","fe13e4170719c2fc586501e777bde143"],["static/fonts/roboto/Roboto-Regular.ttf","ac3f799d5bbaf5196fab15ab8de8431c"],["static/images/card_media_player_bg.png","a34281d1c1835d338a642e90930e61aa"],["static/webcomponents-lite.min.js","b0f32ad3c7749c40d486603f31c9d8b1"]];
|
||||
/* eslint-enable quotes, comma-spacing */
|
||||
var CacheNamePrefix = 'sw-precache-v1--' + (self.registration ? self.registration.scope : '') + '-';
|
||||
|
||||
|
||||
var IgnoreUrlParametersMatching = [/^utm_/];
|
||||
|
||||
|
||||
|
||||
var addDirectoryIndex = function (originalUrl, index) {
|
||||
var url = new URL(originalUrl);
|
||||
if (url.pathname.slice(-1) === '/') {
|
||||
url.pathname += index;
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
var getCacheBustedUrl = function (url, param) {
|
||||
param = param || Date.now();
|
||||
|
||||
var urlWithCacheBusting = new URL(url);
|
||||
urlWithCacheBusting.search += (urlWithCacheBusting.search ? '&' : '') +
|
||||
'sw-precache=' + param;
|
||||
|
||||
return urlWithCacheBusting.toString();
|
||||
};
|
||||
|
||||
var isPathWhitelisted = function (whitelist, absoluteUrlString) {
|
||||
// If the whitelist is empty, then consider all URLs to be whitelisted.
|
||||
if (whitelist.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otherwise compare each path regex to the path of the URL passed in.
|
||||
var path = (new URL(absoluteUrlString)).pathname;
|
||||
return whitelist.some(function(whitelistedPathRegex) {
|
||||
return path.match(whitelistedPathRegex);
|
||||
});
|
||||
};
|
||||
|
||||
var populateCurrentCacheNames = function (precacheConfig,
|
||||
cacheNamePrefix, baseUrl) {
|
||||
var absoluteUrlToCacheName = {};
|
||||
var currentCacheNamesToAbsoluteUrl = {};
|
||||
|
||||
precacheConfig.forEach(function(cacheOption) {
|
||||
var absoluteUrl = new URL(cacheOption[0], baseUrl).toString();
|
||||
var cacheName = cacheNamePrefix + absoluteUrl + '-' + cacheOption[1];
|
||||
currentCacheNamesToAbsoluteUrl[cacheName] = absoluteUrl;
|
||||
absoluteUrlToCacheName[absoluteUrl] = cacheName;
|
||||
});
|
||||
|
||||
return {
|
||||
absoluteUrlToCacheName: absoluteUrlToCacheName,
|
||||
currentCacheNamesToAbsoluteUrl: currentCacheNamesToAbsoluteUrl
|
||||
};
|
||||
};
|
||||
|
||||
var stripIgnoredUrlParameters = function (originalUrl,
|
||||
ignoreUrlParametersMatching) {
|
||||
var url = new URL(originalUrl);
|
||||
|
||||
url.search = url.search.slice(1) // Exclude initial '?'
|
||||
.split('&') // Split into an array of 'key=value' strings
|
||||
.map(function(kv) {
|
||||
return kv.split('='); // Split each 'key=value' string into a [key, value] array
|
||||
})
|
||||
.filter(function(kv) {
|
||||
return ignoreUrlParametersMatching.every(function(ignoredRegex) {
|
||||
return !ignoredRegex.test(kv[0]); // Return true iff the key doesn't match any of the regexes.
|
||||
});
|
||||
})
|
||||
.map(function(kv) {
|
||||
return kv.join('='); // Join each [key, value] array into a 'key=value' string
|
||||
})
|
||||
.join('&'); // Join the array of 'key=value' strings into a string with '&' in between each
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
|
||||
var mappings = populateCurrentCacheNames(PrecacheConfig, CacheNamePrefix, self.location);
|
||||
var AbsoluteUrlToCacheName = mappings.absoluteUrlToCacheName;
|
||||
var CurrentCacheNamesToAbsoluteUrl = mappings.currentCacheNamesToAbsoluteUrl;
|
||||
|
||||
function deleteAllCaches() {
|
||||
return caches.keys().then(function(cacheNames) {
|
||||
return Promise.all(
|
||||
cacheNames.map(function(cacheName) {
|
||||
return caches.delete(cacheName);
|
||||
})
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener('install', function(event) {
|
||||
event.waitUntil(
|
||||
// Take a look at each of the cache names we expect for this version.
|
||||
Promise.all(Object.keys(CurrentCacheNamesToAbsoluteUrl).map(function(cacheName) {
|
||||
return caches.open(cacheName).then(function(cache) {
|
||||
// Get a list of all the entries in the specific named cache.
|
||||
// For caches that are already populated for a given version of a
|
||||
// resource, there should be 1 entry.
|
||||
return cache.keys().then(function(keys) {
|
||||
// If there are 0 entries, either because this is a brand new version
|
||||
// of a resource or because the install step was interrupted the
|
||||
// last time it ran, then we need to populate the cache.
|
||||
if (keys.length === 0) {
|
||||
// Use the last bit of the cache name, which contains the hash,
|
||||
// as the cache-busting parameter.
|
||||
// See https://github.com/GoogleChrome/sw-precache/issues/100
|
||||
var cacheBustParam = cacheName.split('-').pop();
|
||||
var urlWithCacheBusting = getCacheBustedUrl(
|
||||
CurrentCacheNamesToAbsoluteUrl[cacheName], cacheBustParam);
|
||||
|
||||
var request = new Request(urlWithCacheBusting,
|
||||
{credentials: 'same-origin'});
|
||||
return fetch(request).then(function(response) {
|
||||
if (response.ok) {
|
||||
return cache.put(CurrentCacheNamesToAbsoluteUrl[cacheName],
|
||||
response);
|
||||
}
|
||||
|
||||
console.error('Request for %s returned a response status %d, ' +
|
||||
'so not attempting to cache it.',
|
||||
urlWithCacheBusting, response.status);
|
||||
// Get rid of the empty cache if we can't add a successful response to it.
|
||||
return caches.delete(cacheName);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
})).then(function() {
|
||||
return caches.keys().then(function(allCacheNames) {
|
||||
return Promise.all(allCacheNames.filter(function(cacheName) {
|
||||
return cacheName.indexOf(CacheNamePrefix) === 0 &&
|
||||
!(cacheName in CurrentCacheNamesToAbsoluteUrl);
|
||||
}).map(function(cacheName) {
|
||||
return caches.delete(cacheName);
|
||||
})
|
||||
);
|
||||
});
|
||||
}).then(function() {
|
||||
if (typeof self.skipWaiting === 'function') {
|
||||
// Force the SW to transition from installing -> active state
|
||||
self.skipWaiting();
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
if (self.clients && (typeof self.clients.claim === 'function')) {
|
||||
self.addEventListener('activate', function(event) {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
}
|
||||
|
||||
self.addEventListener('message', function(event) {
|
||||
if (event.data.command === 'delete_all') {
|
||||
console.log('About to delete all caches...');
|
||||
deleteAllCaches().then(function() {
|
||||
console.log('Caches deleted.');
|
||||
event.ports[0].postMessage({
|
||||
error: null
|
||||
});
|
||||
}).catch(function(error) {
|
||||
console.log('Caches not deleted:', error);
|
||||
event.ports[0].postMessage({
|
||||
error: error
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
self.addEventListener('fetch', function(event) {
|
||||
if (event.request.method === 'GET') {
|
||||
var urlWithoutIgnoredParameters = stripIgnoredUrlParameters(event.request.url,
|
||||
IgnoreUrlParametersMatching);
|
||||
|
||||
var cacheName = AbsoluteUrlToCacheName[urlWithoutIgnoredParameters];
|
||||
var directoryIndex = 'index.html';
|
||||
if (!cacheName && directoryIndex) {
|
||||
urlWithoutIgnoredParameters = addDirectoryIndex(urlWithoutIgnoredParameters, directoryIndex);
|
||||
cacheName = AbsoluteUrlToCacheName[urlWithoutIgnoredParameters];
|
||||
}
|
||||
|
||||
var navigateFallback = '';
|
||||
// Ideally, this would check for event.request.mode === 'navigate', but that is not widely
|
||||
// supported yet:
|
||||
// https://code.google.com/p/chromium/issues/detail?id=540967
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1209081
|
||||
if (!cacheName && navigateFallback && event.request.headers.has('accept') &&
|
||||
event.request.headers.get('accept').includes('text/html') &&
|
||||
/* eslint-disable quotes, comma-spacing */
|
||||
isPathWhitelisted([], event.request.url)) {
|
||||
/* eslint-enable quotes, comma-spacing */
|
||||
var navigateFallbackUrl = new URL(navigateFallback, self.location);
|
||||
cacheName = AbsoluteUrlToCacheName[navigateFallbackUrl.toString()];
|
||||
}
|
||||
|
||||
if (cacheName) {
|
||||
event.respondWith(
|
||||
// Rely on the fact that each cache we manage should only have one entry, and return that.
|
||||
caches.open(cacheName).then(function(cache) {
|
||||
return cache.keys().then(function(keys) {
|
||||
return cache.match(keys[0]).then(function(response) {
|
||||
if (response) {
|
||||
return response;
|
||||
}
|
||||
// If for some reason the response was deleted from the cache,
|
||||
// raise and exception and fall back to the fetch() triggered in the catch().
|
||||
throw Error('The cache ' + cacheName + ' is empty.');
|
||||
});
|
||||
});
|
||||
}).catch(function(e) {
|
||||
console.warn('Couldn\'t serve response for "%s" from cache: %O', event.request.url, e);
|
||||
return fetch(event.request);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 9.2 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
BIN
homeassistant/components/frontend/www_static/tile-win-70x70.png
Normal file
BIN
homeassistant/components/frontend/www_static/tile-win-70x70.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
@@ -11,7 +11,7 @@ from itertools import groupby
|
||||
|
||||
from homeassistant.components import recorder, script
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.const import HTTP_BAD_REQUEST
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
DOMAIN = 'history'
|
||||
DEPENDENCIES = ['recorder', 'http']
|
||||
@@ -155,49 +155,44 @@ def get_state(utc_point_in_time, entity_id, run=None):
|
||||
# pylint: disable=unused-argument
|
||||
def setup(hass, config):
|
||||
"""Setup the history hooks."""
|
||||
hass.http.register_path(
|
||||
'GET',
|
||||
re.compile(
|
||||
r'/api/history/entity/(?P<entity_id>[a-zA-Z\._0-9]+)/'
|
||||
r'recent_states'),
|
||||
_api_last_5_states)
|
||||
|
||||
hass.http.register_path('GET', URL_HISTORY_PERIOD, _api_history_period)
|
||||
hass.wsgi.register_view(Last5StatesView)
|
||||
hass.wsgi.register_view(HistoryPeriodView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
# pylint: disable=invalid-name
|
||||
def _api_last_5_states(handler, path_match, data):
|
||||
"""Return the last 5 states for an entity id as JSON."""
|
||||
entity_id = path_match.group('entity_id')
|
||||
class Last5StatesView(HomeAssistantView):
|
||||
"""Handle last 5 state view requests."""
|
||||
|
||||
handler.write_json(last_5_states(entity_id))
|
||||
url = '/api/history/entity/<entity:entity_id>/recent_states'
|
||||
name = 'api:history:entity-recent-states'
|
||||
|
||||
def get(self, request, entity_id):
|
||||
"""Retrieve last 5 states of entity."""
|
||||
return self.json(last_5_states(entity_id))
|
||||
|
||||
|
||||
def _api_history_period(handler, path_match, data):
|
||||
"""Return history over a period of time."""
|
||||
date_str = path_match.group('date')
|
||||
one_day = timedelta(seconds=86400)
|
||||
class HistoryPeriodView(HomeAssistantView):
|
||||
"""Handle history period requests."""
|
||||
|
||||
if date_str:
|
||||
start_date = dt_util.parse_date(date_str)
|
||||
url = '/api/history/period'
|
||||
name = 'api:history:view-period'
|
||||
extra_urls = ['/api/history/period/<date:date>']
|
||||
|
||||
if start_date is None:
|
||||
handler.write_json_message("Error parsing JSON", HTTP_BAD_REQUEST)
|
||||
return
|
||||
def get(self, request, date=None):
|
||||
"""Return history over a period of time."""
|
||||
one_day = timedelta(days=1)
|
||||
|
||||
start_time = dt_util.as_utc(dt_util.start_of_local_day(start_date))
|
||||
else:
|
||||
start_time = dt_util.utcnow() - one_day
|
||||
if date:
|
||||
start_time = dt_util.as_utc(dt_util.start_of_local_day(date))
|
||||
else:
|
||||
start_time = dt_util.utcnow() - one_day
|
||||
|
||||
end_time = start_time + one_day
|
||||
end_time = start_time + one_day
|
||||
entity_id = request.args.get('filter_entity_id')
|
||||
|
||||
entity_id = data.get('filter_entity_id')
|
||||
|
||||
handler.write_json(
|
||||
get_significant_states(start_time, end_time, entity_id).values())
|
||||
return self.json(
|
||||
get_significant_states(start_time, end_time, entity_id).values())
|
||||
|
||||
|
||||
def _is_significant(state):
|
||||
|
||||
@@ -1,41 +1,25 @@
|
||||
"""
|
||||
This module provides an API and a HTTP interface for debug purposes.
|
||||
|
||||
For more details about the RESTful API, please refer to the documentation at
|
||||
https://home-assistant.io/developers/api/
|
||||
"""
|
||||
import gzip
|
||||
"""This module provides WSGI application to serve the Home Assistant API."""
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import ssl
|
||||
import mimetypes
|
||||
import threading
|
||||
import time
|
||||
from datetime import timedelta
|
||||
from http import cookies
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
from socketserver import ThreadingMixIn
|
||||
from urllib.parse import parse_qs, urlparse
|
||||
import re
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.bootstrap as bootstrap
|
||||
import homeassistant.core as ha
|
||||
import homeassistant.remote as rem
|
||||
import homeassistant.util as util
|
||||
import homeassistant.util.dt as date_util
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant import util
|
||||
from homeassistant.const import (
|
||||
CONTENT_TYPE_JSON, CONTENT_TYPE_TEXT_PLAIN, HTTP_HEADER_ACCEPT_ENCODING,
|
||||
HTTP_HEADER_CACHE_CONTROL, HTTP_HEADER_CONTENT_ENCODING,
|
||||
HTTP_HEADER_CONTENT_LENGTH, HTTP_HEADER_CONTENT_TYPE, HTTP_HEADER_EXPIRES,
|
||||
HTTP_HEADER_HA_AUTH, HTTP_HEADER_VARY,
|
||||
SERVER_PORT, HTTP_HEADER_HA_AUTH, HTTP_HEADER_CACHE_CONTROL,
|
||||
HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS, HTTP_METHOD_NOT_ALLOWED,
|
||||
HTTP_NOT_FOUND, HTTP_OK, HTTP_UNAUTHORIZED, HTTP_UNPROCESSABLE_ENTITY,
|
||||
ALLOWED_CORS_HEADERS,
|
||||
SERVER_PORT, URL_ROOT, URL_API_EVENT_FORWARD)
|
||||
HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS, ALLOWED_CORS_HEADERS)
|
||||
from homeassistant.helpers.entity import split_entity_id
|
||||
import homeassistant.util.dt as dt_util
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
DOMAIN = "http"
|
||||
REQUIREMENTS = ("eventlet==0.19.0", "static3==0.7.0", "Werkzeug==0.11.5",)
|
||||
|
||||
CONF_API_PASSWORD = "api_password"
|
||||
CONF_SERVER_HOST = "server_host"
|
||||
@@ -47,10 +31,7 @@ CONF_CORS_ORIGINS = 'cors_allowed_origins'
|
||||
|
||||
DATA_API_PASSWORD = 'api_password'
|
||||
|
||||
# Throttling time in seconds for expired sessions check
|
||||
SESSION_CLEAR_INTERVAL = timedelta(seconds=20)
|
||||
SESSION_TIMEOUT_SECONDS = 1800
|
||||
SESSION_KEY = 'sessionId'
|
||||
_FINGERPRINT = re.compile(r'^(.+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -68,13 +49,32 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
class HideSensitiveFilter(logging.Filter):
|
||||
"""Filter API password calls."""
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
def __init__(self, hass):
|
||||
"""Initialize sensitive data filter."""
|
||||
super().__init__()
|
||||
self.hass = hass
|
||||
|
||||
def filter(self, record):
|
||||
"""Hide sensitive data in messages."""
|
||||
if self.hass.wsgi.api_password is None:
|
||||
return True
|
||||
|
||||
record.msg = record.msg.replace(self.hass.wsgi.api_password, '*******')
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Set up the HTTP API and debug interface."""
|
||||
_LOGGER.addFilter(HideSensitiveFilter(hass))
|
||||
|
||||
conf = config.get(DOMAIN, {})
|
||||
|
||||
api_password = util.convert(conf.get(CONF_API_PASSWORD), str)
|
||||
|
||||
# If no server host is given, accept all incoming requests
|
||||
server_host = conf.get(CONF_SERVER_HOST, '0.0.0.0')
|
||||
server_port = conf.get(CONF_SERVER_PORT, SERVER_PORT)
|
||||
development = str(conf.get(CONF_DEVELOPMENT, "")) == "1"
|
||||
@@ -82,22 +82,24 @@ def setup(hass, config):
|
||||
ssl_key = conf.get(CONF_SSL_KEY)
|
||||
cors_origins = conf.get(CONF_CORS_ORIGINS, [])
|
||||
|
||||
try:
|
||||
server = HomeAssistantHTTPServer(
|
||||
(server_host, server_port), RequestHandler, hass, api_password,
|
||||
development, ssl_certificate, ssl_key, cors_origins)
|
||||
except OSError:
|
||||
# If address already in use
|
||||
_LOGGER.exception("Error setting up HTTP server")
|
||||
return False
|
||||
server = HomeAssistantWSGI(
|
||||
hass,
|
||||
development=development,
|
||||
server_host=server_host,
|
||||
server_port=server_port,
|
||||
api_password=api_password,
|
||||
ssl_certificate=ssl_certificate,
|
||||
ssl_key=ssl_key,
|
||||
cors_origins=cors_origins
|
||||
)
|
||||
|
||||
hass.bus.listen_once(
|
||||
ha.EVENT_HOMEASSISTANT_START,
|
||||
lambda event:
|
||||
threading.Thread(target=server.start, daemon=True,
|
||||
name='HTTP-server').start())
|
||||
name='WSGI-server').start())
|
||||
|
||||
hass.http = server
|
||||
hass.wsgi = server
|
||||
hass.config.api = rem.API(server_host if server_host != '0.0.0.0'
|
||||
else util.get_local_ip(),
|
||||
api_password, server_port,
|
||||
@@ -106,413 +108,338 @@ def setup(hass, config):
|
||||
return True
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class HomeAssistantHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
"""Handle HTTP requests in a threaded fashion."""
|
||||
def request_class():
|
||||
"""Generate request class.
|
||||
|
||||
# pylint: disable=too-few-public-methods
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
Done in method because of imports.
|
||||
"""
|
||||
from werkzeug.exceptions import BadRequest
|
||||
from werkzeug.wrappers import BaseRequest, AcceptMixin
|
||||
from werkzeug.utils import cached_property
|
||||
|
||||
class Request(BaseRequest, AcceptMixin):
|
||||
"""Base class for incoming requests."""
|
||||
|
||||
@cached_property
|
||||
def json(self):
|
||||
"""Get the result of json.loads if possible."""
|
||||
if not self.data:
|
||||
return None
|
||||
# elif 'json' not in self.environ.get('CONTENT_TYPE', ''):
|
||||
# raise BadRequest('Not a JSON request')
|
||||
try:
|
||||
return json.loads(self.data.decode(
|
||||
self.charset, self.encoding_errors))
|
||||
except (TypeError, ValueError):
|
||||
raise BadRequest('Unable to read JSON request')
|
||||
|
||||
return Request
|
||||
|
||||
|
||||
def routing_map(hass):
|
||||
"""Generate empty routing map with HA validators."""
|
||||
from werkzeug.routing import Map, BaseConverter, ValidationError
|
||||
|
||||
class EntityValidator(BaseConverter):
|
||||
"""Validate entity_id in urls."""
|
||||
|
||||
regex = r"(\w+)\.(\w+)"
|
||||
|
||||
def __init__(self, url_map, exist=True, domain=None):
|
||||
"""Initilalize entity validator."""
|
||||
super().__init__(url_map)
|
||||
self._exist = exist
|
||||
self._domain = domain
|
||||
|
||||
def to_python(self, value):
|
||||
"""Validate entity id."""
|
||||
if self._exist and hass.states.get(value) is None:
|
||||
raise ValidationError()
|
||||
if self._domain is not None and \
|
||||
split_entity_id(value)[0] != self._domain:
|
||||
raise ValidationError()
|
||||
|
||||
return value
|
||||
|
||||
def to_url(self, value):
|
||||
"""Convert entity_id for a url."""
|
||||
return value
|
||||
|
||||
class DateValidator(BaseConverter):
|
||||
"""Validate dates in urls."""
|
||||
|
||||
regex = r'\d{4}-\d{1,2}-\d{1,2}'
|
||||
|
||||
def to_python(self, value):
|
||||
"""Validate and convert date."""
|
||||
parsed = dt_util.parse_date(value)
|
||||
|
||||
if parsed is None:
|
||||
raise ValidationError()
|
||||
|
||||
return parsed
|
||||
|
||||
def to_url(self, value):
|
||||
"""Convert date to url value."""
|
||||
return value.isoformat()
|
||||
|
||||
return Map(converters={
|
||||
'entity': EntityValidator,
|
||||
'date': DateValidator,
|
||||
})
|
||||
|
||||
|
||||
class HomeAssistantWSGI(object):
|
||||
"""WSGI server for Home Assistant."""
|
||||
|
||||
# pylint: disable=too-many-instance-attributes, too-many-locals
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(self, server_address, request_handler_class,
|
||||
hass, api_password, development, ssl_certificate, ssl_key,
|
||||
cors_origins):
|
||||
"""Initialize the server."""
|
||||
super().__init__(server_address, request_handler_class)
|
||||
|
||||
self.server_address = server_address
|
||||
def __init__(self, hass, development, api_password, ssl_certificate,
|
||||
ssl_key, server_host, server_port, cors_origins):
|
||||
"""Initilalize the WSGI Home Assistant server."""
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
Response.mimetype = 'text/html'
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
self.Request = request_class()
|
||||
self.url_map = routing_map(hass)
|
||||
self.views = {}
|
||||
self.hass = hass
|
||||
self.api_password = api_password
|
||||
self.extra_apps = {}
|
||||
self.development = development
|
||||
self.paths = []
|
||||
self.sessions = SessionStore()
|
||||
self.use_ssl = ssl_certificate is not None
|
||||
self.api_password = api_password
|
||||
self.ssl_certificate = ssl_certificate
|
||||
self.ssl_key = ssl_key
|
||||
self.server_host = server_host
|
||||
self.server_port = server_port
|
||||
self.cors_origins = cors_origins
|
||||
|
||||
# We will lazy init this one if needed
|
||||
self.event_forwarder = None
|
||||
|
||||
if development:
|
||||
_LOGGER.info("running http in development mode")
|
||||
def register_view(self, view):
|
||||
"""Register a view with the WSGI server.
|
||||
|
||||
if ssl_certificate is not None:
|
||||
context = ssl.create_default_context(
|
||||
purpose=ssl.Purpose.CLIENT_AUTH)
|
||||
context.load_cert_chain(ssl_certificate, keyfile=ssl_key)
|
||||
self.socket = context.wrap_socket(self.socket, server_side=True)
|
||||
The view argument must be a class that inherits from HomeAssistantView.
|
||||
It is optional to instantiate it before registering; this method will
|
||||
handle it either way.
|
||||
"""
|
||||
from werkzeug.routing import Rule
|
||||
|
||||
if view.name in self.views:
|
||||
_LOGGER.warning("View '%s' is being overwritten", view.name)
|
||||
if isinstance(view, type):
|
||||
# Instantiate the view, if needed
|
||||
view = view(self.hass)
|
||||
|
||||
self.views[view.name] = view
|
||||
|
||||
rule = Rule(view.url, endpoint=view.name)
|
||||
self.url_map.add(rule)
|
||||
for url in view.extra_urls:
|
||||
rule = Rule(url, endpoint=view.name)
|
||||
self.url_map.add(rule)
|
||||
|
||||
def register_redirect(self, url, redirect_to):
|
||||
"""Register a redirect with the server.
|
||||
|
||||
If given this must be either a string or callable. In case of a
|
||||
callable it's called with the url adapter that triggered the match and
|
||||
the values of the URL as keyword arguments and has to return the target
|
||||
for the redirect, otherwise it has to be a string with placeholders in
|
||||
rule syntax.
|
||||
"""
|
||||
from werkzeug.routing import Rule
|
||||
|
||||
self.url_map.add(Rule(url, redirect_to=redirect_to))
|
||||
|
||||
def register_static_path(self, url_root, path, cache_length=31):
|
||||
"""Register a folder to serve as a static path.
|
||||
|
||||
Specify optional cache length of asset in days.
|
||||
"""
|
||||
from static import Cling
|
||||
|
||||
headers = []
|
||||
|
||||
if cache_length and not self.development:
|
||||
# 1 year in seconds
|
||||
cache_time = cache_length * 86400
|
||||
|
||||
headers.append({
|
||||
'prefix': '',
|
||||
HTTP_HEADER_CACHE_CONTROL:
|
||||
"public, max-age={}".format(cache_time)
|
||||
})
|
||||
|
||||
self.register_wsgi_app(url_root, Cling(path, headers=headers))
|
||||
|
||||
def register_wsgi_app(self, url_root, app):
|
||||
"""Register a path to serve a WSGI app."""
|
||||
if url_root in self.extra_apps:
|
||||
_LOGGER.warning("Url root '%s' is being overwritten", url_root)
|
||||
|
||||
self.extra_apps[url_root] = app
|
||||
|
||||
def start(self):
|
||||
"""Start the HTTP server."""
|
||||
def stop_http(event):
|
||||
"""Stop the HTTP server."""
|
||||
self.shutdown()
|
||||
"""Start the wsgi server."""
|
||||
from eventlet import wsgi
|
||||
import eventlet
|
||||
|
||||
self.hass.bus.listen_once(ha.EVENT_HOMEASSISTANT_STOP, stop_http)
|
||||
sock = eventlet.listen((self.server_host, self.server_port))
|
||||
if self.ssl_certificate:
|
||||
sock = eventlet.wrap_ssl(sock, certfile=self.ssl_certificate,
|
||||
keyfile=self.ssl_key, server_side=True)
|
||||
wsgi.server(sock, self, log=_LOGGER)
|
||||
|
||||
protocol = 'https' if self.use_ssl else 'http'
|
||||
def dispatch_request(self, request):
|
||||
"""Handle incoming request."""
|
||||
from werkzeug.exceptions import (
|
||||
MethodNotAllowed, NotFound, BadRequest, Unauthorized,
|
||||
)
|
||||
from werkzeug.routing import RequestRedirect
|
||||
|
||||
_LOGGER.info(
|
||||
"Starting web interface at %s://%s:%d",
|
||||
protocol, self.server_address[0], self.server_address[1])
|
||||
with request:
|
||||
adapter = self.url_map.bind_to_environ(request.environ)
|
||||
try:
|
||||
endpoint, values = adapter.match()
|
||||
return self.views[endpoint].handle_request(request, **values)
|
||||
except RequestRedirect as ex:
|
||||
return ex
|
||||
except (BadRequest, NotFound, MethodNotAllowed,
|
||||
Unauthorized) as ex:
|
||||
resp = ex.get_response(request.environ)
|
||||
if request.accept_mimetypes.accept_json:
|
||||
resp.data = json.dumps({
|
||||
"result": "error",
|
||||
"message": str(ex),
|
||||
})
|
||||
resp.mimetype = "application/json"
|
||||
return resp
|
||||
|
||||
# 31-1-2015: Refactored frontend/api components out of this component
|
||||
# To prevent stuff from breaking, load the two extracted components
|
||||
bootstrap.setup_component(self.hass, 'api')
|
||||
bootstrap.setup_component(self.hass, 'frontend')
|
||||
def base_app(self, environ, start_response):
|
||||
"""WSGI Handler of requests to base app."""
|
||||
request = self.Request(environ)
|
||||
response = self.dispatch_request(request)
|
||||
|
||||
self.serve_forever()
|
||||
if self.cors_origins:
|
||||
cors_check = (environ.get("HTTP_ORIGIN") in self.cors_origins)
|
||||
cors_headers = ", ".join(ALLOWED_CORS_HEADERS)
|
||||
if cors_check:
|
||||
response.headers[HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN] = \
|
||||
environ.get("HTTP_ORIGIN")
|
||||
response.headers[HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS] = \
|
||||
cors_headers
|
||||
|
||||
def register_path(self, method, url, callback, require_auth=True):
|
||||
"""Register a path with the server."""
|
||||
self.paths.append((method, url, callback, require_auth))
|
||||
return response(environ, start_response)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
"""Redirect built-in log to HA logging."""
|
||||
# pylint: disable=no-self-use
|
||||
_LOGGER.info(fmt, *args)
|
||||
def __call__(self, environ, start_response):
|
||||
"""Handle a request for base app + extra apps."""
|
||||
from werkzeug.wsgi import DispatcherMiddleware
|
||||
|
||||
app = DispatcherMiddleware(self.base_app, self.extra_apps)
|
||||
# Strip out any cachebusting MD5 fingerprints
|
||||
fingerprinted = _FINGERPRINT.match(environ.get('PATH_INFO', ''))
|
||||
if fingerprinted:
|
||||
environ['PATH_INFO'] = "{}.{}".format(*fingerprinted.groups())
|
||||
return app(environ, start_response)
|
||||
|
||||
|
||||
# pylint: disable=too-many-public-methods,too-many-locals
|
||||
class RequestHandler(SimpleHTTPRequestHandler):
|
||||
"""Handle incoming HTTP requests.
|
||||
class HomeAssistantView(object):
|
||||
"""Base view for all views."""
|
||||
|
||||
We extend from SimpleHTTPRequestHandler instead of Base so we
|
||||
can use the guess content type methods.
|
||||
"""
|
||||
extra_urls = []
|
||||
requires_auth = True # Views inheriting from this class can override this
|
||||
|
||||
server_version = "HomeAssistant/1.0"
|
||||
def __init__(self, hass):
|
||||
"""Initilalize the base view."""
|
||||
from werkzeug.wrappers import Response
|
||||
|
||||
def __init__(self, req, client_addr, server):
|
||||
"""Constructor, call the base constructor and set up session."""
|
||||
# Track if this was an authenticated request
|
||||
self.authenticated = False
|
||||
SimpleHTTPRequestHandler.__init__(self, req, client_addr, server)
|
||||
self.protocol_version = 'HTTP/1.1'
|
||||
if not hasattr(self, 'url'):
|
||||
class_name = self.__class__.__name__
|
||||
raise AttributeError(
|
||||
'{0} missing required attribute "url"'.format(class_name)
|
||||
)
|
||||
|
||||
def log_message(self, fmt, *arguments):
|
||||
"""Redirect built-in log to HA logging."""
|
||||
if self.server.api_password is None:
|
||||
_LOGGER.info(fmt, *arguments)
|
||||
else:
|
||||
_LOGGER.info(
|
||||
fmt, *(arg.replace(self.server.api_password, '*******')
|
||||
if isinstance(arg, str) else arg for arg in arguments))
|
||||
if not hasattr(self, 'name'):
|
||||
class_name = self.__class__.__name__
|
||||
raise AttributeError(
|
||||
'{0} missing required attribute "name"'.format(class_name)
|
||||
)
|
||||
|
||||
def _handle_request(self, method): # pylint: disable=too-many-branches
|
||||
"""Perform some common checks and call appropriate method."""
|
||||
url = urlparse(self.path)
|
||||
self.hass = hass
|
||||
# pylint: disable=invalid-name
|
||||
self.Response = Response
|
||||
|
||||
# Read query input. parse_qs gives a list for each value, we want last
|
||||
data = {key: data[-1] for key, data in parse_qs(url.query).items()}
|
||||
def handle_request(self, request, **values):
|
||||
"""Handle request to url."""
|
||||
from werkzeug.exceptions import MethodNotAllowed, Unauthorized
|
||||
|
||||
# Did we get post input ?
|
||||
content_length = int(self.headers.get(HTTP_HEADER_CONTENT_LENGTH, 0))
|
||||
try:
|
||||
handler = getattr(self, request.method.lower())
|
||||
except AttributeError:
|
||||
raise MethodNotAllowed
|
||||
|
||||
if content_length:
|
||||
body_content = self.rfile.read(content_length).decode("UTF-8")
|
||||
# Auth code verbose on purpose
|
||||
authenticated = False
|
||||
|
||||
if self.hass.wsgi.api_password is None:
|
||||
authenticated = True
|
||||
|
||||
elif hmac.compare_digest(request.headers.get(HTTP_HEADER_HA_AUTH, ''),
|
||||
self.hass.wsgi.api_password):
|
||||
# A valid auth header has been set
|
||||
authenticated = True
|
||||
|
||||
elif hmac.compare_digest(request.args.get(DATA_API_PASSWORD, ''),
|
||||
self.hass.wsgi.api_password):
|
||||
authenticated = True
|
||||
|
||||
if self.requires_auth and not authenticated:
|
||||
raise Unauthorized()
|
||||
|
||||
request.authenticated = authenticated
|
||||
|
||||
result = handler(request, **values)
|
||||
|
||||
if isinstance(result, self.Response):
|
||||
# The method handler returned a ready-made Response, how nice of it
|
||||
return result
|
||||
|
||||
status_code = 200
|
||||
|
||||
if isinstance(result, tuple):
|
||||
result, status_code = result
|
||||
|
||||
return self.Response(result, status=status_code)
|
||||
|
||||
def json(self, result, status_code=200):
|
||||
"""Return a JSON response."""
|
||||
msg = json.dumps(
|
||||
result,
|
||||
sort_keys=True,
|
||||
cls=rem.JSONEncoder
|
||||
).encode('UTF-8')
|
||||
return self.Response(msg, mimetype="application/json",
|
||||
status=status_code)
|
||||
|
||||
def json_message(self, error, status_code=200):
|
||||
"""Return a JSON message response."""
|
||||
return self.json({'message': error}, status_code)
|
||||
|
||||
def file(self, request, fil, mimetype=None):
|
||||
"""Return a file."""
|
||||
from werkzeug.wsgi import wrap_file
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
if isinstance(fil, str):
|
||||
if mimetype is None:
|
||||
mimetype = mimetypes.guess_type(fil)[0]
|
||||
|
||||
try:
|
||||
data.update(json.loads(body_content))
|
||||
except (TypeError, ValueError):
|
||||
# TypeError if JSON object is not a dict
|
||||
# ValueError if we could not parse JSON
|
||||
_LOGGER.exception(
|
||||
"Exception parsing JSON: %s", body_content)
|
||||
self.write_json_message(
|
||||
"Error parsing JSON", HTTP_UNPROCESSABLE_ENTITY)
|
||||
return
|
||||
fil = open(fil)
|
||||
except IOError:
|
||||
raise NotFound()
|
||||
|
||||
if self.verify_session():
|
||||
# The user has a valid session already
|
||||
self.authenticated = True
|
||||
elif self.server.api_password is None:
|
||||
# No password is set, so everyone is authenticated
|
||||
self.authenticated = True
|
||||
elif hmac.compare_digest(self.headers.get(HTTP_HEADER_HA_AUTH, ''),
|
||||
self.server.api_password):
|
||||
# A valid auth header has been set
|
||||
self.authenticated = True
|
||||
elif hmac.compare_digest(data.get(DATA_API_PASSWORD, ''),
|
||||
self.server.api_password):
|
||||
# A valid password has been specified
|
||||
self.authenticated = True
|
||||
else:
|
||||
self.authenticated = False
|
||||
|
||||
# we really shouldn't need to forward the password from here
|
||||
if url.path not in [URL_ROOT, URL_API_EVENT_FORWARD]:
|
||||
data.pop(DATA_API_PASSWORD, None)
|
||||
|
||||
if '_METHOD' in data:
|
||||
method = data.pop('_METHOD')
|
||||
|
||||
# Var to keep track if we found a path that matched a handler but
|
||||
# the method was different
|
||||
path_matched_but_not_method = False
|
||||
|
||||
# Var to hold the handler for this path and method if found
|
||||
handle_request_method = False
|
||||
require_auth = True
|
||||
|
||||
# Check every handler to find matching result
|
||||
for t_method, t_path, t_handler, t_auth in self.server.paths:
|
||||
# we either do string-comparison or regular expression matching
|
||||
# pylint: disable=maybe-no-member
|
||||
if isinstance(t_path, str):
|
||||
path_match = url.path == t_path
|
||||
else:
|
||||
path_match = t_path.match(url.path)
|
||||
|
||||
if path_match and method == t_method:
|
||||
# Call the method
|
||||
handle_request_method = t_handler
|
||||
require_auth = t_auth
|
||||
break
|
||||
|
||||
elif path_match:
|
||||
path_matched_but_not_method = True
|
||||
|
||||
# Did we find a handler for the incoming request?
|
||||
if handle_request_method:
|
||||
# For some calls we need a valid password
|
||||
msg = "API password missing or incorrect."
|
||||
if require_auth and not self.authenticated:
|
||||
self.write_json_message(msg, HTTP_UNAUTHORIZED)
|
||||
_LOGGER.warning('%s Source IP: %s',
|
||||
msg,
|
||||
self.client_address[0])
|
||||
return
|
||||
|
||||
handle_request_method(self, path_match, data)
|
||||
|
||||
elif path_matched_but_not_method:
|
||||
self.send_response(HTTP_METHOD_NOT_ALLOWED)
|
||||
self.end_headers()
|
||||
|
||||
else:
|
||||
self.send_response(HTTP_NOT_FOUND)
|
||||
self.end_headers()
|
||||
|
||||
def do_HEAD(self): # pylint: disable=invalid-name
|
||||
"""HEAD request handler."""
|
||||
self._handle_request('HEAD')
|
||||
|
||||
def do_GET(self): # pylint: disable=invalid-name
|
||||
"""GET request handler."""
|
||||
self._handle_request('GET')
|
||||
|
||||
def do_POST(self): # pylint: disable=invalid-name
|
||||
"""POST request handler."""
|
||||
self._handle_request('POST')
|
||||
|
||||
def do_PUT(self): # pylint: disable=invalid-name
|
||||
"""PUT request handler."""
|
||||
self._handle_request('PUT')
|
||||
|
||||
def do_DELETE(self): # pylint: disable=invalid-name
|
||||
"""DELETE request handler."""
|
||||
self._handle_request('DELETE')
|
||||
|
||||
def write_json_message(self, message, status_code=HTTP_OK):
|
||||
"""Helper method to return a message to the caller."""
|
||||
self.write_json({'message': message}, status_code=status_code)
|
||||
|
||||
def write_json(self, data=None, status_code=HTTP_OK, location=None):
|
||||
"""Helper method to return JSON to the caller."""
|
||||
json_data = json.dumps(data, indent=4, sort_keys=True,
|
||||
cls=rem.JSONEncoder).encode('UTF-8')
|
||||
self.send_response(status_code)
|
||||
|
||||
if location:
|
||||
self.send_header('Location', location)
|
||||
|
||||
self.set_session_cookie_header()
|
||||
|
||||
self.write_content(json_data, CONTENT_TYPE_JSON)
|
||||
|
||||
def write_text(self, message, status_code=HTTP_OK):
|
||||
"""Helper method to return a text message to the caller."""
|
||||
msg_data = message.encode('UTF-8')
|
||||
self.send_response(status_code)
|
||||
self.set_session_cookie_header()
|
||||
|
||||
self.write_content(msg_data, CONTENT_TYPE_TEXT_PLAIN)
|
||||
|
||||
def write_file(self, path, cache_headers=True):
|
||||
"""Return a file to the user."""
|
||||
try:
|
||||
with open(path, 'rb') as inp:
|
||||
self.write_file_pointer(self.guess_type(path), inp,
|
||||
cache_headers)
|
||||
|
||||
except IOError:
|
||||
self.send_response(HTTP_NOT_FOUND)
|
||||
self.end_headers()
|
||||
_LOGGER.exception("Unable to serve %s", path)
|
||||
|
||||
def write_file_pointer(self, content_type, inp, cache_headers=True):
|
||||
"""Helper function to write a file pointer to the user."""
|
||||
self.send_response(HTTP_OK)
|
||||
|
||||
if cache_headers:
|
||||
self.set_cache_header()
|
||||
self.set_session_cookie_header()
|
||||
|
||||
self.write_content(inp.read(), content_type)
|
||||
|
||||
def write_content(self, content, content_type=None):
|
||||
"""Helper method to write content bytes to output stream."""
|
||||
if content_type is not None:
|
||||
self.send_header(HTTP_HEADER_CONTENT_TYPE, content_type)
|
||||
|
||||
if 'gzip' in self.headers.get(HTTP_HEADER_ACCEPT_ENCODING, ''):
|
||||
content = gzip.compress(content)
|
||||
|
||||
self.send_header(HTTP_HEADER_CONTENT_ENCODING, "gzip")
|
||||
self.send_header(HTTP_HEADER_VARY, HTTP_HEADER_ACCEPT_ENCODING)
|
||||
|
||||
self.send_header(HTTP_HEADER_CONTENT_LENGTH, str(len(content)))
|
||||
|
||||
cors_check = (self.headers.get("Origin") in self.server.cors_origins)
|
||||
|
||||
cors_headers = ", ".join(ALLOWED_CORS_HEADERS)
|
||||
|
||||
if self.server.cors_origins and cors_check:
|
||||
self.send_header(HTTP_HEADER_ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
self.headers.get("Origin"))
|
||||
self.send_header(HTTP_HEADER_ACCESS_CONTROL_ALLOW_HEADERS,
|
||||
cors_headers)
|
||||
self.end_headers()
|
||||
|
||||
if self.command == 'HEAD':
|
||||
return
|
||||
|
||||
self.wfile.write(content)
|
||||
|
||||
def set_cache_header(self):
|
||||
"""Add cache headers if not in development."""
|
||||
if self.server.development:
|
||||
return
|
||||
|
||||
# 1 year in seconds
|
||||
cache_time = 365 * 86400
|
||||
|
||||
self.send_header(
|
||||
HTTP_HEADER_CACHE_CONTROL,
|
||||
"public, max-age={}".format(cache_time))
|
||||
self.send_header(
|
||||
HTTP_HEADER_EXPIRES,
|
||||
self.date_time_string(time.time()+cache_time))
|
||||
|
||||
def set_session_cookie_header(self):
|
||||
"""Add the header for the session cookie and return session ID."""
|
||||
if not self.authenticated:
|
||||
return None
|
||||
|
||||
session_id = self.get_cookie_session_id()
|
||||
|
||||
if session_id is not None:
|
||||
self.server.sessions.extend_validation(session_id)
|
||||
return session_id
|
||||
|
||||
self.send_header(
|
||||
'Set-Cookie',
|
||||
'{}={}'.format(SESSION_KEY, self.server.sessions.create())
|
||||
)
|
||||
|
||||
return session_id
|
||||
|
||||
def verify_session(self):
|
||||
"""Verify that we are in a valid session."""
|
||||
return self.get_cookie_session_id() is not None
|
||||
|
||||
def get_cookie_session_id(self):
|
||||
"""Extract the current session ID from the cookie.
|
||||
|
||||
Return None if not set or invalid.
|
||||
"""
|
||||
if 'Cookie' not in self.headers:
|
||||
return None
|
||||
|
||||
cookie = cookies.SimpleCookie()
|
||||
try:
|
||||
cookie.load(self.headers["Cookie"])
|
||||
except cookies.CookieError:
|
||||
return None
|
||||
|
||||
morsel = cookie.get(SESSION_KEY)
|
||||
|
||||
if morsel is None:
|
||||
return None
|
||||
|
||||
session_id = cookie[SESSION_KEY].value
|
||||
|
||||
if self.server.sessions.is_valid(session_id):
|
||||
return session_id
|
||||
|
||||
return None
|
||||
|
||||
def destroy_session(self):
|
||||
"""Destroy the session."""
|
||||
session_id = self.get_cookie_session_id()
|
||||
|
||||
if session_id is None:
|
||||
return
|
||||
|
||||
self.send_header('Set-Cookie', '')
|
||||
self.server.sessions.destroy(session_id)
|
||||
|
||||
|
||||
def session_valid_time():
|
||||
"""Time till when a session will be valid."""
|
||||
return date_util.utcnow() + timedelta(seconds=SESSION_TIMEOUT_SECONDS)
|
||||
|
||||
|
||||
class SessionStore(object):
|
||||
"""Responsible for storing and retrieving HTTP sessions."""
|
||||
|
||||
def __init__(self):
|
||||
"""Setup the session store."""
|
||||
self._sessions = {}
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@util.Throttle(SESSION_CLEAR_INTERVAL)
|
||||
def _remove_expired(self):
|
||||
"""Remove any expired sessions."""
|
||||
now = date_util.utcnow()
|
||||
for key in [key for key, valid_time in self._sessions.items()
|
||||
if valid_time < now]:
|
||||
self._sessions.pop(key)
|
||||
|
||||
def is_valid(self, key):
|
||||
"""Return True if a valid session is given."""
|
||||
with self._lock:
|
||||
self._remove_expired()
|
||||
|
||||
return (key in self._sessions and
|
||||
self._sessions[key] > date_util.utcnow())
|
||||
|
||||
def extend_validation(self, key):
|
||||
"""Extend a session validation time."""
|
||||
with self._lock:
|
||||
if key not in self._sessions:
|
||||
return
|
||||
self._sessions[key] = session_valid_time()
|
||||
|
||||
def destroy(self, key):
|
||||
"""Destroy a session by key."""
|
||||
with self._lock:
|
||||
self._sessions.pop(key, None)
|
||||
|
||||
def create(self):
|
||||
"""Create a new session."""
|
||||
with self._lock:
|
||||
session_id = util.get_random_string(20)
|
||||
|
||||
while session_id in self._sessions:
|
||||
session_id = util.get_random_string(20)
|
||||
|
||||
self._sessions[session_id] = session_valid_time()
|
||||
|
||||
return session_id
|
||||
return self.Response(wrap_file(request.environ, fil),
|
||||
mimetype=mimetype, direct_passthrough=True)
|
||||
|
||||
@@ -29,7 +29,7 @@ SERVICE_SET_AUX_HEAT = "set_aux_heat"
|
||||
SERVICE_SET_TEMPERATURE = "set_temperature"
|
||||
SERVICE_SET_FAN_MODE = "set_fan_mode"
|
||||
SERVICE_SET_OPERATION_MODE = "set_operation_mode"
|
||||
SERVICE_SET_SWING = "set_swing_mode"
|
||||
SERVICE_SET_SWING_MODE = "set_swing_mode"
|
||||
SERVICE_SET_HUMIDITY = "set_humidity"
|
||||
|
||||
STATE_HEAT = "heat"
|
||||
@@ -40,17 +40,17 @@ STATE_DRY = "dry"
|
||||
STATE_FAN_ONLY = "fan_only"
|
||||
|
||||
ATTR_CURRENT_TEMPERATURE = "current_temperature"
|
||||
ATTR_CURRENT_HUMIDITY = "current_humidity"
|
||||
ATTR_HUMIDITY = "humidity"
|
||||
ATTR_AWAY_MODE = "away_mode"
|
||||
ATTR_AUX_HEAT = "aux_heat"
|
||||
ATTR_FAN = "fan"
|
||||
ATTR_FAN_LIST = "fan_list"
|
||||
ATTR_MAX_TEMP = "max_temp"
|
||||
ATTR_MIN_TEMP = "min_temp"
|
||||
ATTR_AWAY_MODE = "away_mode"
|
||||
ATTR_AUX_HEAT = "aux_heat"
|
||||
ATTR_FAN_MODE = "fan_mode"
|
||||
ATTR_FAN_LIST = "fan_list"
|
||||
ATTR_CURRENT_HUMIDITY = "current_humidity"
|
||||
ATTR_HUMIDITY = "humidity"
|
||||
ATTR_MAX_HUMIDITY = "max_humidity"
|
||||
ATTR_MIN_HUMIDITY = "min_humidity"
|
||||
ATTR_OPERATION = "operation_mode"
|
||||
ATTR_OPERATION_MODE = "operation_mode"
|
||||
ATTR_OPERATION_LIST = "operation_list"
|
||||
ATTR_SWING_MODE = "swing_mode"
|
||||
ATTR_SWING_LIST = "swing_list"
|
||||
@@ -108,7 +108,7 @@ def set_humidity(hass, humidity, entity_id=None):
|
||||
|
||||
def set_fan_mode(hass, fan, entity_id=None):
|
||||
"""Turn all or specified hvac fan mode on."""
|
||||
data = {ATTR_FAN: fan}
|
||||
data = {ATTR_FAN_MODE: fan}
|
||||
|
||||
if entity_id:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
@@ -118,7 +118,7 @@ def set_fan_mode(hass, fan, entity_id=None):
|
||||
|
||||
def set_operation_mode(hass, operation_mode, entity_id=None):
|
||||
"""Set new target operation mode."""
|
||||
data = {ATTR_OPERATION: operation_mode}
|
||||
data = {ATTR_OPERATION_MODE: operation_mode}
|
||||
|
||||
if entity_id is not None:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
@@ -133,7 +133,7 @@ def set_swing_mode(hass, swing_mode, entity_id=None):
|
||||
if entity_id is not None:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_SET_SWING, data)
|
||||
hass.services.call(DOMAIN, SERVICE_SET_SWING_MODE, data)
|
||||
|
||||
|
||||
# pylint: disable=too-many-branches
|
||||
@@ -247,12 +247,12 @@ def setup(hass, config):
|
||||
"""Set fan mode on target hvacs."""
|
||||
target_hvacs = component.extract_from_service(service)
|
||||
|
||||
fan = service.data.get(ATTR_FAN)
|
||||
fan = service.data.get(ATTR_FAN_MODE)
|
||||
|
||||
if fan is None:
|
||||
_LOGGER.error(
|
||||
"Received call to %s without attribute %s",
|
||||
SERVICE_SET_FAN_MODE, ATTR_FAN)
|
||||
SERVICE_SET_FAN_MODE, ATTR_FAN_MODE)
|
||||
return
|
||||
|
||||
for hvac in target_hvacs:
|
||||
@@ -269,16 +269,16 @@ def setup(hass, config):
|
||||
"""Set operating mode on the target hvacs."""
|
||||
target_hvacs = component.extract_from_service(service)
|
||||
|
||||
operation_mode = service.data.get(ATTR_OPERATION)
|
||||
operation_mode = service.data.get(ATTR_OPERATION_MODE)
|
||||
|
||||
if operation_mode is None:
|
||||
_LOGGER.error(
|
||||
"Received call to %s without attribute %s",
|
||||
SERVICE_SET_OPERATION_MODE, ATTR_OPERATION)
|
||||
SERVICE_SET_OPERATION_MODE, ATTR_OPERATION_MODE)
|
||||
return
|
||||
|
||||
for hvac in target_hvacs:
|
||||
hvac.set_operation(operation_mode)
|
||||
hvac.set_operation_mode(operation_mode)
|
||||
|
||||
if hvac.should_poll:
|
||||
hvac.update_ha_state(True)
|
||||
@@ -296,18 +296,18 @@ def setup(hass, config):
|
||||
if swing_mode is None:
|
||||
_LOGGER.error(
|
||||
"Received call to %s without attribute %s",
|
||||
SERVICE_SET_SWING, ATTR_SWING_MODE)
|
||||
SERVICE_SET_SWING_MODE, ATTR_SWING_MODE)
|
||||
return
|
||||
|
||||
for hvac in target_hvacs:
|
||||
hvac.set_swing(swing_mode)
|
||||
hvac.set_swing_mode(swing_mode)
|
||||
|
||||
if hvac.should_poll:
|
||||
hvac.update_ha_state(True)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_SET_SWING, swing_set_service,
|
||||
descriptions.get(SERVICE_SET_SWING))
|
||||
DOMAIN, SERVICE_SET_SWING_MODE, swing_set_service,
|
||||
descriptions.get(SERVICE_SET_SWING_MODE))
|
||||
return True
|
||||
|
||||
|
||||
@@ -330,19 +330,30 @@ class HvacDevice(Entity):
|
||||
ATTR_MAX_TEMP: self._convert_for_display(self.max_temp),
|
||||
ATTR_TEMPERATURE:
|
||||
self._convert_for_display(self.target_temperature),
|
||||
ATTR_HUMIDITY: self.target_humidity,
|
||||
ATTR_CURRENT_HUMIDITY: self.current_humidity,
|
||||
ATTR_MIN_HUMIDITY: self.min_humidity,
|
||||
ATTR_MAX_HUMIDITY: self.max_humidity,
|
||||
ATTR_FAN_LIST: self.fan_list,
|
||||
ATTR_OPERATION_LIST: self.operation_list,
|
||||
ATTR_SWING_LIST: self.swing_list,
|
||||
ATTR_OPERATION: self.current_operation,
|
||||
ATTR_FAN: self.current_fan_mode,
|
||||
ATTR_SWING_MODE: self.current_swing_mode,
|
||||
|
||||
}
|
||||
|
||||
humidity = self.target_humidity
|
||||
if humidity is not None:
|
||||
data[ATTR_HUMIDITY] = humidity
|
||||
data[ATTR_CURRENT_HUMIDITY] = self.current_humidity
|
||||
data[ATTR_MIN_HUMIDITY] = self.min_humidity
|
||||
data[ATTR_MAX_HUMIDITY] = self.max_humidity
|
||||
|
||||
fan_mode = self.current_fan_mode
|
||||
if fan_mode is not None:
|
||||
data[ATTR_FAN_MODE] = fan_mode
|
||||
data[ATTR_FAN_LIST] = self.fan_list
|
||||
|
||||
operation_mode = self.current_operation
|
||||
if operation_mode is not None:
|
||||
data[ATTR_OPERATION_MODE] = operation_mode
|
||||
data[ATTR_OPERATION_LIST] = self.operation_list
|
||||
|
||||
swing_mode = self.current_swing_mode
|
||||
if swing_mode is not None:
|
||||
data[ATTR_SWING_MODE] = swing_mode
|
||||
data[ATTR_SWING_LIST] = self.swing_list
|
||||
|
||||
is_away = self.is_away_mode_on
|
||||
if is_away is not None:
|
||||
data[ATTR_AWAY_MODE] = STATE_ON if is_away else STATE_OFF
|
||||
@@ -430,11 +441,11 @@ class HvacDevice(Entity):
|
||||
"""Set new target fan mode."""
|
||||
pass
|
||||
|
||||
def set_operation(self, operation_mode):
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set new target operation mode."""
|
||||
pass
|
||||
|
||||
def set_swing(self, swing_mode):
|
||||
def set_swing_mode(self, swing_mode):
|
||||
"""Set new target swing operation."""
|
||||
pass
|
||||
|
||||
@@ -457,12 +468,12 @@ class HvacDevice(Entity):
|
||||
@property
|
||||
def min_temp(self):
|
||||
"""Return the minimum temperature."""
|
||||
return self._convert_for_display(7)
|
||||
return convert(19, TEMP_CELCIUS, self.unit_of_measurement)
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
"""Return the maximum temperature."""
|
||||
return self._convert_for_display(35)
|
||||
return convert(30, TEMP_CELCIUS, self.unit_of_measurement)
|
||||
|
||||
@property
|
||||
def min_humidity(self):
|
||||
|
||||
@@ -118,7 +118,7 @@ class DemoHvac(HvacDevice):
|
||||
self._target_humidity = humidity
|
||||
self.update_ha_state()
|
||||
|
||||
def set_swing(self, swing_mode):
|
||||
def set_swing_mode(self, swing_mode):
|
||||
"""Set new target temperature."""
|
||||
self._current_swing_mode = swing_mode
|
||||
self.update_ha_state()
|
||||
@@ -128,7 +128,7 @@ class DemoHvac(HvacDevice):
|
||||
self._current_fan_mode = fan
|
||||
self.update_ha_state()
|
||||
|
||||
def set_operation(self, operation_mode):
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set new target temperature."""
|
||||
self._current_operation = operation_mode
|
||||
self.update_ha_state()
|
||||
|
||||
55
homeassistant/components/hvac/zwave.py
Normal file → Executable file
55
homeassistant/components/hvac/zwave.py
Normal file → Executable file
@@ -1,5 +1,9 @@
|
||||
"""ZWave Hvac device."""
|
||||
"""
|
||||
Support for ZWave HVAC devices.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/hvac.zwave/
|
||||
"""
|
||||
# Because we do not compile openzwave on CI
|
||||
# pylint: disable=import-error
|
||||
import logging
|
||||
@@ -19,6 +23,12 @@ REMOTEC = 0x5254
|
||||
REMOTEC_ZXT_120 = 0x8377
|
||||
REMOTEC_ZXT_120_THERMOSTAT = (REMOTEC, REMOTEC_ZXT_120, 0)
|
||||
|
||||
COMMAND_CLASS_SENSOR_MULTILEVEL = 0x31
|
||||
COMMAND_CLASS_THERMOSTAT_MODE = 0x40
|
||||
COMMAND_CLASS_THERMOSTAT_SETPOINT = 0x43
|
||||
COMMAND_CLASS_THERMOSTAT_FAN_MODE = 0x44
|
||||
COMMAND_CLASS_CONFIGURATION = 0x70
|
||||
|
||||
WORKAROUND_ZXT_120 = 'zxt_120'
|
||||
|
||||
DEVICE_MAPPINGS = {
|
||||
@@ -96,22 +106,24 @@ class ZWaveHvac(ZWaveDeviceEntity, HvacDevice):
|
||||
def update_properties(self):
|
||||
"""Callback on data change for the registered node/value pair."""
|
||||
# Set point
|
||||
for value in self._node.get_values(class_id=0x43).values():
|
||||
for value in self._node.get_values(
|
||||
class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values():
|
||||
if int(value.data) != 0:
|
||||
self._target_temperature = int(value.data)
|
||||
# Operation Mode
|
||||
for value in self._node.get_values(class_id=0x40).values():
|
||||
for value in self._node.get_values(
|
||||
class_id=COMMAND_CLASS_THERMOSTAT_MODE).values():
|
||||
self._current_operation = value.data
|
||||
self._operation_list = list(value.data_items)
|
||||
_LOGGER.debug("self._operation_list=%s", self._operation_list)
|
||||
# Current Temp
|
||||
for value in self._node.get_values(class_id=0x31).values():
|
||||
for value in self._node.get_values(
|
||||
class_id=COMMAND_CLASS_SENSOR_MULTILEVEL).values():
|
||||
self._current_temperature = int(value.data)
|
||||
self._unit = value.units
|
||||
# Fan Mode
|
||||
fan_class_id = 0x44 if self._zxt_120 else 0x42
|
||||
_LOGGER.debug("fan_class_id=%s", fan_class_id)
|
||||
for value in self._node.get_values(class_id=fan_class_id).values():
|
||||
for value in self._node.get_values(
|
||||
class_id=COMMAND_CLASS_THERMOSTAT_FAN_MODE).values():
|
||||
self._current_operation_state = value.data
|
||||
self._fan_list = list(value.data_items)
|
||||
_LOGGER.debug("self._fan_list=%s", self._fan_list)
|
||||
@@ -119,7 +131,8 @@ class ZWaveHvac(ZWaveDeviceEntity, HvacDevice):
|
||||
self._current_operation_state)
|
||||
# Swing mode
|
||||
if self._zxt_120 == 1:
|
||||
for value in self._node.get_values(class_id=0x70).values():
|
||||
for value in self._node.get_values(
|
||||
class_id=COMMAND_CLASS_CONFIGURATION).values():
|
||||
if value.command_class == 112 and value.index == 33:
|
||||
self._current_swing_mode = value.data
|
||||
self._swing_list = [0, 1]
|
||||
@@ -184,7 +197,8 @@ class ZWaveHvac(ZWaveDeviceEntity, HvacDevice):
|
||||
|
||||
def set_temperature(self, temperature):
|
||||
"""Set new target temperature."""
|
||||
for value in self._node.get_values(class_id=0x43).values():
|
||||
for value in self._node.get_values(
|
||||
class_id=COMMAND_CLASS_THERMOSTAT_SETPOINT).values():
|
||||
if value.command_class != 67:
|
||||
continue
|
||||
if self._zxt_120:
|
||||
@@ -200,29 +214,22 @@ class ZWaveHvac(ZWaveDeviceEntity, HvacDevice):
|
||||
|
||||
def set_fan_mode(self, fan):
|
||||
"""Set new target fan mode."""
|
||||
for value in self._node.get_values(class_id=0x44).values():
|
||||
for value in self._node.get_values(
|
||||
class_id=COMMAND_CLASS_THERMOSTAT_FAN_MODE).values():
|
||||
if value.command_class == 68 and value.index == 0:
|
||||
value.data = bytes(fan, 'utf-8')
|
||||
|
||||
def set_operation(self, operation_mode):
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set new target operation mode."""
|
||||
for value in self._node.get_values(class_id=0x40).values():
|
||||
for value in self._node.get_values(
|
||||
class_id=COMMAND_CLASS_THERMOSTAT_MODE).values():
|
||||
if value.command_class == 64 and value.index == 0:
|
||||
value.data = bytes(operation_mode, 'utf-8')
|
||||
|
||||
def set_swing(self, swing_mode):
|
||||
def set_swing_mode(self, swing_mode):
|
||||
"""Set new target swing mode."""
|
||||
if self._zxt_120 == 1:
|
||||
for value in self._node.get_values(class_id=0x70).values():
|
||||
for value in self._node.get_values(
|
||||
class_id=COMMAND_CLASS_CONFIGURATION).values():
|
||||
if value.command_class == 112 and value.index == 33:
|
||||
value.data = int(swing_mode)
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
"""Return the minimum temperature."""
|
||||
return self._convert_for_display(19)
|
||||
|
||||
@property
|
||||
def max_temp(self):
|
||||
"""Return the maximum temperature."""
|
||||
return self._convert_for_display(30)
|
||||
|
||||
@@ -11,7 +11,6 @@ from homeassistant.const import (
|
||||
ATTR_DISCOVERED, ATTR_SERVICE, CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME,
|
||||
EVENT_PLATFORM_DISCOVERED)
|
||||
from homeassistant.helpers import validate_config
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.loader import get_component
|
||||
|
||||
DOMAIN = "insteon_hub"
|
||||
@@ -53,43 +52,3 @@ def setup(hass, config):
|
||||
EVENT_PLATFORM_DISCOVERED,
|
||||
{ATTR_SERVICE: discovery, ATTR_DISCOVERED: {}})
|
||||
return True
|
||||
|
||||
|
||||
class InsteonToggleDevice(ToggleEntity):
|
||||
"""An abstract Class for an Insteon node."""
|
||||
|
||||
def __init__(self, node):
|
||||
"""Initialize the device."""
|
||||
self.node = node
|
||||
self._value = 0
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the the name of the node."""
|
||||
return self.node.DeviceName
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the ID of this insteon node."""
|
||||
return self.node.DeviceID
|
||||
|
||||
def update(self):
|
||||
"""Update state of the sensor."""
|
||||
resp = self.node.send_command('get_status', wait=True)
|
||||
try:
|
||||
self._value = resp['response']['level']
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return the boolean response if the node is on."""
|
||||
return self._value != 0
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
"""Turn device on."""
|
||||
self.node.send_command('on')
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
"""Turn device off."""
|
||||
self.node.send_command('off')
|
||||
|
||||
@@ -16,7 +16,7 @@ from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.loader import get_component
|
||||
|
||||
DOMAIN = "isy994"
|
||||
REQUIREMENTS = ['PyISY==1.0.5']
|
||||
REQUIREMENTS = ['PyISY==1.0.6']
|
||||
DISCOVER_LIGHTS = "isy994.lights"
|
||||
DISCOVER_SWITCHES = "isy994.switches"
|
||||
DISCOVER_SENSORS = "isy994.sensors"
|
||||
|
||||
@@ -39,6 +39,7 @@ ATTR_TRANSITION = "transition"
|
||||
ATTR_RGB_COLOR = "rgb_color"
|
||||
ATTR_XY_COLOR = "xy_color"
|
||||
ATTR_COLOR_TEMP = "color_temp"
|
||||
ATTR_COLOR_NAME = "color_name"
|
||||
|
||||
# int with value 0 .. 255 representing brightness of the light.
|
||||
ATTR_BRIGHTNESS = "brightness"
|
||||
@@ -87,6 +88,7 @@ LIGHT_TURN_ON_SCHEMA = vol.Schema({
|
||||
ATTR_PROFILE: str,
|
||||
ATTR_TRANSITION: VALID_TRANSITION,
|
||||
ATTR_BRIGHTNESS: cv.byte,
|
||||
ATTR_COLOR_NAME: str,
|
||||
ATTR_RGB_COLOR: vol.All(vol.ExactSequence((cv.byte, cv.byte, cv.byte)),
|
||||
vol.Coerce(tuple)),
|
||||
ATTR_XY_COLOR: vol.All(vol.ExactSequence((cv.small_float, cv.small_float)),
|
||||
@@ -122,7 +124,7 @@ def is_on(hass, entity_id=None):
|
||||
# pylint: disable=too-many-arguments
|
||||
def turn_on(hass, entity_id=None, transition=None, brightness=None,
|
||||
rgb_color=None, xy_color=None, color_temp=None, profile=None,
|
||||
flash=None, effect=None):
|
||||
flash=None, effect=None, color_name=None):
|
||||
"""Turn all or specified light on."""
|
||||
data = {
|
||||
key: value for key, value in [
|
||||
@@ -135,6 +137,7 @@ def turn_on(hass, entity_id=None, transition=None, brightness=None,
|
||||
(ATTR_COLOR_TEMP, color_temp),
|
||||
(ATTR_FLASH, flash),
|
||||
(ATTR_EFFECT, effect),
|
||||
(ATTR_COLOR_NAME, color_name),
|
||||
] if value is not None
|
||||
}
|
||||
|
||||
@@ -228,6 +231,11 @@ def setup(hass, config):
|
||||
params.setdefault(ATTR_XY_COLOR, profile[:2])
|
||||
params.setdefault(ATTR_BRIGHTNESS, profile[2])
|
||||
|
||||
color_name = params.pop(ATTR_COLOR_NAME, None)
|
||||
|
||||
if color_name is not None:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name)
|
||||
|
||||
for light in target_lights:
|
||||
light.turn_on(**params)
|
||||
|
||||
|
||||
92
homeassistant/components/light/enocean.py
Normal file
92
homeassistant/components/light/enocean.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Support for EnOcean light sources.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/light.enocean/
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from homeassistant.components.light import Light, ATTR_BRIGHTNESS
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.components import enocean
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ["enocean"]
|
||||
|
||||
CONF_ID = "id"
|
||||
CONF_SENDER_ID = "sender_id"
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup the EnOcean light platform."""
|
||||
sender_id = config.get(CONF_SENDER_ID, None)
|
||||
devname = config.get(CONF_NAME, "Enocean actuator")
|
||||
dev_id = config.get(CONF_ID, [0x00, 0x00, 0x00, 0x00])
|
||||
|
||||
add_devices([EnOceanLight(sender_id, devname, dev_id)])
|
||||
|
||||
|
||||
class EnOceanLight(enocean.EnOceanDevice, Light):
|
||||
"""Representation of an EnOcean light source."""
|
||||
|
||||
def __init__(self, sender_id, devname, dev_id):
|
||||
"""Initialize the EnOcean light source."""
|
||||
enocean.EnOceanDevice.__init__(self)
|
||||
self._on_state = False
|
||||
self._brightness = 50
|
||||
self._sender_id = sender_id
|
||||
self.dev_id = dev_id
|
||||
self._devname = devname
|
||||
self.stype = "dimmer"
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device if any."""
|
||||
return self._devname
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Brightness of the light.
|
||||
|
||||
This method is optional. Removing it indicates to Home Assistant
|
||||
that brightness is not supported for this light.
|
||||
"""
|
||||
return self._brightness
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""If light is on."""
|
||||
return self._on_state
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
"""Turn the light source on or sets a specific dimmer value."""
|
||||
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
||||
if brightness is not None:
|
||||
self._brightness = brightness
|
||||
|
||||
bval = math.floor(self._brightness / 256.0 * 100.0)
|
||||
if bval == 0:
|
||||
bval = 1
|
||||
command = [0xa5, 0x02, bval, 0x01, 0x09]
|
||||
command.extend(self._sender_id)
|
||||
command.extend([0x00])
|
||||
self.send_command(command, [], 0x01)
|
||||
self._on_state = True
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
"""Turn the light source off."""
|
||||
command = [0xa5, 0x02, 0x00, 0x01, 0x09]
|
||||
command.extend(self._sender_id)
|
||||
command.extend([0x00])
|
||||
self.send_command(command, [], 0x01)
|
||||
self._on_state = False
|
||||
|
||||
def value_changed(self, val):
|
||||
"""Update the internal state of this device in HA."""
|
||||
self._brightness = math.floor(val / 100.0 * 256.0)
|
||||
self._on_state = bool(val != 0)
|
||||
self.update_ha_state()
|
||||
@@ -235,14 +235,16 @@ class HueLight(Light):
|
||||
if ATTR_TRANSITION in kwargs:
|
||||
command['transitiontime'] = kwargs[ATTR_TRANSITION] * 10
|
||||
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
command['bri'] = kwargs[ATTR_BRIGHTNESS]
|
||||
|
||||
if ATTR_XY_COLOR in kwargs:
|
||||
command['xy'] = kwargs[ATTR_XY_COLOR]
|
||||
elif ATTR_RGB_COLOR in kwargs:
|
||||
command['xy'] = color_util.color_RGB_to_xy(
|
||||
xyb = color_util.color_RGB_to_xy(
|
||||
*(int(val) for val in kwargs[ATTR_RGB_COLOR]))
|
||||
command['xy'] = xyb[0], xyb[1]
|
||||
command['bri'] = xyb[2]
|
||||
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
command['bri'] = kwargs[ATTR_BRIGHTNESS]
|
||||
|
||||
if ATTR_COLOR_TEMP in kwargs:
|
||||
command['ct'] = kwargs[ATTR_COLOR_TEMP]
|
||||
|
||||
@@ -4,7 +4,8 @@ Support for Insteon Hub lights.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/insteon_hub/
|
||||
"""
|
||||
from homeassistant.components.insteon_hub import INSTEON, InsteonToggleDevice
|
||||
from homeassistant.components.insteon_hub import INSTEON
|
||||
from homeassistant.components.light import ATTR_BRIGHTNESS, Light
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
@@ -16,3 +17,53 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
if device.DeviceCategory == "Dimmable Lighting Control":
|
||||
devs.append(InsteonToggleDevice(device))
|
||||
add_devices(devs)
|
||||
|
||||
|
||||
class InsteonToggleDevice(Light):
|
||||
"""An abstract Class for an Insteon node."""
|
||||
|
||||
def __init__(self, node):
|
||||
"""Initialize the device."""
|
||||
self.node = node
|
||||
self._value = 0
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the the name of the node."""
|
||||
return self.node.DeviceName
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return the ID of this insteon node."""
|
||||
return self.node.DeviceID
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return self._value / 100 * 255
|
||||
|
||||
def update(self):
|
||||
"""Update state of the sensor."""
|
||||
resp = self.node.send_command('get_status', wait=True)
|
||||
try:
|
||||
self._value = resp['response']['level']
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return the boolean response if the node is on."""
|
||||
return self._value != 0
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
"""Turn device on."""
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
self._value = kwargs[ATTR_BRIGHTNESS] / 255 * 100
|
||||
self.node.send_command('on', self._value)
|
||||
else:
|
||||
self._value = 100
|
||||
self.node.send_command('on')
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
"""Turn device off."""
|
||||
self.node.send_command('off')
|
||||
|
||||
165
homeassistant/components/light/osramlightify.py
Normal file
165
homeassistant/components/light/osramlightify.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
Support for Osram Lightify.
|
||||
|
||||
Uses: https://github.com/aneumeier/python-lightify for the Osram light
|
||||
interface.
|
||||
|
||||
In order to use the platform just add the following to the configuration.yaml:
|
||||
|
||||
light:
|
||||
platform: osramlightify
|
||||
host: <hostname_or_ip>
|
||||
|
||||
Todo:
|
||||
Add support for Non RGBW lights.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import socket
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant import util
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.components.light import (
|
||||
Light,
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_TRANSITION
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REQUIREMENTS = ['lightify==1.0.3']
|
||||
|
||||
TEMP_MIN = 2000 # lightify minimum temperature
|
||||
TEMP_MAX = 6500 # lightify maximum temperature
|
||||
TEMP_MIN_HASS = 154 # home assistant minimum temperature
|
||||
TEMP_MAX_HASS = 500 # home assistant maximum temperature
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
||||
MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
"""Find and return lights."""
|
||||
import lightify
|
||||
host = config.get(CONF_HOST)
|
||||
if host:
|
||||
try:
|
||||
bridge = lightify.Lightify(host)
|
||||
except socket.error as err:
|
||||
msg = 'Error connecting to bridge: {} due to: {}'.format(host,
|
||||
str(err))
|
||||
_LOGGER.exception(msg)
|
||||
return False
|
||||
setup_bridge(bridge, add_devices_callback)
|
||||
else:
|
||||
_LOGGER.error('No host found in configuration')
|
||||
return False
|
||||
|
||||
|
||||
def setup_bridge(bridge, add_devices_callback):
|
||||
"""Setup the Lightify bridge."""
|
||||
lights = {}
|
||||
|
||||
@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
|
||||
def update_lights():
|
||||
"""Update the lights objects with latest info from bridge."""
|
||||
bridge.update_all_light_status()
|
||||
|
||||
new_lights = []
|
||||
|
||||
for (light_id, light) in bridge.lights().items():
|
||||
if light_id not in lights:
|
||||
osram_light = OsramLightifyLight(light_id, light,
|
||||
update_lights)
|
||||
|
||||
lights[light_id] = osram_light
|
||||
new_lights.append(osram_light)
|
||||
else:
|
||||
lights[light_id].light = light
|
||||
|
||||
if new_lights:
|
||||
add_devices_callback(new_lights)
|
||||
|
||||
update_lights()
|
||||
|
||||
|
||||
class OsramLightifyLight(Light):
|
||||
"""Defines an Osram Lightify Light."""
|
||||
|
||||
def __init__(self, light_id, light, update_lights):
|
||||
"""Initialize the light."""
|
||||
self._light = light
|
||||
self._light_id = light_id
|
||||
self.update_lights = update_lights
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device if any."""
|
||||
return self._light.name()
|
||||
|
||||
@property
|
||||
def rgb_color(self):
|
||||
"""Last RGB color value set."""
|
||||
return self._light.rgb()
|
||||
|
||||
@property
|
||||
def color_temp(self):
|
||||
"""Return the color temperature."""
|
||||
o_temp = self._light.temp()
|
||||
temperature = int(TEMP_MIN_HASS + (TEMP_MAX_HASS - TEMP_MIN_HASS) *
|
||||
(o_temp - TEMP_MIN) / (TEMP_MAX - TEMP_MIN))
|
||||
return temperature
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Brightness of this light between 0..255."""
|
||||
return int(self._light.lum() * 2.55)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Update Status to True if device is on."""
|
||||
self.update_lights()
|
||||
_LOGGER.debug("is_on light state for light: %s is: %s",
|
||||
self._light.name(), self._light.on())
|
||||
return self._light.on()
|
||||
|
||||
def turn_on(self, **kwargs):
|
||||
"""Turn the device on."""
|
||||
brightness = 100
|
||||
if self.brightness:
|
||||
brightness = int(self.brightness / 2.55)
|
||||
|
||||
if ATTR_TRANSITION in kwargs:
|
||||
fade = kwargs[ATTR_TRANSITION] * 10
|
||||
else:
|
||||
fade = 0
|
||||
|
||||
if ATTR_RGB_COLOR in kwargs:
|
||||
red, green, blue = kwargs[ATTR_RGB_COLOR]
|
||||
self._light.set_rgb(red, green, blue, fade)
|
||||
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
brightness = int(kwargs[ATTR_BRIGHTNESS] / 2.55)
|
||||
|
||||
if ATTR_COLOR_TEMP in kwargs:
|
||||
color_t = kwargs[ATTR_COLOR_TEMP]
|
||||
kelvin = int(((TEMP_MAX - TEMP_MIN) * (color_t - TEMP_MIN_HASS) /
|
||||
(TEMP_MAX_HASS - TEMP_MIN_HASS)) + TEMP_MIN)
|
||||
self._light.set_temperature(kelvin, fade)
|
||||
|
||||
self._light.set_luminance(brightness, fade)
|
||||
self.update_ha_state()
|
||||
|
||||
def turn_off(self, **kwargs):
|
||||
"""Turn the device off."""
|
||||
if ATTR_TRANSITION in kwargs:
|
||||
fade = kwargs[ATTR_TRANSITION] * 10
|
||||
else:
|
||||
fade = 0
|
||||
self._light.set_luminance(0, fade)
|
||||
self.update_ha_state()
|
||||
|
||||
def update(self):
|
||||
"""Synchronize state with bridge."""
|
||||
self.update_lights(no_throttle=True)
|
||||
35
homeassistant/components/light/qwikswitch.py
Normal file
35
homeassistant/components/light/qwikswitch.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
Support for Qwikswitch Relays and Dimmers.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/light.qwikswitch/
|
||||
"""
|
||||
import logging
|
||||
import homeassistant.components.qwikswitch as qwikswitch
|
||||
from homeassistant.components.light import Light
|
||||
|
||||
DEPENDENCIES = ['qwikswitch']
|
||||
|
||||
|
||||
class QSLight(qwikswitch.QSToggleEntity, Light):
|
||||
"""Light based on a Qwikswitch relay/dimmer module."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Store add_devices for the light components."""
|
||||
if discovery_info is None or 'qsusb_id' not in discovery_info:
|
||||
logging.getLogger(__name__).error(
|
||||
'Configure main Qwikswitch component')
|
||||
return False
|
||||
|
||||
qsusb = qwikswitch.QSUSB[discovery_info['qsusb_id']]
|
||||
|
||||
for item in qsusb.ha_devices:
|
||||
if item['type'] not in ['dim', 'rel']:
|
||||
continue
|
||||
dev = QSLight(item, qsusb)
|
||||
add_devices([dev])
|
||||
qsusb.ha_objects[item['id']] = dev
|
||||
@@ -16,6 +16,10 @@ turn_on:
|
||||
description: Color for the light in RGB-format
|
||||
example: '[255, 100, 100]'
|
||||
|
||||
color_name:
|
||||
description: A human readable color name
|
||||
example: 'red'
|
||||
|
||||
xy_color:
|
||||
description: Color for the light in XY-format
|
||||
example: '[0.52, 0.43]'
|
||||
|
||||
@@ -105,6 +105,7 @@ class WemoLight(Light):
|
||||
elif ATTR_RGB_COLOR in kwargs:
|
||||
xycolor = color_util.color_RGB_to_xy(
|
||||
*(int(val) for val in kwargs[ATTR_RGB_COLOR]))
|
||||
kwargs.setdefault(ATTR_BRIGHTNESS, xycolor[2])
|
||||
else:
|
||||
xycolor = None
|
||||
|
||||
|
||||
@@ -97,7 +97,9 @@ class WinkLight(Light):
|
||||
}
|
||||
|
||||
if rgb_color:
|
||||
state_kwargs['color_xy'] = color_util.color_RGB_to_xy(*rgb_color)
|
||||
xyb = color_util.color_RGB_to_xy(*rgb_color)
|
||||
state_kwargs['color_xy'] = xyb[0], xyb[1]
|
||||
state_kwargs['brightness'] = xyb[2]
|
||||
|
||||
if color_temp_mired:
|
||||
state_kwargs['color_kelvin'] = mired_to_kelvin(color_temp_mired)
|
||||
|
||||
78
homeassistant/components/lirc.py
Normal file
78
homeassistant/components/lirc.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
LIRC interface to receive signals from a infrared remote control.
|
||||
|
||||
This sensor will momentarily set state to various values as defined
|
||||
in the .lintrc file which can be interpreted in home-assistant to
|
||||
trigger various actions.
|
||||
|
||||
Sending signals to other IR receivers can be accomplished with the
|
||||
shell_command component and the irsend command for now.
|
||||
"""
|
||||
# pylint: disable=import-error
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
|
||||
from homeassistant.const import (EVENT_HOMEASSISTANT_STOP,
|
||||
EVENT_HOMEASSISTANT_START)
|
||||
|
||||
DOMAIN = "lirc"
|
||||
REQUIREMENTS = ['python-lirc==1.2.1']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
ICON = 'mdi:remote'
|
||||
EVENT_IR_COMMAND_RECEIVED = 'ir_command_received'
|
||||
BUTTON_NAME = 'button_name'
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Setup LIRC capability."""
|
||||
import lirc
|
||||
|
||||
# blocking=True gives unexpected behavior (multiple responses for 1 press)
|
||||
# also by not blocking, we allow hass to shut down the thread gracefully
|
||||
# on exit.
|
||||
lirc.init('home-assistant', blocking=False)
|
||||
lirc_interface = LircInterface(hass)
|
||||
|
||||
def _start_lirc(_event):
|
||||
lirc_interface.start()
|
||||
|
||||
def _stop_lirc(_event):
|
||||
lirc_interface.stopped.set()
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_lirc)
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_lirc)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
class LircInterface(threading.Thread):
|
||||
"""
|
||||
This interfaces with the lirc daemon to read IR commands.
|
||||
|
||||
When using lirc in blocking mode, sometimes repeated commands get produced
|
||||
in the next read of a command so we use a thread here to just wait
|
||||
around until a non-empty response is obtained from lirc.
|
||||
"""
|
||||
|
||||
def __init__(self, hass):
|
||||
"""Construct a LIRC interface object."""
|
||||
threading.Thread.__init__(self)
|
||||
self.daemon = True
|
||||
self.stopped = threading.Event()
|
||||
self.hass = hass
|
||||
|
||||
def run(self):
|
||||
"""Main loop of LIRC interface thread."""
|
||||
import lirc
|
||||
while not self.stopped.isSet():
|
||||
code = lirc.nextcode() # list; empty if no buttons pressed
|
||||
# interpret result from python-lirc
|
||||
if code:
|
||||
code = code[0]
|
||||
_LOGGER.info('Got new LIRC code %s', code)
|
||||
self.hass.bus.fire(EVENT_IR_COMMAND_RECEIVED,
|
||||
{BUTTON_NAME: code})
|
||||
else:
|
||||
time.sleep(0.2)
|
||||
_LOGGER.info('LIRC interface thread stopped')
|
||||
@@ -15,12 +15,13 @@ import homeassistant.util.dt as dt_util
|
||||
from homeassistant.components import recorder, sun
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED,
|
||||
HTTP_BAD_REQUEST, STATE_NOT_HOME, STATE_OFF, STATE_ON)
|
||||
STATE_NOT_HOME, STATE_OFF, STATE_ON)
|
||||
from homeassistant.core import DOMAIN as HA_DOMAIN
|
||||
from homeassistant.core import State
|
||||
from homeassistant.helpers.entity import split_entity_id
|
||||
from homeassistant.helpers import template
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
|
||||
DOMAIN = "logbook"
|
||||
DEPENDENCIES = ['recorder', 'http']
|
||||
@@ -76,34 +77,34 @@ def setup(hass, config):
|
||||
message = template.render(hass, message)
|
||||
log_entry(hass, name, message, domain, entity_id)
|
||||
|
||||
hass.http.register_path('GET', URL_LOGBOOK, _handle_get_logbook)
|
||||
hass.wsgi.register_view(LogbookView)
|
||||
|
||||
hass.services.register(DOMAIN, 'log', log_message,
|
||||
schema=LOG_MESSAGE_SCHEMA)
|
||||
return True
|
||||
|
||||
|
||||
def _handle_get_logbook(handler, path_match, data):
|
||||
"""Return logbook entries."""
|
||||
date_str = path_match.group('date')
|
||||
class LogbookView(HomeAssistantView):
|
||||
"""Handle logbook view requests."""
|
||||
|
||||
if date_str:
|
||||
start_date = dt_util.parse_date(date_str)
|
||||
url = '/api/logbook'
|
||||
name = 'api:logbook'
|
||||
extra_urls = ['/api/logbook/<date:date>']
|
||||
|
||||
if start_date is None:
|
||||
handler.write_json_message("Error parsing JSON", HTTP_BAD_REQUEST)
|
||||
return
|
||||
def get(self, request, date=None):
|
||||
"""Retrieve logbook entries."""
|
||||
if date:
|
||||
start_day = dt_util.start_of_local_day(date)
|
||||
else:
|
||||
start_day = dt_util.start_of_local_day()
|
||||
|
||||
start_day = dt_util.start_of_local_day(start_date)
|
||||
else:
|
||||
start_day = dt_util.start_of_local_day()
|
||||
end_day = start_day + timedelta(days=1)
|
||||
|
||||
end_day = start_day + timedelta(days=1)
|
||||
events = recorder.query_events(
|
||||
QUERY_EVENTS_BETWEEN,
|
||||
(dt_util.as_utc(start_day), dt_util.as_utc(end_day)))
|
||||
|
||||
events = recorder.query_events(
|
||||
QUERY_EVENTS_BETWEEN,
|
||||
(dt_util.as_utc(start_day), dt_util.as_utc(end_day)))
|
||||
|
||||
handler.write_json(humanify(events))
|
||||
return self.json(humanify(events))
|
||||
|
||||
|
||||
class Entry(object):
|
||||
|
||||
61
homeassistant/components/logentries.py
Normal file
61
homeassistant/components/logentries.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Support for sending data to Logentries webhook endpoint.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/logentries/
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
import homeassistant.util as util
|
||||
from homeassistant.const import EVENT_STATE_CHANGED
|
||||
from homeassistant.helpers import state as state_helper
|
||||
from homeassistant.helpers import validate_config
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "logentries"
|
||||
DEPENDENCIES = []
|
||||
|
||||
DEFAULT_HOST = 'https://webhook.logentries.com/noformat/logs/'
|
||||
|
||||
CONF_TOKEN = 'token'
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Setup the Logentries component."""
|
||||
if not validate_config(config, {DOMAIN: ['token']}, _LOGGER):
|
||||
_LOGGER.error("Logentries token not present")
|
||||
return False
|
||||
conf = config[DOMAIN]
|
||||
token = util.convert(conf.get(CONF_TOKEN), str)
|
||||
le_wh = DEFAULT_HOST + token
|
||||
|
||||
def logentries_event_listener(event):
|
||||
"""Listen for new messages on the bus and sends them to Logentries."""
|
||||
state = event.data.get('new_state')
|
||||
if state is None:
|
||||
return
|
||||
try:
|
||||
_state = state_helper.state_as_number(state)
|
||||
except ValueError:
|
||||
_state = state.state
|
||||
json_body = [
|
||||
{
|
||||
'domain': state.domain,
|
||||
'entity_id': state.object_id,
|
||||
'attributes': dict(state.attributes),
|
||||
'time': str(event.time_fired),
|
||||
'value': _state,
|
||||
}
|
||||
]
|
||||
try:
|
||||
payload = {"host": le_wh,
|
||||
"event": json_body}
|
||||
requests.post(le_wh, data=json.dumps(payload), timeout=10)
|
||||
except requests.exceptions.RequestException as error:
|
||||
_LOGGER.exception('Error sending to Logentries: %s', error)
|
||||
|
||||
hass.bus.listen(EVENT_STATE_CHANGED, logentries_event_listener)
|
||||
|
||||
return True
|
||||
@@ -36,6 +36,7 @@ DISCOVERY_PLATFORMS = {
|
||||
discovery.SERVICE_PLEX: 'plex',
|
||||
discovery.SERVICE_SQUEEZEBOX: 'squeezebox',
|
||||
discovery.SERVICE_PANASONIC_VIERA: 'panasonic_viera',
|
||||
discovery.SERVICE_ROKU: 'roku',
|
||||
}
|
||||
|
||||
SERVICE_PLAY_MEDIA = 'play_media'
|
||||
@@ -62,6 +63,7 @@ ATTR_APP_NAME = 'app_name'
|
||||
ATTR_SUPPORTED_MEDIA_COMMANDS = 'supported_media_commands'
|
||||
ATTR_INPUT_SOURCE = 'source'
|
||||
ATTR_INPUT_SOURCE_LIST = 'source_list'
|
||||
ATTR_MEDIA_ENQUEUE = 'enqueue'
|
||||
|
||||
MEDIA_TYPE_MUSIC = 'music'
|
||||
MEDIA_TYPE_TVSHOW = 'tvshow'
|
||||
@@ -144,6 +146,7 @@ MEDIA_PLAYER_MEDIA_SEEK_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
|
||||
MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
|
||||
vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string,
|
||||
vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string,
|
||||
ATTR_MEDIA_ENQUEUE: cv.boolean,
|
||||
})
|
||||
|
||||
MEDIA_PLAYER_SELECT_SOURCE_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({
|
||||
@@ -255,7 +258,7 @@ def media_seek(hass, position, entity_id=None):
|
||||
hass.services.call(DOMAIN, SERVICE_MEDIA_SEEK, data)
|
||||
|
||||
|
||||
def play_media(hass, media_type, media_id, entity_id=None):
|
||||
def play_media(hass, media_type, media_id, entity_id=None, enqueue=None):
|
||||
"""Send the media player the command for playing media."""
|
||||
data = {ATTR_MEDIA_CONTENT_TYPE: media_type,
|
||||
ATTR_MEDIA_CONTENT_ID: media_id}
|
||||
@@ -263,6 +266,9 @@ def play_media(hass, media_type, media_id, entity_id=None):
|
||||
if entity_id:
|
||||
data[ATTR_ENTITY_ID] = entity_id
|
||||
|
||||
if enqueue:
|
||||
data[ATTR_MEDIA_ENQUEUE] = enqueue
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_PLAY_MEDIA, data)
|
||||
|
||||
|
||||
@@ -363,9 +369,14 @@ def setup(hass, config):
|
||||
"""Play specified media_id on the media player."""
|
||||
media_type = service.data.get(ATTR_MEDIA_CONTENT_TYPE)
|
||||
media_id = service.data.get(ATTR_MEDIA_CONTENT_ID)
|
||||
enqueue = service.data.get(ATTR_MEDIA_ENQUEUE)
|
||||
|
||||
kwargs = {
|
||||
ATTR_MEDIA_ENQUEUE: enqueue,
|
||||
}
|
||||
|
||||
for player in component.extract_from_service(service):
|
||||
player.play_media(media_type, media_id)
|
||||
player.play_media(media_type, media_id, **kwargs)
|
||||
|
||||
if player.should_poll:
|
||||
player.update_ha_state(True)
|
||||
|
||||
@@ -253,7 +253,7 @@ class CastDevice(MediaPlayerDevice):
|
||||
"""Seek the media to a specific location."""
|
||||
self.cast.media_controller.seek(position)
|
||||
|
||||
def play_media(self, media_type, media_id):
|
||||
def play_media(self, media_type, media_id, **kwargs):
|
||||
"""Play media from a URL."""
|
||||
self.cast.media_controller.play_media(media_id, media_type)
|
||||
|
||||
|
||||
@@ -152,7 +152,7 @@ class DemoYoutubePlayer(AbstractDemoPlayer):
|
||||
"""Flag of media commands that are supported."""
|
||||
return YOUTUBE_PLAYER_SUPPORT
|
||||
|
||||
def play_media(self, media_type, media_id):
|
||||
def play_media(self, media_type, media_id, **kwargs):
|
||||
"""Play a piece of media."""
|
||||
self.youtube_id = media_id
|
||||
self.update_ha_state()
|
||||
|
||||
158
homeassistant/components/media_player/gpmdp.py
Normal file
158
homeassistant/components/media_player/gpmdp.py
Normal file
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
Support for Google Play Music Desktop Player.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/media_player.gpmdp/
|
||||
"""
|
||||
import logging
|
||||
import json
|
||||
import socket
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK,
|
||||
SUPPORT_PAUSE, MediaPlayerDevice)
|
||||
from homeassistant.const import (
|
||||
STATE_PLAYING, STATE_PAUSED, STATE_OFF)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
REQUIREMENTS = ['websocket-client==0.35.0']
|
||||
SUPPORT_GPMDP = SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup the GPMDP platform."""
|
||||
from websocket import create_connection
|
||||
|
||||
name = config.get("name", "GPM Desktop Player")
|
||||
address = config.get("address")
|
||||
|
||||
if address is None:
|
||||
_LOGGER.error("Missing address in config")
|
||||
return False
|
||||
|
||||
add_devices([GPMDP(name, address, create_connection)])
|
||||
|
||||
|
||||
class GPMDP(MediaPlayerDevice):
|
||||
"""Representation of a GPMDP."""
|
||||
|
||||
# pylint: disable=too-many-public-methods, abstract-method
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
def __init__(self, name, address, create_connection):
|
||||
"""Initialize the media player."""
|
||||
self._connection = create_connection
|
||||
self._address = address
|
||||
self._name = name
|
||||
self._status = STATE_OFF
|
||||
self._ws = None
|
||||
self._title = None
|
||||
self._artist = None
|
||||
self._albumart = None
|
||||
self.update()
|
||||
|
||||
def get_ws(self):
|
||||
"""Check if the websocket is setup and connected."""
|
||||
if self._ws is None:
|
||||
try:
|
||||
self._ws = self._connection(("ws://" + self._address +
|
||||
":5672"), timeout=1)
|
||||
except (socket.timeout, ConnectionRefusedError,
|
||||
ConnectionResetError):
|
||||
self._ws = None
|
||||
elif self._ws.connected is True:
|
||||
self._ws.close()
|
||||
try:
|
||||
self._ws = self._connection(("ws://" + self._address +
|
||||
":5672"), timeout=1)
|
||||
except (socket.timeout, ConnectionRefusedError,
|
||||
ConnectionResetError):
|
||||
self._ws = None
|
||||
return self._ws
|
||||
|
||||
def update(self):
|
||||
"""Get the latest details from the player."""
|
||||
websocket = self.get_ws()
|
||||
if websocket is None:
|
||||
self._status = STATE_OFF
|
||||
return
|
||||
else:
|
||||
state = websocket.recv()
|
||||
state = ((json.loads(state))['payload'])
|
||||
if state is True:
|
||||
websocket.recv()
|
||||
websocket.recv()
|
||||
song = websocket.recv()
|
||||
song = json.loads(song)
|
||||
self._title = (song['payload']['title'])
|
||||
self._artist = (song['payload']['artist'])
|
||||
self._albumart = (song['payload']['albumArt'])
|
||||
self._status = STATE_PLAYING
|
||||
elif state is False:
|
||||
self._status = STATE_PAUSED
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
"""Content type of current playing media."""
|
||||
return MEDIA_TYPE_MUSIC
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
return self._status
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
"""Title of current playing media."""
|
||||
return self._title
|
||||
|
||||
@property
|
||||
def media_artist(self):
|
||||
"""Artist of current playing media (Music track only)."""
|
||||
return self._artist
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
"""Image url of current playing media."""
|
||||
return self._albumart
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def supported_media_commands(self):
|
||||
"""Flag of media commands that are supported."""
|
||||
return SUPPORT_GPMDP
|
||||
|
||||
def media_next_track(self):
|
||||
"""Send media_next command to media player."""
|
||||
websocket = self.get_ws()
|
||||
if websocket is None:
|
||||
return
|
||||
websocket.send('{"namespace": "playback", "method": "forward"}')
|
||||
|
||||
def media_previous_track(self):
|
||||
"""Send media_previous command to media player."""
|
||||
websocket = self.get_ws()
|
||||
if websocket is None:
|
||||
return
|
||||
websocket.send('{"namespace": "playback", "method": "rewind"}')
|
||||
|
||||
def media_play(self):
|
||||
"""Send media_play command to media player."""
|
||||
websocket = self.get_ws()
|
||||
if websocket is None:
|
||||
return
|
||||
websocket.send('{"namespace": "playback", "method": "playPause"}')
|
||||
self._status = STATE_PAUSED
|
||||
self.update_ha_state()
|
||||
|
||||
def media_pause(self):
|
||||
"""Send media_pause command to media player."""
|
||||
websocket = self.get_ws()
|
||||
if websocket is None:
|
||||
return
|
||||
websocket.send('{"namespace": "playback", "method": "playPause"}')
|
||||
self._status = STATE_PAUSED
|
||||
self.update_ha_state()
|
||||
@@ -320,7 +320,7 @@ class ItunesDevice(MediaPlayerDevice):
|
||||
response = self.client.previous()
|
||||
self.update_state(response)
|
||||
|
||||
def play_media(self, media_type, media_id):
|
||||
def play_media(self, media_type, media_id, **kwargs):
|
||||
"""Send the play_media command to the media player."""
|
||||
if media_type == MEDIA_TYPE_PLAYLIST:
|
||||
response = self.client.play_playlist(media_id)
|
||||
|
||||
@@ -10,7 +10,7 @@ import urllib
|
||||
from homeassistant.components.media_player import (
|
||||
SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK,
|
||||
SUPPORT_PLAY_MEDIA, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP,
|
||||
MediaPlayerDevice)
|
||||
SUPPORT_TURN_OFF, MediaPlayerDevice)
|
||||
from homeassistant.const import (
|
||||
STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING)
|
||||
|
||||
@@ -36,7 +36,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
url,
|
||||
auth=(
|
||||
config.get('user', ''),
|
||||
config.get('password', ''))),
|
||||
config.get('password', '')),
|
||||
turn_off_action=config.get('turn_off_action', 'none')),
|
||||
])
|
||||
|
||||
|
||||
@@ -44,7 +45,8 @@ class KodiDevice(MediaPlayerDevice):
|
||||
"""Representation of a XBMC/Kodi device."""
|
||||
|
||||
# pylint: disable=too-many-public-methods, abstract-method
|
||||
def __init__(self, name, url, auth=None):
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
def __init__(self, name, url, auth=None, turn_off_action=None):
|
||||
"""Initialize the Kodi device."""
|
||||
import jsonrpc_requests
|
||||
self._name = name
|
||||
@@ -52,6 +54,7 @@ class KodiDevice(MediaPlayerDevice):
|
||||
self._server = jsonrpc_requests.Server(
|
||||
'{}/jsonrpc'.format(self._url),
|
||||
auth=auth)
|
||||
self._turn_off_action = turn_off_action
|
||||
self._players = list()
|
||||
self._properties = None
|
||||
self._item = None
|
||||
@@ -181,11 +184,29 @@ class KodiDevice(MediaPlayerDevice):
|
||||
@property
|
||||
def supported_media_commands(self):
|
||||
"""Flag of media commands that are supported."""
|
||||
return SUPPORT_KODI
|
||||
supported_media_commands = SUPPORT_KODI
|
||||
|
||||
if self._turn_off_action in [
|
||||
'quit', 'hibernate', 'suspend', 'reboot', 'shutdown']:
|
||||
supported_media_commands |= SUPPORT_TURN_OFF
|
||||
|
||||
return supported_media_commands
|
||||
|
||||
def turn_off(self):
|
||||
"""Turn off media player."""
|
||||
self._server.System.Shutdown()
|
||||
"""Execute turn_off_action to turn off media player."""
|
||||
if self._turn_off_action == 'quit':
|
||||
self._server.Application.Quit()
|
||||
elif self._turn_off_action == 'hibernate':
|
||||
self._server.System.Hibernate()
|
||||
elif self._turn_off_action == 'suspend':
|
||||
self._server.System.Suspend()
|
||||
elif self._turn_off_action == 'reboot':
|
||||
self._server.System.Reboot()
|
||||
elif self._turn_off_action == 'shutdown':
|
||||
self._server.System.Shutdown()
|
||||
else:
|
||||
_LOGGER.warning('turn_off requested but turn_off_action is none')
|
||||
|
||||
self.update_ha_state()
|
||||
|
||||
def volume_up(self):
|
||||
@@ -278,6 +299,6 @@ class KodiDevice(MediaPlayerDevice):
|
||||
|
||||
self.update_ha_state()
|
||||
|
||||
def play_media(self, media_type, media_id):
|
||||
def play_media(self, media_type, media_id, **kwargs):
|
||||
"""Send the play_media command to the media player."""
|
||||
self._server.Player.Open({media_type: media_id}, {})
|
||||
|
||||
211
homeassistant/components/media_player/lg_netcast.py
Normal file
211
homeassistant/components/media_player/lg_netcast.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Support for LG TV running on NetCast 3 or 4.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/media_player.lg_netcast/
|
||||
"""
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from requests import RequestException
|
||||
import voluptuous as vol
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.media_player import (
|
||||
SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK,
|
||||
SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP,
|
||||
SUPPORT_SELECT_SOURCE, MEDIA_TYPE_CHANNEL, MediaPlayerDevice)
|
||||
from homeassistant.const import (
|
||||
CONF_PLATFORM, CONF_HOST, CONF_NAME, CONF_ACCESS_TOKEN,
|
||||
STATE_OFF, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN)
|
||||
import homeassistant.util as util
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
REQUIREMENTS = ['https://github.com/wokar/pylgnetcast/archive/'
|
||||
'v0.2.0.zip#pylgnetcast==0.2.0']
|
||||
|
||||
SUPPORT_LGTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \
|
||||
SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \
|
||||
SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE
|
||||
|
||||
MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10)
|
||||
MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1)
|
||||
|
||||
DEFAULT_NAME = 'LG TV Remote'
|
||||
|
||||
PLATFORM_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_PLATFORM): "lg_netcast",
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_ACCESS_TOKEN, default=None):
|
||||
vol.All(cv.string, vol.Length(max=6)),
|
||||
})
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup the LG TV platform."""
|
||||
from pylgnetcast import LgNetCastClient
|
||||
client = LgNetCastClient(config[CONF_HOST], config[CONF_ACCESS_TOKEN])
|
||||
add_devices([LgTVDevice(client, config[CONF_NAME])])
|
||||
|
||||
|
||||
# pylint: disable=too-many-public-methods, abstract-method
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class LgTVDevice(MediaPlayerDevice):
|
||||
"""Representation of a LG TV."""
|
||||
|
||||
def __init__(self, client, name):
|
||||
"""Initialize the LG TV device."""
|
||||
self._client = client
|
||||
self._name = name
|
||||
self._muted = False
|
||||
# Assume that the TV is in Play mode
|
||||
self._playing = True
|
||||
self._volume = 0
|
||||
self._channel_name = ''
|
||||
self._program_name = ''
|
||||
self._state = STATE_UNKNOWN
|
||||
self._sources = {}
|
||||
self._source_names = []
|
||||
|
||||
self.update()
|
||||
|
||||
def send_command(self, command):
|
||||
"""Send remote control commands to the TV."""
|
||||
from pylgnetcast import LgNetCastError
|
||||
try:
|
||||
with self._client as client:
|
||||
client.send_command(command)
|
||||
except (LgNetCastError, RequestException):
|
||||
self._state = STATE_OFF
|
||||
|
||||
@util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS)
|
||||
def update(self):
|
||||
"""Retrieve the latest data from the LG TV."""
|
||||
from pylgnetcast import LgNetCastError
|
||||
try:
|
||||
with self._client as client:
|
||||
self._state = STATE_PLAYING
|
||||
volume_info = client.query_data('volume_info')
|
||||
if volume_info:
|
||||
volume_info = volume_info[0]
|
||||
self._volume = float(volume_info.find('level').text)
|
||||
self._muted = volume_info.find('mute').text == 'true'
|
||||
|
||||
channel_info = client.query_data('cur_channel')
|
||||
if channel_info:
|
||||
channel_info = channel_info[0]
|
||||
self._channel_name = channel_info.find('chname').text
|
||||
self._program_name = channel_info.find('progName').text
|
||||
|
||||
channel_list = client.query_data('channel_list')
|
||||
if channel_list:
|
||||
channel_names = [str(c.find('chname').text) for
|
||||
c in channel_list]
|
||||
self._sources = dict(zip(channel_names, channel_list))
|
||||
# sort source names by the major channel number
|
||||
source_tuples = [(k, self._sources[k].find('major').text)
|
||||
for k in self._sources.keys()]
|
||||
sorted_sources = sorted(
|
||||
source_tuples, key=lambda channel: int(channel[1]))
|
||||
self._source_names = [n for n, k in sorted_sources]
|
||||
except (LgNetCastError, RequestException):
|
||||
self._state = STATE_OFF
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def is_volume_muted(self):
|
||||
"""Boolean if volume is currently muted."""
|
||||
return self._muted
|
||||
|
||||
@property
|
||||
def volume_level(self):
|
||||
"""Volume level of the media player (0..1)."""
|
||||
return self._volume / 100.0
|
||||
|
||||
@property
|
||||
def source(self):
|
||||
"""Return the current input source."""
|
||||
return self._channel_name
|
||||
|
||||
@property
|
||||
def source_list(self):
|
||||
"""List of available input sources."""
|
||||
return self._source_names
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
"""Content type of current playing media."""
|
||||
return MEDIA_TYPE_CHANNEL
|
||||
|
||||
@property
|
||||
def media_channel(self):
|
||||
"""Channel currently playing."""
|
||||
return self._channel_name
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
"""Title of current playing media."""
|
||||
return self._program_name
|
||||
|
||||
@property
|
||||
def supported_media_commands(self):
|
||||
"""Flag of media commands that are supported."""
|
||||
return SUPPORT_LGTV
|
||||
|
||||
def turn_off(self):
|
||||
"""Turn off media player."""
|
||||
self.send_command(1)
|
||||
|
||||
def volume_up(self):
|
||||
"""Volume up the media player."""
|
||||
self.send_command(24)
|
||||
|
||||
def volume_down(self):
|
||||
"""Volume down media player."""
|
||||
self.send_command(25)
|
||||
|
||||
def mute_volume(self, mute):
|
||||
"""Send mute command."""
|
||||
self.send_command(26)
|
||||
|
||||
def select_source(self, source):
|
||||
"""Select input source."""
|
||||
self._client.change_channel(self._sources[source])
|
||||
|
||||
def media_play_pause(self):
|
||||
"""Simulate play pause media player."""
|
||||
if self._playing:
|
||||
self.media_pause()
|
||||
else:
|
||||
self.media_play()
|
||||
|
||||
def media_play(self):
|
||||
"""Send play command."""
|
||||
self._playing = True
|
||||
self._state = STATE_PLAYING
|
||||
self.send_command(33)
|
||||
|
||||
def media_pause(self):
|
||||
"""Send media pause command to media player."""
|
||||
self._playing = False
|
||||
self._state = STATE_PAUSED
|
||||
self.send_command(34)
|
||||
|
||||
def media_next_track(self):
|
||||
"""Send next track command."""
|
||||
self.send_command(36)
|
||||
|
||||
def media_previous_track(self):
|
||||
"""Send the previous track command."""
|
||||
self.send_command(37)
|
||||
@@ -89,7 +89,13 @@ class MpdDevice(MediaPlayerDevice):
|
||||
try:
|
||||
self.status = self.client.status()
|
||||
self.currentsong = self.client.currentsong()
|
||||
except mpd.ConnectionError:
|
||||
except (mpd.ConnectionError, BrokenPipeError, ValueError):
|
||||
# Cleanly disconnect in case connection is not in valid state
|
||||
try:
|
||||
self.client.disconnect()
|
||||
except mpd.ConnectionError:
|
||||
pass
|
||||
|
||||
self.client.connect(self.server, self.port)
|
||||
|
||||
if self.password is not None:
|
||||
@@ -206,7 +212,7 @@ class MpdDevice(MediaPlayerDevice):
|
||||
"""Service to send the MPD the command for previous track."""
|
||||
self.client.previous()
|
||||
|
||||
def play_media(self, media_type, media_id):
|
||||
def play_media(self, media_type, media_id, **kwargs):
|
||||
"""Send the media player the command for playing a playlist."""
|
||||
_LOGGER.info(str.format("Playing playlist: {0}", media_id))
|
||||
if media_type == MEDIA_TYPE_PLAYLIST:
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
from homeassistant.components.media_player import (
|
||||
SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
|
||||
SUPPORT_SELECT_SOURCE, MediaPlayerDevice)
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, CONF_HOST, CONF_NAME
|
||||
|
||||
REQUIREMENTS = ['https://github.com/danieljkemp/onkyo-eiscp/archive/'
|
||||
'python3.zip#onkyo-eiscp==0.9.2']
|
||||
@@ -17,29 +17,59 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SUPPORT_ONKYO = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
|
||||
SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE
|
||||
KNOWN_HOSTS = []
|
||||
DEFAULT_SOURCES = {"tv": "TV", "bd": "Bluray", "game": "Game", "aux1": "Aux1",
|
||||
"video1": "Video 1", "video2": "Video 2",
|
||||
"video3": "Video 3", "video4": "Video 4",
|
||||
"video5": "Video 5", "video6": "Video 6",
|
||||
"video7": "Video 7"}
|
||||
CONFIG_SOURCE_LIST = "sources"
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup the Onkyo platform."""
|
||||
import eiscp
|
||||
from eiscp import eISCP
|
||||
add_devices(OnkyoDevice(receiver)
|
||||
for receiver in eISCP.discover())
|
||||
hosts = []
|
||||
|
||||
if CONF_HOST in config and config[CONF_HOST] not in KNOWN_HOSTS:
|
||||
try:
|
||||
hosts.append(OnkyoDevice(eiscp.eISCP(config[CONF_HOST]),
|
||||
config.get(CONFIG_SOURCE_LIST,
|
||||
DEFAULT_SOURCES),
|
||||
name=config[CONF_NAME]))
|
||||
KNOWN_HOSTS.append(config[CONF_HOST])
|
||||
except OSError:
|
||||
_LOGGER.error('Unable to connect to receiver at %s.',
|
||||
config[CONF_HOST])
|
||||
else:
|
||||
for receiver in eISCP.discover():
|
||||
if receiver.host not in KNOWN_HOSTS:
|
||||
hosts.append(OnkyoDevice(receiver,
|
||||
config.get(CONFIG_SOURCE_LIST,
|
||||
DEFAULT_SOURCES)))
|
||||
KNOWN_HOSTS.append(receiver.host)
|
||||
add_devices(hosts)
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class OnkyoDevice(MediaPlayerDevice):
|
||||
"""Representation of a Onkyo device."""
|
||||
|
||||
# pylint: disable=too-many-public-methods, abstract-method
|
||||
def __init__(self, receiver):
|
||||
def __init__(self, receiver, sources, name=None):
|
||||
"""Initialize the Onkyo Receiver."""
|
||||
self._receiver = receiver
|
||||
self._muted = False
|
||||
self._volume = 0
|
||||
self._pwstate = STATE_OFF
|
||||
self.update()
|
||||
self._name = '{}_{}'.format(
|
||||
self._name = name or '{}_{}'.format(
|
||||
receiver.info['model_name'], receiver.info['identifier'])
|
||||
self._current_source = None
|
||||
self._source_list = list(sources.values())
|
||||
self._source_mapping = sources
|
||||
self._reverse_mapping = {value: key for key, value in sources.items()}
|
||||
self.update()
|
||||
|
||||
def update(self):
|
||||
"""Get the latest details from the device."""
|
||||
@@ -52,8 +82,13 @@ class OnkyoDevice(MediaPlayerDevice):
|
||||
volume_raw = self._receiver.command('volume query')
|
||||
mute_raw = self._receiver.command('audio-muting query')
|
||||
current_source_raw = self._receiver.command('input-selector query')
|
||||
self._current_source = '_'.join('_'.join(
|
||||
[i for i in current_source_raw[1]]))
|
||||
for source in current_source_raw[1]:
|
||||
if source in self._source_mapping:
|
||||
self._current_source = self._source_mapping[source]
|
||||
break
|
||||
else:
|
||||
self._current_source = '_'.join(
|
||||
[i for i in current_source_raw[1]])
|
||||
self._muted = bool(mute_raw[1] == 'on')
|
||||
self._volume = int(volume_raw[1], 16)/80.0
|
||||
|
||||
@@ -87,6 +122,11 @@ class OnkyoDevice(MediaPlayerDevice):
|
||||
""""Return the current input source of the device."""
|
||||
return self._current_source
|
||||
|
||||
@property
|
||||
def source_list(self):
|
||||
"""List of available input sources."""
|
||||
return self._source_list
|
||||
|
||||
def turn_off(self):
|
||||
"""Turn off media player."""
|
||||
self._receiver.command('system-power standby')
|
||||
@@ -108,4 +148,6 @@ class OnkyoDevice(MediaPlayerDevice):
|
||||
|
||||
def select_source(self, source):
|
||||
"""Set the input source."""
|
||||
if source in self._source_list:
|
||||
source = self._reverse_mapping[source]
|
||||
self._receiver.command('input-selector {}'.format(source))
|
||||
|
||||
205
homeassistant/components/media_player/roku.py
Normal file
205
homeassistant/components/media_player/roku.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Support for the roku media player.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/media_player.roku/
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MEDIA_TYPE_VIDEO, SUPPORT_NEXT_TRACK, SUPPORT_PLAY_MEDIA,
|
||||
SUPPORT_PREVIOUS_TRACK, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
|
||||
SUPPORT_SELECT_SOURCE, MediaPlayerDevice)
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, STATE_IDLE, STATE_PLAYING, STATE_UNKNOWN, STATE_HOME)
|
||||
|
||||
REQUIREMENTS = [
|
||||
'https://github.com/bah2830/python-roku/archive/3.1.1.zip'
|
||||
'#python-roku==3.1.1']
|
||||
|
||||
KNOWN_HOSTS = []
|
||||
DEFAULT_PORT = 8060
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SUPPORT_ROKU = SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK |\
|
||||
SUPPORT_PLAY_MEDIA | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\
|
||||
SUPPORT_SELECT_SOURCE
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup the Roku platform."""
|
||||
hosts = []
|
||||
|
||||
if discovery_info and discovery_info in KNOWN_HOSTS:
|
||||
return
|
||||
|
||||
if discovery_info is not None:
|
||||
_LOGGER.debug('Discovered Roku: %s', discovery_info[0])
|
||||
hosts.append(discovery_info[0])
|
||||
|
||||
elif CONF_HOST in config:
|
||||
hosts.append(config[CONF_HOST])
|
||||
|
||||
rokus = []
|
||||
for host in hosts:
|
||||
rokus.append(RokuDevice(host))
|
||||
KNOWN_HOSTS.append(host)
|
||||
|
||||
add_devices(rokus)
|
||||
|
||||
|
||||
class RokuDevice(MediaPlayerDevice):
|
||||
"""Representation of a Roku device on the network."""
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
# pylint: disable=too-many-public-methods
|
||||
def __init__(self, host):
|
||||
"""Initialize the Roku device."""
|
||||
from roku import Roku
|
||||
|
||||
self.roku = Roku(host)
|
||||
self.update()
|
||||
|
||||
def update(self):
|
||||
"""Retrieve latest state."""
|
||||
import requests.exceptions
|
||||
|
||||
try:
|
||||
self.roku_name = "roku_" + self.roku.device_info.sernum
|
||||
self.ip_address = self.roku.host
|
||||
self.channels = self.get_source_list()
|
||||
|
||||
if self.roku.current_app is not None:
|
||||
self.current_app = self.roku.current_app
|
||||
else:
|
||||
self.current_app = None
|
||||
except requests.exceptions.ConnectionError:
|
||||
self.current_app = None
|
||||
|
||||
def get_source_list(self):
|
||||
"""Get the list of applications to be used as sources."""
|
||||
return ["Home"] + sorted(channel.name for channel in self.roku.apps)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""Device should be polled."""
|
||||
return True
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self.roku_name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the device."""
|
||||
if self.current_app is None:
|
||||
return STATE_UNKNOWN
|
||||
|
||||
if self.current_app.name in ["Power Saver", "Default screensaver"]:
|
||||
return STATE_IDLE
|
||||
elif self.current_app.name == "Roku":
|
||||
return STATE_HOME
|
||||
elif self.current_app.name is not None:
|
||||
return STATE_PLAYING
|
||||
|
||||
return STATE_UNKNOWN
|
||||
|
||||
@property
|
||||
def supported_media_commands(self):
|
||||
"""Flag of media commands that are supported."""
|
||||
return SUPPORT_ROKU
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
"""Content type of current playing media."""
|
||||
if self.current_app is None:
|
||||
return None
|
||||
elif self.current_app.name == "Power Saver":
|
||||
return None
|
||||
elif self.current_app.name == "Roku":
|
||||
return None
|
||||
else:
|
||||
return MEDIA_TYPE_VIDEO
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
"""Image url of current playing media."""
|
||||
if self.current_app is None:
|
||||
return None
|
||||
elif self.current_app.name == "Roku":
|
||||
return None
|
||||
elif self.current_app.name == "Power Saver":
|
||||
return None
|
||||
elif self.current_app.id is None:
|
||||
return None
|
||||
|
||||
return 'http://{0}:{1}/query/icon/{2}'.format(self.ip_address,
|
||||
DEFAULT_PORT,
|
||||
self.current_app.id)
|
||||
|
||||
@property
|
||||
def app_name(self):
|
||||
"""Name of the current running app."""
|
||||
if self.current_app is not None:
|
||||
return self.current_app.name
|
||||
|
||||
@property
|
||||
def app_id(self):
|
||||
"""Return the ID of the current running app."""
|
||||
if self.current_app is not None:
|
||||
return self.current_app.id
|
||||
|
||||
@property
|
||||
def source(self):
|
||||
"""Return the current input source."""
|
||||
if self.current_app is not None:
|
||||
return self.current_app.name
|
||||
|
||||
@property
|
||||
def source_list(self):
|
||||
"""List of available input sources."""
|
||||
return self.channels
|
||||
|
||||
def media_play_pause(self):
|
||||
"""Send play/pause command."""
|
||||
if self.current_app is not None:
|
||||
self.roku.play()
|
||||
|
||||
def media_previous_track(self):
|
||||
"""Send previous track command."""
|
||||
if self.current_app is not None:
|
||||
self.roku.reverse()
|
||||
|
||||
def media_next_track(self):
|
||||
"""Send next track command."""
|
||||
if self.current_app is not None:
|
||||
self.roku.forward()
|
||||
|
||||
def mute_volume(self, mute):
|
||||
"""Mute the volume."""
|
||||
if self.current_app is not None:
|
||||
self.roku.volume_mute()
|
||||
|
||||
def volume_up(self):
|
||||
"""Volume up media player."""
|
||||
if self.current_app is not None:
|
||||
self.roku.volume_up()
|
||||
|
||||
def volume_down(self):
|
||||
"""Volume down media player."""
|
||||
if self.current_app is not None:
|
||||
self.roku.volume_down()
|
||||
|
||||
def select_source(self, source):
|
||||
"""Select input source."""
|
||||
if self.current_app is not None:
|
||||
if source == "Home":
|
||||
self.roku.home()
|
||||
else:
|
||||
channel = self.roku[source]
|
||||
channel.launch()
|
||||
@@ -145,3 +145,11 @@ select_source:
|
||||
source:
|
||||
description: Name of the source to switch to. Platform dependent.
|
||||
example: 'video1'
|
||||
|
||||
sonos_group_players:
|
||||
description: Send Sonos media player the command for grouping all players into one (party mode).
|
||||
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name(s) of entites that will coordinate the grouping. Platform dependent.
|
||||
example: 'media_player.living_room_sonos'
|
||||
|
||||
@@ -7,14 +7,15 @@ https://home-assistant.io/components/media_player.sonos/
|
||||
import datetime
|
||||
import logging
|
||||
import socket
|
||||
from os import path
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE,
|
||||
SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK,
|
||||
SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET,
|
||||
MediaPlayerDevice)
|
||||
ATTR_MEDIA_ENQUEUE, DOMAIN, MEDIA_TYPE_MUSIC, SUPPORT_NEXT_TRACK,
|
||||
SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK,
|
||||
SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice)
|
||||
from homeassistant.const import (
|
||||
STATE_IDLE, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, STATE_OFF)
|
||||
from homeassistant.config import load_yaml_config_file
|
||||
|
||||
REQUIREMENTS = ['SoCo==0.11.1']
|
||||
|
||||
@@ -32,6 +33,8 @@ SUPPORT_SONOS = SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE |\
|
||||
SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_PLAY_MEDIA |\
|
||||
SUPPORT_SEEK
|
||||
|
||||
SERVICE_GROUP_PLAYERS = 'sonos_group_players'
|
||||
|
||||
|
||||
# pylint: disable=unused-argument
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
@@ -63,9 +66,31 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
_LOGGER.warning('No Sonos speakers found.')
|
||||
return False
|
||||
|
||||
add_devices(SonosDevice(hass, p) for p in players)
|
||||
devices = [SonosDevice(hass, p) for p in players]
|
||||
add_devices(devices)
|
||||
_LOGGER.info('Added %s Sonos speakers', len(players))
|
||||
|
||||
def group_players_service(service):
|
||||
"""Group media players, use player as coordinator."""
|
||||
entity_id = service.data.get('entity_id')
|
||||
|
||||
if entity_id:
|
||||
_devices = [device for device in devices
|
||||
if device.entity_id == entity_id]
|
||||
else:
|
||||
_devices = devices
|
||||
|
||||
for device in _devices:
|
||||
device.group_players()
|
||||
device.update_ha_state(True)
|
||||
|
||||
descriptions = load_yaml_config_file(
|
||||
path.join(path.dirname(__file__), 'services.yaml'))
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_GROUP_PLAYERS,
|
||||
group_players_service,
|
||||
descriptions.get(SERVICE_GROUP_PLAYERS))
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@@ -74,16 +99,26 @@ def only_if_coordinator(func):
|
||||
|
||||
If used as decorator, avoid calling the decorated method if player is not
|
||||
a coordinator. If not, a grouped speaker (not in coordinator role) will
|
||||
throw soco.exceptions.SoCoSlaveException
|
||||
throw soco.exceptions.SoCoSlaveException.
|
||||
|
||||
Also, partially catch exceptions like:
|
||||
|
||||
soco.exceptions.SoCoUPnPException: UPnP Error 701 received:
|
||||
Transition not available from <player ip address>
|
||||
"""
|
||||
def wrapper(*args, **kwargs):
|
||||
"""Decorator wrapper."""
|
||||
if args[0].is_coordinator:
|
||||
return func(*args, **kwargs)
|
||||
from soco.exceptions import SoCoUPnPException
|
||||
try:
|
||||
func(*args, **kwargs)
|
||||
except SoCoUPnPException:
|
||||
_LOGGER.error('command "%s" for Sonos device "%s" '
|
||||
'not available in this mode',
|
||||
func.__name__, args[0].name)
|
||||
else:
|
||||
_LOGGER.debug('Ignore command "%s" for Sonos device "%s" '
|
||||
'(not coordinator)',
|
||||
func.__name__, args[0].name)
|
||||
_LOGGER.debug('Ignore command "%s" for Sonos device "%s" (%s)',
|
||||
func.__name__, args[0].name, 'not coordinator')
|
||||
|
||||
return wrapper
|
||||
|
||||
@@ -104,7 +139,7 @@ class SonosDevice(MediaPlayerDevice):
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
"""Polling needed."""
|
||||
return True
|
||||
|
||||
def update_sonos(self, now):
|
||||
@@ -258,9 +293,27 @@ class SonosDevice(MediaPlayerDevice):
|
||||
self._player.play()
|
||||
|
||||
@only_if_coordinator
|
||||
def play_media(self, media_type, media_id):
|
||||
"""Send the play_media command to the media player."""
|
||||
self._player.play_uri(media_id)
|
||||
def play_media(self, media_type, media_id, **kwargs):
|
||||
"""
|
||||
Send the play_media command to the media player.
|
||||
|
||||
If ATTR_MEDIA_ENQUEUE is True, add `media_id` to the queue.
|
||||
"""
|
||||
if kwargs.get(ATTR_MEDIA_ENQUEUE):
|
||||
from soco.exceptions import SoCoUPnPException
|
||||
try:
|
||||
self._player.add_uri_to_queue(media_id)
|
||||
except SoCoUPnPException:
|
||||
_LOGGER.error('Error parsing media uri "%s", '
|
||||
"please check it's a valid media resource "
|
||||
'supported by Sonos', media_id)
|
||||
else:
|
||||
self._player.play_uri(media_id)
|
||||
|
||||
@only_if_coordinator
|
||||
def group_players(self):
|
||||
"""Group all players under this coordinator."""
|
||||
self._player.partymode()
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
|
||||
@@ -402,7 +402,7 @@ class UniversalMediaPlayer(MediaPlayerDevice):
|
||||
data = {ATTR_MEDIA_SEEK_POSITION: position}
|
||||
self._call_service(SERVICE_MEDIA_SEEK, data)
|
||||
|
||||
def play_media(self, media_type, media_id):
|
||||
def play_media(self, media_type, media_id, **kwargs):
|
||||
"""Play a piece of media."""
|
||||
data = {ATTR_MEDIA_CONTENT_TYPE: media_type,
|
||||
ATTR_MEDIA_CONTENT_ID: media_id}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user