From 3dbe06d8689436bbec16c46102fba842dbfa1357 Mon Sep 17 00:00:00 2001 From: Quentame Date: Sat, 4 May 2019 21:54:50 +0200 Subject: [PATCH 1/5] Add iCloud devices battery status + services --- .coveragerc | 2 +- CODEOWNERS | 1 + homeassistant/components/icloud/__init__.py | 557 +++++++++++++++++- .../components/icloud/device_tracker.py | 516 ++-------------- homeassistant/components/icloud/manifest.json | 4 +- homeassistant/components/icloud/sensor.py | 93 +++ homeassistant/components/icloud/services.yaml | 57 ++ 7 files changed, 754 insertions(+), 476 deletions(-) create mode 100644 homeassistant/components/icloud/sensor.py diff --git a/.coveragerc b/.coveragerc index 16c87ea4d47187..4e15a8b7bde653 100644 --- a/.coveragerc +++ b/.coveragerc @@ -286,7 +286,7 @@ omit = homeassistant/components/hydrawise/* homeassistant/components/hyperion/light.py homeassistant/components/ialarm/alarm_control_panel.py - homeassistant/components/icloud/device_tracker.py + homeassistant/components/icloud/* homeassistant/components/idteck_prox/* homeassistant/components/ifttt/* homeassistant/components/iglo/light.py diff --git a/CODEOWNERS b/CODEOWNERS index 91bf80f9d339cb..aedf8ea02feb72 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -128,6 +128,7 @@ homeassistant/components/http/* @home-assistant/core homeassistant/components/huawei_lte/* @scop homeassistant/components/huawei_router/* @abmantis homeassistant/components/hue/* @balloob +homeassistant/components/icloud/* @Quentame homeassistant/components/ign_sismologia/* @exxamalte homeassistant/components/incomfort/* @zxdavb homeassistant/components/influxdb/* @fabaff diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 1169104c99d9a3..05dd62c4e2dbac 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -1 +1,556 @@ -"""The icloud component.""" +"""The iCloud component.""" +import logging +import os +from datetime import timedelta + +import voluptuous as vol +from pyicloud import PyiCloudService +from pyicloud.exceptions import (PyiCloudException, + PyiCloudFailedLoginException, + PyiCloudNoDevicesException) +from pyicloud.services.findmyiphone import AppleDevice + +import homeassistant.helpers.config_validation as cv +from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME +from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.util import slugify +from homeassistant.util.dt import utcnow + +DOMAIN = 'icloud' +DATA_ICLOUD = 'icloud_data' + +ATTRIBUTION = "Data provided by Apple iCloud" + +SIGNAL_UPDATE_ICLOUD = 'icloud_update' + +# iCloud dev tracker comp +CONF_ACCOUNTNAME = 'account_name' +CONF_MAX_INTERVAL = 'max_interval' +CONF_GPS_ACCURACY_THRESHOLD = 'gps_accuracy_threshold' + +# entity attributes +ATTR_ACCOUNTNAME = 'account_name' +ATTR_BATTERY = 'battery' +ATTR_BATTERYSTATUS = 'battery_status' +ATTR_DEVICENAME = 'device_name' +ATTR_DEVICESTATUS = 'device_status' +ATTR_LOWPOWERMODE = 'low_power_mode' +ATTR_OWNERNAME = 'owner_fullname' + +DEVICE_STATUS_SET = ['features', 'maxMsgChar', 'darkWake', 'fmlyShare', + 'deviceStatus', 'remoteLock', 'activationLocked', + 'deviceClass', 'id', 'deviceModel', 'rawDeviceModel', + 'passcodeLength', 'canWipeAfterLock', 'trackingInfo', + 'location', 'msg', 'batteryLevel', 'remoteWipe', + 'thisDevice', 'snd', 'prsId', 'wipeInProgress', + 'lowPowerMode', 'lostModeEnabled', 'isLocating', + 'lostModeCapable', 'mesg', 'name', 'batteryStatus', + 'lockedTimestamp', 'lostTimestamp', 'locationCapable', + 'deviceDisplayName', 'lostDevice', 'deviceColor', + 'wipedTimestamp', 'modelDisplayName', 'locationEnabled', + 'isMac', 'locFoundEnabled'] + +DEVICE_STATUS_CODES = { + '200': 'online', + '201': 'offline', + '203': 'pending', + '204': 'unregistered', +} + +_CONFIGURING = {} + +_LOGGER = logging.getLogger(__name__) + +SERVICE_ICLOUD_PLAY_SOUND = 'play_sound' +SERVICE_ICLOUD_DISPLAY_MESSAGE = 'display_message' +SERVICE_ICLOUD_LOST_DEVICE = 'lost_device' +SERVICE_ICLOUD_UPDATE = 'update' +SERVICE_ICLOUD_RESET = 'reset' +SERVICE_ATTR_LOST_DEVICE_MESSAGE = 'message' +SERVICE_ATTR_LOST_DEVICE_NUMBER = 'number' +SERVICE_ATTR_LOST_DEVICE_SOUND = 'sound' + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ACCOUNTNAME): cv.string, +}) + +SERVICE_SCHEMA_PLAY_SOUND = vol.Schema({ + vol.Required(ATTR_ACCOUNTNAME): cv.string, + vol.Required(ATTR_DEVICENAME): cv.string, +}) + +SERVICE_SCHEMA_DISPLAY_MESSAGE = vol.Schema({ + vol.Required(ATTR_ACCOUNTNAME): cv.string, + vol.Required(ATTR_DEVICENAME): cv.string, + vol.Required(SERVICE_ATTR_LOST_DEVICE_MESSAGE): cv.string, + vol.Optional(SERVICE_ATTR_LOST_DEVICE_SOUND): cv.boolean, +}) + +SERVICE_SCHEMA_LOST_DEVICE = vol.Schema({ + vol.Required(ATTR_ACCOUNTNAME): cv.string, + vol.Required(ATTR_DEVICENAME): cv.string, + vol.Required(SERVICE_ATTR_LOST_DEVICE_NUMBER): cv.string, + vol.Required(SERVICE_ATTR_LOST_DEVICE_MESSAGE): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(ATTR_ACCOUNTNAME): cv.slugify, + vol.Optional(CONF_MAX_INTERVAL, default=60): cv.positive_int, + vol.Optional(CONF_GPS_ACCURACY_THRESHOLD, default=500): cv.positive_int + })]) +}, extra=vol.ALLOW_EXTRA) + +ICLOUD_COMPONENTS = [ + 'sensor', 'device_tracker' +] + + +def setup(hass, config): + """Set up the iCloud component.""" + + async def play_sound(service): + """Play sound on the device.""" + accountname = service.data.get(ATTR_ACCOUNTNAME) + accountname = slugify(accountname.partition('@')[0]) + devicename = service.data.get(ATTR_DEVICENAME) + devicename = slugify(devicename.replace(' ', '', 99)) + + hass.data[DATA_ICLOUD][accountname].devices[devicename].play_sound() + hass.services.register(DOMAIN, SERVICE_ICLOUD_PLAY_SOUND, play_sound, + schema=SERVICE_SCHEMA_PLAY_SOUND) + + async def display_message(service): + """Display a message on the device.""" + accountname = service.data.get(ATTR_ACCOUNTNAME) + accountname = slugify(accountname.partition('@')[0]) + devicename = service.data.get(ATTR_DEVICENAME) + devicename = slugify(devicename.replace(' ', '', 99)) + message = service.data.get(SERVICE_ATTR_LOST_DEVICE_MESSAGE) + sound = service.data.get(SERVICE_ATTR_LOST_DEVICE_SOUND, False) + + hass.data[DATA_ICLOUD][accountname].devices[devicename].display_message( + message, + sound) + hass.services.register(DOMAIN, SERVICE_ICLOUD_DISPLAY_MESSAGE, + display_message, + schema=SERVICE_SCHEMA_DISPLAY_MESSAGE) + + async def lost_device(service): + """Make the device in lost state.""" + accountname = service.data.get(ATTR_ACCOUNTNAME) + accountname = slugify(accountname.partition('@')[0]) + devicename = service.data.get(ATTR_DEVICENAME) + devicename = slugify(devicename.replace(' ', '', 99)) + number = service.data.get(SERVICE_ATTR_LOST_DEVICE_NUMBER) + message = service.data.get(SERVICE_ATTR_LOST_DEVICE_MESSAGE) + + hass.data[DATA_ICLOUD][accountname].devices[devicename].lost_device( + number, + message) + hass.services.register(DOMAIN, SERVICE_ICLOUD_LOST_DEVICE, lost_device, + schema=SERVICE_SCHEMA_LOST_DEVICE) + + async def update(service): + """Call the update function of an iCloud account.""" + accountname = service.data.get(ATTR_ACCOUNTNAME) + + if accountname is None: + for accountname, account in hass.data[DATA_ICLOUD].items(): + account.keep_alive(utcnow()) + else: + accountname = slugify(accountname.partition('@')[0]) + hass.data[DATA_ICLOUD][accountname].keep_alive(utcnow()) + hass.services.register(DOMAIN, SERVICE_ICLOUD_UPDATE, update, + schema=SERVICE_SCHEMA) + + async def reset_account(service): + """Reset an iCloud account.""" + accountname = service.data.get(ATTR_ACCOUNTNAME) + + if accountname is None: + for accountname, account in hass.data[DATA_ICLOUD].items(): + account.reset_account() + else: + accountname = slugify(accountname.partition('@')[0]) + hass.data[DATA_ICLOUD][accountname].reset_account() + + hass.services.register(DOMAIN, SERVICE_ICLOUD_RESET, + reset_account, schema=SERVICE_SCHEMA) + + + def setup_icloud(icloud_config): + """Set up an iCloud account.""" + _LOGGER.debug("Logging into iCloud...") + + username = icloud_config.get(CONF_USERNAME) + password = icloud_config.get(CONF_PASSWORD) + account_name = icloud_config.get(CONF_ACCOUNTNAME, + slugify(username.partition('@')[0])) + max_interval = icloud_config.get(CONF_MAX_INTERVAL) + gps_accuracy_threshold = icloud_config.get(CONF_GPS_ACCURACY_THRESHOLD) + + account = IcloudAccount(hass, username, password, account_name, + max_interval, gps_accuracy_threshold) + + if account.api is not None: + hass.data[DATA_ICLOUD][account.name] = account + + else: + _LOGGER.error("No iCloud data added for account=%s", account_name) + return False + + for component in ICLOUD_COMPONENTS: + # if component != 'device_tracker': + load_platform(hass, component, DOMAIN, {}, icloud_config) + + hass.data[DATA_ICLOUD] = {} + for icloud_config in config[DOMAIN]: + setup_icloud(icloud_config) + + return True + + +class IcloudAccount(): + """Representation of an iCloud account.""" + + def __init__(self, hass, username, password, accountname, max_interval, + gps_accuracy_threshold): + """Initialize an iCloud account.""" + self._hass = hass + self.username = username + self.__password = password + self._accountname = accountname + self._max_interval = max_interval + self._gps_accuracy_threshold = gps_accuracy_threshold + + self.api = None + self.account_owner_fullname = None + self.family_members_fullname = {} + self.devices = {} + + self.__trusted_device = None + self.__verification_code = None + + self.reset_account() + + def reset_account(self): + """Reset an iCloud account.""" + + icloud_dir = self._hass.config.path('icloud') + if not os.path.exists(icloud_dir): + os.makedirs(icloud_dir) + + try: + self.api = PyiCloudService( + self.username, self.__password, + cookie_directory=icloud_dir, + verify=True) + except PyiCloudFailedLoginException as error: + self.api = None + _LOGGER.error("Error logging into iCloud Service: %s", error) + return + + try: + # Gets device owners infos + user_info = self.api.devices.response['userInfo'] + self.account_owner_fullname = user_info['firstName'] + ' ' + user_info['lastName'] + + self.family_members_fullname = {} + for prs_id, member in user_info['membersInfo'].items(): + self.family_members_fullname[prs_id] = member['firstName'] + ' ' + member['lastName'] + + self.devices = {} + self.update_devices() + + except PyiCloudNoDevicesException: + _LOGGER.error('No iCloud Devices found!') + + def update_devices(self): + """Update iCloud devices""" + if self.api is None: + return + + try: + # Gets devices infos + for device in self.api.devices: + status = device.status(DEVICE_STATUS_SET) + devicename = slugify(status['name'].replace(' ', '', 99)) + + if self.devices.get(devicename, None) is not None: + # Seen device -> updating + _LOGGER.info('Updating iCloud device: %s', devicename) + self.devices[devicename].update(status) + else: + # New device, should be unique + if devicename in self.devices: + _LOGGER.error('Multiple devices with name: %s', devicename) + continue + + _LOGGER.debug('Adding iCloud device: %s', devicename) + self.devices[devicename] = IcloudDevice(self, device) + + except PyiCloudNoDevicesException: + _LOGGER.error("No iCloud Devices found") + + async_dispatcher_send(self._hass, SIGNAL_UPDATE_ICLOUD) + # compute interval HERE + async_track_point_in_utc_time(self._hass, self.keep_alive, utcnow() + timedelta(seconds=30)) + + def keep_alive(self, now): + """Keep the API alive.""" + if self.api is None: + self.reset_account() + + if self.api is None: + return + + if self.api.requires_2fa: + from pyicloud.exceptions import PyiCloudException + try: + if self.__trusted_device is None: + self.icloud_need_trusted_device() + return + + if self.__verification_code is None: + self.icloud_need_verification_code() + return + + self.api.authenticate() + if self.api.requires_2fa: + raise Exception('Unknown failure') + + self.__trusted_device = None + self.__verification_code = None + except PyiCloudException as error: + _LOGGER.error("Error setting up 2FA: %s", error) + else: + self.api.authenticate() + + self.update_devices() + + def icloud_trusted_device_callback(self, callback_data): + """Handle chosen trusted devices.""" + self.__trusted_device = int(callback_data.get('trusted_device')) + self.__trusted_device = self.api.trusted_devices[self.__trusted_device] + + if not self.api.send_verification_code(self.__trusted_device): + _LOGGER.error("Failed to send verification code") + self.__trusted_device = None + return + + if self._accountname in _CONFIGURING: + request_id = _CONFIGURING.pop(self._accountname) + configurator = self._hass.components.configurator + configurator.request_done(request_id) + + # Trigger the next step immediately + self.icloud_need_verification_code() + + def icloud_need_trusted_device(self): + """We need a trusted device.""" + configurator = self._hass.components.configurator + if self._accountname in _CONFIGURING: + return + + devicesstring = '' + devices = self.api.trusted_devices + for i, device in enumerate(devices): + devicename = device.get( + 'deviceName', 'SMS to %s' % device.get('phoneNumber')) + devicesstring += "{}: {};".format(i, devicename) + + _CONFIGURING[self._accountname] = configurator.request_config( + 'iCloud {}'.format(self._accountname), + self.icloud_trusted_device_callback, + description=( + 'Please choose your trusted device by entering' + ' the index from this list: ' + devicesstring), + entity_picture="/static/images/config_icloud.png", + submit_caption='Confirm', + fields=[{'id': 'trusted_device', 'name': 'Trusted Device'}] + ) + + def icloud_verification_callback(self, callback_data): + """Handle the chosen trusted device.""" + self.__verification_code = callback_data.get('code') + + try: + if not self.api.validate_verification_code( + self.__trusted_device, self.__verification_code): + raise PyiCloudException('Unknown failure') + except PyiCloudException as error: + # Reset to the initial 2FA state to allow the user to retry + _LOGGER.error("Failed to verify verification code: %s", error) + self.__trusted_device = None + self.__verification_code = None + + # Trigger the next step immediately + self.icloud_need_trusted_device() + + if self._accountname in _CONFIGURING: + request_id = _CONFIGURING.pop(self._accountname) + configurator = self._hass.components.configurator + configurator.request_done(request_id) + + def icloud_need_verification_code(self): + """Return the verification code.""" + configurator = self._hass.components.configurator + if self._accountname in _CONFIGURING: + return + + _CONFIGURING[self._accountname] = configurator.request_config( + 'iCloud {}'.format(self._accountname), + self.icloud_verification_callback, + description=('Please enter the validation code:'), + entity_picture="/static/images/config_icloud.png", + submit_caption='Confirm', + fields=[{'id': 'code', 'name': 'code'}] + ) + + @property + def name(self): + """Return the account name.""" + return self._accountname + + +class IcloudDevice(): + """Representation of a iCloud device.""" + + def __init__(self, account: IcloudAccount, device: AppleDevice): + """Initialize the iCloud device.""" + self.__account = account + self._accountname = account.name + + self._device = device + self.__status = device.status(DEVICE_STATUS_SET) + _LOGGER.debug('Device Status is %s', self.__status) + + self._name = self.__status['name'] + self._dev_id = slugify(self._name.replace(' ', '', 99)) # devicename + self._device_class = self.__status['deviceClass'] + self._device_name = self.__status['deviceDisplayName'] + if self.__status['prsId']: + self._owner_fullname = account.family_members_fullname[self.__status['prsId']] + else: + self._owner_fullname = account.account_owner_fullname + + self._battery_level = None + self._battery_status = None + self._low_power_mode = None + self._location = None + + self._seen = False + + self.update(self.__status) + + def update(self, status): + """Update the iCloud device.""" + self.__status = status + + self._device_status = DEVICE_STATUS_CODES.get(self.__status['deviceStatus'], 'error') + + self._attrs = { + ATTR_ACCOUNTNAME: self._accountname, + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_DEVICENAME: self._device_name, + ATTR_DEVICESTATUS: self._device_status, + ATTR_OWNERNAME: self._owner_fullname + } + + if self.__status['batteryStatus'] != 'Unknown': + self._battery_level = round(self.__status.get('batteryLevel', 0) + * 100) + self._battery_status = self.__status['batteryStatus'] + self._low_power_mode = self.__status['lowPowerMode'] + + self._attrs[ATTR_BATTERY] = self._battery_level + self._attrs[ATTR_BATTERYSTATUS] = self._battery_status + self._attrs[ATTR_LOWPOWERMODE] = self._low_power_mode + + if self.__status['location'] and self.__status['location']['latitude']: + location = self.__status['location'] + self._location = location + + def play_sound(self): + """Play sound on the device.""" + if self.__account.api is None: + return + + self.__account.api.authenticate() + _LOGGER.info("Playing Lost iPhone sound for %s", self.name) + self.device.play_sound() + + def display_message(self, message: str, sound: bool = False): + """Display a message on the device.""" + if self.__account.api is None: + return + + self.__account.api.authenticate() + _LOGGER.info("Displaying message for %s", self.name) + self.device.display_message('Subject not working', message, sound) + + def lost_device(self, number: str, message: str): + """Make the device in lost state.""" + if self.__account.api is None: + return + + self.__account.api.authenticate() + if self.__status['lostModeCapable']: + _LOGGER.info("Make device lost for %s", self.name) + self.device.lost_device(number, message, None) + else: + _LOGGER.error("Cannot make device lost for %s", self.name) + + @property + def device(self) -> AppleDevice: + """Return the Apple device.""" + return self._device + + @property + def dev_id(self): + """Return the device ID.""" + return self._dev_id + + @property + def device_class(self): + """Return the Apple device class.""" + return self._device_class + + @property + def name(self): + """Return the Apple device name.""" + return self._name + + @property + def battery_level(self): + """Return the Apple device battery level.""" + return self._battery_level + + @property + def battery_status(self): + """Return the Apple device battery status.""" + return self._battery_status + + @property + def location(self): + """Return the Apple device location.""" + return self._location + + @property + def attributes(self): + """Return the attributes.""" + return self._attrs + + @property + def seen(self): + """Return the seen value.""" + return self._seen + + def set_seen(self, seen): + """Set the seen value""" + self._seen = seen diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 89de6e57f6ead1..39ae08718a01fb 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -1,491 +1,61 @@ -"""Platform that supports scanning iCloud.""" +"""Support for tracking for iCloud devices.""" import logging -import random -import os -import voluptuous as vol +from homeassistant.components.device_tracker.const import ENTITY_ID_FORMAT +from homeassistant.helpers.dispatcher import dispatcher_connect -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -from homeassistant.components.device_tracker import PLATFORM_SCHEMA -from homeassistant.components.device_tracker.const import ( - DOMAIN, ATTR_ATTRIBUTES, ENTITY_ID_FORMAT) -from homeassistant.components.device_tracker.legacy import DeviceScanner -from homeassistant.components.zone import async_active_zone -from homeassistant.helpers.event import track_utc_time_change -import homeassistant.helpers.config_validation as cv -from homeassistant.util import slugify -import homeassistant.util.dt as dt_util -from homeassistant.util.location import distance -from homeassistant.util.async_ import run_callback_threadsafe +from . import DATA_ICLOUD, SIGNAL_UPDATE_ICLOUD, IcloudDevice _LOGGER = logging.getLogger(__name__) -CONF_ACCOUNTNAME = 'account_name' -CONF_MAX_INTERVAL = 'max_interval' -CONF_GPS_ACCURACY_THRESHOLD = 'gps_accuracy_threshold' -# entity attributes -ATTR_ACCOUNTNAME = 'account_name' -ATTR_INTERVAL = 'interval' -ATTR_DEVICENAME = 'device_name' -ATTR_BATTERY = 'battery' -ATTR_DISTANCE = 'distance' -ATTR_DEVICESTATUS = 'device_status' -ATTR_LOWPOWERMODE = 'low_power_mode' -ATTR_BATTERYSTATUS = 'battery_status' - -ICLOUDTRACKERS = {} - -_CONFIGURING = {} - -DEVICESTATUSSET = ['features', 'maxMsgChar', 'darkWake', 'fmlyShare', - 'deviceStatus', 'remoteLock', 'activationLocked', - 'deviceClass', 'id', 'deviceModel', 'rawDeviceModel', - 'passcodeLength', 'canWipeAfterLock', 'trackingInfo', - 'location', 'msg', 'batteryLevel', 'remoteWipe', - 'thisDevice', 'snd', 'prsId', 'wipeInProgress', - 'lowPowerMode', 'lostModeEnabled', 'isLocating', - 'lostModeCapable', 'mesg', 'name', 'batteryStatus', - 'lockedTimestamp', 'lostTimestamp', 'locationCapable', - 'deviceDisplayName', 'lostDevice', 'deviceColor', - 'wipedTimestamp', 'modelDisplayName', 'locationEnabled', - 'isMac', 'locFoundEnabled'] - -DEVICESTATUSCODES = { - '200': 'online', - '201': 'offline', - '203': 'pending', - '204': 'unregistered', -} - -SERVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ACCOUNTNAME): vol.All(cv.ensure_list, [cv.slugify]), - vol.Optional(ATTR_DEVICENAME): cv.slugify, - vol.Optional(ATTR_INTERVAL): cv.positive_int -}) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(ATTR_ACCOUNTNAME): cv.slugify, - vol.Optional(CONF_MAX_INTERVAL, default=30): cv.positive_int, - vol.Optional(CONF_GPS_ACCURACY_THRESHOLD, default=1000): cv.positive_int -}) - - -def setup_scanner(hass, config: dict, see, discovery_info=None): - """Set up the iCloud Scanner.""" - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - account = config.get(CONF_ACCOUNTNAME, slugify(username.partition('@')[0])) - max_interval = config.get(CONF_MAX_INTERVAL) - gps_accuracy_threshold = config.get(CONF_GPS_ACCURACY_THRESHOLD) - - icloudaccount = Icloud(hass, username, password, account, max_interval, - gps_accuracy_threshold, see) - - if icloudaccount.api is not None: - ICLOUDTRACKERS[account] = icloudaccount - - else: - _LOGGER.error("No ICLOUDTRACKERS added") +def setup_scanner(hass, config, see, discovery_info=None): + """Set up the iCloud device tracker.""" + if discovery_info is None: return False - def lost_iphone(call): - """Call the lost iPhone function if the device is found.""" - accounts = call.data.get(ATTR_ACCOUNTNAME, ICLOUDTRACKERS) - devicename = call.data.get(ATTR_DEVICENAME) - for account in accounts: - if account in ICLOUDTRACKERS: - ICLOUDTRACKERS[account].lost_iphone(devicename) - - hass.services.register(DOMAIN, 'icloud_lost_iphone', lost_iphone, - schema=SERVICE_SCHEMA) - - def update_icloud(call): - """Call the update function of an iCloud account.""" - accounts = call.data.get(ATTR_ACCOUNTNAME, ICLOUDTRACKERS) - devicename = call.data.get(ATTR_DEVICENAME) - for account in accounts: - if account in ICLOUDTRACKERS: - ICLOUDTRACKERS[account].update_icloud(devicename) - - hass.services.register(DOMAIN, 'icloud_update', update_icloud, - schema=SERVICE_SCHEMA) - - def reset_account_icloud(call): - """Reset an iCloud account.""" - accounts = call.data.get(ATTR_ACCOUNTNAME, ICLOUDTRACKERS) - for account in accounts: - if account in ICLOUDTRACKERS: - ICLOUDTRACKERS[account].reset_account_icloud() - - hass.services.register(DOMAIN, 'icloud_reset_account', - reset_account_icloud, schema=SERVICE_SCHEMA) - - def setinterval(call): - """Call the update function of an iCloud account.""" - accounts = call.data.get(ATTR_ACCOUNTNAME, ICLOUDTRACKERS) - interval = call.data.get(ATTR_INTERVAL) - devicename = call.data.get(ATTR_DEVICENAME) - for account in accounts: - if account in ICLOUDTRACKERS: - ICLOUDTRACKERS[account].setinterval(interval, devicename) - - hass.services.register(DOMAIN, 'icloud_set_interval', setinterval, - schema=SERVICE_SCHEMA) + def see_device(): + """Handle the reporting of the iCloud device position.""" + for accountname, icloud_account in hass.data[DATA_ICLOUD].items(): + for devicename, device in icloud_account.devices.items(): - # Tells the bootstrapper that the component was successfully initialized - return True - - -class Icloud(DeviceScanner): - """Representation of an iCloud account.""" - - def __init__(self, hass, username, password, name, max_interval, - gps_accuracy_threshold, see): - """Initialize an iCloud account.""" - self.hass = hass - self.username = username - self.password = password - self.api = None - self.accountname = name - self.devices = {} - self.seen_devices = {} - self._overridestates = {} - self._intervals = {} - self._max_interval = max_interval - self._gps_accuracy_threshold = gps_accuracy_threshold - self.see = see - - self._trusted_device = None - self._verification_code = None - - self._attrs = {} - self._attrs[ATTR_ACCOUNTNAME] = name - - self.reset_account_icloud() - - randomseconds = random.randint(10, 59) - track_utc_time_change( - self.hass, self.keep_alive, second=randomseconds) - - def reset_account_icloud(self): - """Reset an iCloud account.""" - from pyicloud import PyiCloudService - from pyicloud.exceptions import ( - PyiCloudFailedLoginException, PyiCloudNoDevicesException) - - icloud_dir = self.hass.config.path('icloud') - if not os.path.exists(icloud_dir): - os.makedirs(icloud_dir) - - try: - self.api = PyiCloudService( - self.username, self.password, - cookie_directory=icloud_dir, - verify=True) - except PyiCloudFailedLoginException as error: - self.api = None - _LOGGER.error("Error logging into iCloud Service: %s", error) - return - - try: - self.devices = {} - self._overridestates = {} - self._intervals = {} - for device in self.api.devices: - status = device.status(DEVICESTATUSSET) - _LOGGER.debug('Device Status is %s', status) - devicename = slugify(status['name'].replace(' ', '', 99)) - _LOGGER.info('Adding icloud device: %s', devicename) - if devicename in self.devices: - _LOGGER.error('Multiple devices with name: %s', devicename) + # An entity will not be created by see() when track=false in + # 'known_devices.yaml', but we need to see() it at least once + entity = hass.states.get(ENTITY_ID_FORMAT.format(devicename)) + if entity is None and device.seen: continue - self.devices[devicename] = device - self._intervals[devicename] = 1 - self._overridestates[devicename] = None - except PyiCloudNoDevicesException: - _LOGGER.error('No iCloud Devices found!') - - def icloud_trusted_device_callback(self, callback_data): - """Handle chosen trusted devices.""" - self._trusted_device = int(callback_data.get('trusted_device')) - self._trusted_device = self.api.trusted_devices[self._trusted_device] - - if not self.api.send_verification_code(self._trusted_device): - _LOGGER.error("Failed to send verification code") - self._trusted_device = None - return - - if self.accountname in _CONFIGURING: - request_id = _CONFIGURING.pop(self.accountname) - configurator = self.hass.components.configurator - configurator.request_done(request_id) - - # Trigger the next step immediately - self.icloud_need_verification_code() - - def icloud_need_trusted_device(self): - """We need a trusted device.""" - configurator = self.hass.components.configurator - if self.accountname in _CONFIGURING: - return - devicesstring = '' - devices = self.api.trusted_devices - for i, device in enumerate(devices): - devicename = device.get( - 'deviceName', 'SMS to %s' % device.get('phoneNumber')) - devicesstring += "{}: {};".format(i, devicename) - - _CONFIGURING[self.accountname] = configurator.request_config( - 'iCloud {}'.format(self.accountname), - self.icloud_trusted_device_callback, - description=( - 'Please choose your trusted device by entering' - ' the index from this list: ' + devicesstring), - entity_picture="/static/images/config_icloud.png", - submit_caption='Confirm', - fields=[{'id': 'trusted_device', 'name': 'Trusted Device'}] - ) - - def icloud_verification_callback(self, callback_data): - """Handle the chosen trusted device.""" - from pyicloud.exceptions import PyiCloudException - self._verification_code = callback_data.get('code') - - try: - if not self.api.validate_verification_code( - self._trusted_device, self._verification_code): - raise PyiCloudException('Unknown failure') - except PyiCloudException as error: - # Reset to the initial 2FA state to allow the user to retry - _LOGGER.error("Failed to verify verification code: %s", error) - self._trusted_device = None - self._verification_code = None - - # Trigger the next step immediately - self.icloud_need_trusted_device() - - if self.accountname in _CONFIGURING: - request_id = _CONFIGURING.pop(self.accountname) - configurator = self.hass.components.configurator - configurator.request_done(request_id) - - def icloud_need_verification_code(self): - """Return the verification code.""" - configurator = self.hass.components.configurator - if self.accountname in _CONFIGURING: - return - - _CONFIGURING[self.accountname] = configurator.request_config( - 'iCloud {}'.format(self.accountname), - self.icloud_verification_callback, - description=('Please enter the validation code:'), - entity_picture="/static/images/config_icloud.png", - submit_caption='Confirm', - fields=[{'id': 'code', 'name': 'code'}] - ) - - def keep_alive(self, now): - """Keep the API alive.""" - if self.api is None: - self.reset_account_icloud() - - if self.api is None: - return - - if self.api.requires_2fa: - from pyicloud.exceptions import PyiCloudException - try: - if self._trusted_device is None: - self.icloud_need_trusted_device() - return - - if self._verification_code is None: - self.icloud_need_verification_code() - return - - self.api.authenticate() - if self.api.requires_2fa: - raise Exception('Unknown failure') - - self._trusted_device = None - self._verification_code = None - except PyiCloudException as error: - _LOGGER.error("Error setting up 2FA: %s", error) - else: - self.api.authenticate() - - currentminutes = dt_util.now().hour * 60 + dt_util.now().minute - try: - for devicename in self.devices: - interval = self._intervals.get(devicename, 1) - if ((currentminutes % interval == 0) or - (interval > 10 and - currentminutes % interval in [2, 4])): - self.update_device(devicename) - except ValueError: - _LOGGER.debug("iCloud API returned an error") - - def determine_interval(self, devicename, latitude, longitude, battery): - """Calculate new interval.""" - currentzone = run_callback_threadsafe( - self.hass.loop, - async_active_zone, self.hass, latitude, longitude - ).result() - - if ((currentzone is not None and - currentzone == self._overridestates.get(devicename)) or - (currentzone is None and - 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] = self._max_interval - return - - if mindistance is None: - return - - # 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 - 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.""" - from pyicloud.exceptions import PyiCloudNoDevicesException - - # An entity will not be created by see() when track=false in - # 'known_devices.yaml', but we need to see() it at least once - entity = self.hass.states.get(ENTITY_ID_FORMAT.format(devicename)) - if entity is None and devicename in self.seen_devices: - return - attrs = {} - kwargs = {} - - if self.api is None: - return - - try: - for device in self.api.devices: - if str(device) != str(self.devices[devicename]): + if device.location is None: + _LOGGER.debug("No position found for device %s", + devicename) continue - status = device.status(DEVICESTATUSSET) - _LOGGER.debug('Device Status is %s', status) - dev_id = status['name'].replace(' ', '', 99) - dev_id = slugify(dev_id) - attrs[ATTR_DEVICESTATUS] = DEVICESTATUSCODES.get( - status['deviceStatus'], 'error') - attrs[ATTR_LOWPOWERMODE] = status['lowPowerMode'] - attrs[ATTR_BATTERYSTATUS] = status['batteryStatus'] - attrs[ATTR_ACCOUNTNAME] = self.accountname - status = device.status(DEVICESTATUSSET) - battery = status.get('batteryLevel', 0) * 100 - location = status['location'] - if location and location['horizontalAccuracy']: - horizontal_accuracy = int(location['horizontalAccuracy']) - if horizontal_accuracy < self._gps_accuracy_threshold: - self.determine_interval( - devicename, location['latitude'], - location['longitude'], battery) - interval = self._intervals.get(devicename, 1) - attrs[ATTR_INTERVAL] = interval - accuracy = location['horizontalAccuracy'] - kwargs['dev_id'] = dev_id - kwargs['host_name'] = status['name'] - kwargs['gps'] = (location['latitude'], - location['longitude']) - kwargs['battery'] = battery - kwargs['gps_accuracy'] = accuracy - kwargs[ATTR_ATTRIBUTES] = attrs - self.see(**kwargs) - self.seen_devices[devicename] = True - except PyiCloudNoDevicesException: - _LOGGER.error("No iCloud Devices found") - - def lost_iphone(self, devicename): - """Call the lost iPhone function if the device is found.""" - if self.api is None: - return + _LOGGER.debug("Updating device_tracker for %s", devicename) - self.api.authenticate() - for device in self.api.devices: - if str(device) == str(self.devices[devicename]): - _LOGGER.info("Playing Lost iPhone sound for %s", devicename) - device.play_sound() + see(dev_id=devicename, + host_name=device.name, + gps=( + device.location['latitude'], + device.location['longitude'] + ), + gps_accuracy=device.location['horizontalAccuracy'], + attributes=device.attributes, + icon=icon_for_icloud_device(device), + battery=device.battery_level) + device.set_seen(True) - def update_icloud(self, devicename=None): - """Request device information from iCloud and update device_tracker.""" - from pyicloud.exceptions import PyiCloudNoDevicesException + dispatcher_connect(hass, SIGNAL_UPDATE_ICLOUD, see_device) - if self.api is None: - return - - try: - if devicename is not None: - if devicename in self.devices: - self.update_device(devicename) - else: - _LOGGER.error("devicename %s unknown for account %s", - devicename, self._attrs[ATTR_ACCOUNTNAME]) - else: - for device in self.devices: - self.update_device(device) - except PyiCloudNoDevicesException: - _LOGGER.error("No iCloud Devices found") + return True - def setinterval(self, interval=None, devicename=None): - """Set the interval of the given devices.""" - devs = [devicename] if devicename else self.devices - for device in devs: - devid = '{}.{}'.format(DOMAIN, device) - devicestate = self.hass.states.get(devid) - if interval is not None: - if devicestate is not None: - self._overridestates[device] = run_callback_threadsafe( - self.hass.loop, - async_active_zone, - self.hass, - float(devicestate.attributes.get('latitude', 0)), - float(devicestate.attributes.get('longitude', 0)) - ).result() - if self._overridestates[device] is None: - self._overridestates[device] = 'away' - self._intervals[device] = interval - else: - self._overridestates[device] = None - self.update_device(device) +def icon_for_icloud_device(icloud_device: IcloudDevice) -> str: + """Return a battery icon valid identifier.""" + switcher = { + "iPad":"mdi:tablet-ipad", + "iPhone": "mdi:cellphone-iphone", + "iPod": "mdi:ipod", + "iMac":"mdi:desktop-mac", + "MacBookPro":"mdi:laptop-mac", + } + + return switcher.get(icloud_device.device_class, "mdi:cellphone-link") diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json index 5f2075a0fd6316..5a40bcf2aa39e0 100644 --- a/homeassistant/components/icloud/manifest.json +++ b/homeassistant/components/icloud/manifest.json @@ -6,5 +6,7 @@ "pyicloud==0.9.1" ], "dependencies": ["configurator"], - "codeowners": [] + "codeowners": [ + "@Quentame" + ] } diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py new file mode 100644 index 00000000000000..801f7ee497a5f2 --- /dev/null +++ b/homeassistant/components/icloud/sensor.py @@ -0,0 +1,93 @@ +"""Battery state for iCloud devices.""" +import logging + +from homeassistant.const import DEVICE_CLASS_BATTERY +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level + +from . import DATA_ICLOUD + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Sensors setup.""" + if discovery_info is None: + return + + devices = [] + for accountname, icloud_account in hass.data[DATA_ICLOUD].items(): + for devicename, icloud_device in icloud_account.devices.items(): + if icloud_device.battery_level is not None: + _LOGGER.debug("Adding sensors from iCloud device=%s", + devicename) + devices.append(IcloudDeviceBatterySensor(hass, + accountname, + devicename)) + + add_entities(devices, True) + + +class IcloudDeviceBatterySensor(Entity): + """iCloud device Battery Sensor.""" + + def __init__(self, hass, accountname, devicename): + self._hass = hass + self._accountname = accountname + self._devicename = devicename + + device = self._hass.data[DATA_ICLOUD][ + self._accountname].devices[self._devicename] + self._dev_id = device.dev_id + "_battery_state" + self._name = device.name + self._battery_level = device.battery_level + self._battery_status = device.battery_status + self._attrs = device.attributes + + + def update(self): + """Fetch new state data for the sensor.""" + device = self._hass.data[DATA_ICLOUD][ + self._accountname].devices[self._devicename] + self._battery_level = device.battery_level + self._battery_status = device.battery_status + self._attrs = device.attributes + + @property + def unique_id(self): + """Return a unique ID.""" + # sensor.name displayed in dev-state, how to use unique_id ? + return self._dev_id + + @property + def name(self): + """Sensor Name.""" + return self._name + " battery state" + + @property + def device_class(self): + """Return the device class of the sensor.""" + return DEVICE_CLASS_BATTERY + + @property + def state(self): + """Battery state percentage.""" + return self._battery_level + + @property + def unit_of_measurement(self): + """Battery state measured in percentage.""" + return '%' + + @property + def icon(self): + """Battery state icon handling.""" + return icon_for_battery_level( + battery_level=self._battery_level, + charging=self._battery_status == 'Charging' + ) + + @property + def device_state_attributes(self): + """Return default attributes for the iCloud device entity.""" + return self._attrs diff --git a/homeassistant/components/icloud/services.yaml b/homeassistant/components/icloud/services.yaml index e69de29bb2d1d6..ba814f7a31fe41 100644 --- a/homeassistant/components/icloud/services.yaml +++ b/homeassistant/components/icloud/services.yaml @@ -0,0 +1,57 @@ +# Describes the format for available iCloud services + +reset: + description: Reset the iCloud account and re build it. + fields: + account_name: + description: Your iCloud account name or username. + example: 'steve@apple.com' + +update: + description: Update iCloud devices. + fields: + account_name: + description: Your iCloud account name or username. + example: 'steve@apple.com' + +play_sound: + description: Play sound on an Apple device. + fields: + account_name: + description: (required) Your iCloud account name or username. + example: 'steve@apple.com' + device_name: + description: (required) The name of the Apple device to play a sound. + example: 'stevesiphone' + +display_message: + description: Display a message on an Apple device. + fields: + account_name: + description: (required) Your iCloud account name or username. + example: 'steve@apple.com' + device_name: + description: (required) The name of the Apple device to display the message. + example: 'stevesiphone' + message: + description: (required) The content of your message. + sound: + description: To make a sound when displaying the message (boolean). + example: 'true' + +lost_device: + description: Make an Apple device in lost state. + fields: + account_name: + description: (required) Your iCloud account name or username. + example: 'steve@apple.com' + device_name: + description: (required) The name of the Apple device to set lost. + example: 'stevesiphone' + number: + description: (required) The phone number to call in lost mode (must contain country code). + example: '+33450020100' + message: + description: (required) The message to display in lost mode. + example: 'Call me' + From d181c43a6dbe911d9f10965d120d14f56b7504af Mon Sep 17 00:00:00 2001 From: Quentame Date: Tue, 4 Jun 2019 21:58:47 +0200 Subject: [PATCH 2/5] WIP: determine_interval --- homeassistant/components/icloud/__init__.py | 118 +++++++++++++++--- .../components/icloud/device_tracker.py | 7 +- homeassistant/components/icloud/sensor.py | 4 +- 3 files changed, 108 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 05dd62c4e2dbac..65720467f919b7 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -1,7 +1,10 @@ """The iCloud component.""" +import json import logging +import operator import os from datetime import timedelta +from pprint import pprint import voluptuous as vol from pyicloud import PyiCloudService @@ -11,12 +14,15 @@ from pyicloud.services.findmyiphone import AppleDevice import homeassistant.helpers.config_validation as cv +from homeassistant.components.zone import async_active_zone from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util import slugify +from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.dt import utcnow +from homeassistant.util.location import distance DOMAIN = 'icloud' DATA_ICLOUD = 'icloud_data' @@ -100,7 +106,7 @@ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(ATTR_ACCOUNTNAME): cv.slugify, - vol.Optional(CONF_MAX_INTERVAL, default=60): cv.positive_int, + vol.Optional(CONF_MAX_INTERVAL, default=30): cv.positive_int, vol.Optional(CONF_GPS_ACCURACY_THRESHOLD, default=500): cv.positive_int })]) }, extra=vol.ALLOW_EXTRA) @@ -112,7 +118,6 @@ def setup(hass, config): """Set up the iCloud component.""" - async def play_sound(service): """Play sound on the device.""" accountname = service.data.get(ATTR_ACCOUNTNAME) @@ -133,9 +138,10 @@ async def display_message(service): message = service.data.get(SERVICE_ATTR_LOST_DEVICE_MESSAGE) sound = service.data.get(SERVICE_ATTR_LOST_DEVICE_SOUND, False) - hass.data[DATA_ICLOUD][accountname].devices[devicename].display_message( - message, - sound) + hass.data[DATA_ICLOUD][accountname].devices[ + devicename].display_message( + message, + sound) hass.services.register(DOMAIN, SERVICE_ICLOUD_DISPLAY_MESSAGE, display_message, schema=SERVICE_SCHEMA_DISPLAY_MESSAGE) @@ -182,7 +188,6 @@ async def reset_account(service): hass.services.register(DOMAIN, SERVICE_ICLOUD_RESET, reset_account, schema=SERVICE_SCHEMA) - def setup_icloud(icloud_config): """Set up an iCloud account.""" _LOGGER.debug("Logging into iCloud...") @@ -205,7 +210,6 @@ def setup_icloud(icloud_config): return False for component in ICLOUD_COMPONENTS: - # if component != 'device_tracker': load_platform(hass, component, DOMAIN, {}, icloud_config) hass.data[DATA_ICLOUD] = {} @@ -240,7 +244,6 @@ def __init__(self, hass, username, password, accountname, max_interval, def reset_account(self): """Reset an iCloud account.""" - icloud_dir = self._hass.config.path('icloud') if not os.path.exists(icloud_dir): os.makedirs(icloud_dir) @@ -258,11 +261,13 @@ def reset_account(self): try: # Gets device owners infos user_info = self.api.devices.response['userInfo'] - self.account_owner_fullname = user_info['firstName'] + ' ' + user_info['lastName'] + self.account_owner_fullname = user_info[ + 'firstName'] + ' ' + user_info['lastName'] self.family_members_fullname = {} for prs_id, member in user_info['membersInfo'].items(): - self.family_members_fullname[prs_id] = member['firstName'] + ' ' + member['lastName'] + self.family_members_fullname[prs_id] = member[ + 'firstName'] + ' ' + member['lastName'] self.devices = {} self.update_devices() @@ -288,7 +293,8 @@ def update_devices(self): else: # New device, should be unique if devicename in self.devices: - _LOGGER.error('Multiple devices with name: %s', devicename) + _LOGGER.error('Multiple devices with name: %s', + devicename) continue _LOGGER.debug('Adding iCloud device: %s', devicename) @@ -298,8 +304,85 @@ def update_devices(self): _LOGGER.error("No iCloud Devices found") async_dispatcher_send(self._hass, SIGNAL_UPDATE_ICLOUD) - # compute interval HERE - async_track_point_in_utc_time(self._hass, self.keep_alive, utcnow() + timedelta(seconds=30)) + interval = self.determine_interval() + _LOGGER.error('determine_interval : %s', interval) + async_track_point_in_utc_time( + self._hass, + self.keep_alive, utcnow() + timedelta(minutes=interval)) + + def determine_interval(self) -> int: + """Calculate new interval between to API fetch (in minutes).""" + intervals = {} + for devicename, device in self.devices.items(): + if device.location is None: + continue + + currentzone = run_callback_threadsafe( + self._hass.loop, + async_active_zone, + self._hass, + device.location['latitude'], + device.location['longitude'] + ).result() + _LOGGER.error('currentzone') + pprint(currentzone) + + if currentzone is not None: + intervals[device.name] = self._max_interval + _LOGGER.error('intervals') + _LOGGER.info(json.dumps(intervals, indent=2)) + continue + + 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( + device.location['latitude'], + device.location['longitude'], + zone_state_lat, + zone_state_long) + distances.append(round(zone_distance / 1000, 1)) + + if distances: + mindistance = min(distances) + _LOGGER.error('distances') + _LOGGER.info(json.dumps(distances, indent=2)) + _LOGGER.error('mindistance : %s', mindistance) + else: + _LOGGER.error('NO distances') + continue + + # 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: + _LOGGER.error('interval > 180 : %s', interval) + # Three hour drive? + # This is far enough that they might be flying + interval = 30 + + if device.battery_level is not None and device.battery_level <= 33 and mindistance > 3: + # Low battery - let's check half as often + _LOGGER.error('Low battery : %s', interval) + interval = interval * 2 + + _LOGGER.error('intervals') + intervals[device.name] = interval + _LOGGER.info(json.dumps(intervals, indent=2)) + + return max( + int(min( + intervals.items(), + key=operator.itemgetter(1))[1]), + self._max_interval) def keep_alive(self, now): """Keep the API alive.""" @@ -435,7 +518,8 @@ def __init__(self, account: IcloudAccount, device: AppleDevice): self._device_class = self.__status['deviceClass'] self._device_name = self.__status['deviceDisplayName'] if self.__status['prsId']: - self._owner_fullname = account.family_members_fullname[self.__status['prsId']] + self._owner_fullname = account.family_members_fullname[ + self.__status['prsId']] else: self._owner_fullname = account.account_owner_fullname @@ -452,7 +536,8 @@ def update(self, status): """Update the iCloud device.""" self.__status = status - self._device_status = DEVICE_STATUS_CODES.get(self.__status['deviceStatus'], 'error') + self._device_status = DEVICE_STATUS_CODES.get(self.__status[ + 'deviceStatus'], 'error') self._attrs = { ATTR_ACCOUNTNAME: self._accountname, @@ -472,7 +557,8 @@ def update(self, status): self._attrs[ATTR_BATTERYSTATUS] = self._battery_status self._attrs[ATTR_LOWPOWERMODE] = self._low_power_mode - if self.__status['location'] and self.__status['location']['latitude']: + if self.__status['location'] and self.__status[ + 'location']['latitude']: location = self.__status['location'] self._location = location diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 39ae08718a01fb..1abb2e4f029efd 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -48,14 +48,15 @@ def see_device(): return True + def icon_for_icloud_device(icloud_device: IcloudDevice) -> str: """Return a battery icon valid identifier.""" switcher = { - "iPad":"mdi:tablet-ipad", + "iPad": "mdi:tablet-ipad", "iPhone": "mdi:cellphone-iphone", "iPod": "mdi:ipod", - "iMac":"mdi:desktop-mac", - "MacBookPro":"mdi:laptop-mac", + "iMac": "mdi:desktop-mac", + "MacBookPro": "mdi:laptop-mac", } return switcher.get(icloud_device.device_class, "mdi:cellphone-link") diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index 801f7ee497a5f2..63dc3048843064 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -20,7 +20,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for devicename, icloud_device in icloud_account.devices.items(): if icloud_device.battery_level is not None: _LOGGER.debug("Adding sensors from iCloud device=%s", - devicename) + devicename) devices.append(IcloudDeviceBatterySensor(hass, accountname, devicename)) @@ -32,6 +32,7 @@ class IcloudDeviceBatterySensor(Entity): """iCloud device Battery Sensor.""" def __init__(self, hass, accountname, devicename): + """Initialize the iCloud device battery sensor.""" self._hass = hass self._accountname = accountname self._devicename = devicename @@ -44,7 +45,6 @@ def __init__(self, hass, accountname, devicename): self._battery_status = device.battery_status self._attrs = device.attributes - def update(self): """Fetch new state data for the sensor.""" device = self._hass.data[DATA_ICLOUD][ From 6cbcf6f85d28725386596b7a858de72692e96bcc Mon Sep 17 00:00:00 2001 From: Quentame Date: Fri, 5 Jul 2019 13:59:23 +0200 Subject: [PATCH 3/5] Fixes : for in loops + flake8 + remove useless logs --- homeassistant/components/icloud/__init__.py | 27 +++++-------------- .../components/icloud/device_tracker.py | 2 +- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 65720467f919b7..a7d98c510b8c81 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -1,10 +1,8 @@ """The iCloud component.""" -import json import logging import operator import os from datetime import timedelta -from pprint import pprint import voluptuous as vol from pyicloud import PyiCloudService @@ -276,7 +274,7 @@ def reset_account(self): _LOGGER.error('No iCloud Devices found!') def update_devices(self): - """Update iCloud devices""" + """Update iCloud devices.""" if self.api is None: return @@ -294,7 +292,7 @@ def update_devices(self): # New device, should be unique if devicename in self.devices: _LOGGER.error('Multiple devices with name: %s', - devicename) + devicename) continue _LOGGER.debug('Adding iCloud device: %s', devicename) @@ -313,7 +311,7 @@ def update_devices(self): def determine_interval(self) -> int: """Calculate new interval between to API fetch (in minutes).""" intervals = {} - for devicename, device in self.devices.items(): + for device in self.devices: if device.location is None: continue @@ -324,13 +322,9 @@ def determine_interval(self) -> int: device.location['latitude'], device.location['longitude'] ).result() - _LOGGER.error('currentzone') - pprint(currentzone) if currentzone is not None: intervals[device.name] = self._max_interval - _LOGGER.error('intervals') - _LOGGER.info(json.dumps(intervals, indent=2)) continue zones = (self._hass.states.get(entity_id) for entity_id @@ -349,11 +343,7 @@ def determine_interval(self) -> int: if distances: mindistance = min(distances) - _LOGGER.error('distances') - _LOGGER.info(json.dumps(distances, indent=2)) - _LOGGER.error('mindistance : %s', mindistance) else: - _LOGGER.error('NO distances') continue # Calculate out how long it would take for the device to drive @@ -364,19 +354,17 @@ def determine_interval(self) -> int: interval = max(interval, 1) if interval > 180: - _LOGGER.error('interval > 180 : %s', interval) # Three hour drive? # This is far enough that they might be flying interval = 30 - if device.battery_level is not None and device.battery_level <= 33 and mindistance > 3: + if (device.battery_level is not None and + device.battery_level <= 33 and + mindistance > 3): # Low battery - let's check half as often - _LOGGER.error('Low battery : %s', interval) interval = interval * 2 - _LOGGER.error('intervals') intervals[device.name] = interval - _LOGGER.info(json.dumps(intervals, indent=2)) return max( int(min( @@ -393,7 +381,6 @@ def keep_alive(self, now): return if self.api.requires_2fa: - from pyicloud.exceptions import PyiCloudException try: if self.__trusted_device is None: self.icloud_need_trusted_device() @@ -638,5 +625,5 @@ def seen(self): return self._seen def set_seen(self, seen): - """Set the seen value""" + """Set the seen value.""" self._seen = seen diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 1abb2e4f029efd..8e6dbcf6f6c6d1 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -16,7 +16,7 @@ def setup_scanner(hass, config, see, discovery_info=None): def see_device(): """Handle the reporting of the iCloud device position.""" - for accountname, icloud_account in hass.data[DATA_ICLOUD].items(): + for icloud_account in hass.data[DATA_ICLOUD]: for devicename, device in icloud_account.devices.items(): # An entity will not be created by see() when track=false in From 1c757fc1bce516e23596e42d5239cbf9b606dfc0 Mon Sep 17 00:00:00 2001 From: Quentame Date: Wed, 10 Jul 2019 13:58:31 +0200 Subject: [PATCH 4/5] Fixing review from @MartinHjelmare part 1 --- homeassistant/components/icloud/__init__.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index a7d98c510b8c81..4d18939101fcd8 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -116,7 +116,7 @@ def setup(hass, config): """Set up the iCloud component.""" - async def play_sound(service): + def play_sound(service): """Play sound on the device.""" accountname = service.data.get(ATTR_ACCOUNTNAME) accountname = slugify(accountname.partition('@')[0]) @@ -127,7 +127,7 @@ async def play_sound(service): hass.services.register(DOMAIN, SERVICE_ICLOUD_PLAY_SOUND, play_sound, schema=SERVICE_SCHEMA_PLAY_SOUND) - async def display_message(service): + def display_message(service): """Display a message on the device.""" accountname = service.data.get(ATTR_ACCOUNTNAME) accountname = slugify(accountname.partition('@')[0]) @@ -144,7 +144,7 @@ async def display_message(service): display_message, schema=SERVICE_SCHEMA_DISPLAY_MESSAGE) - async def lost_device(service): + def lost_device(service): """Make the device in lost state.""" accountname = service.data.get(ATTR_ACCOUNTNAME) accountname = slugify(accountname.partition('@')[0]) @@ -159,7 +159,7 @@ async def lost_device(service): hass.services.register(DOMAIN, SERVICE_ICLOUD_LOST_DEVICE, lost_device, schema=SERVICE_SCHEMA_LOST_DEVICE) - async def update(service): + def update(service): """Call the update function of an iCloud account.""" accountname = service.data.get(ATTR_ACCOUNTNAME) @@ -172,7 +172,7 @@ async def update(service): hass.services.register(DOMAIN, SERVICE_ICLOUD_UPDATE, update, schema=SERVICE_SCHEMA) - async def reset_account(service): + def reset_account(service): """Reset an iCloud account.""" accountname = service.data.get(ATTR_ACCOUNTNAME) @@ -199,6 +199,7 @@ def setup_icloud(icloud_config): account = IcloudAccount(hass, username, password, account_name, max_interval, gps_accuracy_threshold) + account.reset_account() if account.api is not None: hass.data[DATA_ICLOUD][account.name] = account @@ -238,8 +239,6 @@ def __init__(self, hass, username, password, accountname, max_interval, self.__trusted_device = None self.__verification_code = None - self.reset_account() - def reset_account(self): """Reset an iCloud account.""" icloud_dir = self._hass.config.path('icloud') From 384d9459f3bb1de40b87241c76f3d2b0f8bf6ea1 Mon Sep 17 00:00:00 2001 From: Quentame Date: Thu, 11 Jul 2019 14:00:37 +0200 Subject: [PATCH 5/5] [WIP] Config Entry & Flow convertion --- .../components/icloud/.translations/en.json | 15 + homeassistant/components/icloud/__init__.py | 260 +++++++----------- .../components/icloud/config_flow.py | 199 ++++++++++++++ homeassistant/components/icloud/const.py | 15 + .../components/icloud/device_tracker.py | 3 +- homeassistant/components/icloud/manifest.json | 21 +- homeassistant/components/icloud/sensor.py | 47 ++-- homeassistant/components/icloud/strings.json | 15 + 8 files changed, 381 insertions(+), 194 deletions(-) create mode 100644 homeassistant/components/icloud/.translations/en.json create mode 100644 homeassistant/components/icloud/config_flow.py create mode 100644 homeassistant/components/icloud/const.py create mode 100644 homeassistant/components/icloud/strings.json diff --git a/homeassistant/components/icloud/.translations/en.json b/homeassistant/components/icloud/.translations/en.json new file mode 100644 index 00000000000000..616d873b052459 --- /dev/null +++ b/homeassistant/components/icloud/.translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "step": { + "init": { + "data": { + "password": "Password", + "username": "Username" + }, + "description": "Enter your credentials", + "title": "iCloud account" + } + }, + "title": "Apple iCloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/icloud/__init__.py b/homeassistant/components/icloud/__init__.py index 4d18939101fcd8..49d2f5ebe2ee3d 100644 --- a/homeassistant/components/icloud/__init__.py +++ b/homeassistant/components/icloud/__init__.py @@ -13,29 +13,25 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.zone import async_active_zone +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.typing import ConfigType, HomeAssistantType from homeassistant.util import slugify from homeassistant.util.async_ import run_callback_threadsafe from homeassistant.util.dt import utcnow from homeassistant.util.location import distance -DOMAIN = 'icloud' -DATA_ICLOUD = 'icloud_data' +from .config_flow import IcloudFlowHandler # noqa +from .const import (CONF_ACCOUNTNAME, CONF_GPS_ACCURACY_THRESHOLD, + CONF_MAX_INTERVAL, DATA_ICLOUD, DOMAIN, ICLOUD_COMPONENTS, + SIGNAL_UPDATE_ICLOUD) ATTRIBUTION = "Data provided by Apple iCloud" -SIGNAL_UPDATE_ICLOUD = 'icloud_update' - -# iCloud dev tracker comp -CONF_ACCOUNTNAME = 'account_name' -CONF_MAX_INTERVAL = 'max_interval' -CONF_GPS_ACCURACY_THRESHOLD = 'gps_accuracy_threshold' - # entity attributes -ATTR_ACCOUNTNAME = 'account_name' ATTR_BATTERY = 'battery' ATTR_BATTERYSTATUS = 'battery_status' ATTR_DEVICENAME = 'device_name' @@ -77,23 +73,23 @@ SERVICE_ATTR_LOST_DEVICE_SOUND = 'sound' SERVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ACCOUNTNAME): cv.string, + vol.Optional(CONF_ACCOUNTNAME): cv.string, }) SERVICE_SCHEMA_PLAY_SOUND = vol.Schema({ - vol.Required(ATTR_ACCOUNTNAME): cv.string, + vol.Required(CONF_ACCOUNTNAME): cv.string, vol.Required(ATTR_DEVICENAME): cv.string, }) SERVICE_SCHEMA_DISPLAY_MESSAGE = vol.Schema({ - vol.Required(ATTR_ACCOUNTNAME): cv.string, + vol.Required(CONF_ACCOUNTNAME): cv.string, vol.Required(ATTR_DEVICENAME): cv.string, vol.Required(SERVICE_ATTR_LOST_DEVICE_MESSAGE): cv.string, vol.Optional(SERVICE_ATTR_LOST_DEVICE_SOUND): cv.boolean, }) SERVICE_SCHEMA_LOST_DEVICE = vol.Schema({ - vol.Required(ATTR_ACCOUNTNAME): cv.string, + vol.Required(CONF_ACCOUNTNAME): cv.string, vol.Required(ATTR_DEVICENAME): cv.string, vol.Required(SERVICE_ATTR_LOST_DEVICE_NUMBER): cv.string, vol.Required(SERVICE_ATTR_LOST_DEVICE_MESSAGE): cv.string, @@ -103,33 +99,68 @@ DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(ATTR_ACCOUNTNAME): cv.slugify, + vol.Optional(CONF_ACCOUNTNAME): cv.slugify, vol.Optional(CONF_MAX_INTERVAL, default=30): cv.positive_int, vol.Optional(CONF_GPS_ACCURACY_THRESHOLD, default=500): cv.positive_int })]) }, extra=vol.ALLOW_EXTRA) -ICLOUD_COMPONENTS = [ - 'sensor', 'device_tracker' -] +_LOGGER.error('ICLOUD_INIT_:') +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the iCloud components.""" + _LOGGER.error('ICLOUD_INIT_:setup') + return True + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry +) -> bool: + """Set up an iCloud account from a config entry.""" + + _LOGGER.error('ICLOUD_INIT_:entry') + + username = entry.data[CONF_USERNAME] + password = entry.data[CONF_PASSWORD] + account_name = entry.data.get( + CONF_ACCOUNTNAME, + slugify(username.partition('@')[0]) + ) + max_interval = entry.data[CONF_MAX_INTERVAL] + gps_accuracy_threshold = entry.data[CONF_GPS_ACCURACY_THRESHOLD] + + account = IcloudAccount( + hass, + username, + password, + account_name, + max_interval, + gps_accuracy_threshold + ) + account.reset_account() + + if account.api is None: + _LOGGER.error("No iCloud data added for account=%s", account_name) + return False + + hass.data[DATA_ICLOUD][account.name] = account + + for component in ICLOUD_COMPONENTS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) -def setup(hass, config): - """Set up the iCloud component.""" def play_sound(service): """Play sound on the device.""" - accountname = service.data.get(ATTR_ACCOUNTNAME) + accountname = service.data.get(CONF_ACCOUNTNAME) accountname = slugify(accountname.partition('@')[0]) devicename = service.data.get(ATTR_DEVICENAME) devicename = slugify(devicename.replace(' ', '', 99)) hass.data[DATA_ICLOUD][accountname].devices[devicename].play_sound() - hass.services.register(DOMAIN, SERVICE_ICLOUD_PLAY_SOUND, play_sound, - schema=SERVICE_SCHEMA_PLAY_SOUND) def display_message(service): """Display a message on the device.""" - accountname = service.data.get(ATTR_ACCOUNTNAME) + accountname = service.data.get(CONF_ACCOUNTNAME) accountname = slugify(accountname.partition('@')[0]) devicename = service.data.get(ATTR_DEVICENAME) devicename = slugify(devicename.replace(' ', '', 99)) @@ -140,13 +171,10 @@ def display_message(service): devicename].display_message( message, sound) - hass.services.register(DOMAIN, SERVICE_ICLOUD_DISPLAY_MESSAGE, - display_message, - schema=SERVICE_SCHEMA_DISPLAY_MESSAGE) def lost_device(service): """Make the device in lost state.""" - accountname = service.data.get(ATTR_ACCOUNTNAME) + accountname = service.data.get(CONF_ACCOUNTNAME) accountname = slugify(accountname.partition('@')[0]) devicename = service.data.get(ATTR_DEVICENAME) devicename = slugify(devicename.replace(' ', '', 99)) @@ -156,12 +184,10 @@ def lost_device(service): hass.data[DATA_ICLOUD][accountname].devices[devicename].lost_device( number, message) - hass.services.register(DOMAIN, SERVICE_ICLOUD_LOST_DEVICE, lost_device, - schema=SERVICE_SCHEMA_LOST_DEVICE) def update(service): """Call the update function of an iCloud account.""" - accountname = service.data.get(ATTR_ACCOUNTNAME) + accountname = service.data.get(CONF_ACCOUNTNAME) if accountname is None: for accountname, account in hass.data[DATA_ICLOUD].items(): @@ -169,12 +195,10 @@ def update(service): else: accountname = slugify(accountname.partition('@')[0]) hass.data[DATA_ICLOUD][accountname].keep_alive(utcnow()) - hass.services.register(DOMAIN, SERVICE_ICLOUD_UPDATE, update, - schema=SERVICE_SCHEMA) - + def reset_account(service): """Reset an iCloud account.""" - accountname = service.data.get(ATTR_ACCOUNTNAME) + accountname = service.data.get(CONF_ACCOUNTNAME) if accountname is None: for accountname, account in hass.data[DATA_ICLOUD].items(): @@ -183,41 +207,43 @@ def reset_account(service): accountname = slugify(accountname.partition('@')[0]) hass.data[DATA_ICLOUD][accountname].reset_account() - hass.services.register(DOMAIN, SERVICE_ICLOUD_RESET, - reset_account, schema=SERVICE_SCHEMA) - - def setup_icloud(icloud_config): - """Set up an iCloud account.""" - _LOGGER.debug("Logging into iCloud...") - - username = icloud_config.get(CONF_USERNAME) - password = icloud_config.get(CONF_PASSWORD) - account_name = icloud_config.get(CONF_ACCOUNTNAME, - slugify(username.partition('@')[0])) - max_interval = icloud_config.get(CONF_MAX_INTERVAL) - gps_accuracy_threshold = icloud_config.get(CONF_GPS_ACCURACY_THRESHOLD) - - account = IcloudAccount(hass, username, password, account_name, - max_interval, gps_accuracy_threshold) - account.reset_account() + hass.services.register( + DOMAIN, SERVICE_ICLOUD_PLAY_SOUND, play_sound, + schema=SERVICE_SCHEMA_PLAY_SOUND + ) + + hass.services.register( + DOMAIN, SERVICE_ICLOUD_DISPLAY_MESSAGE, display_message, + schema=SERVICE_SCHEMA_DISPLAY_MESSAGE + ) + + hass.services.register( + DOMAIN, SERVICE_ICLOUD_LOST_DEVICE, lost_device, + schema=SERVICE_SCHEMA_LOST_DEVICE + ) + + hass.services.register( + DOMAIN, SERVICE_ICLOUD_UPDATE, update, + schema=SERVICE_SCHEMA + ) + + hass.services.register( + DOMAIN, SERVICE_ICLOUD_RESET, reset_account, + schema=SERVICE_SCHEMA + ) - if account.api is not None: - hass.data[DATA_ICLOUD][account.name] = account - - else: - _LOGGER.error("No iCloud data added for account=%s", account_name) - return False - - for component in ICLOUD_COMPONENTS: - load_platform(hass, component, DOMAIN, {}, icloud_config) + return True - hass.data[DATA_ICLOUD] = {} - for icloud_config in config[DOMAIN]: - setup_icloud(icloud_config) +async def async_unload_entry( + hass: HomeAssistantType, entry: ConfigType +) -> bool: + """Unload iCloud config entry.""" + # for component in 'sensor', 'switch': + # await hass.config_entries.async_forward_entry_unload(entry, component) + hass.data[DOMAIN].pop(entry.data[CONF_ACCOUNTNAME]) return True - class IcloudAccount(): """Representation of an iCloud account.""" @@ -270,7 +296,7 @@ def reset_account(self): self.update_devices() except PyiCloudNoDevicesException: - _LOGGER.error('No iCloud Devices found!') + _LOGGER.error('No iCloud Devices found') def update_devices(self): """Update iCloud devices.""" @@ -379,107 +405,9 @@ def keep_alive(self, now): if self.api is None: return - if self.api.requires_2fa: - try: - if self.__trusted_device is None: - self.icloud_need_trusted_device() - return - - if self.__verification_code is None: - self.icloud_need_verification_code() - return - - self.api.authenticate() - if self.api.requires_2fa: - raise Exception('Unknown failure') - - self.__trusted_device = None - self.__verification_code = None - except PyiCloudException as error: - _LOGGER.error("Error setting up 2FA: %s", error) - else: - self.api.authenticate() - + self.api.authenticate() self.update_devices() - def icloud_trusted_device_callback(self, callback_data): - """Handle chosen trusted devices.""" - self.__trusted_device = int(callback_data.get('trusted_device')) - self.__trusted_device = self.api.trusted_devices[self.__trusted_device] - - if not self.api.send_verification_code(self.__trusted_device): - _LOGGER.error("Failed to send verification code") - self.__trusted_device = None - return - - if self._accountname in _CONFIGURING: - request_id = _CONFIGURING.pop(self._accountname) - configurator = self._hass.components.configurator - configurator.request_done(request_id) - - # Trigger the next step immediately - self.icloud_need_verification_code() - - def icloud_need_trusted_device(self): - """We need a trusted device.""" - configurator = self._hass.components.configurator - if self._accountname in _CONFIGURING: - return - - devicesstring = '' - devices = self.api.trusted_devices - for i, device in enumerate(devices): - devicename = device.get( - 'deviceName', 'SMS to %s' % device.get('phoneNumber')) - devicesstring += "{}: {};".format(i, devicename) - - _CONFIGURING[self._accountname] = configurator.request_config( - 'iCloud {}'.format(self._accountname), - self.icloud_trusted_device_callback, - description=( - 'Please choose your trusted device by entering' - ' the index from this list: ' + devicesstring), - entity_picture="/static/images/config_icloud.png", - submit_caption='Confirm', - fields=[{'id': 'trusted_device', 'name': 'Trusted Device'}] - ) - - def icloud_verification_callback(self, callback_data): - """Handle the chosen trusted device.""" - self.__verification_code = callback_data.get('code') - - try: - if not self.api.validate_verification_code( - self.__trusted_device, self.__verification_code): - raise PyiCloudException('Unknown failure') - except PyiCloudException as error: - # Reset to the initial 2FA state to allow the user to retry - _LOGGER.error("Failed to verify verification code: %s", error) - self.__trusted_device = None - self.__verification_code = None - - # Trigger the next step immediately - self.icloud_need_trusted_device() - - if self._accountname in _CONFIGURING: - request_id = _CONFIGURING.pop(self._accountname) - configurator = self._hass.components.configurator - configurator.request_done(request_id) - - def icloud_need_verification_code(self): - """Return the verification code.""" - configurator = self._hass.components.configurator - if self._accountname in _CONFIGURING: - return - - _CONFIGURING[self._accountname] = configurator.request_config( - 'iCloud {}'.format(self._accountname), - self.icloud_verification_callback, - description=('Please enter the validation code:'), - entity_picture="/static/images/config_icloud.png", - submit_caption='Confirm', - fields=[{'id': 'code', 'name': 'code'}] - ) @property def name(self): @@ -526,7 +454,7 @@ def update(self, status): 'deviceStatus'], 'error') self._attrs = { - ATTR_ACCOUNTNAME: self._accountname, + CONF_ACCOUNTNAME: self._accountname, ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_DEVICENAME: self._device_name, ATTR_DEVICESTATUS: self._device_status, diff --git a/homeassistant/components/icloud/config_flow.py b/homeassistant/components/icloud/config_flow.py new file mode 100644 index 00000000000000..a0be872a582a15 --- /dev/null +++ b/homeassistant/components/icloud/config_flow.py @@ -0,0 +1,199 @@ +"""Config flow to configure the iCloud integration.""" +import logging +import os + +import voluptuous as vol +from pyicloud import PyiCloudService +from pyicloud.exceptions import PyiCloudException, PyiCloudFailedLoginException + +from homeassistant import config_entries +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import (CONF_ACCOUNTNAME, CONF_GPS_ACCURACY_THRESHOLD, + CONF_MAX_INTERVAL, DOMAIN) + +_LOGGER = logging.getLogger(__name__) + +_LOGGER.error('CONFIG_FLOW_ICLOUD') + +@config_entries.HANDLERS.register(DOMAIN) +class IcloudFlowHandler(config_entries.ConfigFlow): + """Handle a iCloud config flow.""" + + _LOGGER.error('CONFIG_FLOW_ICLOUD:class') + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize iCloud config flow.""" + _LOGGER.error('CONFIG_FLOW_ICLOUD:init') + self.api = None + self.__username = None + self.__password = None + self._accountname = None + self.__max_interval = None + self.__gps_accuracy_threshold = None + + self.__trusted_device = None + self.__verification_code = None + + async def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + + _LOGGER.error('CONFIG_FLOW_ICLOUD:form') + return self.async_show_form( + step_id='user', + data_schema=vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_ACCOUNTNAME): str, + vol.Optional(CONF_MAX_INTERVAL, default=30): int, + vol.Optional(CONF_GPS_ACCURACY_THRESHOLD, default=500): int, + } + ), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + + _LOGGER.error('CONFIG_FLOW_ICLOUD:user') + icloud_dir = self.hass.config.path('icloud') + if not os.path.exists(icloud_dir): + os.makedirs(icloud_dir) + + if user_input is None: + return await self._show_setup_form(user_input) + + self.__username = user_input[CONF_USERNAME] + self.__password = user_input[CONF_PASSWORD] + self._accountname = user_input[CONF_ACCOUNTNAME] + self.__max_interval = user_input[CONF_MAX_INTERVAL] + self.__gps_accuracy_threshold = user_input[CONF_GPS_ACCURACY_THRESHOLD] + + try: + self.api = PyiCloudService( + self.__username, + self.__password, + cookie_directory=icloud_dir, + verify=True) + except PyiCloudFailedLoginException as error: + if self.api.requires_2fa: + try: + if self.__trusted_device is None: + self.icloud_need_trusted_device() + return + + if self.__verification_code is None: + self.icloud_need_verification_code() + return + + self.api.authenticate() + if self.api.requires_2fa: + raise Exception('Unknown failure') + + self.__trusted_device = None + self.__verification_code = None + + return True + except PyiCloudException as error: + _LOGGER.error("Error setting up 2FA: %s", error) + return False + else: + self.api = None + _LOGGER.error("Error logging into iCloud Service: %s", error) + return False + return True + + async def async_step_final(self): + """Handle the final step, create the config entry.""" + return self.async_create_entry( + title=self._accountname, + data={ + CONF_USERNAME: self.__username, + CONF_PASSWORD: self.__password, + CONF_ACCOUNTNAME: self._accountname, + CONF_MAX_INTERVAL: self.__max_interval, + CONF_GPS_ACCURACY_THRESHOLD: self.__gps_accuracy_threshold, + }, + ) + + def icloud_need_trusted_device(self): + """We need a trusted device.""" + configurator = self.hass.components.configurator + if self._accountname in _CONFIGURING: + return + + devicesstring = '' + devices = self.api.trusted_devices + for i, device in enumerate(devices): + devicename = device.get( + 'deviceName', 'SMS to %s' % device.get('phoneNumber')) + devicesstring += "{}: {};".format(i, devicename) + + _CONFIGURING[self._accountname] = configurator.request_config( + 'iCloud {}'.format(self._accountname), + self.icloud_trusted_device_callback, + description=( + 'Please choose your trusted device by entering' + ' the index from this list: ' + devicesstring), + entity_picture="/static/images/config_icloud.png", + submit_caption='Confirm', + fields=[{'id': 'trusted_device', 'name': 'Trusted Device'}] + ) + + def icloud_trusted_device_callback(self, callback_data): + """Handle chosen trusted devices.""" + self.__trusted_device = int(callback_data.get('trusted_device')) + self.__trusted_device = self.api.trusted_devices[self.__trusted_device] + + if not self.api.send_verification_code(self.__trusted_device): + _LOGGER.error("Failed to send verification code") + self.__trusted_device = None + return + + if self._accountname in _CONFIGURING: + request_id = _CONFIGURING.pop(self._accountname) + configurator = self.hass.components.configurator + configurator.request_done(request_id) + + # Trigger the next step immediately + self.icloud_need_verification_code() + + def icloud_need_verification_code(self): + """Return the verification code.""" + configurator = self.hass.components.configurator + if self._accountname in _CONFIGURING: + return + + _CONFIGURING[self._accountname] = configurator.request_config( + 'iCloud {}'.format(self._accountname), + self.icloud_verification_callback, + description=('Please enter the validation code:'), + entity_picture="/static/images/config_icloud.png", + submit_caption='Confirm', + fields=[{'id': 'code', 'name': 'code'}] + ) + + def icloud_verification_callback(self, callback_data): + """Handle the chosen trusted device.""" + self.__verification_code = callback_data.get('code') + + try: + if not self.api.validate_verification_code( + self.__trusted_device, self.__verification_code): + raise PyiCloudException('Unknown failure') + except PyiCloudException as error: + # Reset to the initial 2FA state to allow the user to retry + _LOGGER.error("Failed to verify verification code: %s", error) + self.__trusted_device = None + self.__verification_code = None + + # Trigger the next step immediately + self.icloud_need_trusted_device() + + if self._accountname in _CONFIGURING: + request_id = _CONFIGURING.pop(self._accountname) + configurator = self.hass.components.configurator + configurator.request_done(request_id) diff --git a/homeassistant/components/icloud/const.py b/homeassistant/components/icloud/const.py new file mode 100644 index 00000000000000..6f560ec19a8d4e --- /dev/null +++ b/homeassistant/components/icloud/const.py @@ -0,0 +1,15 @@ +"""iCloud component constants.""" + +DOMAIN = 'icloud' +DATA_ICLOUD = 'icloud_data' + +SIGNAL_UPDATE_ICLOUD = 'icloud_update' + +# iCloud dev tracker comp +CONF_ACCOUNTNAME = 'account_name' +CONF_MAX_INTERVAL = 'max_interval' +CONF_GPS_ACCURACY_THRESHOLD = 'gps_accuracy_threshold' + +ICLOUD_COMPONENTS = [ + 'sensor', 'device_tracker' +] diff --git a/homeassistant/components/icloud/device_tracker.py b/homeassistant/components/icloud/device_tracker.py index 8e6dbcf6f6c6d1..8aff6b5435ad7e 100644 --- a/homeassistant/components/icloud/device_tracker.py +++ b/homeassistant/components/icloud/device_tracker.py @@ -4,7 +4,8 @@ from homeassistant.components.device_tracker.const import ENTITY_ID_FORMAT from homeassistant.helpers.dispatcher import dispatcher_connect -from . import DATA_ICLOUD, SIGNAL_UPDATE_ICLOUD, IcloudDevice +from . import IcloudDevice +from .const import DATA_ICLOUD, SIGNAL_UPDATE_ICLOUD _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/icloud/manifest.json b/homeassistant/components/icloud/manifest.json index 5a40bcf2aa39e0..cf0ae97761f990 100644 --- a/homeassistant/components/icloud/manifest.json +++ b/homeassistant/components/icloud/manifest.json @@ -1,12 +1,13 @@ { - "domain": "icloud", - "name": "Icloud", - "documentation": "https://www.home-assistant.io/components/icloud", - "requirements": [ - "pyicloud==0.9.1" - ], - "dependencies": ["configurator"], - "codeowners": [ - "@Quentame" - ] + "domain": "icloud", + "name": "iCloud", + "config_flow": true, + "documentation": "https://www.home-assistant.io/components/icloud", + "requirements": [ + "pyicloud==0.9.1" + ], + "dependencies": [], + "codeowners": [ + "@Quentame" + ] } diff --git a/homeassistant/components/icloud/sensor.py b/homeassistant/components/icloud/sensor.py index 63dc3048843064..25df814e436837 100644 --- a/homeassistant/components/icloud/sensor.py +++ b/homeassistant/components/icloud/sensor.py @@ -1,32 +1,45 @@ """Battery state for iCloud devices.""" import logging -from homeassistant.const import DEVICE_CLASS_BATTERY +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_USERNAME, DEVICE_CLASS_BATTERY from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util import slugify -from . import DATA_ICLOUD +from .const import CONF_ACCOUNTNAME, DATA_ICLOUD _LOGGER = logging.getLogger(__name__) +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up iCloud devices sensors based on a config entry.""" -def setup_platform(hass, config, add_entities, discovery_info=None): - """Sensors setup.""" - if discovery_info is None: - return + username = entry.data[CONF_USERNAME] + account_name = entry.data.get( + CONF_ACCOUNTNAME, + slugify(username.partition('@')[0]) + ) + icloud = hass.data[DATA_ICLOUD][account_name] - devices = [] - for accountname, icloud_account in hass.data[DATA_ICLOUD].items(): - for devicename, icloud_device in icloud_account.devices.items(): - if icloud_device.battery_level is not None: - _LOGGER.debug("Adding sensors from iCloud device=%s", - devicename) - devices.append(IcloudDeviceBatterySensor(hass, - accountname, - devicename)) - - add_entities(devices, True) + # try: + # version = await adguard.version() + # except AdGuardHomeConnectionError as exception: + # raise PlatformNotReady from exception + devices = [] + for devicename, icloud_device in icloud.devices.items(): + if icloud_device.battery_level is not None: + _LOGGER.debug("Adding sensors from iCloud device=%s", devicename) + devices.append( + IcloudDeviceBatterySensor(hass, + icloud.name, + devicename) + ) + + async_add_entities(devices, True) class IcloudDeviceBatterySensor(Entity): """iCloud device Battery Sensor.""" diff --git a/homeassistant/components/icloud/strings.json b/homeassistant/components/icloud/strings.json new file mode 100644 index 00000000000000..fea17f557aae54 --- /dev/null +++ b/homeassistant/components/icloud/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "title": "Apple iCloud", + "step": { + "init": { + "title": "iCloud account", + "description": "Enter your credentials", + "data": { + "username": "Username", + "password": "Password" + } + } + } + } +}