iCloud Binding Location Accuracy and Blockly GIS DistanceFrom UoM

In preparation for a future migration to openHAB 4 where I understand UOM requirements will be more rigorously enforced, I have been reviewing how I am using UOM in some of my rules. I use the Location from the iCloud binding in Blockly with the Distance From block in the Geographic information system library. I also use the Location Accuracy from the binding.

At one time my wife’s iphone Location accuracy would fluctuate, so my rules add the Location Accuracy to a designated “at home” radius, which is obtained from a Number:Length Item that is set every time openHAB starts to 75 m (it usually is restored by the persistence).

The Location Accuracy Channel in the binding is a “number”, not a Number:Length, according to the binding documentation and the UI.

I tried linking a new Number:Length Item to the channel in the Thing UI. Since I am in the USA, I expected the units would default to Feet. But after I linked it (and before I did anything to the UOM Metadata or State Description, I was surprised to see that the state of the item was in m:

To make my original rule work, I had to set variables to the states of the items, then I could add them:


(The make block is because I am doing the same thing on 6 different iCloud devices, and get information from the triggering item to set the parts of the item name).
I stick a " m" on the end of the variable, and when I post it to a Number:Length item, everything works. If I set the State Description to us US Customary Units (ft), the conversion happens as expected.

While what I have has worked, I feel like I’m living on the edge and it could very well not work in openHAB 4.

I prefer to use UOM whenever possible. Remember that NASA’s Mars Climate Orbiter crashed into Mars because NASA used SI units and the Jet Propulsion Laboratory used US Customary Units, and someone failed to convert when they should have.

I think that I can probably use my new Location Accuracy Number:Length Item and change how I am using the Distance From block. If I post the results of the distance from (which although in meters is dimensionless) to a Number:Length Item that has an openHAB 4 UoM Metadata or openHAB 3 State Description of “m”, then I should be able to use the items, now both with UOM, and not have to use variables, and the calculations and comparison operations should all work as expected.

While I believe the underlying Java DistanceFrom method returns a dimensionless number (that is in meters), perhaps the Blockly Distance From Block could have a dropdown where one could choose, dimensionless or specify a unit.

I’m open to doing things in a better way that is more likely to stand the test of time than my current approach.

Here is the code generated by Blockly for the entire rule.

var List_of_Item_Name_Components, User, Device, Device_Distance_From_Ossineke, iCloud_Device_Presence_Radius, Device_Accuracy, iCloud_Device_Accuracy_Plus_Presence_Radius, New_Presence_State, iPhone_Location, Watch_Location;

var logger = Java.type('org.slf4j.LoggerFactory').getLogger('org.openhab.rule.' + ctx.ruleUID);

function geo_distance(position1, position2) {
  var lat1 = position1.latitude;
  var lat2 = position2.latitude;
  var lon1 = position1.longitude;
  var lon2 = position2.longitude;
  var R = 6371e3; // metres
  var o1 = lat1 * Math.PI/180;
  var o2 = lat2 * Math.PI/180;
  var Ao = (lat2-lat1) * Math.PI/180;
  var Ab = (lon2-lon1) * Math.PI/180;
  var a = Math.sin(Ao/2) * Math.sin(Ao/2) +
  Math.cos(o1) * Math.cos(o2) *
  Math.sin(Ab/2) * Math.sin(Ab/2);
  var c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
  return R * c;
}

function geo_position(latitude, longitude) {
  return {'latitude': latitude, 'longitude': longitude};
}

var chronoUnit = Java.type("java.time.temporal.ChronoUnit");

var dtf = Java.type("java.time.format.DateTimeFormatter");

var zdt = Java.type("java.time.ZonedDateTime");

function getZonedDateTime(datetime) {
  datetime = String(datetime).replace('T', ' ')
  var regex_time_min =         /^\d{2}:\d{2}$/;
  var regex_time_sec =         /^\d{2}:\d{2}:\d{2}$/;
  var regex_date =             /^\d{4}-\d{2}-\d{2}$/;
  var regex_date_time_min =    /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/;
  var regex_date_time_sec =    /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
  var regex_date_time_sec_tz = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/;
  var regex_date_time_ms =     /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}\.\d{3}$/;
  var regex_date_time_us =     /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}\.\d{6}$/;
  var regex_date_time_ms_tz =  /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/;
  var regex_date_time_us_tz =  /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}\.\d{6}[+-]\d{2}:\d{2}$/;
  var regex_oh =               /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{4}$/;
  var now = zdt.now();
  var now_year = now.getYear();
  var now_month = now.getMonthValue();
  var now_day = now.getDayOfMonth();
  var today = '' + now_year;
  today += '-' + ('0' + now_month).slice(-2);
  today += '-' + ('0' + now_day).slice(-2)+' ';
  switch (true) {
    case regex_time_min.test(datetime): return zdt.parse(today + datetime + ':00+00:00', dtf.ofPattern('yyyy-MM-dd HH:mm:ssz'));
    case regex_time_sec.test(datetime): return zdt.parse(today + datetime + '+00:00', dtf.ofPattern('yyyy-MM-dd HH:mm:ssz'));
    case regex_date.test(datetime): return zdt.parse(datetime + ' 00:00:00+00:00', dtf.ofPattern('yyyy-MM-dd HH:mm:ssz'));
    case regex_date_time_min.test(datetime): return zdt.parse(datetime + ':00+00:00', dtf.ofPattern('yyyy-MM-dd HH:mm:ssz'));
    case regex_date_time_sec.test(datetime): return zdt.parse(datetime + '+00:00', dtf.ofPattern('yyyy-MM-dd HH:mm:ssz'));
    case regex_date_time_sec_tz.test(datetime): return zdt.parse(datetime, dtf.ofPattern('yyyy-MM-dd HH:mm:ssz'));
    case regex_date_time_ms.test(datetime): return zdt.parse(datetime + ' +00:00', dtf.ofPattern('yyyy-MM-dd HH:mm:ss.SSS z'));
    case regex_date_time_us.test(datetime): return zdt.parse(datetime + ' +00:00', dtf.ofPattern('yyyy-MM-dd HH:mm:ss.SSSSSS z'));
    case regex_date_time_ms_tz.test(datetime): return zdt.parse(datetime, dtf.ofPattern('yyyy-MM-dd HH:mm:ss.SSSSz'));
    case regex_date_time_us_tz.test(datetime): return zdt.parse(datetime, dtf.ofPattern('yyyy-MM-dd HH:mm:ss.SSSSSSSz'));
    case regex_oh.test(datetime): return zdt.parse(datetime.slice(0,26) + ':' + datetime.slice(26,28), dtf.ofPattern('yyyy-MM-dd HH:mm:ss.SSSSz'));
    default: return zdt.parse(datetime);
  }
}

function createZonedDateTime(year, month, day, hour, minute, second, nano, offsetString, timezoneString) {
  stringToParse = '' + year;
  stringToParse += '-' + ('0' + month).slice(-2);
  stringToParse += '-' + ('0' + day).slice(-2);
  stringToParse += 'T' + ('0' + hour).slice(-2);
  stringToParse += ':' + ('0' + minute).slice(-2);
  stringToParse += ':' + ('0' + second).slice(-2);
  stringToParse += '.' + nano + offsetString + '[' + timezoneString + ']';
  return zdt.parse(stringToParse, dtf.ISO_ZONED_DATE_TIME);
}


List_of_Item_Name_Components = event.itemName.split('_');
User = List_of_Item_Name_Components[2];
Device = List_of_Item_Name_Components[3];
logger.info((['Rule: ',ctx.ruleUID,' triggered by Item: ',event.itemName,' User: ',User,' Device: ',Device].join('')));
if (event.itemState != 'UNDEF') {
  Device_Distance_From_Ossineke = String(geo_distance(event.itemState, geo_position(XX.093643, -YY.337313))) + ' m';
  events.postUpdate(([User, Device, 'iCloud_Distance_From_Ossineke'].join('_')), Device_Distance_From_Ossineke);
}
iCloud_Device_Presence_Radius = itemRegistry.getItem('iCloudHomeRadius').getState();
Device_Accuracy = itemRegistry.getItem((['Most_Recent', User, Device, 'Accuracy'].join('_'))).getState();
iCloud_Device_Accuracy_Plus_Presence_Radius = String(Device_Accuracy + iCloud_Device_Presence_Radius) + ' m';
logger.info(iCloud_Device_Accuracy_Plus_Presence_Radius);
events.postUpdate(([User, Device, 'iCloud_Presence_Radius_Plus_Accuracy'].join('_')), iCloud_Device_Accuracy_Plus_Presence_Radius);
New_Presence_State = itemRegistry.getItem(([User, Device, 'iCloud_Distance_From_Ossineke'].join('_'))).getState() <= itemRegistry.getItem(([User, Device, 'iCloud_Presence_Radius_Plus_Accuracy'].join('_'))).getState() ? 'ON' : 'OFF';
if (itemRegistry.getItem(([User, Device, 'iCloud_Is_At_Ossineke'].join('_'))).getState() != New_Presence_State) {
  events.postUpdate(([User, Device, 'iCloud_Is_At_Ossineke'].join('_')), New_Presence_State);
}
iPhone_Location = itemRegistry.getItem((['Most_Recent', User, 'iPhone', 'Location'].join('_'))).getState();
Watch_Location = itemRegistry.getItem((['Most_Recent', User, 'Watch', 'Location'].join('_'))).getState();
logger.info((['Time between iphone and watch location updates: ',chronoUnit.SECONDS.between((itemRegistry.getItem((['Most_Recent', User, 'iPhone', 'Last_Location_Update'].join('_'))).getState().getZonedDateTime()),(itemRegistry.getItem((['Most_Recent', User, 'Watch', 'Last_Location_Update'].join('_'))).getState().getZonedDateTime())),' for user: ',User,' with iPhone source: ',itemRegistry.getItem((['Most_Recent', User, 'iPhone', 'Source'].join('_'))).getState(),'  and Watch iPhone source: ',itemRegistry.getItem((['Most_Recent', User, 'iPhone', 'Source'].join('_'))).getState(),'  and Watch and iPhone are separated by: ',geo_distance(iPhone_Location, Watch_Location),' Watch home state: ',itemRegistry.getItem(([User, 'Watch', 'iCloud_Is_At_Ossineke'].join('_'))).getState(),' iPhone home state: ',itemRegistry.getItem(([User, 'iPhone', 'iCloud_Is_At_Ossineke'].join('_'))).getState(),' m/s: ',(chronoUnit.SECONDS.between((itemRegistry.getItem((['Most_Recent', User, 'iPhone', 'Last_Location_Update'].join('_'))).getState().getZonedDateTime()),(itemRegistry.getItem((['Most_Recent', User, 'Watch', 'Last_Location_Update'].join('_'))).getState().getZonedDateTime()))) != 0 ? (geo_distance(iPhone_Location, Watch_Location)) / (chronoUnit.SECONDS.between((itemRegistry.getItem((['Most_Recent', User, 'iPhone', 'Last_Location_Update'].join('_'))).getState().getZonedDateTime()),(itemRegistry.getItem((['Most_Recent', User, 'Watch', 'Last_Location_Update'].join('_'))).getState().getZonedDateTime()))) : 0].join('')));
if (iPhone_Location != 'UNDEF' && Watch_Location != 'UNDEF') {
  events.postUpdate(([User, 'Watch_Distance_From_iPhone'].join('_')), (String(geo_distance(iPhone_Location, Watch_Location)) + ' m'));
  if (itemRegistry.getItem(([User, 'Watch_Distance_From_iPhone'].join('_'))).getState() <= itemRegistry.getItem('iCloudHomeRadius').getState()) {
    if (itemRegistry.getItem(([User, 'Watch_Near_iPhone'].join('_'))).getState() != 'ON') {
      events.postUpdate(([User, 'Watch_Near_iPhone'].join('_')), 'ON');
    }
  } else {
    if (itemRegistry.getItem(([User, 'Watch_Near_iPhone'].join('_'))).getState() != 'OFF') {
      events.postUpdate(([User, 'Watch_Near_iPhone'].join('_')), 'ON');
    }
  }
} else {
  events.postUpdate(([User, 'Watch_Distance_From_iPhone'].join('_')), 'UNDEF');
}
logger.info((['Verify Watch distance from iPhone Calculation. Watch Distance from iPhone: ',itemRegistry.getItem(([User, 'Watch_Distance_From_iPhone'].join('_'))).getState(),' Radius: ',itemRegistry.getItem('iCloudHomeRadius').getState(),' Are close? ',itemRegistry.getItem(([User, 'Watch_Near_iPhone'].join('_'))).getState()].join('')));

And a little about my set up:

runtimeInfo:
  version: 3.4.4
  buildString: Release Build
locale: en-US
systemInfo:
  configFolder: /etc/openhab
  userdataFolder: /var/lib/openhab
  logFolder: /var/log/openhab
  javaVersion: 17.0.6
  javaVendor: Raspbian
  osName: Linux
  osVersion: 6.1.21-v8+
  osArchitecture: arm
  availableProcessors: 4
  freeMemory: 9454608
  totalMemory: 259522560
  startLevel: 100
bindings:
  - amazonechocontrol
  - androidtv
  - astro
  - denonmarantz
  - exec
  - gpstracker
  - icalendar
  - icloud
  - ipcamera
  - irobot
  - logreader
  - mail
  - mqtt
  - myq
  - network
  - networkupstools
  - ntp
  - openweathermap
  - remoteopenhab
  - ring
  - roku
  - tplinksmarthome
  - tuya
  - zwave
clientInfo:
  device:
    ios: false
    android: false
    androidChrome: false
    desktop: true
    iphone: false
    ipod: false
    ipad: false
    edge: false
    ie: false
    firefox: false
    macos: false
    windows: true
    cordova: false
    phonegap: false
    electron: false
    nwjs: false
    webView: false
    webview: false
    standalone: false
    os: windows
    pixelRatio: 1.5
    prefersColorScheme: light
  isSecureContext: false
  locationbarVisible: true
  menubarVisible: true
  navigator:
    cookieEnabled: true
    deviceMemory: N/A
    hardwareConcurrency: 8
    language: en-US
    languages:
      - en-US
      - en
    onLine: true
    platform: Win32
  screen:
    width: 1707
    height: 1067
    colorDepth: 24
  support:
    touch: false
    pointerEvents: true
    observer: true
    passiveListener: true
    gestures: false
    intersectionObserver: true
  themeOptions:
    dark: light
    filled: true
    pageTransitionAnimation: default
    bars: filled
    homeNavbar: default
    homeBackground: default
    expandableCardAnimation: default
  userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML,
    like Gecko) Chrome/114.0.0.0 Safari/537.36
timestamp: 2023-06-26T21:36:54.509Z

Continuing to experiment, I was able to directly post the Distance From output to the Number:Length item, with the expected result. But I am unable to add the two Number:Length items using the Blockly Math operations box. I have tried dragging the State of Item, but it won’t let me attach it to the expected math input.

But it’s also opt in. You can opt out of UoM by simply using Number Items. No longer are you forced to use units just because the channel does.

Similarly, you can use a Number:Length in this case too. Just make sure to add unit metadata so OH knows how to interpret the number

Have you set your locale and default units?

OH 4 makes this all a lot less iffy. If it’s a Number Item, it never carries units. If it’s a Number:Distance Item, it will always carry units. The unit defined in the metadata field takes precedence. If not defined the unit spoiled by the channel. If none then the system default. If the channel supplies units and the unit metadata is defined, the value is covered to the metadata units automatically. State description only serves to change how the value appears in the UI

I haven’t looked closely at the blockly support for units (there was a lot of work done for 4.0) but if you set the unit md to ft and post “123 m” where 123 is the result of the distance calculation the m will be converted to ft automatically. However, if you just post 123, it will assume that’s 123 ft.

The code is going to look very different for 4.0 as blockly now compiles to ECMAScript 11 with the helper library.

There will be an upgrade step after upgrading to build the new JS code.

There is a new separate set of blocks to add values with units that I think weren’t added until OH 4. The standard blocks won’t understand units.

In OH 3 you need to strip the unit off the number first.

@rlkoshak Thanks for your (as always) thorough and educational response.

Just to confirm my understanding, in OH3, this is done in the State Description Metadata, while in OH4, there is a new Metadata category for Units, and the State Description only affects how it is displayed.

Locale: yes, and I assumed that would carry with it default units. But when I looked in the UI under Settings/Regional Settings with Show Advanced checked, I see that neither Default Units Block is checked. So that would explain why it didn’t default to Ft for the Number:Length Item, but still not sure why it picked meters (correctly). Not a big deal and I now know what to do and why.

If I understood the documentation, the upgrade step is to 1) add the JavaScript Add-on and then 2) for each rule that uses Blockly, open the Blockly (not just the rule, but the Blockly) and save it, which will cause the script to be rewritten in the new JS code.

Looking forward to that.

Correct. It caused all sorts of problems to use State Description for both how it’s displayed and controlling the unit the Item carries. So they’ve been separated. State Description only controls how it’s displayed.

The upgrade tool will look through your Items and create unit metadata that corresponds with the unit in your current State Description patterns. Or, if there isn’t one or the unit is %unit% the unit metadata will be what the Channel says it should be. So you probably don’t need to do anything to upgrade. However, I recommend before upgrading deciding what units you want your Items to be (if any) and set unit metadata yourself to ensure everything works as it should. In OH 3.4 the metadata will be ignored and if it already exists the upgrade tool won’t mess with it.

That will let you deliberately choose the units ahead of the upgrade and there will be no surprises.

Most of the world uses metric so that’s the default even when nothing is selected.

That’s my understanding as well.