diff --git a/README.rst b/README.rst index 5d5b98fe..c04df031 100644 --- a/README.rst +++ b/README.rst @@ -130,7 +130,10 @@ You can list which devices associated with your account by using the ``devices`` and you can access individual devices by either their index, or their ID: -.. code-block:: pycon +>>> api.devices[0] + +>>> api.devices['i9vbKRGIcLYqJnXMd1b257kUWnoyEBcEh6yM+IfmiMLh7BmOpALS+w=='] + >>> api.devices[0] @@ -156,20 +159,16 @@ Location Returns the device's last known location. The Find My iPhone app must have been installed and initialized. -.. code-block:: pycon - - >>> api.iphone.location() - {'timeStamp': 1357753796553, 'locationFinished': True, 'longitude': -0.14189, 'positionType': 'GPS', 'locationType': None, 'latitude': 51.501364, 'isOld': False, 'horizontalAccuracy': 5.0} +>>> api.iphone.location() +{u'timeStamp': 1357753796553, u'locationFinished': True, u'longitude': -0.14189, u'positionType': u'GPS', u'locationType': None, u'latitude': 51.501364, u'isOld': False, u'horizontalAccuracy': 5.0} Status ****** The Find My iPhone response is quite bloated, so for simplicity's sake this method will return a subset of the properties. -.. code-block:: pycon - - >>> api.iphone.status() - {'deviceDisplayName': 'iPhone 5', 'deviceStatus': '200', 'batteryLevel': 0.6166913, 'name': "Peter's iPhone"} +>>> api.iphone.status() +{'deviceDisplayName': u'iPhone 5', 'deviceStatus': u'200', 'batteryLevel': 0.6166913, 'name': u"Peter's iPhone"} If you wish to request further properties, you may do so by passing in a list of property names. diff --git a/pyicloud/base.py b/pyicloud/base.py index 6ac8bdbd..1f53082d 100644 --- a/pyicloud/base.py +++ b/pyicloud/base.py @@ -17,7 +17,7 @@ PyiCloudServiceNotActivatedException, ) from pyicloud.services import ( - FindMyiPhoneServiceManager, + FindMyiPhoneService, CalendarService, UbiquityService, ContactsService, @@ -126,7 +126,7 @@ def request(self, method, url, **kwargs): # pylint: disable=arguments-differ api_error = PyiCloudAPIResponseException( response.reason, response.status_code, retry=True ) - request_logger.debug(api_error) + request_logger.warning(api_error) kwargs["retried"] = True return self.request(method, url, **kwargs) @@ -197,7 +197,7 @@ class PyiCloudService: Usage: from pyicloud import PyiCloudService pyicloud = PyiCloudService('username@apple.com', 'password') - pyicloud.iphone.location() + pyicloud.iphone.location """ AUTH_ENDPOINT = "https://idmsa.apple.com/appleauth/auth" @@ -271,6 +271,7 @@ def __init__( self.authenticate() self._drive = None + self._find_my_iphone = None self._files = None self._photos = None @@ -536,18 +537,38 @@ def _get_webservice_url(self, ws_key): ) return self._webservices[ws_key]["url"] + def fmipWebAuthenticate(self, device): + data = json.dumps( + { + "dsWebAuthToken": self.session_data["session_token"] + } + ) + req = self.session.post("%s/fmipWebAuthenticate" % self.SETUP_ENDPOINT, params=self.params, data=data) + return req.json()['tokens']['mmeFMIPWebEraseDeviceToken'] + + @property + def find_my_iphone(self): + """Gets the 'Find My iPhone' service.""" + if not self._find_my_iphone: + service_root = self._get_webservice_url("findme") + self._find_my_iphone = FindMyiPhoneService( + service_root, self.session, self.params, self.with_family + ) + return self._find_my_iphone + @property def devices(self): - """Returns all devices.""" - service_root = self._get_webservice_url("findme") - return FindMyiPhoneServiceManager( - service_root, self.session, self.params, self.with_family - ) + """Return all devices.""" + service = self.find_my_iphone + service.refresh_client() + return service.devices @property def iphone(self): - """Returns the iPhone.""" - return self.devices[0] + """Return the first device (usually the iPhone).""" + service = self.find_my_iphone + service.refresh_client() + return service.device(0) @property def account(self): diff --git a/pyicloud/cmdline.py b/pyicloud/cmdline.py index 4be65b25..336ddc4f 100644 --- a/pyicloud/cmdline.py +++ b/pyicloud/cmdline.py @@ -9,7 +9,7 @@ from click import confirm -from pyicloud import PyiCloudService +from pyicloud.base import PyiCloudService from pyicloud.exceptions import PyiCloudFailedLoginException from . import utils @@ -24,8 +24,9 @@ def create_pickled_data(idevice, filename): This allows the data to be used without resorting to screen / pipe scrapping. """ - with open(filename, "wb") as pickle_file: - pickle.dump(idevice.content, pickle_file, protocol=pickle.HIGHEST_PROTOCOL) + pickle_file = open(filename, "wb") + pickle.dump(idevice.content, pickle_file, protocol=pickle.HIGHEST_PROTOCOL) + pickle_file.close() def main(args=None): @@ -263,40 +264,41 @@ def main(args=None): print(message, file=sys.stderr) - for dev in api.devices: - if not command_line.device_id or ( - command_line.device_id.strip().lower() == dev.content["id"].strip().lower() + fmi_service = api.find_my_iphone + fmi_service.refresh_client() + for device in fmi_service.devices.values(): + if ( + not command_line.device_id + or command_line.device_id.strip().lower() == device.id.strip().lower() ): # List device(s) if command_line.locate: - dev.location() + fmi_service.refresh_client() if command_line.output_to_file: create_pickled_data( - dev, - filename=(dev.content["name"].strip().lower() + ".fmip_snapshot"), + device, filename=(device.name.strip().lower() + ".fmip_snapshot") ) - contents = dev.content if command_line.longlist: print("-" * 30) - print(contents["name"]) - for key in contents: - print("%20s - %s" % (key, contents[key])) + print(device.name) + for attr, value in device.attrs.items(): + print("%20s - %s" % (attr, value)) elif command_line.list: print("-" * 30) - print("Name - %s" % contents["name"]) - print("Display Name - %s" % contents["deviceDisplayName"]) - print("Location - %s" % contents["location"]) - print("Battery Level - %s" % contents["batteryLevel"]) - print("Battery Status- %s" % contents["batteryStatus"]) - print("Device Class - %s" % contents["deviceClass"]) - print("Device Model - %s" % contents["deviceModel"]) + print("Name - %s" % device.name) + print("Display Name - %s" % device.deviceDisplayName) + print("Location - %s" % device.location) + print("Battery Level - %s" % device.batteryLevel) + print("Battery Status- %s" % device.batteryStatus) + print("Device Class - %s" % device.deviceClass) + print("Device Model - %s" % device.deviceModel) # Play a Sound on a device if command_line.sound: if command_line.device_id: - dev.play_sound() + device.play_sound() else: raise RuntimeError( "\n\n\t\t%s %s\n\n" @@ -309,7 +311,7 @@ def main(args=None): # Display a Message on the device if command_line.message: if command_line.device_id: - dev.display_message( + device.display_message( subject="A Message", message=command_line.message, sounds=True ) else: @@ -324,7 +326,7 @@ def main(args=None): # Display a Silent Message on the device if command_line.silentmessage: if command_line.device_id: - dev.display_message( + device.display_message( subject="A Silent Message", message=command_line.silentmessage, sounds=False, @@ -342,7 +344,7 @@ def main(args=None): # Enable Lost mode if command_line.lostmode: if command_line.device_id: - dev.lost_device( + device.lost_device( number=command_line.lost_phone.strip(), text=command_line.lost_message.strip(), newpasscode=command_line.lost_password.strip(), diff --git a/pyicloud/services/__init__.py b/pyicloud/services/__init__.py index 73cae179..38b8c0f5 100644 --- a/pyicloud/services/__init__.py +++ b/pyicloud/services/__init__.py @@ -1,6 +1,6 @@ """Services.""" from pyicloud.services.calendar import CalendarService -from pyicloud.services.findmyiphone import FindMyiPhoneServiceManager +from pyicloud.services.findmyiphone import FindMyiPhoneService from pyicloud.services.ubiquity import UbiquityService from pyicloud.services.contacts import ContactsService from pyicloud.services.reminders import RemindersService diff --git a/pyicloud/services/account.py b/pyicloud/services/account.py index ecef9efe..11abb170 100644 --- a/pyicloud/services/account.py +++ b/pyicloud/services/account.py @@ -316,13 +316,16 @@ def __init__(self, storage_data): self.usage = AccountStorageUsage( storage_data.get("storageUsageInfo"), storage_data.get("quotaStatus") ) - self.usages_by_media = OrderedDict() + self.usage_by_media = OrderedDict() for usage_media in storage_data.get("storageUsageByMedia"): - self.usages_by_media[usage_media["mediaKey"]] = AccountStorageUsageForMedia( + self.usage_by_media[usage_media["mediaKey"]] = AccountStorageUsageForMedia( usage_media ) + def __unicode__(self): + return "{usage: %s, usages_by_media: %s}" % (self.usage, self.usages_by_media) + def __str__(self): return f"{{usage: {self.usage}, usages_by_media: {self.usages_by_media}}}" diff --git a/pyicloud/services/findmyiphone.py b/pyicloud/services/findmyiphone.py index ab135ba0..ae95fbd7 100644 --- a/pyicloud/services/findmyiphone.py +++ b/pyicloud/services/findmyiphone.py @@ -1,35 +1,70 @@ """Find my iPhone service.""" import json -from pyicloud.exceptions import PyiCloudNoDevicesException - +from six import PY2, text_type -class FindMyiPhoneServiceManager: - """The 'Find my iPhone' iCloud service +from pyicloud.exceptions import PyiCloudNoDevicesException - This connects to iCloud and return phone data including the near-realtime - latitude and longitude. +DEVICE_BATTERY_LEVEL = "batteryLevel" +DEVICE_BATTERY_STATUS = "batteryStatus" +DEVICE_CLASS = "deviceClass" +DEVICE_DISPLAY_NAME = "deviceDisplayName" +DEVICE_FROM_FAMILY = "fmlyShare" +DEVICE_ID = "id" +DEVICE_IS_MAC = "isMac" +DEVICE_LOCATION = "location" +DEVICE_LOCATION_HORIZONTAL_ACCURACY = "horizontalAccuracy" +DEVICE_LOCATION_LATITUDE = "latitude" +DEVICE_LOCATION_LONGITUDE = "longitude" +DEVICE_LOCATION_POSITION_TYPE = "positionType" +DEVICE_LOST_MODE_CAPABLE = "lostModeCapable" +DEVICE_LOW_POWER_MODE = "lowPowerMode" +DEVICE_MODEL = "deviceModel" +DEVICE_MODEL_RAW = "rawDeviceModel" +DEVICE_MSG_MAX_LENGTH = "maxMsgChar" +DEVICE_NAME = "name" +DEVICE_PERSON_ID = "prsId" +DEVICE_STATUS = "deviceStatus" +DEVICE_CAN_WIPE = "canWipeAfterLock" + +class FindMyiPhoneServiceManager(object): + """The 'Find my iPhone' iCloud service""" + +DEVICE_STATUS_ONLINE = "online" +DEVICE_STATUS_OFFLINE = "offline" +DEVICE_STATUS_PENDING = "pending" +DEVICE_STATUS_UNREGISTERED = "unregistered" +DEVICE_STATUS_ERROR = "error" +DEVICE_STATUS_CODES = { + "200": DEVICE_STATUS_ONLINE, + "201": DEVICE_STATUS_OFFLINE, + "203": DEVICE_STATUS_PENDING, + "204": DEVICE_STATUS_UNREGISTERED, +} + + +class FindMyiPhoneService(object): + """ + The 'Find my iPhone' iCloud service, + connects to iCloud and returns devices, + including battery state and location. """ def __init__(self, service_root, session, params, with_family=False): self.session = session self.params = params self.with_family = with_family - fmip_endpoint = "%s/fmipservice/client/web" % service_root self._fmip_refresh_url = "%s/refreshClient" % fmip_endpoint self._fmip_sound_url = "%s/playSound" % fmip_endpoint self._fmip_message_url = "%s/sendMessage" % fmip_endpoint self._fmip_lost_url = "%s/lostDevice" % fmip_endpoint - - self._devices = {} - self.refresh_client() + self._fmip_erase_url = "%s/remoteWipeWithUserAuth" % fmip_endpoint + self._devices = {} # Need to call refresh_client() to fill/update it def refresh_client(self): - """Refreshes the FindMyiPhoneService endpoint, - - This ensures that the location data is up-to-date. - + """ + Refreshes devices to ensures that the data data is up-to-date. """ req = self.session.post( self._fmip_refresh_url, @@ -45,19 +80,19 @@ def refresh_client(self): } ), ) - self.response = req.json() + data = req.json() - for device_info in self.response["content"]: + for device_info in data["content"]: device_id = device_info["id"] if device_id not in self._devices: self._devices[device_id] = AppleDevice( device_info, self.session, self.params, - manager=self, sound_url=self._fmip_sound_url, lost_url=self._fmip_lost_url, message_url=self._fmip_message_url, + erase_url=self._fmip_erase_url ) else: self._devices[device_id].update(device_info) @@ -65,78 +100,59 @@ def refresh_client(self): if not self._devices: raise PyiCloudNoDevicesException() - def __getitem__(self, key): + @property + def devices(self): + """Return all devices.""" + return self._devices + + def device(self, key): + """Return one device.""" if isinstance(key, int): - key = list(self.keys())[key] + if PY2: + key = self.keys()[key] + else: + key = list(self.keys())[key] return self._devices[key] def __getattr__(self, attr): return getattr(self._devices, attr) + def __unicode__(self): + return text_type(self._devices) + def __str__(self): return f"{self._devices}" def __repr__(self): - return f"{self}" + return "<%s: {with_family: %s, devices: %s}>" % (type(self).__name__, str(self)) -class AppleDevice: +class AppleDevice(object): """Apple device.""" def __init__( - self, - content, - session, - params, - manager, - sound_url=None, - lost_url=None, - message_url=None, + self, attrs, session, params, sound_url=None, lost_url=None, message_url=None, erase_url=None ): - self.content = content - self.manager = manager - self.session = session - self.params = params + self._attrs = attrs + self._session = session + self._params = params self.sound_url = sound_url self.lost_url = lost_url self.message_url = message_url + self.erase_url = erase_url - def update(self, data): - """Updates the device data.""" - self.content = data - - def location(self): - """Updates the device location.""" - self.manager.refresh_client() - return self.content["location"] - - def status(self, additional=[]): # pylint: disable=dangerous-default-value - """Returns status information for device. - - This returns only a subset of possible properties. - """ - self.manager.refresh_client() - fields = ["batteryLevel", "deviceDisplayName", "deviceStatus", "name"] - fields += additional - properties = {} - for field in fields: - properties[field] = self.content.get(field) - return properties + def update(self, attrs): + self._attrs = attrs def play_sound(self, subject="Find My iPhone Alert"): """Send a request to the device to play a sound. - It's possible to pass a custom message by changing the `subject`. """ data = json.dumps( - { - "device": self.content["id"], - "subject": subject, - "clientContext": {"fmly": True}, - } + {"device": self.id, "subject": subject, "clientContext": {"fmly": True}} ) - self.session.post(self.sound_url, params=self.params, data=data) + self._session.post(self.sound_url, params=self._params, data=data) def display_message( self, subject="Find My iPhone Alert", message="This is a note", sounds=False @@ -147,14 +163,14 @@ def display_message( """ data = json.dumps( { - "device": self.content["id"], + "device": self.id, "subject": subject, "sound": sounds, "userText": True, "text": message, } ) - self.session.post(self.message_url, params=self.params, data=data) + self._session.post(self.message_url, params=self._params, data=data) def lost_device( self, number, text="This iPhone has been lost. Please call me.", newpasscode="" @@ -172,25 +188,142 @@ def lost_device( "ownerNbr": number, "lostModeEnabled": True, "trackingEnabled": True, - "device": self.content["id"], + "device": self.id, "passcode": newpasscode, } ) - self.session.post(self.lost_url, params=self.params, data=data) + self._session.post(self.lost_url, params=self._params, data=data) + + def erase_device( + self, text="This iPhone has been lost. Please call me.", newpasscode="", erasetoken=None + ): + if (erasetoken==None): + print("This function requires an erase token. Obtain one by using the fmipWebAuthenticate() function") + return + + """Send a request to the device to start a remote erase.""" + data = json.dumps( + { + "authToken": erasetoken, + "text": text, + "device": self.id, + "passcode": newpasscode + } + ) + self._session.post(self.erase_url, params=self._params, data=data) + @property + def attrs(self): + return self._attrs + + @property + def batteryLevel(self): + return self._attrs.get(DEVICE_BATTERY_LEVEL) + + @property + def batteryStatus(self): + return self._attrs[DEVICE_BATTERY_STATUS] + + @property + def deviceClass(self): + return self._attrs[DEVICE_CLASS] + + @property + def deviceDisplayName(self): + return self._attrs[DEVICE_DISPLAY_NAME] + + @property + def deviceModel(self): + return self._attrs.get(DEVICE_MODEL) + + @property + def deviceStatus(self): + return DEVICE_STATUS_CODES.get( + self._attrs.get(DEVICE_STATUS), DEVICE_STATUS_ERROR + ) + + @property + def fmlyShare(self): + return self._attrs[DEVICE_FROM_FAMILY] + + @property + def id(self): + return self._attrs[DEVICE_ID] + + @property + def isMac(self): + return self._attrs[DEVICE_IS_MAC] + + @property + def canWipe(self): + return self._attrs[DEVICE_CAN_WIPE] + + @property + def location(self): + return self._attrs.get(DEVICE_LOCATION) @property - def data(self): - """Gets the device data.""" - return self.content + def horizontalAccuracy(self): + if self.location is None: + return None + return self.location.get(DEVICE_LOCATION_HORIZONTAL_ACCURACY) + + @property + def latitude(self): + if self.location is None: + return None + return self.location.get(DEVICE_LOCATION_LATITUDE) + + @property + def longitude(self): + if self.location is None: + return None + return self.location.get(DEVICE_LOCATION_LONGITUDE) + + @property + def positionType(self): + if self.location is None: + return None + return self.location.get(DEVICE_LOCATION_POSITION_TYPE) + + @property + def lostModeCapable(self): + return self._attrs.get(DEVICE_LOST_MODE_CAPABLE) + + @property + def lowPowerMode(self): + return self._attrs.get(DEVICE_LOW_POWER_MODE) + + @property + def maxMsgChar(self): + return self._attrs.get(DEVICE_MSG_MAX_LENGTH) + + @property + def name(self): + return self._attrs.get(DEVICE_NAME) + + @property + def prsId(self): + return self._attrs.get(DEVICE_PERSON_ID) + + @property + def rawDeviceModel(self): + return self._attrs.get(DEVICE_MODEL_RAW) def __getitem__(self, key): - return self.content[key] + if hasattr(self, key): + return getattr(self, key) + return self._attrs[key] def __getattr__(self, attr): - return getattr(self.content, attr) + return getattr(self._attrs, attr) + + def __unicode__(self): + display_name = self["deviceDisplayName"] + name = self["name"] + return "%s: %s" % (display_name, name) def __str__(self): return f"{self['deviceDisplayName']}: {self['name']}" def __repr__(self): - return f"" + return "" % str(self) diff --git a/tests/__init__.py b/tests/__init__.py index 40317b20..9be9f40c 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -3,6 +3,8 @@ from requests import Response from pyicloud import base +from pyicloud.exceptions import PyiCloudFailedLoginException +from pyicloud.services.findmyiphone import FindMyiPhoneServiceManager, AppleDevice from .const import ( AUTHENTICATED_USER, diff --git a/tests/test_account.py b/tests/test_account.py index 94c8c772..dbcc2ae7 100644 --- a/tests/test_account.py +++ b/tests/test_account.py @@ -70,7 +70,8 @@ def test_storage(self): """Tests storage.""" assert self.service.storage # fmt: off - assert repr(self.service.storage) == "), ('backup', ), ('docs', ), ('mail', )])}>" + if PY3: + assert repr(self.service.storage) == "), ('backup', ), ('docs', ), ('mail', )])}>" # fmt: on def test_storage_usage(self): @@ -92,11 +93,11 @@ def test_storage_usage(self): assert repr(usage) == "" # fmt: on - def test_storage_usages_by_media(self): - """Tests storage usages by media.""" - assert self.service.storage.usages_by_media + def test_storage_usage_by_media(self): + """Tests storage usage by media.""" + assert self.service.storage.usage_by_media - for usage_media in self.service.storage.usages_by_media.values(): + for usage_media in self.service.storage.usage_by_media.values(): assert usage_media.key assert usage_media.label assert usage_media.color diff --git a/tests/test_findmyiphone.py b/tests/test_findmyiphone.py index 517bd1eb..d5c3f5ba 100644 --- a/tests/test_findmyiphone.py +++ b/tests/test_findmyiphone.py @@ -4,6 +4,10 @@ from . import PyiCloudServiceMock from .const import AUTHENTICATED_USER, VALID_PASSWORD +import logging + +LOGGER = logging.getLogger(__name__) + class FindMyiPhoneServiceTest(TestCase): """Find My iPhone service tests.""" @@ -11,14 +15,59 @@ class FindMyiPhoneServiceTest(TestCase): service = None def setUp(self): - """Set up tests.""" self.service = PyiCloudServiceMock(AUTHENTICATED_USER, VALID_PASSWORD) + def test_repr(self): + """Tests representations.""" + # fmt: off + assert repr(self.service) == "" + self.service.refresh_client() + assert repr(self.service) == "" + # fmt: on + + def test_init(self): + """Tests init.""" + assert len(list(self.service.devices)) == 0 + + def test_refresh_client(self): + """Tests devices.""" + assert len(list(self.service.devices)) == 0 + + self.service.refresh_client() + + assert len(self.service.devices) == 13 + + def test_device(self): + """Tests device.""" + with self.assertRaises(KeyError): + self.service.device("iPhone12,1") + with self.assertRaises(IndexError): + self.service.device(0) + + self.service.refresh_client() + + assert self.service.device("iPhone12,1") is not None + assert self.service.device(0) is not None + # fmt: off + assert repr(self.service.device(0)) == "" + # fmt: on + + with self.assertRaises(IndexError): + self.service.device(999) + def test_devices(self): """Tests devices.""" - assert len(list(self.service.devices)) == 13 + self.service.refresh_client() + + assert self.service.devices + + for device in self.service.devices.values(): + assert device.name + assert device.deviceDisplayName + # fmt: off + assert repr(device) == "" + # fmt: on - for device in self.service.devices: assert device["canWipeAfterLock"] is not None assert device["baUUID"] is not None assert device["wipeInProgress"] is not None @@ -53,36 +102,36 @@ def test_devices(self): assert device["darkWake"] is not None assert device["remoteWipe"] is None - assert device.data["canWipeAfterLock"] is not None - assert device.data["baUUID"] is not None - assert device.data["wipeInProgress"] is not None - assert device.data["lostModeEnabled"] is not None - assert device.data["activationLocked"] is not None - assert device.data["passcodeLength"] is not None - assert device.data["deviceStatus"] is not None - assert device.data["features"] is not None - assert device.data["lowPowerMode"] is not None - assert device.data["rawDeviceModel"] is not None - assert device.data["id"] is not None - assert device.data["isLocating"] is not None - assert device.data["modelDisplayName"] is not None - assert device.data["lostTimestamp"] is not None - assert device.data["batteryLevel"] is not None - assert device.data["locationEnabled"] is not None - assert device.data["locFoundEnabled"] is not None - assert device.data["fmlyShare"] is not None - assert device.data["lostModeCapable"] is not None - assert device.data["wipedTimestamp"] is None - assert device.data["deviceDisplayName"] is not None - assert device.data["audioChannels"] is not None - assert device.data["locationCapable"] is not None - assert device.data["batteryStatus"] is not None - assert device.data["trackingInfo"] is None - assert device.data["name"] is not None - assert device.data["isMac"] is not None - assert device.data["thisDevice"] is not None - assert device.data["deviceClass"] is not None - assert device.data["deviceModel"] is not None - assert device.data["maxMsgChar"] is not None - assert device.data["darkWake"] is not None - assert device.data["remoteWipe"] is None + assert device.attrs["canWipeAfterLock"] is not None + assert device.attrs["baUUID"] is not None + assert device.attrs["wipeInProgress"] is not None + assert device.attrs["lostModeEnabled"] is not None + assert device.attrs["activationLocked"] is not None + assert device.attrs["passcodeLength"] is not None + assert device.attrs["deviceStatus"] is not None + assert device.attrs["features"] is not None + assert device.attrs["lowPowerMode"] is not None + assert device.attrs["rawDeviceModel"] is not None + assert device.attrs["id"] is not None + assert device.attrs["isLocating"] is not None + assert device.attrs["modelDisplayName"] is not None + assert device.attrs["lostTimestamp"] is not None + assert device.attrs["batteryLevel"] is not None + assert device.attrs["locationEnabled"] is not None + assert device.attrs["locFoundEnabled"] is not None + assert device.attrs["fmlyShare"] is not None + assert device.attrs["lostModeCapable"] is not None + assert device.attrs["wipedTimestamp"] is None + assert device.attrs["deviceDisplayName"] is not None + assert device.attrs["audioChannels"] is not None + assert device.attrs["locationCapable"] is not None + assert device.attrs["batteryStatus"] is not None + assert device.attrs["trackingInfo"] is None + assert device.attrs["name"] is not None + assert device.attrs["isMac"] is not None + assert device.attrs["thisDevice"] is not None + assert device.attrs["deviceClass"] is not None + assert device.attrs["deviceModel"] is not None + assert device.attrs["maxMsgChar"] is not None + assert device.attrs["darkWake"] is not None + assert device.attrs["remoteWipe"] is None