iCloud device data integration in openHAB

Hi all.

go get rid of my last dependency to MiOS/Vera I´ve started to create a rule to get the location of an iPhone directly from the iCloud.

With the feedback from @ThomDietrich. @hmerk & the community the example evolved and is now able to retrieve quite some device specific information for multiple devices:

  • Name: John’s iPhone
  • Owner: John Doe
  • Unique ID: VIRxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  • BatteryStatus: NotCharging
  • BatteryLevel: 0.8100000023841858
  • Latitude: 51.xxxxxxxxx
  • Longitude: 11.xxxxxxxxxx
  • Accuracy: 16.0
  • TimeStamp: 2017-07-29T15:50:51.372+0200

NOTES:

  • Feel free to contribute & directly edit this post to add your enhancements/corrections. If unsure, use the thread below to discuss your changes first.
  • Since OH 2.2 device data can also be retrieved using the iCloud Binding.

Item(s)

Items
//My work iPhone 7
String iPhone7_Battery "iPhone7 Batterie [%s]" <battery> (IOS)
String iPhone7_Battery_Status "iPhone7 Batterie Status [%s]" <battery> (IOS)
Number iPhone7_Battery_Level "iPhone7 Batterie Level [%.0f]" <battery> (IOS)
String iPhone7_Location "iPhone7 Location [%s]" <suitcase> (IOS)
DateTime iPhone7_Location_Timestamp "iPhone7 Location Last Update [%1$td.%1$tm.%1$tY, %1$tH:%1$tM]" <suitcase> (IOS)
Number iPhone7_Coordinates_Accuracy "iPhone7 Koordinaten HDOP [%.0f]" <suitcase> (IOS)
Location iPhone7_Coordinates "iPhone7 Koordinaten" <suitcase> (IOS)
String iPhone7_Location_Address "iPhone7 Location Address [%s]" (IOS)

//My work iPad Pro
String iPadPro_Battery "iPadPro Batterie [%s]" <battery> (IOS)
String iPadPro_Battery_Status "iPadPro Batterie Status [%s]" <battery> (IOS)
Number iPadPro_Battery_Level "iPadPro Batterie Level [%.0f]" <battery> (IOS)
String iPadPro_Location "iPadPro Location [%s]" <suitcase> (IOS)
DateTime iPadPro_Location_Timestamp "iPadPro Location Last Update [%1$td.%1$tm.%1$tY, %1$tH:%1$tM]" <suitcase> (IOS)
Number iPadPro_Coordinates_Accuracy "iPadPro Koordinaten HDOP [%.0f]" <suitcase> (IOS)
Location iPadPro_Coordinates "iPadPro Koordinaten" <suitcase> (IOS)
String iPadPro_Location_Address "iPadPro Location Address [%s]" (IOS)

//My private iPhone 5
String iPhone5_Battery "iPhone5 Batterie [%s]" <battery> (IOS)
String iPhone5_Battery_Status "iPhone5 Batterie Status [%s]" <battery> (IOS)
Number iPhone5_Battery_Level "iPhone5 Batterie Level [%.0f]" <battery> (IOS)
String iPhone5_Location "iPhone5 Location [%s]" <suitcase> (IOS)
DateTime iPhone5_Location_Timestamp "iPhone5 Location Last Update [%1$td.%1$tm.%1$tY, %1$tH:%1$tM]" <suitcase> (IOS)
Number iPhone5_Coordinates_Accuracy "iPhone5 Koordinaten HDOP [%.0f]" <suitcase> (IOS)
Location iPhone5_Coordinates "iPhone5 Koordinaten" <suitcase> (IOS)
String iPhone5_Location_Address "iPhone5 Location Address [%s]" (IOS)

//My private iPad Air2
String iPadAir2_Battery "iPadAir2 Batterie [%s]" <battery> (IOS)
String iPadAir2_Battery_Status "iPadAir2 Batterie Status [%s]" <battery> (IOS)
Number iPadAir2_Battery_Level "iPadAir2 Batterie Level [%.0f]" <battery> (IOS)
String iPadAir2_Location "iPadAir2 Location [%s]" <suitcase> (IOS)
DateTime iPadAir2_Location_Timestamp "iPadAir2 Location Last Update [%1$td.%1$tm.%1$tY, %1$tH:%1$tM]" <suitcase> (IOS)
Number iPadAir2_Coordinates_Accuracy "iPadAir2 Koordinaten HDOP [%.0f]" <suitcase> (IOS)
Location iPadAir2_Coordinates "iPadAir2 Koordinaten" <suitcase> (IOS)
String iPadAir2_Location_Address "iPadAir2 Location Address [%s]" (IOS)

Rule(s)

imports & definitions
import org.eclipse.xtext.xbase.lib.Functions
import java.net.URL
import java.nio.charset.StandardCharsets
import javax.net.ssl.HttpsURLConnection
import com.sun.org.apache.xml.internal.security.utils.Base64
import java.io.BufferedInputStream
import java.io.BufferedReader
import java.io.InputStreamReader
import java.text.SimpleDateFormat
import java.util.Date

val String filename = "icloud.rules"
//my home location
val PointType home = new PointType(new DecimalType(51.xxxxxxxxxxxxxx), new DecimalType(6.xxxxxxxxxxxxxxx))
//my work location
val PointType work = new PointType(new DecimalType(51.xxxxxxxxxxxxxx), new DecimalType(6.xxxxxxxxxxxxxxx))
Function to retrieve iCloud data
// Function called to retrieve iCloud data
val Functions$Function2<String, String, String> iCloudRetrieve= [ appleId, password |
val String filename = "icloud.rules"
var String jsonResponse = null

logInfo(filename, "Function to retrieve iCloud data is called")

try {
	var iCloudUrl = "https://www.icloud.com"
	var iCloudLoginUrl = "https://fmipmobile.icloud.com/fmipservice/device/" + appleId + "/initClient"

	var loginUrl = new URL(iCloudLoginUrl);
	var HttpsURLConnection connection = loginUrl.openConnection() as HttpsURLConnection
	var request = '{"clientContext":{"appName":"iCloud Find (Web)","appVersion":"2.0","timezone":"US/Eastern","inactiveTime":2255,"apiVersion":"3.0","webStats":"0:15"}}'
	var byte[] postData = request.getBytes(StandardCharsets.UTF_8)

	var String basicAuth = "Basic " + Base64.encode((appleId + ":" + password).getBytes())

	// prepare post request headers for login ...
	connection.setRequestProperty("Authorization", basicAuth)
	connection.requestMethod = "POST"
	connection.setRequestProperty("User-Agent", "Find iPhone/1.3 MeKit (iPad: iPhone OS/4.2.1)")
	connection.setRequestProperty("Origin", iCloudUrl)
	connection.setRequestProperty("Content-Type", "application/json")
	connection.setRequestProperty("charset", "utf-8")
	connection.setRequestProperty("Accept-language", "en-us")
	connection.setRequestProperty("Connection", "keep-alive")
	connection.setRequestProperty("X-Apple-Find-Api-Ver", "2.0")
	connection.setRequestProperty("X-Apple-Authscheme", "UserIdGuest")
	connection.setRequestProperty("X-Apple-Realm-Support", "1.0")
	connection.setRequestProperty("X-Client-Name", "iPad")
	connection.setRequestProperty("Content-Length", Integer.toString(postData.length))

	connection.doOutput = true
	connection.setDoInput = true
	connection.outputStream.write(postData)

	var responseCode = connection.responseCode

	logInfo(filename, "HTTP Code: " + responseCode)
	logInfo(filename, "Message: " + connection.responseMessage)

	var StringBuffer sb = new StringBuffer()
	var inputStream = new BufferedInputStream(connection.getInputStream())
	var BufferedReader br = new BufferedReader(new InputStreamReader(inputStream))
	var String inputLine = ""

	while ((inputLine = br.readLine()) != null) {
		sb.append(inputLine)
	}

	jsonResponse = sb.toString()

	inputStream.close()
	br.close()
	connection.disconnect()
	} catch (Exception e) {
		logError("iCloudRetrieve", "Error in iCloudRetrieve: " + e.toString)
	}
	return jsonResponse
]
Function to transform iCloud jsonResponse
// Function to transform iCloud jsonResponse
val Functions$Function6<String,Integer,GenericItem,GenericItem,GenericItem,GenericItem,DateTimeType> jsonResponseTransform= [ jsonResponse, deviceIndex, Battery_Status, Battery_Level, Coordinates_Accuracy, Coordinates |
  val String filename = "icloud.rules"
  var DateTimeType timestamp

  logInfo(filename, "Function to transform iCloud jsonResponse is called")

  val String owner = transform("JSONPATH", "$.userInfo.firstName", jsonResponse) + " " + transform("JSONPATH", "$.userInfo.lastName", jsonResponse)
  logInfo(filename, "Owner:         " + owner)
  val String deviceName = (transform("JSONPATH", String.format("$.content[%d].name", deviceIndex), jsonResponse))
  logInfo(filename, "Name:          " + deviceName)
  val String deviceId = (transform("JSONPATH", String.format("$.content[%d].id", deviceIndex), jsonResponse))
  logInfo(filename, "Unique ID:     " + deviceId)
  val String batteryStatus = (transform("JSONPATH", String.format("$.content[%d].batteryStatus", deviceIndex), jsonResponse))
  logInfo(filename, "BatteryStatus: " + batteryStatus)
  val Double batteryLevel = Double.parseDouble(transform("JSONPATH", String.format("$.content[%d].batteryLevel", deviceIndex), jsonResponse))
  logInfo(filename, "BatteryLevel:  " + batteryLevel)

  Battery_Status.postUpdate(batteryStatus)
  Battery_Level.postUpdate(batteryLevel * 100)

  if (transform("JSONPATH", String.format("$.content[%d].location.positionType", deviceIndex), jsonResponse) != null) {
    val Long timestampEpoch = Long.parseLong(transform("JSONPATH", String.format("$.content[%d].location.timeStamp", deviceIndex), jsonResponse))
    val SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ")
    val String timestampString = sdf.format(new Date(timestampEpoch))
    timestamp = DateTimeType.valueOf(timestampString)
    logInfo(filename, "Timestamp:      " + timestamp)
    val Double latitude = Double.parseDouble(transform("JSONPATH", String.format("$.content[%d].location.latitude", deviceIndex), jsonResponse))
    logInfo(filename, "Latitude:      " + latitude)
    val Double longitude = Double.parseDouble(transform("JSONPATH", String.format("$.content[%d].location.longitude", deviceIndex), jsonResponse))
    logInfo(filename, "Longitude:     " + longitude)
    val Double accuracy = Double.parseDouble(transform("JSONPATH", String.format("$.content[%d].location.horizontalAccuracy", deviceIndex), jsonResponse))
    logInfo(filename, "Accuracy:      " + accuracy)
    val location = new PointType(new DecimalType(latitude), new DecimalType(longitude))
    logInfo(filename, "Location:      " + location)
  
    Coordinates_Accuracy.postUpdate(accuracy)
    Coordinates.postUpdate(location)
    } 
  return timestamp
]
Function to generate battery state summary
// Function called to generate battery state summary
val Functions$Function2<GenericItem, GenericItem, String> batterySummary= [ Battery_Status, Battery_Level |
	val String status =
		if (Battery_Status.state == "Charging") "(charging...)"
		else if (Battery_Status.state == "Charged") "(charging completed)"
		else ""
	val level = (Battery_Level.state as DecimalType).intValue()
	val summary = String::format("%s %d%%", status, level)

	return summary
]
Function to calculate distance from location
	// Function called to calculate location distance
	val Functions$Function4<GenericItem, PointType, String, Number, String> locationDistance= [ Coordinates, place, placeName, distance2 |
	  val PointType location = Coordinates.state as PointType
	  var int distance
	  var String message
	  // my home location
	  distance = location.distanceFrom(place).intValue()
	  if (distance < distance2) {
	    message = (String::format("%s (%dm)", placeName, distance))
	  } else {
	    message = "(unknown location)"
	    }
	  return message
	]
Function to translate location to address
// Function to transform location coordinates to address
val Functions$Function1<GenericItem, String> locationAddress= [ Coordinates |
	val geocodeURL = "https://maps.googleapis.com/maps/api/geocode/json?latlng=" + iPhone7_Coordinates.state.toString + "&language=german&sensor=true"
	val String geocodeJson = sendHttpGetRequest(geocodeURL)
	var String formattedAddress = transform("JSONPATH", "$.results[0].formatted_address", geocodeJson)
	formattedAddress = formattedAddress.replace(", Germany", "")
	return formattedAddress
]

Main rule to retrieve iCloud data

The following example queries two Apple ID’s with two devices each. This can be handled in one rule and even be extended to more ID’s or devices in one ID.

ATTENTION

You need to have all items defined like shown above and used the in correct order as shown below, otherwise you might get false results or even errors.

iCloud Data Retrieval
rule "iCloud Data Retrieval"
when
	Time cron "0 0/5 * * * ? *"
then

var String jsonResponse = null
var String appleId = null
var String password = null
var Integer deviceIndex = 0
var DateTimeType timestamp

// Example for first AppleID
	logInfo(filename, "Retrieving My Work iCloud Data ...")
	appleId = "My first AppleId"
	password = "secret"

		try{
				jsonResponse = iCloudRetrieve.apply(appleId, password)
				//logInfo(filename, jsonResponse)
		}
		catch (Exception e){
				logError("iCloudRetrieve", "Error in calling iCloudRetrieve: " + e.toString)
		}

//Example for first device in first ApppleID
	deviceIndex = 1
//following item order is important !!!!!
	timestamp = jsonResponseTransform.apply(jsonResponse, deviceIndex, iPhone7_Battery_Status, iPhone7_Battery_Level, iPhone7_Coordinates_Accuracy, iPhone7_Coordinates)
	//logInfo(filename, "TimeStamp:     " + timestamp)
	if (timestamp != null) {
	iPhone7_Location_Timestamp.postUpdate(timestamp)
	}
//End Example for first device in first AppleID

//Example for second device in first ApppleID
	deviceIndex = 0
	timestamp = jsonResponseTransform.apply(jsonResponse, deviceIndex, iPadPro_Battery_Status, iPadPro_Battery_Level, iPadPro_Coordinates_Accuracy, iPadPro_Coordinates)
	//logInfo(filename, "TimeStamp:     " + timestamp)
	if (timestamp != null) {
	iPadPro_Location_Timestamp.postUpdate(timestamp)
	}
//End Example for second device in first AppleID

	logInfo(filename, "My Work iCloud Retrieval Finished")
// End Example for first AppleID

// Example for second AppleID
	logInfo(filename, "Retrieving My Private iCloud Data ...")
	appleId = "My second AppleID"
	password = "secret"

		try{
				jsonResponse = iCloudRetrieve.apply(appleId, password)
				//logInfo(filename, jsonResponse)
		}
		catch (Exception e){
				logError("iCloudRetrieve", "Error in calling iCloudRetrieve: " + e.toString)
		}

//Example for first device in second ApppleID
	deviceIndex = 0
	timestamp = jsonResponseTransform.apply(jsonResponse, deviceIndex, iPhone5_Battery_Status, iPhone5_Battery_Level, iPhone5_Coordinates_Accuracy, iPhone5_Coordinates)
	//logInfo(filename, "TimeStamp:     " + timestamp)
	if (timestamp != null) {
	iPhone5_Location_Timestamp.postUpdate(timestamp)
	}
//End Example for first device in second AppleID

//Example for second device in second ApppleID
	deviceIndex = 1
	timestamp = jsonResponseTransform.apply(jsonResponse, deviceIndex, iPadAir2_Battery_Status, iPadAir2_Battery_Level, iPadAir2_Coordinates_Accuracy, iPadAir2_Coordinates)
	//logInfo(filename, "TimeStamp:     " + timestamp)
	if (timestamp != null) {
	iPadAir2_Location_Timestamp.postUpdate(timestamp)
	}
//End Example for second device in second AppleID

	logInfo(filename, "My Private iCloud Retrieval Finished")
// End Example for second AppleID

end

Other Examples

Rule for battery summary

This rule can be copied for every single device you want to be monitored, just change the item names.

rule "My Work iPhone Battery Summary"
when
  Item iPhone7_Battery_Level changed
then
  logInfo(filename, "My Work iPhone Battery Update")
  iPhone7_Battery.postUpdate(batterySummary.apply(iPhone7_Battery_Status, iPhone7_Battery_Level))
end
Rule for location matching and address translation

This rule can be copied for every single device and location you want to be monitored, just change the item names.

rule "My Work iPhone Location"
when
  Item iPhone7_Coordinates changed
then
  logInfo(filename, "My Work iPhone Location")
  iPhone7_Location.postUpdate(locationDistance.apply(iPhone7_Coordinates, home, "Home" 200))
// If you don't want to translate your GPS coordinates to an address, just remove the following line
  iPhone7_Location_Address.postUpdate(locationAddress.apply(iPhone7_Coordinates))
end

Syntax for distance checking:

device_Location_item.postUpdate(locationDistance.apply(device_Coordinates_item, location_PointType, String_PlaceName, Number_Distance_to_check)
23 Likes

that looks fabulously useful… but a stupid question: is this is a normal OH rule? Doesn’t look like XTend…

Hey @patrik_gfeller, that looks interesting indeed. I shall test it at my brothers home :blush:
As you can grasp from @dan12345 's answer it might be beneficial to explain the way this script works, mention limitations and evaluate the accuracy. I’d for sure be interested in these details. Best and Thanks!

1 Like

I´m not an expert - I just checked the source code of the vera plugin and some other sources & with the help of OH-Designer ported it to a rule. It works as a normal rule - the syntax is probably closer to JAVA; but as said … other may elaborate the differences.

It works as a rule if the imports are present & of course the json transform needs to be present. If I understood right xtend is an extension to java - thus some things might be not as elegant as possible as there might be syntax suger that I could have used …

let me know if it works for you :slight_smile:

Sorry - I´m not sure about limitations … but let me know if you have problems; I´ll do my best to help. The script requests a json from apple server; in the answer you´ll also find the current accuracy, battery state as well as other information about the device queried.

Note: if there are more than one device registered the JSON paths need to be adjusted. Again - I´ll try to help if necessary.

Let me know if it works :slight_smile:

thanks!

Out of interest, what does println do in an openHAB rule, given there’s no standard output as such?

I start OH2 via batch script; then the shell is used as standard out:

2017-07-28 17_02_36-Haus

If you run OH as service you may use the log commands … I use println during rule development - as it gives me feedback w/o the need to open the .log.

with kind regards,
Patrik

Never even knew that’s possible. Wonder if we should not “promote” it further via a tutorial. Will just confuse some newbies.
I’ll test the script as soon as my brother is back in his apartment next week and will send possible rule improvements :wink:

Any thoughts on if this will work with 2 factor auth ?

… to my own surprise it seems to work.

with kind regards,
Patrik

1 Like

Hi!

Would be cool if the position could be shown on a Street map e.g. in Habpanel - any ideas?

Herbert

Any idea if you have multiple apple devices ?
I can’t get it to work, and i suspect that having many devices, might be the case.

Hi Robert,

yes - if you have multiple devices some small adjustments are required. For each device you´ll get a “content” in the JSON response. You can extract the location of the device by using the index in the JSON path. Below example will get the location of the 2nd device:

   logInfo(".rules", sb.toString())
   
   var String _longitude = transform("JSONPATH", "$.content[1].location.longitude", sb.toString())
   var String _latitude = transform("JSONPATH", "$.content[1].location.latitude", sb.toString())
   var String _positionString = _latitude + "," + _longitude

1st add the logInfo to your rule to check the response (I recommend an online json viewer); then you should be able to find yout which device has what index. As a second step adjust the JSON path accordingly. You can also extract the location of more than one device for the response of course.

let me know if this helped, or if you need more details.

with kind regards,
Patrik

I do not know about habpanel - but this might help you the get started (I use ii in basic UI):

let me know if you need help with the map example (maybe in the other topic); of course I also like to know if it works :slight_smile:

with kind regards,
Patrik

@patrik_gfeller, this is great. We were able to retrieve some nice data from the API. You should think about renaming the thread to something like “iCloud device data integration in openHAB”.

As promised I did some digging myself and cam up with a few enhancements and corrections. The following is your code but extended. Please feel free to copy it to the first posting, I’ll redact it here later.

[… redacted …]

2 Likes

This looks excellent!! I’ve been looking for a way of switching off a smart switch when my iPhone is fully charged, so that it doesn’t continually over charge. This looks like the starting point to a solution! I’ll have to have a play…

1 Like

Continuing…

Add this to the end of the rule given above:

[… redacted …] can be found in the first posting.

@patrik_gfeller once again, feel free to transfer :wink:

1 Like

@patrik_gfeller Thx for the hint - will take some time until I try that.

@ThomDietrich Thx for the code --> in your distance to work location your variable should be named work instead of home

Regards,
Herbert

I followed your and @patrik_gfeller helpful instructions, but I get the following error, when Openhab tries to format the location in BasicUI:

2017-08-01 10:50:56.529 [WARN ] [ui.internal.items.ItemUIRegistryImpl] - Exception while formatting value '49.xxxxxxxxx,8.xxxxxxxxxx' of item iPhone_Coordinates with format '%.0f': java.util.IllegalFormatConversionException: f != org.eclipse.smarthome.core.library.types.DecimalType

Any hints for that issue?

Thanks, you are right. That just happened because I replaced my actual places (which are not called home and work).