diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..ede4fbb --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from base import PyiCloudService diff --git a/base.py b/base.py new file mode 100644 index 0000000..612bbc0 --- /dev/null +++ b/base.py @@ -0,0 +1,107 @@ +import time +import uuid +import hashlib +import json +import requests + +from exceptions import PyiCloudFailedLoginException +from services import FindMyiPhoneService, CalendarService + + +class PyiCloudService(object): + """ + A base authentication class for the iCloud service. Handles the + validation and authentication required to access iCloud services. + + Usage: + from pyicloud import PyiCloudService + pyicloud = PyiCloudService('username@apple.com', 'password') + pyicloud.iphone.location() + """ + def __init__(self, apple_id, password): + self.discovery = None + self.client_id = str(uuid.uuid1()).upper() + self.user = {'apple_id': apple_id, 'password': password} + + self._home_endpoint = 'https://www.icloud.com' + self._setup_endpoint = 'https://p12-setup.icloud.com/setup/ws/1' + self._push_endpoint = 'https://p12-pushws.icloud.com' + + self._base_login_url = '%s/login' % self._setup_endpoint + self._base_validate_url = '%s/validate' % self._setup_endpoint + self._base_system_url = '%s/system/version.json' % self._home_endpoint + self._base_webauth_url = '%s/refreshWebAuth' % self._push_endpoint + + self.session = requests.Session() + self.session.verify = False + self.session.headers.update({ + 'host': 'setup.icloud.com', + 'origin': self._home_endpoint, + 'referer': '%s/' % self._home_endpoint, + 'User-Agent': 'Opera/9.52 (X11; Linux i686; U; en)' + }) + + self.refresh_version() + self.params = { + 'clientId': self.client_id, + 'clientBuildNumber': self.build_id + } + + self.authenticate() + + def refresh_version(self): + """ + Retrieves the buildNumber from the /version endpoint. + This is used by almost all request query strings. + """ + req = requests.get(self._base_system_url) + self.build_id = req.json()['buildNumber'] + + def refresh_validate(self): + """ + Queries the /validate endpoint and fetches two key values we need: + 1. "dsInfo" is a nested object which contains the "dsid" integer. + This object doesn't exist until *after* the login has taken place, + the first request will compain about a X-APPLE-WEBAUTH-TOKEN cookie + 2. "instance" is an int which is used to build the "id" query string. + This is, pseudo: sha1(email + "instance") to uppercase. + """ + req = self.session.get(self._base_validate_url, params=self.params) + resp = req.json() + if 'dsInfo' in resp: + dsid = resp['dsInfo']['dsid'] + self.params.update({'dsid': dsid}) + instance = resp['instance'] + sha = hashlib.sha1(self.user.get('apple_id') + instance) + self.params.update({'id': sha.hexdigest().upper()}) + + def authenticate(self): + """ + Handles the full authentication steps, validating, + authenticating and then validating again. + """ + self.refresh_validate() + + data = dict(self.user) + data.update({'id': self.params['id'], 'extended_login': False}) + req = self.session.post( + self._base_login_url, + params=self.params, + data=json.dumps(data) + ) + + if not req.ok: + msg = 'Invalid email/password combination.' + raise PyiCloudFailedLoginException(msg) + + self.refresh_validate() + + self.discovery = req.json() + + @property + def iphone(self): + return FindMyiPhoneService(self.session, self.params) + + @property + def calendar(self): + return CalendarService(self.session, self.params) diff --git a/exceptions.py b/exceptions.py new file mode 100644 index 0000000..ded3635 --- /dev/null +++ b/exceptions.py @@ -0,0 +1,8 @@ + + +class PyiCloudNoDevicesException(Exception): + pass + + +class PyiCloudFailedLoginException(Exception): + pass diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..04f80f5 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +uuid +requests \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py new file mode 100644 index 0000000..19f1d48 --- /dev/null +++ b/services/__init__.py @@ -0,0 +1,2 @@ +from calendar import CalendarService +from findmyiphone import FindMyiPhoneService diff --git a/services/calendar.py b/services/calendar.py new file mode 100644 index 0000000..b7e893b --- /dev/null +++ b/services/calendar.py @@ -0,0 +1,52 @@ +from __future__ import absolute_import +import os +from datetime import datetime +from calendar import monthrange + + +class CalendarService(object): + """ + The 'Calendar' iCloud service, connects to iCloud and returns events. + """ + def __init__(self, session, params): + self.session = session + self.params = params + self._calendar_endpoint = 'https://p12-calendarws.icloud.com/ca' + self._calendar_refresh_url = '%s/events' % self._calendar_endpoint + + def get_system_tz(self): + """ + Retrieves the system's timezone. + From: http://stackoverflow.com/a/7841417 + """ + return '/'.join(os.readlink('/etc/localtime').split('/')[-2:]) + + def refresh_client(self, from_dt=None, to_dt=None): + """ + Refreshes the CalendarService endpoint, ensuring that the + event data is up-to-date. If no 'from_dt' or 'to_dt' datetimes + have been given, the range becomes this month. + """ + today = datetime.today() + first_day, last_day = monthrange(today.year, today.month) + if not from_dt: + from_dt = datetime(today.year, today.month, first_day) + if not to_dt: + to_dt = datetime(today.year, today.month, last_day) + self.session.headers.update({'host': 'p12-calendarws.icloud.com'}) + params = dict(self.params) + params.update({ + 'lang': 'en-us', + 'usertz': self.get_system_tz(), + 'startDate': from_dt.strftime('%Y-%m-%d'), + 'endDate': to_dt.strftime('%Y-%m-%d') + }) + req = self.session.get(self._calendar_refresh_url, params=params) + self.response = req.json() + + def events(self, from_dt=None, to_dt=None): + """ + Retrieves events for a given date range, by default, this month. + """ + self.refresh_client(from_dt, to_dt) + return self.response['Event'] diff --git a/services/findmyiphone.py b/services/findmyiphone.py new file mode 100644 index 0000000..100108e --- /dev/null +++ b/services/findmyiphone.py @@ -0,0 +1,46 @@ +from pyicloud.exceptions import PyiCloudNoDevicesException + + +class FindMyiPhoneService(object): + """ + The 'Find my iPhone' iCloud service, connects to iCloud and returns + phone data including the near-realtime latitude and longitude. + """ + def __init__(self, session, params): + self.session = session + self.params = params + self._fmip_root = 'https://p12-fmipweb.icloud.com' + self._fmip_endpoint = '%s/fmipservice/client/web' % self._fmip_root + self._fmip_refresh_url = '%s/refreshClient' % self._fmip_endpoint + + def refresh_client(self): + """ + Refreshes the FindMyiPhoneService endpoint, + ensuring that the location data is up-to-date. + """ + self.session.headers.update({'host': 'p12-fmipweb.icloud.com'}) + req = self.session.post(self._fmip_refresh_url, params=self.params) + self.response = req.json() + if self.response['content']: + # TODO: Support multiple devices. + self.content = self.response['content'][0] + else: + raise PyiCloudNoDevicesException('You do not have any active devices.') + self.user_info = self.response['userInfo'] + + def location(self): + self.refresh_client() + return self.content['location'] + + def status(self, additional=[]): + """ + The FindMyiPhoneService response is quite bloated, this method + will return a subset of the more useful properties. + """ + self.refresh_client() + fields = ['batteryLevel', 'deviceDisplayName', 'deviceStatus', 'name'] + fields += additional + properties = {} + for field in fields: + properties[field] = self.content.get(field, 'Unknown') + return properties