HA iOS support (#3752)

* Initial commit of the iOS component and platform

* Allow extra

* Add battery to identify, a new function to get devices, and load the upcoming sensor

* Add iOS sensor platform, currently for battery state & level

* Add discoverability for the iOS app

* Convert single quote to double quotes

* Load all required components and platforms when loading the iOS component for the best experience

* Unify quote style to double

* Change to hass_ios

* Update push URL, add support for logging based on status code, log rate limit updates

* Block iOS from coverage checks for now...
pull/3987/head
Robbie Trencheny 2016-10-21 23:20:15 -07:00 committed by Paulus Schoutsen
parent 6e5a3c0a94
commit ea91d24eb2
6 changed files with 529 additions and 2 deletions

View File

@ -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

View File

@ -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'),

View File

@ -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"})

View File

@ -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])

View File

@ -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]

View File

@ -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