mirror of
https://github.com/home-assistant/core.git
synced 2025-07-18 18:57:06 +00:00
commit
ca973b68e0
26
.coveragerc
26
.coveragerc
@ -62,6 +62,9 @@ omit =
|
||||
homeassistant/components/comfoconnect.py
|
||||
homeassistant/components/*/comfoconnect.py
|
||||
|
||||
homeassistant/components/daikin.py
|
||||
homeassistant/components/*/daikin.py
|
||||
|
||||
homeassistant/components/deconz/*
|
||||
homeassistant/components/*/deconz.py
|
||||
|
||||
@ -82,6 +85,9 @@ omit =
|
||||
homeassistant/components/ecobee.py
|
||||
homeassistant/components/*/ecobee.py
|
||||
|
||||
homeassistant/components/egardia.py
|
||||
homeassistant/components/*/egardia.py
|
||||
|
||||
homeassistant/components/enocean.py
|
||||
homeassistant/components/*/enocean.py
|
||||
|
||||
@ -181,6 +187,9 @@ omit =
|
||||
homeassistant/components/opencv.py
|
||||
homeassistant/components/*/opencv.py
|
||||
|
||||
homeassistant/components/pilight.py
|
||||
homeassistant/components/*/pilight.py
|
||||
|
||||
homeassistant/components/qwikswitch.py
|
||||
homeassistant/components/*/qwikswitch.py
|
||||
|
||||
@ -244,6 +253,9 @@ omit =
|
||||
homeassistant/components/notify/twilio_sms.py
|
||||
homeassistant/components/notify/twilio_call.py
|
||||
|
||||
homeassistant/components/upcloud.py
|
||||
homeassistant/components/*/upcloud.py
|
||||
|
||||
homeassistant/components/usps.py
|
||||
homeassistant/components/*/usps.py
|
||||
|
||||
@ -293,13 +305,9 @@ omit =
|
||||
homeassistant/components/zoneminder.py
|
||||
homeassistant/components/*/zoneminder.py
|
||||
|
||||
homeassistant/components/daikin.py
|
||||
homeassistant/components/*/daikin.py
|
||||
|
||||
homeassistant/components/alarm_control_panel/alarmdotcom.py
|
||||
homeassistant/components/alarm_control_panel/canary.py
|
||||
homeassistant/components/alarm_control_panel/concord232.py
|
||||
homeassistant/components/alarm_control_panel/egardia.py
|
||||
homeassistant/components/alarm_control_panel/ialarm.py
|
||||
homeassistant/components/alarm_control_panel/manual_mqtt.py
|
||||
homeassistant/components/alarm_control_panel/nx584.py
|
||||
@ -312,7 +320,6 @@ omit =
|
||||
homeassistant/components/binary_sensor/hikvision.py
|
||||
homeassistant/components/binary_sensor/iss.py
|
||||
homeassistant/components/binary_sensor/mystrom.py
|
||||
homeassistant/components/binary_sensor/pilight.py
|
||||
homeassistant/components/binary_sensor/ping.py
|
||||
homeassistant/components/binary_sensor/rest.py
|
||||
homeassistant/components/binary_sensor/tapsaff.py
|
||||
@ -353,12 +360,14 @@ omit =
|
||||
homeassistant/components/cover/scsgate.py
|
||||
homeassistant/components/device_tracker/actiontec.py
|
||||
homeassistant/components/device_tracker/aruba.py
|
||||
homeassistant/components/device_tracker/asuswrt.py
|
||||
homeassistant/components/device_tracker/automatic.py
|
||||
homeassistant/components/device_tracker/bbox.py
|
||||
homeassistant/components/device_tracker/bluetooth_le_tracker.py
|
||||
homeassistant/components/device_tracker/bluetooth_tracker.py
|
||||
homeassistant/components/device_tracker/bt_home_hub_5.py
|
||||
homeassistant/components/device_tracker/cisco_ios.py
|
||||
homeassistant/components/device_tracker/ddwrt.py
|
||||
homeassistant/components/device_tracker/fritz.py
|
||||
homeassistant/components/device_tracker/gpslogger.py
|
||||
homeassistant/components/device_tracker/hitron_coda.py
|
||||
@ -464,6 +473,7 @@ omit =
|
||||
homeassistant/components/media_player/russound_rio.py
|
||||
homeassistant/components/media_player/russound_rnet.py
|
||||
homeassistant/components/media_player/snapcast.py
|
||||
homeassistant/components/media_player/songpal.py
|
||||
homeassistant/components/media_player/sonos.py
|
||||
homeassistant/components/media_player/spotify.py
|
||||
homeassistant/components/media_player/squeezebox.py
|
||||
@ -508,6 +518,7 @@ omit =
|
||||
homeassistant/components/notify/simplepush.py
|
||||
homeassistant/components/notify/slack.py
|
||||
homeassistant/components/notify/smtp.py
|
||||
homeassistant/components/notify/synology_chat.py
|
||||
homeassistant/components/notify/syslog.py
|
||||
homeassistant/components/notify/telegram.py
|
||||
homeassistant/components/notify/telstra.py
|
||||
@ -619,10 +630,12 @@ omit =
|
||||
homeassistant/components/sensor/ripple.py
|
||||
homeassistant/components/sensor/sabnzbd.py
|
||||
homeassistant/components/sensor/scrape.py
|
||||
homeassistant/components/sensor/sense.py
|
||||
homeassistant/components/sensor/sensehat.py
|
||||
homeassistant/components/sensor/serial.py
|
||||
homeassistant/components/sensor/serial_pm.py
|
||||
homeassistant/components/sensor/shodan.py
|
||||
homeassistant/components/sensor/simulated.py
|
||||
homeassistant/components/sensor/skybeacon.py
|
||||
homeassistant/components/sensor/sma.py
|
||||
homeassistant/components/sensor/snmp.py
|
||||
@ -639,7 +652,6 @@ omit =
|
||||
homeassistant/components/sensor/sytadin.py
|
||||
homeassistant/components/sensor/tank_utility.py
|
||||
homeassistant/components/sensor/ted5000.py
|
||||
homeassistant/components/sensor/teksavvy.py
|
||||
homeassistant/components/sensor/temper.py
|
||||
homeassistant/components/sensor/tibber.py
|
||||
homeassistant/components/sensor/time_date.py
|
||||
@ -658,6 +670,7 @@ omit =
|
||||
homeassistant/components/sensor/worxlandroid.py
|
||||
homeassistant/components/sensor/xbox_live.py
|
||||
homeassistant/components/sensor/zamg.py
|
||||
homeassistant/components/sensor/zestimate.py
|
||||
homeassistant/components/shiftr.py
|
||||
homeassistant/components/spc.py
|
||||
homeassistant/components/switch/acer_projector.py
|
||||
@ -675,7 +688,6 @@ omit =
|
||||
homeassistant/components/switch/mystrom.py
|
||||
homeassistant/components/switch/netio.py
|
||||
homeassistant/components/switch/orvibo.py
|
||||
homeassistant/components/switch/pilight.py
|
||||
homeassistant/components/switch/pulseaudio_loopback.py
|
||||
homeassistant/components/switch/rainbird.py
|
||||
homeassistant/components/switch/rainmachine.py
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -103,3 +103,6 @@ desktop.ini
|
||||
|
||||
# mypy
|
||||
/.mypy_cache/*
|
||||
|
||||
# Secrets
|
||||
.lokalise_token
|
||||
|
17
.travis.yml
17
.travis.yml
@ -6,12 +6,10 @@ addons:
|
||||
matrix:
|
||||
fast_finish: true
|
||||
include:
|
||||
- python: "3.4.2"
|
||||
- python: "3.5.3"
|
||||
env: TOXENV=lint
|
||||
- python: "3.4.2"
|
||||
- python: "3.5.3"
|
||||
env: TOXENV=pylint
|
||||
- python: "3.4.2"
|
||||
env: TOXENV=py34
|
||||
# - python: "3.5"
|
||||
# env: TOXENV=typing
|
||||
- python: "3.5.3"
|
||||
@ -30,4 +28,15 @@ cache:
|
||||
install: pip install -U tox coveralls
|
||||
language: python
|
||||
script: travis_wait 30 tox --develop
|
||||
services:
|
||||
- docker
|
||||
before_deploy:
|
||||
- docker pull lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7
|
||||
deploy:
|
||||
skip_cleanup: true
|
||||
provider: script
|
||||
script: script/travis_deploy
|
||||
on:
|
||||
branch: dev
|
||||
condition: $TOXENV = lint
|
||||
after_success: coveralls
|
||||
|
4
CODEOWNERS
Executable file → Normal file
4
CODEOWNERS
Executable file → Normal file
@ -43,6 +43,7 @@ homeassistant/components/hassio.py @home-assistant/hassio
|
||||
|
||||
# Individual components
|
||||
homeassistant/components/alarm_control_panel/egardia.py @jeroenterheerdt
|
||||
homeassistant/components/binary_sensor/hikvision.py @mezz64
|
||||
homeassistant/components/bmw_connected_drive.py @ChristianKuehnel
|
||||
homeassistant/components/camera/yi.py @bachya
|
||||
homeassistant/components/climate/ephember.py @ttroy50
|
||||
@ -54,6 +55,7 @@ homeassistant/components/device_tracker/tile.py @bachya
|
||||
homeassistant/components/history_graph.py @andrey-git
|
||||
homeassistant/components/light/tplink.py @rytilahti
|
||||
homeassistant/components/light/yeelight.py @rytilahti
|
||||
homeassistant/components/media_player/emby.py @mezz64
|
||||
homeassistant/components/media_player/kodi.py @armills
|
||||
homeassistant/components/media_player/mediaroom.py @dgomes
|
||||
homeassistant/components/media_player/monoprice.py @etsinko
|
||||
@ -77,6 +79,8 @@ homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi
|
||||
homeassistant/components/*/axis.py @kane610
|
||||
homeassistant/components/*/bmw_connected_drive.py @ChristianKuehnel
|
||||
homeassistant/components/*/broadlink.py @danielhiversen
|
||||
homeassistant/components/eight_sleep.py @mezz64
|
||||
homeassistant/components/*/eight_sleep.py @mezz64
|
||||
homeassistant/components/hive.py @Rendili @KJonline
|
||||
homeassistant/components/*/hive.py @Rendili @KJonline
|
||||
homeassistant/components/homekit/* @cdce8p
|
||||
|
@ -15,7 +15,6 @@ from homeassistant.const import (
|
||||
__version__,
|
||||
EVENT_HOMEASSISTANT_START,
|
||||
REQUIRED_PYTHON_VER,
|
||||
REQUIRED_PYTHON_VER_WIN,
|
||||
RESTART_EXIT_CODE,
|
||||
)
|
||||
|
||||
@ -33,12 +32,7 @@ def attempt_use_uvloop():
|
||||
|
||||
def validate_python() -> None:
|
||||
"""Validate that the right Python version is running."""
|
||||
if sys.platform == "win32" and \
|
||||
sys.version_info[:3] < REQUIRED_PYTHON_VER_WIN:
|
||||
print("Home Assistant requires at least Python {}.{}.{}".format(
|
||||
*REQUIRED_PYTHON_VER_WIN))
|
||||
sys.exit(1)
|
||||
elif sys.version_info[:3] < REQUIRED_PYTHON_VER:
|
||||
if sys.version_info[:3] < REQUIRED_PYTHON_VER:
|
||||
print("Home Assistant requires at least Python {}.{}.{}".format(
|
||||
*REQUIRED_PYTHON_VER))
|
||||
sys.exit(1)
|
||||
|
@ -112,18 +112,13 @@ def async_from_config_dict(config: Dict[str, Any],
|
||||
if not loader.PREPARED:
|
||||
yield from hass.async_add_job(loader.prepare, hass)
|
||||
|
||||
# Make a copy because we are mutating it.
|
||||
config = OrderedDict(config)
|
||||
|
||||
# Merge packages
|
||||
conf_util.merge_packages_config(
|
||||
config, core_config.get(conf_util.CONF_PACKAGES, {}))
|
||||
|
||||
# Make a copy because we are mutating it.
|
||||
# Use OrderedDict in case original one was one.
|
||||
# Convert values to dictionaries if they are None
|
||||
new_config = OrderedDict()
|
||||
for key, value in config.items():
|
||||
new_config[key] = value or {}
|
||||
config = new_config
|
||||
|
||||
hass.config_entries = config_entries.ConfigEntries(hass, config)
|
||||
yield from hass.config_entries.async_load()
|
||||
|
||||
|
@ -156,9 +156,10 @@ def async_setup(hass, config):
|
||||
hass.services.async_register(
|
||||
ha.DOMAIN, SERVICE_TOGGLE, async_handle_turn_service)
|
||||
hass.helpers.intent.async_register(intent.ServiceIntentHandler(
|
||||
intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned on {}"))
|
||||
intent.INTENT_TURN_ON, ha.DOMAIN, SERVICE_TURN_ON, "Turned {} on"))
|
||||
hass.helpers.intent.async_register(intent.ServiceIntentHandler(
|
||||
intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF, "Turned off {}"))
|
||||
intent.INTENT_TURN_OFF, ha.DOMAIN, SERVICE_TURN_OFF,
|
||||
"Turned {} off"))
|
||||
hass.helpers.intent.async_register(intent.ServiceIntentHandler(
|
||||
intent.INTENT_TOGGLE, ha.DOMAIN, SERVICE_TOGGLE, "Toggled {}"))
|
||||
|
||||
|
@ -17,7 +17,7 @@ from homeassistant.const import (
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['pyalarmdotcom==0.3.0']
|
||||
REQUIREMENTS = ['pyalarmdotcom==0.3.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -26,7 +26,7 @@ DEFAULT_HOST = 'localhost'
|
||||
DEFAULT_NAME = 'CONCORD232'
|
||||
DEFAULT_PORT = 5007
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=1)
|
||||
SCAN_INTERVAL = timedelta(seconds=10)
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string,
|
||||
|
@ -4,130 +4,65 @@ Interfaces with Egardia/Woonveilig alarm control panel.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/alarm_control_panel.egardia/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.components.alarm_control_panel as alarm
|
||||
from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, CONF_USERNAME,
|
||||
EVENT_HOMEASSISTANT_STOP, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_UNKNOWN)
|
||||
import homeassistant.exceptions as exc
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
REQUIREMENTS = ['pythonegardia==1.0.26']
|
||||
STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_TRIGGERED)
|
||||
from homeassistant.components.egardia import (
|
||||
EGARDIA_DEVICE, EGARDIA_SERVER,
|
||||
REPORT_SERVER_CODES_IGNORE, CONF_REPORT_SERVER_CODES,
|
||||
CONF_REPORT_SERVER_ENABLED, CONF_REPORT_SERVER_PORT
|
||||
)
|
||||
REQUIREMENTS = ['pythonegardia==1.0.38']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_REPORT_SERVER_CODES = 'report_server_codes'
|
||||
CONF_REPORT_SERVER_ENABLED = 'report_server_enabled'
|
||||
CONF_REPORT_SERVER_PORT = 'report_server_port'
|
||||
CONF_REPORT_SERVER_CODES_IGNORE = 'ignore'
|
||||
CONF_VERSION = 'version'
|
||||
|
||||
DEFAULT_NAME = 'Egardia'
|
||||
DEFAULT_PORT = 80
|
||||
DEFAULT_REPORT_SERVER_ENABLED = False
|
||||
DEFAULT_REPORT_SERVER_PORT = 52010
|
||||
DEFAULT_VERSION = 'GATE-01'
|
||||
DOMAIN = 'egardia'
|
||||
D_EGARDIASRV = 'egardiaserver'
|
||||
|
||||
NOTIFICATION_ID = 'egardia_notification'
|
||||
NOTIFICATION_TITLE = 'Egardia'
|
||||
|
||||
STATES = {
|
||||
'ARM': STATE_ALARM_ARMED_AWAY,
|
||||
'DAY HOME': STATE_ALARM_ARMED_HOME,
|
||||
'DISARM': STATE_ALARM_DISARMED,
|
||||
'HOME': STATE_ALARM_ARMED_HOME,
|
||||
'TRIGGERED': STATE_ALARM_TRIGGERED,
|
||||
'UNKNOWN': STATE_UNKNOWN,
|
||||
'ARMHOME': STATE_ALARM_ARMED_HOME,
|
||||
'TRIGGERED': STATE_ALARM_TRIGGERED
|
||||
}
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_REPORT_SERVER_CODES): vol.All(cv.ensure_list),
|
||||
vol.Optional(CONF_REPORT_SERVER_ENABLED,
|
||||
default=DEFAULT_REPORT_SERVER_ENABLED): cv.boolean,
|
||||
vol.Optional(CONF_REPORT_SERVER_PORT, default=DEFAULT_REPORT_SERVER_PORT):
|
||||
cv.port,
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the Egardia platform."""
|
||||
from pythonegardia import egardiadevice
|
||||
from pythonegardia import egardiaserver
|
||||
|
||||
name = config.get(CONF_NAME)
|
||||
username = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
host = config.get(CONF_HOST)
|
||||
port = config.get(CONF_PORT)
|
||||
rs_enabled = config.get(CONF_REPORT_SERVER_ENABLED)
|
||||
rs_port = config.get(CONF_REPORT_SERVER_PORT)
|
||||
rs_codes = config.get(CONF_REPORT_SERVER_CODES)
|
||||
version = config.get(CONF_VERSION)
|
||||
|
||||
try:
|
||||
egardiasystem = egardiadevice.EgardiaDevice(
|
||||
host, port, username, password, '', version)
|
||||
except requests.exceptions.RequestException:
|
||||
raise exc.PlatformNotReady()
|
||||
except egardiadevice.UnauthorizedError:
|
||||
_LOGGER.error("Unable to authorize. Wrong password or username")
|
||||
return
|
||||
|
||||
eg_dev = EgardiaAlarm(
|
||||
name, egardiasystem, rs_enabled, rs_codes)
|
||||
|
||||
if rs_enabled:
|
||||
# Set up the egardia server
|
||||
_LOGGER.info("Setting up EgardiaServer")
|
||||
try:
|
||||
if D_EGARDIASRV not in hass.data:
|
||||
server = egardiaserver.EgardiaServer('', rs_port)
|
||||
bound = server.bind()
|
||||
if not bound:
|
||||
raise IOError(
|
||||
"Binding error occurred while starting EgardiaServer")
|
||||
hass.data[D_EGARDIASRV] = server
|
||||
server.start()
|
||||
except IOError:
|
||||
return
|
||||
hass.data[D_EGARDIASRV].register_callback(eg_dev.handle_status_event)
|
||||
|
||||
def handle_stop_event(event):
|
||||
"""Call function for Home Assistant stop event."""
|
||||
hass.data[D_EGARDIASRV].stop()
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop_event)
|
||||
|
||||
add_devices([eg_dev], True)
|
||||
device = EgardiaAlarm(
|
||||
discovery_info['name'],
|
||||
hass.data[EGARDIA_DEVICE],
|
||||
discovery_info[CONF_REPORT_SERVER_ENABLED],
|
||||
discovery_info.get(CONF_REPORT_SERVER_CODES),
|
||||
discovery_info[CONF_REPORT_SERVER_PORT])
|
||||
# add egardia alarm device
|
||||
add_devices([device], True)
|
||||
|
||||
|
||||
class EgardiaAlarm(alarm.AlarmControlPanel):
|
||||
"""Representation of a Egardia alarm."""
|
||||
|
||||
def __init__(self, name, egardiasystem, rs_enabled=False, rs_codes=None):
|
||||
def __init__(self, name, egardiasystem,
|
||||
rs_enabled=False, rs_codes=None, rs_port=52010):
|
||||
"""Initialize the Egardia alarm."""
|
||||
self._name = name
|
||||
self._egardiasystem = egardiasystem
|
||||
self._status = None
|
||||
self._rs_enabled = rs_enabled
|
||||
if rs_codes is not None:
|
||||
self._rs_codes = rs_codes[0]
|
||||
else:
|
||||
self._rs_codes = rs_codes
|
||||
self._rs_codes = rs_codes
|
||||
self._rs_port = rs_port
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Add Egardiaserver callback if enabled."""
|
||||
if self._rs_enabled:
|
||||
_LOGGER.debug("Registering callback to Egardiaserver")
|
||||
self.hass.data[EGARDIA_SERVER].register_callback(
|
||||
self.handle_status_event)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
@ -156,31 +91,20 @@ class EgardiaAlarm(alarm.AlarmControlPanel):
|
||||
|
||||
def lookupstatusfromcode(self, statuscode):
|
||||
"""Look at the rs_codes and returns the status from the code."""
|
||||
status = 'UNKNOWN'
|
||||
if self._rs_codes is not None:
|
||||
statuscode = str(statuscode).strip()
|
||||
for i in self._rs_codes:
|
||||
val = str(self._rs_codes[i]).strip()
|
||||
if ',' in val:
|
||||
splitted = val.split(',')
|
||||
for code in splitted:
|
||||
code = str(code).strip()
|
||||
if statuscode == code:
|
||||
status = i.upper()
|
||||
break
|
||||
elif statuscode == val:
|
||||
status = i.upper()
|
||||
break
|
||||
status = next((
|
||||
status_group.upper() for status_group, codes
|
||||
in self._rs_codes.items() for code in codes
|
||||
if statuscode == code), 'UNKNOWN')
|
||||
return status
|
||||
|
||||
def parsestatus(self, status):
|
||||
"""Parse the status."""
|
||||
_LOGGER.debug("Parsing status %s", status)
|
||||
# Ignore the statuscode if it is IGNORE
|
||||
if status.lower().strip() != CONF_REPORT_SERVER_CODES_IGNORE:
|
||||
_LOGGER.debug("Not ignoring status")
|
||||
newstatus = ([v for k, v in STATES.items()
|
||||
if status.upper() == k][0])
|
||||
if status.lower().strip() != REPORT_SERVER_CODES_IGNORE:
|
||||
_LOGGER.debug("Not ignoring status %s", status)
|
||||
newstatus = STATES.get(status.upper())
|
||||
_LOGGER.debug("newstatus %s", newstatus)
|
||||
self._status = newstatus
|
||||
else:
|
||||
_LOGGER.error("Ignoring status")
|
||||
|
@ -131,8 +131,7 @@ class APIEventStream(HomeAssistantView):
|
||||
msg = "data: {}\n\n".format(payload)
|
||||
_LOGGER.debug('STREAM %s WRITING %s', id(stop_obj),
|
||||
msg.strip())
|
||||
response.write(msg.encode("UTF-8"))
|
||||
yield from response.drain()
|
||||
yield from response.write(msg.encode("UTF-8"))
|
||||
except asyncio.TimeoutError:
|
||||
yield from to_write.put(STREAM_PING_PAYLOAD)
|
||||
|
||||
|
@ -21,7 +21,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_CONFIGURING = {}
|
||||
|
||||
REQUIREMENTS = ['py-august==0.3.0']
|
||||
REQUIREMENTS = ['py-august==0.4.0']
|
||||
|
||||
DEFAULT_TIMEOUT = 10
|
||||
ACTIVITY_FETCH_LIMIT = 10
|
||||
@ -159,7 +159,7 @@ class AugustData:
|
||||
self._api = api
|
||||
self._access_token = access_token
|
||||
self._doorbells = self._api.get_doorbells(self._access_token) or []
|
||||
self._locks = self._api.get_locks(self._access_token) or []
|
||||
self._locks = self._api.get_operable_locks(self._access_token) or []
|
||||
self._house_ids = [d.house_id for d in self._doorbells + self._locks]
|
||||
|
||||
self._doorbell_detail_by_id = {}
|
||||
|
@ -4,7 +4,7 @@ Component to interface with binary sensors.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor/
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
@ -28,6 +28,7 @@ DEVICE_CLASSES = [
|
||||
'gas', # On means gas detected, Off means no gas (clear)
|
||||
'heat', # On means hot, Off means normal
|
||||
'light', # On means light detected, Off means no light
|
||||
'lock', # On means open (unlocked), Off means closed (locked)
|
||||
'moisture', # On means wet, Off means dry
|
||||
'motion', # On means motion detected, Off means no motion (clear)
|
||||
'moving', # On means moving, Off means not moving (stopped)
|
||||
@ -47,13 +48,12 @@ DEVICE_CLASSES = [
|
||||
DEVICE_CLASSES_SCHEMA = vol.All(vol.Lower, vol.In(DEVICE_CLASSES))
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
async def async_setup(hass, config):
|
||||
"""Track states and offer events for binary sensors."""
|
||||
component = EntityComponent(
|
||||
logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL)
|
||||
|
||||
yield from component.async_setup(config)
|
||||
await component.async_setup(config)
|
||||
return True
|
||||
|
||||
|
||||
|
@ -27,7 +27,7 @@ DEFAULT_NAME = 'Alarm'
|
||||
DEFAULT_PORT = '5007'
|
||||
DEFAULT_SSL = False
|
||||
|
||||
SCAN_INTERVAL = datetime.timedelta(seconds=1)
|
||||
SCAN_INTERVAL = datetime.timedelta(seconds=10)
|
||||
|
||||
ZONE_TYPES_SCHEMA = vol.Schema({
|
||||
cv.positive_int: vol.In(DEVICE_CLASSES),
|
||||
|
78
homeassistant/components/binary_sensor/egardia.py
Normal file
78
homeassistant/components/binary_sensor/egardia.py
Normal file
@ -0,0 +1,78 @@
|
||||
"""
|
||||
Interfaces with Egardia/Woonveilig alarm control panel.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.egardia/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.const import STATE_ON, STATE_OFF
|
||||
from homeassistant.components.egardia import (
|
||||
EGARDIA_DEVICE, ATTR_DISCOVER_DEVICES)
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
EGARDIA_TYPE_TO_DEVICE_CLASS = {'IR Sensor': 'motion',
|
||||
'Door Contact': 'opening',
|
||||
'IR': 'motion'}
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Initialize the platform."""
|
||||
if (discovery_info is None or
|
||||
discovery_info[ATTR_DISCOVER_DEVICES] is None):
|
||||
return
|
||||
|
||||
disc_info = discovery_info[ATTR_DISCOVER_DEVICES]
|
||||
# multiple devices here!
|
||||
async_add_devices(
|
||||
(
|
||||
EgardiaBinarySensor(
|
||||
sensor_id=disc_info[sensor]['id'],
|
||||
name=disc_info[sensor]['name'],
|
||||
egardia_system=hass.data[EGARDIA_DEVICE],
|
||||
device_class=EGARDIA_TYPE_TO_DEVICE_CLASS.get(
|
||||
disc_info[sensor]['type'], None)
|
||||
)
|
||||
for sensor in disc_info
|
||||
), True)
|
||||
|
||||
|
||||
class EgardiaBinarySensor(BinarySensorDevice):
|
||||
"""Represents a sensor based on an Egardia sensor (IR, Door Contact)."""
|
||||
|
||||
def __init__(self, sensor_id, name, egardia_system, device_class):
|
||||
"""Initialize the sensor device."""
|
||||
self._id = sensor_id
|
||||
self._name = name
|
||||
self._state = None
|
||||
self._device_class = device_class
|
||||
self._egardia_system = egardia_system
|
||||
|
||||
def update(self):
|
||||
"""Update the status."""
|
||||
egardia_input = self._egardia_system.getsensorstate(self._id)
|
||||
self._state = STATE_ON if egardia_input else STATE_OFF
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""The name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Whether the device is switched on."""
|
||||
return self._state == STATE_ON
|
||||
|
||||
@property
|
||||
def hidden(self):
|
||||
"""Whether the device is hidden by default."""
|
||||
# these type of sensors are probably mainly used for automations
|
||||
return True
|
||||
|
||||
@property
|
||||
def device_class(self):
|
||||
"""The device class."""
|
||||
return self._device_class
|
@ -18,7 +18,7 @@ from homeassistant.const import (
|
||||
CONF_SSL, EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START,
|
||||
ATTR_LAST_TRIP_TIME, CONF_CUSTOMIZE)
|
||||
|
||||
REQUIREMENTS = ['pyhik==0.1.4']
|
||||
REQUIREMENTS = ['pyhik==0.1.8']
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_IGNORED = 'ignored'
|
||||
@ -48,6 +48,9 @@ DEVICE_CLASS_MAP = {
|
||||
'Face Detection': 'motion',
|
||||
'Scene Change Detection': 'motion',
|
||||
'I/O': None,
|
||||
'Unattended Baggage': 'motion',
|
||||
'Attended Baggage': 'motion',
|
||||
'Recording Failure': None,
|
||||
}
|
||||
|
||||
CUSTOMIZE_SCHEMA = vol.Schema({
|
||||
@ -211,7 +214,7 @@ class HikvisionBinarySensor(BinarySensorDevice):
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return an unique ID."""
|
||||
"""Return a unique ID."""
|
||||
return self._id
|
||||
|
||||
@property
|
||||
|
@ -2,86 +2,56 @@
|
||||
Support for INSTEON dimmers via PowerLinc Modem.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/insteon_plm/
|
||||
https://home-assistant.io/components/binary_sensor.insteon_plm/
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.binary_sensor import BinarySensorDevice
|
||||
from homeassistant.loader import get_component
|
||||
from homeassistant.components.insteon_plm import InsteonPLMEntity
|
||||
|
||||
DEPENDENCIES = ['insteon_plm']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SENSOR_TYPES = {'openClosedSensor': 'opening',
|
||||
'motionSensor': 'motion',
|
||||
'doorSensor': 'door',
|
||||
'leakSensor': 'moisture'}
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the INSTEON PLM device class for the hass platform."""
|
||||
plm = hass.data['insteon_plm']
|
||||
|
||||
device_list = []
|
||||
for device in discovery_info:
|
||||
name = device.get('address')
|
||||
address = device.get('address_hex')
|
||||
address = discovery_info['address']
|
||||
device = plm.devices[address]
|
||||
state_key = discovery_info['state_key']
|
||||
|
||||
_LOGGER.info('Registered %s with binary_sensor platform.', name)
|
||||
_LOGGER.debug('Adding device %s entity %s to Binary Sensor platform',
|
||||
device.address.hex, device.states[state_key].name)
|
||||
|
||||
device_list.append(
|
||||
InsteonPLMBinarySensorDevice(hass, plm, address, name)
|
||||
)
|
||||
new_entity = InsteonPLMBinarySensor(device, state_key)
|
||||
|
||||
async_add_devices(device_list)
|
||||
async_add_devices([new_entity])
|
||||
|
||||
|
||||
class InsteonPLMBinarySensorDevice(BinarySensorDevice):
|
||||
"""A Class for an Insteon device."""
|
||||
class InsteonPLMBinarySensor(InsteonPLMEntity, BinarySensorDevice):
|
||||
"""A Class for an Insteon device entity."""
|
||||
|
||||
def __init__(self, hass, plm, address, name):
|
||||
"""Initialize the binarysensor."""
|
||||
self._hass = hass
|
||||
self._plm = plm.protocol
|
||||
self._address = address
|
||||
self._name = name
|
||||
|
||||
self._plm.add_update_callback(
|
||||
self.async_binarysensor_update, {'address': self._address})
|
||||
def __init__(self, device, state_key):
|
||||
"""Initialize the INSTEON PLM binary sensor."""
|
||||
super().__init__(device, state_key)
|
||||
self._sensor_type = SENSOR_TYPES.get(self._insteon_device_state.name)
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
"""Return the address of the node."""
|
||||
return self._address
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the node."""
|
||||
return self._name
|
||||
def device_class(self):
|
||||
"""Return the class of this sensor."""
|
||||
return self._sensor_type
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return the boolean response if the node is on."""
|
||||
sensorstate = self._plm.get_device_attr(self._address, 'sensorstate')
|
||||
_LOGGER.info("Sensor state for %s is %s", self._address, sensorstate)
|
||||
sensorstate = self._insteon_device_state.value
|
||||
return bool(sensorstate)
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Provide attributes for display on device card."""
|
||||
insteon_plm = get_component('insteon_plm')
|
||||
return insteon_plm.common_attributes(self)
|
||||
|
||||
def get_attr(self, key):
|
||||
"""Return specified attribute for this device."""
|
||||
return self._plm.get_device_attr(self.address, key)
|
||||
|
||||
@callback
|
||||
def async_binarysensor_update(self, message):
|
||||
"""Receive notification from transport that new data exists."""
|
||||
_LOGGER.info("Received update callback from PLM for %s", self._address)
|
||||
self._hass.async_add_job(self.async_update_ha_state())
|
||||
|
@ -56,24 +56,17 @@ def setup_platform(hass, config: ConfigType,
|
||||
else:
|
||||
device_type = _detect_device_type(node)
|
||||
subnode_id = int(node.nid[-1])
|
||||
if device_type == 'opening':
|
||||
# Door/window sensors use an optional "negative" node
|
||||
if subnode_id == 4:
|
||||
if (device_type == 'opening' or device_type == 'moisture'):
|
||||
# These sensors use an optional "negative" subnode 2 to snag
|
||||
# all state changes
|
||||
if subnode_id == 2:
|
||||
parent_device.add_negative_node(node)
|
||||
elif subnode_id == 4:
|
||||
# Subnode 4 is the heartbeat node, which we will represent
|
||||
# as a separate binary_sensor
|
||||
device = ISYBinarySensorHeartbeat(node, parent_device)
|
||||
parent_device.add_heartbeat_device(device)
|
||||
devices.append(device)
|
||||
elif subnode_id == 2:
|
||||
parent_device.add_negative_node(node)
|
||||
elif device_type == 'moisture':
|
||||
# Moisture nodes have a subnode 2, but we ignore it because
|
||||
# it's just the inverse of the primary node.
|
||||
if subnode_id == 4:
|
||||
# Heartbeat node
|
||||
device = ISYBinarySensorHeartbeat(node, parent_device)
|
||||
parent_device.add_heartbeat_device(device)
|
||||
devices.append(device)
|
||||
else:
|
||||
# We don't yet have any special logic for other sensor types,
|
||||
# so add the nodes as individual devices
|
||||
|
@ -4,7 +4,6 @@ Support for KNX/IP binary sensors.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.knx/
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@ -26,6 +25,7 @@ CONF_DEFAULT_HOOK = 'on'
|
||||
CONF_COUNTER = 'counter'
|
||||
CONF_DEFAULT_COUNTER = 1
|
||||
CONF_ACTION = 'action'
|
||||
CONF_RESET_AFTER = 'reset_after'
|
||||
|
||||
CONF__ACTION = 'turn_off_action'
|
||||
|
||||
@ -49,12 +49,13 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_DEVICE_CLASS): cv.string,
|
||||
vol.Optional(CONF_SIGNIFICANT_BIT, default=CONF_DEFAULT_SIGNIFICANT_BIT):
|
||||
cv.positive_int,
|
||||
vol.Optional(CONF_RESET_AFTER): cv.positive_int,
|
||||
vol.Optional(CONF_AUTOMATION): AUTOMATIONS_SCHEMA,
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up binary sensor(s) for KNX platform."""
|
||||
if discovery_info is not None:
|
||||
async_add_devices_discovery(hass, discovery_info, async_add_devices)
|
||||
@ -82,7 +83,8 @@ def async_add_devices_config(hass, config, async_add_devices):
|
||||
name=name,
|
||||
group_address=config.get(CONF_ADDRESS),
|
||||
device_class=config.get(CONF_DEVICE_CLASS),
|
||||
significant_bit=config.get(CONF_SIGNIFICANT_BIT))
|
||||
significant_bit=config.get(CONF_SIGNIFICANT_BIT),
|
||||
reset_after=config.get(CONF_RESET_AFTER))
|
||||
hass.data[DATA_KNX].xknx.devices.add(binary_sensor)
|
||||
|
||||
entity = KNXBinarySensor(hass, binary_sensor)
|
||||
@ -111,11 +113,10 @@ class KNXBinarySensor(BinarySensorDevice):
|
||||
@callback
|
||||
def async_register_callbacks(self):
|
||||
"""Register callbacks to update hass after device was changed."""
|
||||
@asyncio.coroutine
|
||||
def after_update_callback(device):
|
||||
async def after_update_callback(device):
|
||||
"""Call after device was updated."""
|
||||
# pylint: disable=unused-argument
|
||||
yield from self.async_update_ha_state()
|
||||
await self.async_update_ha_state()
|
||||
self.device.register_device_updated_cb(after_update_callback)
|
||||
|
||||
@property
|
||||
|
38
homeassistant/components/binary_sensor/upcloud.py
Normal file
38
homeassistant/components/binary_sensor/upcloud.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""
|
||||
Support for monitoring the state of UpCloud servers.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/binary_sensor.upcloud/
|
||||
"""
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDevice, PLATFORM_SCHEMA)
|
||||
from homeassistant.components.upcloud import (
|
||||
UpCloudServerEntity, CONF_SERVERS, DATA_UPCLOUD)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEPENDENCIES = ['upcloud']
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_SERVERS): vol.All(cv.ensure_list, [cv.string]),
|
||||
})
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the UpCloud server binary sensor."""
|
||||
upcloud = hass.data[DATA_UPCLOUD]
|
||||
|
||||
servers = config.get(CONF_SERVERS)
|
||||
|
||||
devices = [UpCloudBinarySensor(upcloud, uuid) for uuid in servers]
|
||||
|
||||
add_devices(devices, True)
|
||||
|
||||
|
||||
class UpCloudBinarySensor(UpCloudServerEntity, BinarySensorDevice):
|
||||
"""Representation of an UpCloud server sensor."""
|
@ -319,7 +319,10 @@ class XiaomiButton(XiaomiBinarySensor):
|
||||
click_type = 'double'
|
||||
elif value == 'both_click':
|
||||
click_type = 'both'
|
||||
elif value == 'shake':
|
||||
click_type = 'shake'
|
||||
else:
|
||||
_LOGGER.warning("Unsupported click_type detected: %s", value)
|
||||
return False
|
||||
|
||||
self._hass.bus.fire('click', {
|
||||
|
@ -4,7 +4,6 @@ Binary sensors on Zigbee Home Automation networks.
|
||||
For more details on this platform, please refer to the documentation
|
||||
at https://home-assistant.io/components/binary_sensor.zha/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice
|
||||
@ -25,8 +24,8 @@ CLASS_MAPPING = {
|
||||
}
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up the Zigbee Home Automation binary sensors."""
|
||||
discovery_info = zha.get_discovery_info(hass, discovery_info)
|
||||
if discovery_info is None:
|
||||
@ -39,19 +38,19 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
device_class = None
|
||||
cluster = in_clusters[IasZone.cluster_id]
|
||||
if discovery_info['new_join']:
|
||||
yield from cluster.bind()
|
||||
await cluster.bind()
|
||||
ieee = cluster.endpoint.device.application.ieee
|
||||
yield from cluster.write_attributes({'cie_addr': ieee})
|
||||
await cluster.write_attributes({'cie_addr': ieee})
|
||||
|
||||
try:
|
||||
zone_type = yield from cluster['zone_type']
|
||||
zone_type = await cluster['zone_type']
|
||||
device_class = CLASS_MAPPING.get(zone_type, None)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
# If we fail to read from the device, use a non-specific class
|
||||
pass
|
||||
|
||||
sensor = BinarySensor(device_class, **discovery_info)
|
||||
async_add_devices([sensor])
|
||||
async_add_devices([sensor], update_before_add=True)
|
||||
|
||||
|
||||
class BinarySensor(zha.Entity, BinarySensorDevice):
|
||||
@ -66,6 +65,11 @@ class BinarySensor(zha.Entity, BinarySensorDevice):
|
||||
from zigpy.zcl.clusters.security import IasZone
|
||||
self._ias_zone_cluster = self._in_clusters[IasZone.cluster_id]
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Let zha handle polling."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return True if entity is on."""
|
||||
@ -83,7 +87,18 @@ class BinarySensor(zha.Entity, BinarySensorDevice):
|
||||
if command_id == 0:
|
||||
self._state = args[0] & 3
|
||||
_LOGGER.debug("Updated alarm state: %s", self._state)
|
||||
self.schedule_update_ha_state()
|
||||
self.async_schedule_update_ha_state()
|
||||
elif command_id == 1:
|
||||
_LOGGER.debug("Enroll requested")
|
||||
self.hass.add_job(self._ias_zone_cluster.enroll_response(0, 0))
|
||||
res = self._ias_zone_cluster.enroll_response(0, 0)
|
||||
self.hass.async_add_job(res)
|
||||
|
||||
async def async_update(self):
|
||||
"""Retrieve latest state."""
|
||||
from bellows.types.basic import uint16_t
|
||||
|
||||
result = await zha.safe_read(self._endpoint.ias_zone,
|
||||
['zone_status'])
|
||||
state = result.get('zone_status', self._state)
|
||||
if isinstance(state, (int, uint16_t)):
|
||||
self._state = result.get('zone_status', self._state) & 3
|
||||
|
@ -264,9 +264,9 @@ class Camera(Entity):
|
||||
'boundary=--frameboundary')
|
||||
yield from response.prepare(request)
|
||||
|
||||
def write(img_bytes):
|
||||
async def write(img_bytes):
|
||||
"""Write image to stream."""
|
||||
response.write(bytes(
|
||||
await response.write(bytes(
|
||||
'--frameboundary\r\n'
|
||||
'Content-Type: {}\r\n'
|
||||
'Content-Length: {}\r\n\r\n'.format(
|
||||
@ -282,15 +282,14 @@ class Camera(Entity):
|
||||
break
|
||||
|
||||
if img_bytes and img_bytes != last_image:
|
||||
write(img_bytes)
|
||||
yield from write(img_bytes)
|
||||
|
||||
# Chrome seems to always ignore first picture,
|
||||
# print it twice.
|
||||
if last_image is None:
|
||||
write(img_bytes)
|
||||
yield from write(img_bytes)
|
||||
|
||||
last_image = img_bytes
|
||||
yield from response.drain()
|
||||
|
||||
yield from asyncio.sleep(.5)
|
||||
|
||||
|
@ -33,6 +33,9 @@ DEFAULT_PORT = 5000
|
||||
DEFAULT_USERNAME = 'admin'
|
||||
DEFAULT_PASSWORD = '888888'
|
||||
DEFAULT_ARGUMENTS = '-q:v 2'
|
||||
DEFAULT_PROFILE = 0
|
||||
|
||||
CONF_PROFILE = "profile"
|
||||
|
||||
ATTR_PAN = "pan"
|
||||
ATTR_TILT = "tilt"
|
||||
@ -57,6 +60,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_EXTRA_ARGUMENTS, default=DEFAULT_ARGUMENTS): cv.string,
|
||||
vol.Optional(CONF_PROFILE, default=DEFAULT_PROFILE):
|
||||
vol.All(vol.Coerce(int), vol.Range(min=0)),
|
||||
})
|
||||
|
||||
SERVICE_PTZ_SCHEMA = vol.Schema({
|
||||
@ -67,8 +72,7 @@ SERVICE_PTZ_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up a ONVIF camera."""
|
||||
if not hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_HOST)):
|
||||
return
|
||||
@ -91,7 +95,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
|
||||
hass.services.async_register(DOMAIN, SERVICE_PTZ, handle_ptz,
|
||||
schema=SERVICE_PTZ_SCHEMA)
|
||||
async_add_devices([ONVIFHassCamera(hass, config)])
|
||||
add_devices([ONVIFHassCamera(hass, config)])
|
||||
|
||||
|
||||
class ONVIFHassCamera(Camera):
|
||||
@ -114,10 +118,17 @@ class ONVIFHassCamera(Camera):
|
||||
config.get(CONF_USERNAME), config.get(CONF_PASSWORD)
|
||||
)
|
||||
media_service = camera.create_media_service()
|
||||
stream_uri = media_service.GetStreamUri(
|
||||
{'StreamSetup': {'Stream': 'RTP-Unicast', 'Transport': 'RTSP'}}
|
||||
)
|
||||
self._input = stream_uri.Uri.replace(
|
||||
self._profiles = media_service.GetProfiles()
|
||||
self._profile_index = config.get(CONF_PROFILE)
|
||||
if self._profile_index >= len(self._profiles):
|
||||
_LOGGER.warning("ONVIF Camera '%s' doesn't provide profile %d."
|
||||
" Using the last profile.",
|
||||
self._name, self._profile_index)
|
||||
self._profile_index = -1
|
||||
req = media_service.create_type('GetStreamUri')
|
||||
# pylint: disable=protected-access
|
||||
req.ProfileToken = self._profiles[self._profile_index]._token
|
||||
self._input = media_service.GetStreamUri(req).Uri.replace(
|
||||
'rtsp://', 'rtsp://{}:{}@'.format(
|
||||
config.get(CONF_USERNAME),
|
||||
config.get(CONF_PASSWORD)), 1)
|
||||
|
262
homeassistant/components/camera/proxy.py
Normal file
262
homeassistant/components/camera/proxy.py
Normal file
@ -0,0 +1,262 @@
|
||||
"""
|
||||
Proxy camera platform that enables image processing of camera data.
|
||||
|
||||
For more details about this platform, please refer to the documentation
|
||||
https://home-assistant.io/components/proxy
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
import aiohttp
|
||||
import async_timeout
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.util.async import run_coroutine_threadsafe
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
import homeassistant.util.dt as dt_util
|
||||
from homeassistant.const import (
|
||||
CONF_NAME, CONF_ENTITY_ID, HTTP_HEADER_HA_AUTH)
|
||||
from homeassistant.components.camera import (
|
||||
PLATFORM_SCHEMA, Camera)
|
||||
from homeassistant.helpers.aiohttp_client import (
|
||||
async_get_clientsession, async_aiohttp_proxy_web)
|
||||
|
||||
REQUIREMENTS = ['pillow==5.0.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_MAX_IMAGE_WIDTH = "max_image_width"
|
||||
CONF_IMAGE_QUALITY = "image_quality"
|
||||
CONF_IMAGE_REFRESH_RATE = "image_refresh_rate"
|
||||
CONF_FORCE_RESIZE = "force_resize"
|
||||
CONF_MAX_STREAM_WIDTH = "max_stream_width"
|
||||
CONF_STREAM_QUALITY = "stream_quality"
|
||||
CONF_CACHE_IMAGES = "cache_images"
|
||||
|
||||
DEFAULT_BASENAME = "Camera Proxy"
|
||||
DEFAULT_QUALITY = 75
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_MAX_IMAGE_WIDTH): int,
|
||||
vol.Optional(CONF_IMAGE_QUALITY): int,
|
||||
vol.Optional(CONF_IMAGE_REFRESH_RATE): float,
|
||||
vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean,
|
||||
vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean,
|
||||
vol.Optional(CONF_MAX_STREAM_WIDTH): int,
|
||||
vol.Optional(CONF_STREAM_QUALITY): int,
|
||||
})
|
||||
|
||||
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up the Proxy camera platform."""
|
||||
async_add_devices([ProxyCamera(hass, config)])
|
||||
|
||||
|
||||
async def _read_frame(req):
|
||||
"""Read a single frame from an MJPEG stream."""
|
||||
# based on https://gist.github.com/russss/1143799
|
||||
import cgi
|
||||
# Read in HTTP headers:
|
||||
stream = req.content
|
||||
# multipart/x-mixed-replace; boundary=--frameboundary
|
||||
_mimetype, options = cgi.parse_header(req.headers['content-type'])
|
||||
boundary = options.get('boundary').encode('utf-8')
|
||||
if not boundary:
|
||||
_LOGGER.error("Malformed MJPEG missing boundary")
|
||||
raise Exception("Can't find content-type")
|
||||
|
||||
line = await stream.readline()
|
||||
# Seek ahead to the first chunk
|
||||
while line.strip() != boundary:
|
||||
line = await stream.readline()
|
||||
# Read in chunk headers
|
||||
while line.strip() != b'':
|
||||
parts = line.split(b':')
|
||||
if len(parts) > 1 and parts[0].lower() == b'content-length':
|
||||
# Grab chunk length
|
||||
length = int(parts[1].strip())
|
||||
line = await stream.readline()
|
||||
image = await stream.read(length)
|
||||
return image
|
||||
|
||||
|
||||
def _resize_image(image, opts):
|
||||
"""Resize image."""
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
if not opts:
|
||||
return image
|
||||
|
||||
quality = opts.quality or DEFAULT_QUALITY
|
||||
new_width = opts.max_width
|
||||
|
||||
img = Image.open(io.BytesIO(image))
|
||||
imgfmt = str(img.format)
|
||||
if imgfmt != 'PNG' and imgfmt != 'JPEG':
|
||||
_LOGGER.debug("Image is of unsupported type: %s", imgfmt)
|
||||
return image
|
||||
|
||||
(old_width, old_height) = img.size
|
||||
old_size = len(image)
|
||||
if old_width <= new_width:
|
||||
if opts.quality is None:
|
||||
_LOGGER.debug("Image is smaller-than / equal-to requested width")
|
||||
return image
|
||||
new_width = old_width
|
||||
|
||||
scale = new_width / float(old_width)
|
||||
new_height = int((float(old_height)*float(scale)))
|
||||
|
||||
img = img.resize((new_width, new_height), Image.ANTIALIAS)
|
||||
imgbuf = io.BytesIO()
|
||||
img.save(imgbuf, "JPEG", optimize=True, quality=quality)
|
||||
newimage = imgbuf.getvalue()
|
||||
if not opts.force_resize and len(newimage) >= old_size:
|
||||
_LOGGER.debug("Using original image(%d bytes) "
|
||||
"because resized image (%d bytes) is not smaller",
|
||||
old_size, len(newimage))
|
||||
return image
|
||||
|
||||
_LOGGER.debug("Resized image "
|
||||
"from (%dx%d - %d bytes) "
|
||||
"to (%dx%d - %d bytes)",
|
||||
old_width, old_height, old_size,
|
||||
new_width, new_height, len(newimage))
|
||||
return newimage
|
||||
|
||||
|
||||
class ImageOpts():
|
||||
"""The representation of image options."""
|
||||
|
||||
def __init__(self, max_width, quality, force_resize):
|
||||
"""Initialize image options."""
|
||||
self.max_width = max_width
|
||||
self.quality = quality
|
||||
self.force_resize = force_resize
|
||||
|
||||
def __bool__(self):
|
||||
"""Bool evalution rules."""
|
||||
return bool(self.max_width or self.quality)
|
||||
|
||||
|
||||
class ProxyCamera(Camera):
|
||||
"""The representation of a Proxy camera."""
|
||||
|
||||
def __init__(self, hass, config):
|
||||
"""Initialize a proxy camera component."""
|
||||
super().__init__()
|
||||
self.hass = hass
|
||||
self._proxied_camera = config.get(CONF_ENTITY_ID)
|
||||
self._name = (
|
||||
config.get(CONF_NAME) or
|
||||
"{} - {}".format(DEFAULT_BASENAME, self._proxied_camera))
|
||||
self._image_opts = ImageOpts(
|
||||
config.get(CONF_MAX_IMAGE_WIDTH),
|
||||
config.get(CONF_IMAGE_QUALITY),
|
||||
config.get(CONF_FORCE_RESIZE))
|
||||
|
||||
self._stream_opts = ImageOpts(
|
||||
config.get(CONF_MAX_STREAM_WIDTH),
|
||||
config.get(CONF_STREAM_QUALITY),
|
||||
True)
|
||||
|
||||
self._image_refresh_rate = config.get(CONF_IMAGE_REFRESH_RATE)
|
||||
self._cache_images = bool(
|
||||
config.get(CONF_IMAGE_REFRESH_RATE)
|
||||
or config.get(CONF_CACHE_IMAGES))
|
||||
self._last_image_time = 0
|
||||
self._last_image = None
|
||||
self._headers = (
|
||||
{HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password}
|
||||
if self.hass.config.api.api_password is not None
|
||||
else None)
|
||||
|
||||
def camera_image(self):
|
||||
"""Return camera image."""
|
||||
return run_coroutine_threadsafe(
|
||||
self.async_camera_image(), self.hass.loop).result()
|
||||
|
||||
async def async_camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
now = dt_util.utcnow()
|
||||
|
||||
if (self._image_refresh_rate and
|
||||
now < self._last_image_time + self._image_refresh_rate):
|
||||
return self._last_image
|
||||
|
||||
self._last_image_time = now
|
||||
url = "{}/api/camera_proxy/{}".format(
|
||||
self.hass.config.api.base_url, self._proxied_camera)
|
||||
try:
|
||||
websession = async_get_clientsession(self.hass)
|
||||
with async_timeout.timeout(10, loop=self.hass.loop):
|
||||
response = await websession.get(url, headers=self._headers)
|
||||
image = await response.read()
|
||||
except asyncio.TimeoutError:
|
||||
_LOGGER.error("Timeout getting camera image")
|
||||
return self._last_image
|
||||
except aiohttp.ClientError as err:
|
||||
_LOGGER.error("Error getting new camera image: %s", err)
|
||||
return self._last_image
|
||||
|
||||
image = await self.hass.async_add_job(
|
||||
_resize_image, image, self._image_opts)
|
||||
|
||||
if self._cache_images:
|
||||
self._last_image = image
|
||||
return image
|
||||
|
||||
async def handle_async_mjpeg_stream(self, request):
|
||||
"""Generate an HTTP MJPEG stream from camera images."""
|
||||
websession = async_get_clientsession(self.hass)
|
||||
url = "{}/api/camera_proxy_stream/{}".format(
|
||||
self.hass.config.api.base_url, self._proxied_camera)
|
||||
stream_coro = websession.get(url, headers=self._headers)
|
||||
|
||||
if not self._stream_opts:
|
||||
await async_aiohttp_proxy_web(self.hass, request, stream_coro)
|
||||
return
|
||||
|
||||
response = aiohttp.web.StreamResponse()
|
||||
response.content_type = ('multipart/x-mixed-replace; '
|
||||
'boundary=--frameboundary')
|
||||
await response.prepare(request)
|
||||
|
||||
def write(img_bytes):
|
||||
"""Write image to stream."""
|
||||
response.write(bytes(
|
||||
'--frameboundary\r\n'
|
||||
'Content-Type: {}\r\n'
|
||||
'Content-Length: {}\r\n\r\n'.format(
|
||||
self.content_type, len(img_bytes)),
|
||||
'utf-8') + img_bytes + b'\r\n')
|
||||
|
||||
with async_timeout.timeout(10, loop=self.hass.loop):
|
||||
req = await stream_coro
|
||||
|
||||
try:
|
||||
while True:
|
||||
image = await _read_frame(req)
|
||||
if not image:
|
||||
break
|
||||
image = await self.hass.async_add_job(
|
||||
_resize_image, image, self._stream_opts)
|
||||
write(image)
|
||||
except asyncio.CancelledError:
|
||||
_LOGGER.debug("Stream closed by frontend.")
|
||||
req.close()
|
||||
response = None
|
||||
|
||||
finally:
|
||||
if response is not None:
|
||||
await response.write_eof()
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of this camera."""
|
||||
return self._name
|
@ -106,6 +106,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
_LOGGER.error("'%s' is not a whitelisted directory", file_path)
|
||||
return False
|
||||
|
||||
add_devices([RaspberryCamera(setup_config)])
|
||||
|
||||
|
||||
class RaspberryCamera(Camera):
|
||||
"""Representation of a Raspberry Pi camera."""
|
||||
|
@ -38,8 +38,10 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
async def async_setup_platform(hass,
|
||||
config,
|
||||
async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up a Yi Camera."""
|
||||
_LOGGER.debug('Received configuration: %s', config)
|
||||
async_add_devices([YiCamera(hass, config)], True)
|
||||
@ -107,31 +109,29 @@ class YiCamera(Camera):
|
||||
self.user, self.passwd, self.host, self.port, self.path,
|
||||
latest_dir, videos[-1])
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_camera_image(self):
|
||||
async def async_camera_image(self):
|
||||
"""Return a still image response from the camera."""
|
||||
from haffmpeg import ImageFrame, IMAGE_JPEG
|
||||
|
||||
url = yield from self.hass.async_add_job(self.get_latest_video_url)
|
||||
url = await self.hass.async_add_job(self.get_latest_video_url)
|
||||
if url != self._last_url:
|
||||
ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop)
|
||||
self._last_image = yield from asyncio.shield(ffmpeg.get_image(
|
||||
self._last_image = await asyncio.shield(ffmpeg.get_image(
|
||||
url, output_format=IMAGE_JPEG,
|
||||
extra_cmd=self._extra_arguments), loop=self.hass.loop)
|
||||
self._last_url = url
|
||||
|
||||
return self._last_image
|
||||
|
||||
@asyncio.coroutine
|
||||
def handle_async_mjpeg_stream(self, request):
|
||||
async def handle_async_mjpeg_stream(self, request):
|
||||
"""Generate an HTTP MJPEG stream from the camera."""
|
||||
from haffmpeg import CameraMjpeg
|
||||
|
||||
stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop)
|
||||
yield from stream.open_camera(
|
||||
await stream.open_camera(
|
||||
self._last_url, extra_cmd=self._extra_arguments)
|
||||
|
||||
yield from async_aiohttp_proxy_stream(
|
||||
await async_aiohttp_proxy_stream(
|
||||
self.hass, request, stream,
|
||||
'multipart/x-mixed-replace;boundary=ffserver')
|
||||
yield from stream.close()
|
||||
await stream.close()
|
||||
|
@ -15,7 +15,7 @@ from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
REQUIREMENTS = ['py-canary==0.4.0']
|
||||
REQUIREMENTS = ['py-canary==0.4.1']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -237,14 +237,12 @@ def set_swing_mode(hass, swing_mode, entity_id=None):
|
||||
hass.services.call(DOMAIN, SERVICE_SET_SWING_MODE, data)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
async def async_setup(hass, config):
|
||||
"""Set up climate devices."""
|
||||
component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL)
|
||||
yield from component.async_setup(config)
|
||||
await component.async_setup(config)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_away_mode_set_service(service):
|
||||
async def async_away_mode_set_service(service):
|
||||
"""Set away mode on target climate devices."""
|
||||
target_climate = component.async_extract_from_service(service)
|
||||
|
||||
@ -253,23 +251,22 @@ def async_setup(hass, config):
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
if away_mode:
|
||||
yield from climate.async_turn_away_mode_on()
|
||||
await climate.async_turn_away_mode_on()
|
||||
else:
|
||||
yield from climate.async_turn_away_mode_off()
|
||||
await climate.async_turn_away_mode_off()
|
||||
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
await asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_AWAY_MODE, async_away_mode_set_service,
|
||||
schema=SET_AWAY_MODE_SCHEMA)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_hold_mode_set_service(service):
|
||||
async def async_hold_mode_set_service(service):
|
||||
"""Set hold mode on target climate devices."""
|
||||
target_climate = component.async_extract_from_service(service)
|
||||
|
||||
@ -277,21 +274,20 @@ def async_setup(hass, config):
|
||||
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
yield from climate.async_set_hold_mode(hold_mode)
|
||||
await climate.async_set_hold_mode(hold_mode)
|
||||
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
await asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_HOLD_MODE, async_hold_mode_set_service,
|
||||
schema=SET_HOLD_MODE_SCHEMA)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_aux_heat_set_service(service):
|
||||
async def async_aux_heat_set_service(service):
|
||||
"""Set auxiliary heater on target climate devices."""
|
||||
target_climate = component.async_extract_from_service(service)
|
||||
|
||||
@ -300,23 +296,22 @@ def async_setup(hass, config):
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
if aux_heat:
|
||||
yield from climate.async_turn_aux_heat_on()
|
||||
await climate.async_turn_aux_heat_on()
|
||||
else:
|
||||
yield from climate.async_turn_aux_heat_off()
|
||||
await climate.async_turn_aux_heat_off()
|
||||
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
await asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_AUX_HEAT, async_aux_heat_set_service,
|
||||
schema=SET_AUX_HEAT_SCHEMA)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_temperature_set_service(service):
|
||||
async def async_temperature_set_service(service):
|
||||
"""Set temperature on the target climate devices."""
|
||||
target_climate = component.async_extract_from_service(service)
|
||||
|
||||
@ -333,21 +328,20 @@ def async_setup(hass, config):
|
||||
else:
|
||||
kwargs[value] = temp
|
||||
|
||||
yield from climate.async_set_temperature(**kwargs)
|
||||
await climate.async_set_temperature(**kwargs)
|
||||
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
await asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_TEMPERATURE, async_temperature_set_service,
|
||||
schema=SET_TEMPERATURE_SCHEMA)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_humidity_set_service(service):
|
||||
async def async_humidity_set_service(service):
|
||||
"""Set humidity on the target climate devices."""
|
||||
target_climate = component.async_extract_from_service(service)
|
||||
|
||||
@ -355,20 +349,19 @@ def async_setup(hass, config):
|
||||
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
yield from climate.async_set_humidity(humidity)
|
||||
await climate.async_set_humidity(humidity)
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
await asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_HUMIDITY, async_humidity_set_service,
|
||||
schema=SET_HUMIDITY_SCHEMA)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_fan_mode_set_service(service):
|
||||
async def async_fan_mode_set_service(service):
|
||||
"""Set fan mode on target climate devices."""
|
||||
target_climate = component.async_extract_from_service(service)
|
||||
|
||||
@ -376,20 +369,19 @@ def async_setup(hass, config):
|
||||
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
yield from climate.async_set_fan_mode(fan)
|
||||
await climate.async_set_fan_mode(fan)
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
await asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_FAN_MODE, async_fan_mode_set_service,
|
||||
schema=SET_FAN_MODE_SCHEMA)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_operation_set_service(service):
|
||||
async def async_operation_set_service(service):
|
||||
"""Set operating mode on the target climate devices."""
|
||||
target_climate = component.async_extract_from_service(service)
|
||||
|
||||
@ -397,20 +389,19 @@ def async_setup(hass, config):
|
||||
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
yield from climate.async_set_operation_mode(operation_mode)
|
||||
await climate.async_set_operation_mode(operation_mode)
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
await asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_OPERATION_MODE, async_operation_set_service,
|
||||
schema=SET_OPERATION_MODE_SCHEMA)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_swing_set_service(service):
|
||||
async def async_swing_set_service(service):
|
||||
"""Set swing mode on the target climate devices."""
|
||||
target_climate = component.async_extract_from_service(service)
|
||||
|
||||
@ -418,36 +409,35 @@ def async_setup(hass, config):
|
||||
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
yield from climate.async_set_swing_mode(swing_mode)
|
||||
await climate.async_set_swing_mode(swing_mode)
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
await asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SET_SWING_MODE, async_swing_set_service,
|
||||
schema=SET_SWING_MODE_SCHEMA)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_on_off_service(service):
|
||||
async def async_on_off_service(service):
|
||||
"""Handle on/off calls."""
|
||||
target_climate = component.async_extract_from_service(service)
|
||||
|
||||
update_tasks = []
|
||||
for climate in target_climate:
|
||||
if service.service == SERVICE_TURN_ON:
|
||||
yield from climate.async_turn_on()
|
||||
await climate.async_turn_on()
|
||||
elif service.service == SERVICE_TURN_OFF:
|
||||
yield from climate.async_turn_off()
|
||||
await climate.async_turn_off()
|
||||
|
||||
if not climate.should_poll:
|
||||
continue
|
||||
update_tasks.append(climate.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
await asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_TURN_OFF, async_on_off_service,
|
||||
|
@ -4,7 +4,6 @@ Support for KNX/IP climate devices.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/climate.knx/
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@ -61,8 +60,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up climate(s) for KNX platform."""
|
||||
if discovery_info is not None:
|
||||
async_add_devices_discovery(hass, discovery_info, async_add_devices)
|
||||
@ -135,11 +134,10 @@ class KNXClimate(ClimateDevice):
|
||||
|
||||
def async_register_callbacks(self):
|
||||
"""Register callbacks to update hass after device was changed."""
|
||||
@asyncio.coroutine
|
||||
def after_update_callback(device):
|
||||
async def after_update_callback(device):
|
||||
"""Call after device was updated."""
|
||||
# pylint: disable=unused-argument
|
||||
yield from self.async_update_ha_state()
|
||||
await self.async_update_ha_state()
|
||||
self.device.register_device_updated_cb(after_update_callback)
|
||||
|
||||
@property
|
||||
@ -187,14 +185,13 @@ class KNXClimate(ClimateDevice):
|
||||
"""Return the maximum temperature."""
|
||||
return self.device.target_temperature_max
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_temperature(self, **kwargs):
|
||||
async def async_set_temperature(self, **kwargs):
|
||||
"""Set new target temperature."""
|
||||
temperature = kwargs.get(ATTR_TEMPERATURE)
|
||||
if temperature is None:
|
||||
return
|
||||
yield from self.device.set_target_temperature(temperature)
|
||||
yield from self.async_update_ha_state()
|
||||
await self.device.set_target_temperature(temperature)
|
||||
await self.async_update_ha_state()
|
||||
|
||||
@property
|
||||
def current_operation(self):
|
||||
@ -210,10 +207,9 @@ class KNXClimate(ClimateDevice):
|
||||
operation_mode in
|
||||
self.device.get_supported_operation_modes()]
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_operation_mode(self, operation_mode):
|
||||
async def async_set_operation_mode(self, operation_mode):
|
||||
"""Set operation mode."""
|
||||
if self.device.supports_operation_mode:
|
||||
from xknx.knx import HVACOperationMode
|
||||
knx_operation_mode = HVACOperationMode(operation_mode)
|
||||
yield from self.device.set_operation_mode(knx_operation_mode)
|
||||
await self.device.set_operation_mode(knx_operation_mode)
|
||||
|
@ -29,10 +29,6 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
|
||||
NEST_MODE_HEAT_COOL = 'heat-cool'
|
||||
|
||||
SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_HIGH |
|
||||
SUPPORT_TARGET_TEMPERATURE_LOW | SUPPORT_OPERATION_MODE |
|
||||
SUPPORT_AWAY_MODE | SUPPORT_FAN_MODE)
|
||||
|
||||
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Set up the Nest thermostat."""
|
||||
@ -58,6 +54,10 @@ class NestThermostat(ClimateDevice):
|
||||
self.device = device
|
||||
self._fan_list = [STATE_ON, STATE_AUTO]
|
||||
|
||||
# Set the default supported features
|
||||
self._support_flags = (SUPPORT_TARGET_TEMPERATURE |
|
||||
SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE)
|
||||
|
||||
# Not all nest devices support cooling and heating remove unused
|
||||
self._operation_list = [STATE_OFF]
|
||||
|
||||
@ -70,11 +70,16 @@ class NestThermostat(ClimateDevice):
|
||||
|
||||
if self.device.can_heat and self.device.can_cool:
|
||||
self._operation_list.append(STATE_AUTO)
|
||||
self._support_flags = (self._support_flags |
|
||||
SUPPORT_TARGET_TEMPERATURE_HIGH |
|
||||
SUPPORT_TARGET_TEMPERATURE_LOW)
|
||||
|
||||
self._operation_list.append(STATE_ECO)
|
||||
|
||||
# feature of device
|
||||
self._has_fan = self.device.has_fan
|
||||
if self._has_fan:
|
||||
self._support_flags = (self._support_flags | SUPPORT_FAN_MODE)
|
||||
|
||||
# data attributes
|
||||
self._away = None
|
||||
@ -95,7 +100,7 @@ class NestThermostat(ClimateDevice):
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Return the list of supported features."""
|
||||
return SUPPORT_FLAGS
|
||||
return self._support_flags
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
@ -162,6 +167,7 @@ class NestThermostat(ClimateDevice):
|
||||
|
||||
def set_temperature(self, **kwargs):
|
||||
"""Set new target temperature."""
|
||||
import nest
|
||||
target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW)
|
||||
target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH)
|
||||
if self._mode == NEST_MODE_HEAT_COOL:
|
||||
@ -170,7 +176,10 @@ class NestThermostat(ClimateDevice):
|
||||
else:
|
||||
temp = kwargs.get(ATTR_TEMPERATURE)
|
||||
_LOGGER.debug("Nest set_temperature-output-value=%s", temp)
|
||||
self.device.target = temp
|
||||
try:
|
||||
self.device.target = temp
|
||||
except nest.nest.APIError:
|
||||
_LOGGER.error("An error occured while setting the temperature")
|
||||
|
||||
def set_operation_mode(self, operation_mode):
|
||||
"""Set operation mode."""
|
||||
@ -205,11 +214,14 @@ class NestThermostat(ClimateDevice):
|
||||
@property
|
||||
def fan_list(self):
|
||||
"""List of available fan modes."""
|
||||
return self._fan_list
|
||||
if self._has_fan:
|
||||
return self._fan_list
|
||||
return None
|
||||
|
||||
def set_fan_mode(self, fan_mode):
|
||||
"""Turn fan on/off."""
|
||||
self.device.fan = fan_mode.lower()
|
||||
if self._has_fan:
|
||||
self.device.fan = fan_mode.lower()
|
||||
|
||||
@property
|
||||
def min_temp(self):
|
||||
|
@ -15,12 +15,12 @@ import async_timeout
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME, CONF_TYPE)
|
||||
EVENT_HOMEASSISTANT_START, CONF_REGION, CONF_MODE, CONF_NAME)
|
||||
from homeassistant.helpers import entityfilter, config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.components.alexa import smart_home as alexa_sh
|
||||
from homeassistant.components.google_assistant import smart_home as ga_sh
|
||||
from homeassistant.components.google_assistant import helpers as ga_h
|
||||
|
||||
from . import http_api, iot
|
||||
from .const import CONFIG_DIR, DOMAIN, SERVERS
|
||||
@ -51,7 +51,6 @@ ALEXA_ENTITY_SCHEMA = vol.Schema({
|
||||
|
||||
GOOGLE_ENTITY_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_TYPE): vol.In(ga_sh.MAPPING_COMPONENT),
|
||||
vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string])
|
||||
})
|
||||
|
||||
@ -175,7 +174,7 @@ class Cloud:
|
||||
"""If an entity should be exposed."""
|
||||
return conf['filter'](entity.entity_id)
|
||||
|
||||
self._gactions_config = ga_sh.Config(
|
||||
self._gactions_config = ga_h.Config(
|
||||
should_expose=should_expose,
|
||||
agent_user_id=self.claims['cognito:username'],
|
||||
entity_config=conf.get(CONF_ENTITY_CONFIG),
|
||||
|
@ -17,14 +17,6 @@ class UserNotConfirmed(CloudError):
|
||||
"""Raised when a user has not confirmed email yet."""
|
||||
|
||||
|
||||
class ExpiredCode(CloudError):
|
||||
"""Raised when an expired code is encountered."""
|
||||
|
||||
|
||||
class InvalidCode(CloudError):
|
||||
"""Raised when an invalid code is submitted."""
|
||||
|
||||
|
||||
class PasswordChangeRequired(CloudError):
|
||||
"""Raised when a password change is required."""
|
||||
|
||||
@ -42,10 +34,8 @@ class UnknownError(CloudError):
|
||||
AWS_EXCEPTIONS = {
|
||||
'UserNotFoundException': UserNotFound,
|
||||
'NotAuthorizedException': Unauthenticated,
|
||||
'ExpiredCodeException': ExpiredCode,
|
||||
'UserNotConfirmedException': UserNotConfirmed,
|
||||
'PasswordResetRequiredException': PasswordChangeRequired,
|
||||
'CodeMismatchException': InvalidCode,
|
||||
}
|
||||
|
||||
|
||||
@ -69,17 +59,6 @@ def register(cloud, email, password):
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def confirm_register(cloud, confirmation_code, email):
|
||||
"""Confirm confirmation code after registration."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
cognito = _cognito(cloud)
|
||||
try:
|
||||
cognito.confirm_sign_up(confirmation_code, email)
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def resend_email_confirm(cloud, email):
|
||||
"""Resend email confirmation."""
|
||||
from botocore.exceptions import ClientError
|
||||
@ -107,18 +86,6 @@ def forgot_password(cloud, email):
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def confirm_forgot_password(cloud, confirmation_code, email, new_password):
|
||||
"""Confirm forgotten password code and change password."""
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
cognito = _cognito(cloud, username=email)
|
||||
|
||||
try:
|
||||
cognito.confirm_forgot_password(confirmation_code, new_password)
|
||||
except ClientError as err:
|
||||
raise _map_aws_exception(err)
|
||||
|
||||
|
||||
def login(cloud, email, password):
|
||||
"""Log user in and fetch certificate."""
|
||||
cognito = _authenticate(cloud, email, password)
|
||||
|
@ -23,10 +23,8 @@ def async_setup(hass):
|
||||
hass.http.register_view(CloudLogoutView)
|
||||
hass.http.register_view(CloudAccountView)
|
||||
hass.http.register_view(CloudRegisterView)
|
||||
hass.http.register_view(CloudConfirmRegisterView)
|
||||
hass.http.register_view(CloudResendConfirmView)
|
||||
hass.http.register_view(CloudForgotPasswordView)
|
||||
hass.http.register_view(CloudConfirmForgotPasswordView)
|
||||
|
||||
|
||||
_CLOUD_ERRORS = {
|
||||
@ -34,8 +32,6 @@ _CLOUD_ERRORS = {
|
||||
auth_api.UserNotConfirmed: (400, 'Email not confirmed.'),
|
||||
auth_api.Unauthenticated: (401, 'Authentication failed.'),
|
||||
auth_api.PasswordChangeRequired: (400, 'Password change required.'),
|
||||
auth_api.ExpiredCode: (400, 'Confirmation code has expired.'),
|
||||
auth_api.InvalidCode: (400, 'Invalid confirmation code.'),
|
||||
asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.')
|
||||
}
|
||||
|
||||
@ -149,31 +145,6 @@ class CloudRegisterView(HomeAssistantView):
|
||||
return self.json_message('ok')
|
||||
|
||||
|
||||
class CloudConfirmRegisterView(HomeAssistantView):
|
||||
"""Confirm registration on the Home Assistant cloud."""
|
||||
|
||||
url = '/api/cloud/confirm_register'
|
||||
name = 'api:cloud:confirm_register'
|
||||
|
||||
@_handle_cloud_errors
|
||||
@RequestDataValidator(vol.Schema({
|
||||
vol.Required('confirmation_code'): str,
|
||||
vol.Required('email'): str,
|
||||
}))
|
||||
@asyncio.coroutine
|
||||
def post(self, request, data):
|
||||
"""Handle registration confirmation request."""
|
||||
hass = request.app['hass']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(
|
||||
auth_api.confirm_register, cloud, data['confirmation_code'],
|
||||
data['email'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
|
||||
class CloudResendConfirmView(HomeAssistantView):
|
||||
"""Resend email confirmation code."""
|
||||
|
||||
@ -220,33 +191,6 @@ class CloudForgotPasswordView(HomeAssistantView):
|
||||
return self.json_message('ok')
|
||||
|
||||
|
||||
class CloudConfirmForgotPasswordView(HomeAssistantView):
|
||||
"""View to finish Forgot Password flow.."""
|
||||
|
||||
url = '/api/cloud/confirm_forgot_password'
|
||||
name = 'api:cloud:confirm_forgot_password'
|
||||
|
||||
@_handle_cloud_errors
|
||||
@RequestDataValidator(vol.Schema({
|
||||
vol.Required('confirmation_code'): str,
|
||||
vol.Required('email'): str,
|
||||
vol.Required('new_password'): vol.All(str, vol.Length(min=6))
|
||||
}))
|
||||
@asyncio.coroutine
|
||||
def post(self, request, data):
|
||||
"""Handle forgot password confirm request."""
|
||||
hass = request.app['hass']
|
||||
cloud = hass.data[DOMAIN]
|
||||
|
||||
with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop):
|
||||
yield from hass.async_add_job(
|
||||
auth_api.confirm_forgot_password, cloud,
|
||||
data['confirmation_code'], data['email'],
|
||||
data['new_password'])
|
||||
|
||||
return self.json_message('ok')
|
||||
|
||||
|
||||
def _account_data(cloud):
|
||||
"""Generate the auth data JSON response."""
|
||||
claims = cloud.claims
|
||||
|
@ -1,6 +1,7 @@
|
||||
"""Module to handle messages from Home Assistant cloud."""
|
||||
import asyncio
|
||||
import logging
|
||||
import pprint
|
||||
|
||||
from aiohttp import hdrs, client_exceptions, WSMsgType
|
||||
|
||||
@ -154,7 +155,9 @@ class CloudIoT:
|
||||
disconnect_warn = 'Received invalid JSON.'
|
||||
break
|
||||
|
||||
_LOGGER.debug("Received message: %s", msg)
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug("Received message:\n%s\n",
|
||||
pprint.pformat(msg))
|
||||
|
||||
response = {
|
||||
'msgid': msg['msgid'],
|
||||
@ -176,7 +179,9 @@ class CloudIoT:
|
||||
_LOGGER.exception("Error handling message")
|
||||
response['error'] = 'exception'
|
||||
|
||||
_LOGGER.debug("Publishing message: %s", response)
|
||||
if _LOGGER.isEnabledFor(logging.DEBUG):
|
||||
_LOGGER.debug("Publishing message:\n%s\n",
|
||||
pprint.pformat(response))
|
||||
yield from client.send_json(response)
|
||||
|
||||
except client_exceptions.WSServerHandshakeError as err:
|
||||
|
@ -14,9 +14,7 @@ import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.discovery import load_platform
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
REQUIREMENTS = [
|
||||
'https://github.com/balloob/coinbase-python/archive/'
|
||||
'3a35efe13ef728a1cc18204b4f25be1fcb1c6006.zip#coinbase==2.0.8a1']
|
||||
REQUIREMENTS = ['coinbase==2.1.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
@ -13,7 +13,8 @@ from homeassistant.util.yaml import load_yaml, dump
|
||||
|
||||
DOMAIN = 'config'
|
||||
DEPENDENCIES = ['http']
|
||||
SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script')
|
||||
SECTIONS = ('core', 'customize', 'group', 'hassbian', 'automation', 'script',
|
||||
'entity_registry')
|
||||
ON_DEMAND = ('zwave',)
|
||||
FEATURE_FLAGS = ('config_entries',)
|
||||
|
||||
|
@ -97,10 +97,10 @@ class ConfigManagerFlowIndexView(HomeAssistantView):
|
||||
flow for flow in hass.config_entries.flow.async_progress()
|
||||
if flow['source'] != config_entries.SOURCE_USER])
|
||||
|
||||
@asyncio.coroutine
|
||||
@RequestDataValidator(vol.Schema({
|
||||
vol.Required('domain'): str,
|
||||
}))
|
||||
@asyncio.coroutine
|
||||
def post(self, request, data):
|
||||
"""Handle a POST request."""
|
||||
hass = request.app['hass']
|
||||
@ -139,8 +139,8 @@ class ConfigManagerFlowResourceView(HomeAssistantView):
|
||||
|
||||
return self.json(result)
|
||||
|
||||
@asyncio.coroutine
|
||||
@RequestDataValidator(vol.Schema(dict), allow_empty=True)
|
||||
@asyncio.coroutine
|
||||
def post(self, request, flow_id, data):
|
||||
"""Handle a POST request."""
|
||||
hass = request.app['hass']
|
||||
@ -163,7 +163,7 @@ class ConfigManagerFlowResourceView(HomeAssistantView):
|
||||
hass = request.app['hass']
|
||||
|
||||
try:
|
||||
hass.config_entries.async_abort(flow_id)
|
||||
hass.config_entries.flow.async_abort(flow_id)
|
||||
except config_entries.UnknownFlow:
|
||||
return self.json_message('Invalid flow specified', 404)
|
||||
|
||||
|
55
homeassistant/components/config/entity_registry.py
Normal file
55
homeassistant/components/config/entity_registry.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""HTTP views to interact with the entity registry."""
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.http.data_validator import RequestDataValidator
|
||||
from homeassistant.helpers.entity_registry import async_get_registry
|
||||
|
||||
|
||||
async def async_setup(hass):
|
||||
"""Enable the Entity Registry views."""
|
||||
hass.http.register_view(ConfigManagerEntityView)
|
||||
return True
|
||||
|
||||
|
||||
class ConfigManagerEntityView(HomeAssistantView):
|
||||
"""View to interact with an entity registry entry."""
|
||||
|
||||
url = '/api/config/entity_registry/{entity_id}'
|
||||
name = 'api:config:entity_registry:entity'
|
||||
|
||||
async def get(self, request, entity_id):
|
||||
"""Get the entity registry settings for an entity."""
|
||||
hass = request.app['hass']
|
||||
registry = await async_get_registry(hass)
|
||||
entry = registry.entities.get(entity_id)
|
||||
|
||||
if entry is None:
|
||||
return self.json_message('Entry not found', 404)
|
||||
|
||||
return self.json(_entry_dict(entry))
|
||||
|
||||
@RequestDataValidator(vol.Schema({
|
||||
# If passed in, we update value. Passing None will remove old value.
|
||||
vol.Optional('name'): vol.Any(str, None),
|
||||
}))
|
||||
async def post(self, request, entity_id, data):
|
||||
"""Update the entity registry settings for an entity."""
|
||||
hass = request.app['hass']
|
||||
registry = await async_get_registry(hass)
|
||||
|
||||
if entity_id not in registry.entities:
|
||||
return self.json_message('Entry not found', 404)
|
||||
|
||||
entry = registry.async_update_entity(entity_id, **data)
|
||||
return self.json(_entry_dict(entry))
|
||||
|
||||
|
||||
@callback
|
||||
def _entry_dict(entry):
|
||||
"""Helper to convert entry to API format."""
|
||||
return {
|
||||
'entity_id': entry.entity_id,
|
||||
'name': entry.name
|
||||
}
|
@ -4,7 +4,6 @@ Support for functionality to have conversations with Home Assistant.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/conversation/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
@ -67,8 +66,7 @@ def async_register(hass, intent_type, utterances):
|
||||
conf.append(_create_matcher(utterance))
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
async def async_setup(hass, config):
|
||||
"""Register the process service."""
|
||||
config = config.get(DOMAIN, {})
|
||||
intents = hass.data.get(DOMAIN)
|
||||
@ -84,49 +82,73 @@ def async_setup(hass, config):
|
||||
|
||||
conf.extend(_create_matcher(utterance) for utterance in utterances)
|
||||
|
||||
@asyncio.coroutine
|
||||
def process(service):
|
||||
async def process(service):
|
||||
"""Parse text into commands."""
|
||||
text = service.data[ATTR_TEXT]
|
||||
yield from _process(hass, text)
|
||||
try:
|
||||
await _process(hass, text)
|
||||
except intent.IntentHandleError as err:
|
||||
_LOGGER.error('Error processing %s: %s', text, err)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_PROCESS, process, schema=SERVICE_PROCESS_SCHEMA)
|
||||
|
||||
hass.http.register_view(ConversationProcessView)
|
||||
|
||||
async_register(hass, intent.INTENT_TURN_ON,
|
||||
['Turn {name} on', 'Turn on {name}'])
|
||||
async_register(hass, intent.INTENT_TURN_OFF,
|
||||
['Turn {name} off', 'Turn off {name}'])
|
||||
async_register(hass, intent.INTENT_TOGGLE,
|
||||
['Toggle {name}', '{name} toggle'])
|
||||
# We strip trailing 's' from name because our state matcher will fail
|
||||
# if a letter is not there. By removing 's' we can match singular and
|
||||
# plural names.
|
||||
|
||||
async_register(hass, intent.INTENT_TURN_ON, [
|
||||
'Turn [the] [a] {name}[s] on',
|
||||
'Turn on [the] [a] [an] {name}[s]',
|
||||
])
|
||||
async_register(hass, intent.INTENT_TURN_OFF, [
|
||||
'Turn [the] [a] [an] {name}[s] off',
|
||||
'Turn off [the] [a] [an] {name}[s]',
|
||||
])
|
||||
async_register(hass, intent.INTENT_TOGGLE, [
|
||||
'Toggle [the] [a] [an] {name}[s]',
|
||||
'[the] [a] [an] {name}[s] toggle',
|
||||
])
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _create_matcher(utterance):
|
||||
"""Create a regex that matches the utterance."""
|
||||
parts = re.split(r'({\w+})', utterance)
|
||||
# Split utterance into parts that are type: NORMAL, GROUP or OPTIONAL
|
||||
# Pattern matches (GROUP|OPTIONAL): Change light to [the color] {name}
|
||||
parts = re.split(r'({\w+}|\[[\w\s]+\] *)', utterance)
|
||||
# Pattern to extract name from GROUP part. Matches {name}
|
||||
group_matcher = re.compile(r'{(\w+)}')
|
||||
# Pattern to extract text from OPTIONAL part. Matches [the color]
|
||||
optional_matcher = re.compile(r'\[([\w ]+)\] *')
|
||||
|
||||
pattern = ['^']
|
||||
|
||||
for part in parts:
|
||||
match = group_matcher.match(part)
|
||||
group_match = group_matcher.match(part)
|
||||
optional_match = optional_matcher.match(part)
|
||||
|
||||
if match is None:
|
||||
# Normal part
|
||||
if group_match is None and optional_match is None:
|
||||
pattern.append(part)
|
||||
continue
|
||||
|
||||
pattern.append('(?P<{}>{})'.format(match.groups()[0], r'[\w ]+'))
|
||||
# Group part
|
||||
if group_match is not None:
|
||||
pattern.append(
|
||||
r'(?P<{}>[\w ]+?)\s*'.format(group_match.groups()[0]))
|
||||
|
||||
# Optional part
|
||||
elif optional_match is not None:
|
||||
pattern.append(r'(?:{} *)?'.format(optional_match.groups()[0]))
|
||||
|
||||
pattern.append('$')
|
||||
return re.compile(''.join(pattern), re.I)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def _process(hass, text):
|
||||
async def _process(hass, text):
|
||||
"""Process a line of text."""
|
||||
intents = hass.data.get(DOMAIN, {})
|
||||
|
||||
@ -137,7 +159,7 @@ def _process(hass, text):
|
||||
if not match:
|
||||
continue
|
||||
|
||||
response = yield from hass.helpers.intent.async_handle(
|
||||
response = await hass.helpers.intent.async_handle(
|
||||
DOMAIN, intent_type,
|
||||
{key: {'value': value} for key, value
|
||||
in match.groupdict().items()}, text)
|
||||
@ -153,12 +175,15 @@ class ConversationProcessView(http.HomeAssistantView):
|
||||
@RequestDataValidator(vol.Schema({
|
||||
vol.Required('text'): str,
|
||||
}))
|
||||
@asyncio.coroutine
|
||||
def post(self, request, data):
|
||||
async def post(self, request, data):
|
||||
"""Send a request for processing."""
|
||||
hass = request.app['hass']
|
||||
|
||||
intent_result = yield from _process(hass, data['text'])
|
||||
try:
|
||||
intent_result = await _process(hass, data['text'])
|
||||
except intent.IntentHandleError as err:
|
||||
intent_result = intent.IntentResponse()
|
||||
intent_result.async_set_speech(str(err))
|
||||
|
||||
if intent_result is None:
|
||||
intent_result = intent.IntentResponse()
|
||||
|
@ -150,16 +150,14 @@ def stop_cover_tilt(hass, entity_id=None):
|
||||
hass.services.call(DOMAIN, SERVICE_STOP_COVER_TILT, data)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
async def async_setup(hass, config):
|
||||
"""Track states and offer events for covers."""
|
||||
component = EntityComponent(
|
||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_COVERS)
|
||||
|
||||
yield from component.async_setup(config)
|
||||
await component.async_setup(config)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_cover_service(service):
|
||||
async def async_handle_cover_service(service):
|
||||
"""Handle calls to the cover services."""
|
||||
covers = component.async_extract_from_service(service)
|
||||
method = SERVICE_TO_METHOD.get(service.service)
|
||||
@ -169,13 +167,13 @@ def async_setup(hass, config):
|
||||
# call method
|
||||
update_tasks = []
|
||||
for cover in covers:
|
||||
yield from getattr(cover, method['method'])(**params)
|
||||
await getattr(cover, method['method'])(**params)
|
||||
if not cover.should_poll:
|
||||
continue
|
||||
update_tasks.append(cover.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
await asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
for service_name in SERVICE_TO_METHOD:
|
||||
schema = SERVICE_TO_METHOD[service_name].get(
|
||||
|
@ -4,7 +4,6 @@ Support for KNX/IP covers.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/cover.knx/
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@ -50,8 +49,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up cover(s) for KNX platform."""
|
||||
if discovery_info is not None:
|
||||
async_add_devices_discovery(hass, discovery_info, async_add_devices)
|
||||
@ -106,11 +105,10 @@ class KNXCover(CoverDevice):
|
||||
@callback
|
||||
def async_register_callbacks(self):
|
||||
"""Register callbacks to update hass after device was changed."""
|
||||
@asyncio.coroutine
|
||||
def after_update_callback(device):
|
||||
async def after_update_callback(device):
|
||||
"""Call after device was updated."""
|
||||
# pylint: disable=unused-argument
|
||||
yield from self.async_update_ha_state()
|
||||
await self.async_update_ha_state()
|
||||
self.device.register_device_updated_cb(after_update_callback)
|
||||
|
||||
@property
|
||||
@ -147,32 +145,28 @@ class KNXCover(CoverDevice):
|
||||
"""Return if the cover is closed."""
|
||||
return self.device.is_closed()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_close_cover(self, **kwargs):
|
||||
async def async_close_cover(self, **kwargs):
|
||||
"""Close the cover."""
|
||||
if not self.device.is_closed():
|
||||
yield from self.device.set_down()
|
||||
await self.device.set_down()
|
||||
self.start_auto_updater()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_open_cover(self, **kwargs):
|
||||
async def async_open_cover(self, **kwargs):
|
||||
"""Open the cover."""
|
||||
if not self.device.is_open():
|
||||
yield from self.device.set_up()
|
||||
await self.device.set_up()
|
||||
self.start_auto_updater()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_cover_position(self, **kwargs):
|
||||
async def async_set_cover_position(self, **kwargs):
|
||||
"""Move the cover to a specific position."""
|
||||
if ATTR_POSITION in kwargs:
|
||||
position = kwargs[ATTR_POSITION]
|
||||
yield from self.device.set_position(position)
|
||||
await self.device.set_position(position)
|
||||
self.start_auto_updater()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_stop_cover(self, **kwargs):
|
||||
async def async_stop_cover(self, **kwargs):
|
||||
"""Stop the cover."""
|
||||
yield from self.device.stop()
|
||||
await self.device.stop()
|
||||
self.stop_auto_updater()
|
||||
|
||||
@property
|
||||
@ -182,12 +176,11 @@ class KNXCover(CoverDevice):
|
||||
return None
|
||||
return self.device.current_angle()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_cover_tilt_position(self, **kwargs):
|
||||
async def async_set_cover_tilt_position(self, **kwargs):
|
||||
"""Move the cover tilt to a specific position."""
|
||||
if ATTR_TILT_POSITION in kwargs:
|
||||
tilt_position = kwargs[ATTR_TILT_POSITION]
|
||||
yield from self.device.set_angle(tilt_position)
|
||||
await self.device.set_angle(tilt_position)
|
||||
|
||||
def start_auto_updater(self):
|
||||
"""Start the autoupdater to update HASS while cover is moving."""
|
||||
|
@ -118,6 +118,17 @@ def async_setup(hass, config):
|
||||
|
||||
tasks2 = []
|
||||
|
||||
# Set up history graph
|
||||
tasks2.append(bootstrap.async_setup_component(
|
||||
hass, 'history_graph',
|
||||
{'history_graph': {'switches': {
|
||||
'name': 'Recent Switches',
|
||||
'entities': switches,
|
||||
'hours_to_show': 1,
|
||||
'refresh': 60
|
||||
}}}
|
||||
))
|
||||
|
||||
# Set up scripts
|
||||
tasks2.append(bootstrap.async_setup_component(
|
||||
hass, 'script',
|
||||
|
@ -77,11 +77,14 @@ ATTR_MAC = 'mac'
|
||||
ATTR_NAME = 'name'
|
||||
ATTR_SOURCE_TYPE = 'source_type'
|
||||
ATTR_VENDOR = 'vendor'
|
||||
ATTR_CONSIDER_HOME = 'consider_home'
|
||||
|
||||
SOURCE_TYPE_GPS = 'gps'
|
||||
SOURCE_TYPE_ROUTER = 'router'
|
||||
SOURCE_TYPE_BLUETOOTH = 'bluetooth'
|
||||
SOURCE_TYPE_BLUETOOTH_LE = 'bluetooth_le'
|
||||
SOURCE_TYPES = (SOURCE_TYPE_GPS, SOURCE_TYPE_ROUTER,
|
||||
SOURCE_TYPE_BLUETOOTH, SOURCE_TYPE_BLUETOOTH_LE)
|
||||
|
||||
NEW_DEVICE_DEFAULTS_SCHEMA = vol.Any(None, vol.Schema({
|
||||
vol.Optional(CONF_TRACK_NEW, default=DEFAULT_TRACK_NEW): cv.boolean,
|
||||
@ -96,6 +99,19 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_NEW_DEVICE_DEFAULTS,
|
||||
default={}): NEW_DEVICE_DEFAULTS_SCHEMA
|
||||
})
|
||||
SERVICE_SEE_PAYLOAD_SCHEMA = vol.Schema(vol.All(
|
||||
cv.has_at_least_one_key(ATTR_MAC, ATTR_DEV_ID), {
|
||||
ATTR_MAC: cv.string,
|
||||
ATTR_DEV_ID: cv.string,
|
||||
ATTR_HOST_NAME: cv.string,
|
||||
ATTR_LOCATION_NAME: cv.string,
|
||||
ATTR_GPS: cv.gps,
|
||||
ATTR_GPS_ACCURACY: cv.positive_int,
|
||||
ATTR_BATTERY: cv.positive_int,
|
||||
ATTR_ATTRIBUTES: dict,
|
||||
ATTR_SOURCE_TYPE: vol.In(SOURCE_TYPES),
|
||||
ATTR_CONSIDER_HOME: cv.time_period,
|
||||
}))
|
||||
|
||||
|
||||
@bind_hass
|
||||
@ -109,7 +125,7 @@ def is_on(hass: HomeAssistantType, entity_id: str = None):
|
||||
def see(hass: HomeAssistantType, mac: str = None, dev_id: str = None,
|
||||
host_name: str = None, location_name: str = None,
|
||||
gps: GPSType = None, gps_accuracy=None,
|
||||
battery=None, attributes: dict = None):
|
||||
battery: int = None, attributes: dict = None):
|
||||
"""Call service to notify you see device."""
|
||||
data = {key: value for key, value in
|
||||
((ATTR_MAC, mac),
|
||||
@ -203,12 +219,10 @@ def async_setup(hass: HomeAssistantType, config: ConfigType):
|
||||
@asyncio.coroutine
|
||||
def async_see_service(call):
|
||||
"""Service to see a device."""
|
||||
args = {key: value for key, value in call.data.items() if key in
|
||||
(ATTR_MAC, ATTR_DEV_ID, ATTR_HOST_NAME, ATTR_LOCATION_NAME,
|
||||
ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_BATTERY, ATTR_ATTRIBUTES)}
|
||||
yield from tracker.async_see(**args)
|
||||
yield from tracker.async_see(**call.data)
|
||||
|
||||
hass.services.async_register(DOMAIN, SERVICE_SEE, async_see_service)
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_SEE, async_see_service, SERVICE_SEE_PAYLOAD_SCHEMA)
|
||||
|
||||
# restore
|
||||
yield from tracker.async_setup_tracked_device()
|
||||
@ -240,23 +254,26 @@ class DeviceTracker(object):
|
||||
dev.mac)
|
||||
|
||||
def see(self, mac: str = None, dev_id: str = None, host_name: str = None,
|
||||
location_name: str = None, gps: GPSType = None, gps_accuracy=None,
|
||||
battery: str = None, attributes: dict = None,
|
||||
source_type: str = SOURCE_TYPE_GPS, picture: str = None,
|
||||
icon: str = None):
|
||||
location_name: str = None, gps: GPSType = None,
|
||||
gps_accuracy: int = None, battery: int = None,
|
||||
attributes: dict = None, source_type: str = SOURCE_TYPE_GPS,
|
||||
picture: str = None, icon: str = None,
|
||||
consider_home: timedelta = None):
|
||||
"""Notify the device tracker that you see a device."""
|
||||
self.hass.add_job(
|
||||
self.async_see(mac, dev_id, host_name, location_name, gps,
|
||||
gps_accuracy, battery, attributes, source_type,
|
||||
picture, icon)
|
||||
picture, icon, consider_home)
|
||||
)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_see(self, mac: str = None, dev_id: str = None,
|
||||
host_name: str = None, location_name: str = None,
|
||||
gps: GPSType = None, gps_accuracy=None, battery: str = None,
|
||||
attributes: dict = None, source_type: str = SOURCE_TYPE_GPS,
|
||||
picture: str = None, icon: str = None):
|
||||
def async_see(
|
||||
self, mac: str = None, dev_id: str = None, host_name: str = None,
|
||||
location_name: str = None, gps: GPSType = None,
|
||||
gps_accuracy: int = None, battery: int = None,
|
||||
attributes: dict = None, source_type: str = SOURCE_TYPE_GPS,
|
||||
picture: str = None, icon: str = None,
|
||||
consider_home: timedelta = None):
|
||||
"""Notify the device tracker that you see a device.
|
||||
|
||||
This method is a coroutine.
|
||||
@ -275,7 +292,7 @@ class DeviceTracker(object):
|
||||
if device:
|
||||
yield from device.async_seen(
|
||||
host_name, location_name, gps, gps_accuracy, battery,
|
||||
attributes, source_type)
|
||||
attributes, source_type, consider_home)
|
||||
if device.track:
|
||||
yield from device.async_update_ha_state()
|
||||
return
|
||||
@ -283,7 +300,7 @@ class DeviceTracker(object):
|
||||
# If no device can be found, create it
|
||||
dev_id = util.ensure_unique_string(dev_id, self.devices.keys())
|
||||
device = Device(
|
||||
self.hass, self.consider_home, self.track_new,
|
||||
self.hass, consider_home or self.consider_home, self.track_new,
|
||||
dev_id, mac, (host_name or dev_id).replace('_', ' '),
|
||||
picture=picture, icon=icon,
|
||||
hide_if_away=self.defaults.get(CONF_AWAY_HIDE, DEFAULT_AWAY_HIDE))
|
||||
@ -384,9 +401,10 @@ class Device(Entity):
|
||||
host_name = None # type: str
|
||||
location_name = None # type: str
|
||||
gps = None # type: GPSType
|
||||
gps_accuracy = 0
|
||||
gps_accuracy = 0 # type: int
|
||||
last_seen = None # type: dt_util.dt.datetime
|
||||
battery = None # type: str
|
||||
consider_home = None # type: dt_util.dt.timedelta
|
||||
battery = None # type: int
|
||||
attributes = None # type: dict
|
||||
vendor = None # type: str
|
||||
icon = None # type: str
|
||||
@ -476,14 +494,16 @@ class Device(Entity):
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_seen(self, host_name: str = None, location_name: str = None,
|
||||
gps: GPSType = None, gps_accuracy=0, battery: str = None,
|
||||
gps: GPSType = None, gps_accuracy=0, battery: int = None,
|
||||
attributes: dict = None,
|
||||
source_type: str = SOURCE_TYPE_GPS):
|
||||
source_type: str = SOURCE_TYPE_GPS,
|
||||
consider_home: timedelta = None):
|
||||
"""Mark the device as seen."""
|
||||
self.source_type = source_type
|
||||
self.last_seen = dt_util.utcnow()
|
||||
self.host_name = host_name
|
||||
self.location_name = location_name
|
||||
self.consider_home = consider_home or self.consider_home
|
||||
|
||||
if battery:
|
||||
self.battery = battery
|
||||
|
@ -283,15 +283,15 @@ class SshConnection(_Connection):
|
||||
lines = self._ssh.before.split(b'\n')[1:-1]
|
||||
return [line.decode('utf-8') for line in lines]
|
||||
except exceptions.EOF as err:
|
||||
_LOGGER.error("Connection refused. SSH enabled?")
|
||||
_LOGGER.error("Connection refused. %s", self._ssh.before)
|
||||
self.disconnect()
|
||||
return None
|
||||
except pxssh.ExceptionPxssh as err:
|
||||
_LOGGER.error("Unexpected SSH error: %s", str(err))
|
||||
_LOGGER.error("Unexpected SSH error: %s", err)
|
||||
self.disconnect()
|
||||
return None
|
||||
except AssertionError as err:
|
||||
_LOGGER.error("Connection to router unavailable: %s", str(err))
|
||||
_LOGGER.error("Connection to router unavailable: %s", err)
|
||||
self.disconnect()
|
||||
return None
|
||||
|
||||
@ -301,10 +301,10 @@ class SshConnection(_Connection):
|
||||
|
||||
self._ssh = pxssh.pxssh()
|
||||
if self._ssh_key:
|
||||
self._ssh.login(self._host, self._username,
|
||||
self._ssh.login(self._host, self._username, quiet=False,
|
||||
ssh_key=self._ssh_key, port=self._port)
|
||||
else:
|
||||
self._ssh.login(self._host, self._username,
|
||||
self._ssh.login(self._host, self._username, quiet=False,
|
||||
password=self._password, port=self._port)
|
||||
|
||||
super().connect()
|
||||
|
@ -189,10 +189,12 @@ class Icloud(DeviceScanner):
|
||||
for device in self.api.devices:
|
||||
status = device.status(DEVICESTATUSSET)
|
||||
devicename = slugify(status['name'].replace(' ', '', 99))
|
||||
if devicename not in self.devices:
|
||||
self.devices[devicename] = device
|
||||
self._intervals[devicename] = 1
|
||||
self._overridestates[devicename] = None
|
||||
if devicename in self.devices:
|
||||
_LOGGER.error('Multiple devices with name: %s', devicename)
|
||||
continue
|
||||
self.devices[devicename] = device
|
||||
self._intervals[devicename] = 1
|
||||
self._overridestates[devicename] = None
|
||||
except PyiCloudNoDevicesException:
|
||||
_LOGGER.error('No iCloud Devices found!')
|
||||
|
||||
@ -319,14 +321,6 @@ class Icloud(DeviceScanner):
|
||||
|
||||
def determine_interval(self, devicename, latitude, longitude, battery):
|
||||
"""Calculate new interval."""
|
||||
distancefromhome = None
|
||||
zone_state = self.hass.states.get('zone.home')
|
||||
zone_state_lat = zone_state.attributes['latitude']
|
||||
zone_state_long = zone_state.attributes['longitude']
|
||||
distancefromhome = distance(
|
||||
latitude, longitude, zone_state_lat, zone_state_long)
|
||||
distancefromhome = round(distancefromhome / 1000, 1)
|
||||
|
||||
currentzone = active_zone(self.hass, latitude, longitude)
|
||||
|
||||
if ((currentzone is not None and
|
||||
@ -335,22 +329,48 @@ class Icloud(DeviceScanner):
|
||||
self._overridestates.get(devicename) == 'away')):
|
||||
return
|
||||
|
||||
zones = (self.hass.states.get(entity_id) for entity_id
|
||||
in sorted(self.hass.states.entity_ids('zone')))
|
||||
|
||||
distances = []
|
||||
for zone_state in zones:
|
||||
zone_state_lat = zone_state.attributes['latitude']
|
||||
zone_state_long = zone_state.attributes['longitude']
|
||||
zone_distance = distance(
|
||||
latitude, longitude, zone_state_lat, zone_state_long)
|
||||
distances.append(round(zone_distance / 1000, 1))
|
||||
|
||||
if distances:
|
||||
mindistance = min(distances)
|
||||
else:
|
||||
mindistance = None
|
||||
|
||||
self._overridestates[devicename] = None
|
||||
|
||||
if currentzone is not None:
|
||||
self._intervals[devicename] = 30
|
||||
return
|
||||
|
||||
if distancefromhome is None:
|
||||
if mindistance is None:
|
||||
return
|
||||
if distancefromhome > 25:
|
||||
self._intervals[devicename] = round(distancefromhome / 2, 0)
|
||||
elif distancefromhome > 10:
|
||||
self._intervals[devicename] = 5
|
||||
else:
|
||||
self._intervals[devicename] = 1
|
||||
if battery is not None and battery <= 33 and distancefromhome > 3:
|
||||
self._intervals[devicename] = self._intervals[devicename] * 2
|
||||
|
||||
# Calculate out how long it would take for the device to drive to the
|
||||
# nearest zone at 120 km/h:
|
||||
interval = round(mindistance / 2, 0)
|
||||
|
||||
# Never poll more than once per minute
|
||||
interval = max(interval, 1)
|
||||
|
||||
if interval > 180:
|
||||
# Three hour drive? This is far enough that they might be flying
|
||||
# home - check every half hour
|
||||
interval = 30
|
||||
|
||||
if battery is not None and battery <= 33 and mindistance > 3:
|
||||
# Low battery - let's check half as often
|
||||
interval = interval * 2
|
||||
|
||||
self._intervals[devicename] = interval
|
||||
|
||||
def update_device(self, devicename):
|
||||
"""Update the device_tracker entity."""
|
||||
|
@ -44,14 +44,15 @@ class TeslaDeviceTracker(object):
|
||||
_LOGGER.debug("Updating device position: %s", name)
|
||||
dev_id = slugify(device.uniq_name)
|
||||
location = device.get_location()
|
||||
lat = location['latitude']
|
||||
lon = location['longitude']
|
||||
attrs = {
|
||||
'trackr_id': dev_id,
|
||||
'id': dev_id,
|
||||
'name': name
|
||||
}
|
||||
self.see(
|
||||
dev_id=dev_id, host_name=name,
|
||||
gps=(lat, lon), attributes=attrs
|
||||
)
|
||||
if location:
|
||||
lat = location['latitude']
|
||||
lon = location['longitude']
|
||||
attrs = {
|
||||
'trackr_id': dev_id,
|
||||
'id': dev_id,
|
||||
'name': name
|
||||
}
|
||||
self.see(
|
||||
dev_id=dev_id, host_name=name,
|
||||
gps=(lat, lon), attributes=attrs
|
||||
)
|
||||
|
@ -23,7 +23,8 @@ CONF_DHCP_SOFTWARE = 'dhcp_software'
|
||||
DEFAULT_DHCP_SOFTWARE = 'dnsmasq'
|
||||
DHCP_SOFTWARES = [
|
||||
'dnsmasq',
|
||||
'odhcpd'
|
||||
'odhcpd',
|
||||
'none'
|
||||
]
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
@ -40,8 +41,10 @@ def get_scanner(hass, config):
|
||||
dhcp_sw = config[DOMAIN][CONF_DHCP_SOFTWARE]
|
||||
if dhcp_sw == 'dnsmasq':
|
||||
scanner = DnsmasqUbusDeviceScanner(config[DOMAIN])
|
||||
else:
|
||||
elif dhcp_sw == 'odhcpd':
|
||||
scanner = OdhcpdUbusDeviceScanner(config[DOMAIN])
|
||||
else:
|
||||
scanner = UbusDeviceScanner(config[DOMAIN])
|
||||
|
||||
return scanner if scanner.success_init else None
|
||||
|
||||
@ -92,8 +95,8 @@ class UbusDeviceScanner(DeviceScanner):
|
||||
return self.last_results
|
||||
|
||||
def _generate_mac2name(self):
|
||||
"""Must be implemented depending on the software."""
|
||||
raise NotImplementedError
|
||||
"""Return empty MAC to name dict. Overriden if DHCP server is set."""
|
||||
self.mac2name = dict()
|
||||
|
||||
@_refresh_on_access_denied
|
||||
def get_device_name(self, device):
|
||||
|
@ -71,6 +71,7 @@ SERVICE_HANDLERS = {
|
||||
'sabnzbd': ('sensor', 'sabnzbd'),
|
||||
'bose_soundtouch': ('media_player', 'soundtouch'),
|
||||
'bluesound': ('media_player', 'bluesound'),
|
||||
'songpal': ('media_player', 'songpal'),
|
||||
}
|
||||
|
||||
CONF_IGNORE = 'ignore'
|
||||
|
123
homeassistant/components/egardia.py
Normal file
123
homeassistant/components/egardia.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""
|
||||
Interfaces with Egardia/Woonveilig alarm control panel.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/egardia/
|
||||
"""
|
||||
import logging
|
||||
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.helpers import discovery
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.const import (
|
||||
CONF_PORT, CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME,
|
||||
EVENT_HOMEASSISTANT_STOP)
|
||||
|
||||
REQUIREMENTS = ['pythonegardia==1.0.38']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_REPORT_SERVER_CODES = 'report_server_codes'
|
||||
CONF_REPORT_SERVER_ENABLED = 'report_server_enabled'
|
||||
CONF_REPORT_SERVER_PORT = 'report_server_port'
|
||||
REPORT_SERVER_CODES_IGNORE = 'ignore'
|
||||
CONF_VERSION = 'version'
|
||||
|
||||
DEFAULT_NAME = 'Egardia'
|
||||
DEFAULT_PORT = 80
|
||||
DEFAULT_REPORT_SERVER_ENABLED = False
|
||||
DEFAULT_REPORT_SERVER_PORT = 52010
|
||||
DEFAULT_VERSION = 'GATE-01'
|
||||
DOMAIN = 'egardia'
|
||||
EGARDIA_SERVER = 'egardia_server'
|
||||
EGARDIA_DEVICE = 'egardiadevice'
|
||||
EGARDIA_NAME = 'egardianame'
|
||||
EGARDIA_REPORT_SERVER_ENABLED = 'egardia_rs_enabled'
|
||||
EGARDIA_REPORT_SERVER_CODES = 'egardia_rs_codes'
|
||||
NOTIFICATION_ID = 'egardia_notification'
|
||||
NOTIFICATION_TITLE = 'Egardia'
|
||||
ATTR_DISCOVER_DEVICES = 'egardia_sensor'
|
||||
|
||||
SERVER_CODE_SCHEMA = vol.Schema({
|
||||
vol.Optional('arm'): vol.All(cv.ensure_list_csv, [cv.string]),
|
||||
vol.Optional('disarm'): vol.All(cv.ensure_list_csv, [cv.string]),
|
||||
vol.Optional('armhome'): vol.All(cv.ensure_list_csv, [cv.string]),
|
||||
vol.Optional('triggered'): vol.All(cv.ensure_list_csv, [cv.string]),
|
||||
vol.Optional('ignore'): vol.All(cv.ensure_list_csv, [cv.string])
|
||||
})
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_PASSWORD): cv.string,
|
||||
vol.Required(CONF_USERNAME): cv.string,
|
||||
vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
vol.Optional(CONF_REPORT_SERVER_CODES, default={}): SERVER_CODE_SCHEMA,
|
||||
vol.Optional(CONF_REPORT_SERVER_ENABLED,
|
||||
default=DEFAULT_REPORT_SERVER_ENABLED): cv.boolean,
|
||||
vol.Optional(CONF_REPORT_SERVER_PORT,
|
||||
default=DEFAULT_REPORT_SERVER_PORT): cv.port,
|
||||
}),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
def setup(hass, config):
|
||||
"""Set up the Egardia platform."""
|
||||
from pythonegardia import egardiadevice
|
||||
from pythonegardia import egardiaserver
|
||||
conf = config[DOMAIN]
|
||||
username = conf.get(CONF_USERNAME)
|
||||
password = conf.get(CONF_PASSWORD)
|
||||
host = conf.get(CONF_HOST)
|
||||
port = conf.get(CONF_PORT)
|
||||
version = conf.get(CONF_VERSION)
|
||||
rs_enabled = conf.get(CONF_REPORT_SERVER_ENABLED)
|
||||
rs_port = conf.get(CONF_REPORT_SERVER_PORT)
|
||||
try:
|
||||
device = hass.data[EGARDIA_DEVICE] = egardiadevice.EgardiaDevice(
|
||||
host, port, username, password, '', version)
|
||||
except requests.exceptions.RequestException:
|
||||
_LOGGER.error("An error occurred accessing your Egardia device. " +
|
||||
"Please check config.")
|
||||
return False
|
||||
except egardiadevice.UnauthorizedError:
|
||||
_LOGGER.error("Unable to authorize. Wrong password or username.")
|
||||
return False
|
||||
# Set up the egardia server if enabled
|
||||
if rs_enabled:
|
||||
_LOGGER.debug("Setting up EgardiaServer")
|
||||
try:
|
||||
if EGARDIA_SERVER not in hass.data:
|
||||
server = egardiaserver.EgardiaServer('', rs_port)
|
||||
bound = server.bind()
|
||||
if not bound:
|
||||
raise IOError("Binding error occurred while " +
|
||||
"starting EgardiaServer.")
|
||||
hass.data[EGARDIA_SERVER] = server
|
||||
server.start()
|
||||
|
||||
def handle_stop_event(event):
|
||||
"""Callback function for HA stop event."""
|
||||
server.stop()
|
||||
|
||||
# listen to home assistant stop event
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop_event)
|
||||
|
||||
except IOError:
|
||||
_LOGGER.error("Binding error occurred while starting " +
|
||||
"EgardiaServer.")
|
||||
return False
|
||||
|
||||
discovery.load_platform(hass, 'alarm_control_panel', DOMAIN,
|
||||
discovered=conf, hass_config=config)
|
||||
|
||||
# get the sensors from the device and add those
|
||||
sensors = device.getsensors()
|
||||
discovery.load_platform(hass, 'binary_sensor', DOMAIN,
|
||||
{ATTR_DISCOVER_DEVICES: sensors}, config)
|
||||
|
||||
return True
|
@ -4,7 +4,6 @@ Support for local control of entities by emulating the Phillips Hue bridge.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/emulated_hue/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
@ -111,17 +110,15 @@ def setup(hass, yaml_config):
|
||||
config.upnp_bind_multicast, config.advertise_ip,
|
||||
config.advertise_port)
|
||||
|
||||
@asyncio.coroutine
|
||||
def stop_emulated_hue_bridge(event):
|
||||
async def stop_emulated_hue_bridge(event):
|
||||
"""Stop the emulated hue bridge."""
|
||||
upnp_listener.stop()
|
||||
yield from server.stop()
|
||||
await server.stop()
|
||||
|
||||
@asyncio.coroutine
|
||||
def start_emulated_hue_bridge(event):
|
||||
async def start_emulated_hue_bridge(event):
|
||||
"""Start the emulated hue bridge."""
|
||||
upnp_listener.start()
|
||||
yield from server.start()
|
||||
await server.start()
|
||||
hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge)
|
||||
|
||||
|
96
homeassistant/components/fan/insteon_plm.py
Normal file
96
homeassistant/components/fan/insteon_plm.py
Normal file
@ -0,0 +1,96 @@
|
||||
"""
|
||||
Support for INSTEON fans via PowerLinc Modem.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/fan.insteon_plm/
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from homeassistant.components.fan import (SPEED_OFF,
|
||||
SPEED_LOW,
|
||||
SPEED_MEDIUM,
|
||||
SPEED_HIGH,
|
||||
FanEntity,
|
||||
SUPPORT_SET_SPEED)
|
||||
from homeassistant.const import STATE_OFF
|
||||
from homeassistant.components.insteon_plm import InsteonPLMEntity
|
||||
|
||||
DEPENDENCIES = ['insteon_plm']
|
||||
|
||||
SPEED_TO_HEX = {SPEED_OFF: 0x00,
|
||||
SPEED_LOW: 0x3f,
|
||||
SPEED_MEDIUM: 0xbe,
|
||||
SPEED_HIGH: 0xff}
|
||||
|
||||
FAN_SPEEDS = [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the INSTEON PLM device class for the hass platform."""
|
||||
plm = hass.data['insteon_plm']
|
||||
|
||||
address = discovery_info['address']
|
||||
device = plm.devices[address]
|
||||
state_key = discovery_info['state_key']
|
||||
|
||||
_LOGGER.debug('Adding device %s entity %s to Fan platform',
|
||||
device.address.hex, device.states[state_key].name)
|
||||
|
||||
new_entity = InsteonPLMFan(device, state_key)
|
||||
|
||||
async_add_devices([new_entity])
|
||||
|
||||
|
||||
class InsteonPLMFan(InsteonPLMEntity, FanEntity):
|
||||
"""An INSTEON fan component."""
|
||||
|
||||
@property
|
||||
def speed(self) -> str:
|
||||
"""Return the current speed."""
|
||||
return self._hex_to_speed(self._insteon_device_state.value)
|
||||
|
||||
@property
|
||||
def speed_list(self) -> list:
|
||||
"""Get the list of available speeds."""
|
||||
return FAN_SPEEDS
|
||||
|
||||
@property
|
||||
def supported_features(self) -> int:
|
||||
"""Flag supported features."""
|
||||
return SUPPORT_SET_SPEED
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_on(self, speed: str = None, **kwargs) -> None:
|
||||
"""Turn on the entity."""
|
||||
if speed is None:
|
||||
speed = SPEED_MEDIUM
|
||||
yield from self.async_set_speed(speed)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_off(self, **kwargs) -> None:
|
||||
"""Turn off the entity."""
|
||||
yield from self.async_set_speed(SPEED_OFF)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_speed(self, speed: str) -> None:
|
||||
"""Set the speed of the fan."""
|
||||
fan_speed = SPEED_TO_HEX[speed]
|
||||
if fan_speed == 0x00:
|
||||
self._insteon_device_state.off()
|
||||
else:
|
||||
self._insteon_device_state.set_level(fan_speed)
|
||||
|
||||
@staticmethod
|
||||
def _hex_to_speed(speed: int):
|
||||
hex_speed = SPEED_OFF
|
||||
if speed > 0xfe:
|
||||
hex_speed = SPEED_HIGH
|
||||
elif speed > 0x7f:
|
||||
hex_speed = SPEED_MEDIUM
|
||||
elif speed > 0:
|
||||
hex_speed = SPEED_LOW
|
||||
return hex_speed
|
@ -21,9 +21,10 @@ from homeassistant.components.http.const import KEY_AUTHENTICATED
|
||||
from homeassistant.config import find_config_file, load_yaml_config_file
|
||||
from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers.translation import async_get_translations
|
||||
from homeassistant.loader import bind_hass
|
||||
|
||||
REQUIREMENTS = ['home-assistant-frontend==20180227.0', 'user-agents==1.1.0']
|
||||
REQUIREMENTS = ['home-assistant-frontend==20180309.0']
|
||||
|
||||
DOMAIN = 'frontend'
|
||||
DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log']
|
||||
@ -379,6 +380,8 @@ def async_setup(hass, config):
|
||||
|
||||
async_setup_themes(hass, conf.get(CONF_THEMES))
|
||||
|
||||
hass.http.register_view(TranslationsView)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@ -541,6 +544,23 @@ class ThemesView(HomeAssistantView):
|
||||
})
|
||||
|
||||
|
||||
class TranslationsView(HomeAssistantView):
|
||||
"""View to return backend defined translations."""
|
||||
|
||||
url = '/api/translations/{language}'
|
||||
name = 'api:translations'
|
||||
|
||||
@asyncio.coroutine
|
||||
def get(self, request, language):
|
||||
"""Return translations."""
|
||||
hass = request.app['hass']
|
||||
|
||||
resources = yield from async_get_translations(hass, language)
|
||||
return self.json({
|
||||
'resources': resources,
|
||||
})
|
||||
|
||||
|
||||
def _fingerprint(path):
|
||||
"""Fingerprint a file."""
|
||||
with open(path) as fil:
|
||||
@ -553,6 +573,8 @@ def _is_latest(js_option, request):
|
||||
|
||||
Set according to user's preference and URL override.
|
||||
"""
|
||||
import hass_frontend
|
||||
|
||||
if request is None:
|
||||
return js_option == 'latest'
|
||||
|
||||
@ -573,25 +595,5 @@ def _is_latest(js_option, request):
|
||||
return js_option == 'latest'
|
||||
|
||||
useragent = request.headers.get('User-Agent')
|
||||
if not useragent:
|
||||
return False
|
||||
|
||||
from user_agents import parse
|
||||
useragent = parse(useragent)
|
||||
|
||||
# on iOS every browser is a Safari which we support from version 11.
|
||||
if useragent.os.family == 'iOS':
|
||||
# Was >= 10, temp setting it to 12 to work around issue #11387
|
||||
return useragent.os.version[0] >= 12
|
||||
|
||||
family_min_version = {
|
||||
'Chrome': 54, # Object.values
|
||||
'Chrome Mobile': 54,
|
||||
'Firefox': 47, # Object.values
|
||||
'Firefox Mobile': 47,
|
||||
'Opera': 41, # Object.values
|
||||
'Edge': 14, # Array.prototype.includes added in 14
|
||||
'Safari': 10, # Many features not supported by 9
|
||||
}
|
||||
version = family_min_version.get(useragent.browser.family)
|
||||
return version and useragent.browser.version[0] >= version
|
||||
return useragent and hass_frontend.version(useragent)
|
||||
|
@ -17,7 +17,7 @@ import voluptuous as vol
|
||||
from homeassistant.core import HomeAssistant # NOQA
|
||||
from typing import Dict, Any # NOQA
|
||||
|
||||
from homeassistant.const import CONF_NAME, CONF_TYPE
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.loader import bind_hass
|
||||
@ -31,7 +31,6 @@ from .const import (
|
||||
)
|
||||
from .auth import GoogleAssistantAuthView
|
||||
from .http import async_register_http
|
||||
from .smart_home import MAPPING_COMPONENT
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -41,7 +40,6 @@ DEFAULT_AGENT_USER_ID = 'home-assistant'
|
||||
|
||||
ENTITY_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
vol.Optional(CONF_TYPE): vol.In(MAPPING_COMPONENT),
|
||||
vol.Optional(CONF_EXPOSE): cv.boolean,
|
||||
vol.Optional(CONF_ALIASES): vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_ROOM_HINT): cv.string
|
||||
|
@ -22,25 +22,6 @@ DEFAULT_EXPOSED_DOMAINS = [
|
||||
CLIMATE_MODE_HEATCOOL = 'heatcool'
|
||||
CLIMATE_SUPPORTED_MODES = {'heat', 'cool', 'off', 'on', CLIMATE_MODE_HEATCOOL}
|
||||
|
||||
PREFIX_TRAITS = 'action.devices.traits.'
|
||||
TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff'
|
||||
TRAIT_BRIGHTNESS = PREFIX_TRAITS + 'Brightness'
|
||||
TRAIT_RGB_COLOR = PREFIX_TRAITS + 'ColorSpectrum'
|
||||
TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature'
|
||||
TRAIT_SCENE = PREFIX_TRAITS + 'Scene'
|
||||
TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting'
|
||||
|
||||
PREFIX_COMMANDS = 'action.devices.commands.'
|
||||
COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff'
|
||||
COMMAND_BRIGHTNESS = PREFIX_COMMANDS + 'BrightnessAbsolute'
|
||||
COMMAND_COLOR = PREFIX_COMMANDS + 'ColorAbsolute'
|
||||
COMMAND_ACTIVATESCENE = PREFIX_COMMANDS + 'ActivateScene'
|
||||
COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = (
|
||||
PREFIX_COMMANDS + 'ThermostatTemperatureSetpoint')
|
||||
COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = (
|
||||
PREFIX_COMMANDS + 'ThermostatTemperatureSetRange')
|
||||
COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode'
|
||||
|
||||
PREFIX_TYPES = 'action.devices.types.'
|
||||
TYPE_LIGHT = PREFIX_TYPES + 'LIGHT'
|
||||
TYPE_SWITCH = PREFIX_TYPES + 'SWITCH'
|
||||
@ -50,3 +31,12 @@ TYPE_THERMOSTAT = PREFIX_TYPES + 'THERMOSTAT'
|
||||
SERVICE_REQUEST_SYNC = 'request_sync'
|
||||
HOMEGRAPH_URL = 'https://homegraph.googleapis.com/'
|
||||
REQUEST_SYNC_BASE_URL = HOMEGRAPH_URL + 'v1/devices:requestSync'
|
||||
|
||||
# Error codes used for SmartHomeError class
|
||||
# https://developers.google.com/actions/smarthome/create-app#error_responses
|
||||
ERR_DEVICE_OFFLINE = "deviceOffline"
|
||||
ERR_DEVICE_NOT_FOUND = "deviceNotFound"
|
||||
ERR_VALUE_OUT_OF_RANGE = "valueOutOfRange"
|
||||
ERR_NOT_SUPPORTED = "notSupported"
|
||||
ERR_PROTOCOL_ERROR = 'protocolError'
|
||||
ERR_UNKNOWN_ERROR = 'unknownError'
|
||||
|
23
homeassistant/components/google_assistant/helpers.py
Normal file
23
homeassistant/components/google_assistant/helpers.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""Helper classes for Google Assistant integration."""
|
||||
|
||||
|
||||
class SmartHomeError(Exception):
|
||||
"""Google Assistant Smart Home errors.
|
||||
|
||||
https://developers.google.com/actions/smarthome/create-app#error_responses
|
||||
"""
|
||||
|
||||
def __init__(self, code, msg):
|
||||
"""Log error code."""
|
||||
super().__init__(msg)
|
||||
self.code = code
|
||||
|
||||
|
||||
class Config:
|
||||
"""Hold the configuration for Google Assistant."""
|
||||
|
||||
def __init__(self, should_expose, agent_user_id, entity_config=None):
|
||||
"""Initialize the configuration."""
|
||||
self.should_expose = should_expose
|
||||
self.agent_user_id = agent_user_id
|
||||
self.entity_config = entity_config or {}
|
@ -10,8 +10,6 @@ import logging
|
||||
from aiohttp.hdrs import AUTHORIZATION
|
||||
from aiohttp.web import Request, Response # NOQA
|
||||
|
||||
from homeassistant.const import HTTP_UNAUTHORIZED
|
||||
|
||||
# Typing imports
|
||||
# pylint: disable=using-constant-test,unused-import,ungrouped-imports
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
@ -27,7 +25,8 @@ from .const import (
|
||||
CONF_ENTITY_CONFIG,
|
||||
CONF_EXPOSE,
|
||||
)
|
||||
from .smart_home import async_handle_message, Config
|
||||
from .smart_home import async_handle_message
|
||||
from .helpers import Config
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -83,8 +82,7 @@ class GoogleAssistantView(HomeAssistantView):
|
||||
"""Handle Google Assistant requests."""
|
||||
auth = request.headers.get(AUTHORIZATION, None)
|
||||
if 'Bearer {}'.format(self.access_token) != auth:
|
||||
return self.json_message(
|
||||
"missing authorization", status_code=HTTP_UNAUTHORIZED)
|
||||
return self.json_message("missing authorization", status_code=401)
|
||||
|
||||
message = yield from request.json() # type: dict
|
||||
result = yield from async_handle_message(
|
||||
|
@ -1,5 +1,6 @@
|
||||
"""Support for Google Assistant Smart Home API."""
|
||||
import asyncio
|
||||
import collections
|
||||
from itertools import product
|
||||
import logging
|
||||
|
||||
# Typing imports
|
||||
@ -9,447 +10,222 @@ from aiohttp.web import Request, Response # NOQA
|
||||
from typing import Dict, Tuple, Any, Optional # NOQA
|
||||
from homeassistant.helpers.entity import Entity # NOQA
|
||||
from homeassistant.core import HomeAssistant # NOQA
|
||||
from homeassistant.util import color
|
||||
from homeassistant.util.unit_system import UnitSystem # NOQA
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import (
|
||||
ATTR_SUPPORTED_FEATURES, ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT,
|
||||
STATE_OFF, SERVICE_TURN_OFF, SERVICE_TURN_ON,
|
||||
TEMP_FAHRENHEIT, TEMP_CELSIUS,
|
||||
CONF_NAME, CONF_TYPE
|
||||
)
|
||||
CONF_NAME, STATE_UNAVAILABLE, ATTR_SUPPORTED_FEATURES)
|
||||
from homeassistant.components import (
|
||||
switch, light, cover, media_player, group, fan, scene, script, climate,
|
||||
sensor
|
||||
)
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from . import trait
|
||||
from .const import (
|
||||
COMMAND_COLOR,
|
||||
COMMAND_BRIGHTNESS, COMMAND_ONOFF, COMMAND_ACTIVATESCENE,
|
||||
COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT,
|
||||
COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, COMMAND_THERMOSTAT_SET_MODE,
|
||||
TRAIT_ONOFF, TRAIT_BRIGHTNESS, TRAIT_COLOR_TEMP,
|
||||
TRAIT_RGB_COLOR, TRAIT_SCENE, TRAIT_TEMPERATURE_SETTING,
|
||||
TYPE_LIGHT, TYPE_SCENE, TYPE_SWITCH, TYPE_THERMOSTAT,
|
||||
CONF_ALIASES, CONF_ROOM_HINT, CLIMATE_SUPPORTED_MODES,
|
||||
CLIMATE_MODE_HEATCOOL
|
||||
CONF_ALIASES, CONF_ROOM_HINT,
|
||||
ERR_NOT_SUPPORTED, ERR_PROTOCOL_ERROR, ERR_DEVICE_OFFLINE,
|
||||
ERR_UNKNOWN_ERROR
|
||||
)
|
||||
from .helpers import SmartHomeError
|
||||
|
||||
HANDLERS = Registry()
|
||||
QUERY_HANDLERS = Registry()
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
# Mapping is [actions schema, primary trait, optional features]
|
||||
# optional is SUPPORT_* = (trait, command)
|
||||
MAPPING_COMPONENT = {
|
||||
group.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None],
|
||||
scene.DOMAIN: [TYPE_SCENE, TRAIT_SCENE, None],
|
||||
script.DOMAIN: [TYPE_SCENE, TRAIT_SCENE, None],
|
||||
switch.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None],
|
||||
fan.DOMAIN: [TYPE_SWITCH, TRAIT_ONOFF, None],
|
||||
light.DOMAIN: [
|
||||
TYPE_LIGHT, TRAIT_ONOFF, {
|
||||
light.SUPPORT_BRIGHTNESS: TRAIT_BRIGHTNESS,
|
||||
light.SUPPORT_RGB_COLOR: TRAIT_RGB_COLOR,
|
||||
light.SUPPORT_COLOR_TEMP: TRAIT_COLOR_TEMP,
|
||||
DOMAIN_TO_GOOGLE_TYPES = {
|
||||
group.DOMAIN: TYPE_SWITCH,
|
||||
scene.DOMAIN: TYPE_SCENE,
|
||||
script.DOMAIN: TYPE_SCENE,
|
||||
switch.DOMAIN: TYPE_SWITCH,
|
||||
fan.DOMAIN: TYPE_SWITCH,
|
||||
light.DOMAIN: TYPE_LIGHT,
|
||||
cover.DOMAIN: TYPE_SWITCH,
|
||||
media_player.DOMAIN: TYPE_SWITCH,
|
||||
climate.DOMAIN: TYPE_THERMOSTAT,
|
||||
}
|
||||
|
||||
|
||||
def deep_update(target, source):
|
||||
"""Update a nested dictionary with another nested dictionary."""
|
||||
for key, value in source.items():
|
||||
if isinstance(value, collections.Mapping):
|
||||
target[key] = deep_update(target.get(key, {}), value)
|
||||
else:
|
||||
target[key] = value
|
||||
return target
|
||||
|
||||
|
||||
class _GoogleEntity:
|
||||
"""Adaptation of Entity expressed in Google's terms."""
|
||||
|
||||
def __init__(self, hass, config, state):
|
||||
self.hass = hass
|
||||
self.config = config
|
||||
self.state = state
|
||||
|
||||
@property
|
||||
def entity_id(self):
|
||||
"""Return entity ID."""
|
||||
return self.state.entity_id
|
||||
|
||||
@callback
|
||||
def traits(self):
|
||||
"""Return traits for entity."""
|
||||
state = self.state
|
||||
domain = state.domain
|
||||
features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
|
||||
return [Trait(state) for Trait in trait.TRAITS
|
||||
if Trait.supported(domain, features)]
|
||||
|
||||
@callback
|
||||
def sync_serialize(self):
|
||||
"""Serialize entity for a SYNC response.
|
||||
|
||||
https://developers.google.com/actions/smarthome/create-app#actiondevicessync
|
||||
"""
|
||||
traits = self.traits()
|
||||
state = self.state
|
||||
|
||||
# Found no supported traits for this entity
|
||||
if not traits:
|
||||
return None
|
||||
|
||||
entity_config = self.config.entity_config.get(state.entity_id, {})
|
||||
|
||||
device = {
|
||||
'id': state.entity_id,
|
||||
'name': {
|
||||
'name': entity_config.get(CONF_NAME) or state.name
|
||||
},
|
||||
'attributes': {},
|
||||
'traits': [trait.name for trait in traits],
|
||||
'willReportState': False,
|
||||
'type': DOMAIN_TO_GOOGLE_TYPES[state.domain],
|
||||
}
|
||||
],
|
||||
cover.DOMAIN: [
|
||||
TYPE_SWITCH, TRAIT_ONOFF, {
|
||||
cover.SUPPORT_SET_POSITION: TRAIT_BRIGHTNESS
|
||||
}
|
||||
],
|
||||
media_player.DOMAIN: [
|
||||
TYPE_SWITCH, TRAIT_ONOFF, {
|
||||
media_player.SUPPORT_VOLUME_SET: TRAIT_BRIGHTNESS
|
||||
}
|
||||
],
|
||||
climate.DOMAIN: [TYPE_THERMOSTAT, TRAIT_TEMPERATURE_SETTING, None],
|
||||
} # type: Dict[str, list]
|
||||
|
||||
# use aliases
|
||||
aliases = entity_config.get(CONF_ALIASES)
|
||||
if aliases:
|
||||
device['name']['nicknames'] = aliases
|
||||
|
||||
# add room hint if annotated
|
||||
room = entity_config.get(CONF_ROOM_HINT)
|
||||
if room:
|
||||
device['roomHint'] = room
|
||||
|
||||
for trt in traits:
|
||||
device['attributes'].update(trt.sync_attributes())
|
||||
|
||||
return device
|
||||
|
||||
@callback
|
||||
def query_serialize(self):
|
||||
"""Serialize entity for a QUERY response.
|
||||
|
||||
https://developers.google.com/actions/smarthome/create-app#actiondevicesquery
|
||||
"""
|
||||
state = self.state
|
||||
|
||||
if state.state == STATE_UNAVAILABLE:
|
||||
return {'online': False}
|
||||
|
||||
attrs = {'online': True}
|
||||
|
||||
for trt in self.traits():
|
||||
deep_update(attrs, trt.query_attributes())
|
||||
|
||||
return attrs
|
||||
|
||||
async def execute(self, command, params):
|
||||
"""Execute a command.
|
||||
|
||||
https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute
|
||||
"""
|
||||
executed = False
|
||||
for trt in self.traits():
|
||||
if trt.can_execute(command, params):
|
||||
await trt.execute(self.hass, command, params)
|
||||
executed = True
|
||||
break
|
||||
|
||||
if not executed:
|
||||
raise SmartHomeError(
|
||||
ERR_NOT_SUPPORTED,
|
||||
'Unable to execute {} for {}'.format(command,
|
||||
self.state.entity_id))
|
||||
|
||||
@callback
|
||||
def async_update(self):
|
||||
"""Update the entity with latest info from Home Assistant."""
|
||||
self.state = self.hass.states.get(self.entity_id)
|
||||
|
||||
|
||||
"""Error code used for SmartHomeError class."""
|
||||
ERROR_NOT_SUPPORTED = "notSupported"
|
||||
async def async_handle_message(hass, config, message):
|
||||
"""Handle incoming API messages."""
|
||||
response = await _process(hass, config, message)
|
||||
|
||||
|
||||
class SmartHomeError(Exception):
|
||||
"""Google Assistant Smart Home errors."""
|
||||
|
||||
def __init__(self, code, msg):
|
||||
"""Log error code."""
|
||||
super(SmartHomeError, self).__init__(msg)
|
||||
_LOGGER.error(
|
||||
"An error has occurred in Google SmartHome: %s."
|
||||
"Error code: %s", msg, code
|
||||
)
|
||||
self.code = code
|
||||
|
||||
|
||||
class Config:
|
||||
"""Hold the configuration for Google Assistant."""
|
||||
|
||||
def __init__(self, should_expose, agent_user_id, entity_config=None):
|
||||
"""Initialize the configuration."""
|
||||
self.should_expose = should_expose
|
||||
self.agent_user_id = agent_user_id
|
||||
self.entity_config = entity_config or {}
|
||||
|
||||
|
||||
def entity_to_device(entity: Entity, config: Config, units: UnitSystem):
|
||||
"""Convert a hass entity into a google actions device."""
|
||||
entity_config = config.entity_config.get(entity.entity_id, {})
|
||||
google_domain = entity_config.get(CONF_TYPE)
|
||||
class_data = MAPPING_COMPONENT.get(
|
||||
google_domain or entity.domain)
|
||||
|
||||
if class_data is None:
|
||||
return None
|
||||
|
||||
device = {
|
||||
'id': entity.entity_id,
|
||||
'name': {},
|
||||
'attributes': {},
|
||||
'traits': [],
|
||||
'willReportState': False,
|
||||
}
|
||||
device['type'] = class_data[0]
|
||||
device['traits'].append(class_data[1])
|
||||
|
||||
# handle custom names
|
||||
device['name']['name'] = entity_config.get(CONF_NAME) or entity.name
|
||||
|
||||
# use aliases
|
||||
aliases = entity_config.get(CONF_ALIASES)
|
||||
if aliases:
|
||||
device['name']['nicknames'] = aliases
|
||||
|
||||
# add room hint if annotated
|
||||
room = entity_config.get(CONF_ROOM_HINT)
|
||||
if room:
|
||||
device['roomHint'] = room
|
||||
|
||||
# add trait if entity supports feature
|
||||
if class_data[2]:
|
||||
supported = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
for feature, trait in class_data[2].items():
|
||||
if feature & supported > 0:
|
||||
device['traits'].append(trait)
|
||||
|
||||
# Actions require this attributes for a device
|
||||
# supporting temperature
|
||||
# For IKEA trådfri, these attributes only seem to
|
||||
# be set only if the device is on?
|
||||
if trait == TRAIT_COLOR_TEMP:
|
||||
if entity.attributes.get(
|
||||
light.ATTR_MAX_MIREDS) is not None:
|
||||
device['attributes']['temperatureMinK'] = \
|
||||
int(round(color.color_temperature_mired_to_kelvin(
|
||||
entity.attributes.get(light.ATTR_MAX_MIREDS))))
|
||||
if entity.attributes.get(
|
||||
light.ATTR_MIN_MIREDS) is not None:
|
||||
device['attributes']['temperatureMaxK'] = \
|
||||
int(round(color.color_temperature_mired_to_kelvin(
|
||||
entity.attributes.get(light.ATTR_MIN_MIREDS))))
|
||||
|
||||
if entity.domain == climate.DOMAIN:
|
||||
modes = []
|
||||
for mode in entity.attributes.get(climate.ATTR_OPERATION_LIST, []):
|
||||
if mode in CLIMATE_SUPPORTED_MODES:
|
||||
modes.append(mode)
|
||||
elif mode == climate.STATE_AUTO:
|
||||
modes.append(CLIMATE_MODE_HEATCOOL)
|
||||
|
||||
device['attributes'] = {
|
||||
'availableThermostatModes': ','.join(modes),
|
||||
'thermostatTemperatureUnit':
|
||||
'F' if units.temperature_unit == TEMP_FAHRENHEIT else 'C',
|
||||
}
|
||||
_LOGGER.debug('Thermostat attributes %s', device['attributes'])
|
||||
|
||||
if entity.domain == sensor.DOMAIN:
|
||||
if google_domain == climate.DOMAIN:
|
||||
unit_of_measurement = entity.attributes.get(
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
units.temperature_unit
|
||||
)
|
||||
|
||||
device['attributes'] = {
|
||||
'thermostatTemperatureUnit':
|
||||
'F' if unit_of_measurement == TEMP_FAHRENHEIT else 'C',
|
||||
}
|
||||
_LOGGER.debug('Sensor attributes %s', device['attributes'])
|
||||
|
||||
return device
|
||||
|
||||
|
||||
def celsius(deg: Optional[float], units: UnitSystem) -> Optional[float]:
|
||||
"""Convert a float to Celsius and rounds to one decimal place."""
|
||||
if deg is None:
|
||||
return None
|
||||
return round(METRIC_SYSTEM.temperature(deg, units.temperature_unit), 1)
|
||||
|
||||
|
||||
@QUERY_HANDLERS.register(sensor.DOMAIN)
|
||||
def query_response_sensor(
|
||||
entity: Entity, config: Config, units: UnitSystem) -> dict:
|
||||
"""Convert a sensor entity to a QUERY response."""
|
||||
entity_config = config.entity_config.get(entity.entity_id, {})
|
||||
google_domain = entity_config.get(CONF_TYPE)
|
||||
|
||||
if google_domain != climate.DOMAIN:
|
||||
raise SmartHomeError(
|
||||
ERROR_NOT_SUPPORTED,
|
||||
"Sensor type {} is not supported".format(google_domain)
|
||||
)
|
||||
|
||||
# check if we have a string value to convert it to number
|
||||
value = entity.state
|
||||
if isinstance(entity.state, str):
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
value = None
|
||||
|
||||
if value is None:
|
||||
raise SmartHomeError(
|
||||
ERROR_NOT_SUPPORTED,
|
||||
"Invalid value {} for the climate sensor"
|
||||
.format(entity.state)
|
||||
)
|
||||
|
||||
# detect if we report temperature or humidity
|
||||
unit_of_measurement = entity.attributes.get(
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
units.temperature_unit
|
||||
)
|
||||
if unit_of_measurement in [TEMP_FAHRENHEIT, TEMP_CELSIUS]:
|
||||
value = celsius(value, units)
|
||||
attr = 'thermostatTemperatureAmbient'
|
||||
elif unit_of_measurement == '%':
|
||||
attr = 'thermostatHumidityAmbient'
|
||||
else:
|
||||
raise SmartHomeError(
|
||||
ERROR_NOT_SUPPORTED,
|
||||
"Unit {} is not supported by the climate sensor"
|
||||
.format(unit_of_measurement)
|
||||
)
|
||||
|
||||
return {attr: value}
|
||||
|
||||
|
||||
@QUERY_HANDLERS.register(climate.DOMAIN)
|
||||
def query_response_climate(
|
||||
entity: Entity, config: Config, units: UnitSystem) -> dict:
|
||||
"""Convert a climate entity to a QUERY response."""
|
||||
mode = entity.attributes.get(climate.ATTR_OPERATION_MODE)
|
||||
if mode is None:
|
||||
mode = entity.state
|
||||
mode = mode.lower()
|
||||
if mode not in CLIMATE_SUPPORTED_MODES:
|
||||
mode = 'heat'
|
||||
attrs = entity.attributes
|
||||
response = {
|
||||
'thermostatMode': mode,
|
||||
'thermostatTemperatureSetpoint':
|
||||
celsius(attrs.get(climate.ATTR_TEMPERATURE), units),
|
||||
'thermostatTemperatureAmbient':
|
||||
celsius(attrs.get(climate.ATTR_CURRENT_TEMPERATURE), units),
|
||||
'thermostatTemperatureSetpointHigh':
|
||||
celsius(attrs.get(climate.ATTR_TARGET_TEMP_HIGH), units),
|
||||
'thermostatTemperatureSetpointLow':
|
||||
celsius(attrs.get(climate.ATTR_TARGET_TEMP_LOW), units),
|
||||
'thermostatHumidityAmbient':
|
||||
attrs.get(climate.ATTR_CURRENT_HUMIDITY),
|
||||
}
|
||||
return {k: v for k, v in response.items() if v is not None}
|
||||
|
||||
|
||||
@QUERY_HANDLERS.register(media_player.DOMAIN)
|
||||
def query_response_media_player(
|
||||
entity: Entity, config: Config, units: UnitSystem) -> dict:
|
||||
"""Convert a media_player entity to a QUERY response."""
|
||||
level = entity.attributes.get(
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL,
|
||||
1.0 if entity.state != STATE_OFF else 0.0)
|
||||
# Convert 0.0-1.0 to 0-255
|
||||
brightness = int(level * 100)
|
||||
|
||||
return {'brightness': brightness}
|
||||
|
||||
|
||||
@QUERY_HANDLERS.register(light.DOMAIN)
|
||||
def query_response_light(
|
||||
entity: Entity, config: Config, units: UnitSystem) -> dict:
|
||||
"""Convert a light entity to a QUERY response."""
|
||||
response = {} # type: Dict[str, Any]
|
||||
|
||||
brightness = entity.attributes.get(light.ATTR_BRIGHTNESS)
|
||||
if brightness is not None:
|
||||
response['brightness'] = int(100 * (brightness / 255))
|
||||
|
||||
supported_features = entity.attributes.get(ATTR_SUPPORTED_FEATURES, 0)
|
||||
if supported_features & \
|
||||
(light.SUPPORT_COLOR_TEMP | light.SUPPORT_RGB_COLOR):
|
||||
response['color'] = {}
|
||||
|
||||
if entity.attributes.get(light.ATTR_COLOR_TEMP) is not None:
|
||||
response['color']['temperature'] = \
|
||||
int(round(color.color_temperature_mired_to_kelvin(
|
||||
entity.attributes.get(light.ATTR_COLOR_TEMP))))
|
||||
|
||||
if entity.attributes.get(light.ATTR_COLOR_NAME) is not None:
|
||||
response['color']['name'] = \
|
||||
entity.attributes.get(light.ATTR_COLOR_NAME)
|
||||
|
||||
if entity.attributes.get(light.ATTR_RGB_COLOR) is not None:
|
||||
color_rgb = entity.attributes.get(light.ATTR_RGB_COLOR)
|
||||
if color_rgb is not None:
|
||||
response['color']['spectrumRGB'] = \
|
||||
int(color.color_rgb_to_hex(
|
||||
color_rgb[0], color_rgb[1], color_rgb[2]), 16)
|
||||
if 'errorCode' in response['payload']:
|
||||
_LOGGER.error('Error handling message %s: %s',
|
||||
message, response['payload'])
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def query_device(entity: Entity, config: Config, units: UnitSystem) -> dict:
|
||||
"""Take an entity and return a properly formatted device object."""
|
||||
state = entity.state != STATE_OFF
|
||||
defaults = {
|
||||
'on': state,
|
||||
'online': True
|
||||
}
|
||||
|
||||
handler = QUERY_HANDLERS.get(entity.domain)
|
||||
if callable(handler):
|
||||
defaults.update(handler(entity, config, units))
|
||||
|
||||
return defaults
|
||||
|
||||
|
||||
# erroneous bug on old pythons and pylint
|
||||
# https://github.com/PyCQA/pylint/issues/1212
|
||||
# pylint: disable=invalid-sequence-index
|
||||
def determine_service(
|
||||
entity_id: str, command: str, params: dict,
|
||||
units: UnitSystem) -> Tuple[str, dict]:
|
||||
"""
|
||||
Determine service and service_data.
|
||||
|
||||
Attempt to return a tuple of service and service_data based on the entity
|
||||
and action requested.
|
||||
"""
|
||||
_LOGGER.debug("Handling command %s with data %s", command, params)
|
||||
domain = entity_id.split('.')[0]
|
||||
service_data = {ATTR_ENTITY_ID: entity_id} # type: Dict[str, Any]
|
||||
# special media_player handling
|
||||
if domain == media_player.DOMAIN and command == COMMAND_BRIGHTNESS:
|
||||
brightness = params.get('brightness', 0)
|
||||
service_data[media_player.ATTR_MEDIA_VOLUME_LEVEL] = brightness / 100
|
||||
return (media_player.SERVICE_VOLUME_SET, service_data)
|
||||
|
||||
# special cover handling
|
||||
if domain == cover.DOMAIN:
|
||||
if command == COMMAND_BRIGHTNESS:
|
||||
service_data['position'] = params.get('brightness', 0)
|
||||
return (cover.SERVICE_SET_COVER_POSITION, service_data)
|
||||
if command == COMMAND_ONOFF and params.get('on') is True:
|
||||
return (cover.SERVICE_OPEN_COVER, service_data)
|
||||
return (cover.SERVICE_CLOSE_COVER, service_data)
|
||||
|
||||
# special climate handling
|
||||
if domain == climate.DOMAIN:
|
||||
if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT:
|
||||
service_data['temperature'] = \
|
||||
units.temperature(
|
||||
params['thermostatTemperatureSetpoint'], TEMP_CELSIUS)
|
||||
return (climate.SERVICE_SET_TEMPERATURE, service_data)
|
||||
if command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE:
|
||||
service_data['target_temp_high'] = units.temperature(
|
||||
params.get('thermostatTemperatureSetpointHigh', 25),
|
||||
TEMP_CELSIUS)
|
||||
service_data['target_temp_low'] = units.temperature(
|
||||
params.get('thermostatTemperatureSetpointLow', 18),
|
||||
TEMP_CELSIUS)
|
||||
return (climate.SERVICE_SET_TEMPERATURE, service_data)
|
||||
if command == COMMAND_THERMOSTAT_SET_MODE:
|
||||
mode = params['thermostatMode']
|
||||
|
||||
if mode == CLIMATE_MODE_HEATCOOL:
|
||||
mode = climate.STATE_AUTO
|
||||
|
||||
service_data['operation_mode'] = mode
|
||||
return (climate.SERVICE_SET_OPERATION_MODE, service_data)
|
||||
|
||||
if command == COMMAND_BRIGHTNESS:
|
||||
brightness = params.get('brightness')
|
||||
service_data['brightness'] = int(brightness / 100 * 255)
|
||||
return (SERVICE_TURN_ON, service_data)
|
||||
|
||||
if command == COMMAND_COLOR:
|
||||
color_data = params.get('color')
|
||||
if color_data is not None:
|
||||
if color_data.get('temperature', 0) > 0:
|
||||
service_data[light.ATTR_KELVIN] = color_data.get('temperature')
|
||||
return (SERVICE_TURN_ON, service_data)
|
||||
if color_data.get('spectrumRGB', 0) > 0:
|
||||
# blue is 255 so pad up to 6 chars
|
||||
hex_value = \
|
||||
('%0x' % int(color_data.get('spectrumRGB'))).zfill(6)
|
||||
service_data[light.ATTR_RGB_COLOR] = \
|
||||
color.rgb_hex_to_rgb_list(hex_value)
|
||||
return (SERVICE_TURN_ON, service_data)
|
||||
|
||||
if command == COMMAND_ACTIVATESCENE:
|
||||
return (SERVICE_TURN_ON, service_data)
|
||||
|
||||
if COMMAND_ONOFF == command:
|
||||
if params.get('on') is True:
|
||||
return (SERVICE_TURN_ON, service_data)
|
||||
return (SERVICE_TURN_OFF, service_data)
|
||||
|
||||
return (None, service_data)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_message(hass, config, message):
|
||||
"""Handle incoming API messages."""
|
||||
async def _process(hass, config, message):
|
||||
"""Process a message."""
|
||||
request_id = message.get('requestId') # type: str
|
||||
inputs = message.get('inputs') # type: list
|
||||
|
||||
if len(inputs) > 1:
|
||||
_LOGGER.warning('Got unexpected more than 1 input. %s', message)
|
||||
if len(inputs) != 1:
|
||||
return {
|
||||
'requestId': request_id,
|
||||
'payload': {'errorCode': ERR_PROTOCOL_ERROR}
|
||||
}
|
||||
|
||||
# Only use first input
|
||||
intent = inputs[0].get('intent')
|
||||
payload = inputs[0].get('payload')
|
||||
handler = HANDLERS.get(inputs[0].get('intent'))
|
||||
|
||||
handler = HANDLERS.get(intent)
|
||||
if handler is None:
|
||||
return {
|
||||
'requestId': request_id,
|
||||
'payload': {'errorCode': ERR_PROTOCOL_ERROR}
|
||||
}
|
||||
|
||||
if handler:
|
||||
result = yield from handler(hass, config, payload)
|
||||
else:
|
||||
result = {'errorCode': 'protocolError'}
|
||||
|
||||
return {'requestId': request_id, 'payload': result}
|
||||
try:
|
||||
result = await handler(hass, config, inputs[0].get('payload'))
|
||||
return {'requestId': request_id, 'payload': result}
|
||||
except SmartHomeError as err:
|
||||
return {
|
||||
'requestId': request_id,
|
||||
'payload': {'errorCode': err.code}
|
||||
}
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
_LOGGER.exception('Unexpected error')
|
||||
return {
|
||||
'requestId': request_id,
|
||||
'payload': {'errorCode': ERR_UNKNOWN_ERROR}
|
||||
}
|
||||
|
||||
|
||||
@HANDLERS.register('action.devices.SYNC')
|
||||
@asyncio.coroutine
|
||||
def async_devices_sync(hass, config: Config, payload):
|
||||
"""Handle action.devices.SYNC request."""
|
||||
async def async_devices_sync(hass, config, payload):
|
||||
"""Handle action.devices.SYNC request.
|
||||
|
||||
https://developers.google.com/actions/smarthome/create-app#actiondevicessync
|
||||
"""
|
||||
devices = []
|
||||
for entity in hass.states.async_all():
|
||||
if not config.should_expose(entity):
|
||||
for state in hass.states.async_all():
|
||||
if not config.should_expose(state):
|
||||
continue
|
||||
|
||||
device = entity_to_device(entity, config, hass.config.units)
|
||||
if device is None:
|
||||
_LOGGER.warning("No mapping for %s domain", entity.domain)
|
||||
entity = _GoogleEntity(hass, config, state)
|
||||
serialized = entity.sync_serialize()
|
||||
|
||||
if serialized is None:
|
||||
_LOGGER.debug("No mapping for %s domain", entity.state)
|
||||
continue
|
||||
|
||||
devices.append(device)
|
||||
devices.append(serialized)
|
||||
|
||||
return {
|
||||
'agentUserId': config.agent_user_id,
|
||||
@ -458,53 +234,79 @@ def async_devices_sync(hass, config: Config, payload):
|
||||
|
||||
|
||||
@HANDLERS.register('action.devices.QUERY')
|
||||
@asyncio.coroutine
|
||||
def async_devices_query(hass, config, payload):
|
||||
"""Handle action.devices.QUERY request."""
|
||||
async def async_devices_query(hass, config, payload):
|
||||
"""Handle action.devices.QUERY request.
|
||||
|
||||
https://developers.google.com/actions/smarthome/create-app#actiondevicesquery
|
||||
"""
|
||||
devices = {}
|
||||
for device in payload.get('devices', []):
|
||||
devid = device.get('id')
|
||||
# In theory this should never happen
|
||||
if not devid:
|
||||
_LOGGER.error('Device missing ID: %s', device)
|
||||
continue
|
||||
|
||||
devid = device['id']
|
||||
state = hass.states.get(devid)
|
||||
|
||||
if not state:
|
||||
# If we can't find a state, the device is offline
|
||||
devices[devid] = {'online': False}
|
||||
else:
|
||||
try:
|
||||
devices[devid] = query_device(state, config, hass.config.units)
|
||||
except SmartHomeError as error:
|
||||
devices[devid] = {'errorCode': error.code}
|
||||
continue
|
||||
|
||||
devices[devid] = _GoogleEntity(hass, config, state).query_serialize()
|
||||
|
||||
return {'devices': devices}
|
||||
|
||||
|
||||
@HANDLERS.register('action.devices.EXECUTE')
|
||||
@asyncio.coroutine
|
||||
def handle_devices_execute(hass, config, payload):
|
||||
"""Handle action.devices.EXECUTE request."""
|
||||
commands = []
|
||||
for command in payload.get('commands', []):
|
||||
ent_ids = [ent.get('id') for ent in command.get('devices', [])]
|
||||
for execution in command.get('execution'):
|
||||
for eid in ent_ids:
|
||||
success = False
|
||||
domain = eid.split('.')[0]
|
||||
(service, service_data) = determine_service(
|
||||
eid, execution.get('command'), execution.get('params'),
|
||||
hass.config.units)
|
||||
if domain == "group":
|
||||
domain = "homeassistant"
|
||||
success = yield from hass.services.async_call(
|
||||
domain, service, service_data, blocking=True)
|
||||
result = {"ids": [eid], "states": {}}
|
||||
if success:
|
||||
result['status'] = 'SUCCESS'
|
||||
else:
|
||||
result['status'] = 'ERROR'
|
||||
commands.append(result)
|
||||
async def handle_devices_execute(hass, config, payload):
|
||||
"""Handle action.devices.EXECUTE request.
|
||||
|
||||
return {'commands': commands}
|
||||
https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute
|
||||
"""
|
||||
entities = {}
|
||||
results = {}
|
||||
|
||||
for command in payload['commands']:
|
||||
for device, execution in product(command['devices'],
|
||||
command['execution']):
|
||||
entity_id = device['id']
|
||||
|
||||
# Happens if error occurred. Skip entity for further processing
|
||||
if entity_id in results:
|
||||
continue
|
||||
|
||||
if entity_id not in entities:
|
||||
state = hass.states.get(entity_id)
|
||||
|
||||
if state is None:
|
||||
results[entity_id] = {
|
||||
'ids': [entity_id],
|
||||
'status': 'ERROR',
|
||||
'errorCode': ERR_DEVICE_OFFLINE
|
||||
}
|
||||
continue
|
||||
|
||||
entities[entity_id] = _GoogleEntity(hass, config, state)
|
||||
|
||||
try:
|
||||
await entities[entity_id].execute(execution['command'],
|
||||
execution.get('params', {}))
|
||||
except SmartHomeError as err:
|
||||
results[entity_id] = {
|
||||
'ids': [entity_id],
|
||||
'status': 'ERROR',
|
||||
'errorCode': err.code
|
||||
}
|
||||
|
||||
final_results = list(results.values())
|
||||
|
||||
for entity in entities.values():
|
||||
if entity.entity_id in results:
|
||||
continue
|
||||
|
||||
entity.async_update()
|
||||
|
||||
final_results.append({
|
||||
'ids': [entity.entity_id],
|
||||
'status': 'SUCCESS',
|
||||
'states': entity.query_serialize(),
|
||||
})
|
||||
|
||||
return {'commands': final_results}
|
||||
|
521
homeassistant/components/google_assistant/trait.py
Normal file
521
homeassistant/components/google_assistant/trait.py
Normal file
@ -0,0 +1,521 @@
|
||||
"""Implement the Smart Home traits."""
|
||||
from homeassistant.core import DOMAIN as HA_DOMAIN
|
||||
from homeassistant.components import (
|
||||
climate,
|
||||
cover,
|
||||
group,
|
||||
fan,
|
||||
media_player,
|
||||
light,
|
||||
scene,
|
||||
script,
|
||||
switch,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
ATTR_UNIT_OF_MEASUREMENT,
|
||||
SERVICE_TURN_OFF,
|
||||
SERVICE_TURN_ON,
|
||||
STATE_OFF,
|
||||
TEMP_CELSIUS,
|
||||
TEMP_FAHRENHEIT,
|
||||
)
|
||||
from homeassistant.util import color as color_util, temperature as temp_util
|
||||
|
||||
from .const import ERR_VALUE_OUT_OF_RANGE
|
||||
from .helpers import SmartHomeError
|
||||
|
||||
PREFIX_TRAITS = 'action.devices.traits.'
|
||||
TRAIT_ONOFF = PREFIX_TRAITS + 'OnOff'
|
||||
TRAIT_BRIGHTNESS = PREFIX_TRAITS + 'Brightness'
|
||||
TRAIT_COLOR_SPECTRUM = PREFIX_TRAITS + 'ColorSpectrum'
|
||||
TRAIT_COLOR_TEMP = PREFIX_TRAITS + 'ColorTemperature'
|
||||
TRAIT_SCENE = PREFIX_TRAITS + 'Scene'
|
||||
TRAIT_TEMPERATURE_SETTING = PREFIX_TRAITS + 'TemperatureSetting'
|
||||
|
||||
PREFIX_COMMANDS = 'action.devices.commands.'
|
||||
COMMAND_ONOFF = PREFIX_COMMANDS + 'OnOff'
|
||||
COMMAND_BRIGHTNESS_ABSOLUTE = PREFIX_COMMANDS + 'BrightnessAbsolute'
|
||||
COMMAND_COLOR_ABSOLUTE = PREFIX_COMMANDS + 'ColorAbsolute'
|
||||
COMMAND_ACTIVATE_SCENE = PREFIX_COMMANDS + 'ActivateScene'
|
||||
COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT = (
|
||||
PREFIX_COMMANDS + 'ThermostatTemperatureSetpoint')
|
||||
COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE = (
|
||||
PREFIX_COMMANDS + 'ThermostatTemperatureSetRange')
|
||||
COMMAND_THERMOSTAT_SET_MODE = PREFIX_COMMANDS + 'ThermostatSetMode'
|
||||
|
||||
|
||||
TRAITS = []
|
||||
|
||||
|
||||
def register_trait(trait):
|
||||
"""Decorator to register a trait."""
|
||||
TRAITS.append(trait)
|
||||
return trait
|
||||
|
||||
|
||||
def _google_temp_unit(state):
|
||||
"""Return Google temperature unit."""
|
||||
if (state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) ==
|
||||
TEMP_FAHRENHEIT):
|
||||
return 'F'
|
||||
return 'C'
|
||||
|
||||
|
||||
class _Trait:
|
||||
"""Represents a Trait inside Google Assistant skill."""
|
||||
|
||||
commands = []
|
||||
|
||||
def __init__(self, state):
|
||||
"""Initialize a trait for a state."""
|
||||
self.state = state
|
||||
|
||||
def sync_attributes(self):
|
||||
"""Return attributes for a sync request."""
|
||||
raise NotImplementedError
|
||||
|
||||
def query_attributes(self):
|
||||
"""Return the attributes of this trait for this entity."""
|
||||
raise NotImplementedError
|
||||
|
||||
def can_execute(self, command, params):
|
||||
"""Test if command can be executed."""
|
||||
return command in self.commands
|
||||
|
||||
async def execute(self, hass, command, params):
|
||||
"""Execute a trait command."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@register_trait
|
||||
class BrightnessTrait(_Trait):
|
||||
"""Trait to control brightness of a device.
|
||||
|
||||
https://developers.google.com/actions/smarthome/traits/brightness
|
||||
"""
|
||||
|
||||
name = TRAIT_BRIGHTNESS
|
||||
commands = [
|
||||
COMMAND_BRIGHTNESS_ABSOLUTE
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features):
|
||||
"""Test if state is supported."""
|
||||
if domain == light.DOMAIN:
|
||||
return features & light.SUPPORT_BRIGHTNESS
|
||||
elif domain == cover.DOMAIN:
|
||||
return features & cover.SUPPORT_SET_POSITION
|
||||
elif domain == media_player.DOMAIN:
|
||||
return features & media_player.SUPPORT_VOLUME_SET
|
||||
|
||||
return False
|
||||
|
||||
def sync_attributes(self):
|
||||
"""Return brightness attributes for a sync request."""
|
||||
return {}
|
||||
|
||||
def query_attributes(self):
|
||||
"""Return brightness query attributes."""
|
||||
domain = self.state.domain
|
||||
response = {}
|
||||
|
||||
if domain == light.DOMAIN:
|
||||
brightness = self.state.attributes.get(light.ATTR_BRIGHTNESS)
|
||||
if brightness is not None:
|
||||
response['brightness'] = int(100 * (brightness / 255))
|
||||
|
||||
elif domain == cover.DOMAIN:
|
||||
position = self.state.attributes.get(cover.ATTR_CURRENT_POSITION)
|
||||
if position is not None:
|
||||
response['brightness'] = position
|
||||
|
||||
elif domain == media_player.DOMAIN:
|
||||
level = self.state.attributes.get(
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL)
|
||||
if level is not None:
|
||||
# Convert 0.0-1.0 to 0-255
|
||||
response['brightness'] = int(level * 100)
|
||||
|
||||
return response
|
||||
|
||||
async def execute(self, hass, command, params):
|
||||
"""Execute a brightness command."""
|
||||
domain = self.state.domain
|
||||
|
||||
if domain == light.DOMAIN:
|
||||
await hass.services.async_call(
|
||||
light.DOMAIN, light.SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
light.ATTR_BRIGHTNESS_PCT: params['brightness']
|
||||
}, blocking=True)
|
||||
elif domain == cover.DOMAIN:
|
||||
await hass.services.async_call(
|
||||
cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
cover.ATTR_POSITION: params['brightness']
|
||||
}, blocking=True)
|
||||
elif domain == media_player.DOMAIN:
|
||||
await hass.services.async_call(
|
||||
media_player.DOMAIN, media_player.SERVICE_VOLUME_SET, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
media_player.ATTR_MEDIA_VOLUME_LEVEL:
|
||||
params['brightness'] / 100
|
||||
}, blocking=True)
|
||||
|
||||
|
||||
@register_trait
|
||||
class OnOffTrait(_Trait):
|
||||
"""Trait to offer basic on and off functionality.
|
||||
|
||||
https://developers.google.com/actions/smarthome/traits/onoff
|
||||
"""
|
||||
|
||||
name = TRAIT_ONOFF
|
||||
commands = [
|
||||
COMMAND_ONOFF
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features):
|
||||
"""Test if state is supported."""
|
||||
return domain in (
|
||||
group.DOMAIN,
|
||||
switch.DOMAIN,
|
||||
fan.DOMAIN,
|
||||
light.DOMAIN,
|
||||
cover.DOMAIN,
|
||||
media_player.DOMAIN,
|
||||
)
|
||||
|
||||
def sync_attributes(self):
|
||||
"""Return OnOff attributes for a sync request."""
|
||||
return {}
|
||||
|
||||
def query_attributes(self):
|
||||
"""Return OnOff query attributes."""
|
||||
if self.state.domain == cover.DOMAIN:
|
||||
return {'on': self.state.state != cover.STATE_CLOSED}
|
||||
return {'on': self.state.state != STATE_OFF}
|
||||
|
||||
async def execute(self, hass, command, params):
|
||||
"""Execute an OnOff command."""
|
||||
domain = self.state.domain
|
||||
|
||||
if domain == cover.DOMAIN:
|
||||
service_domain = domain
|
||||
if params['on']:
|
||||
service = cover.SERVICE_OPEN_COVER
|
||||
else:
|
||||
service = cover.SERVICE_CLOSE_COVER
|
||||
|
||||
elif domain == group.DOMAIN:
|
||||
service_domain = HA_DOMAIN
|
||||
service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF
|
||||
|
||||
else:
|
||||
service_domain = domain
|
||||
service = SERVICE_TURN_ON if params['on'] else SERVICE_TURN_OFF
|
||||
|
||||
await hass.services.async_call(service_domain, service, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id
|
||||
}, blocking=True)
|
||||
|
||||
|
||||
@register_trait
|
||||
class ColorSpectrumTrait(_Trait):
|
||||
"""Trait to offer color spectrum functionality.
|
||||
|
||||
https://developers.google.com/actions/smarthome/traits/colorspectrum
|
||||
"""
|
||||
|
||||
name = TRAIT_COLOR_SPECTRUM
|
||||
commands = [
|
||||
COMMAND_COLOR_ABSOLUTE
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features):
|
||||
"""Test if state is supported."""
|
||||
if domain != light.DOMAIN:
|
||||
return False
|
||||
|
||||
return features & (light.SUPPORT_RGB_COLOR | light.SUPPORT_XY_COLOR)
|
||||
|
||||
def sync_attributes(self):
|
||||
"""Return color spectrum attributes for a sync request."""
|
||||
# Other colorModel is hsv
|
||||
return {'colorModel': 'rgb'}
|
||||
|
||||
def query_attributes(self):
|
||||
"""Return color spectrum query attributes."""
|
||||
response = {}
|
||||
|
||||
# No need to handle XY color because light component will always
|
||||
# convert XY to RGB if possible (which is when brightness is available)
|
||||
color_rgb = self.state.attributes.get(light.ATTR_RGB_COLOR)
|
||||
if color_rgb is not None:
|
||||
response['color'] = {
|
||||
'spectrumRGB': int(color_util.color_rgb_to_hex(
|
||||
color_rgb[0], color_rgb[1], color_rgb[2]), 16),
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
def can_execute(self, command, params):
|
||||
"""Test if command can be executed."""
|
||||
return (command in self.commands and
|
||||
'spectrumRGB' in params.get('color', {}))
|
||||
|
||||
async def execute(self, hass, command, params):
|
||||
"""Execute a color spectrum command."""
|
||||
# Convert integer to hex format and left pad with 0's till length 6
|
||||
hex_value = "{0:06x}".format(params['color']['spectrumRGB'])
|
||||
color = color_util.rgb_hex_to_rgb_list(hex_value)
|
||||
|
||||
await hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
light.ATTR_RGB_COLOR: color
|
||||
}, blocking=True)
|
||||
|
||||
|
||||
@register_trait
|
||||
class ColorTemperatureTrait(_Trait):
|
||||
"""Trait to offer color temperature functionality.
|
||||
|
||||
https://developers.google.com/actions/smarthome/traits/colortemperature
|
||||
"""
|
||||
|
||||
name = TRAIT_COLOR_TEMP
|
||||
commands = [
|
||||
COMMAND_COLOR_ABSOLUTE
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features):
|
||||
"""Test if state is supported."""
|
||||
if domain != light.DOMAIN:
|
||||
return False
|
||||
|
||||
return features & light.SUPPORT_COLOR_TEMP
|
||||
|
||||
def sync_attributes(self):
|
||||
"""Return color temperature attributes for a sync request."""
|
||||
attrs = self.state.attributes
|
||||
return {
|
||||
'temperatureMinK': color_util.color_temperature_mired_to_kelvin(
|
||||
attrs.get(light.ATTR_MIN_MIREDS)),
|
||||
'temperatureMaxK': color_util.color_temperature_mired_to_kelvin(
|
||||
attrs.get(light.ATTR_MAX_MIREDS)),
|
||||
}
|
||||
|
||||
def query_attributes(self):
|
||||
"""Return color temperature query attributes."""
|
||||
response = {}
|
||||
|
||||
temp = self.state.attributes.get(light.ATTR_COLOR_TEMP)
|
||||
if temp is not None:
|
||||
response['color'] = {
|
||||
'temperature':
|
||||
color_util.color_temperature_mired_to_kelvin(temp)
|
||||
}
|
||||
|
||||
return response
|
||||
|
||||
def can_execute(self, command, params):
|
||||
"""Test if command can be executed."""
|
||||
return (command in self.commands and
|
||||
'temperature' in params.get('color', {}))
|
||||
|
||||
async def execute(self, hass, command, params):
|
||||
"""Execute a color temperature command."""
|
||||
temp = color_util.color_temperature_kelvin_to_mired(
|
||||
params['color']['temperature'])
|
||||
min_temp = self.state.attributes[light.ATTR_MIN_MIREDS]
|
||||
max_temp = self.state.attributes[light.ATTR_MAX_MIREDS]
|
||||
|
||||
if temp < min_temp or temp > max_temp:
|
||||
raise SmartHomeError(
|
||||
ERR_VALUE_OUT_OF_RANGE,
|
||||
"Temperature should be between {} and {}".format(min_temp,
|
||||
max_temp))
|
||||
|
||||
await hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
light.ATTR_COLOR_TEMP: temp,
|
||||
}, blocking=True)
|
||||
|
||||
|
||||
@register_trait
|
||||
class SceneTrait(_Trait):
|
||||
"""Trait to offer scene functionality.
|
||||
|
||||
https://developers.google.com/actions/smarthome/traits/scene
|
||||
"""
|
||||
|
||||
name = TRAIT_SCENE
|
||||
commands = [
|
||||
COMMAND_ACTIVATE_SCENE
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features):
|
||||
"""Test if state is supported."""
|
||||
return domain in (scene.DOMAIN, script.DOMAIN)
|
||||
|
||||
def sync_attributes(self):
|
||||
"""Return scene attributes for a sync request."""
|
||||
# Neither supported domain can support sceneReversible
|
||||
return {}
|
||||
|
||||
def query_attributes(self):
|
||||
"""Return scene query attributes."""
|
||||
return {}
|
||||
|
||||
async def execute(self, hass, command, params):
|
||||
"""Execute a scene command."""
|
||||
# Don't block for scripts as they can be slow.
|
||||
await hass.services.async_call(self.state.domain, SERVICE_TURN_ON, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id
|
||||
}, blocking=self.state.domain != script.DOMAIN)
|
||||
|
||||
|
||||
@register_trait
|
||||
class TemperatureSettingTrait(_Trait):
|
||||
"""Trait to offer handling both temperature point and modes functionality.
|
||||
|
||||
https://developers.google.com/actions/smarthome/traits/temperaturesetting
|
||||
"""
|
||||
|
||||
name = TRAIT_TEMPERATURE_SETTING
|
||||
commands = [
|
||||
COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT,
|
||||
COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE,
|
||||
COMMAND_THERMOSTAT_SET_MODE,
|
||||
]
|
||||
# We do not support "on" as we are unable to know how to restore
|
||||
# the last mode.
|
||||
hass_to_google = {
|
||||
climate.STATE_HEAT: 'heat',
|
||||
climate.STATE_COOL: 'cool',
|
||||
climate.STATE_OFF: 'off',
|
||||
climate.STATE_AUTO: 'heatcool',
|
||||
}
|
||||
google_to_hass = {value: key for key, value in hass_to_google.items()}
|
||||
|
||||
@staticmethod
|
||||
def supported(domain, features):
|
||||
"""Test if state is supported."""
|
||||
if domain != climate.DOMAIN:
|
||||
return False
|
||||
|
||||
return features & climate.SUPPORT_OPERATION_MODE
|
||||
|
||||
def sync_attributes(self):
|
||||
"""Return temperature point and modes attributes for a sync request."""
|
||||
modes = []
|
||||
for mode in self.state.attributes.get(climate.ATTR_OPERATION_LIST, []):
|
||||
google_mode = self.hass_to_google.get(mode)
|
||||
if google_mode is not None:
|
||||
modes.append(google_mode)
|
||||
|
||||
return {
|
||||
'availableThermostatModes': ','.join(modes),
|
||||
'thermostatTemperatureUnit': _google_temp_unit(self.state),
|
||||
}
|
||||
|
||||
def query_attributes(self):
|
||||
"""Return temperature point and modes query attributes."""
|
||||
attrs = self.state.attributes
|
||||
response = {}
|
||||
|
||||
operation = attrs.get(climate.ATTR_OPERATION_MODE)
|
||||
if operation is not None and operation in self.hass_to_google:
|
||||
response['thermostatMode'] = self.hass_to_google[operation]
|
||||
|
||||
unit = self.state.attributes[ATTR_UNIT_OF_MEASUREMENT]
|
||||
|
||||
current_temp = attrs.get(climate.ATTR_CURRENT_TEMPERATURE)
|
||||
if current_temp is not None:
|
||||
response['thermostatTemperatureAmbient'] = \
|
||||
round(temp_util.convert(current_temp, unit, TEMP_CELSIUS), 1)
|
||||
|
||||
current_humidity = attrs.get(climate.ATTR_CURRENT_HUMIDITY)
|
||||
if current_humidity is not None:
|
||||
response['thermostatHumidityAmbient'] = current_humidity
|
||||
|
||||
if (operation == climate.STATE_AUTO and
|
||||
climate.ATTR_TARGET_TEMP_HIGH in attrs and
|
||||
climate.ATTR_TARGET_TEMP_LOW in attrs):
|
||||
response['thermostatTemperatureSetpointHigh'] = \
|
||||
round(temp_util.convert(attrs[climate.ATTR_TARGET_TEMP_HIGH],
|
||||
unit, TEMP_CELSIUS), 1)
|
||||
response['thermostatTemperatureSetpointLow'] = \
|
||||
round(temp_util.convert(attrs[climate.ATTR_TARGET_TEMP_LOW],
|
||||
unit, TEMP_CELSIUS), 1)
|
||||
else:
|
||||
target_temp = attrs.get(climate.ATTR_TEMPERATURE)
|
||||
if target_temp is not None:
|
||||
response['thermostatTemperatureSetpoint'] = round(
|
||||
temp_util.convert(target_temp, unit, TEMP_CELSIUS), 1)
|
||||
|
||||
return response
|
||||
|
||||
async def execute(self, hass, command, params):
|
||||
"""Execute a temperature point or mode command."""
|
||||
# All sent in temperatures are always in Celsius
|
||||
unit = self.state.attributes[ATTR_UNIT_OF_MEASUREMENT]
|
||||
min_temp = self.state.attributes[climate.ATTR_MIN_TEMP]
|
||||
max_temp = self.state.attributes[climate.ATTR_MAX_TEMP]
|
||||
|
||||
if command == COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT:
|
||||
temp = temp_util.convert(params['thermostatTemperatureSetpoint'],
|
||||
TEMP_CELSIUS, unit)
|
||||
|
||||
if temp < min_temp or temp > max_temp:
|
||||
raise SmartHomeError(
|
||||
ERR_VALUE_OUT_OF_RANGE,
|
||||
"Temperature should be between {} and {}".format(min_temp,
|
||||
max_temp))
|
||||
|
||||
await hass.services.async_call(
|
||||
climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
climate.ATTR_TEMPERATURE: temp
|
||||
}, blocking=True)
|
||||
|
||||
elif command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE:
|
||||
temp_high = temp_util.convert(
|
||||
params['thermostatTemperatureSetpointHigh'], TEMP_CELSIUS,
|
||||
unit)
|
||||
|
||||
if temp_high < min_temp or temp_high > max_temp:
|
||||
raise SmartHomeError(
|
||||
ERR_VALUE_OUT_OF_RANGE,
|
||||
"Upper bound for temperature range should be between "
|
||||
"{} and {}".format(min_temp, max_temp))
|
||||
|
||||
temp_low = temp_util.convert(
|
||||
params['thermostatTemperatureSetpointLow'], TEMP_CELSIUS, unit)
|
||||
|
||||
if temp_low < min_temp or temp_low > max_temp:
|
||||
raise SmartHomeError(
|
||||
ERR_VALUE_OUT_OF_RANGE,
|
||||
"Lower bound for temperature range should be between "
|
||||
"{} and {}".format(min_temp, max_temp))
|
||||
|
||||
await hass.services.async_call(
|
||||
climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
climate.ATTR_TARGET_TEMP_HIGH: temp_high,
|
||||
climate.ATTR_TARGET_TEMP_LOW: temp_low,
|
||||
}, blocking=True)
|
||||
|
||||
elif command == COMMAND_THERMOSTAT_SET_MODE:
|
||||
await hass.services.async_call(
|
||||
climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE, {
|
||||
ATTR_ENTITY_ID: self.state.entity_id,
|
||||
climate.ATTR_OPERATION_MODE:
|
||||
self.google_to_hass[params['thermostatMode']],
|
||||
}, blocking=True)
|
@ -257,12 +257,16 @@ def async_setup(hass, config):
|
||||
|
||||
@asyncio.coroutine
|
||||
def reload_service_handler(service):
|
||||
"""Remove all groups and load new ones from config."""
|
||||
"""Remove all user-defined groups and load new ones from config."""
|
||||
auto = list(filter(lambda e: not e.user_defined, component.entities))
|
||||
|
||||
conf = yield from component.async_prepare_reload()
|
||||
if conf is None:
|
||||
return
|
||||
yield from _async_process_config(hass, conf, component)
|
||||
|
||||
yield from component.async_add_entities(auto)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, SERVICE_RELOAD, reload_service_handler,
|
||||
schema=RELOAD_SERVICE_SCHEMA)
|
||||
@ -407,7 +411,7 @@ class Group(Entity):
|
||||
self.group_off = None
|
||||
self.visible = visible
|
||||
self.control = control
|
||||
self._user_defined = user_defined
|
||||
self.user_defined = user_defined
|
||||
self._order = order
|
||||
self._assumed_state = False
|
||||
self._async_unsub_state_changed = None
|
||||
@ -497,7 +501,7 @@ class Group(Entity):
|
||||
ATTR_ENTITY_ID: self.tracking,
|
||||
ATTR_ORDER: self._order,
|
||||
}
|
||||
if not self._user_defined:
|
||||
if not self.user_defined:
|
||||
data[ATTR_AUTO] = True
|
||||
if self.view:
|
||||
data[ATTR_VIEW] = True
|
||||
|
@ -1,4 +1,4 @@
|
||||
"""Support for Apple Homekit.
|
||||
"""Support for Apple HomeKit.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/homekit/
|
||||
@ -11,17 +11,20 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, CONF_PORT,
|
||||
TEMP_CELSIUS, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
|
||||
TEMP_CELSIUS, TEMP_FAHRENHEIT,
|
||||
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP)
|
||||
from homeassistant.components.climate import (
|
||||
SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW)
|
||||
from homeassistant.util import get_local_ip
|
||||
from homeassistant.util.decorator import Registry
|
||||
|
||||
TYPES = Registry()
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
_RE_VALID_PINCODE = re.compile(r"^(\d{3}-\d{2}-\d{3})$")
|
||||
_RE_VALID_PINCODE = r"^(\d{3}-\d{2}-\d{3})$"
|
||||
|
||||
DOMAIN = 'homekit'
|
||||
REQUIREMENTS = ['HAP-python==1.1.5']
|
||||
REQUIREMENTS = ['HAP-python==1.1.7']
|
||||
|
||||
BRIDGE_NAME = 'Home Assistant'
|
||||
CONF_PIN_CODE = 'pincode'
|
||||
@ -30,11 +33,11 @@ HOMEKIT_FILE = '.homekit.state'
|
||||
|
||||
|
||||
def valid_pin(value):
|
||||
"""Validate pincode value."""
|
||||
match = _RE_VALID_PINCODE.findall(value.strip())
|
||||
if match == []:
|
||||
"""Validate pin code value."""
|
||||
match = re.match(_RE_VALID_PINCODE, str(value).strip())
|
||||
if not match:
|
||||
raise vol.Invalid("Pin must be in the format: '123-45-678'")
|
||||
return match[0]
|
||||
return match.group(0)
|
||||
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
@ -47,14 +50,14 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Setup the homekit component."""
|
||||
_LOGGER.debug("Begin setup homekit")
|
||||
"""Setup the HomeKit component."""
|
||||
_LOGGER.debug("Begin setup HomeKit")
|
||||
|
||||
conf = config[DOMAIN]
|
||||
port = conf.get(CONF_PORT)
|
||||
pin = str.encode(conf.get(CONF_PIN_CODE))
|
||||
|
||||
homekit = Homekit(hass, port)
|
||||
homekit = HomeKit(hass, port)
|
||||
homekit.setup_bridge(pin)
|
||||
|
||||
hass.bus.async_listen_once(
|
||||
@ -63,18 +66,18 @@ def async_setup(hass, config):
|
||||
|
||||
|
||||
def import_types():
|
||||
"""Import all types from files in the homekit dir."""
|
||||
"""Import all types from files in the HomeKit directory."""
|
||||
_LOGGER.debug("Import type files.")
|
||||
# pylint: disable=unused-variable
|
||||
from .covers import Window # noqa F401
|
||||
# pylint: disable=unused-variable
|
||||
from .sensors import TemperatureSensor # noqa F401
|
||||
from . import ( # noqa F401
|
||||
covers, security_systems, sensors, switches, thermostats)
|
||||
|
||||
|
||||
def get_accessory(hass, state):
|
||||
"""Take state and return an accessory object if supported."""
|
||||
if state.domain == 'sensor':
|
||||
if state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == TEMP_CELSIUS:
|
||||
unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
if unit == TEMP_CELSIUS or unit == TEMP_FAHRENHEIT:
|
||||
_LOGGER.debug("Add \"%s\" as \"%s\"",
|
||||
state.entity_id, 'TemperatureSensor')
|
||||
return TYPES['TemperatureSensor'](hass, state.entity_id,
|
||||
@ -87,14 +90,35 @@ def get_accessory(hass, state):
|
||||
state.entity_id, 'Window')
|
||||
return TYPES['Window'](hass, state.entity_id, state.name)
|
||||
|
||||
elif state.domain == 'alarm_control_panel':
|
||||
_LOGGER.debug("Add \"%s\" as \"%s\"", state.entity_id,
|
||||
'SecuritySystem')
|
||||
return TYPES['SecuritySystem'](hass, state.entity_id, state.name)
|
||||
|
||||
elif state.domain == 'climate':
|
||||
support_auto = False
|
||||
features = state.attributes.get(ATTR_SUPPORTED_FEATURES)
|
||||
# Check if climate device supports auto mode
|
||||
if (features & SUPPORT_TARGET_TEMPERATURE_HIGH) \
|
||||
and (features & SUPPORT_TARGET_TEMPERATURE_LOW):
|
||||
support_auto = True
|
||||
_LOGGER.debug("Add \"%s\" as \"%s\"", state.entity_id, 'Thermostat')
|
||||
return TYPES['Thermostat'](hass, state.entity_id,
|
||||
state.name, support_auto)
|
||||
|
||||
elif state.domain == 'switch' or state.domain == 'remote' \
|
||||
or state.domain == 'input_boolean':
|
||||
_LOGGER.debug("Add \"%s\" as \"%s\"", state.entity_id, 'Switch')
|
||||
return TYPES['Switch'](hass, state.entity_id, state.name)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class Homekit():
|
||||
"""Class to handle all actions between homekit and Home Assistant."""
|
||||
class HomeKit():
|
||||
"""Class to handle all actions between HomeKit and Home Assistant."""
|
||||
|
||||
def __init__(self, hass, port):
|
||||
"""Initialize a homekit object."""
|
||||
"""Initialize a HomeKit object."""
|
||||
self._hass = hass
|
||||
self._port = port
|
||||
self.bridge = None
|
||||
@ -103,8 +127,7 @@ class Homekit():
|
||||
def setup_bridge(self, pin):
|
||||
"""Setup the bridge component to track all accessories."""
|
||||
from .accessories import HomeBridge
|
||||
self.bridge = HomeBridge(BRIDGE_NAME, pincode=pin)
|
||||
self.bridge.set_accessory_info('homekit.bridge')
|
||||
self.bridge = HomeBridge(BRIDGE_NAME, 'homekit.bridge', pin)
|
||||
|
||||
def start_driver(self, event):
|
||||
"""Start the accessory driver."""
|
||||
|
@ -1,55 +1,69 @@
|
||||
"""Extend the basic Accessory and Bridge functions."""
|
||||
import logging
|
||||
|
||||
from pyhap.accessory import Accessory, Bridge, Category
|
||||
|
||||
from .const import (
|
||||
SERVICES_ACCESSORY_INFO, MANUFACTURER,
|
||||
SERV_ACCESSORY_INFO, SERV_BRIDGING_STATE, MANUFACTURER,
|
||||
CHAR_MODEL, CHAR_MANUFACTURER, CHAR_SERIAL_NUMBER)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def set_accessory_info(acc, model, manufacturer=MANUFACTURER,
|
||||
serial_number='0000'):
|
||||
"""Set the default accessory information."""
|
||||
service = acc.get_service(SERV_ACCESSORY_INFO)
|
||||
service.get_characteristic(CHAR_MODEL).set_value(model)
|
||||
service.get_characteristic(CHAR_MANUFACTURER).set_value(manufacturer)
|
||||
service.get_characteristic(CHAR_SERIAL_NUMBER).set_value(serial_number)
|
||||
|
||||
|
||||
def add_preload_service(acc, service, chars=None, opt_chars=None):
|
||||
"""Define and return a service to be available for the accessory."""
|
||||
from pyhap.loader import get_serv_loader, get_char_loader
|
||||
service = get_serv_loader().get(service)
|
||||
if chars:
|
||||
chars = chars if isinstance(chars, list) else [chars]
|
||||
for char_name in chars:
|
||||
char = get_char_loader().get(char_name)
|
||||
service.add_characteristic(char)
|
||||
if opt_chars:
|
||||
opt_chars = opt_chars if isinstance(opt_chars, list) else [opt_chars]
|
||||
for opt_char_name in opt_chars:
|
||||
opt_char = get_char_loader().get(opt_char_name)
|
||||
service.add_opt_characteristic(opt_char)
|
||||
acc.add_service(service)
|
||||
return service
|
||||
|
||||
|
||||
def override_properties(char, new_properties):
|
||||
"""Override characteristic property values."""
|
||||
char.properties.update(new_properties)
|
||||
|
||||
|
||||
class HomeAccessory(Accessory):
|
||||
"""Class to extend the Accessory class."""
|
||||
|
||||
ALL_CATEGORIES = Category
|
||||
|
||||
def __init__(self, display_name):
|
||||
def __init__(self, display_name, model, category='OTHER', **kwargs):
|
||||
"""Initialize a Accessory object."""
|
||||
super().__init__(display_name)
|
||||
super().__init__(display_name, **kwargs)
|
||||
set_accessory_info(self, model)
|
||||
self.category = getattr(Category, category, Category.OTHER)
|
||||
|
||||
def set_category(self, category):
|
||||
"""Set the category of the accessory."""
|
||||
self.category = category
|
||||
|
||||
def add_preload_service(self, service):
|
||||
"""Define the services to be available for the accessory."""
|
||||
from pyhap.loader import get_serv_loader
|
||||
self.add_service(get_serv_loader().get(service))
|
||||
|
||||
def set_accessory_info(self, model, manufacturer=MANUFACTURER,
|
||||
serial_number='0000'):
|
||||
"""Set the default accessory information."""
|
||||
service_info = self.get_service(SERVICES_ACCESSORY_INFO)
|
||||
service_info.get_characteristic(CHAR_MODEL) \
|
||||
.set_value(model)
|
||||
service_info.get_characteristic(CHAR_MANUFACTURER) \
|
||||
.set_value(manufacturer)
|
||||
service_info.get_characteristic(CHAR_SERIAL_NUMBER) \
|
||||
.set_value(serial_number)
|
||||
def _set_services(self):
|
||||
add_preload_service(self, SERV_ACCESSORY_INFO)
|
||||
|
||||
|
||||
class HomeBridge(Bridge):
|
||||
"""Class to extend the Bridge class."""
|
||||
|
||||
def __init__(self, display_name, pincode):
|
||||
def __init__(self, display_name, model, pincode, **kwargs):
|
||||
"""Initialize a Bridge object."""
|
||||
super().__init__(display_name, pincode=pincode)
|
||||
super().__init__(display_name, pincode=pincode, **kwargs)
|
||||
set_accessory_info(self, model)
|
||||
|
||||
def set_accessory_info(self, model, manufacturer=MANUFACTURER,
|
||||
serial_number='0000'):
|
||||
"""Set the default accessory information."""
|
||||
service_info = self.get_service(SERVICES_ACCESSORY_INFO)
|
||||
service_info.get_characteristic(CHAR_MODEL) \
|
||||
.set_value(model)
|
||||
service_info.get_characteristic(CHAR_MANUFACTURER) \
|
||||
.set_value(manufacturer)
|
||||
service_info.get_characteristic(CHAR_SERIAL_NUMBER) \
|
||||
.set_value(serial_number)
|
||||
def _set_services(self):
|
||||
add_preload_service(self, SERV_ACCESSORY_INFO)
|
||||
add_preload_service(self, SERV_BRIDGING_STATE)
|
||||
|
@ -1,18 +1,36 @@
|
||||
"""Constants used be the homekit component."""
|
||||
"""Constants used be the HomeKit component."""
|
||||
MANUFACTURER = 'HomeAssistant'
|
||||
|
||||
# Service: AccessoryInfomation
|
||||
SERVICES_ACCESSORY_INFO = 'AccessoryInformation'
|
||||
CHAR_MODEL = 'Model'
|
||||
CHAR_MANUFACTURER = 'Manufacturer'
|
||||
CHAR_SERIAL_NUMBER = 'SerialNumber'
|
||||
# Services
|
||||
SERV_ACCESSORY_INFO = 'AccessoryInformation'
|
||||
SERV_BRIDGING_STATE = 'BridgingState'
|
||||
SERV_SECURITY_SYSTEM = 'SecuritySystem'
|
||||
SERV_SWITCH = 'Switch'
|
||||
SERV_TEMPERATURE_SENSOR = 'TemperatureSensor'
|
||||
SERV_THERMOSTAT = 'Thermostat'
|
||||
SERV_WINDOW_COVERING = 'WindowCovering'
|
||||
|
||||
# Service: TemperatureSensor
|
||||
SERVICES_TEMPERATURE_SENSOR = 'TemperatureSensor'
|
||||
CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature'
|
||||
|
||||
# Service: WindowCovering
|
||||
SERVICES_WINDOW_COVERING = 'WindowCovering'
|
||||
# Characteristics
|
||||
CHAR_ACC_IDENTIFIER = 'AccessoryIdentifier'
|
||||
CHAR_CATEGORY = 'Category'
|
||||
CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature'
|
||||
CHAR_CURRENT_HEATING_COOLING = 'CurrentHeatingCoolingState'
|
||||
CHAR_CURRENT_POSITION = 'CurrentPosition'
|
||||
CHAR_TARGET_POSITION = 'TargetPosition'
|
||||
CHAR_CURRENT_SECURITY_STATE = 'SecuritySystemCurrentState'
|
||||
CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature'
|
||||
CHAR_HEATING_THRESHOLD_TEMPERATURE = 'HeatingThresholdTemperature'
|
||||
CHAR_LINK_QUALITY = 'LinkQuality'
|
||||
CHAR_MANUFACTURER = 'Manufacturer'
|
||||
CHAR_MODEL = 'Model'
|
||||
CHAR_ON = 'On'
|
||||
CHAR_POSITION_STATE = 'PositionState'
|
||||
CHAR_REACHABLE = 'Reachable'
|
||||
CHAR_SERIAL_NUMBER = 'SerialNumber'
|
||||
CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState'
|
||||
CHAR_TARGET_POSITION = 'TargetPosition'
|
||||
CHAR_TARGET_SECURITY_STATE = 'SecuritySystemTargetState'
|
||||
CHAR_TARGET_TEMPERATURE = 'TargetTemperature'
|
||||
CHAR_TEMP_DISPLAY_UNITS = 'TemperatureDisplayUnits'
|
||||
|
||||
# Properties
|
||||
PROP_CELSIUS = {'minValue': -273, 'maxValue': 999}
|
||||
|
@ -5,9 +5,9 @@ from homeassistant.components.cover import ATTR_CURRENT_POSITION
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
|
||||
from . import TYPES
|
||||
from .accessories import HomeAccessory
|
||||
from .accessories import HomeAccessory, add_preload_service
|
||||
from .const import (
|
||||
SERVICES_WINDOW_COVERING, CHAR_CURRENT_POSITION,
|
||||
SERV_WINDOW_COVERING, CHAR_CURRENT_POSITION,
|
||||
CHAR_TARGET_POSITION, CHAR_POSITION_STATE)
|
||||
|
||||
|
||||
@ -23,10 +23,7 @@ class Window(HomeAccessory):
|
||||
|
||||
def __init__(self, hass, entity_id, display_name):
|
||||
"""Initialize a Window accessory object."""
|
||||
super().__init__(display_name)
|
||||
self.set_category(self.ALL_CATEGORIES.WINDOW)
|
||||
self.set_accessory_info(entity_id)
|
||||
self.add_preload_service(SERVICES_WINDOW_COVERING)
|
||||
super().__init__(display_name, entity_id, 'WINDOW')
|
||||
|
||||
self._hass = hass
|
||||
self._entity_id = entity_id
|
||||
@ -34,13 +31,16 @@ class Window(HomeAccessory):
|
||||
self.current_position = None
|
||||
self.homekit_target = None
|
||||
|
||||
self.service_cover = self.get_service(SERVICES_WINDOW_COVERING)
|
||||
self.char_current_position = self.service_cover. \
|
||||
self.serv_cover = add_preload_service(self, SERV_WINDOW_COVERING)
|
||||
self.char_current_position = self.serv_cover. \
|
||||
get_characteristic(CHAR_CURRENT_POSITION)
|
||||
self.char_target_position = self.service_cover. \
|
||||
self.char_target_position = self.serv_cover. \
|
||||
get_characteristic(CHAR_TARGET_POSITION)
|
||||
self.char_position_state = self.service_cover. \
|
||||
self.char_position_state = self.serv_cover. \
|
||||
get_characteristic(CHAR_POSITION_STATE)
|
||||
self.char_current_position.value = 0
|
||||
self.char_target_position.value = 0
|
||||
self.char_position_state.value = 0
|
||||
|
||||
self.char_target_position.setter_callback = self.move_cover
|
||||
|
||||
@ -53,7 +53,7 @@ class Window(HomeAccessory):
|
||||
self._hass, self._entity_id, self.update_cover_position)
|
||||
|
||||
def move_cover(self, value):
|
||||
"""Move cover to value if call came from homekit."""
|
||||
"""Move cover to value if call came from HomeKit."""
|
||||
if value != self.current_position:
|
||||
_LOGGER.debug("%s: Set position to %d", self._entity_id, value)
|
||||
self.homekit_target = value
|
||||
|
92
homeassistant/components/homekit/security_systems.py
Normal file
92
homeassistant/components/homekit/security_systems.py
Normal file
@ -0,0 +1,92 @@
|
||||
"""Class to hold all alarm control panel accessories."""
|
||||
import logging
|
||||
|
||||
from homeassistant.const import (
|
||||
STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME,
|
||||
STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED,
|
||||
ATTR_ENTITY_ID, ATTR_CODE)
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
|
||||
from . import TYPES
|
||||
from .accessories import HomeAccessory, add_preload_service
|
||||
from .const import (
|
||||
SERV_SECURITY_SYSTEM, CHAR_CURRENT_SECURITY_STATE,
|
||||
CHAR_TARGET_SECURITY_STATE)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
HASS_TO_HOMEKIT = {STATE_ALARM_DISARMED: 3, STATE_ALARM_ARMED_HOME: 0,
|
||||
STATE_ALARM_ARMED_AWAY: 1, STATE_ALARM_ARMED_NIGHT: 2}
|
||||
HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()}
|
||||
STATE_TO_SERVICE = {STATE_ALARM_DISARMED: 'alarm_disarm',
|
||||
STATE_ALARM_ARMED_HOME: 'alarm_arm_home',
|
||||
STATE_ALARM_ARMED_AWAY: 'alarm_arm_away',
|
||||
STATE_ALARM_ARMED_NIGHT: 'alarm_arm_night'}
|
||||
|
||||
|
||||
@TYPES.register('SecuritySystem')
|
||||
class SecuritySystem(HomeAccessory):
|
||||
"""Generate an SecuritySystem accessory for an alarm control panel."""
|
||||
|
||||
def __init__(self, hass, entity_id, display_name, alarm_code=None):
|
||||
"""Initialize a SecuritySystem accessory object."""
|
||||
super().__init__(display_name, entity_id, 'ALARM_SYSTEM')
|
||||
|
||||
self._hass = hass
|
||||
self._entity_id = entity_id
|
||||
self._alarm_code = alarm_code
|
||||
|
||||
self.flag_target_state = False
|
||||
|
||||
self.service_alarm = add_preload_service(self, SERV_SECURITY_SYSTEM)
|
||||
self.char_current_state = self.service_alarm. \
|
||||
get_characteristic(CHAR_CURRENT_SECURITY_STATE)
|
||||
self.char_current_state.value = 3
|
||||
self.char_target_state = self.service_alarm. \
|
||||
get_characteristic(CHAR_TARGET_SECURITY_STATE)
|
||||
self.char_target_state.value = 3
|
||||
|
||||
self.char_target_state.setter_callback = self.set_security_state
|
||||
|
||||
def run(self):
|
||||
"""Method called be object after driver is started."""
|
||||
state = self._hass.states.get(self._entity_id)
|
||||
self.update_security_state(new_state=state)
|
||||
|
||||
async_track_state_change(self._hass, self._entity_id,
|
||||
self.update_security_state)
|
||||
|
||||
def set_security_state(self, value):
|
||||
"""Move security state to value if call came from HomeKit."""
|
||||
_LOGGER.debug("%s: Set security state to %d",
|
||||
self._entity_id, value)
|
||||
self.flag_target_state = True
|
||||
hass_value = HOMEKIT_TO_HASS[value]
|
||||
service = STATE_TO_SERVICE[hass_value]
|
||||
|
||||
params = {ATTR_ENTITY_ID: self._entity_id}
|
||||
if self._alarm_code is not None:
|
||||
params[ATTR_CODE] = self._alarm_code
|
||||
self._hass.services.call('alarm_control_panel', service, params)
|
||||
|
||||
def update_security_state(self, entity_id=None,
|
||||
old_state=None, new_state=None):
|
||||
"""Update security state after state changed."""
|
||||
if new_state is None:
|
||||
return
|
||||
|
||||
hass_state = new_state.state
|
||||
if hass_state not in HASS_TO_HOMEKIT:
|
||||
return
|
||||
current_security_state = HASS_TO_HOMEKIT[hass_state]
|
||||
self.char_current_state.set_value(current_security_state)
|
||||
_LOGGER.debug("%s: Updated current state to %s (%d)",
|
||||
self._entity_id, hass_state,
|
||||
current_security_state)
|
||||
|
||||
if not self.flag_target_state:
|
||||
self.char_target_state.set_value(current_security_state,
|
||||
should_callback=False)
|
||||
elif self.char_target_state.get_value() \
|
||||
== self.char_current_state.get_value():
|
||||
self.flag_target_state = False
|
@ -1,38 +1,54 @@
|
||||
"""Class to hold all sensor accessories."""
|
||||
import logging
|
||||
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
from homeassistant.const import (
|
||||
ATTR_UNIT_OF_MEASUREMENT, TEMP_FAHRENHEIT, TEMP_CELSIUS)
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
|
||||
from . import TYPES
|
||||
from .accessories import HomeAccessory
|
||||
from .accessories import (
|
||||
HomeAccessory, add_preload_service, override_properties)
|
||||
from .const import (
|
||||
SERVICES_TEMPERATURE_SENSOR, CHAR_CURRENT_TEMPERATURE)
|
||||
SERV_TEMPERATURE_SENSOR, CHAR_CURRENT_TEMPERATURE, PROP_CELSIUS)
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def calc_temperature(state, unit=TEMP_CELSIUS):
|
||||
"""Calculate temperature from state and unit.
|
||||
|
||||
Always return temperature as Celsius value.
|
||||
Conversion is handled on the device.
|
||||
"""
|
||||
try:
|
||||
value = float(state)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return round((value - 32) / 1.8, 2) if unit == TEMP_FAHRENHEIT else value
|
||||
|
||||
|
||||
@TYPES.register('TemperatureSensor')
|
||||
class TemperatureSensor(HomeAccessory):
|
||||
"""Generate a TemperatureSensor accessory for a temperature sensor.
|
||||
|
||||
Sensor entity must return either temperature in °C or STATE_UNKNOWN.
|
||||
Sensor entity must return temperature in °C, °F.
|
||||
"""
|
||||
|
||||
def __init__(self, hass, entity_id, display_name):
|
||||
"""Initialize a TemperatureSensor accessory object."""
|
||||
super().__init__(display_name)
|
||||
self.set_category(self.ALL_CATEGORIES.SENSOR)
|
||||
self.set_accessory_info(entity_id)
|
||||
self.add_preload_service(SERVICES_TEMPERATURE_SENSOR)
|
||||
super().__init__(display_name, entity_id, 'SENSOR')
|
||||
|
||||
self._hass = hass
|
||||
self._entity_id = entity_id
|
||||
|
||||
self.service_temp = self.get_service(SERVICES_TEMPERATURE_SENSOR)
|
||||
self.char_temp = self.service_temp. \
|
||||
self.serv_temp = add_preload_service(self, SERV_TEMPERATURE_SENSOR)
|
||||
self.char_temp = self.serv_temp. \
|
||||
get_characteristic(CHAR_CURRENT_TEMPERATURE)
|
||||
override_properties(self.char_temp, PROP_CELSIUS)
|
||||
self.char_temp.value = 0
|
||||
self.unit = None
|
||||
|
||||
def run(self):
|
||||
"""Method called be object after driver is started."""
|
||||
@ -48,6 +64,9 @@ class TemperatureSensor(HomeAccessory):
|
||||
if new_state is None:
|
||||
return
|
||||
|
||||
temperature = new_state.state
|
||||
if temperature != STATE_UNKNOWN:
|
||||
self.char_temp.set_value(float(temperature))
|
||||
unit = new_state.attributes[ATTR_UNIT_OF_MEASUREMENT]
|
||||
temperature = calc_temperature(new_state.state, unit)
|
||||
if temperature is not None:
|
||||
self.char_temp.set_value(temperature)
|
||||
_LOGGER.debug("%s: Current temperature set to %d°C",
|
||||
self._entity_id, temperature)
|
||||
|
62
homeassistant/components/homekit/switches.py
Normal file
62
homeassistant/components/homekit/switches.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""Class to hold all switch accessories."""
|
||||
import logging
|
||||
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import split_entity_id
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
|
||||
from . import TYPES
|
||||
from .accessories import HomeAccessory, add_preload_service
|
||||
from .const import SERV_SWITCH, CHAR_ON
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@TYPES.register('Switch')
|
||||
class Switch(HomeAccessory):
|
||||
"""Generate a Switch accessory."""
|
||||
|
||||
def __init__(self, hass, entity_id, display_name):
|
||||
"""Initialize a Switch accessory object to represent a remote."""
|
||||
super().__init__(display_name, entity_id, 'SWITCH')
|
||||
|
||||
self._hass = hass
|
||||
self._entity_id = entity_id
|
||||
self._domain = split_entity_id(entity_id)[0]
|
||||
|
||||
self.flag_target_state = False
|
||||
|
||||
self.service_switch = add_preload_service(self, SERV_SWITCH)
|
||||
self.char_on = self.service_switch.get_characteristic(CHAR_ON)
|
||||
self.char_on.value = False
|
||||
self.char_on.setter_callback = self.set_state
|
||||
|
||||
def run(self):
|
||||
"""Method called be object after driver is started."""
|
||||
state = self._hass.states.get(self._entity_id)
|
||||
self.update_state(new_state=state)
|
||||
|
||||
async_track_state_change(self._hass, self._entity_id,
|
||||
self.update_state)
|
||||
|
||||
def set_state(self, value):
|
||||
"""Move switch state to value if call came from HomeKit."""
|
||||
_LOGGER.debug("%s: Set switch state to %s",
|
||||
self._entity_id, value)
|
||||
self.flag_target_state = True
|
||||
service = 'turn_on' if value else 'turn_off'
|
||||
self._hass.services.call(self._domain, service,
|
||||
{ATTR_ENTITY_ID: self._entity_id})
|
||||
|
||||
def update_state(self, entity_id=None, old_state=None, new_state=None):
|
||||
"""Update switch state after state changed."""
|
||||
if new_state is None:
|
||||
return
|
||||
|
||||
current_state = (new_state.state == 'on')
|
||||
if not self.flag_target_state:
|
||||
_LOGGER.debug("%s: Set current state to %s",
|
||||
self._entity_id, current_state)
|
||||
self.char_on.set_value(current_state, should_callback=False)
|
||||
else:
|
||||
self.flag_target_state = False
|
245
homeassistant/components/homekit/thermostats.py
Normal file
245
homeassistant/components/homekit/thermostats.py
Normal file
@ -0,0 +1,245 @@
|
||||
"""Class to hold all thermostat accessories."""
|
||||
import logging
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_CURRENT_TEMPERATURE, ATTR_TEMPERATURE,
|
||||
ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW,
|
||||
ATTR_OPERATION_MODE, ATTR_OPERATION_LIST,
|
||||
STATE_HEAT, STATE_COOL, STATE_AUTO)
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, ATTR_UNIT_OF_MEASUREMENT,
|
||||
TEMP_CELSIUS, TEMP_FAHRENHEIT)
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
|
||||
from . import TYPES
|
||||
from .accessories import HomeAccessory, add_preload_service
|
||||
from .const import (
|
||||
SERV_THERMOSTAT, CHAR_CURRENT_HEATING_COOLING,
|
||||
CHAR_TARGET_HEATING_COOLING, CHAR_CURRENT_TEMPERATURE,
|
||||
CHAR_TARGET_TEMPERATURE, CHAR_TEMP_DISPLAY_UNITS,
|
||||
CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_HEATING_THRESHOLD_TEMPERATURE)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STATE_OFF = 'off'
|
||||
UNIT_HASS_TO_HOMEKIT = {TEMP_CELSIUS: 0, TEMP_FAHRENHEIT: 1}
|
||||
UNIT_HOMEKIT_TO_HASS = {c: s for s, c in UNIT_HASS_TO_HOMEKIT.items()}
|
||||
HC_HASS_TO_HOMEKIT = {STATE_OFF: 0, STATE_HEAT: 1,
|
||||
STATE_COOL: 2, STATE_AUTO: 3}
|
||||
HC_HOMEKIT_TO_HASS = {c: s for s, c in HC_HASS_TO_HOMEKIT.items()}
|
||||
|
||||
|
||||
@TYPES.register('Thermostat')
|
||||
class Thermostat(HomeAccessory):
|
||||
"""Generate a Thermostat accessory for a climate."""
|
||||
|
||||
def __init__(self, hass, entity_id, display_name, support_auto=False):
|
||||
"""Initialize a Thermostat accessory object."""
|
||||
super().__init__(display_name, entity_id, 'THERMOSTAT')
|
||||
|
||||
self._hass = hass
|
||||
self._entity_id = entity_id
|
||||
self._call_timer = None
|
||||
|
||||
self.heat_cool_flag_target_state = False
|
||||
self.temperature_flag_target_state = False
|
||||
self.coolingthresh_flag_target_state = False
|
||||
self.heatingthresh_flag_target_state = False
|
||||
|
||||
extra_chars = None
|
||||
# Add additional characteristics if auto mode is supported
|
||||
if support_auto:
|
||||
extra_chars = [CHAR_COOLING_THRESHOLD_TEMPERATURE,
|
||||
CHAR_HEATING_THRESHOLD_TEMPERATURE]
|
||||
|
||||
# Preload the thermostat service
|
||||
self.service_thermostat = add_preload_service(self, SERV_THERMOSTAT,
|
||||
extra_chars)
|
||||
|
||||
# Current and target mode characteristics
|
||||
self.char_current_heat_cool = self.service_thermostat. \
|
||||
get_characteristic(CHAR_CURRENT_HEATING_COOLING)
|
||||
self.char_current_heat_cool.value = 0
|
||||
self.char_target_heat_cool = self.service_thermostat. \
|
||||
get_characteristic(CHAR_TARGET_HEATING_COOLING)
|
||||
self.char_target_heat_cool.value = 0
|
||||
self.char_target_heat_cool.setter_callback = self.set_heat_cool
|
||||
|
||||
# Current and target temperature characteristics
|
||||
self.char_current_temp = self.service_thermostat. \
|
||||
get_characteristic(CHAR_CURRENT_TEMPERATURE)
|
||||
self.char_current_temp.value = 21.0
|
||||
self.char_target_temp = self.service_thermostat. \
|
||||
get_characteristic(CHAR_TARGET_TEMPERATURE)
|
||||
self.char_target_temp.value = 21.0
|
||||
self.char_target_temp.setter_callback = self.set_target_temperature
|
||||
|
||||
# Display units characteristic
|
||||
self.char_display_units = self.service_thermostat. \
|
||||
get_characteristic(CHAR_TEMP_DISPLAY_UNITS)
|
||||
self.char_display_units.value = 0
|
||||
|
||||
# If the device supports it: high and low temperature characteristics
|
||||
if support_auto:
|
||||
self.char_cooling_thresh_temp = self.service_thermostat. \
|
||||
get_characteristic(CHAR_COOLING_THRESHOLD_TEMPERATURE)
|
||||
self.char_cooling_thresh_temp.value = 23.0
|
||||
self.char_cooling_thresh_temp.setter_callback = \
|
||||
self.set_cooling_threshold
|
||||
|
||||
self.char_heating_thresh_temp = self.service_thermostat. \
|
||||
get_characteristic(CHAR_HEATING_THRESHOLD_TEMPERATURE)
|
||||
self.char_heating_thresh_temp.value = 19.0
|
||||
self.char_heating_thresh_temp.setter_callback = \
|
||||
self.set_heating_threshold
|
||||
else:
|
||||
self.char_cooling_thresh_temp = None
|
||||
self.char_heating_thresh_temp = None
|
||||
|
||||
def run(self):
|
||||
"""Method called be object after driver is started."""
|
||||
state = self._hass.states.get(self._entity_id)
|
||||
self.update_thermostat(new_state=state)
|
||||
|
||||
async_track_state_change(self._hass, self._entity_id,
|
||||
self.update_thermostat)
|
||||
|
||||
def set_heat_cool(self, value):
|
||||
"""Move operation mode to value if call came from HomeKit."""
|
||||
if value in HC_HOMEKIT_TO_HASS:
|
||||
_LOGGER.debug("%s: Set heat-cool to %d", self._entity_id, value)
|
||||
self.heat_cool_flag_target_state = True
|
||||
hass_value = HC_HOMEKIT_TO_HASS[value]
|
||||
self._hass.services.call('climate', 'set_operation_mode',
|
||||
{ATTR_ENTITY_ID: self._entity_id,
|
||||
ATTR_OPERATION_MODE: hass_value})
|
||||
|
||||
def set_cooling_threshold(self, value):
|
||||
"""Set cooling threshold temp to value if call came from HomeKit."""
|
||||
_LOGGER.debug("%s: Set cooling threshold temperature to %.2f",
|
||||
self._entity_id, value)
|
||||
self.coolingthresh_flag_target_state = True
|
||||
low = self.char_heating_thresh_temp.get_value()
|
||||
self._hass.services.call(
|
||||
'climate', 'set_temperature',
|
||||
{ATTR_ENTITY_ID: self._entity_id,
|
||||
ATTR_TARGET_TEMP_HIGH: value,
|
||||
ATTR_TARGET_TEMP_LOW: low})
|
||||
|
||||
def set_heating_threshold(self, value):
|
||||
"""Set heating threshold temp to value if call came from HomeKit."""
|
||||
_LOGGER.debug("%s: Set heating threshold temperature to %.2f",
|
||||
self._entity_id, value)
|
||||
self.heatingthresh_flag_target_state = True
|
||||
# Home assistant always wants to set low and high at the same time
|
||||
high = self.char_cooling_thresh_temp.get_value()
|
||||
self._hass.services.call(
|
||||
'climate', 'set_temperature',
|
||||
{ATTR_ENTITY_ID: self._entity_id,
|
||||
ATTR_TARGET_TEMP_LOW: value,
|
||||
ATTR_TARGET_TEMP_HIGH: high})
|
||||
|
||||
def set_target_temperature(self, value):
|
||||
"""Set target temperature to value if call came from HomeKit."""
|
||||
_LOGGER.debug("%s: Set target temperature to %.2f",
|
||||
self._entity_id, value)
|
||||
self.temperature_flag_target_state = True
|
||||
self._hass.services.call(
|
||||
'climate', 'set_temperature',
|
||||
{ATTR_ENTITY_ID: self._entity_id,
|
||||
ATTR_TEMPERATURE: value})
|
||||
|
||||
def update_thermostat(self, entity_id=None,
|
||||
old_state=None, new_state=None):
|
||||
"""Update security state after state changed."""
|
||||
if new_state is None:
|
||||
return
|
||||
|
||||
# Update current temperature
|
||||
current_temp = new_state.attributes.get(ATTR_CURRENT_TEMPERATURE)
|
||||
if current_temp is not None:
|
||||
self.char_current_temp.set_value(current_temp)
|
||||
|
||||
# Update target temperature
|
||||
target_temp = new_state.attributes.get(ATTR_TEMPERATURE)
|
||||
if target_temp is not None:
|
||||
if not self.temperature_flag_target_state:
|
||||
self.char_target_temp.set_value(target_temp,
|
||||
should_callback=False)
|
||||
else:
|
||||
self.temperature_flag_target_state = False
|
||||
|
||||
# Update cooling threshold temperature if characteristic exists
|
||||
if self.char_cooling_thresh_temp is not None:
|
||||
cooling_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_HIGH)
|
||||
if cooling_thresh is not None:
|
||||
if not self.coolingthresh_flag_target_state:
|
||||
self.char_cooling_thresh_temp.set_value(
|
||||
cooling_thresh, should_callback=False)
|
||||
else:
|
||||
self.coolingthresh_flag_target_state = False
|
||||
|
||||
# Update heating threshold temperature if characteristic exists
|
||||
if self.char_heating_thresh_temp is not None:
|
||||
heating_thresh = new_state.attributes.get(ATTR_TARGET_TEMP_LOW)
|
||||
if heating_thresh is not None:
|
||||
if not self.heatingthresh_flag_target_state:
|
||||
self.char_heating_thresh_temp.set_value(
|
||||
heating_thresh, should_callback=False)
|
||||
else:
|
||||
self.heatingthresh_flag_target_state = False
|
||||
|
||||
# Update display units
|
||||
display_units = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
if display_units is not None \
|
||||
and display_units in UNIT_HASS_TO_HOMEKIT:
|
||||
self.char_display_units.set_value(
|
||||
UNIT_HASS_TO_HOMEKIT[display_units])
|
||||
|
||||
# Update target operation mode
|
||||
operation_mode = new_state.attributes.get(ATTR_OPERATION_MODE)
|
||||
if operation_mode is not None \
|
||||
and operation_mode in HC_HASS_TO_HOMEKIT:
|
||||
if not self.heat_cool_flag_target_state:
|
||||
self.char_target_heat_cool.set_value(
|
||||
HC_HASS_TO_HOMEKIT[operation_mode], should_callback=False)
|
||||
else:
|
||||
self.heat_cool_flag_target_state = False
|
||||
|
||||
# Set current operation mode based on temperatures and target mode
|
||||
if operation_mode == STATE_HEAT:
|
||||
if current_temp < target_temp:
|
||||
current_operation_mode = STATE_HEAT
|
||||
else:
|
||||
current_operation_mode = STATE_OFF
|
||||
elif operation_mode == STATE_COOL:
|
||||
if current_temp > target_temp:
|
||||
current_operation_mode = STATE_COOL
|
||||
else:
|
||||
current_operation_mode = STATE_OFF
|
||||
elif operation_mode == STATE_AUTO:
|
||||
# Check if auto is supported
|
||||
if self.char_cooling_thresh_temp is not None:
|
||||
lower_temp = self.char_heating_thresh_temp.get_value()
|
||||
upper_temp = self.char_cooling_thresh_temp.get_value()
|
||||
if current_temp < lower_temp:
|
||||
current_operation_mode = STATE_HEAT
|
||||
elif current_temp > upper_temp:
|
||||
current_operation_mode = STATE_COOL
|
||||
else:
|
||||
current_operation_mode = STATE_OFF
|
||||
else:
|
||||
# Check if heating or cooling are supported
|
||||
heat = STATE_HEAT in new_state.attributes[ATTR_OPERATION_LIST]
|
||||
cool = STATE_COOL in new_state.attributes[ATTR_OPERATION_LIST]
|
||||
if current_temp < target_temp and heat:
|
||||
current_operation_mode = STATE_HEAT
|
||||
elif current_temp > target_temp and cool:
|
||||
current_operation_mode = STATE_COOL
|
||||
else:
|
||||
current_operation_mode = STATE_OFF
|
||||
else:
|
||||
current_operation_mode = STATE_OFF
|
||||
|
||||
self.char_current_heat_cool.set_value(
|
||||
HC_HASS_TO_HOMEKIT[current_operation_mode])
|
@ -4,21 +4,18 @@ This module provides WSGI application to serve the Home Assistant API.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/http/
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from ipaddress import ip_network
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import ssl
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web_exceptions import HTTPUnauthorized, HTTPMovedPermanently
|
||||
from aiohttp.web_exceptions import HTTPMovedPermanently
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import (
|
||||
SERVER_PORT, CONTENT_TYPE_JSON,
|
||||
EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START,)
|
||||
from homeassistant.core import is_callback
|
||||
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, SERVER_PORT)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.remote as rem
|
||||
import homeassistant.util as hass_util
|
||||
@ -28,10 +25,13 @@ from .auth import setup_auth
|
||||
from .ban import setup_bans
|
||||
from .cors import setup_cors
|
||||
from .real_ip import setup_real_ip
|
||||
from .const import KEY_AUTHENTICATED, KEY_REAL_IP
|
||||
from .static import (
|
||||
CachingFileResponse, CachingStaticResource, staticresource_middleware)
|
||||
|
||||
# Import as alias
|
||||
from .const import KEY_AUTHENTICATED, KEY_REAL_IP # noqa
|
||||
from .view import HomeAssistantView # noqa
|
||||
|
||||
REQUIREMENTS = ['aiohttp_cors==0.6.0']
|
||||
|
||||
DOMAIN = 'http'
|
||||
@ -98,8 +98,7 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
async def async_setup(hass, config):
|
||||
"""Set up the HTTP API and debug interface."""
|
||||
conf = config.get(DOMAIN)
|
||||
|
||||
@ -135,16 +134,14 @@ def async_setup(hass, config):
|
||||
is_ban_enabled=is_ban_enabled
|
||||
)
|
||||
|
||||
@asyncio.coroutine
|
||||
def stop_server(event):
|
||||
async def stop_server(event):
|
||||
"""Stop the server."""
|
||||
yield from server.stop()
|
||||
await server.stop()
|
||||
|
||||
@asyncio.coroutine
|
||||
def start_server(event):
|
||||
async def start_server(event):
|
||||
"""Start the server."""
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_server)
|
||||
yield from server.start()
|
||||
await server.start()
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_server)
|
||||
|
||||
@ -252,13 +249,11 @@ class HomeAssistantHTTP(object):
|
||||
return
|
||||
|
||||
if cache_headers:
|
||||
@asyncio.coroutine
|
||||
def serve_file(request):
|
||||
async def serve_file(request):
|
||||
"""Serve file from disk."""
|
||||
return CachingFileResponse(path)
|
||||
else:
|
||||
@asyncio.coroutine
|
||||
def serve_file(request):
|
||||
async def serve_file(request):
|
||||
"""Serve file from disk."""
|
||||
return web.FileResponse(path)
|
||||
|
||||
@ -276,10 +271,13 @@ class HomeAssistantHTTP(object):
|
||||
|
||||
self.app.router.add_route('GET', url_pattern, serve_file)
|
||||
|
||||
@asyncio.coroutine
|
||||
def start(self):
|
||||
async def start(self):
|
||||
"""Start the WSGI server."""
|
||||
yield from self.app.startup()
|
||||
# We misunderstood the startup signal. You're not allowed to change
|
||||
# anything during startup. Temp workaround.
|
||||
# pylint: disable=protected-access
|
||||
self.app._on_startup.freeze()
|
||||
await self.app.startup()
|
||||
|
||||
if self.ssl_certificate:
|
||||
try:
|
||||
@ -298,133 +296,24 @@ class HomeAssistantHTTP(object):
|
||||
# Aiohttp freezes apps after start so that no changes can be made.
|
||||
# However in Home Assistant components can be discovered after boot.
|
||||
# This will now raise a RunTimeError.
|
||||
# To work around this we now fake that we are frozen.
|
||||
# A more appropriate fix would be to create a new app and
|
||||
# re-register all redirects, views, static paths.
|
||||
self.app._frozen = True # pylint: disable=protected-access
|
||||
# To work around this we now prevent the router from getting frozen
|
||||
self.app._router.freeze = lambda: None
|
||||
|
||||
self._handler = self.app.make_handler(loop=self.hass.loop)
|
||||
|
||||
try:
|
||||
self.server = yield from self.hass.loop.create_server(
|
||||
self.server = await self.hass.loop.create_server(
|
||||
self._handler, self.server_host, self.server_port, ssl=context)
|
||||
except OSError as error:
|
||||
_LOGGER.error("Failed to create HTTP server at port %d: %s",
|
||||
self.server_port, error)
|
||||
|
||||
# pylint: disable=protected-access
|
||||
self.app._middlewares = tuple(self.app._prepare_middleware())
|
||||
self.app._frozen = False
|
||||
|
||||
@asyncio.coroutine
|
||||
def stop(self):
|
||||
async def stop(self):
|
||||
"""Stop the WSGI server."""
|
||||
if self.server:
|
||||
self.server.close()
|
||||
yield from self.server.wait_closed()
|
||||
yield from self.app.shutdown()
|
||||
await self.server.wait_closed()
|
||||
await self.app.shutdown()
|
||||
if self._handler:
|
||||
yield from self._handler.shutdown(10)
|
||||
yield from self.app.cleanup()
|
||||
|
||||
|
||||
class HomeAssistantView(object):
|
||||
"""Base view for all views."""
|
||||
|
||||
url = None
|
||||
extra_urls = []
|
||||
requires_auth = True # Views inheriting from this class can override this
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def json(self, result, status_code=200, headers=None):
|
||||
"""Return a JSON response."""
|
||||
msg = json.dumps(
|
||||
result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8')
|
||||
response = web.Response(
|
||||
body=msg, content_type=CONTENT_TYPE_JSON, status=status_code,
|
||||
headers=headers)
|
||||
response.enable_compression()
|
||||
return response
|
||||
|
||||
def json_message(self, message, status_code=200, message_code=None,
|
||||
headers=None):
|
||||
"""Return a JSON message response."""
|
||||
data = {'message': message}
|
||||
if message_code is not None:
|
||||
data['code'] = message_code
|
||||
return self.json(data, status_code, headers=headers)
|
||||
|
||||
@asyncio.coroutine
|
||||
# pylint: disable=no-self-use
|
||||
def file(self, request, fil):
|
||||
"""Return a file."""
|
||||
assert isinstance(fil, str), 'only string paths allowed'
|
||||
return web.FileResponse(fil)
|
||||
|
||||
def register(self, router):
|
||||
"""Register the view with a router."""
|
||||
assert self.url is not None, 'No url set for view'
|
||||
urls = [self.url] + self.extra_urls
|
||||
|
||||
for method in ('get', 'post', 'delete', 'put'):
|
||||
handler = getattr(self, method, None)
|
||||
|
||||
if not handler:
|
||||
continue
|
||||
|
||||
handler = request_handler_factory(self, handler)
|
||||
|
||||
for url in urls:
|
||||
router.add_route(method, url, handler)
|
||||
|
||||
# aiohttp_cors does not work with class based views
|
||||
# self.app.router.add_route('*', self.url, self, name=self.name)
|
||||
|
||||
# for url in self.extra_urls:
|
||||
# self.app.router.add_route('*', url, self)
|
||||
|
||||
|
||||
def request_handler_factory(view, handler):
|
||||
"""Wrap the handler classes."""
|
||||
assert asyncio.iscoroutinefunction(handler) or is_callback(handler), \
|
||||
"Handler should be a coroutine or a callback."
|
||||
|
||||
@asyncio.coroutine
|
||||
def handle(request):
|
||||
"""Handle incoming request."""
|
||||
if not request.app['hass'].is_running:
|
||||
return web.Response(status=503)
|
||||
|
||||
authenticated = request.get(KEY_AUTHENTICATED, False)
|
||||
|
||||
if view.requires_auth and not authenticated:
|
||||
raise HTTPUnauthorized()
|
||||
|
||||
_LOGGER.info('Serving %s to %s (auth: %s)',
|
||||
request.path, request.get(KEY_REAL_IP), authenticated)
|
||||
|
||||
result = handler(request, **request.match_info)
|
||||
|
||||
if asyncio.iscoroutine(result):
|
||||
result = yield from result
|
||||
|
||||
if isinstance(result, web.StreamResponse):
|
||||
# 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
|
||||
|
||||
if isinstance(result, str):
|
||||
result = result.encode('utf-8')
|
||||
elif result is None:
|
||||
result = b''
|
||||
elif not isinstance(result, bytes):
|
||||
assert False, ('Result should be None, string, bytes or Response. '
|
||||
'Got: {}').format(result)
|
||||
|
||||
return web.Response(body=result, status=status_code)
|
||||
|
||||
return handle
|
||||
await self._handler.shutdown(10)
|
||||
await self.app.cleanup()
|
||||
|
@ -1,5 +1,5 @@
|
||||
"""Authentication for HTTP component."""
|
||||
import asyncio
|
||||
|
||||
import base64
|
||||
import hmac
|
||||
import logging
|
||||
@ -20,13 +20,12 @@ _LOGGER = logging.getLogger(__name__)
|
||||
def setup_auth(app, trusted_networks, api_password):
|
||||
"""Create auth middleware for the app."""
|
||||
@middleware
|
||||
@asyncio.coroutine
|
||||
def auth_middleware(request, handler):
|
||||
async def auth_middleware(request, handler):
|
||||
"""Authenticate as middleware."""
|
||||
# If no password set, just always set authenticated=True
|
||||
if api_password is None:
|
||||
request[KEY_AUTHENTICATED] = True
|
||||
return (yield from handler(request))
|
||||
return await handler(request)
|
||||
|
||||
# Check authentication
|
||||
authenticated = False
|
||||
@ -50,10 +49,9 @@ def setup_auth(app, trusted_networks, api_password):
|
||||
authenticated = True
|
||||
|
||||
request[KEY_AUTHENTICATED] = authenticated
|
||||
return (yield from handler(request))
|
||||
return await handler(request)
|
||||
|
||||
@asyncio.coroutine
|
||||
def auth_startup(app):
|
||||
async def auth_startup(app):
|
||||
"""Initialize auth middleware when app starts up."""
|
||||
app.middlewares.append(auth_middleware)
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
"""Ban logic for HTTP component."""
|
||||
import asyncio
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
from ipaddress import ip_address
|
||||
@ -38,11 +38,10 @@ SCHEMA_IP_BAN_ENTRY = vol.Schema({
|
||||
@callback
|
||||
def setup_bans(hass, app, login_threshold):
|
||||
"""Create IP Ban middleware for the app."""
|
||||
@asyncio.coroutine
|
||||
def ban_startup(app):
|
||||
async def ban_startup(app):
|
||||
"""Initialize bans when app starts up."""
|
||||
app.middlewares.append(ban_middleware)
|
||||
app[KEY_BANNED_IPS] = yield from hass.async_add_job(
|
||||
app[KEY_BANNED_IPS] = await hass.async_add_job(
|
||||
load_ip_bans_config, hass.config.path(IP_BANS_FILE))
|
||||
app[KEY_FAILED_LOGIN_ATTEMPTS] = defaultdict(int)
|
||||
app[KEY_LOGIN_THRESHOLD] = login_threshold
|
||||
@ -51,12 +50,11 @@ def setup_bans(hass, app, login_threshold):
|
||||
|
||||
|
||||
@middleware
|
||||
@asyncio.coroutine
|
||||
def ban_middleware(request, handler):
|
||||
async def ban_middleware(request, handler):
|
||||
"""IP Ban middleware."""
|
||||
if KEY_BANNED_IPS not in request.app:
|
||||
_LOGGER.error('IP Ban middleware loaded but banned IPs not loaded')
|
||||
return (yield from handler(request))
|
||||
return await handler(request)
|
||||
|
||||
# Verify if IP is not banned
|
||||
ip_address_ = request[KEY_REAL_IP]
|
||||
@ -67,14 +65,13 @@ def ban_middleware(request, handler):
|
||||
raise HTTPForbidden()
|
||||
|
||||
try:
|
||||
return (yield from handler(request))
|
||||
return await handler(request)
|
||||
except HTTPUnauthorized:
|
||||
yield from process_wrong_login(request)
|
||||
await process_wrong_login(request)
|
||||
raise
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def process_wrong_login(request):
|
||||
async def process_wrong_login(request):
|
||||
"""Process a wrong login attempt."""
|
||||
remote_addr = request[KEY_REAL_IP]
|
||||
|
||||
@ -98,7 +95,7 @@ def process_wrong_login(request):
|
||||
request.app[KEY_BANNED_IPS].append(new_ban)
|
||||
|
||||
hass = request.app['hass']
|
||||
yield from hass.async_add_job(
|
||||
await hass.async_add_job(
|
||||
update_ip_bans_config, hass.config.path(IP_BANS_FILE), new_ban)
|
||||
|
||||
_LOGGER.warning(
|
||||
|
@ -1,5 +1,5 @@
|
||||
"""Provide cors support for the HTTP component."""
|
||||
import asyncio
|
||||
|
||||
|
||||
from aiohttp.hdrs import ACCEPT, ORIGIN, CONTENT_TYPE
|
||||
|
||||
@ -27,8 +27,7 @@ def setup_cors(app, origins):
|
||||
) for host in origins
|
||||
})
|
||||
|
||||
@asyncio.coroutine
|
||||
def cors_startup(app):
|
||||
async def cors_startup(app):
|
||||
"""Initialize cors when app starts up."""
|
||||
cors_added = set()
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
"""Decorator for view methods to help with data validation."""
|
||||
import asyncio
|
||||
|
||||
from functools import wraps
|
||||
import logging
|
||||
|
||||
@ -24,16 +24,15 @@ class RequestDataValidator:
|
||||
|
||||
def __call__(self, method):
|
||||
"""Decorate a function."""
|
||||
@asyncio.coroutine
|
||||
@wraps(method)
|
||||
def wrapper(view, request, *args, **kwargs):
|
||||
async def wrapper(view, request, *args, **kwargs):
|
||||
"""Wrap a request handler with data validation."""
|
||||
data = None
|
||||
try:
|
||||
data = yield from request.json()
|
||||
data = await request.json()
|
||||
except ValueError:
|
||||
if not self._allow_empty or \
|
||||
(yield from request.content.read()) != b'':
|
||||
(await request.content.read()) != b'':
|
||||
_LOGGER.error('Invalid JSON received.')
|
||||
return view.json_message('Invalid JSON.', 400)
|
||||
data = {}
|
||||
@ -45,7 +44,7 @@ class RequestDataValidator:
|
||||
return view.json_message(
|
||||
'Message format incorrect: {}'.format(err), 400)
|
||||
|
||||
result = yield from method(view, request, *args, **kwargs)
|
||||
result = await method(view, request, *args, **kwargs)
|
||||
return result
|
||||
|
||||
return wrapper
|
||||
|
@ -1,5 +1,5 @@
|
||||
"""Middleware to fetch real IP."""
|
||||
import asyncio
|
||||
|
||||
from ipaddress import ip_address
|
||||
|
||||
from aiohttp.web import middleware
|
||||
@ -14,8 +14,7 @@ from .const import KEY_REAL_IP
|
||||
def setup_real_ip(app, use_x_forwarded_for):
|
||||
"""Create IP Ban middleware for the app."""
|
||||
@middleware
|
||||
@asyncio.coroutine
|
||||
def real_ip_middleware(request, handler):
|
||||
async def real_ip_middleware(request, handler):
|
||||
"""Real IP middleware."""
|
||||
if (use_x_forwarded_for and
|
||||
X_FORWARDED_FOR in request.headers):
|
||||
@ -25,10 +24,9 @@ def setup_real_ip(app, use_x_forwarded_for):
|
||||
request[KEY_REAL_IP] = \
|
||||
ip_address(request.transport.get_extra_info('peername')[0])
|
||||
|
||||
return (yield from handler(request))
|
||||
return await handler(request)
|
||||
|
||||
@asyncio.coroutine
|
||||
def app_startup(app):
|
||||
async def app_startup(app):
|
||||
"""Initialize bans when app starts up."""
|
||||
app.middlewares.append(real_ip_middleware)
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
"""Static file handling for HTTP component."""
|
||||
import asyncio
|
||||
|
||||
import re
|
||||
|
||||
from aiohttp import hdrs
|
||||
@ -14,8 +14,7 @@ _FINGERPRINT = re.compile(r'^(.+)-[a-z0-9]{32}\.(\w+)$', re.IGNORECASE)
|
||||
class CachingStaticResource(StaticResource):
|
||||
"""Static Resource handler that will add cache headers."""
|
||||
|
||||
@asyncio.coroutine
|
||||
def _handle(self, request):
|
||||
async def _handle(self, request):
|
||||
filename = URL(request.match_info['filename']).path
|
||||
try:
|
||||
# PyLint is wrong about resolve not being a member.
|
||||
@ -32,13 +31,14 @@ class CachingStaticResource(StaticResource):
|
||||
raise HTTPNotFound() from error
|
||||
|
||||
if filepath.is_dir():
|
||||
return (yield from super()._handle(request))
|
||||
return await super()._handle(request)
|
||||
elif filepath.is_file():
|
||||
return CachingFileResponse(filepath, chunk_size=self._chunk_size)
|
||||
else:
|
||||
raise HTTPNotFound
|
||||
|
||||
|
||||
# pylint: disable=too-many-ancestors
|
||||
class CachingFileResponse(FileResponse):
|
||||
"""FileSender class that caches output if not in dev mode."""
|
||||
|
||||
@ -48,26 +48,24 @@ class CachingFileResponse(FileResponse):
|
||||
|
||||
orig_sendfile = self._sendfile
|
||||
|
||||
@asyncio.coroutine
|
||||
def sendfile(request, fobj, count):
|
||||
async def sendfile(request, fobj, count):
|
||||
"""Sendfile that includes a cache header."""
|
||||
cache_time = 31 * 86400 # = 1 month
|
||||
self.headers[hdrs.CACHE_CONTROL] = "public, max-age={}".format(
|
||||
cache_time)
|
||||
|
||||
yield from orig_sendfile(request, fobj, count)
|
||||
await orig_sendfile(request, fobj, count)
|
||||
|
||||
# Overwriting like this because __init__ can change implementation.
|
||||
self._sendfile = sendfile
|
||||
|
||||
|
||||
@middleware
|
||||
@asyncio.coroutine
|
||||
def staticresource_middleware(request, handler):
|
||||
async def staticresource_middleware(request, handler):
|
||||
"""Middleware to strip out fingerprint from fingerprinted assets."""
|
||||
path = request.path
|
||||
if not path.startswith('/static/') and not path.startswith('/frontend'):
|
||||
return (yield from handler(request))
|
||||
return await handler(request)
|
||||
|
||||
fingerprinted = _FINGERPRINT.match(request.match_info['filename'])
|
||||
|
||||
@ -75,4 +73,4 @@ def staticresource_middleware(request, handler):
|
||||
request.match_info['filename'] = \
|
||||
'{}.{}'.format(*fingerprinted.groups())
|
||||
|
||||
return (yield from handler(request))
|
||||
return await handler(request)
|
||||
|
121
homeassistant/components/http/view.py
Normal file
121
homeassistant/components/http/view.py
Normal file
@ -0,0 +1,121 @@
|
||||
"""
|
||||
This module provides WSGI application to serve the Home Assistant API.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/http/
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.web_exceptions import HTTPUnauthorized
|
||||
|
||||
import homeassistant.remote as rem
|
||||
from homeassistant.core import is_callback
|
||||
from homeassistant.const import CONTENT_TYPE_JSON
|
||||
|
||||
from .const import KEY_AUTHENTICATED, KEY_REAL_IP
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HomeAssistantView(object):
|
||||
"""Base view for all views."""
|
||||
|
||||
url = None
|
||||
extra_urls = []
|
||||
requires_auth = True # Views inheriting from this class can override this
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
def json(self, result, status_code=200, headers=None):
|
||||
"""Return a JSON response."""
|
||||
msg = json.dumps(
|
||||
result, sort_keys=True, cls=rem.JSONEncoder).encode('UTF-8')
|
||||
response = web.Response(
|
||||
body=msg, content_type=CONTENT_TYPE_JSON, status=status_code,
|
||||
headers=headers)
|
||||
response.enable_compression()
|
||||
return response
|
||||
|
||||
def json_message(self, message, status_code=200, message_code=None,
|
||||
headers=None):
|
||||
"""Return a JSON message response."""
|
||||
data = {'message': message}
|
||||
if message_code is not None:
|
||||
data['code'] = message_code
|
||||
return self.json(data, status_code, headers=headers)
|
||||
|
||||
# pylint: disable=no-self-use
|
||||
async def file(self, request, fil):
|
||||
"""Return a file."""
|
||||
assert isinstance(fil, str), 'only string paths allowed'
|
||||
return web.FileResponse(fil)
|
||||
|
||||
def register(self, router):
|
||||
"""Register the view with a router."""
|
||||
assert self.url is not None, 'No url set for view'
|
||||
urls = [self.url] + self.extra_urls
|
||||
|
||||
for method in ('get', 'post', 'delete', 'put'):
|
||||
handler = getattr(self, method, None)
|
||||
|
||||
if not handler:
|
||||
continue
|
||||
|
||||
handler = request_handler_factory(self, handler)
|
||||
|
||||
for url in urls:
|
||||
router.add_route(method, url, handler)
|
||||
|
||||
# aiohttp_cors does not work with class based views
|
||||
# self.app.router.add_route('*', self.url, self, name=self.name)
|
||||
|
||||
# for url in self.extra_urls:
|
||||
# self.app.router.add_route('*', url, self)
|
||||
|
||||
|
||||
def request_handler_factory(view, handler):
|
||||
"""Wrap the handler classes."""
|
||||
assert asyncio.iscoroutinefunction(handler) or is_callback(handler), \
|
||||
"Handler should be a coroutine or a callback."
|
||||
|
||||
async def handle(request):
|
||||
"""Handle incoming request."""
|
||||
if not request.app['hass'].is_running:
|
||||
return web.Response(status=503)
|
||||
|
||||
authenticated = request.get(KEY_AUTHENTICATED, False)
|
||||
|
||||
if view.requires_auth and not authenticated:
|
||||
raise HTTPUnauthorized()
|
||||
|
||||
_LOGGER.info('Serving %s to %s (auth: %s)',
|
||||
request.path, request.get(KEY_REAL_IP), authenticated)
|
||||
|
||||
result = handler(request, **request.match_info)
|
||||
|
||||
if asyncio.iscoroutine(result):
|
||||
result = await result
|
||||
|
||||
if isinstance(result, web.StreamResponse):
|
||||
# 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
|
||||
|
||||
if isinstance(result, str):
|
||||
result = result.encode('utf-8')
|
||||
elif result is None:
|
||||
result = b''
|
||||
elif not isinstance(result, bytes):
|
||||
assert False, ('Result should be None, string, bytes or Response. '
|
||||
'Got: {}').format(result)
|
||||
|
||||
return web.Response(body=result, status=status_code)
|
||||
|
||||
return handle
|
@ -4,20 +4,24 @@ This component provides basic support for the Philips Hue system.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/hue/
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
from functools import partial
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
|
||||
import async_timeout
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.discovery import SERVICE_HUE
|
||||
from homeassistant.const import CONF_FILENAME, CONF_HOST
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers import discovery, aiohttp_client
|
||||
from homeassistant import config_entries
|
||||
|
||||
REQUIREMENTS = ['phue==1.0']
|
||||
REQUIREMENTS = ['phue==1.0', 'aiohue==0.3.0']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -133,13 +137,14 @@ def bridge_discovered(hass, service, discovery_info):
|
||||
|
||||
|
||||
def setup_bridge(host, hass, filename=None, allow_unreachable=False,
|
||||
allow_in_emulated_hue=True, allow_hue_groups=True):
|
||||
allow_in_emulated_hue=True, allow_hue_groups=True,
|
||||
username=None):
|
||||
"""Set up a given Hue bridge."""
|
||||
# Only register a device once
|
||||
if socket.gethostbyname(host) in hass.data[DOMAIN]:
|
||||
return
|
||||
|
||||
bridge = HueBridge(host, hass, filename, allow_unreachable,
|
||||
bridge = HueBridge(host, hass, filename, username, allow_unreachable,
|
||||
allow_in_emulated_hue, allow_hue_groups)
|
||||
bridge.setup()
|
||||
|
||||
@ -164,13 +169,14 @@ def _find_host_from_config(hass, filename=PHUE_CONFIG_FILE):
|
||||
class HueBridge(object):
|
||||
"""Manages a single Hue bridge."""
|
||||
|
||||
def __init__(self, host, hass, filename, allow_unreachable=False,
|
||||
def __init__(self, host, hass, filename, username, allow_unreachable=False,
|
||||
allow_in_emulated_hue=True, allow_hue_groups=True):
|
||||
"""Initialize the system."""
|
||||
self.host = host
|
||||
self.bridge_id = socket.gethostbyname(host)
|
||||
self.hass = hass
|
||||
self.filename = filename
|
||||
self.username = username
|
||||
self.allow_unreachable = allow_unreachable
|
||||
self.allow_in_emulated_hue = allow_in_emulated_hue
|
||||
self.allow_hue_groups = allow_hue_groups
|
||||
@ -189,10 +195,14 @@ class HueBridge(object):
|
||||
import phue
|
||||
|
||||
try:
|
||||
self.bridge = phue.Bridge(
|
||||
self.host,
|
||||
config_file_path=self.hass.config.path(self.filename))
|
||||
except (ConnectionRefusedError, OSError): # Wrong host was given
|
||||
kwargs = {}
|
||||
if self.username is not None:
|
||||
kwargs['username'] = self.username
|
||||
if self.filename is not None:
|
||||
kwargs['config_file_path'] = \
|
||||
self.hass.config.path(self.filename)
|
||||
self.bridge = phue.Bridge(self.host, **kwargs)
|
||||
except OSError: # Wrong host was given
|
||||
_LOGGER.error("Error connecting to the Hue bridge at %s",
|
||||
self.host)
|
||||
return
|
||||
@ -204,6 +214,7 @@ class HueBridge(object):
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unknown error connecting with Hue bridge at %s",
|
||||
self.host)
|
||||
return
|
||||
|
||||
# If we came here and configuring this host, mark as done
|
||||
if self.config_request_id:
|
||||
@ -260,3 +271,112 @@ class HueBridge(object):
|
||||
def set_group(self, light_id, command):
|
||||
"""Change light settings for a group. See phue for detail."""
|
||||
return self.bridge.set_group(light_id, command)
|
||||
|
||||
|
||||
@config_entries.HANDLERS.register(DOMAIN)
|
||||
class HueFlowHandler(config_entries.ConfigFlowHandler):
|
||||
"""Handle a Hue config flow."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the Hue flow."""
|
||||
self.host = None
|
||||
|
||||
@property
|
||||
def _websession(self):
|
||||
"""Return a websession.
|
||||
|
||||
Cannot assign in init because hass variable is not set yet.
|
||||
"""
|
||||
return aiohttp_client.async_get_clientsession(self.hass)
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
"""Handle a flow start."""
|
||||
from aiohue.discovery import discover_nupnp
|
||||
|
||||
if user_input is not None:
|
||||
self.host = user_input['host']
|
||||
return await self.async_step_link()
|
||||
|
||||
try:
|
||||
with async_timeout.timeout(5):
|
||||
bridges = await discover_nupnp(websession=self._websession)
|
||||
except asyncio.TimeoutError:
|
||||
return self.async_abort(
|
||||
reason='Unable to discover Hue bridges.'
|
||||
)
|
||||
|
||||
if not bridges:
|
||||
return self.async_abort(
|
||||
reason='No Philips Hue bridges discovered.'
|
||||
)
|
||||
|
||||
# Find already configured hosts
|
||||
configured_hosts = set(
|
||||
entry.data['host'] for entry
|
||||
in self.hass.config_entries.async_entries(DOMAIN))
|
||||
|
||||
hosts = [bridge.host for bridge in bridges
|
||||
if bridge.host not in configured_hosts]
|
||||
|
||||
if not hosts:
|
||||
return self.async_abort(
|
||||
reason='All Philips Hue bridges are already configured.'
|
||||
)
|
||||
|
||||
elif len(hosts) == 1:
|
||||
self.host = hosts[0]
|
||||
return await self.async_step_link()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='init',
|
||||
title='Pick Hue Bridge',
|
||||
data_schema=vol.Schema({
|
||||
vol.Required('host'): vol.In(hosts)
|
||||
})
|
||||
)
|
||||
|
||||
async def async_step_link(self, user_input=None):
|
||||
"""Attempt to link with the Hue bridge."""
|
||||
import aiohue
|
||||
errors = {}
|
||||
|
||||
if user_input is not None:
|
||||
bridge = aiohue.Bridge(self.host, websession=self._websession)
|
||||
try:
|
||||
with async_timeout.timeout(5):
|
||||
# Create auth token
|
||||
await bridge.create_user('home-assistant')
|
||||
# Fetches name and id
|
||||
await bridge.initialize()
|
||||
except (asyncio.TimeoutError, aiohue.RequestError,
|
||||
aiohue.LinkButtonNotPressed):
|
||||
errors['base'] = 'Failed to register, please try again.'
|
||||
except aiohue.AiohueException:
|
||||
errors['base'] = 'Unknown linking error occurred.'
|
||||
_LOGGER.exception('Uknown Hue linking error occurred')
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=bridge.config.name,
|
||||
data={
|
||||
'host': bridge.host,
|
||||
'bridge_id': bridge.config.bridgeid,
|
||||
'username': bridge.username,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id='link',
|
||||
title='Link Hub',
|
||||
description=CONFIG_INSTRUCTIONS,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(hass, entry):
|
||||
"""Set up a bridge for a config entry."""
|
||||
await hass.async_add_job(partial(
|
||||
setup_bridge, entry.data['host'], hass,
|
||||
username=entry.data['username']))
|
||||
return True
|
||||
|
@ -22,7 +22,7 @@ from homeassistant.helpers import discovery
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.typing import HomeAssistantType
|
||||
|
||||
REQUIREMENTS = ['ihcsdk==2.1.1']
|
||||
REQUIREMENTS = ['ihcsdk==2.2.0']
|
||||
|
||||
DOMAIN = 'ihc'
|
||||
IHC_DATA = 'ihc'
|
||||
|
@ -43,7 +43,7 @@ DEFAULT_TIMEOUT = 10
|
||||
DEFAULT_CONFIDENCE = 80
|
||||
|
||||
SOURCE_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(CONF_ENTITY_ID): cv.entity_domain('camera'),
|
||||
vol.Optional(CONF_NAME): cv.string,
|
||||
})
|
||||
|
||||
|
@ -35,7 +35,6 @@ CONF_COMPONENT_CONFIG = 'component_config'
|
||||
CONF_COMPONENT_CONFIG_GLOB = 'component_config_glob'
|
||||
CONF_COMPONENT_CONFIG_DOMAIN = 'component_config_domain'
|
||||
CONF_RETRY_COUNT = 'max_retries'
|
||||
CONF_RETRY_QUEUE = 'retry_queue_limit'
|
||||
|
||||
DEFAULT_DATABASE = 'home_assistant'
|
||||
DEFAULT_VERIFY_SSL = True
|
||||
@ -43,14 +42,17 @@ DOMAIN = 'influxdb'
|
||||
|
||||
TIMEOUT = 5
|
||||
RETRY_DELAY = 20
|
||||
QUEUE_BACKLOG_SECONDS = 10
|
||||
QUEUE_BACKLOG_SECONDS = 30
|
||||
|
||||
BATCH_TIMEOUT = 1
|
||||
BATCH_BUFFER_SIZE = 100
|
||||
|
||||
COMPONENT_CONFIG_SCHEMA_ENTRY = vol.Schema({
|
||||
vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string,
|
||||
})
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.All(cv.deprecated(CONF_RETRY_QUEUE), vol.Schema({
|
||||
DOMAIN: vol.All(vol.Schema({
|
||||
vol.Optional(CONF_HOST): cv.string,
|
||||
vol.Inclusive(CONF_USERNAME, 'authentication'): cv.string,
|
||||
vol.Inclusive(CONF_PASSWORD, 'authentication'): cv.string,
|
||||
@ -68,7 +70,6 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
vol.Optional(CONF_PORT): cv.port,
|
||||
vol.Optional(CONF_SSL): cv.boolean,
|
||||
vol.Optional(CONF_RETRY_COUNT, default=0): cv.positive_int,
|
||||
vol.Optional(CONF_RETRY_QUEUE, default=20): cv.positive_int,
|
||||
vol.Optional(CONF_DEFAULT_MEASUREMENT): cv.string,
|
||||
vol.Optional(CONF_OVERRIDE_MEASUREMENT): cv.string,
|
||||
vol.Optional(CONF_TAGS, default={}):
|
||||
@ -143,18 +144,18 @@ def setup(hass, config):
|
||||
"READ/WRITE", exc)
|
||||
return False
|
||||
|
||||
def influx_handle_event(event):
|
||||
"""Send an event to Influx."""
|
||||
def event_to_json(event):
|
||||
"""Add an event to the outgoing Influx list."""
|
||||
state = event.data.get('new_state')
|
||||
if state is None or state.state in (
|
||||
STATE_UNKNOWN, '', STATE_UNAVAILABLE) or \
|
||||
state.entity_id in blacklist_e or state.domain in blacklist_d:
|
||||
return True
|
||||
return
|
||||
|
||||
try:
|
||||
if (whitelist_e and state.entity_id not in whitelist_e) or \
|
||||
(whitelist_d and state.domain not in whitelist_d):
|
||||
return True
|
||||
return
|
||||
|
||||
_include_state = _include_value = False
|
||||
|
||||
@ -183,61 +184,59 @@ def setup(hass, config):
|
||||
else:
|
||||
include_uom = False
|
||||
|
||||
json_body = [
|
||||
{
|
||||
'measurement': measurement,
|
||||
'tags': {
|
||||
'domain': state.domain,
|
||||
'entity_id': state.object_id,
|
||||
},
|
||||
'time': event.time_fired,
|
||||
'fields': {
|
||||
}
|
||||
}
|
||||
]
|
||||
json = {
|
||||
'measurement': measurement,
|
||||
'tags': {
|
||||
'domain': state.domain,
|
||||
'entity_id': state.object_id,
|
||||
},
|
||||
'time': event.time_fired,
|
||||
'fields': {}
|
||||
}
|
||||
if _include_state:
|
||||
json_body[0]['fields']['state'] = state.state
|
||||
json['fields']['state'] = state.state
|
||||
if _include_value:
|
||||
json_body[0]['fields']['value'] = _state_as_value
|
||||
json['fields']['value'] = _state_as_value
|
||||
|
||||
for key, value in state.attributes.items():
|
||||
if key in tags_attributes:
|
||||
json_body[0]['tags'][key] = value
|
||||
json['tags'][key] = value
|
||||
elif key != 'unit_of_measurement' or include_uom:
|
||||
# If the key is already in fields
|
||||
if key in json_body[0]['fields']:
|
||||
if key in json['fields']:
|
||||
key = key + "_"
|
||||
# Prevent column data errors in influxDB.
|
||||
# For each value we try to cast it as float
|
||||
# But if we can not do it we store the value
|
||||
# as string add "_str" postfix to the field key
|
||||
try:
|
||||
json_body[0]['fields'][key] = float(value)
|
||||
json['fields'][key] = float(value)
|
||||
except (ValueError, TypeError):
|
||||
new_key = "{}_str".format(key)
|
||||
new_value = str(value)
|
||||
json_body[0]['fields'][new_key] = new_value
|
||||
json['fields'][new_key] = new_value
|
||||
|
||||
if RE_DIGIT_TAIL.match(new_value):
|
||||
json_body[0]['fields'][key] = float(
|
||||
json['fields'][key] = float(
|
||||
RE_DECIMAL.sub('', new_value))
|
||||
|
||||
json_body[0]['tags'].update(tags)
|
||||
# Infinity is not a valid float in InfluxDB
|
||||
if (key, float("inf")) in json['fields'].items():
|
||||
del json['fields'][key]
|
||||
|
||||
try:
|
||||
influx.write_points(json_body)
|
||||
return True
|
||||
except (exceptions.InfluxDBClientError, IOError):
|
||||
return False
|
||||
json['tags'].update(tags)
|
||||
|
||||
return json
|
||||
|
||||
instance = hass.data[DOMAIN] = InfluxThread(
|
||||
hass, influx_handle_event, max_tries)
|
||||
hass, influx, event_to_json, max_tries)
|
||||
instance.start()
|
||||
|
||||
def shutdown(event):
|
||||
"""Shut down the thread."""
|
||||
instance.queue.put(None)
|
||||
instance.join()
|
||||
influx.close()
|
||||
|
||||
hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, shutdown)
|
||||
|
||||
@ -247,12 +246,15 @@ def setup(hass, config):
|
||||
class InfluxThread(threading.Thread):
|
||||
"""A threaded event handler class."""
|
||||
|
||||
def __init__(self, hass, event_handler, max_tries):
|
||||
def __init__(self, hass, influx, event_to_json, max_tries):
|
||||
"""Initialize the listener."""
|
||||
threading.Thread.__init__(self, name='InfluxDB')
|
||||
self.queue = queue.Queue()
|
||||
self.event_handler = event_handler
|
||||
self.influx = influx
|
||||
self.event_to_json = event_to_json
|
||||
self.max_tries = max_tries
|
||||
self.write_errors = 0
|
||||
self.shutdown = False
|
||||
hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener)
|
||||
|
||||
def _event_listener(self, event):
|
||||
@ -260,41 +262,77 @@ class InfluxThread(threading.Thread):
|
||||
item = (time.monotonic(), event)
|
||||
self.queue.put(item)
|
||||
|
||||
def run(self):
|
||||
"""Process incoming events."""
|
||||
@staticmethod
|
||||
def batch_timeout():
|
||||
"""Return number of seconds to wait for more events."""
|
||||
return BATCH_TIMEOUT
|
||||
|
||||
def get_events_json(self):
|
||||
"""Return a batch of events formatted for writing."""
|
||||
queue_seconds = QUEUE_BACKLOG_SECONDS + self.max_tries*RETRY_DELAY
|
||||
|
||||
write_error = False
|
||||
dropped = False
|
||||
count = 0
|
||||
json = []
|
||||
|
||||
while True:
|
||||
item = self.queue.get()
|
||||
dropped = 0
|
||||
|
||||
if item is None:
|
||||
try:
|
||||
while len(json) < BATCH_BUFFER_SIZE and not self.shutdown:
|
||||
timeout = None if count == 0 else self.batch_timeout()
|
||||
item = self.queue.get(timeout=timeout)
|
||||
count += 1
|
||||
|
||||
if item is None:
|
||||
self.shutdown = True
|
||||
else:
|
||||
timestamp, event = item
|
||||
age = time.monotonic() - timestamp
|
||||
|
||||
if age < queue_seconds:
|
||||
event_json = self.event_to_json(event)
|
||||
if event_json:
|
||||
json.append(event_json)
|
||||
else:
|
||||
dropped += 1
|
||||
|
||||
except queue.Empty:
|
||||
pass
|
||||
|
||||
if dropped:
|
||||
_LOGGER.warning("Catching up, dropped %d old events", dropped)
|
||||
|
||||
return count, json
|
||||
|
||||
def write_to_influxdb(self, json):
|
||||
"""Write preprocessed events to influxdb, with retry."""
|
||||
from influxdb import exceptions
|
||||
|
||||
for retry in range(self.max_tries+1):
|
||||
try:
|
||||
self.influx.write_points(json)
|
||||
|
||||
if self.write_errors:
|
||||
_LOGGER.error("Resumed, lost %d events", self.write_errors)
|
||||
self.write_errors = 0
|
||||
|
||||
_LOGGER.debug("Wrote %d events", len(json))
|
||||
break
|
||||
except (exceptions.InfluxDBClientError, IOError):
|
||||
if retry < self.max_tries:
|
||||
time.sleep(RETRY_DELAY)
|
||||
else:
|
||||
if not self.write_errors:
|
||||
_LOGGER.exception("Write error")
|
||||
self.write_errors += len(json)
|
||||
|
||||
def run(self):
|
||||
"""Process incoming events."""
|
||||
while not self.shutdown:
|
||||
count, json = self.get_events_json()
|
||||
if json:
|
||||
self.write_to_influxdb(json)
|
||||
for _ in range(count):
|
||||
self.queue.task_done()
|
||||
return
|
||||
|
||||
timestamp, event = item
|
||||
age = time.monotonic() - timestamp
|
||||
|
||||
if age < queue_seconds:
|
||||
for retry in range(self.max_tries+1):
|
||||
if self.event_handler(event):
|
||||
if write_error:
|
||||
_LOGGER.error("Resumed writing to InfluxDB")
|
||||
write_error = False
|
||||
dropped = False
|
||||
break
|
||||
elif retry < self.max_tries:
|
||||
time.sleep(RETRY_DELAY)
|
||||
elif not write_error:
|
||||
_LOGGER.error("Error writing to InfluxDB")
|
||||
write_error = True
|
||||
elif not dropped:
|
||||
_LOGGER.warning("Dropping old events to catch up")
|
||||
dropped = True
|
||||
|
||||
self.queue.task_done()
|
||||
|
||||
def block_till_done(self):
|
||||
"""Block till all events processed."""
|
||||
|
@ -4,117 +4,211 @@ Support for INSTEON PowerLinc Modem.
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/insteon_plm/
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
|
||||
import collections
|
||||
import logging
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.const import (
|
||||
CONF_PORT, EVENT_HOMEASSISTANT_STOP)
|
||||
from homeassistant.const import (CONF_PORT, EVENT_HOMEASSISTANT_STOP,
|
||||
CONF_PLATFORM)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers import discovery
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
REQUIREMENTS = ['insteonplm==0.7.5']
|
||||
REQUIREMENTS = ['insteonplm==0.8.2']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = 'insteon_plm'
|
||||
|
||||
CONF_OVERRIDE = 'device_override'
|
||||
CONF_ADDRESS = 'address'
|
||||
CONF_CAT = 'cat'
|
||||
CONF_SUBCAT = 'subcat'
|
||||
CONF_FIRMWARE = 'firmware'
|
||||
CONF_PRODUCT_KEY = 'product_key'
|
||||
|
||||
CONF_DEVICE_OVERRIDE_SCHEMA = vol.All(
|
||||
cv.deprecated(CONF_PLATFORM), vol.Schema({
|
||||
vol.Required(CONF_ADDRESS): cv.string,
|
||||
vol.Optional(CONF_CAT): cv.byte,
|
||||
vol.Optional(CONF_SUBCAT): cv.byte,
|
||||
vol.Optional(CONF_FIRMWARE): cv.byte,
|
||||
vol.Optional(CONF_PRODUCT_KEY): cv.byte,
|
||||
vol.Optional(CONF_PLATFORM): cv.string,
|
||||
}))
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Required(CONF_PORT): cv.string,
|
||||
vol.Optional(CONF_OVERRIDE, default=[]): cv.ensure_list_csv,
|
||||
})
|
||||
vol.Optional(CONF_OVERRIDE): vol.All(
|
||||
cv.ensure_list_csv, [CONF_DEVICE_OVERRIDE_SCHEMA])
|
||||
})
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
PLM_PLATFORMS = {
|
||||
'binary_sensor': ['binary_sensor'],
|
||||
'light': ['light'],
|
||||
'switch': ['switch'],
|
||||
}
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
"""Set up the connection to the PLM."""
|
||||
import insteonplm
|
||||
|
||||
ipdb = IPDB()
|
||||
|
||||
conf = config[DOMAIN]
|
||||
port = conf.get(CONF_PORT)
|
||||
overrides = conf.get(CONF_OVERRIDE)
|
||||
overrides = conf.get(CONF_OVERRIDE, [])
|
||||
|
||||
@callback
|
||||
def async_plm_new_device(device):
|
||||
"""Detect device from transport to be delegated to platform."""
|
||||
name = device.get('address')
|
||||
address = device.get('address_hex')
|
||||
capabilities = device.get('capabilities', [])
|
||||
for state_key in device.states:
|
||||
platform_info = ipdb[device.states[state_key]]
|
||||
platform = platform_info.platform
|
||||
if platform is not None:
|
||||
_LOGGER.info("New INSTEON PLM device: %s (%s) %s",
|
||||
device.address,
|
||||
device.states[state_key].name,
|
||||
platform)
|
||||
|
||||
_LOGGER.info("New INSTEON PLM device: %s (%s) %r",
|
||||
name, address, capabilities)
|
||||
|
||||
loadlist = []
|
||||
for platform in PLM_PLATFORMS:
|
||||
caplist = PLM_PLATFORMS.get(platform)
|
||||
for key in capabilities:
|
||||
if key in caplist:
|
||||
loadlist.append(platform)
|
||||
|
||||
loadlist = sorted(set(loadlist))
|
||||
|
||||
for loadplatform in loadlist:
|
||||
hass.async_add_job(
|
||||
discovery.async_load_platform(
|
||||
hass, loadplatform, DOMAIN, discovered=[device],
|
||||
hass_config=config))
|
||||
hass.async_add_job(
|
||||
discovery.async_load_platform(
|
||||
hass, platform, DOMAIN,
|
||||
discovered={'address': device.address.hex,
|
||||
'state_key': state_key},
|
||||
hass_config=config))
|
||||
|
||||
_LOGGER.info("Looking for PLM on %s", port)
|
||||
plm = yield from insteonplm.Connection.create(device=port, loop=hass.loop)
|
||||
conn = yield from insteonplm.Connection.create(
|
||||
device=port,
|
||||
loop=hass.loop,
|
||||
workdir=hass.config.config_dir)
|
||||
|
||||
for device in overrides:
|
||||
plm = conn.protocol
|
||||
|
||||
for device_override in overrides:
|
||||
#
|
||||
# Override the device default capabilities for a specific address
|
||||
#
|
||||
if isinstance(device['platform'], list):
|
||||
plm.protocol.devices.add_override(
|
||||
device['address'], 'capabilities', device['platform'])
|
||||
else:
|
||||
plm.protocol.devices.add_override(
|
||||
device['address'], 'capabilities', [device['platform']])
|
||||
address = device_override.get('address')
|
||||
for prop in device_override:
|
||||
if prop in [CONF_CAT, CONF_SUBCAT]:
|
||||
plm.devices.add_override(address, prop,
|
||||
device_override[prop])
|
||||
elif prop in [CONF_FIRMWARE, CONF_PRODUCT_KEY]:
|
||||
plm.devices.add_override(address, CONF_PRODUCT_KEY,
|
||||
device_override[prop])
|
||||
|
||||
hass.data['insteon_plm'] = plm
|
||||
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, plm.close)
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, conn.close)
|
||||
|
||||
plm.protocol.devices.add_device_callback(async_plm_new_device, {})
|
||||
plm.devices.add_device_callback(async_plm_new_device)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def common_attributes(entity):
|
||||
"""Return the device state attributes."""
|
||||
attributes = {}
|
||||
attributekeys = {
|
||||
'address': 'INSTEON Address',
|
||||
'description': 'Description',
|
||||
'model': 'Model',
|
||||
'cat': 'Category',
|
||||
'subcat': 'Subcategory',
|
||||
'firmware': 'Firmware',
|
||||
'product_key': 'Product Key'
|
||||
}
|
||||
State = collections.namedtuple('Product', 'stateType platform')
|
||||
|
||||
hexkeys = ['cat', 'subcat', 'firmware']
|
||||
|
||||
for key in attributekeys:
|
||||
name = attributekeys[key]
|
||||
val = entity.get_attr(key)
|
||||
if val is not None:
|
||||
if key in hexkeys:
|
||||
attributes[name] = hex(int(val))
|
||||
else:
|
||||
attributes[name] = val
|
||||
return attributes
|
||||
class IPDB(object):
|
||||
"""Embodies the INSTEON Product Database static data and access methods."""
|
||||
|
||||
def __init__(self):
|
||||
"""Create the INSTEON Product Database (IPDB)."""
|
||||
from insteonplm.states.onOff import (OnOffSwitch,
|
||||
OnOffSwitch_OutletTop,
|
||||
OnOffSwitch_OutletBottom,
|
||||
OpenClosedRelay)
|
||||
|
||||
from insteonplm.states.dimmable import (DimmableSwitch,
|
||||
DimmableSwitch_Fan)
|
||||
|
||||
from insteonplm.states.sensor import (VariableSensor,
|
||||
OnOffSensor,
|
||||
SmokeCO2Sensor,
|
||||
IoLincSensor)
|
||||
|
||||
self.states = [State(OnOffSwitch_OutletTop, 'switch'),
|
||||
State(OnOffSwitch_OutletBottom, 'switch'),
|
||||
State(OpenClosedRelay, 'switch'),
|
||||
State(OnOffSwitch, 'switch'),
|
||||
|
||||
State(IoLincSensor, 'binary_sensor'),
|
||||
State(SmokeCO2Sensor, 'sensor'),
|
||||
State(OnOffSensor, 'binary_sensor'),
|
||||
State(VariableSensor, 'sensor'),
|
||||
|
||||
State(DimmableSwitch_Fan, 'fan'),
|
||||
State(DimmableSwitch, 'light')]
|
||||
|
||||
def __len__(self):
|
||||
"""Return the number of INSTEON state types mapped to HA platforms."""
|
||||
return len(self.states)
|
||||
|
||||
def __iter__(self):
|
||||
"""Itterate through the INSTEON state types to HA platforms."""
|
||||
for product in self.states:
|
||||
yield product
|
||||
|
||||
def __getitem__(self, key):
|
||||
"""Return a Home Assistant platform from an INSTEON state type."""
|
||||
for state in self.states:
|
||||
if isinstance(key, state.stateType):
|
||||
return state
|
||||
return None
|
||||
|
||||
|
||||
class InsteonPLMEntity(Entity):
|
||||
"""INSTEON abstract base entity."""
|
||||
|
||||
def __init__(self, device, state_key):
|
||||
"""Initialize the INSTEON PLM binary sensor."""
|
||||
self._insteon_device_state = device.states[state_key]
|
||||
self._insteon_device = device
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
"""Return the address of the node."""
|
||||
return self._insteon_device.address.human
|
||||
|
||||
@property
|
||||
def group(self):
|
||||
"""Return the INSTEON group that the entity responds to."""
|
||||
return self._insteon_device_state.group
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the node (used for Entity_ID)."""
|
||||
name = ''
|
||||
if self._insteon_device_state.group == 0x01:
|
||||
name = self._insteon_device.id
|
||||
else:
|
||||
name = '{:s}_{:d}'.format(self._insteon_device.id,
|
||||
self._insteon_device_state.group)
|
||||
return name
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Provide attributes for display on device card."""
|
||||
attributes = {
|
||||
'INSTEON Address': self.address,
|
||||
'INSTEON Group': self.group
|
||||
}
|
||||
return attributes
|
||||
|
||||
@callback
|
||||
def async_entity_update(self, deviceid, statename, val):
|
||||
"""Receive notification from transport that new data exists."""
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_added_to_hass(self):
|
||||
"""Register INSTEON update events."""
|
||||
self._insteon_device_state.register_updates(
|
||||
self.async_entity_update)
|
||||
|
@ -70,5 +70,5 @@ class JuicenetDevice(Entity):
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return an unique ID."""
|
||||
"""Return a unique ID."""
|
||||
return "{}-{}".format(self.device.id(), self.type)
|
||||
|
@ -4,17 +4,20 @@ Connects to KNX platform.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/knx/
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.const import (
|
||||
CONF_ENTITY_ID, CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import discovery
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
from homeassistant.helpers.script import Script
|
||||
|
||||
REQUIREMENTS = ['xknx==0.8.3']
|
||||
REQUIREMENTS = ['xknx==0.8.4']
|
||||
|
||||
DOMAIN = "knx"
|
||||
DATA_KNX = "data_knx"
|
||||
@ -26,6 +29,9 @@ CONF_KNX_LOCAL_IP = "local_ip"
|
||||
CONF_KNX_FIRE_EVENT = "fire_event"
|
||||
CONF_KNX_FIRE_EVENT_FILTER = "fire_event_filter"
|
||||
CONF_KNX_STATE_UPDATER = "state_updater"
|
||||
CONF_KNX_EXPOSE = "expose"
|
||||
CONF_KNX_EXPOSE_TYPE = "type"
|
||||
CONF_KNX_EXPOSE_ADDRESS = "address"
|
||||
|
||||
SERVICE_KNX_SEND = "send"
|
||||
SERVICE_KNX_ATTR_ADDRESS = "address"
|
||||
@ -45,6 +51,12 @@ ROUTING_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_KNX_LOCAL_IP): cv.string,
|
||||
})
|
||||
|
||||
EXPOSE_SCHEMA = vol.Schema({
|
||||
vol.Required(CONF_KNX_EXPOSE_TYPE): cv.string,
|
||||
vol.Optional(CONF_ENTITY_ID): cv.entity_id,
|
||||
vol.Required(CONF_KNX_EXPOSE_ADDRESS): cv.string,
|
||||
})
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema({
|
||||
DOMAIN: vol.Schema({
|
||||
vol.Optional(CONF_KNX_CONFIG): cv.string,
|
||||
@ -56,6 +68,10 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
vol.Inclusive(CONF_KNX_FIRE_EVENT_FILTER, 'fire_ev'):
|
||||
vol.All(cv.ensure_list, [cv.string]),
|
||||
vol.Optional(CONF_KNX_STATE_UPDATER, default=True): cv.boolean,
|
||||
vol.Optional(CONF_KNX_EXPOSE):
|
||||
vol.All(
|
||||
cv.ensure_list,
|
||||
[EXPOSE_SCHEMA]),
|
||||
})
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
@ -66,13 +82,13 @@ SERVICE_KNX_SEND_SCHEMA = vol.Schema({
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
async def async_setup(hass, config):
|
||||
"""Set up the KNX component."""
|
||||
from xknx.exceptions import XKNXException
|
||||
try:
|
||||
hass.data[DATA_KNX] = KNXModule(hass, config)
|
||||
yield from hass.data[DATA_KNX].start()
|
||||
hass.data[DATA_KNX].async_create_exposures()
|
||||
await hass.data[DATA_KNX].start()
|
||||
|
||||
except XKNXException as ex:
|
||||
_LOGGER.warning("Can't connect to KNX interface: %s", ex)
|
||||
@ -88,6 +104,7 @@ def async_setup(hass, config):
|
||||
('light', 'Light'),
|
||||
('sensor', 'Sensor'),
|
||||
('binary_sensor', 'BinarySensor'),
|
||||
('scene', 'Scene'),
|
||||
('notify', 'Notification')):
|
||||
found_devices = _get_devices(hass, discovery_type)
|
||||
hass.async_add_job(
|
||||
@ -122,26 +139,25 @@ class KNXModule(object):
|
||||
self.connected = False
|
||||
self.init_xknx()
|
||||
self.register_callbacks()
|
||||
self.exposures = []
|
||||
|
||||
def init_xknx(self):
|
||||
"""Initialize of KNX object."""
|
||||
from xknx import XKNX
|
||||
self.xknx = XKNX(config=self.config_file(), loop=self.hass.loop)
|
||||
|
||||
@asyncio.coroutine
|
||||
def start(self):
|
||||
async def start(self):
|
||||
"""Start KNX object. Connect to tunneling or Routing device."""
|
||||
connection_config = self.connection_config()
|
||||
yield from self.xknx.start(
|
||||
await self.xknx.start(
|
||||
state_updater=self.config[DOMAIN][CONF_KNX_STATE_UPDATER],
|
||||
connection_config=connection_config)
|
||||
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.stop)
|
||||
self.connected = True
|
||||
|
||||
@asyncio.coroutine
|
||||
def stop(self, event):
|
||||
async def stop(self, event):
|
||||
"""Stop KNX object. Disconnect from tunneling or Routing device."""
|
||||
yield from self.xknx.stop()
|
||||
await self.xknx.stop()
|
||||
|
||||
def config_file(self):
|
||||
"""Resolve and return the full path of xknx.yaml if configured."""
|
||||
@ -202,8 +218,27 @@ class KNXModule(object):
|
||||
self.xknx.telegram_queue.register_telegram_received_cb(
|
||||
self.telegram_received_cb, address_filters)
|
||||
|
||||
@asyncio.coroutine
|
||||
def telegram_received_cb(self, telegram):
|
||||
@callback
|
||||
def async_create_exposures(self):
|
||||
"""Create exposures."""
|
||||
if CONF_KNX_EXPOSE not in self.config[DOMAIN]:
|
||||
return
|
||||
for to_expose in self.config[DOMAIN][CONF_KNX_EXPOSE]:
|
||||
expose_type = to_expose.get(CONF_KNX_EXPOSE_TYPE)
|
||||
entity_id = to_expose.get(CONF_ENTITY_ID)
|
||||
address = to_expose.get(CONF_KNX_EXPOSE_ADDRESS)
|
||||
if expose_type in ['time', 'date', 'datetime']:
|
||||
exposure = KNXExposeTime(
|
||||
self.xknx, expose_type, address)
|
||||
exposure.async_register()
|
||||
self.exposures.append(exposure)
|
||||
else:
|
||||
exposure = KNXExposeSensor(
|
||||
self.hass, self.xknx, expose_type, entity_id, address)
|
||||
exposure.async_register()
|
||||
self.exposures.append(exposure)
|
||||
|
||||
async def telegram_received_cb(self, telegram):
|
||||
"""Call invoked after a KNX telegram was received."""
|
||||
self.hass.bus.fire('knx_event', {
|
||||
'address': telegram.group_address.str(),
|
||||
@ -212,8 +247,7 @@ class KNXModule(object):
|
||||
# False signals XKNX to proceed with processing telegrams.
|
||||
return False
|
||||
|
||||
@asyncio.coroutine
|
||||
def service_send_to_knx_bus(self, call):
|
||||
async def service_send_to_knx_bus(self, call):
|
||||
"""Service for sending an arbitrary KNX message to the KNX bus."""
|
||||
from xknx.knx import Telegram, GroupAddress, DPTBinary, DPTArray
|
||||
attr_payload = call.data.get(SERVICE_KNX_ATTR_PAYLOAD)
|
||||
@ -230,7 +264,7 @@ class KNXModule(object):
|
||||
telegram = Telegram()
|
||||
telegram.payload = payload
|
||||
telegram.group_address = address
|
||||
yield from self.xknx.telegrams.put(telegram)
|
||||
await self.xknx.telegrams.put(telegram)
|
||||
|
||||
|
||||
class KNXAutomation():
|
||||
@ -248,3 +282,59 @@ class KNXAutomation():
|
||||
hass.data[DATA_KNX].xknx, self.script.async_run,
|
||||
hook=hook, counter=counter)
|
||||
device.actions.append(self.action)
|
||||
|
||||
|
||||
class KNXExposeTime(object):
|
||||
"""Object to Expose Time/Date object to KNX bus."""
|
||||
|
||||
def __init__(self, xknx, expose_type, address):
|
||||
"""Initialize of Expose class."""
|
||||
self.xknx = xknx
|
||||
self.type = expose_type
|
||||
self.address = address
|
||||
self.device = None
|
||||
|
||||
@callback
|
||||
def async_register(self):
|
||||
"""Register listener."""
|
||||
from xknx.devices import DateTime, DateTimeBroadcastType
|
||||
broadcast_type_string = self.type.upper()
|
||||
broadcast_type = DateTimeBroadcastType[broadcast_type_string]
|
||||
self.device = DateTime(
|
||||
self.xknx,
|
||||
'Time',
|
||||
broadcast_type=broadcast_type,
|
||||
group_address=self.address)
|
||||
self.xknx.devices.add(self.device)
|
||||
|
||||
|
||||
class KNXExposeSensor(object):
|
||||
"""Object to Expose HASS entity to KNX bus."""
|
||||
|
||||
def __init__(self, hass, xknx, expose_type, entity_id, address):
|
||||
"""Initialize of Expose class."""
|
||||
self.hass = hass
|
||||
self.xknx = xknx
|
||||
self.type = expose_type
|
||||
self.entity_id = entity_id
|
||||
self.address = address
|
||||
self.device = None
|
||||
|
||||
@callback
|
||||
def async_register(self):
|
||||
"""Register listener."""
|
||||
from xknx.devices import ExposeSensor
|
||||
self.device = ExposeSensor(
|
||||
self.xknx,
|
||||
name=self.entity_id,
|
||||
group_address=self.address,
|
||||
value_type=self.type)
|
||||
self.xknx.devices.add(self.device)
|
||||
async_track_state_change(
|
||||
self.hass, self.entity_id, self._async_entity_changed)
|
||||
|
||||
async def _async_entity_changed(self, entity_id, old_state, new_state):
|
||||
"""Callback after entity changed."""
|
||||
if new_state is None:
|
||||
return
|
||||
await self.device.set(float(new_state.state))
|
||||
|
@ -12,7 +12,8 @@ import os
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import group
|
||||
from homeassistant.components.group import \
|
||||
ENTITY_ID_FORMAT as GROUP_ENTITY_ID_FORMAT
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID, SERVICE_TOGGLE, SERVICE_TURN_OFF, SERVICE_TURN_ON,
|
||||
STATE_ON)
|
||||
@ -21,6 +22,7 @@ import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa
|
||||
from homeassistant.helpers.entity import ToggleEntity
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers import intent
|
||||
from homeassistant.loader import bind_hass
|
||||
import homeassistant.util.color as color_util
|
||||
|
||||
@ -29,7 +31,7 @@ DEPENDENCIES = ['group']
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
GROUP_NAME_ALL_LIGHTS = 'all lights'
|
||||
ENTITY_ID_ALL_LIGHTS = group.ENTITY_ID_FORMAT.format('all_lights')
|
||||
ENTITY_ID_ALL_LIGHTS = GROUP_ENTITY_ID_FORMAT.format('all_lights')
|
||||
|
||||
ENTITY_ID_FORMAT = DOMAIN + '.{}'
|
||||
|
||||
@ -84,8 +86,6 @@ LIGHT_PROFILES_FILE = "light_profiles.csv"
|
||||
PROP_TO_ATTR = {
|
||||
'brightness': ATTR_BRIGHTNESS,
|
||||
'color_temp': ATTR_COLOR_TEMP,
|
||||
'min_mireds': ATTR_MIN_MIREDS,
|
||||
'max_mireds': ATTR_MAX_MIREDS,
|
||||
'rgb_color': ATTR_RGB_COLOR,
|
||||
'xy_color': ATTR_XY_COLOR,
|
||||
'white_value': ATTR_WHITE_VALUE,
|
||||
@ -135,6 +135,8 @@ PROFILE_SCHEMA = vol.Schema(
|
||||
vol.ExactSequence((str, cv.small_float, cv.small_float, cv.byte))
|
||||
)
|
||||
|
||||
INTENT_SET = 'HassLightSet'
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -206,8 +208,9 @@ def async_turn_off(hass, entity_id=None, transition=None):
|
||||
DOMAIN, SERVICE_TURN_OFF, data))
|
||||
|
||||
|
||||
@callback
|
||||
@bind_hass
|
||||
def toggle(hass, entity_id=None, transition=None):
|
||||
def async_toggle(hass, entity_id=None, transition=None):
|
||||
"""Toggle all or specified light."""
|
||||
data = {
|
||||
key: value for key, value in [
|
||||
@ -216,7 +219,14 @@ def toggle(hass, entity_id=None, transition=None):
|
||||
] if value is not None
|
||||
}
|
||||
|
||||
hass.services.call(DOMAIN, SERVICE_TOGGLE, data)
|
||||
hass.async_add_job(hass.services.async_call(
|
||||
DOMAIN, SERVICE_TOGGLE, data))
|
||||
|
||||
|
||||
@bind_hass
|
||||
def toggle(hass, entity_id=None, transition=None):
|
||||
"""Toggle all or specified light."""
|
||||
hass.add_job(async_toggle, hass, entity_id, transition)
|
||||
|
||||
|
||||
def preprocess_turn_on_alternatives(params):
|
||||
@ -228,7 +238,12 @@ def preprocess_turn_on_alternatives(params):
|
||||
|
||||
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)
|
||||
try:
|
||||
params[ATTR_RGB_COLOR] = color_util.color_name_to_rgb(color_name)
|
||||
except ValueError:
|
||||
_LOGGER.warning('Got unknown color %s, falling back to white',
|
||||
color_name)
|
||||
params[ATTR_RGB_COLOR] = (255, 255, 255)
|
||||
|
||||
kelvin = params.pop(ATTR_KELVIN, None)
|
||||
if kelvin is not None:
|
||||
@ -240,20 +255,79 @@ def preprocess_turn_on_alternatives(params):
|
||||
params[ATTR_BRIGHTNESS] = int(255 * brightness_pct/100)
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup(hass, config):
|
||||
class SetIntentHandler(intent.IntentHandler):
|
||||
"""Handle set color intents."""
|
||||
|
||||
intent_type = INTENT_SET
|
||||
slot_schema = {
|
||||
vol.Required('name'): cv.string,
|
||||
vol.Optional('color'): color_util.color_name_to_rgb,
|
||||
vol.Optional('brightness'): vol.All(vol.Coerce(int), vol.Range(0, 100))
|
||||
}
|
||||
|
||||
async def async_handle(self, intent_obj):
|
||||
"""Handle the hass intent."""
|
||||
hass = intent_obj.hass
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
state = hass.helpers.intent.async_match_state(
|
||||
slots['name']['value'],
|
||||
[state for state in hass.states.async_all()
|
||||
if state.domain == DOMAIN])
|
||||
|
||||
service_data = {
|
||||
ATTR_ENTITY_ID: state.entity_id,
|
||||
}
|
||||
speech_parts = []
|
||||
|
||||
if 'color' in slots:
|
||||
intent.async_test_feature(
|
||||
state, SUPPORT_RGB_COLOR, 'changing colors')
|
||||
service_data[ATTR_RGB_COLOR] = slots['color']['value']
|
||||
# Use original passed in value of the color because we don't have
|
||||
# human readable names for that internally.
|
||||
speech_parts.append('the color {}'.format(
|
||||
intent_obj.slots['color']['value']))
|
||||
|
||||
if 'brightness' in slots:
|
||||
intent.async_test_feature(
|
||||
state, SUPPORT_BRIGHTNESS, 'changing brightness')
|
||||
service_data[ATTR_BRIGHTNESS_PCT] = slots['brightness']['value']
|
||||
speech_parts.append('{}% brightness'.format(
|
||||
slots['brightness']['value']))
|
||||
|
||||
await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, service_data)
|
||||
|
||||
response = intent_obj.create_response()
|
||||
|
||||
if not speech_parts: # No attributes changed
|
||||
speech = 'Turned on {}'.format(state.name)
|
||||
else:
|
||||
parts = ['Changed {} to'.format(state.name)]
|
||||
for index, part in enumerate(speech_parts):
|
||||
if index == 0:
|
||||
parts.append(' {}'.format(part))
|
||||
elif index != len(speech_parts) - 1:
|
||||
parts.append(', {}'.format(part))
|
||||
else:
|
||||
parts.append(' and {}'.format(part))
|
||||
speech = ''.join(parts)
|
||||
|
||||
response.async_set_speech(speech)
|
||||
return response
|
||||
|
||||
|
||||
async def async_setup(hass, config):
|
||||
"""Expose light control via state machine and services."""
|
||||
component = EntityComponent(
|
||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_LIGHTS)
|
||||
yield from component.async_setup(config)
|
||||
await component.async_setup(config)
|
||||
|
||||
# load profiles from files
|
||||
profiles_valid = yield from Profiles.load_profiles(hass)
|
||||
profiles_valid = await Profiles.load_profiles(hass)
|
||||
if not profiles_valid:
|
||||
return False
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_handle_light_service(service):
|
||||
async def async_handle_light_service(service):
|
||||
"""Handle a turn light on or off service call."""
|
||||
# Get the validated data
|
||||
params = service.data.copy()
|
||||
@ -267,18 +341,18 @@ def async_setup(hass, config):
|
||||
update_tasks = []
|
||||
for light in target_lights:
|
||||
if service.service == SERVICE_TURN_ON:
|
||||
yield from light.async_turn_on(**params)
|
||||
await light.async_turn_on(**params)
|
||||
elif service.service == SERVICE_TURN_OFF:
|
||||
yield from light.async_turn_off(**params)
|
||||
await light.async_turn_off(**params)
|
||||
else:
|
||||
yield from light.async_toggle(**params)
|
||||
await light.async_toggle(**params)
|
||||
|
||||
if not light.should_poll:
|
||||
continue
|
||||
update_tasks.append(light.async_update_ha_state(True))
|
||||
|
||||
if update_tasks:
|
||||
yield from asyncio.wait(update_tasks, loop=hass.loop)
|
||||
await asyncio.wait(update_tasks, loop=hass.loop)
|
||||
|
||||
# Listen for light on and light off service calls.
|
||||
hass.services.async_register(
|
||||
@ -293,6 +367,8 @@ def async_setup(hass, config):
|
||||
DOMAIN, SERVICE_TOGGLE, async_handle_light_service,
|
||||
schema=LIGHT_TOGGLE_SCHEMA)
|
||||
|
||||
hass.helpers.intent.async_register(SetIntentHandler())
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@ -302,8 +378,7 @@ class Profiles:
|
||||
_all = None
|
||||
|
||||
@classmethod
|
||||
@asyncio.coroutine
|
||||
def load_profiles(cls, hass):
|
||||
async def load_profiles(cls, hass):
|
||||
"""Load and cache profiles."""
|
||||
def load_profile_data(hass):
|
||||
"""Load built-in profiles and custom profiles."""
|
||||
@ -333,7 +408,7 @@ class Profiles:
|
||||
return None
|
||||
return profiles
|
||||
|
||||
cls._all = yield from hass.async_add_job(load_profile_data, hass)
|
||||
cls._all = await hass.async_add_job(load_profile_data, hass)
|
||||
return cls._all is not None
|
||||
|
||||
@classmethod
|
||||
@ -399,6 +474,10 @@ class Light(ToggleEntity):
|
||||
"""Return optional state attributes."""
|
||||
data = {}
|
||||
|
||||
if self.supported_features & SUPPORT_COLOR_TEMP:
|
||||
data[ATTR_MIN_MIREDS] = self.min_mireds
|
||||
data[ATTR_MAX_MIREDS] = self.max_mireds
|
||||
|
||||
if self.is_on:
|
||||
for prop, attr in PROP_TO_ATTR.items():
|
||||
value = getattr(self, prop)
|
||||
|
@ -28,11 +28,11 @@ SUPPORT_DEMO = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT |
|
||||
def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
"""Set up the demo light platform."""
|
||||
add_devices_callback([
|
||||
DemoLight("Bed Light", False, True, effect_list=LIGHT_EFFECT_LIST,
|
||||
DemoLight(1, "Bed Light", False, True, effect_list=LIGHT_EFFECT_LIST,
|
||||
effect=LIGHT_EFFECT_LIST[0]),
|
||||
DemoLight("Ceiling Lights", True, True,
|
||||
DemoLight(2, "Ceiling Lights", True, True,
|
||||
LIGHT_COLORS[0], LIGHT_TEMPS[1]),
|
||||
DemoLight("Kitchen Lights", True, True,
|
||||
DemoLight(3, "Kitchen Lights", True, True,
|
||||
LIGHT_COLORS[1], LIGHT_TEMPS[0])
|
||||
])
|
||||
|
||||
@ -40,10 +40,11 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None):
|
||||
class DemoLight(Light):
|
||||
"""Representation of a demo light."""
|
||||
|
||||
def __init__(self, name, state, available=False, rgb=None, ct=None,
|
||||
brightness=180, xy_color=(.5, .5), white=200,
|
||||
def __init__(self, unique_id, name, state, available=False, rgb=None,
|
||||
ct=None, brightness=180, xy_color=(.5, .5), white=200,
|
||||
effect_list=None, effect=None):
|
||||
"""Initialize the light."""
|
||||
self._unique_id = unique_id
|
||||
self._name = name
|
||||
self._state = state
|
||||
self._rgb = rgb
|
||||
@ -64,6 +65,11 @@ class DemoLight(Light):
|
||||
"""Return the name of the light if any."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return unique ID for light."""
|
||||
return self._unique_id
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return availability."""
|
||||
|
290
homeassistant/components/light/group.py
Normal file
290
homeassistant/components/light/group.py
Normal file
@ -0,0 +1,290 @@
|
||||
"""
|
||||
This platform allows several lights to be grouped into one light.
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/light.group/
|
||||
"""
|
||||
import logging
|
||||
import itertools
|
||||
from typing import List, Tuple, Optional, Iterator, Any, Callable
|
||||
from collections import Counter
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.core import State, callback
|
||||
from homeassistant.components import light
|
||||
from homeassistant.const import (STATE_ON, ATTR_ENTITY_ID, CONF_NAME,
|
||||
CONF_ENTITIES, STATE_UNAVAILABLE,
|
||||
ATTR_SUPPORTED_FEATURES)
|
||||
from homeassistant.helpers.event import async_track_state_change
|
||||
from homeassistant.helpers.typing import HomeAssistantType, ConfigType
|
||||
from homeassistant.components.light import (
|
||||
SUPPORT_BRIGHTNESS, SUPPORT_RGB_COLOR, SUPPORT_COLOR_TEMP,
|
||||
SUPPORT_TRANSITION, SUPPORT_EFFECT, SUPPORT_FLASH, SUPPORT_XY_COLOR,
|
||||
SUPPORT_WHITE_VALUE, PLATFORM_SCHEMA, ATTR_BRIGHTNESS, ATTR_XY_COLOR,
|
||||
ATTR_RGB_COLOR, ATTR_WHITE_VALUE, ATTR_COLOR_TEMP, ATTR_MIN_MIREDS,
|
||||
ATTR_MAX_MIREDS, ATTR_EFFECT_LIST, ATTR_EFFECT, ATTR_FLASH,
|
||||
ATTR_TRANSITION)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = 'Light Group'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Required(CONF_ENTITIES): cv.entities_domain(light.DOMAIN)
|
||||
})
|
||||
|
||||
SUPPORT_GROUP_LIGHT = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT
|
||||
| SUPPORT_FLASH | SUPPORT_RGB_COLOR | SUPPORT_TRANSITION
|
||||
| SUPPORT_XY_COLOR | SUPPORT_WHITE_VALUE)
|
||||
|
||||
|
||||
async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
|
||||
async_add_devices, discovery_info=None) -> None:
|
||||
"""Initialize light.group platform."""
|
||||
async_add_devices([LightGroup(config.get(CONF_NAME),
|
||||
config[CONF_ENTITIES])])
|
||||
|
||||
|
||||
class LightGroup(light.Light):
|
||||
"""Representation of a light group."""
|
||||
|
||||
def __init__(self, name: str, entity_ids: List[str]) -> None:
|
||||
"""Initialize a light group."""
|
||||
self._name = name # type: str
|
||||
self._entity_ids = entity_ids # type: List[str]
|
||||
self._is_on = False # type: bool
|
||||
self._available = False # type: bool
|
||||
self._brightness = None # type: Optional[int]
|
||||
self._xy_color = None # type: Optional[Tuple[float, float]]
|
||||
self._rgb_color = None # type: Optional[Tuple[int, int, int]]
|
||||
self._color_temp = None # type: Optional[int]
|
||||
self._min_mireds = 154 # type: Optional[int]
|
||||
self._max_mireds = 500 # type: Optional[int]
|
||||
self._white_value = None # type: Optional[int]
|
||||
self._effect_list = None # type: Optional[List[str]]
|
||||
self._effect = None # type: Optional[str]
|
||||
self._supported_features = 0 # type: int
|
||||
self._async_unsub_state_changed = None
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
@callback
|
||||
def async_state_changed_listener(entity_id: str, old_state: State,
|
||||
new_state: State):
|
||||
"""Handle child updates."""
|
||||
self.async_schedule_update_ha_state(True)
|
||||
|
||||
self._async_unsub_state_changed = async_track_state_change(
|
||||
self.hass, self._entity_ids, async_state_changed_listener)
|
||||
await self.async_update()
|
||||
|
||||
async def async_will_remove_from_hass(self):
|
||||
"""Callback when removed from HASS."""
|
||||
if self._async_unsub_state_changed is not None:
|
||||
self._async_unsub_state_changed()
|
||||
self._async_unsub_state_changed = None
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Return the name of the entity."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return the on/off state of the light group."""
|
||||
return self._is_on
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return whether the light group is available."""
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def brightness(self) -> Optional[int]:
|
||||
"""Return the brightness of this light group between 0..255."""
|
||||
return self._brightness
|
||||
|
||||
@property
|
||||
def xy_color(self) -> Optional[Tuple[float, float]]:
|
||||
"""Return the XY color value [float, float]."""
|
||||
return self._xy_color
|
||||
|
||||
@property
|
||||
def rgb_color(self) -> Optional[Tuple[int, int, int]]:
|
||||
"""Return the RGB color value [int, int, int]."""
|
||||
return self._rgb_color
|
||||
|
||||
@property
|
||||
def color_temp(self) -> Optional[int]:
|
||||
"""Return the CT color value in mireds."""
|
||||
return self._color_temp
|
||||
|
||||
@property
|
||||
def min_mireds(self) -> Optional[int]:
|
||||
"""Return the coldest color_temp that this light group supports."""
|
||||
return self._min_mireds
|
||||
|
||||
@property
|
||||
def max_mireds(self) -> Optional[int]:
|
||||
"""Return the warmest color_temp that this light group supports."""
|
||||
return self._max_mireds
|
||||
|
||||
@property
|
||||
def white_value(self) -> Optional[int]:
|
||||
"""Return the white value of this light group between 0..255."""
|
||||
return self._white_value
|
||||
|
||||
@property
|
||||
def effect_list(self) -> Optional[List[str]]:
|
||||
"""Return the list of supported effects."""
|
||||
return self._effect_list
|
||||
|
||||
@property
|
||||
def effect(self) -> Optional[str]:
|
||||
"""Return the current effect."""
|
||||
return self._effect
|
||||
|
||||
@property
|
||||
def supported_features(self) -> int:
|
||||
"""Flag supported features."""
|
||||
return self._supported_features
|
||||
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""No polling needed for a light group."""
|
||||
return False
|
||||
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Forward the turn_on command to all lights in the light group."""
|
||||
data = {ATTR_ENTITY_ID: self._entity_ids}
|
||||
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
data[ATTR_BRIGHTNESS] = kwargs[ATTR_BRIGHTNESS]
|
||||
|
||||
if ATTR_XY_COLOR in kwargs:
|
||||
data[ATTR_XY_COLOR] = kwargs[ATTR_XY_COLOR]
|
||||
|
||||
if ATTR_RGB_COLOR in kwargs:
|
||||
data[ATTR_RGB_COLOR] = kwargs[ATTR_RGB_COLOR]
|
||||
|
||||
if ATTR_COLOR_TEMP in kwargs:
|
||||
data[ATTR_COLOR_TEMP] = kwargs[ATTR_COLOR_TEMP]
|
||||
|
||||
if ATTR_WHITE_VALUE in kwargs:
|
||||
data[ATTR_WHITE_VALUE] = kwargs[ATTR_WHITE_VALUE]
|
||||
|
||||
if ATTR_EFFECT in kwargs:
|
||||
data[ATTR_EFFECT] = kwargs[ATTR_EFFECT]
|
||||
|
||||
if ATTR_TRANSITION in kwargs:
|
||||
data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION]
|
||||
|
||||
if ATTR_FLASH in kwargs:
|
||||
data[ATTR_FLASH] = kwargs[ATTR_FLASH]
|
||||
|
||||
await self.hass.services.async_call(
|
||||
light.DOMAIN, light.SERVICE_TURN_ON, data, blocking=True)
|
||||
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Forward the turn_off command to all lights in the light group."""
|
||||
data = {ATTR_ENTITY_ID: self._entity_ids}
|
||||
|
||||
if ATTR_TRANSITION in kwargs:
|
||||
data[ATTR_TRANSITION] = kwargs[ATTR_TRANSITION]
|
||||
|
||||
await self.hass.services.async_call(
|
||||
light.DOMAIN, light.SERVICE_TURN_OFF, data, blocking=True)
|
||||
|
||||
async def async_update(self):
|
||||
"""Query all members and determine the light group state."""
|
||||
all_states = [self.hass.states.get(x) for x in self._entity_ids]
|
||||
states = list(filter(None, all_states))
|
||||
on_states = [state for state in states if state.state == STATE_ON]
|
||||
|
||||
self._is_on = len(on_states) > 0
|
||||
self._available = any(state.state != STATE_UNAVAILABLE
|
||||
for state in states)
|
||||
|
||||
self._brightness = _reduce_attribute(on_states, ATTR_BRIGHTNESS)
|
||||
|
||||
self._xy_color = _reduce_attribute(
|
||||
on_states, ATTR_XY_COLOR, reduce=_mean_tuple)
|
||||
|
||||
self._rgb_color = _reduce_attribute(
|
||||
on_states, ATTR_RGB_COLOR, reduce=_mean_tuple)
|
||||
if self._rgb_color is not None:
|
||||
self._rgb_color = tuple(map(int, self._rgb_color))
|
||||
|
||||
self._white_value = _reduce_attribute(on_states, ATTR_WHITE_VALUE)
|
||||
|
||||
self._color_temp = _reduce_attribute(on_states, ATTR_COLOR_TEMP)
|
||||
self._min_mireds = _reduce_attribute(
|
||||
states, ATTR_MIN_MIREDS, default=154, reduce=min)
|
||||
self._max_mireds = _reduce_attribute(
|
||||
states, ATTR_MAX_MIREDS, default=500, reduce=max)
|
||||
|
||||
self._effect_list = None
|
||||
all_effect_lists = list(
|
||||
_find_state_attributes(states, ATTR_EFFECT_LIST))
|
||||
if all_effect_lists:
|
||||
# Merge all effects from all effect_lists with a union merge.
|
||||
self._effect_list = list(set().union(*all_effect_lists))
|
||||
|
||||
self._effect = None
|
||||
all_effects = list(_find_state_attributes(on_states, ATTR_EFFECT))
|
||||
if all_effects:
|
||||
# Report the most common effect.
|
||||
effects_count = Counter(itertools.chain(all_effects))
|
||||
self._effect = effects_count.most_common(1)[0][0]
|
||||
|
||||
self._supported_features = 0
|
||||
for support in _find_state_attributes(states, ATTR_SUPPORTED_FEATURES):
|
||||
# Merge supported features by emulating support for every feature
|
||||
# we find.
|
||||
self._supported_features |= support
|
||||
# Bitwise-and the supported features with the GroupedLight's features
|
||||
# so that we don't break in the future when a new feature is added.
|
||||
self._supported_features &= SUPPORT_GROUP_LIGHT
|
||||
|
||||
|
||||
def _find_state_attributes(states: List[State],
|
||||
key: str) -> Iterator[Any]:
|
||||
"""Find attributes with matching key from states."""
|
||||
for state in states:
|
||||
value = state.attributes.get(key)
|
||||
if value is not None:
|
||||
yield value
|
||||
|
||||
|
||||
def _mean_int(*args):
|
||||
"""Return the mean of the supplied values."""
|
||||
return int(sum(args) / len(args))
|
||||
|
||||
|
||||
def _mean_tuple(*args):
|
||||
"""Return the mean values along the columns of the supplied values."""
|
||||
return tuple(sum(l) / len(l) for l in zip(*args))
|
||||
|
||||
|
||||
# https://github.com/PyCQA/pylint/issues/1831
|
||||
# pylint: disable=bad-whitespace
|
||||
def _reduce_attribute(states: List[State],
|
||||
key: str,
|
||||
default: Optional[Any] = None,
|
||||
reduce: Callable[..., Any] = _mean_int) -> Any:
|
||||
"""Find the first attribute matching key from states.
|
||||
|
||||
If none are found, return default.
|
||||
"""
|
||||
attrs = list(_find_state_attributes(states, key))
|
||||
|
||||
if not attrs:
|
||||
return default
|
||||
|
||||
if len(attrs) == 1:
|
||||
return attrs[0]
|
||||
|
||||
return reduce(*attrs)
|
@ -287,22 +287,21 @@ class HueLight(Light):
|
||||
if self.info.get('manufacturername') == 'OSRAM':
|
||||
color_hue, sat = color_util.color_xy_to_hs(
|
||||
*kwargs[ATTR_XY_COLOR])
|
||||
command['hue'] = color_hue
|
||||
command['sat'] = sat
|
||||
command['hue'] = color_hue / 360 * 65535
|
||||
command['sat'] = sat / 100 * 255
|
||||
else:
|
||||
command['xy'] = kwargs[ATTR_XY_COLOR]
|
||||
elif ATTR_RGB_COLOR in kwargs:
|
||||
if self.info.get('manufacturername') == 'OSRAM':
|
||||
hsv = color_util.color_RGB_to_hsv(
|
||||
*(int(val) for val in kwargs[ATTR_RGB_COLOR]))
|
||||
command['hue'] = hsv[0]
|
||||
command['sat'] = hsv[1]
|
||||
command['bri'] = hsv[2]
|
||||
command['hue'] = hsv[0] / 360 * 65535
|
||||
command['sat'] = hsv[1] / 100 * 255
|
||||
command['bri'] = hsv[2] / 100 * 255
|
||||
else:
|
||||
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]
|
||||
elif ATTR_COLOR_TEMP in kwargs:
|
||||
temp = kwargs[ATTR_COLOR_TEMP]
|
||||
command['ct'] = max(self.min_mireds, min(temp, self.max_mireds))
|
||||
|
@ -213,9 +213,10 @@ class Hyperion(Light):
|
||||
except (KeyError, IndexError):
|
||||
pass
|
||||
|
||||
if not response['info']['activeLedColor']:
|
||||
led_color = response['info']['activeLedColor']
|
||||
if not led_color or led_color[0]['RGB value'] == [0, 0, 0]:
|
||||
# Get the active effect
|
||||
if response['info']['activeEffects']:
|
||||
if response['info'].get('activeEffects'):
|
||||
self._rgb_color = [175, 0, 255]
|
||||
self._icon = 'mdi:lava-lamp'
|
||||
try:
|
||||
|
@ -2,15 +2,14 @@
|
||||
Support for Insteon lights via PowerLinc Modem.
|
||||
|
||||
For more details about this component, please refer to the documentation at
|
||||
https://home-assistant.io/components/insteon_plm/
|
||||
https://home-assistant.io/components/light.insteon_plm/
|
||||
"""
|
||||
import logging
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.components.insteon_plm import InsteonPLMEntity
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light)
|
||||
from homeassistant.loader import get_component
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@ -24,96 +23,47 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the Insteon PLM device."""
|
||||
plm = hass.data['insteon_plm']
|
||||
|
||||
device_list = []
|
||||
for device in discovery_info:
|
||||
name = device.get('address')
|
||||
address = device.get('address_hex')
|
||||
dimmable = bool('dimmable' in device.get('capabilities'))
|
||||
address = discovery_info['address']
|
||||
device = plm.devices[address]
|
||||
state_key = discovery_info['state_key']
|
||||
|
||||
_LOGGER.info("Registered %s with light platform", name)
|
||||
_LOGGER.debug('Adding device %s entity %s to Light platform',
|
||||
device.address.hex, device.states[state_key].name)
|
||||
|
||||
device_list.append(
|
||||
InsteonPLMDimmerDevice(hass, plm, address, name, dimmable)
|
||||
)
|
||||
new_entity = InsteonPLMDimmerDevice(device, state_key)
|
||||
|
||||
async_add_devices(device_list)
|
||||
async_add_devices([new_entity])
|
||||
|
||||
|
||||
class InsteonPLMDimmerDevice(Light):
|
||||
class InsteonPLMDimmerDevice(InsteonPLMEntity, Light):
|
||||
"""A Class for an Insteon device."""
|
||||
|
||||
def __init__(self, hass, plm, address, name, dimmable):
|
||||
"""Initialize the light."""
|
||||
self._hass = hass
|
||||
self._plm = plm.protocol
|
||||
self._address = address
|
||||
self._name = name
|
||||
self._dimmable = dimmable
|
||||
|
||||
self._plm.add_update_callback(
|
||||
self.async_light_update, {'address': self._address})
|
||||
|
||||
@property
|
||||
def should_poll(self):
|
||||
"""No polling needed."""
|
||||
return False
|
||||
|
||||
@property
|
||||
def address(self):
|
||||
"""Return the address of the node."""
|
||||
return self._address
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the node."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
onlevel = self._plm.get_device_attr(self._address, 'onlevel')
|
||||
_LOGGER.debug("on level for %s is %s", self._address, onlevel)
|
||||
onlevel = self._insteon_device_state.value
|
||||
return int(onlevel)
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Return the boolean response if the node is on."""
|
||||
onlevel = self._plm.get_device_attr(self._address, 'onlevel')
|
||||
_LOGGER.debug("on level for %s is %s", self._address, onlevel)
|
||||
return bool(onlevel)
|
||||
return bool(self.brightness)
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
if self._dimmable:
|
||||
return SUPPORT_BRIGHTNESS
|
||||
|
||||
@property
|
||||
def device_state_attributes(self):
|
||||
"""Provide attributes for display on device card."""
|
||||
insteon_plm = get_component('insteon_plm')
|
||||
return insteon_plm.common_attributes(self)
|
||||
|
||||
def get_attr(self, key):
|
||||
"""Return specified attribute for this device."""
|
||||
return self._plm.get_device_attr(self.address, key)
|
||||
|
||||
@callback
|
||||
def async_light_update(self, message):
|
||||
"""Receive notification from transport that new data exists."""
|
||||
_LOGGER.info("Received update callback from PLM for %s", self._address)
|
||||
self._hass.async_add_job(self.async_update_ha_state())
|
||||
return SUPPORT_BRIGHTNESS
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_on(self, **kwargs):
|
||||
"""Turn device on."""
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
brightness = int(kwargs[ATTR_BRIGHTNESS])
|
||||
self._insteon_device_state.set_level(brightness)
|
||||
else:
|
||||
brightness = MAX_BRIGHTNESS
|
||||
self._plm.turn_on(self._address, brightness=brightness)
|
||||
self._insteon_device_state.on()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_off(self, **kwargs):
|
||||
"""Turn device off."""
|
||||
self._plm.turn_off(self._address)
|
||||
self._insteon_device_state.off()
|
||||
|
@ -4,7 +4,6 @@ Support for KNX/IP lights.
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/light.knx/
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@ -37,8 +36,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
})
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
async def async_setup_platform(hass, config, async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up lights for KNX platform."""
|
||||
if discovery_info is not None:
|
||||
async_add_devices_discovery(hass, discovery_info, async_add_devices)
|
||||
@ -86,11 +85,10 @@ class KNXLight(Light):
|
||||
@callback
|
||||
def async_register_callbacks(self):
|
||||
"""Register callbacks to update hass after device was changed."""
|
||||
@asyncio.coroutine
|
||||
def after_update_callback(device):
|
||||
async def after_update_callback(device):
|
||||
"""Call after device was updated."""
|
||||
# pylint: disable=unused-argument
|
||||
yield from self.async_update_ha_state()
|
||||
await self.async_update_ha_state()
|
||||
self.device.register_device_updated_cb(after_update_callback)
|
||||
|
||||
@property
|
||||
@ -111,8 +109,8 @@ class KNXLight(Light):
|
||||
@property
|
||||
def brightness(self):
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
return self.device.brightness \
|
||||
if self.device.supports_dimming else \
|
||||
return self.device.current_brightness \
|
||||
if self.device.supports_brightness else \
|
||||
None
|
||||
|
||||
@property
|
||||
@ -124,7 +122,7 @@ class KNXLight(Light):
|
||||
def rgb_color(self):
|
||||
"""Return the RBG color value."""
|
||||
if self.device.supports_color:
|
||||
return self.device.current_color()
|
||||
return self.device.current_color
|
||||
return None
|
||||
|
||||
@property
|
||||
@ -156,23 +154,23 @@ class KNXLight(Light):
|
||||
def supported_features(self):
|
||||
"""Flag supported features."""
|
||||
flags = 0
|
||||
if self.device.supports_dimming:
|
||||
if self.device.supports_brightness:
|
||||
flags |= SUPPORT_BRIGHTNESS
|
||||
if self.device.supports_color:
|
||||
flags |= SUPPORT_RGB_COLOR
|
||||
return flags
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_on(self, **kwargs):
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn the light on."""
|
||||
if ATTR_BRIGHTNESS in kwargs and self.device.supports_dimming:
|
||||
yield from self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS]))
|
||||
if ATTR_BRIGHTNESS in kwargs:
|
||||
if self.device.supports_brightness:
|
||||
await self.device.set_brightness(int(kwargs[ATTR_BRIGHTNESS]))
|
||||
elif ATTR_RGB_COLOR in kwargs:
|
||||
yield from self.device.set_color(kwargs[ATTR_RGB_COLOR])
|
||||
if self.device.supports_color:
|
||||
await self.device.set_color(kwargs[ATTR_RGB_COLOR])
|
||||
else:
|
||||
yield from self.device.set_on()
|
||||
await self.device.set_on()
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_off(self, **kwargs):
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Turn the light off."""
|
||||
yield from self.device.set_off()
|
||||
await self.device.set_off()
|
||||
|
@ -123,8 +123,10 @@ def aiolifx_effects():
|
||||
return aiolifx_effects_module
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
async def async_setup_platform(hass,
|
||||
config,
|
||||
async_add_devices,
|
||||
discovery_info=None):
|
||||
"""Set up the LIFX platform."""
|
||||
if sys.platform == 'win32':
|
||||
_LOGGER.warning("The lifx platform is known to not work on Windows. "
|
||||
@ -169,13 +171,15 @@ def find_hsbk(**kwargs):
|
||||
if ATTR_RGB_COLOR in kwargs:
|
||||
hue, saturation, brightness = \
|
||||
color_util.color_RGB_to_hsv(*kwargs[ATTR_RGB_COLOR])
|
||||
saturation = convert_8_to_16(saturation)
|
||||
brightness = convert_8_to_16(brightness)
|
||||
hue = int(hue / 360 * 65535)
|
||||
saturation = int(saturation / 100 * 65535)
|
||||
brightness = int(brightness / 100 * 65535)
|
||||
kelvin = 3500
|
||||
|
||||
if ATTR_XY_COLOR in kwargs:
|
||||
hue, saturation = color_util.color_xy_to_hs(*kwargs[ATTR_XY_COLOR])
|
||||
saturation = convert_8_to_16(saturation)
|
||||
hue = int(hue / 360 * 65535)
|
||||
saturation = int(saturation / 100 * 65535)
|
||||
kelvin = 3500
|
||||
|
||||
if ATTR_COLOR_TEMP in kwargs:
|
||||
@ -212,8 +216,7 @@ class LIFXManager(object):
|
||||
|
||||
def register_set_state(self):
|
||||
"""Register the LIFX set_state service call."""
|
||||
@asyncio.coroutine
|
||||
def async_service_handle(service):
|
||||
async def service_handler(service):
|
||||
"""Apply a service."""
|
||||
tasks = []
|
||||
for light in self.service_to_entities(service):
|
||||
@ -221,36 +224,34 @@ class LIFXManager(object):
|
||||
task = light.async_set_state(**service.data)
|
||||
tasks.append(self.hass.async_add_job(task))
|
||||
if tasks:
|
||||
yield from asyncio.wait(tasks, loop=self.hass.loop)
|
||||
await asyncio.wait(tasks, loop=self.hass.loop)
|
||||
|
||||
self.hass.services.async_register(
|
||||
DOMAIN, SERVICE_LIFX_SET_STATE, async_service_handle,
|
||||
DOMAIN, SERVICE_LIFX_SET_STATE, service_handler,
|
||||
schema=LIFX_SET_STATE_SCHEMA)
|
||||
|
||||
def register_effects(self):
|
||||
"""Register the LIFX effects as hass service calls."""
|
||||
@asyncio.coroutine
|
||||
def async_service_handle(service):
|
||||
async def service_handler(service):
|
||||
"""Apply a service, i.e. start an effect."""
|
||||
entities = self.service_to_entities(service)
|
||||
if entities:
|
||||
yield from self.start_effect(
|
||||
await self.start_effect(
|
||||
entities, service.service, **service.data)
|
||||
|
||||
self.hass.services.async_register(
|
||||
DOMAIN, SERVICE_EFFECT_PULSE, async_service_handle,
|
||||
DOMAIN, SERVICE_EFFECT_PULSE, service_handler,
|
||||
schema=LIFX_EFFECT_PULSE_SCHEMA)
|
||||
|
||||
self.hass.services.async_register(
|
||||
DOMAIN, SERVICE_EFFECT_COLORLOOP, async_service_handle,
|
||||
DOMAIN, SERVICE_EFFECT_COLORLOOP, service_handler,
|
||||
schema=LIFX_EFFECT_COLORLOOP_SCHEMA)
|
||||
|
||||
self.hass.services.async_register(
|
||||
DOMAIN, SERVICE_EFFECT_STOP, async_service_handle,
|
||||
DOMAIN, SERVICE_EFFECT_STOP, service_handler,
|
||||
schema=LIFX_EFFECT_STOP_SCHEMA)
|
||||
|
||||
@asyncio.coroutine
|
||||
def start_effect(self, entities, service, **kwargs):
|
||||
async def start_effect(self, entities, service, **kwargs):
|
||||
"""Start a light effect on entities."""
|
||||
devices = list(map(lambda l: l.device, entities))
|
||||
|
||||
@ -262,7 +263,7 @@ class LIFXManager(object):
|
||||
mode=kwargs.get(ATTR_MODE),
|
||||
hsbk=find_hsbk(**kwargs),
|
||||
)
|
||||
yield from self.effects_conductor.start(effect, devices)
|
||||
await self.effects_conductor.start(effect, devices)
|
||||
elif service == SERVICE_EFFECT_COLORLOOP:
|
||||
preprocess_turn_on_alternatives(kwargs)
|
||||
|
||||
@ -278,9 +279,9 @@ class LIFXManager(object):
|
||||
transition=kwargs.get(ATTR_TRANSITION),
|
||||
brightness=brightness,
|
||||
)
|
||||
yield from self.effects_conductor.start(effect, devices)
|
||||
await self.effects_conductor.start(effect, devices)
|
||||
elif service == SERVICE_EFFECT_STOP:
|
||||
yield from self.effects_conductor.stop(devices)
|
||||
await self.effects_conductor.stop(devices)
|
||||
|
||||
def service_to_entities(self, service):
|
||||
"""Return the known devices that a service call mentions."""
|
||||
@ -295,25 +296,24 @@ class LIFXManager(object):
|
||||
|
||||
@callback
|
||||
def register(self, device):
|
||||
"""Handle newly detected bulb."""
|
||||
self.hass.async_add_job(self.async_register(device))
|
||||
"""Handle aiolifx detected bulb."""
|
||||
self.hass.async_add_job(self.register_new_device(device))
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_register(self, device):
|
||||
async def register_new_device(self, device):
|
||||
"""Handle newly detected bulb."""
|
||||
if device.mac_addr in self.entities:
|
||||
entity = self.entities[device.mac_addr]
|
||||
entity.registered = True
|
||||
_LOGGER.debug("%s register AGAIN", entity.who)
|
||||
yield from entity.update_hass()
|
||||
await entity.update_hass()
|
||||
else:
|
||||
_LOGGER.debug("%s register NEW", device.ip_addr)
|
||||
|
||||
# Read initial state
|
||||
ack = AwaitAioLIFX().wait
|
||||
version_resp = yield from ack(device.get_version)
|
||||
version_resp = await ack(device.get_version)
|
||||
if version_resp:
|
||||
color_resp = yield from ack(device.get_color)
|
||||
color_resp = await ack(device.get_color)
|
||||
|
||||
if version_resp is None or color_resp is None:
|
||||
_LOGGER.error("Failed to initialize %s", device.ip_addr)
|
||||
@ -335,7 +335,7 @@ class LIFXManager(object):
|
||||
|
||||
@callback
|
||||
def unregister(self, device):
|
||||
"""Handle disappearing bulbs."""
|
||||
"""Handle aiolifx disappearing bulbs."""
|
||||
if device.mac_addr in self.entities:
|
||||
entity = self.entities[device.mac_addr]
|
||||
_LOGGER.debug("%s unregister", entity.who)
|
||||
@ -359,15 +359,14 @@ class AwaitAioLIFX:
|
||||
self.message = message
|
||||
self.event.set()
|
||||
|
||||
@asyncio.coroutine
|
||||
def wait(self, method):
|
||||
async def wait(self, method):
|
||||
"""Call an aiolifx method and wait for its response."""
|
||||
self.device = None
|
||||
self.message = None
|
||||
self.event.clear()
|
||||
method(callb=self.callback)
|
||||
|
||||
yield from self.event.wait()
|
||||
await self.event.wait()
|
||||
return self.message
|
||||
|
||||
|
||||
@ -464,21 +463,19 @@ class LIFXLight(Light):
|
||||
return 'lifx_effect_' + effect.name
|
||||
return None
|
||||
|
||||
@asyncio.coroutine
|
||||
def update_hass(self, now=None):
|
||||
async def update_hass(self, now=None):
|
||||
"""Request new status and push it to hass."""
|
||||
self.postponed_update = None
|
||||
yield from self.async_update()
|
||||
yield from self.async_update_ha_state()
|
||||
await self.async_update()
|
||||
await self.async_update_ha_state()
|
||||
|
||||
@asyncio.coroutine
|
||||
def update_during_transition(self, when):
|
||||
async def update_during_transition(self, when):
|
||||
"""Update state at the start and end of a transition."""
|
||||
if self.postponed_update:
|
||||
self.postponed_update()
|
||||
|
||||
# Transition has started
|
||||
yield from self.update_hass()
|
||||
await self.update_hass()
|
||||
|
||||
# Transition has ended
|
||||
if when > 0:
|
||||
@ -486,28 +483,25 @@ class LIFXLight(Light):
|
||||
self.hass, self.update_hass,
|
||||
util.dt.utcnow() + timedelta(milliseconds=when))
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_on(self, **kwargs):
|
||||
async def async_turn_on(self, **kwargs):
|
||||
"""Turn the device on."""
|
||||
kwargs[ATTR_POWER] = True
|
||||
self.hass.async_add_job(self.async_set_state(**kwargs))
|
||||
self.hass.async_add_job(self.set_state(**kwargs))
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_off(self, **kwargs):
|
||||
async def async_turn_off(self, **kwargs):
|
||||
"""Turn the device off."""
|
||||
kwargs[ATTR_POWER] = False
|
||||
self.hass.async_add_job(self.async_set_state(**kwargs))
|
||||
self.hass.async_add_job(self.set_state(**kwargs))
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_state(self, **kwargs):
|
||||
async def set_state(self, **kwargs):
|
||||
"""Set a color on the light and turn it on/off."""
|
||||
with (yield from self.lock):
|
||||
async with self.lock:
|
||||
bulb = self.device
|
||||
|
||||
yield from self.effects_conductor.stop([bulb])
|
||||
await self.effects_conductor.stop([bulb])
|
||||
|
||||
if ATTR_EFFECT in kwargs:
|
||||
yield from self.default_effect(**kwargs)
|
||||
await self.default_effect(**kwargs)
|
||||
return
|
||||
|
||||
if ATTR_INFRARED in kwargs:
|
||||
@ -529,51 +523,47 @@ class LIFXLight(Light):
|
||||
|
||||
if not self.is_on:
|
||||
if power_off:
|
||||
yield from self.set_power(ack, False)
|
||||
await self.set_power(ack, False)
|
||||
if hsbk:
|
||||
yield from self.set_color(ack, hsbk, kwargs)
|
||||
await self.set_color(ack, hsbk, kwargs)
|
||||
if power_on:
|
||||
yield from self.set_power(ack, True, duration=fade)
|
||||
await self.set_power(ack, True, duration=fade)
|
||||
else:
|
||||
if power_on:
|
||||
yield from self.set_power(ack, True)
|
||||
await self.set_power(ack, True)
|
||||
if hsbk:
|
||||
yield from self.set_color(ack, hsbk, kwargs, duration=fade)
|
||||
await self.set_color(ack, hsbk, kwargs, duration=fade)
|
||||
if power_off:
|
||||
yield from self.set_power(ack, False, duration=fade)
|
||||
await self.set_power(ack, False, duration=fade)
|
||||
|
||||
# Avoid state ping-pong by holding off updates as the state settles
|
||||
yield from asyncio.sleep(0.3)
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# Update when the transition starts and ends
|
||||
yield from self.update_during_transition(fade)
|
||||
await self.update_during_transition(fade)
|
||||
|
||||
@asyncio.coroutine
|
||||
def set_power(self, ack, pwr, duration=0):
|
||||
async def set_power(self, ack, pwr, duration=0):
|
||||
"""Send a power change to the device."""
|
||||
yield from ack(partial(self.device.set_power, pwr, duration=duration))
|
||||
await ack(partial(self.device.set_power, pwr, duration=duration))
|
||||
|
||||
@asyncio.coroutine
|
||||
def set_color(self, ack, hsbk, kwargs, duration=0):
|
||||
async def set_color(self, ack, hsbk, kwargs, duration=0):
|
||||
"""Send a color change to the device."""
|
||||
hsbk = merge_hsbk(self.device.color, hsbk)
|
||||
yield from ack(partial(self.device.set_color, hsbk, duration=duration))
|
||||
await ack(partial(self.device.set_color, hsbk, duration=duration))
|
||||
|
||||
@asyncio.coroutine
|
||||
def default_effect(self, **kwargs):
|
||||
async def default_effect(self, **kwargs):
|
||||
"""Start an effect with default parameters."""
|
||||
service = kwargs[ATTR_EFFECT]
|
||||
data = {
|
||||
ATTR_ENTITY_ID: self.entity_id,
|
||||
}
|
||||
yield from self.hass.services.async_call(DOMAIN, service, data)
|
||||
await self.hass.services.async_call(DOMAIN, service, data)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_update(self):
|
||||
async def async_update(self):
|
||||
"""Update bulb status."""
|
||||
_LOGGER.debug("%s async_update", self.who)
|
||||
if self.available and not self.lock.locked():
|
||||
yield from AwaitAioLIFX().wait(self.device.get_color)
|
||||
await AwaitAioLIFX().wait(self.device.get_color)
|
||||
|
||||
|
||||
class LIFXWhite(LIFXLight):
|
||||
@ -612,15 +602,17 @@ class LIFXColor(LIFXLight):
|
||||
"""Return the RGB value."""
|
||||
hue, sat, bri, _ = self.device.color
|
||||
|
||||
return color_util.color_hsv_to_RGB(
|
||||
hue, convert_16_to_8(sat), convert_16_to_8(bri))
|
||||
hue = hue / 65535 * 360
|
||||
sat = sat / 65535 * 100
|
||||
bri = bri / 65535 * 100
|
||||
|
||||
return color_util.color_hsv_to_RGB(hue, sat, bri)
|
||||
|
||||
|
||||
class LIFXStrip(LIFXColor):
|
||||
"""Representation of a LIFX light strip with multiple zones."""
|
||||
|
||||
@asyncio.coroutine
|
||||
def set_color(self, ack, hsbk, kwargs, duration=0):
|
||||
async def set_color(self, ack, hsbk, kwargs, duration=0):
|
||||
"""Send a color change to the device."""
|
||||
bulb = self.device
|
||||
num_zones = len(bulb.color_zones)
|
||||
@ -630,7 +622,7 @@ class LIFXStrip(LIFXColor):
|
||||
# Fast track: setting all zones to the same brightness and color
|
||||
# can be treated as a single-zone bulb.
|
||||
if hsbk[2] is not None and hsbk[3] is not None:
|
||||
yield from super().set_color(ack, hsbk, kwargs, duration)
|
||||
await super().set_color(ack, hsbk, kwargs, duration)
|
||||
return
|
||||
|
||||
zones = list(range(0, num_zones))
|
||||
@ -639,11 +631,11 @@ class LIFXStrip(LIFXColor):
|
||||
|
||||
# Zone brightness is not reported when powered off
|
||||
if not self.is_on and hsbk[2] is None:
|
||||
yield from self.set_power(ack, True)
|
||||
yield from asyncio.sleep(0.3)
|
||||
yield from self.update_color_zones()
|
||||
yield from self.set_power(ack, False)
|
||||
yield from asyncio.sleep(0.3)
|
||||
await self.set_power(ack, True)
|
||||
await asyncio.sleep(0.3)
|
||||
await self.update_color_zones()
|
||||
await self.set_power(ack, False)
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# Send new color to each zone
|
||||
for index, zone in enumerate(zones):
|
||||
@ -655,23 +647,21 @@ class LIFXStrip(LIFXColor):
|
||||
color=zone_hsbk,
|
||||
duration=duration,
|
||||
apply=apply)
|
||||
yield from ack(set_zone)
|
||||
await ack(set_zone)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_update(self):
|
||||
async def async_update(self):
|
||||
"""Update strip status."""
|
||||
if self.available and not self.lock.locked():
|
||||
yield from super().async_update()
|
||||
yield from self.update_color_zones()
|
||||
await super().async_update()
|
||||
await self.update_color_zones()
|
||||
|
||||
@asyncio.coroutine
|
||||
def update_color_zones(self):
|
||||
async def update_color_zones(self):
|
||||
"""Get updated color information for each zone."""
|
||||
zone = 0
|
||||
top = 1
|
||||
while self.available and zone < top:
|
||||
# Each get_color_zones can update 8 zones at once
|
||||
resp = yield from AwaitAioLIFX().wait(partial(
|
||||
resp = await AwaitAioLIFX().wait(partial(
|
||||
self.device.get_color_zones,
|
||||
start_index=zone))
|
||||
if resp:
|
||||
|
@ -17,6 +17,7 @@ from homeassistant.components.light import (
|
||||
SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_FLASH,
|
||||
SUPPORT_RGB_COLOR, SUPPORT_TRANSITION, Light, PLATFORM_SCHEMA)
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.util.color import color_temperature_mired_to_kelvin
|
||||
from homeassistant.helpers.restore_state import async_get_last_state
|
||||
|
||||
REQUIREMENTS = ['limitlessled==1.1.0']
|
||||
@ -222,6 +223,16 @@ class LimitlessLEDGroup(Light):
|
||||
"""Return the brightness property."""
|
||||
return self._brightness
|
||||
|
||||
@property
|
||||
def min_mireds(self):
|
||||
"""Return the coldest color_temp that this light supports."""
|
||||
return 154
|
||||
|
||||
@property
|
||||
def max_mireds(self):
|
||||
"""Return the warmest color_temp that this light supports."""
|
||||
return 370
|
||||
|
||||
@property
|
||||
def color_temp(self):
|
||||
"""Return the temperature property."""
|
||||
@ -310,8 +321,11 @@ class LimitlessLEDGroup(Light):
|
||||
|
||||
def limitlessled_temperature(self):
|
||||
"""Convert Home Assistant color temperature units to percentage."""
|
||||
width = self.max_mireds - self.min_mireds
|
||||
temperature = 1 - (self._temperature - self.min_mireds) / width
|
||||
max_kelvin = color_temperature_mired_to_kelvin(self.min_mireds)
|
||||
min_kelvin = color_temperature_mired_to_kelvin(self.max_mireds)
|
||||
width = max_kelvin - min_kelvin
|
||||
kelvin = color_temperature_mired_to_kelvin(self._temperature)
|
||||
temperature = (kelvin - min_kelvin) / width
|
||||
return max(0, min(1, temperature))
|
||||
|
||||
def limitlessled_brightness(self):
|
||||
|
@ -169,3 +169,13 @@ xiaomi_miio_set_scene:
|
||||
scene:
|
||||
description: Number of the fixed scene, between 1 and 4.
|
||||
example: 1
|
||||
|
||||
xiaomi_miio_set_delayed_turn_off:
|
||||
description: Delayed turn off.
|
||||
fields:
|
||||
entity_id:
|
||||
description: Name of the light entity.
|
||||
example: 'light.xiaomi_miio'
|
||||
time_period:
|
||||
description: Time period for the delayed turn off.
|
||||
example: "5, '0:05', {'minutes': 5}"
|
||||
|
@ -8,6 +8,8 @@ import asyncio
|
||||
from functools import partial
|
||||
import logging
|
||||
from math import ceil
|
||||
from datetime import timedelta
|
||||
import datetime
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@ -18,16 +20,24 @@ from homeassistant.components.light import (
|
||||
|
||||
from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_TOKEN, )
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.util import dt
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_NAME = 'Xiaomi Philips Light'
|
||||
PLATFORM = 'xiaomi_miio'
|
||||
DATA_KEY = 'light.xiaomi_miio'
|
||||
|
||||
CONF_MODEL = 'model'
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)),
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_MODEL): vol.In(
|
||||
['philips.light.sread1',
|
||||
'philips.light.ceiling',
|
||||
'philips.light.zyceiling',
|
||||
'philips.light.bulb']),
|
||||
})
|
||||
|
||||
REQUIREMENTS = ['python-miio==0.3.7']
|
||||
@ -36,25 +46,38 @@ REQUIREMENTS = ['python-miio==0.3.7']
|
||||
CCT_MIN = 1
|
||||
CCT_MAX = 100
|
||||
|
||||
DELAYED_TURN_OFF_MAX_DEVIATION = 4
|
||||
|
||||
SUCCESS = ['ok']
|
||||
ATTR_MODEL = 'model'
|
||||
ATTR_SCENE = 'scene'
|
||||
ATTR_DELAYED_TURN_OFF = 'delayed_turn_off'
|
||||
ATTR_TIME_PERIOD = 'time_period'
|
||||
|
||||
SERVICE_SET_SCENE = 'xiaomi_miio_set_scene'
|
||||
SERVICE_SET_DELAYED_TURN_OFF = 'xiaomi_miio_set_delayed_turn_off'
|
||||
|
||||
XIAOMI_MIIO_SERVICE_SCHEMA = vol.Schema({
|
||||
vol.Optional(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
})
|
||||
|
||||
SERVICE_SCHEMA_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend({
|
||||
SERVICE_SCHEMA_SET_SCENE = XIAOMI_MIIO_SERVICE_SCHEMA.extend({
|
||||
vol.Required(ATTR_SCENE):
|
||||
vol.All(vol.Coerce(int), vol.Clamp(min=1, max=4))
|
||||
})
|
||||
|
||||
SERVICE_SCHEMA_SET_DELAYED_TURN_OFF = XIAOMI_MIIO_SERVICE_SCHEMA.extend({
|
||||
vol.Required(ATTR_TIME_PERIOD):
|
||||
vol.All(cv.time_period, cv.positive_timedelta)
|
||||
})
|
||||
|
||||
SERVICE_TO_METHOD = {
|
||||
SERVICE_SET_DELAYED_TURN_OFF: {
|
||||
'method': 'async_set_delayed_turn_off',
|
||||
'schema': SERVICE_SCHEMA_SET_DELAYED_TURN_OFF},
|
||||
SERVICE_SET_SCENE: {
|
||||
'method': 'async_set_scene',
|
||||
'schema': SERVICE_SCHEMA_SCENE}
|
||||
'schema': SERVICE_SCHEMA_SET_SCENE},
|
||||
}
|
||||
|
||||
|
||||
@ -63,46 +86,48 @@ SERVICE_TO_METHOD = {
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
"""Set up the light from config."""
|
||||
from miio import Device, DeviceException
|
||||
if PLATFORM not in hass.data:
|
||||
hass.data[PLATFORM] = {}
|
||||
if DATA_KEY not in hass.data:
|
||||
hass.data[DATA_KEY] = {}
|
||||
|
||||
host = config.get(CONF_HOST)
|
||||
name = config.get(CONF_NAME)
|
||||
token = config.get(CONF_TOKEN)
|
||||
model = config.get(CONF_MODEL)
|
||||
|
||||
_LOGGER.info("Initializing with host %s (token %s...)", host, token[:5])
|
||||
|
||||
try:
|
||||
light = Device(host, token)
|
||||
device_info = light.info()
|
||||
_LOGGER.info("%s %s %s initialized",
|
||||
device_info.model,
|
||||
device_info.firmware_version,
|
||||
device_info.hardware_version)
|
||||
if model is None:
|
||||
try:
|
||||
miio_device = Device(host, token)
|
||||
device_info = miio_device.info()
|
||||
model = device_info.model
|
||||
_LOGGER.info("%s %s %s detected",
|
||||
model,
|
||||
device_info.firmware_version,
|
||||
device_info.hardware_version)
|
||||
except DeviceException:
|
||||
raise PlatformNotReady
|
||||
|
||||
if device_info.model == 'philips.light.sread1':
|
||||
from miio import PhilipsEyecare
|
||||
light = PhilipsEyecare(host, token)
|
||||
device = XiaomiPhilipsEyecareLamp(name, light, device_info)
|
||||
elif device_info.model == 'philips.light.ceiling':
|
||||
from miio import Ceil
|
||||
light = Ceil(host, token)
|
||||
device = XiaomiPhilipsCeilingLamp(name, light, device_info)
|
||||
elif device_info.model == 'philips.light.bulb':
|
||||
from miio import PhilipsBulb
|
||||
light = PhilipsBulb(host, token)
|
||||
device = XiaomiPhilipsLightBall(name, light, device_info)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
'Unsupported device found! Please create an issue at '
|
||||
'https://github.com/rytilahti/python-miio/issues '
|
||||
'and provide the following data: %s', device_info.model)
|
||||
return False
|
||||
if model == 'philips.light.sread1':
|
||||
from miio import PhilipsEyecare
|
||||
light = PhilipsEyecare(host, token)
|
||||
device = XiaomiPhilipsEyecareLamp(name, light, model)
|
||||
elif model in ['philips.light.ceiling', 'philips.light.zyceiling']:
|
||||
from miio import Ceil
|
||||
light = Ceil(host, token)
|
||||
device = XiaomiPhilipsCeilingLamp(name, light, model)
|
||||
elif model == 'philips.light.bulb':
|
||||
from miio import PhilipsBulb
|
||||
light = PhilipsBulb(host, token)
|
||||
device = XiaomiPhilipsLightBall(name, light, model)
|
||||
else:
|
||||
_LOGGER.error(
|
||||
'Unsupported device found! Please create an issue at '
|
||||
'https://github.com/rytilahti/python-miio/issues '
|
||||
'and provide the following data: %s', model)
|
||||
return False
|
||||
|
||||
except DeviceException:
|
||||
raise PlatformNotReady
|
||||
|
||||
hass.data[PLATFORM][host] = device
|
||||
hass.data[DATA_KEY][host] = device
|
||||
async_add_devices([device], update_before_add=True)
|
||||
|
||||
@asyncio.coroutine
|
||||
@ -113,10 +138,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
if key != ATTR_ENTITY_ID}
|
||||
entity_ids = service.data.get(ATTR_ENTITY_ID)
|
||||
if entity_ids:
|
||||
target_devices = [dev for dev in hass.data[PLATFORM].values()
|
||||
target_devices = [dev for dev in hass.data[DATA_KEY].values()
|
||||
if dev.entity_id in entity_ids]
|
||||
else:
|
||||
target_devices = hass.data[PLATFORM].values()
|
||||
target_devices = hass.data[DATA_KEY].values()
|
||||
|
||||
update_tasks = []
|
||||
for target_device in target_devices:
|
||||
@ -136,10 +161,10 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
class XiaomiPhilipsGenericLight(Light):
|
||||
"""Representation of a Xiaomi Philips Light."""
|
||||
|
||||
def __init__(self, name, light, device_info):
|
||||
def __init__(self, name, light, model):
|
||||
"""Initialize the light device."""
|
||||
self._name = name
|
||||
self._device_info = device_info
|
||||
self._model = model
|
||||
|
||||
self._brightness = None
|
||||
self._color_temp = None
|
||||
@ -147,7 +172,9 @@ class XiaomiPhilipsGenericLight(Light):
|
||||
self._light = light
|
||||
self._state = None
|
||||
self._state_attrs = {
|
||||
ATTR_MODEL: self._device_info.model,
|
||||
ATTR_MODEL: self._model,
|
||||
ATTR_SCENE: None,
|
||||
ATTR_DELAYED_TURN_OFF: None,
|
||||
}
|
||||
|
||||
@property
|
||||
@ -217,14 +244,14 @@ class XiaomiPhilipsGenericLight(Light):
|
||||
|
||||
if result:
|
||||
self._brightness = brightness
|
||||
|
||||
self._state = yield from self._try_command(
|
||||
"Turning the light on failed.", self._light.on)
|
||||
else:
|
||||
yield from self._try_command(
|
||||
"Turning the light on failed.", self._light.on)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_off(self, **kwargs):
|
||||
"""Turn the light off."""
|
||||
self._state = yield from self._try_command(
|
||||
yield from self._try_command(
|
||||
"Turning the light off failed.", self._light.off)
|
||||
|
||||
@asyncio.coroutine
|
||||
@ -236,9 +263,20 @@ class XiaomiPhilipsGenericLight(Light):
|
||||
_LOGGER.debug("Got new state: %s", state)
|
||||
|
||||
self._state = state.is_on
|
||||
self._brightness = ceil((255/100.0) * state.brightness)
|
||||
self._brightness = ceil((255 / 100.0) * state.brightness)
|
||||
|
||||
delayed_turn_off = self.delayed_turn_off_timestamp(
|
||||
state.delay_off_countdown,
|
||||
dt.utcnow(),
|
||||
self._state_attrs[ATTR_DELAYED_TURN_OFF])
|
||||
|
||||
self._state_attrs.update({
|
||||
ATTR_SCENE: state.scene,
|
||||
ATTR_DELAYED_TURN_OFF: delayed_turn_off,
|
||||
})
|
||||
|
||||
except DeviceException as ex:
|
||||
self._state = None
|
||||
_LOGGER.error("Got exception while fetching the state: %s", ex)
|
||||
|
||||
@asyncio.coroutine
|
||||
@ -248,6 +286,13 @@ class XiaomiPhilipsGenericLight(Light):
|
||||
"Setting a fixed scene failed.",
|
||||
self._light.set_scene, scene)
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_set_delayed_turn_off(self, time_period: timedelta):
|
||||
"""Set delay off. The unit is different per device."""
|
||||
yield from self._try_command(
|
||||
"Setting the delay off failed.",
|
||||
self._light.delay_off, time_period.total_seconds())
|
||||
|
||||
@staticmethod
|
||||
def translate(value, left_min, left_max, right_min, right_max):
|
||||
"""Map a value from left span to right span."""
|
||||
@ -256,6 +301,28 @@ class XiaomiPhilipsGenericLight(Light):
|
||||
value_scaled = float(value - left_min) / float(left_span)
|
||||
return int(right_min + (value_scaled * right_span))
|
||||
|
||||
@staticmethod
|
||||
def delayed_turn_off_timestamp(countdown: int,
|
||||
current: datetime,
|
||||
previous: datetime):
|
||||
"""Update the turn off timestamp only if necessary."""
|
||||
if countdown > 0:
|
||||
new = current.replace(microsecond=0) + \
|
||||
timedelta(seconds=countdown)
|
||||
|
||||
if previous is None:
|
||||
return new
|
||||
|
||||
lower = timedelta(seconds=-DELAYED_TURN_OFF_MAX_DEVIATION)
|
||||
upper = timedelta(seconds=DELAYED_TURN_OFF_MAX_DEVIATION)
|
||||
diff = previous - new
|
||||
if lower < diff < upper:
|
||||
return previous
|
||||
|
||||
return new
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light):
|
||||
"""Representation of a Xiaomi Philips Light Ball."""
|
||||
@ -339,7 +406,7 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light):
|
||||
self._brightness = brightness
|
||||
|
||||
else:
|
||||
self._state = yield from self._try_command(
|
||||
yield from self._try_command(
|
||||
"Turning the light on failed.", self._light.on)
|
||||
|
||||
@asyncio.coroutine
|
||||
@ -351,13 +418,24 @@ class XiaomiPhilipsLightBall(XiaomiPhilipsGenericLight, Light):
|
||||
_LOGGER.debug("Got new state: %s", state)
|
||||
|
||||
self._state = state.is_on
|
||||
self._brightness = ceil((255/100.0) * state.brightness)
|
||||
self._brightness = ceil((255 / 100.0) * state.brightness)
|
||||
self._color_temp = self.translate(
|
||||
state.color_temperature,
|
||||
CCT_MIN, CCT_MAX,
|
||||
self.max_mireds, self.min_mireds)
|
||||
|
||||
delayed_turn_off = self.delayed_turn_off_timestamp(
|
||||
state.delay_off_countdown,
|
||||
dt.utcnow(),
|
||||
self._state_attrs[ATTR_DELAYED_TURN_OFF])
|
||||
|
||||
self._state_attrs.update({
|
||||
ATTR_SCENE: state.scene,
|
||||
ATTR_DELAYED_TURN_OFF: delayed_turn_off,
|
||||
})
|
||||
|
||||
except DeviceException as ex:
|
||||
self._state = None
|
||||
_LOGGER.error("Got exception while fetching the state: %s", ex)
|
||||
|
||||
|
||||
|
@ -47,6 +47,11 @@ CONFIG_SCHEMA = vol.Schema({
|
||||
}),
|
||||
}, extra=vol.ALLOW_EXTRA)
|
||||
|
||||
ALL_EVENT_TYPES = [
|
||||
EVENT_STATE_CHANGED, EVENT_LOGBOOK_ENTRY,
|
||||
EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP
|
||||
]
|
||||
|
||||
GROUP_BY_MINUTES = 15
|
||||
|
||||
CONTINUOUS_DOMAINS = ['proximity', 'sensor']
|
||||
@ -266,15 +271,18 @@ def humanify(events):
|
||||
|
||||
def _get_events(hass, config, start_day, end_day):
|
||||
"""Get events for a period of time."""
|
||||
from homeassistant.components.recorder.models import Events
|
||||
from homeassistant.components.recorder.models import Events, States
|
||||
from homeassistant.components.recorder.util import (
|
||||
execute, session_scope)
|
||||
|
||||
with session_scope(hass=hass) as session:
|
||||
query = session.query(Events).order_by(
|
||||
Events.time_fired).filter(
|
||||
(Events.time_fired > start_day) &
|
||||
(Events.time_fired < end_day))
|
||||
query = session.query(Events).order_by(Events.time_fired) \
|
||||
.outerjoin(States, (Events.event_id == States.event_id)) \
|
||||
.filter(Events.event_type.in_(ALL_EVENT_TYPES)) \
|
||||
.filter((Events.time_fired > start_day)
|
||||
& (Events.time_fired < end_day)) \
|
||||
.filter((States.last_updated == States.last_changed)
|
||||
| (States.state_id.is_(None)))
|
||||
events = execute(query)
|
||||
return humanify(_exclude_events(events, config))
|
||||
|
||||
|
@ -25,6 +25,10 @@ DEPENDENCIES = ['apple_tv']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SUPPORT_APPLE_TV = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | \
|
||||
SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SEEK | \
|
||||
SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass, config, async_add_devices, discovery_info=None):
|
||||
@ -79,7 +83,7 @@ class AppleTvDevice(MediaPlayerDevice):
|
||||
|
||||
@property
|
||||
def unique_id(self):
|
||||
"""Return an unique ID."""
|
||||
"""Return a unique ID."""
|
||||
return self.atv.metadata.device_id
|
||||
|
||||
@property
|
||||
@ -196,14 +200,7 @@ class AppleTvDevice(MediaPlayerDevice):
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag media player features that are supported."""
|
||||
features = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA
|
||||
if self._playing is None or self.state == STATE_IDLE:
|
||||
return features
|
||||
|
||||
features |= SUPPORT_PAUSE | SUPPORT_PLAY | SUPPORT_SEEK | \
|
||||
SUPPORT_STOP | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK
|
||||
|
||||
return features
|
||||
return SUPPORT_APPLE_TV
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_turn_on(self):
|
||||
|
@ -5,7 +5,6 @@ For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/media_player.cast/
|
||||
"""
|
||||
# pylint: disable=import-error
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
import functools
|
||||
@ -135,9 +134,8 @@ def _async_create_cast_device(hass, chromecast):
|
||||
return None
|
||||
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
|
||||
async_add_devices, discovery_info=None):
|
||||
async def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
|
||||
async_add_devices, discovery_info=None):
|
||||
"""Set up the cast platform."""
|
||||
import pychromecast
|
||||
|
||||
@ -187,7 +185,7 @@ def async_setup_platform(hass: HomeAssistantType, config: ConfigType,
|
||||
try:
|
||||
func = functools.partial(pychromecast.Chromecast, *want_host,
|
||||
tries=SOCKET_CLIENT_RETRIES)
|
||||
chromecast = yield from hass.async_add_job(func)
|
||||
chromecast = await hass.async_add_job(func)
|
||||
except pychromecast.ChromecastConnectionError as err:
|
||||
_LOGGER.warning("Can't set up chromecast on %s: %s",
|
||||
want_host[0], err)
|
||||
@ -420,7 +418,7 @@ class CastDevice(MediaPlayerDevice):
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
"""Return an unique ID."""
|
||||
"""Return a unique ID."""
|
||||
if self.cast.uuid is not None:
|
||||
return str(self.cast.uuid)
|
||||
return None
|
||||
@ -439,8 +437,7 @@ class CastDevice(MediaPlayerDevice):
|
||||
self.cast_status = self.cast.status
|
||||
self.media_status = self.cast.media_controller.status
|
||||
|
||||
@asyncio.coroutine
|
||||
def async_will_remove_from_hass(self):
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Disconnect Chromecast object when removed."""
|
||||
self._async_disconnect()
|
||||
|
||||
|
303
homeassistant/components/media_player/channels.py
Normal file
303
homeassistant/components/media_player/channels.py
Normal file
@ -0,0 +1,303 @@
|
||||
"""
|
||||
Support for interfacing with an instance of Channels (https://getchannels.com).
|
||||
|
||||
For more details about this platform, please refer to the documentation at
|
||||
https://home-assistant.io/components/media_player.channels/
|
||||
"""
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
MEDIA_TYPE_CHANNEL, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_EPISODE,
|
||||
MEDIA_TYPE_VIDEO, SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_STOP,
|
||||
SUPPORT_VOLUME_MUTE, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK,
|
||||
SUPPORT_PLAY_MEDIA, SUPPORT_SELECT_SOURCE, DOMAIN, PLATFORM_SCHEMA,
|
||||
MediaPlayerDevice)
|
||||
|
||||
from homeassistant.const import (
|
||||
CONF_HOST, CONF_PORT, CONF_NAME, STATE_IDLE, STATE_PAUSED, STATE_PLAYING,
|
||||
ATTR_ENTITY_ID)
|
||||
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_CHANNELS = 'channels'
|
||||
DEFAULT_NAME = 'Channels'
|
||||
DEFAULT_PORT = 57000
|
||||
|
||||
FEATURE_SUPPORT = SUPPORT_PLAY | SUPPORT_PAUSE | SUPPORT_STOP | \
|
||||
SUPPORT_VOLUME_MUTE | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK | \
|
||||
SUPPORT_PLAY_MEDIA | SUPPORT_SELECT_SOURCE
|
||||
|
||||
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
|
||||
vol.Required(CONF_HOST): cv.string,
|
||||
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
|
||||
vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port,
|
||||
})
|
||||
|
||||
SERVICE_SEEK_FORWARD = 'channels_seek_forward'
|
||||
SERVICE_SEEK_BACKWARD = 'channels_seek_backward'
|
||||
SERVICE_SEEK_BY = 'channels_seek_by'
|
||||
|
||||
# Service call validation schemas
|
||||
ATTR_SECONDS = 'seconds'
|
||||
|
||||
CHANNELS_SCHEMA = vol.Schema({
|
||||
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
|
||||
})
|
||||
|
||||
CHANNELS_SEEK_BY_SCHEMA = CHANNELS_SCHEMA.extend({
|
||||
vol.Required(ATTR_SECONDS): vol.Coerce(int),
|
||||
})
|
||||
|
||||
REQUIREMENTS = ['pychannels==1.0.0']
|
||||
|
||||
|
||||
# pylint: disable=unused-argument, abstract-method
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
def setup_platform(hass, config, add_devices, discovery_info=None):
|
||||
"""Setup the Channels platform."""
|
||||
device = ChannelsPlayer(
|
||||
config.get('name', DEFAULT_NAME),
|
||||
config.get(CONF_HOST),
|
||||
config.get(CONF_PORT, DEFAULT_PORT)
|
||||
)
|
||||
|
||||
if DATA_CHANNELS not in hass.data:
|
||||
hass.data[DATA_CHANNELS] = []
|
||||
|
||||
add_devices([device], True)
|
||||
hass.data[DATA_CHANNELS].append(device)
|
||||
|
||||
def service_handler(service):
|
||||
"""Handler for services."""
|
||||
entity_ids = service.data.get(ATTR_ENTITY_ID)
|
||||
|
||||
if entity_ids:
|
||||
devices = [device for device in hass.data[DATA_CHANNELS]
|
||||
if device.entity_id in entity_ids]
|
||||
else:
|
||||
devices = hass.data[DATA_CHANNELS]
|
||||
|
||||
for device in devices:
|
||||
if service.service == SERVICE_SEEK_FORWARD:
|
||||
device.seek_forward()
|
||||
elif service.service == SERVICE_SEEK_BACKWARD:
|
||||
device.seek_backward()
|
||||
elif service.service == SERVICE_SEEK_BY:
|
||||
seconds = service.data.get('seconds')
|
||||
device.seek_by(seconds)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_SEEK_FORWARD, service_handler,
|
||||
schema=CHANNELS_SCHEMA)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_SEEK_BACKWARD, service_handler,
|
||||
schema=CHANNELS_SCHEMA)
|
||||
|
||||
hass.services.register(
|
||||
DOMAIN, SERVICE_SEEK_BY, service_handler,
|
||||
schema=CHANNELS_SEEK_BY_SCHEMA)
|
||||
|
||||
|
||||
class ChannelsPlayer(MediaPlayerDevice):
|
||||
"""Representation of a Channels instance."""
|
||||
|
||||
# pylint: disable=too-many-public-methods
|
||||
def __init__(self, name, host, port):
|
||||
"""Initialize the Channels app."""
|
||||
from pychannels import Channels
|
||||
|
||||
self._name = name
|
||||
self._host = host
|
||||
self._port = port
|
||||
|
||||
self.client = Channels(self._host, self._port)
|
||||
|
||||
self.status = None
|
||||
self.muted = None
|
||||
|
||||
self.channel_number = None
|
||||
self.channel_name = None
|
||||
self.channel_image_url = None
|
||||
|
||||
self.now_playing_title = None
|
||||
self.now_playing_episode_title = None
|
||||
self.now_playing_season_number = None
|
||||
self.now_playing_episode_number = None
|
||||
self.now_playing_summary = None
|
||||
self.now_playing_image_url = None
|
||||
|
||||
self.favorite_channels = []
|
||||
|
||||
def update_favorite_channels(self):
|
||||
"""Update the favorite channels from the client."""
|
||||
self.favorite_channels = self.client.favorite_channels()
|
||||
|
||||
def update_state(self, state_hash):
|
||||
"""Update all the state properties with the passed in dictionary."""
|
||||
self.status = state_hash.get('status', "stopped")
|
||||
self.muted = state_hash.get('muted', False)
|
||||
|
||||
channel_hash = state_hash.get('channel')
|
||||
np_hash = state_hash.get('now_playing')
|
||||
|
||||
if channel_hash:
|
||||
self.channel_number = channel_hash.get('channel_number')
|
||||
self.channel_name = channel_hash.get('channel_name')
|
||||
self.channel_image_url = channel_hash.get('channel_image_url')
|
||||
else:
|
||||
self.channel_number = None
|
||||
self.channel_name = None
|
||||
self.channel_image_url = None
|
||||
|
||||
if np_hash:
|
||||
self.now_playing_title = np_hash.get('title')
|
||||
self.now_playing_episode_title = np_hash.get('episode_title')
|
||||
self.now_playing_season_number = np_hash.get('season_number')
|
||||
self.now_playing_episode_number = np_hash.get('episode_number')
|
||||
self.now_playing_summary = np_hash.get('summary')
|
||||
self.now_playing_image_url = np_hash.get('image_url')
|
||||
else:
|
||||
self.now_playing_title = None
|
||||
self.now_playing_episode_title = None
|
||||
self.now_playing_season_number = None
|
||||
self.now_playing_episode_number = None
|
||||
self.now_playing_summary = None
|
||||
self.now_playing_image_url = None
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the player."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def state(self):
|
||||
"""Return the state of the player."""
|
||||
if self.status == 'stopped':
|
||||
return STATE_IDLE
|
||||
|
||||
if self.status == 'paused':
|
||||
return STATE_PAUSED
|
||||
|
||||
if self.status == 'playing':
|
||||
return STATE_PLAYING
|
||||
|
||||
return None
|
||||
|
||||
def update(self):
|
||||
"""Retrieve latest state."""
|
||||
self.update_favorite_channels()
|
||||
self.update_state(self.client.status())
|
||||
|
||||
@property
|
||||
def source_list(self):
|
||||
"""List of favorite channels."""
|
||||
sources = [channel['name'] for channel in self.favorite_channels]
|
||||
return sources
|
||||
|
||||
@property
|
||||
def is_volume_muted(self):
|
||||
"""Boolean if volume is currently muted."""
|
||||
return self.muted
|
||||
|
||||
@property
|
||||
def media_content_id(self):
|
||||
"""Content ID of current playing channel."""
|
||||
return self.channel_number
|
||||
|
||||
@property
|
||||
def media_content_type(self):
|
||||
"""Content type of current playing media."""
|
||||
return MEDIA_TYPE_CHANNEL
|
||||
|
||||
@property
|
||||
def media_image_url(self):
|
||||
"""Image url of current playing media."""
|
||||
if self.now_playing_image_url:
|
||||
return self.now_playing_image_url
|
||||
elif self.channel_image_url:
|
||||
return self.channel_image_url
|
||||
|
||||
return 'https://getchannels.com/assets/img/icon-1024.png'
|
||||
|
||||
@property
|
||||
def media_title(self):
|
||||
"""Title of current playing media."""
|
||||
if self.state:
|
||||
return self.now_playing_title
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag of media commands that are supported."""
|
||||
return FEATURE_SUPPORT
|
||||
|
||||
def mute_volume(self, mute):
|
||||
"""Mute (true) or unmute (false) player."""
|
||||
if mute != self.muted:
|
||||
response = self.client.toggle_muted()
|
||||
self.update_state(response)
|
||||
|
||||
def media_stop(self):
|
||||
"""Send media_stop command to player."""
|
||||
self.status = "stopped"
|
||||
response = self.client.stop()
|
||||
self.update_state(response)
|
||||
|
||||
def media_play(self):
|
||||
"""Send media_play command to player."""
|
||||
response = self.client.resume()
|
||||
self.update_state(response)
|
||||
|
||||
def media_pause(self):
|
||||
"""Send media_pause command to player."""
|
||||
response = self.client.pause()
|
||||
self.update_state(response)
|
||||
|
||||
def media_next_track(self):
|
||||
"""Seek ahead."""
|
||||
response = self.client.skip_forward()
|
||||
self.update_state(response)
|
||||
|
||||
def media_previous_track(self):
|
||||
"""Seek back."""
|
||||
response = self.client.skip_backward()
|
||||
self.update_state(response)
|
||||
|
||||
def select_source(self, source):
|
||||
"""Select a channel to tune to."""
|
||||
for channel in self.favorite_channels:
|
||||
if channel["name"] == source:
|
||||
response = self.client.play_channel(channel["number"])
|
||||
self.update_state(response)
|
||||
break
|
||||
|
||||
def play_media(self, media_type, media_id, **kwargs):
|
||||
"""Send the play_media command to the player."""
|
||||
if media_type == MEDIA_TYPE_CHANNEL:
|
||||
response = self.client.play_channel(media_id)
|
||||
self.update_state(response)
|
||||
elif media_type in [MEDIA_TYPE_VIDEO, MEDIA_TYPE_EPISODE,
|
||||
MEDIA_TYPE_TVSHOW]:
|
||||
response = self.client.play_recording(media_id)
|
||||
self.update_state(response)
|
||||
|
||||
def seek_forward(self):
|
||||
"""Seek forward in the timeline."""
|
||||
response = self.client.seek_forward()
|
||||
self.update_state(response)
|
||||
|
||||
def seek_backward(self):
|
||||
"""Seek backward in the timeline."""
|
||||
response = self.client.seek_backward()
|
||||
self.update_state(response)
|
||||
|
||||
def seek_by(self, seconds):
|
||||
"""Seek backward in the timeline."""
|
||||
response = self.client.seek(seconds)
|
||||
self.update_state(response)
|
@ -37,11 +37,13 @@ YOUTUBE_PLAYER_SUPPORT = \
|
||||
MUSIC_PLAYER_SUPPORT = \
|
||||
SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \
|
||||
SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_CLEAR_PLAYLIST | \
|
||||
SUPPORT_PLAY | SUPPORT_SHUFFLE_SET
|
||||
SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | \
|
||||
SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK
|
||||
|
||||
NETFLIX_PLAYER_SUPPORT = \
|
||||
SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \
|
||||
SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET
|
||||
SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | \
|
||||
SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK
|
||||
|
||||
|
||||
class AbstractDemoPlayer(MediaPlayerDevice):
|
||||
@ -284,15 +286,7 @@ class DemoMusicPlayer(AbstractDemoPlayer):
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag media player features that are supported."""
|
||||
support = MUSIC_PLAYER_SUPPORT
|
||||
|
||||
if self._cur_track > 0:
|
||||
support |= SUPPORT_PREVIOUS_TRACK
|
||||
|
||||
if self._cur_track < len(self.tracks) - 1:
|
||||
support |= SUPPORT_NEXT_TRACK
|
||||
|
||||
return support
|
||||
return MUSIC_PLAYER_SUPPORT
|
||||
|
||||
def media_previous_track(self):
|
||||
"""Send previous track command."""
|
||||
@ -379,15 +373,7 @@ class DemoTVShowPlayer(AbstractDemoPlayer):
|
||||
@property
|
||||
def supported_features(self):
|
||||
"""Flag media player features that are supported."""
|
||||
support = NETFLIX_PLAYER_SUPPORT
|
||||
|
||||
if self._cur_episode > 1:
|
||||
support |= SUPPORT_PREVIOUS_TRACK
|
||||
|
||||
if self._cur_episode < self._episode_count:
|
||||
support |= SUPPORT_NEXT_TRACK
|
||||
|
||||
return support
|
||||
return NETFLIX_PLAYER_SUPPORT
|
||||
|
||||
def media_previous_track(self):
|
||||
"""Send previous track command."""
|
||||
|
@ -21,7 +21,7 @@ from homeassistant.core import callback
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
REQUIREMENTS = ['pyemby==1.4']
|
||||
REQUIREMENTS = ['pyemby==1.5']
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user