""" Native Home Assistant iOS app component. For more details about this platform, please refer to the documentation at https://home-assistant.io/ecosystem/ios/ """ import asyncio import os import json import logging import voluptuous as vol from voluptuous.humanize import humanize_error from homeassistant.helpers import config_validation as cv from homeassistant.helpers import discovery from homeassistant.core import callback from homeassistant.components.http import HomeAssistantView from homeassistant.const import (HTTP_INTERNAL_SERVER_ERROR, HTTP_BAD_REQUEST) _LOGGER = logging.getLogger(__name__) DOMAIN = "ios" DEPENDENCIES = ["device_tracker", "http", "zeroconf"] 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] = {} # Notify needs to have discovery # notify_config = {"notify": {CONF_PLATFORM: "ios"}} # bootstrap.setup_component(hass, "notify", notify_config) discovery.load_platform(hass, "sensor", DOMAIN, {}, config) hass.http.register_view(iOSIdentifyDeviceView) app_config = config.get(DOMAIN, {}) hass.http.register_view(iOSPushConfigView(app_config.get(CONF_PUSH, {}))) 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, push_config): """Init the view.""" self.push_config = push_config @callback 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" @asyncio.coroutine def post(self, request): """Handle the POST request for device identification.""" try: req_data = yield from request.json() except ValueError: return self.json_message('Invalid JSON', HTTP_BAD_REQUEST) try: data = IDENTIFY_SCHEMA(req_data) 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"})