diff --git a/.coveragerc b/.coveragerc index 8916c8fd251..15fa27dd1c0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -31,6 +31,9 @@ omit = homeassistant/components/insteon_hub.py homeassistant/components/*/insteon_hub.py + homeassistant/components/ios.py + homeassistant/components/*/ios.py + homeassistant/components/isy994.py homeassistant/components/*/isy994.py diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index fa48be04e74..32e1bbd5f6a 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -14,15 +14,17 @@ import voluptuous as vol from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.helpers.discovery import load_platform, discover -REQUIREMENTS = ['netdisco==0.7.1'] +REQUIREMENTS = ['netdisco==0.7.2'] DOMAIN = 'discovery' SCAN_INTERVAL = 300 # seconds SERVICE_NETGEAR = 'netgear_router' SERVICE_WEMO = 'belkin_wemo' +SERVICE_HASS_IOS_APP = 'hass_ios' SERVICE_HANDLERS = { + SERVICE_HASS_IOS_APP: ('ios', None), SERVICE_NETGEAR: ('device_tracker', None), SERVICE_WEMO: ('wemo', None), 'philips_hue': ('light', 'hue'), diff --git a/homeassistant/components/ios.py b/homeassistant/components/ios.py new file mode 100644 index 00000000000..0793417fab3 --- /dev/null +++ b/homeassistant/components/ios.py @@ -0,0 +1,323 @@ +""" +Native Home Assistant iOS app component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/ios/ +""" +import os +import json +import logging + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant.helpers import config_validation as cv + +import homeassistant.loader as loader + +from homeassistant.helpers import discovery + +from homeassistant.components.http import HomeAssistantView + +from homeassistant.const import (HTTP_INTERNAL_SERVER_ERROR, + HTTP_BAD_REQUEST) + +from homeassistant.components.notify import DOMAIN as NotifyDomain + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "ios" + +DEPENDENCIES = ["http"] + +CONF_PUSH = "push" +CONF_PUSH_CATEGORIES = "categories" +CONF_PUSH_CATEGORIES_NAME = "name" +CONF_PUSH_CATEGORIES_IDENTIFIER = "identifier" +CONF_PUSH_CATEGORIES_ACTIONS = "actions" + +CONF_PUSH_ACTIONS_IDENTIFIER = "identifier" +CONF_PUSH_ACTIONS_TITLE = "title" +CONF_PUSH_ACTIONS_ACTIVATION_MODE = "activationMode" +CONF_PUSH_ACTIONS_AUTHENTICATION_REQUIRED = "authenticationRequired" +CONF_PUSH_ACTIONS_DESTRUCTIVE = "destructive" +CONF_PUSH_ACTIONS_BEHAVIOR = "behavior" +CONF_PUSH_ACTIONS_CONTEXT = "context" +CONF_PUSH_ACTIONS_TEXT_INPUT_BUTTON_TITLE = "textInputButtonTitle" +CONF_PUSH_ACTIONS_TEXT_INPUT_PLACEHOLDER = "textInputPlaceholder" + +ATTR_FOREGROUND = "foreground" +ATTR_BACKGROUND = "background" + +ACTIVATION_MODES = [ATTR_FOREGROUND, ATTR_BACKGROUND] + +ATTR_DEFAULT_BEHAVIOR = "default" +ATTR_TEXT_INPUT_BEHAVIOR = "textInput" + +BEHAVIORS = [ATTR_DEFAULT_BEHAVIOR, ATTR_TEXT_INPUT_BEHAVIOR] + +ATTR_DEVICE = "device" +ATTR_PUSH_TOKEN = "pushToken" +ATTR_APP = "app" +ATTR_PERMISSIONS = "permissions" +ATTR_PUSH_ID = "pushId" +ATTR_DEVICE_ID = "deviceId" +ATTR_PUSH_SOUNDS = "pushSounds" +ATTR_BATTERY = "battery" + +ATTR_DEVICE_NAME = "name" +ATTR_DEVICE_LOCALIZED_MODEL = "localizedModel" +ATTR_DEVICE_MODEL = "model" +ATTR_DEVICE_PERMANENT_ID = "permanentID" +ATTR_DEVICE_SYSTEM_VERSION = "systemVersion" +ATTR_DEVICE_TYPE = "type" +ATTR_DEVICE_SYSTEM_NAME = "systemName" + +ATTR_APP_BUNDLE_IDENTIFER = "bundleIdentifer" +ATTR_APP_BUILD_NUMBER = "buildNumber" +ATTR_APP_VERSION_NUMBER = "versionNumber" + +ATTR_LOCATION_PERMISSION = "location" +ATTR_NOTIFICATIONS_PERMISSION = "notifications" + +PERMISSIONS = [ATTR_LOCATION_PERMISSION, ATTR_NOTIFICATIONS_PERMISSION] + +ATTR_BATTERY_STATE = "state" +ATTR_BATTERY_LEVEL = "level" + +ATTR_BATTERY_STATE_UNPLUGGED = "Unplugged" +ATTR_BATTERY_STATE_CHARGING = "Charging" +ATTR_BATTERY_STATE_FULL = "Full" +ATTR_BATTERY_STATE_UNKNOWN = "Unknown" + +BATTERY_STATES = [ATTR_BATTERY_STATE_UNPLUGGED, ATTR_BATTERY_STATE_CHARGING, + ATTR_BATTERY_STATE_FULL, ATTR_BATTERY_STATE_UNKNOWN] + +ATTR_DEVICES = "devices" + +ACTION_SCHEMA = vol.Schema({ + vol.Required(CONF_PUSH_ACTIONS_IDENTIFIER): vol.Upper, + vol.Required(CONF_PUSH_ACTIONS_TITLE): cv.string, + vol.Optional(CONF_PUSH_ACTIONS_ACTIVATION_MODE, + default=ATTR_BACKGROUND): vol.In(ACTIVATION_MODES), + vol.Optional(CONF_PUSH_ACTIONS_AUTHENTICATION_REQUIRED, + default=False): cv.boolean, + vol.Optional(CONF_PUSH_ACTIONS_DESTRUCTIVE, + default=False): cv.boolean, + vol.Optional(CONF_PUSH_ACTIONS_BEHAVIOR, + default=ATTR_DEFAULT_BEHAVIOR): vol.In(BEHAVIORS), + vol.Optional(CONF_PUSH_ACTIONS_TEXT_INPUT_BUTTON_TITLE): cv.string, + vol.Optional(CONF_PUSH_ACTIONS_TEXT_INPUT_PLACEHOLDER): cv.string, +}, extra=vol.ALLOW_EXTRA) + +ACTION_SCHEMA_LIST = vol.All(cv.ensure_list, [ACTION_SCHEMA]) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: { + CONF_PUSH: { + CONF_PUSH_CATEGORIES: vol.All(cv.ensure_list, [{ + vol.Required(CONF_PUSH_CATEGORIES_NAME): cv.string, + vol.Required(CONF_PUSH_CATEGORIES_IDENTIFIER): vol.Upper, + vol.Required(CONF_PUSH_CATEGORIES_ACTIONS): ACTION_SCHEMA_LIST + }]) + } + } +}, extra=vol.ALLOW_EXTRA) + +IDENTIFY_DEVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_DEVICE_NAME): cv.string, + vol.Required(ATTR_DEVICE_LOCALIZED_MODEL): cv.string, + vol.Required(ATTR_DEVICE_MODEL): cv.string, + vol.Required(ATTR_DEVICE_PERMANENT_ID): cv.string, + vol.Required(ATTR_DEVICE_SYSTEM_VERSION): cv.string, + vol.Required(ATTR_DEVICE_TYPE): cv.string, + vol.Required(ATTR_DEVICE_SYSTEM_NAME): cv.string, +}, extra=vol.ALLOW_EXTRA) + +IDENTIFY_DEVICE_SCHEMA_CONTAINER = vol.All(dict, IDENTIFY_DEVICE_SCHEMA) + +IDENTIFY_APP_SCHEMA = vol.Schema({ + vol.Required(ATTR_APP_BUNDLE_IDENTIFER): cv.string, + vol.Required(ATTR_APP_BUILD_NUMBER): cv.positive_int, + vol.Required(ATTR_APP_VERSION_NUMBER): cv.positive_int +}, extra=vol.ALLOW_EXTRA) + +IDENTIFY_APP_SCHEMA_CONTAINER = vol.All(dict, IDENTIFY_APP_SCHEMA) + +IDENTIFY_BATTERY_SCHEMA = vol.Schema({ + vol.Required(ATTR_BATTERY_LEVEL): cv.positive_int, + vol.Required(ATTR_BATTERY_STATE): vol.In(BATTERY_STATES) +}, extra=vol.ALLOW_EXTRA) + +IDENTIFY_BATTERY_SCHEMA_CONTAINER = vol.All(dict, IDENTIFY_BATTERY_SCHEMA) + +IDENTIFY_SCHEMA = vol.Schema({ + vol.Required(ATTR_DEVICE): IDENTIFY_DEVICE_SCHEMA_CONTAINER, + vol.Required(ATTR_BATTERY): IDENTIFY_BATTERY_SCHEMA_CONTAINER, + vol.Required(ATTR_PUSH_TOKEN): cv.string, + vol.Required(ATTR_APP): IDENTIFY_APP_SCHEMA_CONTAINER, + vol.Required(ATTR_PERMISSIONS): vol.All(cv.ensure_list, + [vol.In(PERMISSIONS)]), + vol.Required(ATTR_PUSH_ID): cv.string, + vol.Required(ATTR_DEVICE_ID): cv.string, + vol.Optional(ATTR_PUSH_SOUNDS): list +}, extra=vol.ALLOW_EXTRA) + +CONFIGURATION_FILE = "ios.conf" + +CONFIG_FILE = {ATTR_DEVICES: {}} + +CONFIG_FILE_PATH = "" + + +def _load_config(filename): + """Load configuration.""" + if not os.path.isfile(filename): + return {} + + try: + with open(filename, "r") as fdesc: + inp = fdesc.read() + + # In case empty file + if not inp: + return {} + + return json.loads(inp) + except (IOError, ValueError) as error: + _LOGGER.error("Reading config file %s failed: %s", filename, error) + return None + + +def _save_config(filename, config): + """Save configuration.""" + try: + with open(filename, "w") as fdesc: + fdesc.write(json.dumps(config)) + except (IOError, TypeError) as error: + _LOGGER.error("Saving config file failed: %s", error) + return False + return True + + +def devices_with_push(): + """Return a dictionary of push enabled targets.""" + targets = {} + for device_name, device in CONFIG_FILE[ATTR_DEVICES].items(): + if device.get(ATTR_PUSH_ID) is not None: + targets[device_name] = device.get(ATTR_PUSH_ID) + return targets + + +def enabled_push_ids(): + """Return a list of push enabled target push IDs.""" + push_ids = list() + # pylint: disable=unused-variable + for device_name, device in CONFIG_FILE[ATTR_DEVICES].items(): + if device.get(ATTR_PUSH_ID) is not None: + push_ids.append(device.get(ATTR_PUSH_ID)) + return push_ids + + +def devices(): + """Return a dictionary of all identified devices.""" + return CONFIG_FILE[ATTR_DEVICES] + + +def device_name_for_push_id(push_id): + """Return the device name for the push ID.""" + for device_name, device in CONFIG_FILE[ATTR_DEVICES].items(): + if device.get(ATTR_PUSH_ID) is push_id: + return device_name + return None + + +def setup(hass, config): + """Setup the iOS component.""" + # pylint: disable=global-statement, import-error + global CONFIG_FILE + global CONFIG_FILE_PATH + + CONFIG_FILE_PATH = hass.config.path(CONFIGURATION_FILE) + + CONFIG_FILE = _load_config(CONFIG_FILE_PATH) + + if CONFIG_FILE == {}: + CONFIG_FILE[ATTR_DEVICES] = {} + + device_tracker = loader.get_component("device_tracker") + if device_tracker.DOMAIN not in hass.config.components: + device_tracker.setup(hass, {}) + # Need this to enable requirements checking in the app. + hass.config.components.append(device_tracker.DOMAIN) + + if "notify.ios" not in hass.config.components: + notify = loader.get_component("notify.ios") + notify.get_service(hass, {}) + # Need this to enable requirements checking in the app. + if NotifyDomain not in hass.config.components: + hass.config.components.append(NotifyDomain) + + zeroconf = loader.get_component("zeroconf") + if zeroconf.DOMAIN not in hass.config.components: + zeroconf.setup(hass, config) + # Need this to enable requirements checking in the app. + hass.config.components.append(zeroconf.DOMAIN) + + discovery.load_platform(hass, "sensor", DOMAIN, {}, config) + + hass.wsgi.register_view(iOSIdentifyDeviceView(hass)) + + if config.get(DOMAIN) is not None: + app_config = config[DOMAIN] + if app_config.get(CONF_PUSH) is not None: + push_config = app_config[CONF_PUSH] + hass.wsgi.register_view(iOSPushConfigView(hass, push_config)) + + return True + + +# pylint: disable=invalid-name +class iOSPushConfigView(HomeAssistantView): + """A view that provides the push categories configuration.""" + + url = "/api/ios/push" + name = "api:ios:push" + + def __init__(self, hass, push_config): + """Init the view.""" + super().__init__(hass) + self.push_config = push_config + + def get(self, request): + """Handle the GET request for the push configuration.""" + return self.json(self.push_config) + + +class iOSIdentifyDeviceView(HomeAssistantView): + """A view that accepts device identification requests.""" + + url = "/api/ios/identify" + name = "api:ios:identify" + + def __init__(self, hass): + """Init the view.""" + super().__init__(hass) + + def post(self, request): + """Handle the POST request for device identification.""" + try: + data = IDENTIFY_SCHEMA(request.json) + except vol.Invalid as ex: + return self.json_message(humanize_error(request.json, ex), + HTTP_BAD_REQUEST) + + name = data.get(ATTR_DEVICE_ID) + + CONFIG_FILE[ATTR_DEVICES][name] = data + + if not _save_config(CONFIG_FILE_PATH, CONFIG_FILE): + return self.json_message("Error saving device.", + HTTP_INTERNAL_SERVER_ERROR) + + return self.json({"status": "registered"}) diff --git a/homeassistant/components/notify/ios.py b/homeassistant/components/notify/ios.py new file mode 100644 index 00000000000..cb85ab8f753 --- /dev/null +++ b/homeassistant/components/notify/ios.py @@ -0,0 +1,87 @@ +""" +iOS push notification platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.ios/ +""" +import logging +from datetime import datetime, timezone +import requests + +from homeassistant.components import ios + +import homeassistant.util.dt as dt_util + +from homeassistant.components.notify import ( + ATTR_TARGET, ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_MESSAGE, + ATTR_DATA, BaseNotificationService) + +_LOGGER = logging.getLogger(__name__) + +PUSH_URL = "https://ios-push.home-assistant.io/push" + +DEPENDENCIES = ["ios"] + + +def get_service(hass, config): + """Get the iOS notification service.""" + if "notify.ios" not in hass.config.components: + # Need this to enable requirements checking in the app. + hass.config.components.append("notify.ios") + + return iOSNotificationService() + + +# pylint: disable=too-few-public-methods, too-many-arguments, invalid-name +class iOSNotificationService(BaseNotificationService): + """Implement the notification service for iOS.""" + + def __init__(self): + """Initialize the service.""" + + @property + def targets(self): + """Return a dictionary of registered targets.""" + return ios.devices_with_push() + + def send_message(self, message="", **kwargs): + """Send a message to the Lambda APNS gateway.""" + data = {ATTR_MESSAGE: message} + + if kwargs.get(ATTR_TITLE) is not None: + # Remove default title from notifications. + if kwargs.get(ATTR_TITLE) != ATTR_TITLE_DEFAULT: + data[ATTR_TITLE] = kwargs.get(ATTR_TITLE) + + targets = kwargs.get(ATTR_TARGET) + + if not targets: + targets = ios.enabled_push_ids() + + if kwargs.get(ATTR_DATA) is not None: + data[ATTR_DATA] = kwargs.get(ATTR_DATA) + + for target in targets: + data[ATTR_TARGET] = target + + req = requests.post(PUSH_URL, json=data, timeout=10) + + if req.status_code is not 201: + message = req.json()["message"] + if req.status_code is 429: + _LOGGER.warning(message) + elif req.status_code is 400 or 500: + _LOGGER.error(message) + + if req.status_code in (201, 429): + rate_limits = req.json()["rateLimits"] + resetsAt = dt_util.parse_datetime(rate_limits["resetsAt"]) + resetsAtTime = resetsAt - datetime.now(timezone.utc) + rate_limit_msg = ("iOS push notification rate limits for %s: " + "%d sent, %d allowed, %d errors, " + "resets in %s") + _LOGGER.info(rate_limit_msg, + ios.device_name_for_push_id(target), + rate_limits["successful"], + rate_limits["maximum"], rate_limits["errors"], + str(resetsAtTime).split(".")[0]) diff --git a/homeassistant/components/sensor/ios.py b/homeassistant/components/sensor/ios.py new file mode 100644 index 00000000000..c4c8f1eba69 --- /dev/null +++ b/homeassistant/components/sensor/ios.py @@ -0,0 +1,112 @@ +""" +Support for Home Assistant iOS app sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.ios/ +""" +from homeassistant.components import ios +from homeassistant.helpers.entity import Entity + +DEPENDENCIES = ["ios"] + +SENSOR_TYPES = { + "level": ["Battery Level", "%"], + "state": ["Battery State", None] +} + +DEFAULT_ICON = "mdi:battery" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the iOS sensor.""" + if discovery_info is None: + return + dev = list() + for device_name, device in ios.devices().items(): + for sensor_type in ("level", "state"): + dev.append(IOSSensor(sensor_type, device_name, device)) + + add_devices(dev) + + +class IOSSensor(Entity): + """Representation of an iOS sensor.""" + + def __init__(self, sensor_type, device_name, device): + """Initialize the sensor.""" + self._device_name = device_name + self._name = device_name + " " + SENSOR_TYPES[sensor_type][0] + self._device = device + self.type = sensor_type + self._state = None + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self.update() + + @property + def name(self): + """Return the name of the iOS sensor.""" + device_name = self._device[ios.ATTR_DEVICE][ios.ATTR_DEVICE_NAME] + return "{} {}".format(device_name, SENSOR_TYPES[self.type][0]) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unique_id(self): + """Return the unique ID of this sensor.""" + return "sensor_ios_battery_{}_{}".format(self.type, self._device_name) + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + device = self._device[ios.ATTR_DEVICE] + device_battery = self._device[ios.ATTR_BATTERY] + return { + "Battery State": device_battery[ios.ATTR_BATTERY_STATE], + "Battery Level": device_battery[ios.ATTR_BATTERY_LEVEL], + "Device Type": device[ios.ATTR_DEVICE_TYPE], + "Device Name": device[ios.ATTR_DEVICE_NAME], + "Device Version": device[ios.ATTR_DEVICE_SYSTEM_VERSION], + } + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + device_battery = self._device[ios.ATTR_BATTERY] + battery_state = device_battery[ios.ATTR_BATTERY_STATE] + battery_level = device_battery[ios.ATTR_BATTERY_LEVEL] + rounded_level = round(battery_level, -1) + returning_icon = DEFAULT_ICON + if battery_state == ios.ATTR_BATTERY_STATE_FULL: + returning_icon = DEFAULT_ICON + elif battery_state == ios.ATTR_BATTERY_STATE_CHARGING: + # Why is MDI missing 10, 50, 70? + if rounded_level in (20, 30, 40, 60, 80, 90, 100): + returning_icon = "{}-charging-{}".format(DEFAULT_ICON, + str(rounded_level)) + else: + returning_icon = "{}-charging".format(DEFAULT_ICON) + elif battery_state == ios.ATTR_BATTERY_STATE_UNPLUGGED: + if rounded_level < 10: + returning_icon = "{}-outline".format(DEFAULT_ICON) + elif battery_level == 100: + returning_icon = DEFAULT_ICON + else: + returning_icon = "{}-{}".format(DEFAULT_ICON, + str(rounded_level)) + elif battery_state == ios.ATTR_BATTERY_STATE_UNKNOWN: + returning_icon = "{}-unknown".format(DEFAULT_ICON) + + return returning_icon + + def update(self): + """Get the latest state of the sensor.""" + self._device = ios.devices().get(self._device_name) + self._state = self._device[ios.ATTR_BATTERY][self.type] diff --git a/requirements_all.txt b/requirements_all.txt index a3af772aeab..01a0acdeebd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -277,7 +277,7 @@ mficlient==0.3.0 miflora==0.1.9 # homeassistant.components.discovery -netdisco==0.7.1 +netdisco==0.7.2 # homeassistant.components.sensor.neurio_energy neurio==0.2.10