From ccecde38af4238775212c7c1666257b1919a5087 Mon Sep 17 00:00:00 2001 From: gcobb321 Date: Mon, 2 Nov 2020 11:03:17 -0500 Subject: [PATCH] iCloud3 v2.2.1 General Release --- custom_components/icloud3/__init__.py | 2 +- custom_components/icloud3/device_tracker.py | 1777 +-- .../icloud3/icloud3-event-log-card.js | 738 +- custom_components/icloud3/pyicloud_ic3.py | 18 +- custom_components/icloud3/services.yaml | 5 +- development area - v2.2.1/change log.txt | 49 - development area - v2.2.1/device_tracker.py | 9541 ----------------- development area - v2.2.1/iCloud3 v2.2.1f.zip | Bin 105874 -> 0 bytes .../icloud3-event-log-card.js | 1544 --- development area - v2.2.1/pyicloud_ic3.py | 924 -- icloud3.zip | Bin 118740 -> 129815 bytes www/custom_cards/icloud3-event-log-card.js | 738 +- 12 files changed, 1939 insertions(+), 13397 deletions(-) delete mode 100644 development area - v2.2.1/change log.txt delete mode 100644 development area - v2.2.1/device_tracker.py delete mode 100644 development area - v2.2.1/iCloud3 v2.2.1f.zip delete mode 100644 development area - v2.2.1/icloud3-event-log-card.js delete mode 100644 development area - v2.2.1/pyicloud_ic3.py diff --git a/custom_components/icloud3/__init__.py b/custom_components/icloud3/__init__.py index d8c2ac7..6aa6c4f 100644 --- a/custom_components/icloud3/__init__.py +++ b/custom_components/icloud3/__init__.py @@ -1 +1 @@ -""" iCloud3 Device Tracker """ +"""iCloud3 Device Tracker""" diff --git a/custom_components/icloud3/device_tracker.py b/custom_components/icloud3/device_tracker.py index 8fb6926..6947acb 100644 --- a/custom_components/icloud3/device_tracker.py +++ b/custom_components/icloud3/device_tracker.py @@ -22,7 +22,7 @@ #pylint: disable=unused-argument, unused-variable #pylint: disable=too-many-instance-attributes, too-many-lines -VERSION = '2.2.0' +VERSION = '2.2.1' #Symbols = •▶¦▶ ●►◄ ▬ ▲▼◀▶ oPhone=►▶► @@ -95,13 +95,15 @@ CONF_DEVICENAME = 'device_name' CONF_NAME = 'name' CONF_TRACKING_METHOD = 'tracking_method' -CONF_IOSAPP_LOCATE_REQUEST_MAX_CNT = 'iosapp_locate_request_max_cnt' +CONF_IOSAPP_REQUEST_LOC_MAX_CNT = 'iosapp_request_loc_max_cnt' CONF_TRACK_DEVICES = 'track_devices' CONF_TRACK_DEVICE = 'track_device' CONF_UNIT_OF_MEASUREMENT = 'unit_of_measurement' +CONF_TIME_FORMAT = 'time_format' CONF_INTERVAL = 'interval' CONF_BASE_ZONE = 'base_zone' CONF_INZONE_INTERVAL = 'inzone_interval' +CONF_DISPLAY_ZONE_FNAME = 'display_zone_fname' CONF_CENTER_IN_ZONE = 'center_in_zone' CONF_STATIONARY_STILL_TIME = 'stationary_still_time' CONF_STATIONARY_INZONE_INTERVAL = 'stationary_inzone_interval' @@ -187,11 +189,21 @@ ATTR_EVENT_LOG = 'event_log' ATTR_PICTURE = 'entity_picture' - +#General constants +HOME = 'home' +NOT_HOME = 'not_home' +NOT_SET = 'not_set' +STATIONARY = 'stationary' +AWAY_FROM = 'AwayFrom' +AWAY = 'Away' +PAUSED = 'Paused' +PAUSED_CAPS = 'PAUSED' +UNKNOWN = 'Unknown' +NEVER = 'Never' +ERROR = 'Error' TIMESTAMP_ZERO = '0000-00-00 00:00:00' HHMMSS_ZERO = '00:00:00' HIGH_INTEGER = 9999999999 -TIME_24H = True UTC_TIME = True LOCAL_TIME = False NUMERIC = True @@ -218,8 +230,8 @@ ATTR_NAME: '', ATTR_DEVICE_CLASS: 'iPhone', ATTR_BATTERY_LEVEL: 0, - ATTR_BATTERY_STATUS: 'Unknown', - ATTR_DEVICE_STATUS: '', + ATTR_BATTERY_STATUS: UNKNOWN, + ATTR_DEVICE_STATUS: UNKNOWN, ATTR_LOW_POWER_MODE: False, ATTR_TIMESTAMP: 0, ATTR_TIMESTAMP_TIME: HHMMSS_ZERO, @@ -349,9 +361,9 @@ SENSOR_ATTR_ICON = { 'zone': 'mdi:cellphone-iphone', - 'last_zone': 'mdi:cellphone-iphone', - 'base_zone': 'mdi:cellphone-iphone', - 'zone_timestamp': 'mdi:restore-clock', + 'last_zone': 'mdi:map-clock-outline', + 'base_zone': 'mdi:map-clock', + 'zone_timestamp': 'mdi:clock-in', 'zone_distance': 'mdi:map-marker-distance', 'calc_distance': 'mdi:map-marker-distance', 'waze_distance': 'mdi:map-marker-distance', @@ -359,14 +371,14 @@ 'dir_of_travel': 'mdi:compass-outline', 'interval': 'mdi:clock-start', 'info': 'mdi:information-outline', - 'last_located': 'mdi:restore-clock', - 'last_update': 'mdi:restore-clock', + 'last_located': 'mdi:history', + 'last_update': 'mdi:history', 'next_update': 'mdi:update', 'poll_count': 'mdi:counter', 'travel_distance': 'mdi:map-marker-distance', 'trigger': 'mdi:flash-outline', - 'battery': 'mdi:battery', - 'battery_status': 'mdi:battery', + 'battery': 'mdi:battery-outline', + 'battery_status': 'mdi:battery-outline', 'gps_accuracy': 'mdi:map-marker-radius', 'altitude': 'mdi:image-filter-hdr', 'vertical_accuracy': 'mdi:map-marker-radius', @@ -408,32 +420,48 @@ 'name': 'name', } - -ATTR_TIMESTAMP_FORMAT = '%Y-%m-%d %H:%M:%S.%f' -APPLE_DEVICE_TYPES = ['iphone', 'ipad', 'ipod', 'watch', 'iwatch', 'icloud', - 'iPhone', 'iPad', 'iPod', 'Watch', 'iWatch', 'iCloud'] +ATTR_TIMESTAMP_FORMAT = '%Y-%m-%d %H:%M:%S.%f' +APPLE_DEVICE_TYPES = ['iphone', 'ipad', 'ipod', 'watch', 'iwatch', 'icloud', + 'iPhone', 'iPad', 'iPod', 'Watch', 'iWatch', 'iCloud'] FMF_FAMSHR_LOCATION_FIELDS = ['altitude', 'latitude', 'longitude', 'timestamp', 'horizontalAccuracy', 'verticalAccuracy', ATTR_ICLOUD_BATTERY_STATUS] + +#Convert zone/state value to display value +ZONE_TO_DISPLAY_BASE = { + None: "not_home", + "not_home": "Away", + "not_set": "NotSet", + "away": "Away", + "stationary": "Stationary", + "nearzone": "NearZone", + "near_zone": "NearZone", + } +#Convert state non-fname value to internal zone/state value +STATE_TO_ZONE_BASE = { + "NotSet": "not_set", + "Away": "not_home", + "away": "not_home", + "NotHome": "not_home", + "nothome": "not_home", + "Stationary": "stationary", + "stationary": "stationary", + "NearZone": "nearzone", + "NearZone": "near_zone", + } + #icloud_update commands CMD_ERROR = 1 CMD_INTERVAL = 2 CMD_PAUSE = 3 CMD_RESUME = 4 CMD_WAZE = 5 +CMD_LOCATION = 6 #Other constants IOSAPP_DT_ENTITY = True ICLOUD_DT_ENTITY = False ICLOUD_LOCATION_DATA_ERROR = False -#General constants -HOME = 'home' -NOT_HOME = 'not_home' -NOT_SET = 'not_set' -STATIONARY = 'stationary' -AWAY_FROM = 'AwayFrom' -AWAY = 'Away' -PAUSED = 'Paused' STATIONARY_LAT_90 = 90 STATIONARY_LONG_180 = 180 STAT_ZONE_NEW_LOCATION = True @@ -446,7 +474,6 @@ ICLOUD3_ERROR_MSG = "ICLOUD3 ERROR-SEE EVENT LOG" #Devicename config parameter file extraction - DI_DEVICENAME = 0 DI_DEVICE_TYPE = 1 DI_NAME = 2 @@ -456,7 +483,6 @@ DI_IOSAPP_SUFFIX = 6 DI_ZONES = 7 - #Waze status codes WAZE_REGIONS = ['US', 'NA', 'EU', 'IL', 'AU'] WAZE_USED = 0 @@ -465,6 +491,16 @@ WAZE_OUT_OF_RANGE = 3 WAZE_NO_DATA = 4 +#Interval range table used for setting the interval based on a retry count +#The key is starting retry count range, the value is the interval (in minutes) +#poor_location_gps cnt, icloud_authentication cnt (default) +POOR_LOC_GPS_CNT = 1.1 +AUTH_ERROR_CNT = 1.2 +RETRY_INTERVAL_RANGE_1 = {0:.25, 4:1, 8:5, 12:30, 16:60, 20:120, 24:240} +#request iosapp location retry cnt +IOSAPP_REQUEST_LOC_CNT = 2.1 +RETRY_INTERVAL_RANGE_2 = {0:.5, 4:2, 8:30, 12:60, 14:120, 16:240, 18:480} + #Used by the 'update_method' in the polling_5_sec loop IOSAPP_UPDATE = "IOSAPP" ICLOUD_UPDATE = "ICLOUD" @@ -604,7 +640,7 @@ vol.Optional(CONF_PASSWORD, default=''): cv.string, vol.Optional(CONF_GROUP, default='group'): cv.slugify, vol.Optional(CONF_TRACKING_METHOD, default=FMF): cv.slugify, - vol.Optional(CONF_IOSAPP_LOCATE_REQUEST_MAX_CNT, default=100): cv.string, + vol.Optional(CONF_IOSAPP_REQUEST_LOC_MAX_CNT, default=HIGH_INTEGER): cv.string, vol.Optional(CONF_ENTITY_REGISTRY_FILE): cv.string, vol.Optional(CONF_CONFIG_IC3_FILE_NAME, default=''): cv.string, vol.Optional(CONF_EVENT_LOG_CARD_DIRECTORY, default='www/custom_cards'): cv.string, @@ -613,7 +649,9 @@ #-----►►General Attributes ---------- vol.Optional(CONF_UNIT_OF_MEASUREMENT, default='mi'): cv.slugify, + vol.Optional(CONF_TIME_FORMAT, default=0): cv.string, vol.Optional(CONF_INZONE_INTERVAL, default='2 hrs'): cv.string, + vol.Optional(CONF_DISPLAY_ZONE_FNAME, default=False): cv.boolean, vol.Optional(CONF_CENTER_IN_ZONE, default=False): cv.boolean, vol.Optional(CONF_MAX_INTERVAL, default='4 hrs'): cv.string, vol.Optional(CONF_TRAVEL_TIME_FACTOR, default=.60): cv.string, @@ -622,7 +660,7 @@ vol.Optional(CONF_IGNORE_GPS_ACC_INZONE, default=True): cv.boolean, vol.Optional(CONF_HIDE_GPS_COORDINATES, default=False): cv.boolean, vol.Optional(CONF_LOG_LEVEL, default=''): cv.string, - vol.Optional(CONF_DEVICE_STATUS, default='online'): cv.string, + vol.Optional(CONF_DEVICE_STATUS, default='online, pending'): cv.string, #-----►►Filter, Include, Exclude Devices ---------- vol.Optional(CONF_TRACK_DEVICES, default=[]): vol.All(cv.ensure_list, [cv.string]), @@ -646,7 +684,9 @@ DEFAULT_CONFIG_VALUES = { CONF_UNIT_OF_MEASUREMENT: 'mi', + CONF_TIME_FORMAT: 0, CONF_INZONE_INTERVAL: '2 hrs', + CONF_DISPLAY_ZONE_FNAME: False, CONF_CENTER_IN_ZONE: False, CONF_MAX_INTERVAL: '4 hrs', CONF_TRAVEL_TIME_FACTOR: .60, @@ -655,7 +695,7 @@ CONF_IGNORE_GPS_ACC_INZONE: True, CONF_HIDE_GPS_COORDINATES: False, CONF_LOG_LEVEL: '', - CONF_DEVICE_STATUS: 'online', + CONF_DEVICE_STATUS: 'online, pending', CONF_TRACK_DEVICES: [], CONF_TRACK_DEVICE: [], CONF_DISTANCE_METHOD: 'waze', @@ -736,7 +776,7 @@ def format_gps(latitude, longitude, accuracy, latitude_to=None, longitude_to=Non '''Format the GPS string for logs & messages''' accuracy_text = (f"/{accuracy}m)") if accuracy > 0 else "" - gps_to_text = (f" to [{round(latitude_to, 6)}, {round(longitude_to, 6)}]") if latitude_to else "" + gps_to_text = (f" to ({round(latitude_to, 6)}, {round(longitude_to, 6)})") if latitude_to else "" gps_text = f"({round(latitude, 6)}, {round(longitude, 6)}){accuracy_text}{gps_to_text}" return gps_text #============================================================================== @@ -758,7 +798,7 @@ def setup_scanner(hass, config: dict, see, discovery_info=None): entity_registry_file = config.get(CONF_ENTITY_REGISTRY_FILE) config_ic3_file_name = config.get(CONF_CONFIG_IC3_FILE_NAME) event_log_card_directory = config.get(CONF_EVENT_LOG_CARD_DIRECTORY) - iosapp_locate_request_max_cnt = int(config.get(CONF_IOSAPP_LOCATE_REQUEST_MAX_CNT)) + iosapp_request_location_max_cnt = int(config.get(CONF_IOSAPP_REQUEST_LOC_MAX_CNT)) legacy_mode = config.get(CONF_LEGACY_MODE) display_text_as = config.get(CONF_DISPLAY_TEXT_AS) @@ -793,12 +833,14 @@ def setup_scanner(hass, config: dict, see, discovery_info=None): inzone_interval_str = config.get(CONF_INZONE_INTERVAL) max_interval_str = config.get(CONF_MAX_INTERVAL) + display_zone_fname = config.get(CONF_DISPLAY_ZONE_FNAME) center_in_zone_flag = config.get(CONF_CENTER_IN_ZONE) gps_accuracy_threshold = config.get(CONF_GPS_ACCURACY_THRESHOLD) old_location_threshold_str = config.get(CONF_OLD_LOCATION_THRESHOLD) ignore_gps_accuracy_inzone_flag = config.get(CONF_IGNORE_GPS_ACC_INZONE) hide_gps_coordinates = config.get(CONF_HIDE_GPS_COORDINATES) unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + time_format = config.get(CONF_TIME_FORMAT) stationary_inzone_interval_str = config.get(CONF_STATIONARY_INZONE_INTERVAL) stationary_still_time_str = config.get(CONF_STATIONARY_STILL_TIME) @@ -828,14 +870,15 @@ def setup_scanner(hass, config: dict, see, discovery_info=None): ICLOUD3_GROUP_OBJS[group] = Icloud3( hass, see, username, password, group, base_zone, tracking_method, track_devices, - iosapp_locate_request_max_cnt, inzone_interval_str, - max_interval_str, center_in_zone_flag, + iosapp_request_location_max_cnt, inzone_interval_str, + max_interval_str, display_zone_fname, center_in_zone_flag, gps_accuracy_threshold, old_location_threshold_str, stationary_inzone_interval_str, stationary_still_time_str, stationary_zone_offset, ignore_gps_accuracy_inzone_flag, hide_gps_coordinates, sensor_ids, exclude_sensor_ids, - unit_of_measurement, travel_time_factor, distance_method, + unit_of_measurement, time_format, + travel_time_factor, distance_method, waze_region, waze_realtime, waze_max_distance, waze_min_distance, log_level, device_status, display_text_as, entity_registry_file, config_ic3_file_name, event_log_card_directory @@ -892,8 +935,9 @@ def _service_callback_setinterval(call): def _service_callback_lost_iphone(call): """Call the lost iPhone function if the device is found.""" - groups = call.data.get(CONF_GROUP, ICLOUD3_GROUP_OBJS) + groups = call.data.get(CONF_GROUP, ICLOUD3_GROUP_OBJS) devicename = call.data.get(CONF_DEVICENAME) + for group in groups: if group in ICLOUD3_GROUP_OBJS: ICLOUD3_GROUP_OBJS[group]._service_handler_lost_iphone( @@ -913,14 +957,15 @@ class Icloud3:#(DeviceScanner): def __init__(self, hass, see, username, password, group, base_zone, tracking_method, track_devices, - iosapp_locate_request_max_cnt, inzone_interval_str, - max_interval_str, center_in_zone_flag, + iosapp_request_location_max_cnt, inzone_interval_str, + max_interval_str, display_zone_fname, center_in_zone_flag, gps_accuracy_threshold, old_location_threshold_str, stationary_inzone_interval_str, stationary_still_time_str, stationary_zone_offset, ignore_gps_accuracy_inzone_flag, hide_gps_coordinates, sensor_ids, exclude_sensor_ids, - unit_of_measurement, travel_time_factor, distance_method, + unit_of_measurement, time_format, + travel_time_factor, distance_method, waze_region, waze_realtime, waze_max_distance, waze_min_distance, log_level, device_status, display_text_as, entity_registry_file, config_ic3_file_name, event_log_card_directory @@ -950,19 +995,21 @@ def __init__(self, self.trusted_devices = None self.tracking_method_config = tracking_method - self.iosapp_locate_request_max_cnt= iosapp_locate_request_max_cnt + self.iosapp_request_loc_max_cnt= iosapp_request_location_max_cnt self.start_icloud3_request_flag = False self.start_icloud3_inprocess_flag = False self.authenticated_time = 0 self.log_level = log_level self.log_level_eventlog_flag = False - self.device_status_online = device_status + self.device_status_online = list(device_status.replace(" ","").split(",")) + self.device_status_online.append('') self.attributes_initialized_flag = False self.track_devices = track_devices self.distance_method_waze_flag = (distance_method.lower() == 'waze') self.inzone_interval_secs = self._time_str_to_secs(inzone_interval_str) self.max_interval_secs = self._time_str_to_secs(max_interval_str) + self.display_zone_fname_flag = display_zone_fname self.center_in_zone_flag = center_in_zone_flag self.gps_accuracy_threshold = int(gps_accuracy_threshold) self.old_location_threshold = self._time_str_to_secs(old_location_threshold_str) @@ -972,6 +1019,7 @@ def __init__(self, self.sensor_ids = sensor_ids self.exclude_sensor_ids = exclude_sensor_ids self.unit_of_measurement = unit_of_measurement + self.time_format = int(time_format) self.travel_time_factor = float(travel_time_factor) self.e_seconds_local_offset_secs = 0 self.waze_region = waze_region @@ -1019,10 +1067,10 @@ def _start_icloud3(self): self.startup_log_msgs_prefix = '' devicename = '' - self._initialize_um_formats(self.unit_of_measurement) self._define_device_fields() self._define_device_status_fields() self._define_device_tracking_fields() + self._define_zone_fields() self._define_device_zone_fields() self._define_tracking_control_fields() self._define_sensor_fields(self.start_icloud3_initial_load_flag) @@ -1035,6 +1083,7 @@ def _start_icloud3(self): self.startup_log_msgs_prefix = NEW_LINE self._check_config_ic3_yaml_parameter_file() self._check_ic3_event_log_file_version() + self._initialize_um_formats(self.unit_of_measurement) for item in self.display_text_as: from_to_text = item.split(">") @@ -1327,22 +1376,22 @@ def _polling_loop_5_sec_device(self, now): return self.this_update_secs = self._time_now_secs() - this_update_hhmmss = dt_util.now().strftime('%H:%M:%S') + self.this_update_time = dt_util.now().strftime('%H:%M:%S') this_minute = int(dt_util.now().strftime('%M')) this_5sec_loop_second = int(dt_util.now().strftime('%S')) #Reset counts on new day, check for daylight saving time new offset - if this_update_hhmmss.endswith(':00:00'): + if self.this_update_time.endswith(':00:00'): self._timer_tasks_every_hour() - if this_update_hhmmss == HHMMSS_ZERO: + if self.this_update_time == HHMMSS_ZERO: self._timer_tasks_midnight() - elif this_update_hhmmss == '01:00:00': + elif self.this_update_time == '01:00:00': self._timer_tasks_1am() #Test code to check ios monitor, display it every minute - #if this_update_hhmmss.endswith(':00'): + #if self.this_update_time.endswith(':00'): # self.last_iosapp_msg['gary_iphone'] = "" try: @@ -1372,12 +1421,14 @@ def _polling_loop_5_sec_device(self, now): ic3dev_state = self._get_state(ic3_entity_id) #Will be not_set with a last_poll value if the iosapp has not be set up - if ic3dev_state == NOT_SET and self.state_last_poll.get(devicename) != NOT_SET: - self._request_iosapp_location_update(devicename) + if (ic3dev_state == NOT_SET + and self.state_last_poll.get(devicename) != NOT_SET): + event_msg = "iOSApp state will be updated after iOSApp starts" + self._save_event(devicename,event_msg) continue - ic3dev_attrs = self._get_device_attributes(ic3_entity_id) #Extract only attrs needed to update the device + ic3dev_attrs = self._get_device_attributes(ic3_entity_id) ic3dev_attrs_avail = {k: v for k, v in ic3dev_attrs.items() if k in DEVICE_ATTRS_BASE} ic3dev_data = {**DEVICE_ATTRS_BASE, **ic3dev_attrs_avail} @@ -1393,7 +1444,7 @@ def _polling_loop_5_sec_device(self, now): ATTR_GPS_ACCURACY: ic3dev_gps_accuracy, ATTR_BATTERY_LEVEL: ic3dev_battery} - #iosapp v2 uses the device_tracker._# entity for + #iosapp uses the device_tracker._# entity for #location info and sensor._last_update_trigger entity #for trigger info. Get location data and trigger. #Use the trigger/timestamp if timestamp is newer than current @@ -1402,6 +1453,7 @@ def _polling_loop_5_sec_device(self, now): update_reason = "" ios_update_reason = "" update_via_iosapp_flag = False + last_located_secs_plus_5 = self.last_located_secs.get(devicename) + 5 if self.iosapp_monitor_dev_trk_flag.get(devicename): iosapp_state, iosapp_dev_attrs, iosapp_data_flag = \ @@ -1426,7 +1478,7 @@ def _polling_loop_5_sec_device(self, now): iosapp_msg = (f"iOSApp Monitor {iosapp_data_msg}> " f"Trigger-{iosapp_trigger}@{iosapp_trigger_changed_time} (%tage ago), " f"State-{iosapp_state}@{iosapp_state_changed_time} (%sage ago), " - f"GPS-{format_gps(iosapp_dev_attrs[ATTR_LATITUDE], iosapp_dev_attrs[ATTR_LONGITUDE], ic3dev_gps_accuracy)}, " + f"GPS-{format_gps(iosapp_dev_attrs[ATTR_LATITUDE], iosapp_dev_attrs[ATTR_LONGITUDE], iosapp_dev_attrs[ATTR_GPS_ACCURACY])}, " f"LastiC3UpdtTime-{self.last_update_time.get(devicename_zone)}, ") #Initialize if first time through @@ -1446,6 +1498,11 @@ def _polling_loop_5_sec_device(self, now): update_via_iosapp_flag = False ios_update_reason = "No data received from the iOS App, using iC3 data" + elif (iosapp_state == NOT_SET + and self.iosapp_request_loc_retry_cnt.get(devicename) == 0): + update_via_iosapp_flag = True + ios_update_reason = "Reinitialize iOSApp location" + elif (instr(iosapp_state, STATIONARY) and iosapp_dev_attrs[ATTR_LATITUDE] == self.stat_zone_base_lat and iosapp_dev_attrs[ATTR_LONGITUDE] == self.stat_zone_base_long): @@ -1457,12 +1514,14 @@ def _polling_loop_5_sec_device(self, now): ios_update_reason = (f"State Change-{iosapp_state}") #trigger time is after last locate - elif iosapp_trigger_changed_secs > (self.last_located_secs.get(devicename) + 5): + #elif iosapp_trigger_changed_secs > (self.last_located_secs.get(devicename) + 5): + elif iosapp_trigger_changed_secs > last_located_secs_plus_5: update_via_iosapp_flag = True ios_update_reason = (f"Trigger Change-{iosapp_trigger}") #State changed more than 5-secs after last locate - elif iosapp_state_changed_secs > (self.last_located_secs.get(devicename) + 5): + #elif iosapp_state_changed_secs > (self.last_located_secs.get(devicename) + 5): + elif iosapp_state_changed_secs > last_located_secs_plus_5: update_via_iosapp_flag = True iosapp_trigger = "iOSApp Loc Update" iosapp_trigger_changed_secs = iosapp_state_changed_secs @@ -1471,7 +1530,8 @@ def _polling_loop_5_sec_device(self, now): #Prevent duplicate update if State & Trigger changed at the same time #and state change was handled on last cycle elif (iosapp_trigger_changed_secs == iosapp_state_changed_secs - or iosapp_trigger_changed_secs <= (self.last_located_secs.get(devicename) + 5)): + or iosapp_trigger_changed_secs <= last_located_secs_plus_5): + # or iosapp_trigger_changed_secs <= (self.last_located_secs.get(devicename) + 5)): self.last_iosapp_trigger[devicename] = iosapp_trigger ios_update_reason = "Already Processed" @@ -1484,7 +1544,8 @@ def _polling_loop_5_sec_device(self, now): elif instr(iosapp_trigger, '@'): ios_update_reason = "Trigger Already Processed" - elif iosapp_trigger_changed_secs <= (self.last_located_secs.get(devicename) + 5): + #elif iosapp_trigger_changed_secs <= (self.last_located_secs.get(devicename) + 5): + elif iosapp_trigger_changed_secs <= last_located_secs_plus_5: ios_update_reason = "Trigger Before Last Locate" else: @@ -1493,7 +1554,7 @@ def _polling_loop_5_sec_device(self, now): iosapp_msg += (f"WillUpdate-{update_via_iosapp_flag}") #Show iOS App monitor every half hour - if this_update_hhmmss.endswith(':00:00') or this_update_hhmmss.endswith(':30:00'): + if self.this_update_time.endswith(':00:00') or self.this_update_time.endswith(':30:00'): self.last_iosapp_msg[devicename] = "" if (iosapp_msg != self.last_iosapp_msg.get(devicename)): @@ -1518,7 +1579,8 @@ def _polling_loop_5_sec_device(self, now): and self.in_stationary_zone_flag.get(devicename) == False): event_msg = (f"Stationary Zone Entered > iOS App used another device's " - f"Stationary Zone, changed {iosapp_state} to {self._format_zone_name(devicename, STATIONARY)}") + f"Stationary Zone, changed {iosapp_state} to " + f"{self._format_zone_name(devicename, STATIONARY)}") self._save_event(devicename, event_msg) latitude = self.zone_lat.get(iosapp_state) @@ -1528,7 +1590,7 @@ def _polling_loop_5_sec_device(self, now): devicename, latitude, longitude, - STAT_ZONE_NEW_LOCATION, 'poll 5-1442') + STAT_ZONE_NEW_LOCATION, 'poll 5-EnterTrigger') iosapp_state = self._format_zone_name(devicename, STATIONARY) @@ -1538,11 +1600,11 @@ def _polling_loop_5_sec_device(self, now): devicename, self.stat_zone_base_lat, self.stat_zone_base_long, - STAT_ZONE_MOVE_TO_BASE, 'poll 5-1470') + STAT_ZONE_MOVE_TO_BASE, 'poll 5-ExitTrigger') #Discard another devices Stationary Zone triggers - if instr(iosapp_state, STATIONARY) and instr(iosapp_state, devicename) == False: - continue + #if instr(iosapp_state, STATIONARY) and instr(iosapp_state, devicename) == False: + # continue if instr(iosapp_state, STATIONARY): iosapp_state = STATIONARY @@ -1550,6 +1612,7 @@ def _polling_loop_5_sec_device(self, now): #---------------------------------------------------------------------------- ic3dev_battery = self._get_attr(iosapp_dev_attrs, ATTR_BATTERY_LEVEL, NUMERIC) self.last_battery[devicename] = ic3dev_battery + if update_via_iosapp_flag: iosapp_trigger_age_secs = self._secs_since(iosapp_trigger_changed_secs) age = iosapp_trigger_changed_secs - self.this_update_secs @@ -1576,13 +1639,13 @@ def _polling_loop_5_sec_device(self, now): #Check ios app for activity every 6-hours #Issue ios app Location update 15-min before 6-hour alert - elif (this_update_hhmmss in ['23:45:00', '05:45:00', '11:45:00', '17:45:00'] + elif (self.this_update_time in ['23:45:00', '05:45:00', '11:45:00', '17:45:00'] and iosapp_state_age_secs > 20700 and iosapp_trigger_age_secs > 20700): - self._request_iosapp_location_update(devicename) + self._iosapp_request_loc_update(devicename) #No activity, display Alert msg in Event Log - elif (this_update_hhmmss in ['00:00:00', '06:00:00', '12:00:00', '18:00:00'] + elif (self.this_update_time in ['00:00:00', '06:00:00', '12:00:00', '18:00:00'] and iosapp_state_age_secs > 21600 and iosapp_trigger_age_secs > 21600): event_msg = (f"{EVLOG_ALERT}iOS App Alert > No iOS App updates for more than 6 hours > " @@ -1594,7 +1657,6 @@ def _polling_loop_5_sec_device(self, now): f"—{iosapp_trigger_age_str} ago") self._display_info_status_msg(devicename, event_msg) - zone = self._format_zone_name(devicename, ic3dev_state) event_msg = "" update_reason = "" @@ -1616,7 +1678,6 @@ def _polling_loop_5_sec_device(self, now): self.zone_radius_m.get(HOME)) zone_radius_accuracy_m = zone_radius_m + self.gps_accuracy_threshold - if (self.TRK_METHOD_IOSAPP and self.zone_current.get(devicename) == ''): update_method = IOSAPP_UPDATE update_reason = "Initial Locate" @@ -1625,8 +1686,12 @@ def _polling_loop_5_sec_device(self, now): #latitude and longitude. Reset via icloud update. elif ic3dev_latitude == 0: update_method = ICLOUD_UPDATE - update_reason = (f"GPS data = 0 {self.state_last_poll.get(devicename)}-{ic3dev_state}") + update_reason = (f"GPS=(0, 0) {self.state_last_poll.get(devicename)}/{ic3dev_state}") ic3dev_trigger = "RefreshLocation" + device_offline_msg = (f"May be Offline, No Location " + f"(#{self.poor_location_gps_cnt.get(devicename)})") + self._display_info_status_msg(devicename, device_offline_msg) + self._save_event(devicename,device_offline_msg) #Update the device if it wasn't completed last time. elif self.state_last_poll.get(devicename) == NOT_SET: @@ -1648,7 +1713,7 @@ def _polling_loop_5_sec_device(self, now): elif (self.stat_zone_timer.get(devicename, 0) > 0 and self.this_update_secs >= self.stat_zone_timer.get(devicename) - and self.old_loc_poor_gps_cnt.get(devicename) == 0): + and self.poor_location_gps_cnt.get(devicename) == 0): event_msg = (f"Move into Stationary Zone Timer reached > {devicename}, " f"Expired-{self._secs_to_time(self.stat_zone_timer.get(devicename, -1))}") self._save_event(devicename, event_msg) @@ -1665,7 +1730,7 @@ def _polling_loop_5_sec_device(self, now): #changed without a trigger being posted and will be picked #up here anyway. elif (ic3dev_state != self.state_last_poll.get(devicename) - and ic3dev_timestamp_secs > (self.last_located_secs.get(devicename) + 5)): + and ic3dev_timestamp_secs > last_located_secs_plus_5): self.count_state_changed[devicename] += 1 self.state_change_flag[devicename] = True update_method = IOSAPP_UPDATE @@ -1680,20 +1745,29 @@ def _polling_loop_5_sec_device(self, now): and iosapp_dev_attrs[ATTR_LONGITUDE] == self.stat_zone_base_long): update_method = ICLOUD_UPDATE update_reason = "Verify Location" - event_msg = (f"Discarded > Can not set current location to Stationary Base Zone") + event_msg = (f"Discarded > Entering Stationary Base Zone") self._save_event(devicename, event_msg) elif (ic3dev_trigger != self.trigger.get(devicename) - and ic3dev_timestamp_secs > (self.last_located_secs.get(devicename) + 5)): + and ic3dev_timestamp_secs > last_located_secs_plus_5): update_method = IOSAPP_UPDATE self.count_trigger_changed[devicename] += 1 update_reason = "Trigger Change" event_msg = (f"iOSApp Trigger Change detected > {ic3dev_trigger}") self._save_event(devicename, event_msg) + #If iOS App tracking and next update time reached and message not sent, request location. iOS App should respond within 20-seconds and create a Location Request + #trigger. Do this 4-times and then display message and wait. + elif (self.TRK_METHOD_IOSAPP + and self._check_next_update_time_reached_devicename(devicename)): + self._iosapp_request_loc_update(devicename) + else: update_reason = f"OlderTrigger-{ic3dev_trigger}" + if (self.TRK_METHOD_IOSAPP + and update_method == ICLOUD_UPDATE): + update_method = IOSAPP_UPDATE #Update because of state or trigger change. #Accept the location data as it was sent by ios if the trigger @@ -1703,19 +1777,20 @@ def _polling_loop_5_sec_device(self, now): #If the trigger was sometning else (Significant Location Change, #Background Fetch, etc, check to make sure it is not old or #has poor gps info. - if update_method == IOSAPP_UPDATE: + if (update_method == IOSAPP_UPDATE + or update_method == ICLOUD_UPDATE and self.TRK_METHOD_IOSAPP): + self.iosapp_request_loc_sent_flag[devicename] = False + #If exit trigger flag is not set, set it now if Exiting zone #If already set, leave it alone and reset when enter Zone (v2.1x) if self.got_exit_trigger_flag.get(devicename) == False: self.got_exit_trigger_flag[devicename] = (ic3dev_trigger in IOS_TRIGGERS_EXIT) - #self._trace_device_attributes(devicename, '5sPOLL', update_reason, ic3dev_attrs) - if ic3dev_trigger in IOS_TRIGGERS_ENTER: if (zone in self.zone_lat and dist_from_zone_m > self.zone_radius_m.get(zone)*2 and dist_from_zone_m < HIGH_INTEGER): - event_msg = (f"Conflicting enter zone trigger, Moving into zone > " + event_msg = (f"Enter Zone Trigger Conflict Moving into Zone > " f"Zone-{zone}, Distance-{dist_from_zone_m}m, " f"ZoneVerifyDist-{self.zone_radius_m.get(zone)*2}m, " f"GPS-{format_gps(ic3dev_latitude, ic3dev_longitude, ic3dev_gps_accuracy)}") @@ -1728,7 +1803,7 @@ def _polling_loop_5_sec_device(self, now): #RegionExit trigger overrules old location, gps accuracy and other checks elif (ic3dev_trigger in IOS_TRIGGERS_EXIT - and ic3dev_timestamp_secs > (self.last_located_secs.get(devicename) + 5)): + and ic3dev_timestamp_secs > last_located_secs_plus_5): update_method = IOSAPP_UPDATE update_reason = "Region Exit" @@ -1737,18 +1812,18 @@ def _polling_loop_5_sec_device(self, now): #elif (ic3dev_trigger in IOS_TRIGGERS_VERIFY_LOCATION): #If not an enter/exit trigger, verify the location elif (ic3dev_trigger not in IOS_TRIGGERS_ENTER_EXIT): - old_loc_poor_gps_flag, discard_reason = self._check_old_loc_poor_gps( + poor_location_gps_flag, discard_reason = self._check_poor_location_gps( devicename, ic3dev_timestamp_secs, ic3dev_gps_accuracy) #If old location, discard - if old_loc_poor_gps_flag: + if poor_location_gps_flag: location_age = self._secs_since(ic3dev_timestamp_secs) - event_msg = (f"Update via iCloud > Old location or poor GPS " - f"(#{self.old_loc_poor_gps_cnt.get(devicename)}), " + event_msg = (f"Old Location or Poor GPS " + f"(#{self.poor_location_gps_cnt.get(devicename)}) > " f"Located-{self._secs_to_time(ic3dev_timestamp_secs)} " - f"({self._secs_to_time_str(location_age)} ago), " + f"({self._format_age(location_age)}), " f"GPS-{format_gps(ic3dev_latitude, ic3dev_longitude, ic3dev_gps_accuracy)}, " f"OldLocThreshold-{self._secs_to_time_str(self.old_location_secs.get(devicename))}") @@ -1790,7 +1865,7 @@ def _polling_loop_5_sec_device(self, now): #by treating it as poor GPS if self._is_inzone_zonename(zone): outside_no_exit_trigger_flag, info_msg = \ - self._check_outside_zone_no_exit(devicename, zone, + self._check_outside_zone_no_exit(devicename, zone, ic3dev_trigger, ic3dev_latitude, ic3dev_longitude) if outside_no_exit_trigger_flag: update_method = None @@ -1800,7 +1875,7 @@ def _polling_loop_5_sec_device(self, now): #update via icloud to verify location if less than home_radius*10 elif (dist_from_zone_m <= zone_radius_m * 10 and self.TRK_METHOD_FMF_FAMSHR): - event_msg = (f"Update via iCloud > Verify location needed > " + event_msg = (f"Verify Location Needed > " f"Zone-{zone}, " f"Distance-{self._format_dist_m(dist_from_zone_m)}m, " f"ZoneVerifyDist-{self._format_dist_m(zone_radius_m*10)}m, " @@ -1808,6 +1883,10 @@ def _polling_loop_5_sec_device(self, now): self._save_event_halog_info(devicename, event_msg) update_method = ICLOUD_UPDATE update_reason = "Verify Location" + else: + pass + else: + pass if (ic3dev_data[ATTR_LATITUDE] is None or ic3dev_data[ATTR_LONGITUDE] is None): update_method = ICLOUD_UPDATE @@ -1827,19 +1906,36 @@ def _polling_loop_5_sec_device(self, now): self._evlog_debug_msg(devicename, device_monitor_msg) self.last_device_monitor_msg[devicename] = device_monitor_msg + if (self._device_online(devicename) == False + or self.state_this_poll == NOT_SET): + device_offline_msg = (f"May be Offline, Never Located " + f"(#{self.poor_location_gps_cnt.get(devicename)})") + self._display_info_status_msg(devicename, device_offline_msg) + #Save trigger and time for cycle even if trigger was discarded. self.trigger[devicename] = ic3dev_trigger self.last_located_secs[devicename] = ic3dev_timestamp_secs + if update_method == ICLOUD_UPDATE: + if self.TRK_METHOD_FMF_FAMSHR: + self._update_device_icloud(update_reason, devicename) + + elif self.TRK_METHOD_IOSAPP: + update_method = IOSAPP_UPDATE + self.iosapp_request_loc_sent_flag[devicename] = False + if update_method == IOSAPP_UPDATE: self.state_this_poll[devicename] = ic3dev_state self.iosapp_update_flag[devicename] = True update_method = self._update_device_iosapp(devicename, update_reason, ic3dev_data) - self.any_device_being_updated_flag = False - if update_method == ICLOUD_UPDATE and self.TRK_METHOD_FMF_FAMSHR: - self._update_device_icloud(update_reason, devicename) + #Retry with iCloud of an error or no data + if (update_method == ICLOUD_UPDATE + and self.TRK_METHOD_FMF_FAMSHR): + self._update_device_icloud(update_reason, devicename) + + self.any_device_being_updated_flag = False #If less than 90 secs to the next update for any devicename:zone, display time to #the next update in the NextUpdt time field, e.g, 1m05s or 0m15s. @@ -1848,8 +1944,10 @@ def _polling_loop_5_sec_device(self, now): devicename_zone = self._format_devicename_zone(devicename, zone) if devicename_zone in self.next_update_secs: age_secs = self._secs_to(self.next_update_secs.get(devicename_zone)) - if (age_secs <= 90 and age_secs >= -15): - self._display_time_till_update_info_msg(devicename_zone, age_secs) + #if (age_secs <= 90 and age_secs >= -15): + # self._display_time_till_update_info_msg(devicename_zone, age_secs) + self._display_time_till_update_info_msg(devicename_zone, age_secs) + if update_method is not None: self.device_being_updated_flag[devicename] = False @@ -1864,14 +1962,14 @@ def _polling_loop_5_sec_device(self, now): log_msg = (f"Device Update Error, Error-{ValueError}") self._log_error_msg(log_msg) - self.update_in_process_flag = False + self.update_in_process_flag = False self._log_debug_msgs_trace_flag = False #Cycle thru all devices and check to see if devices next update time #will occur in 5-secs. If so, request a location update now so it #it might be current on the next update. - if ((this_5sec_loop_second % 15) == 10): - self._polling_loop_10_sec_fmf_loc_prefetch(now) + if ((this_5sec_loop_second % 15) == 10) and self.TRK_METHOD_FMF_FAMSHR: + self._polling_loop_10_sec_loc_prefetch(now) #Cycle thru all devices and check to see if devices need to be #updated via every 15 seconds @@ -1909,6 +2007,47 @@ def _retry_update(self, devicename): self._update_device_icloud(update_reason, devicename) +######################################################### +# +# This function is called every 10 seconds. Cycle through all +# of the devices to see if any of the ones being tracked will +# be updated on the 15-sec polling loop in 5-secs. If so, request a +# location update now so it might be current when the device +# is updated. +# +######################################################### + def _polling_loop_10_sec_loc_prefetch(self, now): + + try: + if self.next_update_secs is None: + return + + for devicename_zone in self.next_update_secs: + devicename = devicename_zone.split(':')[0] + if self.state_this_poll.get(devicename) == NOT_SET: + continue + + time_till_update = self.next_update_secs.get(devicename_zone) - \ + self.this_update_secs + location_data = self.location_data.get(devicename) + + if time_till_update <= 10: + location_data = self.location_data.get(devicename) + location_age = location_data[ATTR_AGE] + + if location_age > 15: + if self.TRK_METHOD_FMF_FAMSHR: + self._refresh_pyicloud_devices_location_data(location_age, devicename) + + elif self.TRK_METHOD_IOSAPP: + self._iosapp_request_loc_update(devicename) + break + + except Exception as err: + _LOGGER.exception(err) + + return + ######################################################### # # Update the device on a state or trigger change was recieved from the ios app @@ -1916,7 +2055,7 @@ def _retry_update(self, devicename): ######################################################### def _update_device_iosapp(self, devicename, update_reason, ic3dev_data): """ - + Update the devices location using data from the iOS App """ if self.start_icloud3_inprocess_flag: @@ -1927,14 +2066,21 @@ def _update_device_iosapp(self, devicename, update_reason, ic3dev_data): self.any_device_being_updated_flag = True return_code = IOSAPP_UPDATE + self.iosapp_request_loc_retry_cnt[devicename] = 0 + self.iosapp_request_loc_sent_secs[devicename] = 0 + self.iosapp_request_loc_sent_flag[devicename] = False + try: devicename_zone = self._format_devicename_zone(devicename, HOME) if self.next_update_time.get(devicename_zone) == PAUSED: + self._save_event(devicename,"2047 update_iosapp, paused") return elif ATTR_LATITUDE not in ic3dev_data or ATTR_LONGITUDE not in ic3dev_data: + self._save_event(devicename,f"2050 update_iosapp, ic3_dev_data={ic3dev_data}") return elif ic3dev_data[ATTR_LATITUDE] is None or ic3dev_data[ATTR_LONGITUDE] is None: + self._save_event(devicename,f"2053 update_iosapp, lat=None, ic3_dev_data={ic3dev_data}") return self._log_start_finish_update_banner('▼▼▼', devicename, "iOSApp", update_reason) @@ -1949,7 +2095,7 @@ def _update_device_iosapp(self, devicename, update_reason, ic3dev_data): latitude = ic3dev_data[ATTR_LATITUDE] longitude = ic3dev_data[ATTR_LONGITUDE] timestamp = self._timestamp_to_time(ic3dev_data[ATTR_TIMESTAMP]) - timestamp = self._secs_to_time(self.this_update_secs) if timestamp == HHMMSS_ZERO else timestamp + timestamp = self.this_update_time if timestamp == HHMMSS_ZERO else timestamp gps_accuracy = ic3dev_data[ATTR_GPS_ACCURACY] battery = ic3dev_data[ATTR_BATTERY_LEVEL] battery_status = ic3dev_data[ATTR_BATTERY_STATUS] @@ -1959,9 +2105,12 @@ def _update_device_iosapp(self, devicename, update_reason, ic3dev_data): vertical_accuracy = self._get_attr(ic3dev_data, ATTR_VERT_ACCURACY, NUMERIC) location_isold_attr = False - old_loc_poor_gps_flag = False - self.old_loc_poor_gps_cnt[devicename] = 0 - self.old_loc_poor_gps_msg[devicename] = False + poor_location_gps_flag = False + self.poor_location_gps_cnt[devicename] = 0 + self.poor_location_gps_msg[devicename] = False + self.poor_gps_flag[devicename] = False #v2.2.1 + self.device_status[devicename] = device_status + attrs = {} #-------------------------------------------------------- @@ -1991,7 +2140,8 @@ def _update_device_iosapp(self, devicename, update_reason, ic3dev_data): #discard trigger if outsize zone with no exit trigger if self._is_inzone_zonename(zone): discard_flag, discard_msg = \ - self._check_outside_zone_no_exit(devicename, zone, latitude, longitude) + self._check_outside_zone_no_exit(devicename, zone, '', + latitude, longitude) if discard_flag: self._save_event(devicename, discard_msg) @@ -2006,7 +2156,7 @@ def _update_device_iosapp(self, devicename, update_reason, ic3dev_data): longitude, battery, gps_accuracy, - old_loc_poor_gps_flag, + poor_location_gps_flag, self.last_located_secs.get(devicename), timestamp, "iOSApp") @@ -2026,7 +2176,7 @@ def _update_device_iosapp(self, devicename, update_reason, ic3dev_data): #device_being_update_flag so an icloud update will be done. if attrs == {} and self.TRK_METHOD_FMF_FAMSHR: self.any_device_being_updated_flag = False - self.iosapp_locate_update_secs[devicename] = 0 + self.iosapp_request_loc_sent_secs[devicename] = 0 event_msg = ("iOS update was not completed, " f"will retry with {self.trk_method_short_name}") @@ -2048,7 +2198,13 @@ def _update_device_iosapp(self, devicename, update_reason, ic3dev_data): #If location is empty or trying to set to the Stationary Base Zone Location, #discard the update and try again in 15-sec - if self._update_last_latitude_longitude(devicename, latitude, longitude, (f"iOSApp-{update_reason}")) == False: + check_gps = self._update_last_latitude_longitude( + devicename, + latitude, + longitude, + (f"iOSApp-{update_reason}")) + + if check_gps == False: self.any_device_being_updated_flag = False return ICLOUD_UPDATE @@ -2061,7 +2217,7 @@ def _update_device_iosapp(self, devicename, update_reason, ic3dev_data): altitude = 0 attrs[ATTR_LAST_LOCATED] = self._time_to_12hrtime(timestamp) - attrs[ATTR_DEVICE_STATUS] = device_status + attrs[ATTR_DEVICE_STATUS] = self.device_status.get(devicename) attrs[ATTR_LOW_POWER_MODE] = low_power_mode attrs[ATTR_BATTERY] = battery attrs[ATTR_BATTERY_STATUS] = battery_status @@ -2118,43 +2274,72 @@ def _update_device_iosapp(self, devicename, update_reason, ic3dev_data): return_code = ICLOUD_UPDATE self.any_device_being_updated_flag = False - self.iosapp_locate_update_secs[devicename] = 0 + #self.iosapp_request_loc_sent_secs[devicename] = 0 return return_code ######################################################### # -# This function is called every 10 seconds. Cycle through all -# of the devices to see if any of the ones being tracked will -# be updated on the 15-sec polling loop in 5-secs. If so, request a -# location update now so it might be current when the device -# is updated. +# Using the iosapp tracking method or iCloud is disabled +# so trigger the iosapp to send a +# location transaction # ######################################################### - def _polling_loop_10_sec_fmf_loc_prefetch(self, now): + def _iosapp_request_loc_update(self, devicename, reissue_msg=""): + ''' + Send location request to phone. Check to see if one has been sent but not responded to + and, if true, set interval based on the retry count. + ''' try: - if self.next_update_secs is None: - return - elif self.TRK_METHOD_IOSAPP: - return + age = self._secs_since(self.iosapp_request_loc_sent_secs.get(devicename)) + event_msg = "Requested iOSApp Loc" + + #Save initial sent time if not sent before, otherwise increase retry cnt and + #set new interval time + if self.iosapp_request_loc_sent_flag.get(devicename) == False: + self.iosapp_request_loc_sent_secs[devicename] = self.this_update_secs + self.iosapp_request_loc_retry_cnt[devicename] = 0 + event_msg += (f"@{self.this_update_time}") + else: + self.iosapp_request_loc_retry_cnt[devicename] += 1 + event_msg += (f"@{self._secs_to_time(self.iosapp_request_loc_sent_secs.get(devicename))}, " + f"({self._format_age(age)}), " + f"#{self.iosapp_request_loc_retry_cnt.get(devicename)}") - for devicename_zone in self.next_update_secs: - time_till_update = self.next_update_secs.get(devicename_zone) - \ - self.this_update_secs - if time_till_update <= 10: - devicename = devicename_zone.split(':')[0] - location_data = self.location_data.get(devicename) - age = location_data[ATTR_AGE] + if self.iosapp_request_loc_retry_cnt.get(devicename) > 10: + event_msg += ", May be offline/asleep" - if age > 15: - if self.TRK_METHOD_FMF_FAMSHR: - self._refresh_pyicloud_devices_location_data(age, devicename) - break + self._determine_interval_after_error( + devicename, + event_msg, + counter=IOSAPP_REQUEST_LOC_CNT) + + self.iosapp_request_loc_sent_flag[devicename] = True + self.iosapp_request_loc_cnt[devicename] += 1 + + message = {"message": "request_location_update"} + return_code = self._send_message_to_device(devicename, message) + + #self.hass.async_create_task( + # self.hass.services.async_call('notify', entity_id, service_data)) + + self._save_event_halog_info(devicename, event_msg) + + if return_code: + attrs = {} + attrs[ATTR_POLL_COUNT] = self._format_poll_count(devicename) + attrs[ATTR_INFO] = event_msg + self._update_device_sensors(devicename, attrs) + else: + event_msg = f"{EVA_NOTICE}{event_msg} > Failed to send message" + self._save_event_halog_info(devicename, event_msg) except Exception as err: _LOGGER.exception(err) + error_msg = (f"iCloud3 Error > An error occurred sending a location request > " + f"Device-{self.notify_iosapp_entity.get(devicename)}, Error-{err}") + self._save_event_halog_error(devicename, error_msg) - return ######################################################### # @@ -2186,6 +2371,9 @@ def _polling_loop_15_sec_icloud(self, now): if (self.tracking_device_flag.get(devicename) is False or self.next_update_time.get(devicename_zone) == PAUSED): continue + elif (self.state_this_poll.get(devicename) == NOT_SET + and self.next_update_secs.get(devicename_zone) > self.this_update_secs): + continue self.iosapp_update_flag[devicename] = False update_method = None @@ -2210,7 +2398,7 @@ def _polling_loop_15_sec_icloud(self, now): if 'nearzone' in state: state = 'near_zone' - self.state_this_poll[devicename] = state + self.state_this_poll[devicename] = state self.next_update_secs[devicename_zone] = 0 attrs = {} @@ -2229,6 +2417,7 @@ def _polling_loop_15_sec_icloud(self, now): log_msg = (f"{self._format_fname_devtype(devicename)} Cancelled update retry") self._log_info_msg(log_msg) + update_via_other_devicename = None if self._check_in_zone_and_before_next_update(devicename): continue @@ -2250,16 +2439,24 @@ def _polling_loop_15_sec_icloud(self, now): else: update_via_other_devicename = self._check_next_update_time_reached() - if update_via_other_devicename is not None: + #TODO. Pia update from other device flag into update routine so it won't update of old loc and from another def and next update time not reached + if (update_via_other_devicename != devicename + and self.poor_location_gps_cnt.get(devicename) > 0 + and self.next_update_secs.get(devicename_zone) > self.this_update_secs): + pass + + elif update_via_other_devicename is not None: self._log_debug_msgs_trace_flag = False + retry_suffix = "" if self.poor_location_gps_cnt.get(devicename) == 0 else " (Retry)" update_method = ICLOUD_UPDATE - update_reason = "Next Update Time" - self.trigger[devicename] = 'NextUpdateTime' - event_msg = (f"NextUpdateTime reached > {update_via_other_devicename}") + update_reason = f"Next Update Time reached{retry_suffix}" + self.trigger[devicename] = f"NextUpdateTime{retry_suffix}" + event_msg = (f"{update_reason} > {update_via_other_devicename}") self._save_event(devicename, event_msg) elif update_method == ICLOUD_UPDATE: - event_msg = (f"NextUpdateTime reached > {devicename}") + retry_suffix = "" if self.poor_location_gps_cnt.get(devicename) == 0 else " (Retry)" + event_msg = (f"Next Update Time reached{retry_suffix} > {devicename}") self._save_event(devicename, event_msg) if update_method == ICLOUD_UPDATE: @@ -2272,7 +2469,7 @@ def _polling_loop_15_sec_icloud(self, now): self.update_in_process_flag = False return - self.info_notification = (f"THE ICLOUD 2SA CODE IS NEEDED TO VERIFY " + self.info_notification = (f"THE ICLOUD 2FA CODE IS NEEDED TO VERIFY " f"THE ACCOUNT FOR {self.username}") for devicename in self.tracked_devices: self._display_info_status_msg(devicename, self.info_notification) @@ -2287,7 +2484,7 @@ def _polling_loop_15_sec_icloud(self, now): self._icloud_show_trusted_device_request_form() return - self._update_device_icloud(update_reason) + self._update_device_icloud(update_reason, update_via_other_devicename) self.update_in_process_flag = False @@ -2298,7 +2495,7 @@ def _polling_loop_15_sec_icloud(self, now): self._log_error_msg(log_msg) self.api.authenticate() #Reset iCloud self.authenticated_time = time.time() - self._update_device_icloud('iCloud/FmF Reauth') #Retry update devices + self._update_device_icloud('iCloud/FmF Reauth', update_via_other_devicename) #Retry update devices self.update_in_process_flag = False self._log_debug_msgs_trace_flag = False @@ -2311,10 +2508,22 @@ def _polling_loop_15_sec_icloud(self, now): # ●►●◄►●▬▲▼◀►►●◀ oPhone=►▶ ######################################################### def _update_device_icloud(self, update_reason='Check iCloud', - arg_devicename=None): + arg_devicename=None, arg_via_other_devicename=None): + """ Request device information from iCloud (if needed) and update device_tracker information. + + arg_devicename - + None = Update all devices + Not None = Update specified device + arg_other-devicename - + None = Update all devices + Not None = One device in the list reached the next update time. + Do not update another device that now has poor location + gps after all the results have been determined if + their update time has not been reached. + """ if self.TRK_METHOD_IOSAPP: @@ -2331,13 +2540,15 @@ def _update_device_icloud(self, update_reason='Check iCloud', try: for devicename in self.tracked_devices: zone = self.zone_current.get(devicename) - devicename_zone = self._format_devicename_zone(devicename) + devicename_home_zone = self._format_devicename_zone(devicename) if arg_devicename and devicename != arg_devicename: continue - elif self.next_update_time.get(devicename_zone) == PAUSED: + elif self.next_update_time.get(devicename_home_zone) == PAUSED: + continue + elif (self.state_this_poll.get(devicename) == NOT_SET + and self.next_update_secs.get(devicename_home_zone) > self.this_update_secs): continue - #If the device is in a zone, and was in the same zone on the #last poll of another device on the account and this device @@ -2358,7 +2569,7 @@ def _update_device_icloud(self, update_reason='Check iCloud', self.trk_method_short_name, update_reason) self.update_timer[devicename] = time.time() - self.iosapp_locate_update_secs[devicename] = 0 + self.iosapp_request_loc_sent_secs[devicename] = 0 do_not_update_flag = False location_time = 0 @@ -2374,11 +2585,10 @@ def _update_device_icloud(self, update_reason='Check iCloud', #if ic3dev_data[0] is False: if valid_data_flag == ICLOUD_LOCATION_DATA_ERROR: self.icloud_acct_auth_error_cnt += 1 - self._determine_interval_retry_after_error( + self._determine_interval_after_error( devicename, - self.icloud_acct_auth_error_cnt, - "offline", - "iCloud Offline (Authentication or Location Error)") + "iCloud Offline (Authentication or Location Error)", + counter=AUTH_ERROR_CNT) if (self.interval_seconds.get(devicename) != 15 and self.icloud_acct_auth_error_cnt > 2): @@ -2407,24 +2617,10 @@ def _update_device_icloud(self, update_reason='Check iCloud', longitude = location_data[ATTR_LONGITUDE] gps_accuracy = 0 - #Discard if no location coordinates - if latitude == 0 or longitude == 0: - location_time = 'Unknown' - location_age = None - info_msg = (f"No location data returned from iCloud Location Svcs, " - f"GPS-({latitude}, {longitude})") - - self._determine_interval_retry_after_error( - devicename, - self.old_loc_poor_gps_cnt.get(devicename), - "", - info_msg) - do_not_update_flag = True - - else: + if latitude != 0 and longitude != 0: timestamp = location_data[ATTR_TIMESTAMP] location_isold_attr = location_data[ATTR_ISOLD] - location_time_secs = location_data[ATTR_TIMESTAMP] + last_located_secs = location_data[ATTR_TIMESTAMP] location_time = location_data[ATTR_TIMESTAMP_TIME] battery = location_data[ATTR_BATTERY_LEVEL] battery_status = location_data[ATTR_BATTERY_STATUS] @@ -2436,67 +2632,94 @@ def _update_device_icloud(self, update_reason='Check iCloud', location_age = self._secs_since(location_data.get(ATTR_TIMESTAMP)) location_data[ATTR_AGE] = location_age - old_loc_poor_gps_flag, discard_reason = self._check_old_loc_poor_gps( - devicename, - location_time_secs, - gps_accuracy) self.location_data[devicename][ATTR_BATTERY_LEVEL] = battery - self.last_located_secs[devicename] = location_time_secs + self.last_located_secs[devicename] = last_located_secs + self.device_status[devicename] = device_status + + poor_location_gps_flag, discard_reason = self._check_poor_location_gps( + devicename, + last_located_secs, + gps_accuracy) + + #Discard if no location coordinates + if latitude == 0 or longitude == 0: + location_time = NEVER + location_age = HIGH_INTEGER + self.poor_location_gps_cnt[devicename] += 1 + self.device_status[devicename] = UNKNOWN + discard_reason = (f"No location data " + f"(#{self.poor_location_gps_cnt.get(devicename)})") + device_offline_msg = (f"May be Offline, No Location " + f"(#{self.poor_location_gps_cnt.get(devicename)})") + info_msg = discard_reason + self._determine_interval_after_error( + devicename, + device_offline_msg) + do_not_update_flag = True #Check to see if currently in a zone. If so, check the zone distance. #If new location is outside of the zone and inside radius*4, discard #by treating it as poor GPS - if (self._isnot_inzone_zonename(zone) or self.state_this_poll.get(devicename) == NOT_SET): + elif (self._isnot_inzone_zonename(zone) + or self.state_this_poll.get(devicename) == NOT_SET): outside_no_exit_trigger_flag = False info_msg= '' else: outside_no_exit_trigger_flag, info_msg = \ - self._check_outside_zone_no_exit(devicename, zone, latitude, longitude) + self._check_outside_zone_no_exit(devicename, zone, '', + latitude, longitude) - #If not authorized or no data, don't check old or accuracy errors if self.icloud_acct_auth_error_cnt > 0: pass #If initializing, nothing is set yet - elif self.state_this_poll.get(devicename) == NOT_SET: - pass + #elif (self.state_this_poll.get(devicename) == NOT_SET + # and location_age < 300): + # pass #If no location data elif do_not_update_flag: pass - elif device_status not in ["online", "pending", ""]: + #Ignore old location when in a zone, let normal next time update + #check process + elif (poor_location_gps_flag + and self.this_update_secs < self.next_update_secs.get(devicename_home_zone) + and (self._is_inzone(devicename) + or devicename != arg_via_other_devicename)): + self.poor_location_gps_cnt[devicename] -= 1 + pass + + #elif device_status not in ["online", "pending", ""]: + elif self._device_online(devicename) == False: do_not_update_flag = True - info_msg = "DEVICE OFFLINE" - self._determine_interval_retry_after_error( + info_msg = "Device Offline" + self._determine_interval_after_error( devicename, - self.old_loc_poor_gps_cnt.get(devicename), - device_status, info_msg) - event_msg = (f"{EVLOG_ALERT}iCloud Alert > {devicename} is Offline > Tracking is delayed, Status-{device_status}, " + event_msg = (f"{EVLOG_ALERT}iCloud Alert > {devicename} is Offline > Tracking is delayed, " + f"Status-{self.device_status.get(devicename)}, " f"OnlineStatus-{self.device_status_online}") self._save_event(devicename, event_msg) + #elif self.state_this_poll.get(devicename) == NOT_SET: + # pass + #Outside zone, no exit trigger check elif outside_no_exit_trigger_flag: - self.poor_gps_accuracy_flag[devicename] = True - self.old_loc_poor_gps_cnt[devicename] += 1 + self.poor_gps_flag[devicename] = True + self.poor_location_gps_cnt[devicename] += 1 do_not_update_flag = True - self._determine_interval_retry_after_error( + self._determine_interval_after_error( devicename, - self.old_loc_poor_gps_cnt.get(devicename), - device_status, info_msg) #Discard if location is old or poor gps - elif old_loc_poor_gps_flag: + elif poor_location_gps_flag: info_msg = (f"{discard_reason}") - do_not_update_flag = True - self._determine_interval_retry_after_error( + self._determine_interval_after_error( devicename, - self.old_loc_poor_gps_cnt.get(devicename), - device_status, info_msg) #discard if outside home zone and less than zone_radius+self.gps_accuracy_threshold due to gps errors @@ -2504,10 +2727,14 @@ def _update_device_icloud(self, update_reason='Check iCloud', self.zone_home_lat, self.zone_home_long) if do_not_update_flag: - event_msg = (f"Discarding > {info_msg} > GPS-{format_gps(latitude, longitude, gps_accuracy)}, " - f"Located-{location_time} " - f"({self._secs_to_time_str(location_age)} ago), " + location_age_str = "" if location_time == NEVER else (f" ({self._format_age(location_age)})") + event_msg = (f"Discarded > {info_msg} > " + f"GPS-{format_gps(latitude, longitude, gps_accuracy)}, " + f"Located-{location_time}{location_age_str}, " + f"NextUpdate-{self.next_update_time.get(self._format_devicename_zone(devicename))}, " f"OldLocThreshold-{self._secs_to_time_str(self.old_location_secs.get(devicename))}") + if self._device_online(devicename) == False: + event_msg += (f", DeviceStatus-{self.device_status.get(devicename)}") self._save_event(devicename, event_msg) self._log_start_finish_update_banner('▲▲▲', devicename, @@ -2522,7 +2749,7 @@ def _update_device_icloud(self, update_reason='Check iCloud', else: info_msg = "Updating" event_msg = (f"Updating Device > GPS-{format_gps(latitude, longitude, gps_accuracy)}, " - f"Located-{location_time} ({self._secs_to_time_str(location_age)} ago)") + f"Located-{location_time} ({self._format_age(location_age)})") info_msg = f"{info_msg} {self.fname.get(devicename)}" self._display_info_status_msg(devicename, info_msg) self._save_event(devicename, event_msg) @@ -2551,8 +2778,8 @@ def _update_device_icloud(self, update_reason='Check iCloud', longitude, battery, gps_accuracy, - old_loc_poor_gps_flag, - location_time_secs, + poor_location_gps_flag, + last_located_secs, location_time, "icld") if attrs != {}: @@ -2573,18 +2800,22 @@ def _update_device_icloud(self, update_reason='Check iCloud', #location goes to 'See' as a "(latitude, longitude)" pair. #'See' converts them to ATTR_LATITUDE and ATTR_LONGITUDE #and discards the 'gps' item. - log_msg = (f"LOCATION ATTRIBUTES, State-{self.state_last_poll.get(devicename)}, " - f"Attrs-{attrs}") + log_msg = (f"LOCATION ATTRIBUTES, " + f"State-{self.state_last_poll.get(devicename)}, " + f"Attrs-{attrs}") self._log_debug_msg(devicename, log_msg) self.count_update_icloud[devicename] += 1 - if not old_loc_poor_gps_flag: - self._update_last_latitude_longitude(devicename, latitude, longitude, (f"iCloud-{update_reason}")) + if not poor_location_gps_flag: + self._update_last_latitude_longitude( + devicename, latitude, longitude, + (f"iCloud-{update_reason}")) if altitude is None: altitude = -2 - attrs[ATTR_DEVICE_STATUS] = device_status + #attrs[ATTR_DEVICE_STATUS] = device_status + attrs[ATTR_DEVICE_STATUS] = self.device_status.get(devicename) attrs[ATTR_LOW_POWER_MODE] = low_power_mode attrs[ATTR_BATTERY] = battery attrs[ATTR_BATTERY_STATUS] = battery_status @@ -2689,10 +2920,10 @@ def _get_fmf_data(self, devicename): log_msg = (f"LOCATION DATA-{location_data})") self._log_debug_msg(devicename, log_msg) - age = location_data[ATTR_AGE] + location_age = location_data[ATTR_AGE] - if age > self.old_location_secs.get(devicename): - if self._refresh_pyicloud_devices_location_data(age, devicename): + if location_age > self.old_location_secs.get(devicename): + if self._refresh_pyicloud_devices_location_data(location_age, devicename): self._display_info_status_msg(devicename, "Location Data Old, Refreshing") location_data = self.location_data.get(devicename) @@ -2704,7 +2935,10 @@ def _get_fmf_data(self, devicename): self._log_error_msg(f"iCloud3 Error > No Location Data " f"Returned for {devicename}") return ICLOUD_LOCATION_DATA_ERROR - self._display_info_status_msg(devicename, "iCloud Location Data Available") + + if self._device_online(devicename): + self._display_info_status_msg(devicename, "iCloud Location Data Available") + return True except Exception as err: @@ -2742,10 +2976,10 @@ def _get_famshr_data(self, devicename): log_msg = (f"LOCATION DATA-{location_data})") self._log_debug_msg(devicename, log_msg) - age = location_data[ATTR_AGE] + location_age = location_data[ATTR_AGE] - if age > self.old_location_secs.get(devicename): - if self._refresh_pyicloud_devices_location_data(age, devicename): + if location_age > self.old_location_secs.get(devicename): + if self._refresh_pyicloud_devices_location_data(location_age, devicename): self._display_info_status_msg(devicename, "Location Data Old, Refreshing") location_data = self.location_data.get(devicename) @@ -2766,7 +3000,7 @@ def _get_famshr_data(self, devicename): return ICLOUD_LOCATION_DATA_ERROR #---------------------------------------------------------------------------- - def _refresh_pyicloud_devices_location_data(self, age, arg_devicename): + def _refresh_pyicloud_devices_location_data(self, location_age, arg_devicename): ''' Authenticate pyicloud & refresh device & location data. This calls the function to update 'self.location_data' for each device being tracked. @@ -2776,12 +3010,12 @@ def _refresh_pyicloud_devices_location_data(self, age, arg_devicename): ''' try: - event_msg=(f"Sending location request to iCloud > Last Updated") - if age == HIGH_INTEGER: - event_msg += (f": Never (Initial Locate)") + event_msg=(f"Sending location request to iCloud > Last Updated-") + if location_age == HIGH_INTEGER: + event_msg += (f"Never (Initial Locate)") else: - event_msg += (f" {self.location_data.get(arg_devicename)[ATTR_TIMESTAMP_TIME]} " - f"({self._secs_to_time_str(age)} ago)") + event_msg += (f"{self.location_data.get(arg_devicename)[ATTR_TIMESTAMP_TIME]} " + f"({self._format_age(location_age)})") self._save_event_halog_info(arg_devicename, event_msg) exit_get_data_loop = False @@ -2875,9 +3109,9 @@ def _update_location_data(self, devicename, device_data): timestamp = device_data[ATTR_LOCATION][timestamp_field] / 1000 if timestamp == self.location_data.get(devicename)[ATTR_TIMESTAMP]: - age = self.location_data.get(devicename)[ATTR_AGE] + location_age = self.location_data.get(devicename)[ATTR_AGE] debug_msg = (f"DEVICE NOT UPDATED > Will Refresh, Located-{self._secs_to_time(timestamp)} " - f"({self._secs_to_time_str(age)} ago)") + f"({self._format_age(location_age)})") self._log_debug_msg(devicename, debug_msg) return @@ -2911,7 +3145,7 @@ def _update_location_data(self, devicename, device_data): self.location_data[devicename] = location_data debug_msg = (f"UPDATE LOCATION > Located-{location_data[ATTR_TIMESTAMP_TIME]} " - f"({self._secs_to_time_str(location_data[ATTR_AGE])} ago)") + f"({self._format_age(location_data[ATTR_AGE])})") self._log_debug_msg(devicename, debug_msg) self._log_debug_msg(devicename, location_data) @@ -2922,52 +3156,6 @@ def _update_location_data(self, devicename, device_data): return False -######################################################### -# -# iCloud is disabled so trigger the iosapp to send a -# Background Fetch location transaction -# -######################################################### - def _request_iosapp_location_update(self, devicename): - '''Send location request to phone''' - - if (self.iosapp_locate_request_cnt.get(devicename) > self.iosapp_locate_request_max_cnt): - return - - request_msg_suffix = '' - - try: - if self.iosapp_locate_update_secs.get(devicename, 0) > 0: - age = self._secs_since(self.iosapp_locate_update_secs.get(devicename)) - request_msg_suffix = (f" {self._secs_to_time_str(age)} ago") - - else: - self.iosapp_locate_update_secs[devicename] = self.this_update_secs - self.iosapp_locate_request_cnt[devicename] += 1 - - message = {"message": "request_location_update"} - return_code = self._send_message_to_device(devicename, message) - - #self.hass.async_create_task( - # self.hass.services.async_call('notify', entity_id, service_data)) - if return_code: - event_msg = (f"Requested iOS App Location (#{self.iosapp_locate_request_cnt.get(devicename)})") - - attrs = {} - attrs[ATTR_POLL_COUNT] = self._format_poll_count(devicename) - attrs[ATTR_INFO] = (f"● Requested iOS App Location " - f"(#{self.iosapp_locate_request_cnt.get(devicename)})" - f"{request_msg_suffix} ●") - self._update_device_sensors(devicename, attrs) - self._save_event(devicename, event_msg) - - except Exception as err: - _LOGGER.exception(err) - error_msg = (f"iCloud3 Error > An error occurred sending a location request > " - f"Device-{self.notify_iosapp_entity.get(devicename)}, Error-{err}") - self._save_event_halog_error(devicename, error_msg) - - ######################################################### # # Calculate polling interval based on zone, distance from home and @@ -2975,8 +3163,8 @@ def _request_iosapp_location_update(self, devicename): # ######################################################### def _determine_interval(self, devicename, latitude, longitude, - battery, gps_accuracy, old_loc_poor_gps_flag, - location_time_secs, location_time, + battery, gps_accuracy, poor_location_gps_flag, + last_located_secs, location_time, ios_icld = ''): """Calculate new interval. Return location based attributes""" @@ -2991,13 +3179,13 @@ def _determine_interval(self, devicename, latitude, longitude, latitude, longitude, gps_accuracy, - old_loc_poor_gps_flag) + poor_location_gps_flag) log_msg = (f"Location_data-{location_data}") self._log_debug_interval_msg(devicename, log_msg) #Abort and Retry if Internal Error - if (location_data[0] == 'ERROR'): + if (location_data[0] == ERROR): return location_data[1] #(attrs) self._display_info_status_msg(devicename, "● Calculating Tracking Info ●") @@ -3019,6 +3207,7 @@ def _determine_interval(self, devicename, latitude, longitude, dir_of_trav_msg = location_data[14] timestamp = location_data[15] + last_located_secs = self.last_located_secs.get(devicename) except Exception as err: attrs_msg = self._internal_error_msg(fct_name, err, 'SetLocation') @@ -3063,9 +3252,9 @@ def _determine_interval(self, devicename, latitude, longitude, f"NZ-{near_zone_flag}") self._log_debug_interval_msg(devicename, log_msg) - log_method = '' + interval_method = '' log_msg = '' - log_method_im = '' + interval_method_im = '' old_location_secs_msg = '' except Exception as err: @@ -3084,102 +3273,98 @@ def _determine_interval(self, devicename, latitude, longitude, #updating the device_tracker state so it will not trigger a state chng if self.state_change_flag.get(devicename): if inzone_flag: - - if (STATIONARY in zone): interval = self.stat_zone_inzone_interval - log_method = "1sz-Stationary" + interval_method = "1sz-Stationary" log_msg = f"Zone-{zone}" #inzone & old location - elif old_loc_poor_gps_flag: - interval = self._get_interval_for_error_retry_cnt( - self.old_loc_poor_gps_cnt.get(devicename)) - log_method = '1iz-OldLocPoorGPS' + elif poor_location_gps_flag: + interval = self._get_interval_for_error_retry_cnt(devicename, POOR_LOC_GPS_CNT) + #self.poor_location_gps_cnt.get(devicename)) + interval_method = '1iz-OldLocPoorGPS' else: interval = self.inzone_interval_secs - log_method="1ez-EnterZone" + interval_method="1ez-EnterZone" #entered 'near_zone' zone if close to HOME and last is NOT_HOME elif (near_zone_flag and wasnot_inzone_flag and calc_dist_from_zone_km < 2): interval = 15 dir_of_travel = 'NearZone' - log_method="1nz-EnterHomeNearZone" + interval_method="1nzniz-EnterNearZone" #entered 'near_zone' zone if close to HOME and last is NOT_HOME elif (near_zone_flag and was_inzone_flag and calc_dist_from_zone_km < 2): interval = 15 dir_of_travel = 'NearZone' - log_method="1nhz-EnterNearHomeZone" + interval_method="1nziz-EnterNearZone" #exited HOME zone elif (not_inzone_flag and was_inzone_home_flag): interval = 240 dir_of_travel = AWAY_FROM - log_method="1ehz-ExitHomeZone" + interval_method="1ehz-ExitHomeZone" #exited 'other' zone elif (not_inzone_flag and was_inzone_flag): interval = 120 dir_of_travel = 'left_zone' - log_method="1ez-ExitZone" + interval_method="1ez-ExitZone" #entered 'other' zone else: interval = 240 - log_method="1zc-ZoneChanged" + interval_method="1zc-ZoneChanged" log_msg = (f"Zone-{zone}, Last-{self.state_last_poll.get(devicename)}, " f"This-{self.state_this_poll.get(devicename)}") self._log_debug_interval_msg(devicename, log_msg) #inzone & poor gps & check gps accuracy when inzone - elif (self.poor_gps_accuracy_flag.get(devicename) + elif (self.poor_gps_flag.get(devicename) and inzone_flag and self.check_gps_accuracy_inzone_flag): interval = 300 #poor accuracy, try again in 5 minutes - log_method = '2iz-PoorGPS' + interval_method = '2pgpsiz-PoorGPSinZone' - elif self.poor_gps_accuracy_flag.get(devicename): - interval = self._get_interval_for_error_retry_cnt( - self.old_loc_poor_gps_cnt.get(devicename)) - log_method = '2niz-PoorGPS' + elif self.poor_gps_flag.get(devicename): + interval = self._get_interval_for_error_retry_cnt(devicename, POOR_LOC_GPS_CNT) + interval_method = '2pgps-PoorGPS' - elif self.overrideinterval_seconds.get(devicename) > 0: - interval = self.overrideinterval_seconds.get(devicename) - log_method = '3-Override' + elif self.override_interval_seconds.get(devicename) > 0: + interval = self.override_interval_seconds.get(devicename) + interval_method = '3ior-Override' elif (STATIONARY in zone): interval = self.stat_zone_inzone_interval - log_method = "4sz-Stationary" + interval_method = "4sz-Stationary" log_msg = f"Zone-{zone}" - elif old_loc_poor_gps_flag: - interval = self._get_interval_for_error_retry_cnt( - self.old_loc_poor_gps_cnt.get(devicename)) - log_method = '4-OldLocPoorGPS' - log_msg = f"Cnt-{self.old_loc_poor_gps_cnt.get(devicename)}" + elif poor_location_gps_flag: + interval = self._get_interval_for_error_retry_cnt(devicename, POOR_LOC_GPS_CNT) + interval_method = '4old-OldLocPoorGPS' + log_msg = f"Cnt-{self.poor_location_gps_cnt.get(devicename)}" elif (inzone_home_flag or (dist_from_zone_km < .05 and dir_of_travel == 'towards')): interval = self.inzone_interval_secs - log_method = '4iz-InZone' + interval_method = '4ihz-InHomeZone' log_msg = f"Zone-{zone}" elif zone == 'near_zone': interval = 15 - log_method = '4nz-NearZone' + interval_method = '4nz-NearZone' log_msg = f"Zone-{zone}, Dir-{dir_of_travel}" #in another zone and inzone time > travel time elif (inzone_flag and self.inzone_interval_secs > waze_interval): interval = self.inzone_interval_secs - log_method = '4iz-InZone' + interval_method = '4iz-InZone' log_msg = f"Zone-{zone}" elif dir_of_travel in ('left_zone', NOT_SET): @@ -3188,51 +3373,50 @@ def _determine_interval(self, devicename, latitude, longitude, dir_of_travel = AWAY_FROM else: dir_of_travel = NOT_SET - log_method = '5-NeedInfo' + interval_method = '5ni-NeedInfo' log_msg = f"ZoneLeft-{zone}" - elif dist_from_zone_km < 2.5 and self.went_3km.get(devicename): interval = 15 #1.5 mi=real close and driving - log_method = '10a-Dist < 2.5km(1.5mi)' + interval_method = '10a-<2.5km' elif dist_from_zone_km < 3.5: #2 mi=30 sec interval = 30 - log_method = '10b-Dist < 3.5km(2mi)' + interval_method = '10b-<3.5km' elif waze_time_from_zone > 5 and waze_interval > 0: interval = waze_interval - log_method = '10c-WazeTime' + interval_method = '10c-WazeTime' log_msg = f"TimeFmHome-{waze_time_from_zone}" elif dist_from_zone_km < 5: #3 mi=1 min interval = 60 - log_method = '10d-Dist < 5km(3mi)' + interval_method = '10d-<5km' elif dist_from_zone_km < 8: #5 mi=2 min interval = 120 - log_method = '10e-Dist < 8km(5mi)' + interval_method = '10e-<8km' elif dist_from_zone_km < 12: #7.5 mi=3 min interval = 180 - log_method = '10f-Dist < 12km(7mi)' + interval_method = '10f-<12km' elif dist_from_zone_km < 20: #12 mi=10 min interval = 600 - log_method = '10g-Dist < 20km(12mi)' + interval_method = '10g-<20km' elif dist_from_zone_km < 40: #25 mi=15 min interval = 900 - log_method = '10h-Dist < 40km(25mi)' + interval_method = '10h-<40km' elif dist_from_zone_km > 150: #90 mi=1 hr interval = 3600 - log_method = '10i-Dist > 150km(90mi)' + interval_method = '10i->150km' else: interval = calc_interval - log_method = '20-Calculated' + interval_method = '20calc-Calculated' log_msg = f"Value-{self._km_to_mi(dist_from_zone_km)}/1.5" except Exception as err: @@ -3243,7 +3427,7 @@ def _determine_interval(self, devicename, latitude, longitude, #determined in get_dist_data with dir_of_travel if dir_of_travel == STATIONARY: interval = self.stat_zone_inzone_interval - log_method = "21-Stationary" + interval_method = "21stat-Stationary" if self.in_stationary_zone_flag.get(devicename) is False: rtn_code = self._update_stationary_zone( @@ -3256,25 +3440,32 @@ def _determine_interval(self, devicename, latitude, longitude, if rtn_code: self.zone_current[devicename] = self._format_zone_name(devicename, STATIONARY) self.zone_timestamp[devicename] = dt_util.now().strftime(self.um_date_time_strfmt) - log_method_im = "●Set.Stationary.Zone" + interval_method_im = "●Set.Stationary.Zone" zone = STATIONARY dir_of_travel = 'in_zone' inzone_flag = True not_inzone_flag = False else: dir_of_travel = NOT_SET + if (dir_of_travel in ('', AWAY_FROM) + and interval < 180 + and interval > 0): + interval_method += ',xx30dot-Away(<3min)' - if dir_of_travel in ('', AWAY_FROM) and interval < 180: + if (dir_of_travel in ('', AWAY_FROM) + and interval < 180 + and interval > 30): interval = 180 - log_method_im = '30-Away(<3min)' + interval_method += ',30dot-Away(<3min)' elif (dir_of_travel == AWAY_FROM and not self.distance_method_waze_flag): interval_multiplier = 2 #calc-increase timer - log_method_im = '30-Away(Calc)' - - elif (dir_of_travel == NOT_SET and interval > 180): + interval_method += ',30dot-Away(Calc)' + elif (dir_of_travel == NOT_SET + and interval > 180): interval = 180 + interval_method += ',301dor->180s' #15-sec interval (close to zone) and may be going into a stationary zone, #increase the interval @@ -3282,7 +3473,7 @@ def _determine_interval(self, devicename, latitude, longitude, and devicename in self.stat_zone_timer and self.this_update_secs >= self.stat_zone_timer.get(devicename)+45): interval = 30 - log_method_im = '31-StatTimer+45' + interval_method += ',31st-StatTimer+45' except Exception as err: attrs_msg = self._internal_error_msg(fct_name, err, 'SetStatZone') @@ -3293,28 +3484,31 @@ def _determine_interval(self, devicename, latitude, longitude, #Turn off waze close to zone flag to use waze after leaving zone if inzone_flag: self.waze_close_to_zone_pause_flag = False - + if (self.iosapp_update_flag.get(devicename) + and interval < 180 + and self.override_interval_seconds.get(devicename) == 0): + interval_method += ',xx33ios-iosAppTrigger' #if triggered by ios app (Zone Enter/Exit, Manual, Fetch, etc.) #and interval < 3 min, set to 3 min. Leave alone if > 3 min. if (self.iosapp_update_flag.get(devicename) and interval < 180 - and self.overrideinterval_seconds.get(devicename) == 0): + and interval > 30 + and self.override_interval_seconds.get(devicename) == 0): interval = 180 - log_method = '0-iosAppTrigger' + interval_method += ',33ios-iosAppTrigger' #if changed zones on this poll reset multiplier if self.state_change_flag.get(devicename): interval_multiplier = 1 #Check accuracy again to make sure nothing changed, update counter - if self.poor_gps_accuracy_flag.get(devicename): + if self.poor_gps_flag.get(devicename): interval_multiplier = 1 except Exception as err: attrs_msg = self._internal_error_msg(fct_name, err, 'ResetStatZone') return attrs_msg - try: #Real close, final check to make sure interval is not adjusted if (interval <= 60 @@ -3329,21 +3523,22 @@ def _determine_interval(self, devicename, latitude, longitude, if interval > self.max_interval_secs and not_inzone_flag: interval_str = (f"{self._secs_to_time_str(self.max_interval_secs)}({self._secs_to_time_str(interval)})") interval = self.max_interval_secs - log_method = (f"40-MaxIntervalOverride {interval_str}") + interval_method += (f",40-Max") else: interval_str = self._secs_to_time_str(interval) - interval_debug_msg = (f"●Interval-{interval_str} ({log_method}, {log_msg}), " + interval_debug_msg = (f"●Interval-{interval_str} ({interval_method}, {log_msg}), " f"●DirOfTrav-{dir_of_trav_msg}, " f"●State-{self.state_last_poll.get(devicename)}->, " f"{self.state_this_poll.get(devicename)}, " f"Zone-{zone}") - event_msg = (f"Interval basis: {log_method}, {log_msg}, Direction {dir_of_travel}") + event_msg = (f"Interval basis: {interval_method}, {log_msg}, Direction {dir_of_travel}") #self._save_event(devicename, event_msg) - if interval_multiplier != 1: - interval_debug_msg = (f"{interval_debug_msg}, " - f"Multiplier-{interval_multiplier}({log_method_im})") + if interval_multiplier > 1: + interval_method += (f"x{interval_multiplier}") + interval_debug_msg = (f"{interval_debug_msg}, " + f"Multiplier-{interval_multiplier} ({interval_method})") #check if next update is past midnight (next day), if so, adjust it next_poll = round((self.this_update_secs + interval)/15, 0) * 15 @@ -3354,7 +3549,7 @@ def _determine_interval(self, devicename, latitude, longitude, self.interval_seconds[devicename_zone] = interval self.interval_str[devicename_zone] = interval_str self.last_update_secs[devicename_zone] = self.this_update_secs - self.last_update_time[devicename_zone] = self._secs_to_time(self.this_update_secs) + self.last_update_time[devicename_zone] = self._time_to_12hrtime(self.this_update_time) #-------------------------------------------------------------------------------- #Calculate the old_location age check based on the direction and if there are @@ -3365,7 +3560,7 @@ def _determine_interval(self, devicename, latitude, longitude, self.old_location_secs[devicename] = HIGH_INTEGER ic3dev_old_location_secs = self.old_location_secs.get(devicename) - new_old_location_secs = self._determine_old_location_secs(zone, interval) + new_old_location_secs = self._determine_old_location_secs(zone, interval) select='' if inzone_flag: select='inzone' @@ -3401,14 +3596,15 @@ def _determine_interval(self, devicename, latitude, longitude, if self.log_level_intervalcalc_flag == False: interval_debug_msg = '' - log_msg = (f"DETERMINE INTERVAL , " - f"This poll: {self._secs_to_time(self.this_update_secs)}({self.this_update_secs}), " - f"Last Update: {self.last_update_time.get(devicename_zone)}({self.last_update_secs.get(devicename_zone)}), " - f"Next Update: {self.next_update_time.get(devicename_zone)}({self.next_update_secs.get(devicename_zone)}), " - f"Interval: {self.interval_str.get(devicename_zone)}*{interval_multiplier}, " - f"OverrideInterval-{self.overrideinterval_seconds.get(devicename)}, " - f"DistTraveled-{dist_last_poll_moved_km}, CurrZone-{zone}") - self._log_debug_interval_msg(devicename, log_msg) + event_msg = (f"{EVLOG_DEBUG}Interval Results > " + f"Interval-{interval_str} ({interval_method}), " + f"LastUpdate-{self.last_update_time.get(devicename_zone)}, " + f"NextUpdate-{self.next_update_time.get(devicename_zone)}, " + f"DistTraveled-{self._format_dist(calc_dist_last_poll_moved_km)}, " + f"CurrZone-{zone}") + if self.override_interval_seconds.get(devicename) > 0: + event_msg += (f", OverrideInterval-{self._secs_to_time_str(self.override_interval_seconds.get(devicename))}") + self._save_event_halog_info(devicename, event_msg, log_title="") except Exception as err: attrs_msg = self._internal_error_msg(fct_name, err, 'ShowMsgs') @@ -3419,15 +3615,18 @@ def _determine_interval(self, devicename, latitude, longitude, if near_zone_flag: zone = NOT_HOME - log_msg = (f"DIR OF TRAVEL ATTRS, Direction-{dir_of_travel}, LastDir-{last_dir_of_travel}, " - f"Dist-{dist_from_zone_km}, LastDist-{last_dist_from_zone_km}, " - f"SelfDist-{self.zone_dist.get(devicename_zone)}, Moved-{dist_from_zone_moved_km}," + log_msg = (f"DIR OF TRAVEL ATTRS, Direction-{dir_of_travel}, " + f"LastDir-{last_dir_of_travel}, " + f"Dist-{dist_from_zone_km}, " + f"LastDist-{last_dist_from_zone_km}, " + f"SelfDist-{self.zone_dist.get(devicename_zone)}, " + f"Moved-{dist_from_zone_moved_km}," f"WazeMoved-{waze_dist_from_zone_moved_km}") self._log_debug_interval_msg(devicename, log_msg) #if poor gps and moved less than 1km, redisplay last distances if (self.state_change_flag.get(devicename) == False - and self.poor_gps_accuracy_flag.get(devicename) + and self.poor_gps_flag.get(devicename) and dist_last_poll_moved_km < 1): dist_from_zone_km = self.zone_dist.get(devicename_zone) waze_dist_from_zone_km = self.waze_dist.get(devicename_zone) @@ -3451,36 +3650,43 @@ def _determine_interval(self, devicename, latitude, longitude, #Save last and new state, set attributes #If first time thru, set the last state to the current state #so a zone change will not be triggered next time + if self.state_last_poll.get(devicename) == NOT_SET: - self.state_last_poll[devicename] = zone + last_state = zone #When put into stationary zone, also set last_poll so it #won't trigger again on next cycle as a state change elif (instr(zone, STATIONARY) or instr(self.state_this_poll.get(devicename), STATIONARY)): zone = STATIONARY - self.state_last_poll[devicename] = STATIONARY + last_state = STATIONARY + #elif self.state_this_poll.get(devicename) == AWAY: + # self.state_last_poll[devicename] = NOT_HOME else: - self.state_last_poll[devicename] = self.state_this_poll.get(devicename) + last_state = self.state_this_poll.get(devicename) + + #Make sure the new 'last state' value is the internal value for + #the state (e.g., Away-->not_home) to reduce state change triggers later. + last_state = STATE_TO_ZONE_BASE.get(last_state, last_state) + self.state_last_poll[devicename] = last_state self.state_this_poll[devicename] = zone self.last_located_time[devicename] = self._time_to_12hrtime(location_time) - location_age = self._secs_since(location_time_secs) - location_age_str = self._secs_to_time_str(location_age) - if (old_loc_poor_gps_flag and self.poor_gps_accuracy_flag.get(devicename) == False): + location_age = self._secs_since(last_located_secs) + location_age_str = self._format_age(location_age) + if (poor_location_gps_flag and self.poor_gps_flag.get(devicename) == False): location_age_str = (f"Old-{location_age_str}") log_msg = (f"LOCATION TIME-{devicename} location_time-{location_time}, " - f"loc_time_secs-{self._secs_to_time(location_time_secs)}({location_time_secs}), " - f"age-{location_age_str}") + f"loc_time_secs-{self._secs_to_time(last_located_secs)} ({location_age_str})") self._log_debug_msg(devicename, log_msg) attrs = {} attrs[ATTR_ZONE] = self.zone_current.get(devicename) attrs[ATTR_ZONE_TIMESTAMP] = str(self.zone_timestamp.get(devicename)) attrs[ATTR_LAST_ZONE] = self.zone_last.get(devicename) - attrs[ATTR_LAST_UPDATE_TIME] = self._secs_to_time(self.this_update_secs) + attrs[ATTR_LAST_UPDATE_TIME] = self._time_to_12hrtime(self.this_update_time) attrs[ATTR_LAST_LOCATED] = self._time_to_12hrtime(location_time) attrs[ATTR_INTERVAL] = interval_str @@ -3521,8 +3727,7 @@ def _determine_interval(self, devicename, latitude, longitude, battery, gps_accuracy, dist_last_poll_moved_km, - zone, - old_loc_poor_gps_flag, location_time_secs) + zone) attrs[ATTR_INFO] = interval_debug_msg + info_msg @@ -3532,18 +3737,21 @@ def _determine_interval(self, devicename, latitude, longitude, self.last_distance_str[devicename_zone] = (f"{self._km_to_mi(dist_from_zone_km)} {self.unit_of_measurement}") self._trace_device_attributes(devicename, 'RESULTS', fct_name, attrs) - event_msg = (f"Results: {self.zone_fname.get(self.base_zone)} > " - f"CurrZone-{self.zone_fname.get(self.zone_current.get(devicename), AWAY)}, " - f"GPS-{format_gps(latitude, longitude, gps_accuracy)}, " - f"Interval-{interval_str}, " - f"Dist-{self._km_to_mi(dist_from_zone_km)} {self.unit_of_measurement}, " + zone = self.zone_current.get(devicename) + + event_msg = (f"Results: From-{self.zone_to_display.get(self.base_zone)} > CurrZone-" + f"{self.zone_to_display.get(zone)}, ") + event_msg +=(f"GPS-{format_gps(latitude, longitude, gps_accuracy)}, " + f"Interval-{interval_str}") + event_msg +=(f" ({interval_method}), ") if self.log_level_debug_flag else ", " + event_msg +=(f"Dist-{self._km_to_mi(dist_from_zone_km)} {self.unit_of_measurement}, " f"TravTime-{waze_time_msg} ({dir_of_travel}), " f"NextUpdt-{self._secs_to_time(next_poll)}, " - f"Located-{self._time_to_12hrtime(location_time)} ({location_age_str} ago), " + f"Located-{self._time_to_12hrtime(location_time)} ({location_age_str}), " f"OldLocThreshold-{old_location_secs_msg}") if self.stat_zone_timer.get(devicename) > 0: - event_msg += (f", WillMoveIntoStatZoneAfter-{self._secs_to_time(self.stat_zone_timer.get(devicename))}" - f"({self.stat_zone_moved_total.get(devicename)*100}m)") + event_msg += (f", WillMoveIntoStatZoneAfter-{self._secs_to_time(self.stat_zone_timer.get(devicename))} " + f"({self._format_dist(self.stat_zone_moved_total.get(devicename))})") self._save_event_halog_info(devicename, event_msg, log_title="") return attrs @@ -3560,7 +3768,7 @@ def _determine_interval(self, devicename, latitude, longitude, # retry intervals based on current retry count. # ######################################################### - def _determine_interval_retry_after_error(self, devicename, retry_cnt, device_status, info_msg): + def _determine_interval_after_error(self, devicename, info_msg, counter=POOR_LOC_GPS_CNT): ''' Handle errors where the device can not be or should not be updated with the current data. The update will be retried 4 times on a 15 sec interval. @@ -3579,16 +3787,13 @@ def _determine_interval_retry_after_error(self, devicename, retry_cnt, device_st - Poor GPS Acuracy ''' - fct_name = "_determine_interval_retry_after_error" + fct_name = "_determine_interval_after_error" base_zone_home_flag = (self.base_zone == HOME) devicename_zone = self._format_devicename_zone(devicename) try: - if device_status in ["online", "pending", ""]: - interval = self._get_interval_for_error_retry_cnt(retry_cnt) - else: - interval = 900 + interval = self._get_interval_for_error_retry_cnt(devicename, counter) #check if next update is past midnight (next day), if so, adjust it next_poll = round((self.this_update_secs + interval)/15, 0) * 15 @@ -3596,22 +3801,29 @@ def _determine_interval_retry_after_error(self, devicename, retry_cnt, device_st # Update all dates and other fields interval_str = self._secs_to_time_str(interval) next_updt_str = self._secs_to_time(next_poll) - last_updt_str = self._secs_to_time(self.this_update_secs) self.interval_seconds[devicename_zone] = interval self.last_update_secs[devicename_zone] = self.this_update_secs + self.last_update_time[devicename_zone] = self._time_to_12hrtime(self.this_update_time) self.next_update_secs[devicename_zone] = next_poll - self.last_update_time[devicename_zone] = last_updt_str self.next_update_time[devicename_zone] = next_updt_str self.interval_str[devicename_zone] = interval_str self.count_update_ignore[devicename] += 1 attrs = {} - attrs[ATTR_LAST_UPDATE_TIME] = last_updt_str + attrs[ATTR_LAST_UPDATE_TIME] = self._time_to_12hrtime(self.this_update_time) attrs[ATTR_INTERVAL] = interval_str attrs[ATTR_NEXT_UPDATE_TIME] = next_updt_str attrs[ATTR_POLL_COUNT] = self._format_poll_count(devicename) - attrs[ATTR_INFO] = (f"●● {info_msg} ●●") + + #If located more than 12 hrs ago, use dhms ago instead of time + location_age = self._secs_since(self.last_located_secs.get(devicename)) + if location_age > 42300: + attrs[ATTR_LAST_LOCATED] = f"{self.format_age(location_age)}" + info_msg = f"May be Offline, {info_msg}, LastLocated-{self.format_age(location_age)}" + self._save_event(devicename,info_msg) + + attrs[ATTR_INFO] = (f"●● {info_msg} ●●") this_zone = self.state_this_poll.get(devicename) this_zone = self._format_zone_name(devicename, this_zone) @@ -3650,12 +3862,20 @@ def _determine_interval_retry_after_error(self, devicename, retry_cnt, device_st self.device_being_updated_flag[devicename] = False - log_msg = (f"DETERMINE INTERVAL ERROR RETRY, CurrZone-{this_zone}, " - f"LastZone-{last_zone}, GPS-{format_gps(latitude,longitude, 0)}") - self._log_debug_interval_msg(devicename, log_msg) - log_msg = (f"DETERMINE INTERVAL ERROR RETRY, Interval-{interval_str}, " - f"LastUpdt-{last_updt_str}, NextUpdt-{next_updt_str}, Info-{info_msg}") - self._log_debug_interval_msg(devicename, log_msg) + #Set the lat/long for the Error Msg if not_set + if this_zone == NOT_SET: + latitude = 0 + longitude = 0 + + event_msg = (f"{EVLOG_DEBUG}Discarded Results: {self.zone_to_display.get(self.base_zone)} > " + f"CurrZone-{this_zone}, " + f"GPS-{format_gps(latitude, longitude, 0)}, " + f"Interval-{interval_str}, " + f"NextUpdt-{next_updt_str}, " + f"OldLocPoorGpsCnt-#{self.poor_location_gps_cnt.get(devicename)}, " + f"UpdateErrorCnt-#{self.count_update_ignore.get(devicename)}, " + f"InfoMsg-{info_msg}") + self._save_event(devicename, event_msg) except Exception as err: _LOGGER.exception(err) @@ -3666,7 +3886,7 @@ def _determine_interval_retry_after_error(self, devicename, retry_cnt, device_st # ######################################################### def _get_distance_data(self, devicename, latitude, longitude, - gps_accuracy, old_loc_poor_gps_flag): + gps_accuracy, poor_location_gps_flag): """ Determine the location of the device. Returns: - zone (current zone from lat & long) @@ -3682,7 +3902,7 @@ def _get_distance_data(self, devicename, latitude, longitude, try: if latitude is None or longitude is None: attrs = self._internal_error_msg(fct_name, 'lat/long=None', 'NoLocation') - return ('ERROR', attrs) + return (ERROR, attrs) base_zone_home_flag = (self.base_zone == HOME) devicename_zone = self._format_devicename_zone(devicename) @@ -3713,7 +3933,7 @@ def _get_distance_data(self, devicename, latitude, longitude, _LOGGER.exception(err) error_msg = (f"Entity-{entity_id}, Err-{err}") attrs = self._internal_error_msg(fct_name, error_msg, 'GetAttrs') - return ('ERROR', attrs) + return (ERROR, attrs) try: #Not available if first time after reset @@ -3745,7 +3965,7 @@ def _get_distance_data(self, devicename, latitude, longitude, except Exception as err: _LOGGER.exception(err) attrs = self._internal_error_msg(fct_name, err, 'SetupLocation') - return ('ERROR', attrs) + return (ERROR, attrs) try: zone = self._get_zone(devicename, this_lat, this_long) @@ -3759,7 +3979,7 @@ def _get_distance_data(self, devicename, latitude, longitude, except Exception as err: _LOGGER.exception(err) attrs = self._internal_error_msg(fct_name, err, 'GetCurrZone') - return ('ERROR', attrs) + return (ERROR, attrs) try: # Get Waze distance & time @@ -3832,7 +4052,7 @@ def _get_distance_data(self, devicename, latitude, longitude, except Exception as err: _LOGGER.exception(err) attrs = self._internal_error_msg(fct_name, err, 'InitializeDist') - return ('ERROR', attrs) + return (ERROR, attrs) try: if self.waze_status == WAZE_USED: @@ -3869,7 +4089,7 @@ def _get_distance_data(self, devicename, latitude, longitude, #device. if (gps_accuracy <= self.gps_accuracy_threshold and waze_dist_from_zone_km > 0 - and old_loc_poor_gps_flag is False): + and poor_location_gps_flag is False): self.waze_distance_history[devicename_zone] = \ [self._time_now_secs(), this_lat, @@ -3943,7 +4163,7 @@ def _get_distance_data(self, devicename, latitude, longitude, except Exception as err: _LOGGER.exception(err) attrs = self._internal_error_msg(fct_name, err, 'CalcDist') - return ('ERROR', attrs) + return (ERROR, attrs) try: section = "dir_of_trav" @@ -3965,7 +4185,7 @@ def _get_distance_data(self, devicename, latitude, longitude, dir_of_travel = AWAY_FROM dir_of_trav_msg = (f"Dist-{self._format_dist(dist_from_zone_moved_km)}") - elif self.poor_gps_accuracy_flag.get(devicename): + elif self.poor_gps_flag.get(devicename): dir_of_travel = 'Poor.GPS' dir_of_trav_msg = ("Poor.GPS-{gps_accuracy}") @@ -4090,7 +4310,7 @@ def _get_distance_data(self, devicename, latitude, longitude, except Exception as err: _LOGGER.exception(err) attrs = self._internal_error_msg(fct_name+section, err, 'Finalize') - return ('ERROR', attrs) + return (ERROR, attrs) #-------------------------------------------------------------------------------- def _determine_old_location_secs(self, zone, interval): @@ -4101,19 +4321,19 @@ def _determine_old_location_secs(self, zone, interval): if self.old_location_threshold > 0: return self.old_location_threshold - old_location_secs = 14 + old_location_secs = 60 if self._is_inzone_zonename(zone): old_location_secs = interval * .025 #inzone interval --> 2.5% if old_location_secs < 90: old_location_secs = 90 elif interval < 90: - old_location_secs = 30 #30 secs if < 1.5 min + old_location_secs = 60 #60 secs if < 1.5 min else: old_location_secs = interval * .125 #12.5% of the interval - if old_location_secs < 15: - old_location_secs = 15 + if old_location_secs < 61: + old_location_secs = 61 elif old_location_secs > 600: old_location_secs = 600 @@ -4131,19 +4351,14 @@ def _determine_old_location_secs(self, zone, interval): def _get_state(self, entity_id): """ Get current state of the device_tracker entity - (home, away, other state) + (zone's name, actual state (home, away) or zone's + friendly name) and return it's zone name """ try: - device_state = self.hass.states.get(entity_id).state + entity_state = self.hass.states.get(entity_id).state - if device_state: - if device_state.lower() == 'not set': - state = NOT_SET - else: - state = device_state - else: - state = NOT_HOME + state = self.state_to_zone.get(entity_state, entity_state.lower()) except Exception as err: #When starting iCloud3, the device_tracker for the iosapp might @@ -4151,7 +4366,7 @@ def _get_state(self, entity_id): #_LOGGER.exception(err) state = NOT_SET - return state.lower() + return state #-------------------------------------------------------------------- def _set_state(self, entity_id, state_value, attrs_value): """ @@ -4183,7 +4398,7 @@ def _get_entity_last_changed_time(self, entity_id, devicename): hhmmss = self._secs_to_time(secs) timestamp = self._secs_to_timestamp(secs) age_secs = self.this_update_secs - secs - dhms_age_str = self._secs_to_minsec_str(age_secs) + dhms_age_str = self._secs_to_dhms_str(age_secs) return hhmmss, secs, timestamp, age_secs, dhms_age_str @@ -4230,7 +4445,7 @@ def _get_attr(attributes, attribute_name, numeric = False): #-------------------------------------------------------------------- def _update_device_attributes(self, devicename, kwargs: str = None, - attrs: str = None, fct_name: str = 'Unknown'): + attrs: str = None, fct_name: str = UNKNOWN): """ Update the device and attributes with new information On Entry, kwargs = {} or contains the base attributes. @@ -4270,9 +4485,9 @@ def _update_device_attributes(self, devicename, kwargs: str = None, 'entity_picture': '/local/gary-caller_id.png'} """ + zone = self.zone_current.get(devicename) ic3dev_state = self.state_this_poll.get(devicename) - zone = self.zone_current.get(devicename) - + ic3dev_state_fname = ic3dev_state ####################################################################### #The current zone is based on location of the device after it is looked @@ -4293,28 +4508,21 @@ def _update_device_attributes(self, devicename, kwargs: str = None, #current zone that was based on the device location. #If the state is in a zone but not the current zone, change the state #to the current zone that was based on the device location. - elif ((ic3dev_state == STATIONARY and self._is_inzone(zone)) or - (self._is_inzone(ic3dev_state) and self._is_inzone(zone) and - ic3dev_state != zone)): - event_msg = (f"State/Zone mismatch > Resetting iC3 State ({ic3dev_state}) " - f"to ({zone})") - self._save_event(devicename, event_msg) - ic3dev_state = zone + elif ((ic3dev_state == STATIONARY + and self._is_inzone(zone)) + or (self._is_inzone(ic3dev_state) + and self._is_inzone(zone) + and ic3dev_state != zone)): + if self._check_overlapping_zone(ic3dev_state, zone) == False: + event_msg = (f"State/Zone mismatch > Resetting iC3 State ({ic3dev_state}) " + f"to ({zone})") + self._save_event(devicename, event_msg) + ic3dev_state = zone elif ic3dev_state == HOME: self.waze_distance_history = {} - #Get friendly name or capitalize and reformat ic3dev_state, reset waze history - if self._is_inzone_zonename(ic3dev_state): - if self.zone_fname.get(ic3dev_state): - ic3dev_state = self.zone_fname.get(ic3dev_state) - - else: - ic3dev_state = ic3dev_state.replace('_', ' ') - ic3dev_state = ic3dev_state.title() - - if ic3dev_state == 'Home': - ic3dev_state = HOME + ic3dev_state_fname = self.zone_to_display.get(ic3dev_state, ic3dev_state) #Update the device timestamp if not attrs: @@ -4343,7 +4551,7 @@ def _update_device_attributes(self, devicename, kwargs: str = None, self.trigger[devicename] = new_trigger attrs[ATTR_TRIGGER] = new_trigger - #Update sensor._last_update_trigger if IOS App v2 detected + #Update sensor._last_update_trigger if IOS App detected #and iCloud3 has been running for at least 10 secs to let HA & #mobile_app start up to avoid error if iC3 loads before the mobile_app @@ -4357,7 +4565,7 @@ def _update_device_attributes(self, devicename, kwargs: str = None, kwargs['dev_id'] = devicename kwargs['host_name'] = self.fname.get(devicename) - kwargs['location_name'] = ic3dev_state + kwargs['location_name'] = ic3dev_state_fname kwargs['source_type'] = 'gps' kwargs[ATTR_ATTRIBUTES] = attrs self.see(**kwargs) @@ -4446,7 +4654,7 @@ def _setup_base_kwargs(self, devicename, latitude, longitude, and zone_dist <= self.zone_radius_m.get(zone_name, 100) and (latitude != zone_lat or longitude != zone_long)): event_msg = (f"Moving to zone center > {zone_name}, " - f"GPS-{format_gps(latitude, longitude, zone_lat, zone_long, 0)}, " + f"GPS-{format_gps(latitude, longitude, gps_accuracy, zone_lat, zone_long)}, " f"Distance-{self._format_dist_m(zone_dist)}") self._save_event(devicename, event_msg) self._log_debug_msg(devicename, event_msg) @@ -4484,7 +4692,7 @@ def _format_fname_devicename(self, devicename): #-------------------------------------------------------------------- def _format_fname_zone(self, zone): - return (f"{self.zone_fname.get(zone, zone.title())}") + return (f"{self.zone_to_display.get(zone, zone.title())}") #-------------------------------------------------------------------- def _format_devicename_zone(self, devicename, zone = None): @@ -4498,6 +4706,9 @@ def _format_list(self, arg_list): formatted_list = formatted_list.replace("{", "").replace("}", "") formatted_list = formatted_list.replace("'", "").replace(",", "CRLF• ") return (f"CRLF• {formatted_list}") +#-------------------------------------------------------------------- + def _format_age(self, secs): + return (f"{self._secs_to_time_str(secs)} ago") #-------------------------------------------------------------------- def _trace_device_attributes(self, devicename, description, fct_name, attrs): @@ -4568,9 +4779,13 @@ def _send_message_to_device(self, devicename, service_data): "Trigger was not received"}} ''' try: - evlog_msg = (f"{EVLOG_NOTICE}Sending Message to Device > " - f"Message-{service_data.get('message')}") - self._save_event_halog_info(devicename, evlog_msg) + if self.notify_iosapp_entity.get(devicename, None) == None: + return + + if service_data.get('message') != "request_location_update": + evlog_msg = (f"{EVLOG_NOTICE}Sending Message to Device > " + f"Message-{service_data.get('message')}") + self._save_event_halog_info(devicename, evlog_msg) for notify_devicename in self.notify_iosapp_entity.get(devicename): entity_id = (f"mobile_app_{notify_devicename}") @@ -4607,6 +4822,12 @@ def _get_iosapp_device_tracker_state_attributes(self, devicename, ic3_state, ic3 iosapp_state = self._get_state(entity_id) iosapp_dev_attrs = self._get_device_attributes(entity_id) + #iosapp state is the friendly name of the zone, ic3 keeps the state + #by zone name, not the friendly name. Convert iosapp state back to zone + #entity name to match ic3 state_this_poll and state_last_poll, + #state-zone name mismatch errors and state change detected trigger + #when it really didn't + if ATTR_LATITUDE in iosapp_dev_attrs: self.iosapp_monitor_error_cnt[devicename] = 0 @@ -4678,7 +4899,7 @@ def _get_zone(self, devicename, latitude, longitude): ''' zone_selected_dist = HIGH_INTEGER zone_selected = None - log_msg = f"Select Zone > " + zones_msg = "" iosapp_zone_msg = "" zone_iosapp = self.state_this_poll.get(devicename) if self._is_inzone_zonename(self.state_this_poll.get(devicename)) else None @@ -4710,17 +4931,12 @@ def _get_zone(self, devicename, latitude, longitude): zone_selected = zone iosapp_zone_msg = " (Using iOSApp State)" - log_msg += (f"{self.zone_fname.get(zone)}-" - f"{self._format_dist(zone_dist)}/r" - f"{round(self.zone_radius_m.get(zone))}, ") - - event_msg = (f"{log_msg[:-2]}") - event_msg += (f" > Selected-{self.zone_fname.get(zone_selected, AWAY)}{iosapp_zone_msg}") - - self._save_event(devicename, event_msg) - log_msg = f"GET ZONE {event_msg}" - self._log_debug_msg(devicename, log_msg) - + if (instr(zone, STATIONARY)): + zones_msg += self.zone_to_display.get(STATIONARY) + else: + zones_msg += self.zone_to_display.get(zone) + zones_msg += (f"-{self._format_dist(zone_dist)}" + f"/r{round(self.zone_radius_m.get(zone))}, ") if zone_selected is None: zone_selected = NOT_HOME @@ -4737,6 +4953,21 @@ def _get_zone(self, devicename, latitude, longitude): elif instr(zone,'nearzone'): zone_selected = 'near_zone' + event_msg = (f"Selected Zone > ") + + if self.display_zone_fname_flag: + event_msg += ZONE_TO_DISPLAY_BASE.get(zone_selected, zone_selected) + else: + event_msg += self.zone_to_display.get(zone_selected, NOT_HOME) + + event_msg += (f"{iosapp_zone_msg} > {zones_msg[:-2]}") + + self._save_event(devicename, event_msg) + self._log_debug_msg(devicename, event_msg) + + #Get distance between zone selected and current zone to see if they overlap + if self._check_overlapping_zone(self.zone_current.get(devicename), zone_selected): + self.zone_current[devicename] = zone_selected #If the zone changed from a previous poll, save it and set the new one if (self.zone_current.get(devicename) != zone_selected): @@ -4759,6 +4990,25 @@ def _get_zone(self, devicename, latitude, longitude): return zone_selected +#-------------------------------------------------------------------- + def _check_overlapping_zone(self, zone1, zone2): + ''' + zone1 and zone2 overlap if their distance between centers is less than 2m + ''' + try: + if zone1 == zone2: + return True + + if zone1 == "": zone1 = HOME + zone_dist = self._calc_distance_m( + self.zone_lat.get(zone1), self.zone_long.get(zone1), + self.zone_lat.get(zone2), self.zone_long.get(zone2)) + + return (zone_dist <= 2) + + except: + return False + #-------------------------------------------------------------------- @staticmethod def _get_zone_names(zone_name): @@ -4774,9 +5024,9 @@ def _get_zone_names(zone_name): office__bldg_1 --> Office Bldg1 """ if zone_name: - if STATIONARY in zone_name: + if instr(zone_name, STATIONARY): name1 = STATIONARY - elif NOT_HOME in zone_name: + elif instr(zone_name, NOT_HOME): name1 = AWAY else: name1 = zone_name.title() @@ -4784,8 +5034,8 @@ def _get_zone_names(zone_name): if zone_name == ATTR_ZONE: badge_state = name1 - name2 = zone_name.title().replace('_', ' ', 99) - name3 = zone_name.title().replace('_', '', 99) + name2 = zone_name.title().replace('_', ' ') + name3 = zone_name.title().replace('_', '') else: name1 = NOT_SET name2 = 'Not Set' @@ -4808,7 +5058,7 @@ def _set_base_zone_name_lat_long_radius(self, zone): Set the base_zone's name, lat, long & radius ''' self.base_zone = zone - self.base_zone_name = self.zone_fname.get(zone) + self.base_zone_name = self.zone_to_display.get(zone) self.base_zone_lat = self.zone_lat.get(zone) self.base_zone_long = self.zone_long.get(zone) self.base_zone_radius_km = float(self.zone_radius_km.get(zone)) @@ -4925,9 +5175,9 @@ def _update_stationary_zone(self, devicename, latitude, longitude, enter_exit_fl if latitude is None or longitude is None: return + stat_zone_dist = self._calc_distance_m(latitude, longitude, self.zone_lat.get(stat_zone_name), self.zone_long.get(stat_zone_name)) - stat_home_dist = self._calc_distance_m(latitude, longitude, self.zone_home_lat, self.zone_home_long,) @@ -4973,6 +5223,11 @@ def _update_stationary_zone(self, devicename, latitude, longitude, enter_exit_fl f"DistFromHome-{self._format_dist_m(stat_home_dist)}") self._save_event(devicename, event_msg) + else: + #self.zone_by_fname[stat_zone_name] = stat_zone_name + self.zone_to_display[stat_zone_name] = 'Stationary' + self.state_to_zone[stat_zone_name] = stat_zone_name + return True #Set Stationary Zone at new location @@ -5081,25 +5336,23 @@ def _update_device_sensors(self, arg_devicename, attrs:dict): sensor_attrs = {} if attr_name in SENSOR_ATTR_FORMAT: format_type = SENSOR_ATTR_FORMAT.get(attr_name) - if format_type == "dist": - sensor_attrs['unit_of_measurement'] = \ - self.unit_of_measurement + if format_type == 'dist': + sensor_attrs[CONF_UNIT_OF_MEASUREMENT] = self.unit_of_measurement - elif format_type == "diststr": + elif format_type == 'diststr': try: x = (state_value / 2) - sensor_attrs['unit_of_measurement'] = \ - self.unit_of_measurement + sensor_attrs[CONF_UNIT_OF_MEASUREMENT] = self.unit_of_measurement except: - sensor_attrs['unit_of_measurement'] = '' - elif format_type == "%": - sensor_attrs['unit_of_measurment'] = '%' + sensor_attrs[CONF_UNIT_OF_MEASUREMENT] = '' + elif format_type == '%': + sensor_attrs[CONF_UNIT_OF_MEASUREMENT] = '%' elif format_type == 'title': state_value = state_value.title().replace('_', ' ') elif format_type == 'kph-mph': - sensor_attrs['unit_of_measurement'] = self.um_kph_mph + sensor_attrs[CONF_UNIT_OF_MEASUREMENT] = self.um_kph_mph elif format_type == 'm-ft': - sensor_attrs['unit_of_measurement'] = self.um_m_ft + sensor_attrs[CONF_UNIT_OF_MEASUREMENT] = self.um_m_ft if attr_name in SENSOR_ATTR_ICON: sensor_attrs['icon'] = SENSOR_ATTR_ICON.get(attr_name) @@ -5170,7 +5423,7 @@ def _update_device_sensors_hass(self, devicename, base_entity, attr_name, #-------------------------------------------------------------------- def _format_info_attr(self, devicename, battery, gps_accuracy, dist_last_poll_moved_km, - zone, old_loc_poor_gps_flag, location_time_secs): #location_time): + zone): """ Initialize info attribute @@ -5189,7 +5442,7 @@ def _format_info_attr(self, devicename, battery, and self.tracking_method_config != IOSAPP): info_msg += (f" • Track.Method: {self.trk_method_short_name}") - if self.overrideinterval_seconds.get(devicename) > 0: + if self.override_interval_seconds.get(devicename) > 0: info_msg += " • Overriding.Interval" if zone == 'near_zone': @@ -5200,20 +5453,20 @@ def _format_info_attr(self, devicename, battery, if gps_accuracy > self.gps_accuracy_threshold: info_msg += (f" • Poor.GPS.Accuracy, Dist-{self._format_dist(gps_accuracy)}") - if self.old_loc_poor_gps_cnt.get(devicename) > 0: - info_msg += (f" (#{self.old_loc_poor_gps_cnt.get(devicename)})") + if self.poor_location_gps_cnt.get(devicename) > 0: + info_msg += (f" (#{self.poor_location_gps_cnt.get(devicename)})") if (self._is_inzone_zonename(zone) and self.ignore_gps_accuracy_inzone_flag): info_msg += " - Discarded" - isold_cnt = self.old_loc_poor_gps_cnt.get(devicename) - - if isold_cnt > 0: - age = self._secs_since(self.last_located_secs.get(devicename)) - info_msg += (f" • Old.Location, Age-{self._secs_to_time_str(age)} (#{isold_cnt})") + old_cnt = self.poor_location_gps_cnt.get(devicename) + if old_cnt > 16: + location_age = self._secs_since(self.last_located_secs.get(devicename)) + info_msg += (f" • May Be Offline, Located-{self._format_age(location_age)}, " + f"OldCnt-#{old_cnt}") if self.stat_zone_timer.get(devicename) > 0: - info_msg += (f" • Into.Stationary.Zone@{self._secs_to_time(self.stat_zone_timer.get(devicename))}") + info_msg += (f" • MoveIntoStatZone@{self._secs_to_time(self.stat_zone_timer.get(devicename))}") if self.waze_data_copied_from.get(devicename) is not None: copied_from = self.waze_data_copied_from.get(devicename) @@ -5305,7 +5558,7 @@ def _display_usage_counts(self, devicename, force_display=False): self.count_update_ignore.get(devicename) + \ self.count_state_changed.get(devicename) + \ self.count_trigger_changed.get(devicename) + \ - self.iosapp_locate_request_cnt.get(devicename) + self.iosapp_request_loc_cnt.get(devicename) pyi_avg_time_per_call = self.time_pyicloud_calls / \ @@ -5341,7 +5594,7 @@ def _display_usage_counts(self, devicename, force_display=False): f"«LT-iOS App Updates¦LC-{self.count_update_iosapp.get(devicename)}¦RT-Time/Locate (secs)¦RC-{round(pyi_avg_time_per_call, 2)}»") else: count_msg += (f"«HS¦LH-Device Counts¦RH-iOS App Counts»HE" - f"«LT-State/Triggers Chgs¦LC-{state_trig_count}¦RT-iOS Locate Rqsts¦RC-{self.iosapp_locate_request_cnt.get(devicename)}»" + f"«LT-State/Triggers Chgs¦LC-{state_trig_count}¦RT-iOS Locate Requests¦RC-{self.iosapp_request_loc_cnt.get(devicename)}»" f"«LT-iCloud Updates¦LC-{self.count_update_icloud.get(devicename)}¦RT-iOS App Updates¦RC-{self.count_update_iosapp.get(devicename)}»") count_msg += (f"«LT-Discarded¦LC-{self.count_update_ignore.get(devicename)}¦RT-Waze Routes¦RC-{self.count_waze_locates.get(devicename)}»" @@ -5461,6 +5714,7 @@ def _define_device_fields(self): self.api_device_devicename = {} #icloud.api.device obj for each devicename self.data_source = {} self.device_type = {} + self.device_status = {} self.devicename_iosapp_entity = {} self.devicename_iosapp_suffix = {} self.notify_iosapp_entity = {} @@ -5485,29 +5739,73 @@ def _define_device_status_fields(self): self.zone_timestamp = {} self.state_change_flag = {} self.location_data = {} - self.overrideinterval_seconds = {} self.last_located_time = {} self.last_located_secs = {} #device timestamp in seconds self.went_3km = {} #>3 km/mi, probably driving +#-------------------------------------------------------------------- + def _initialize_device_status_fields(self, devicename): + ''' + Make domain name entity ids for the device_tracker and + sensors for each device so we don't have to do it all the + time. Then check to see if 'sensor.geocode_location' + exists. If it does, then using iosapp version 2. + ''' + entity_id = (f"device_tracker.{devicename}") + self.device_tracker_entity_ic3[devicename] = entity_id + self.state_this_poll[devicename] = self._get_state(entity_id) + self.state_last_poll[devicename] = NOT_SET + self.device_status[devicename] = UNKNOWN + self.zone_last[devicename] = '' + self.zone_current[devicename] = '' + self.zone_timestamp[devicename] = '' + self.state_change_flag[devicename] = False + self.location_data[devicename] = INITIAL_LOCATION_DATA + self.last_located_time[devicename] = HHMMSS_ZERO + self.last_located_secs[devicename] = 0 + self.went_3km[devicename] = False + + self.trigger[devicename] = 'iCloud3' + self.got_exit_trigger_flag[devicename] = False + self.seen_this_device_flag[devicename] = False + self.device_being_updated_flag[devicename] = False + self.device_being_updated_retry_cnt[devicename] = 0 + + self.iosapp_update_flag[devicename] = False + + #iosapp v2 entity info + self.last_iosapp_state[devicename] = '' + self.last_iosapp_state_changed_time[devicename] = '' + self.last_iosapp_state_changed_secs[devicename] = 0 + self.last_iosapp_trigger[devicename] = '' + self.last_iosapp_trigger_changed_time[devicename] = '' + self.last_iosapp_trigger_changed_secs[devicename] = 0 + self.iosapp_monitor_error_cnt[devicename] = 0 + + #-------------------------------------------------------------------- def _initialize_um_formats(self, unit_of_measurement): #Define variables, lists & tables if unit_of_measurement == 'mi': - self.um_time_strfmt = '%I:%M:%S' - self.um_time_strfmt_ampm = '%I:%M:%S%P' - self.um_date_time_strfmt = '%Y-%m-%d %I:%M:%S' + if self.time_format != 24: self.time_format = 12 self.um_km_mi_factor = 0.62137 self.um_m_ft = 'ft' self.um_kph_mph = 'mph' else: - self.um_time_strfmt = '%H:%M:%S' - self.um_time_strfmt_ampm = '%H:%M:%S' - self.um_date_time_strfmt = '%Y-%m-%d %H:%M:%S' + if self.time_format != 12: self.time_format = 24 self.um_km_mi_factor = 1 self.um_m_ft = 'm' self.um_kph_mph = 'kph' + if self.time_format == 12: + self.um_time_strfmt = '%I:%M:%S' + self.um_time_strfmt_ampm = '%I:%M:%S%P' + self.um_date_time_strfmt = '%Y-%m-%d %I:%M:%S' + else: + self.um_time_strfmt = '%H:%M:%S' + self.um_time_strfmt_ampm = '%H:%M:%S' + self.um_date_time_strfmt = '%Y-%m-%d %H:%M:%S' + #-------------------------------------------------------------------- def _setup_tracking_method(self, tracking_method): ''' @@ -5555,76 +5853,38 @@ def _setup_iosapp_tracking_method(self): self.TRK_METHOD_FAMSHR = False self.TRK_METHOD_FMF_FAMSHR = False -#-------------------------------------------------------------------- - def _initialize_device_status_fields(self, devicename): - ''' - Make domain name entity ids for the device_tracker and - sensors for each device so we don't have to do it all the - time. Then check to see if 'sensor.geocode_location' - exists. If it does, then using iosapp version 2. - ''' - entity_id = self.device_tracker_entity_ic3.get(devicename) - self.state_this_poll[devicename] = self._get_state(entity_id) - self.state_last_poll[devicename] = NOT_SET - self.zone_last[devicename] = '' - self.zone_current[devicename] = '' - self.zone_timestamp[devicename] = '' - self.state_change_flag[devicename] = False - self.trigger[devicename] = 'iCloud3' - self.got_exit_trigger_flag[devicename] = False - self.last_located_time[devicename] = HHMMSS_ZERO - self.last_located_secs[devicename] = 0 - self.location_data[devicename] = INITIAL_LOCATION_DATA - self.went_3km[devicename] = False - self.seen_this_device_flag[devicename] = False - self.device_being_updated_flag[devicename] = False - self.device_being_updated_retry_cnt[devicename] = 0 - - self.iosapp_update_flag[devicename] = False - - #iosapp v2 entity info - self.last_iosapp_state[devicename] = '' - self.last_iosapp_state_changed_time[devicename] = '' - self.last_iosapp_state_changed_secs[devicename] = 0 - self.last_iosapp_trigger[devicename] = '' - self.last_iosapp_trigger_changed_time[devicename] = '' - self.last_iosapp_trigger_changed_secs[devicename] = 0 - self.iosapp_monitor_error_cnt[devicename] = 0 - - #-------------------------------------------------------------------- def _define_device_tracking_fields(self): #times, flags - self.update_timer = {} - self.overrideinterval_seconds = {} + self.update_timer = {} + self.override_interval_seconds = {} self.dist_from_zone_km_small_move_total = {} - self.this_update_secs = 0 + self.this_update_secs = 0 #location, gps - self.old_loc_poor_gps_cnt = {} # override interval while < 4 - self.old_loc_poor_gps_msg = {} + self.poor_location_gps_cnt = {} # override interval while < 4 + self.poor_location_gps_msg = {} self.old_location_secs = {} - - self.poor_gps_accuracy_flag = {} + self.poor_gps_flag = {} + self.last_lat = {} self.last_long = {} self.last_battery = {} #used to detect iosapp v2 change - self.last_lat = {} self.last_gps_accuracy = {} #used to detect iosapp v2 change #-------------------------------------------------------------------- def _initialize_device_tracking_fields(self, devicename): #times, flags - self.update_timer[devicename] = time.time() - self.overrideinterval_seconds[devicename] = 0 + self.update_timer[devicename] = time.time() + self.override_interval_seconds[devicename] = 0 self.dist_from_zone_km_small_move_total[devicename] = 0 #location, gps - self.old_loc_poor_gps_cnt[devicename] = 0 - self.old_loc_poor_gps_msg[devicename] = False + self.poor_location_gps_cnt[devicename] = 0 + self.poor_location_gps_msg[devicename] = False self.old_location_secs[devicename] = 90 #Timer (secs) before a location is old + self.poor_gps_flag[devicename] = False self.last_lat[devicename] = self.zone_home_lat self.last_long[devicename] = self.zone_home_long - self.poor_gps_accuracy_flag[devicename] = False self.last_battery[devicename] = 0 self.last_gps_accuracy[devicename] = 0 self.stat_zone_timer[devicename] = 0 @@ -5664,13 +5924,17 @@ def _initialize_usage_counters(self, devicename, clear_counters=True): self.time_waze_calls[devicename] = 0.0 #-------------------------------------------------------------------- def _define_iosapp_message_fields(self): - self.iosapp_locate_update_secs = {} - self.iosapp_locate_request_cnt = {} + self.iosapp_request_loc_sent_flag = {} + self.iosapp_request_loc_sent_secs = {} + self.iosapp_request_loc_cnt = {} + self.iosapp_request_loc_retry_cnt = {} #-------------------------------------------------------------------- def _initialize_iosapp_message_fields(self, devicename): - self.iosapp_locate_update_secs[devicename] = 0 - self.iosapp_locate_request_cnt[devicename] = 0 + self.iosapp_request_loc_sent_flag[devicename] = False + self.iosapp_request_loc_sent_secs[devicename] = 0 + self.iosapp_request_loc_cnt[devicename] = 0 + self.iosapp_request_loc_retry_cnt[devicename] = 0 #-------------------------------------------------------------------- def _initialize_next_update_time(self, devicename): @@ -5691,6 +5955,20 @@ def _define_sensor_fields(self, initial_load_flag): self.sensor_prefix_name = {} self.sensor_attr_fname_prefix = {} +#-------------------------------------------------------------------- + def _define_zone_fields(self): + ''' + Dictionary fields for zone friendly names + ''' + self.zones = [] + self.zone_to_display= dict(ZONE_TO_DISPLAY_BASE) + self.state_to_zone = dict(STATE_TO_ZONE_BASE) + self.zone_lat = {} + self.zone_long = {} + self.zone_radius_km = {} + self.zone_radius_m = {} + self.zone_passive = {} + #-------------------------------------------------------------------- def _define_device_zone_fields(self): ''' @@ -5759,7 +6037,7 @@ def _initialize_device_zone_fields(self, devicename): self.stat_zone_devicename_icon[icon_name] = devicename stat_zone_name = self._format_zone_name(devicename, STATIONARY) - self.zone_fname[stat_zone_name] = "Stationary" + self.zone_to_display[stat_zone_name] = "Stationary" except Exception as err: _LOGGER.exception(err) @@ -5823,11 +6101,11 @@ def _initialize_attrs(self, devicename): attrs[ATTR_TIMESTAMP] = dt_util.utcnow().isoformat()[0:19] attrs[ATTR_AUTHENTICATED] = '' attrs[ATTR_BATTERY] = 0 - attrs[ATTR_BATTERY_STATUS] = '' + attrs[ATTR_BATTERY_STATUS] = UNKNOWN attrs[ATTR_INFO] = "● HA is initializing dev_trk attributes ●" attrs[ATTR_ALTITUDE] = 0 attrs[ATTR_VERT_ACCURACY] = 0 - attrs[ATTR_DEVICE_STATUS] = '' + attrs[ATTR_DEVICE_STATUS] = UNKNOWN attrs[ATTR_LOW_POWER_MODE] = '' attrs[CONF_GROUP] = self.group attrs[ATTR_PICTURE] = self.badge_picture.get(devicename) @@ -5841,13 +6119,6 @@ def _initialize_zone_tables(self): ''' Get friendly name of all zones to set the device_tracker state ''' - self.zones = [] - self.zone_fname = {"not_home": "Away", "near_zone": "NearZone"} - self.zone_lat = {} - self.zone_long = {} - self.zone_radius_km = {} - self.zone_radius_m = {} - self.zone_passive = {} try: if self.start_icloud3_initial_load_flag == False: @@ -5870,7 +6141,7 @@ def _initialize_zone_tables(self): self._log_debug_msg("*",f"zone-{zone_name}, data-{zone_data}") if instr(zone_name.lower(), STATIONARY): - self.zone_fname[zone_name] = 'Stationary' + self.zone_to_display[zone_name] = 'Stationary' if ATTR_LATITUDE in zone_data: self.zone_lat[zone_name] = zone_data.get(ATTR_LATITUDE, 0) @@ -5878,7 +6149,16 @@ def _initialize_zone_tables(self): self.zone_passive[zone_name] = zone_data.get('passive', True) self.zone_radius_m[zone_name] = int(zone_data.get(ATTR_RADIUS, 100)) self.zone_radius_km[zone_name] = round(self.zone_radius_m[zone_name]/1000, 4) - self.zone_fname[zone_name] = zone_data.get(ATTR_FRIENDLY_NAME, zone_name.title()) + + zone_fname = zone_data.get(ATTR_FRIENDLY_NAME, zone_name) + if self.display_zone_fname_flag: + self.zone_to_display[zone_name] = zone_fname + self.state_to_zone[zone_fname] = zone_name + else: + zone_name_title = zone_name.replace('_',' ').title().replace(' ', '') + self.zone_to_display[zone_name] = zone_name_title + self.state_to_zone[zone_fname] = zone_name + self.state_to_zone[zone_name_title] = zone_name else: log_msg = (f"Error loading zone {zone_name} > No data was returned from HA. " @@ -5892,7 +6172,7 @@ def _initialize_zone_tables(self): except Exception as err: _LOGGER.exception(err) - zone_msg = (f"{zone_msg}{zone_name}/{self.zone_fname.get(zone_name)} " + zone_msg = (f"{zone_msg}{zone_name}/{self.zone_to_display.get(zone_name)} " f"(r{self.zone_radius_m[zone_name]}m), ") log_msg = (f"Set up Zones > {zone_msg[:-2]}") @@ -5904,7 +6184,7 @@ def _initialize_zone_tables(self): self.zone_home_radius_m = self.zone_radius_m.get(HOME) self.base_zone = HOME - self.base_zone_name = self.zone_fname.get(HOME) + self.base_zone_name = self.zone_to_display.get(HOME) self.base_zone_lat = self.zone_lat.get(HOME) self.base_zone_long = self.zone_long.get(HOME) self.base_zone_radius_km = float(self.zone_radius_km.get(HOME)) @@ -6396,7 +6676,6 @@ def _verify_tracked_devices_for_famshr(self): self.api_device_devicename[device_content_name] = devicename self.api_device_devicename[devicename] = device_content_name - devicename_list_tracked += (f"CRLF• {devicename} ({device_content_name}/{device_type})") else: @@ -6485,7 +6764,7 @@ def _setup_tracked_devices_config_parm(self, config_parameter): self.device_type[devicename] = di[DI_DEVICE_TYPE] self.fname[devicename] = di[DI_NAME] self.badge_picture[devicename] = di[DI_BADGE_PICTURE] - self.devicename_iosapp_entity[devicename] = di[DI_IOSAPP_ENTITY] + self.devicename_iosapp_entity[devicename] = di[DI_IOSAPP_ENTITY] self.devicename_iosapp_suffix[devicename] = di[DI_IOSAPP_SUFFIX] self.track_from_zone[devicename] = di[DI_ZONES] @@ -6579,7 +6858,7 @@ def _decode_track_device_config_parms(self, track_device_line): items = parameters.split(',') for itemx in items: - item_entered = itemx.strip().replace(' ', '_', 99) + item_entered = itemx.strip().replace(' ', '_') item = item_entered.lower() if item == '': @@ -6588,7 +6867,7 @@ def _decode_track_device_config_parms(self, track_device_line): email = item elif instr(item, 'png') or instr(item, 'jpg'): badge_picture = item - elif item == 'iosappv1': + elif item in ['iosappv1', 'noiosapp']: iosapp_monitor_flag = False elif item.startswith("_"): dev_trk_iosapp_suffix = item @@ -6639,7 +6918,7 @@ def _setup_monitored_iosapp_entities(self, devicename, iosapp_entities, notify_d #and the sensor.xxxx_last_update_trigger entity for that device. - self.device_tracker_entity_ic3[devicename] = (f"device_tracker.{devicename}") + #self.device_tracker_entity_ic3[devicename] = (f"device_tracker.{devicename}") if (self.iosapp_monitor_dev_trk_flag.get(devicename) == False): self.device_tracker_entity_iosapp[devicename] = (f"device_tracker.{devicename}") self.notify_iosapp_entity[devicename] = (f"ios_{devicename}") @@ -6863,8 +7142,10 @@ def _setup_sensor_base_attrs(self, devicename): attr_prefix_fname = attr_prefix_fname.replace('Ip','-iP') attr_prefix_fname = attr_prefix_fname.replace('Iw','-iW') attr_prefix_fname = attr_prefix_fname.replace('--','-') + attr_prefix_fname = attr_prefix_fname.replace('-De-', '-de-') + if attr_prefix_fname.startswith('-'): attr_prefix_fname = attr_prefix_fname[1:] - self.sensor_attr_fname_prefix[devicename] = (f"{attr_prefix_fname}-") + self.sensor_attr_fname_prefix[devicename] = (f"{attr_prefix_fname} ") badge_attrs = {} badge_attrs['entity_picture'] = self.badge_picture.get(devicename) @@ -6923,7 +7204,15 @@ def _setup_sensors_custom_list(self, initial_load_flag): # DEVICE STATUS SUPPORT FUNCTIONS FOR GPS ACCURACY, OLD LOC DATA, ETC # ######################################################### - def _check_old_loc_poor_gps(self, devicename, timestamp_secs, gps_accuracy): + def _check_poor_location_gps_only(self, devicename, timestamp_secs, gps_accuracy): + poor_location_gps_flag, discard_reason = self._check_poor_location_gps( + devicename, + last_located_secs, + gps_accuracy, + checkOnly=True) + return poor_location_gps_flag + + def _check_poor_location_gps(self, devicename, timestamp_secs, gps_accuracy, checkOnly=False): """ If this is checked in the icloud location cycle, check if the location isold flag. Then check to see if @@ -6934,48 +7223,58 @@ def _check_old_loc_poor_gps(self, devicename, timestamp_secs, gps_accuracy): already updated the lat/long so you don't want to discard the record just because it is old. If in a zone, use the trigger but check the distance from the - zone when updating the device. If the distance from the zone = 0, - then reset the lat/long to the center of the zone. + zone when updating the device. + + Update the old_location_poor_gps_cnt if just_check=False """ try: - discard_reason = "" - age = int(self._secs_since(timestamp_secs)) - age_str = self._secs_to_time_str(age) - location_isold_flag = (age > self.old_location_secs.get(devicename)) - poor_gps_flag = (gps_accuracy > self.gps_accuracy_threshold) - - if location_isold_flag and poor_gps_flag: - discard_reason = (f"Old.Location-{age_str}, GPSAccuracy-{gps_accuracy}m") - elif location_isold_flag: - discard_reason = (f"Old.Location-{age_str}") + discard_reason = "" + location_age = int(self._secs_since(timestamp_secs)) + location_age_str = (f"{self._format_age(location_age)}") + old_location_flag = (location_age > self.old_location_secs.get(devicename)) + poor_gps_flag = (gps_accuracy > self.gps_accuracy_threshold) + + if self._is_inzone(devicename): + old_location_flag = False + poor_gps_flag = (poor_gps_flag \ + and self.ignore_gps_accuracy_inzone_flag == False) + + if old_location_flag and poor_gps_flag: + discard_reason = (f"OldLocation-{location_age_str}, GPSAccuracy-{gps_accuracy}m") + elif old_location_flag: + discard_reason = (f"OldLocation-{location_age_str}") elif poor_gps_flag: discard_reason = (f"GPSAccuracy-{gps_accuracy}m") - if location_isold_flag or poor_gps_flag: - self.old_loc_poor_gps_cnt[devicename] += 1 - discard_reason += (f" (#{self.old_loc_poor_gps_cnt.get(devicename)})") - else: - self.old_loc_poor_gps_cnt[devicename] = 0 + #Do not update status and count if just checking poor loc gps status + if checkOnly: + return (old_location_flag or poor_gps_flag) - self.poor_gps_accuracy_flag[devicename] = (location_isold_flag or poor_gps_flag) + if old_location_flag or poor_gps_flag: + self.poor_gps_flag[devicename] = True + self.poor_location_gps_cnt[devicename] += 1 + discard_reason += (f" (#{self.poor_location_gps_cnt.get(devicename)})") + else: + self.poor_gps_flag[devicename] = False + self.poor_location_gps_cnt[devicename] = 0 log_msg = (f"CHECK ISOLD/GPS ACCURACY, Time-{self._secs_to_time(timestamp_secs)}, " - f"isOldFlag-{location_isold_flag}, Age-{age_str}, " + f"isOldFlag-{old_location_flag}, Age-{location_age_str}, " f"GPSAccuracy-{gps_accuracy}m, GPSAccuracyFlag-{poor_gps_flag}", - f"Results-{self.poor_gps_accuracy_flag.get(devicename)}, " + f"Results-{self.poor_gps_flag.get(devicename)}, " f"DiscardReason-{discard_reason}") self._log_debug_msg(devicename, log_msg) except Exception as err: _LOGGER.exception(err) - self.poor_gps_accuracy_flag[devicename] = False - self.old_loc_poor_gps_cnt[devicename] = 0 + self.poor_gps_flag[devicename] = False + self.poor_location_gps_cnt[devicename] = 0 log_msg = ("►INTERNAL ERROR (ChkOldLocPoorGPS)") self._log_error_msg(log_msg) - return (self.poor_gps_accuracy_flag.get(devicename), discard_reason) + return (self.poor_gps_flag.get(devicename), discard_reason) #-------------------------------------------------------------------- def _check_next_update_time_reached(self, devicename=None): @@ -6983,6 +7282,9 @@ def _check_next_update_time_reached(self, devicename=None): Cycle through the next_update_secs for all devices and determine if one of them is earlier than the current time. If so, the devices need to be updated. + + Return None - Next update time has not been reached on any devicename:zone + devicename - devicename of phone that needs to be updated ''' try: if self.next_update_secs is None: @@ -7004,6 +7306,32 @@ def _check_next_update_time_reached(self, devicename=None): return None +#-------------------------------------------------------------------- + def _check_next_update_time_reached_devicename(self, devicename): + ''' + Cycle through the next_update_secs for this devicename and + determine if one of them is earlier than the current time. + If so, the devicename need to be updated. + + Return True - Reached next_update_secs, update devicename + False - Do not update devicename + ''' + try: + if self.next_update_secs is None: + return False + + for devicename_zone in self.next_update_secs: + if devicename_zone.startswith(devicename): + time_till_update = self.next_update_secs.get(devicename_zone) - \ + self.this_update_secs + + if time_till_update <= 5: + return True + + except Exception as err: + _LOGGER.exception(err) + + return False #-------------------------------------------------------------------- def _check_in_zone_and_before_next_update(self, devicename): ''' @@ -7024,12 +7352,16 @@ def _check_in_zone_and_before_next_update(self, devicename): return False #-------------------------------------------------------------------- - def _check_outside_zone_no_exit(self,devicename, zone, latitude, longitude): + def _check_outside_zone_no_exit(self,devicename, zone, trigger, latitude, longitude): ''' If the device is outside of the zone and less than the zone radius + gps_acuracy_threshold and no Geographic Zone Exit trigger was received, it has probably wandered due to GPS errors. If so, discard the poll and try again later ''' + trigger = self.trigger.get(devicename) if trigger == '' else trigger + if trigger in IOS_TRIGGERS_ENTER: + return False, '' + dist_from_zone_m = self._zone_distance_m( devicename, zone, @@ -7046,7 +7378,7 @@ def _check_outside_zone_no_exit(self,devicename, zone, latitude, longitude): and self.got_exit_trigger_flag.get(devicename) == False and instr(zone, STATIONARY) == False): if dist_from_zone_m < zone_radius_accuracy_m: - self.poor_gps_accuracy_flag[devicename] = True + self.poor_gps_flag[devicename] = True msg = ("Outside Zone and No Exit Zone trigger, " f"Keeping in zone > Zone-{zone}, " @@ -7061,30 +7393,59 @@ def _check_outside_zone_no_exit(self,devicename, zone, latitude, longitude): return False, '' #-------------------------------------------------------------------- - #@staticmethod - def _get_interval_for_error_retry_cnt(self, retry_cnt): - cycle, cycle_cnt = divmod(retry_cnt, 4) + def _get_interval_for_error_retry_cnt(self, devicename, counter=POOR_LOC_GPS_CNT): + ''' + Get the interval time based on the retry_cnt. + retry_cnt = -1 - Use the poor_location_gps count for devicename + = iosapp_request_loc_sent_retry_cnt + = retry pyicloud authorization count - if cycle == 0: - interval = 15 - elif cycle == 1: - interval = 60 #1 min - elif cycle == 2: - interval = 300 #5 min - elif cycle == 3: - interval = 900 #15 min + Interval range table - key = retry_cnt, value = time in minutes + - poor_location_gps cnt, icloud_authentication cnt (default): + interval_range_1 = {0:.25, 4:1, 8:5, 12:30, 16:60, 20:120, 24:240} + - request iosapp location retry cnt: + interval_range_2 = {0:.5, 4:2, 8:30, 12:60, 16:120} + + ''' + if counter == POOR_LOC_GPS_CNT: + retry_cnt = self.poor_location_gps_cnt.get(devicename, 0) + range_tbl = RETRY_INTERVAL_RANGE_1 + + elif counter == AUTH_ERROR_CNT: + retry_cnt = self.icloud_acct_auth_error_cnt + range_tbl = RETRY_INTERVAL_RANGE_1 + + elif counter == IOSAPP_REQUEST_LOC_CNT: + retry_cnt = self.iosapp_request_loc_retry_cnt.get(devicename) + range_tbl = RETRY_INTERVAL_RANGE_2 else: - interval = 1800 #30 min + return 60 + + interval = .25 + for k, v in range_tbl.items(): + if k <= retry_cnt: + interval = v + + interval = interval * 60 return interval #-------------------------------------------------------------------- def _display_time_till_update_info_msg(self, devicename_zone, age_secs): - info_msg = (f"● {self._secs_to_minsec_str(age_secs)} ●") + ''' + Display the secs until the next update in the next update time field. + if between 90s to -90s. if between -90s and -120s, resisplay time + without the age to make sure it goes away. + ''' - attrs = {} - attrs[ATTR_NEXT_UPDATE_TIME] = info_msg + info_msg=(f"{self.next_update_time.get(devicename_zone)} ") + if age_secs <= 90 and age_secs >= -90: + info_msg += (f"({age_secs}s)") - self._update_device_sensors(devicename_zone, attrs) + if age_secs <= 90 and age_secs > -120: + attrs = {} + attrs[ATTR_NEXT_UPDATE_TIME] = info_msg + + self._update_device_sensors(devicename_zone, attrs) #-------------------------------------------------------------------- def _log_device_status_attrubutes(self, status): @@ -7180,6 +7541,8 @@ def _setup_event_log_base_attrs(self, initial_load_flag): if len(self.tracked_devices) > 0: self.log_table_max_items = EVLOG_RECDS_PER_DEVICE * len(self.tracked_devices) + if len(self.track_from_zone.get(devicename)) > 1: + self.log_table_max_items = .25 * self.log_table_max_items * len(self.track_from_zone.get(devicename)) base_attrs["names"] = name_attrs @@ -7228,18 +7591,24 @@ def _save_event(self, devicename, event_text): else: iosapp_state= self.last_iosapp_state.get(devicename, '') - zone_names = self._get_zone_names(self.zone_current.get(devicename, '')) - zone = zone_names[1][:12] if zone_names != '' else '' + zone = self.zone_current.get(devicename, AWAY) interval = self.interval_str.get(devicename_zone, '').split("(")[0] travel_time = self.last_tavel_time.get(devicename_zone, '') distance = self.last_distance_str.get(devicename_zone, '') if (instr(type(event_text), 'dict') or instr(type(event_text), 'list')): event_text = str(event_text) - if instr(iosapp_state, STATIONARY): iosapp_state = STATIONARY + if instr(zone, STATIONARY): zone = STATIONARY - if len(event_text) == 0: event_text = 'Info Message' + if instr(iosapp_state, STATIONARY): iosapp_state = STATIONARY + iosapp_state = self.zone_to_display.get(iosapp_state, iosapp_state.title()) + zone = self.zone_to_display.get(zone, zone.title()) + + if iosapp_state == None: iosapp_state = '' + if zone == None: zone = '' + + if len(event_text) == 0: event_text = 'Info Message' if event_text.startswith('__'): event_text = event_text[2:] event_text = event_text.replace('"', '`') event_text = event_text.replace("'", "`") @@ -7260,7 +7629,7 @@ def _save_event(self, devicename, event_text): if event_text.startswith('*'): color_symbol = '*' if event_text.startswith('**'): color_symbol = '**' if event_text.startswith('***'): color_symbol = '***' - if instr(event_text, 'Error'): color_symbol = '!' + if instr(event_text, ERROR): color_symbol = '!' char_per_line = 2000 #Break the event_text string into chunks of 250 characters each and @@ -7520,7 +7889,7 @@ def _get_waze_data(self, devicename, self._log_error_msg(log_msg) waze_time_msg = self._format_waze_time_msg(waze_time_from_zone) - event_msg = (f"Waze Route Info: {self.zone_fname.get(self.base_zone)} > " + event_msg = (f"Waze Route Info: {self.zone_to_display.get(self.base_zone)} > " f"Dist-{waze_dist_from_zone_km}km, " f"TravTime-{waze_time_msg}, " f"DistMovedSinceLastUpdate-{waze_dist_last_poll}km") @@ -7530,7 +7899,8 @@ def _get_waze_data(self, devicename, f"Status-{waze_status}, DistFromHome-{waze_dist_from_zone_km}, " f"TimeFromHome-{waze_time_from_zone}, " f"DistLastPoll-{waze_dist_last_poll}, " - f"WazeFromHome-{waze_from_zone}, WazeFromLastPoll-{waze_from_last_poll}") + f"WazeFromHome-{waze_from_zone}, " + f"WazeFromLastPoll-{waze_from_last_poll}") self._log_debug_interval_msg(devicename, log_msg) return (waze_status, waze_dist_from_zone_km, waze_time_from_zone, @@ -7673,7 +8043,7 @@ def _get_waze_from_data_history(self, devicename, #Return the waze history data for the other closest device if used_data_from_devicename_zone is not None: used_devicename = used_data_from_devicename_zone.split(':')[0] - event_msg = (f"Waze Route History Used: {self.zone_fname.get(self.base_zone)} > " + event_msg = (f"Waze Route History Used: {self.zone_to_display.get(self.base_zone)} > " f"Dist-{other_closest_device_data[1]}km, " f"TravTime-{round(other_closest_device_data[2], 0)} min, " f"UsedInfoFrom-{self._format_fname_devicename(used_devicename)}, " @@ -7799,11 +8169,16 @@ def _check_config_ic3_yaml_parameter_file(self): and os.path.exists(self.config_ic3_file_name)): config_filename = self.config_ic3_file_name + elif os.path.exists(f"/config/{self.config_ic3_file_name}"): + config_filename = (f"/config/{self.config_ic3_file_name}") + elif os.path.exists(f"{ic3_directory}/{self.config_ic3_file_name}"): config_filename = (f"{ic3_directory}/{self.config_ic3_file_name}") - elif os.path.exists(f"/config/{self.config_ic3_file_name}"): - config_filename = (f"/config/{self.config_ic3_file_name}") + alert_msg = (f"{EVLOG_ALERT}iCloud3 Alert > The `config_ic3.yaml` " + f"configuration file should moved to the `/config` " + f"directory so it is not deleted when HACS updates iCloud3") + self._save_event("*", alert_msg) else: event_msg += "Error-File Not Found" @@ -7912,21 +8287,25 @@ def _set_parameter_item(self, parameter_name, parameter_value): return ("", "") - log_msg = (f"config_ic3 Updating parameter-{parameter_name}: {parameter_value}") - self._log_debug_msg("*", log_msg) + log_msg = (f"{EVLOG_DEBUG}config_ic3 Parameter > {parameter_name}: {parameter_value}") + self._save_event("*", log_msg) if parameter_name in [CONF_TRACK_DEVICES, CONF_TRACK_DEVICE]: self.track_devices = parameter_value - elif parameter_name == CONF_IOSAPP_LOCATE_REQUEST_MAX_CNT: - self.iosapp_locate_request_max_cnt = int(parameter_value) + elif parameter_name == CONF_IOSAPP_REQUEST_LOC_MAX_CNT: + self.iosapp_request_loc_max_cnt = int(parameter_value) elif parameter_name == CONF_UNIT_OF_MEASUREMENT: self.unit_of_measurement = parameter_value + elif parameter_name == CONF_TIME_FORMAT: + self.time_format = int(parameter_value) elif parameter_name == CONF_BASE_ZONE: self.base_zone = parameter_value elif parameter_name == CONF_INZONE_INTERVAL: self.inzone_interval_secs = self._time_str_to_secs(parameter_value) elif parameter_name == CONF_MAX_INTERVAL: self.max_interval_secs = self._time_str_to_secs(parameter_value) + elif parameter_name == CONF_DISPLAY_ZONE_FNAME: + self.display_zone_fname_flag = (parameter_value == 'true') elif parameter_name == CONF_CENTER_IN_ZONE: self.center_in_zone_flag = (parameter_value == 'true') elif parameter_name == CONF_STATIONARY_STILL_TIME: @@ -7959,7 +8338,8 @@ def _set_parameter_item(self, parameter_name, parameter_value): elif parameter_name == CONF_EVENT_LOG_CARD_DIRECTORY: self.event_log_card_directory = parameter_value elif parameter_name == CONF_DEVICE_STATUS: - self.device_status_online = parameter_value + self.device_status_online = list(parameter_value.replace(" ","").split(",")) + self.device_status_online.append('') elif parameter_name == CONF_DISPLAY_TEXT_AS: self.display_text_as = parameter_value else: @@ -8013,7 +8393,7 @@ def _check_ic3_event_log_file_version(self): www_version, www_version_text = self._read_event_log_card_js_file(www_evlog_filename) else: www_version = 0 - www_version_text = "Unknown" + www_version_text = UNKNOWN try: os.mkdir(www_directory) except FileExistsError: @@ -8069,7 +8449,7 @@ def _read_event_log_card_js_file(self, evlog_filename): ''' try: #Cycle thru the file looking for the Version - evlog_version_text = "Unknown" + evlog_version_text = UNKNOWN evlog_version_no = 0 evlog_file = open(evlog_filename) evlog_version_parts = [0, 0, 0] @@ -8101,7 +8481,7 @@ def _read_event_log_card_js_file(self, evlog_filename): except FileNotFoundError: evlog_version_no = 0 - evlog_version_text = "Unknown" + evlog_version_text = UNKNOWN return (evlog_version_no, evlog_version_text) except Exception as err: @@ -8223,11 +8603,11 @@ def _log_debug_msg2(self, log_msg): _LOGGER.debug(log_msg) #-------------------------------------- - @staticmethod - def _internal_error_msg(function_name, err_text: str='', + def _internal_error_msg(self, function_name, err_text: str='', section_name: str=''): log_msg = (f"►INTERNAL ERROR-RETRYING ({function_name}:{section_name}-{err_text})") _LOGGER.error(log_msg) + self._save_event("*", log_msg) attrs = {} attrs[ATTR_INTERVAL] = '0 sec' @@ -8246,18 +8626,16 @@ def _time_now_secs(): ''' Return the epoch seconds in utc time ''' return int(time.time()) + #-------------------------------------------------------------------- - def _secs_to_time(self, e_seconds, time_24h = False): + def _secs_to_time(self, e_seconds): """ Convert seconds to hh:mm:ss """ if e_seconds == 0 or e_seconds == HIGH_INTEGER: return HHMMSS_ZERO else: t_struct = time.localtime(e_seconds + self.e_seconds_local_offset_secs) - if time_24h: - return time.strftime("%H:%M:%S", t_struct).lstrip('0') - else: - return time.strftime(self.um_time_strfmt, t_struct).lstrip('0') + return time.strftime(self.um_time_strfmt, t_struct).lstrip('0') #-------------------------------------------------------------------- @staticmethod @@ -8265,7 +8643,9 @@ def _secs_to_time_str(secs): """ Create the time string from seconds """ try: - if secs < 60: + if secs >= 86400: + time_str = self._secs_to_dhms_str(secs) + elif secs < 60: time_str = str(round(secs, 0)) + " sec" elif secs < 3600: time_str = str(round(secs/60, 1)) + " min" @@ -8283,19 +8663,20 @@ def _secs_to_time_str(secs): return time_str #-------------------------------------------------------------------- @staticmethod - def _secs_to_minsec_str(secs): + def _secs_to_dhms_str(secs): """ Create the time 0d0h0m0s time string from seconds """ try: secs_dhms = float(secs) dhms_str = "" - if (secs > 86400): dhms_str += f"{secs_dhms // 86400}d" + if (secs >= 86400): dhms_str += f"{secs_dhms // 86400}d" secs_dhms = secs_dhms % 86400 - if (secs > 3600): dhms_str += f"{secs_dhms // 3600}h" + if (secs >= 3600): dhms_str += f"{secs_dhms // 3600}h" secs_dhms %= 3600 - if (secs > 60): dhms_str += f"{secs_dhms // 60}m" + if (secs >= 60): dhms_str += f"{secs_dhms // 60}m" secs_dhms %= 60 dhms_str += f"{secs_dhms}s" + dhms_str = dhms_str.replace('.0', '') except: @@ -8323,10 +8704,11 @@ def _time_to_secs(hhmmss): return secs #-------------------------------------------------------------------- - def _time_to_12hrtime(self, hhmmss, time_24h=False, ampm=False): + def _time_to_12hrtime(self, hhmmss, ampm=False): try: - if self.unit_of_measurement == 'mi' and time_24h is False: + #if self.unit_of_measurement == 'mi' and time_24h is False: + if self.time_format == 12: hh_mm_ss = hhmmss.split(':') hhmmss_hh = int(hh_mm_ss[0]) @@ -8391,12 +8773,11 @@ def _timestamp_to_time_utcsecs(self, utc_timestamp) -> int: return hhmmss #-------------------------------------------------------------------- - def _timestamp_to_time(self, timestamp, time_24h = False): + def _timestamp_to_time(self, timestamp): """ Extract the time from the device timeStamp attribute updated by the IOS app. Format is --'timestamp': '2019-02-02 12:12:38.358-0500' - Return as a 24hour time if time_24h = True """ try: @@ -8509,24 +8890,23 @@ def _km_to_mi(self, arg_distance): """ try: - arg_distance = arg_distance * self.um_km_mi_factor + mi = arg_distance * self.um_km_mi_factor - if arg_distance == 0: + if mi == 0: mi = 0 - elif arg_distance <= 10: - mi = round(arg_distance, 2) - elif arg_distance <= 100: - mi = round(arg_distance, 1) + elif mi <= 10: + mi = round(mi, 2) + elif mi <= 100: + mi = round(mi, 1) else: - mi = round(arg_distance) + mi = round(mi) except: mi = 0 - return mi def _mi_to_km(self, arg_distance): - return round(float(arg_distance) / self.um_km_mi_factor, 2) + return round(float(arg_distance) / self.um_km_mi_factor, 2) #-------------------------------------------------------------------- @staticmethod @@ -8580,7 +8960,6 @@ def _isnumber(string): return False #-------------------------------------------------------------------- - def _extract_name_device_type(self, devicename): '''Extract the name and device type from the devicename''' @@ -8601,6 +8980,14 @@ def _extract_name_device_type(self, devicename): return (fname, "iCloud") +#-------------------------------------------------------------------- + def _device_online(self, devicename): + ''' Returns True/False if the device is online based on the device_status ''' + if self.TRK_METHOD_IOSAPP: + return True + else: + return (self.device_status.get(devicename) in self.device_status_online) #["online", "pending", ""]) + ######################################################### # # These functions handle notification and entry of the @@ -8610,6 +8997,9 @@ def _extract_name_device_type(self, devicename): def _icloud_show_trusted_device_request_form(self): """We need a trusted device.""" + if self._icloud_valid_api() == False: + return + self._service_handler_icloud_update(self.group, arg_command='pause') configurator = self.hass.components.configurator @@ -8662,13 +9052,17 @@ def _icloud_handle_trusted_device_entry(self, callback_data): {'deviceType': 'SMS', 'areaCode': '', 'phoneNumber': '********66', 'deviceId': '2'}] """ + + if self._icloud_valid_api() == False: + return + device_id_entered = str(callback_data.get('trusted_device')) if device_id_entered in self.trusted_device_list: self.trusted_device = self.trusted_device_list.get(device_id_entered) phone_number = self.trusted_device.get('phoneNumber') event_msg = (f"Verify Trusted Device Id > Device Id Entered-{device_id_entered}, " - f"Phone Number-{phone_number}") + f"Phone Number-{phone_number}") self._save_event("*", event_msg) if self.username not in self.hass_configurator_request_id: @@ -8719,6 +9113,9 @@ def _icloud_show_verification_code_entry_form(self, invalid_code_msg=""): def _icloud_handle_verification_code_entry(self, callback_data): """Handle the chosen trusted device.""" + if self._icloud_valid_api() == False: + return + from .pyicloud_ic3 import PyiCloudException self.verification_code = callback_data.get('code') event_msg = (f"Submit Verification Code > Code-{callback_data}") @@ -8735,6 +9132,7 @@ def _icloud_handle_verification_code_entry(self, callback_data): self._save_event("*", event_msg) except PyiCloudException as error: + # Reset to the initial 2FA state to allow the user to retry event_msg = (f"Failed to verify account > Error-{error}") self._save_event_halog_error("*", event_msg) @@ -8752,38 +9150,39 @@ def _icloud_handle_verification_code_entry(self, callback_data): configurator.request_done(request_id) self._setup_tracking_method(self.tracking_method_config) - event_msg = (f"{EVLOG_ALERT}iCloud Alert > iCloud Account Verification completed, {self.tracking_method_config}" - f"{self.trk_method_short_name} will be used.") + event_msg = (f"{EVLOG_ALERT}iCloud Alert > iCloud Account Verification completed, " + f"{self.trk_method_short_name} ({self.tracking_method_config}) will be used.") self._save_event("*", event_msg) - self._service_handler_icloud_update(self.group, arg_command='resume') + self._start_icloud3() + #self._service_handler_icloud_update(self.group, arg_command='resume') + +#-------------------------------------------------------------------- + def _icloud_valid_api(self): + ''' + Make sure the pyicloud_ic3 api is valid + ''' + + if self.api == None: + event_msg = (f"{EVLOG_ERROR}iCloud3 Error > There was an error logging into the " + f"Apple/iCloud Account when iCloud3 was started. Verification can not " + f"be completed. Review the iCloud3 Event Log for more information.") + self._save_event_halog_error('*', event_msg) + return False + return True ######################################################### # # ICLOUD ROUTINES # ######################################################### - def _service_handler_lost_iphone(self, group, arg_devicename): + def _service_handler_lost_iphone(self, group, devicename): """Call the lost iPhone function if the device is found.""" - if self.TRK_METHOD_FAMSHR is False: - log_msg = ("Lost Phone Alert Error: Alerts can only be sent " - "when using tracking_method FamShr") - self._log_warning_msg(log_msg) - self.info_notification = log_msg - self._display_status_info_msg(arg_devicename, log_msg) - return - - valid_devicename = self._service_multi_acct_devicename_check( - "Lost iPhone Service", group, arg_devicename) - if valid_devicename is False: - return - - device = self.tracked_devices.get(arg_devicename) - device.play_sound() - - log_msg = (f"iCloud Lost iPhone Alert, Device {arg_devicename}") - self._log_info_msg(log_msg) - self._display_status_info_msg(arg_devicename, "Lost Phone Alert sent") + lost_message = ("This phone has been identified as lost. " + "Please contact the owner with it's location.") + message = {"title": "Lost Phone Notification", + "message": lost_message} + self._send_message_to_device(devicename, message) #-------------------------------------------------------------------- def _service_handler_icloud_update(self, group, arg_devicename=None, @@ -8817,13 +9216,6 @@ def _service_handler_icloud_update(self, group, arg_devicename=None, log_msg = (f"iCLOUD3 COMMAND, Device: `{arg_devicename}`, Command: `{arg_command}`") self._log_debug_msg("*", log_msg) - if (arg_devicename and arg_devicename != "startup_log"): - if (arg_devicename != 'restart'): - valid_devicename = self._service_multi_acct_devicename_check( - "Update iCloud Service", group, arg_devicename) - if valid_devicename is False: - return - self._evlog_debug_msg(arg_devicename, (f"Service Call Command Received > `{arg_command}`")) arg_command = (f"{arg_command} ") @@ -8946,7 +9338,7 @@ def _service_handler_icloud_update(self, group, arg_devicename=None, attrs = {} self._wait_if_update_in_process(arg_devicename) - self.overrideinterval_seconds[arg_devicename] = 0 + self.override_interval_seconds[arg_devicename] = 0 self.update_in_process_flag = False self._initialize_next_update_time(arg_devicename) @@ -8976,13 +9368,13 @@ def _service_handler_icloud_update(self, group, arg_devicename=None, cmd_type = CMD_PAUSE self.next_update_secs[devicename_zone] = HIGH_INTEGER self.next_update_time[devicename_zone] = PAUSED - self._display_info_status_msg(devicename, 'PAUSED') + self._display_info_status_msg(devicename, PAUSED_CAPS) elif arg_command_cmd == 'resume': cmd_type = CMD_RESUME self.next_update_time[devicename_zone] = HHMMSS_ZERO self.next_update_secs[devicename_zone] = 0 - self.overrideinterval_seconds[devicename] = 0 + self.override_interval_seconds[devicename] = 0 self._display_info_status_msg(devicename, 'RESUMING') self._update_device_icloud('Resuming', devicename) @@ -8993,7 +9385,7 @@ def _service_handler_icloud_update(self, group, arg_devicename=None, self.next_update_secs[devicename_zone] = 0 attrs[ATTR_NEXT_UPDATE_TIME] = HHMMSS_ZERO attrs[ATTR_WAZE_DISTANCE] = 'Resuming' - self.overrideinterval_seconds[devicename] = 0 + self.override_interval_seconds[devicename] = 0 self._update_device_sensors(devicename, attrs) attrs = {} @@ -9002,8 +9394,16 @@ def _service_handler_icloud_update(self, group, arg_devicename=None, attrs[ATTR_WAZE_DISTANCE] = PAUSED attrs[ATTR_WAZE_TIME] = '' - elif arg_command_cmd == ATTR_LOCATION: - self._request_iosapp_location_update(devicename) + elif arg_command_cmd == 'location': + cmd_type = CMD_LOCATION + self._display_info_status_msg(devicename, 'locating') + if self.iosapp_monitor_dev_trk_flag.get(devicename): + self._iosapp_request_loc_update(devicename) + else: + self.next_update_time[devicename_zone] = HHMMSS_ZERO + self.next_update_secs[devicename_zone] = 0 + self.override_interval_seconds[devicename] = 0 + self._update_device_icloud('Locating', devicename) self._update_sensor_ic3_event_log(devicename) else: @@ -9040,19 +9440,13 @@ def _service_handler_icloud_setinterval(self, group, arg_interval=None, #device. if arg_devicename and self.TRK_METHOD_IOSAPP: - if self.iosapp_locate_request_cnt.get(arg_devicename) > self.iosapp_locate_request_max_cnt: + if self.iosapp_request_loc_cnt.get(arg_devicename) > self.iosapp_request_loc_max_cnt: event_msg = (f"Can not Set Interval, location request cnt " - f"exceeded ({self.iosapp_locate_request_cnt.get(arg_devicename)} " - f"of { self.iosapp_locate_request_max_cnt})") + f"exceeded ({self.iosapp_request_location_cnt.get(arg_devicename)} " + f"of {self.iosapp_request_loc_max_cnt})") self._save_event(arg_devicename, event_msg) return - elif arg_devicename: - valid_devicename = self._service_multi_acct_devicename_check( - "Update Interval Service", group, arg_devicename) - if valid_devicename is False: - return - if arg_interval is None: if arg_devicename is not None: self._save_event(arg_devicename, "Set Interval Command Error, " @@ -9085,7 +9479,7 @@ def _service_handler_icloud_setinterval(self, group, arg_interval=None, self.next_update_time[devicename_zone] = HHMMSS_ZERO self.next_update_secs[devicename_zone] = 0 self.interval_str[devicename_zone] = new_interval - self.overrideinterval_seconds[devicename] = self._time_str_to_secs(new_interval) + self.override_interval_seconds[devicename] = self._time_str_to_secs(new_interval) now_seconds = self._time_to_secs(dt_util.now().strftime('%X')) x, update_in_secs = divmod(now_seconds, 15) @@ -9096,22 +9490,5 @@ def _service_handler_icloud_setinterval(self, group, arg_interval=None, log_msg = (f"SET INTERVAL COMMAND END {devicename}") self._log_debug_msg(devicename, log_msg) -#-------------------------------------------------------------------- - def _service_multi_acct_devicename_check(self, svc_call_name, - group, arg_devicename): - if arg_devicename is None: - log_msg = (f"{svc_call_name} Error, no devicename specified") - self._log_error_msg(log_msg) - return False - - info_msg = (f"Checking {svc_call_name} for {group}") - - if (arg_devicename not in self.track_devicename_list): - event_msg = (f"{info_msg}, {arg_devicename} not in this group") - self._log_info_msg(event_msg) - return False - - event_msg = (f"{info_msg}-{arg_devicename} Processed") - return True #-------------------------------------------------------------------- diff --git a/custom_components/icloud3/icloud3-event-log-card.js b/custom_components/icloud3/icloud3-event-log-card.js index 1f976da..3acebd7 100644 --- a/custom_components/icloud3/icloud3-event-log-card.js +++ b/custom_components/icloud3/icloud3-event-log-card.js @@ -12,247 +12,293 @@ // If they do not match, the one in the 'custom_components\icloud3' is copied // to the 'www\custom_cards' directory. // -// Version=2.2.0.10 (10/4/2020) +// Version=2.2.1.02 (11/01/2020) // ///////////////////////////////////////////////////////////////////////////// class iCloud3EventLogCard extends HTMLElement { constructor() { - super(); - this.attachShadow({ mode: 'open' }); + super() + this.attachShadow({ mode: 'open' }) } //--------------------------------------------------------------------------- setConfig(config) { - const version = "2.2.0.10" + const version = "2.2.1.02" const cardTitle = "iCloud3 Event Log" - const root = this.shadowRoot; - const hass = this._hass; + const root = this.shadowRoot + const hass = this._hass // Create card elements - const card = document.createElement('ha-card'); - const background = document.createElement('div'); - background.id = "background"; + const card = document.createElement('ha-card') + const background = document.createElement('div') + background.id = "background" // Title Bar - const titleBar = document.createElement("div"); - titleBar.id = "titleBar"; - const title = document.createElement("div"); - title.id = "title"; + const titleBar = document.createElement("div") + titleBar.id = "titleBar" + const title = document.createElement("div") + title.id = "title" title.textContent = cardTitle - const utilityBar = document.createElement("div"); - utilityBar.id = "utilityBar"; - const thisButtonId = document.createElement("div"); - thisButtonId.id = "thisButtonId"; - thisButtonId.classList.add("themeTextColor"); - thisButtonId.innerText = "setup"; - const logRecdCnt = document.createElement("div"); - logRecdCnt.id = "logRecdCnt"; - logRecdCnt.innerText = "-1"; - const devType = document.createElement("div"); - devType.id = "devType"; - devType.innerText = ""; - const hdrCellWidth = document.createElement("div"); - hdrCellWidth.id = "hdrCellWidth"; - hdrCellWidth.innerText = "0,66.7px,97.8px,94.4px,80px,70px,66.7px"; - const versionText = document.createElement("div"); - versionText.id = "versionText"; - versionText.style.setProperty('visibility', 'hidden'); - versionText.textContent = 'v' + version + //Utility base contains hidden variables + const utilityBar = document.createElement("div") + utilityBar.id = "utilityBar" + const thisButtonId = document.createElement("div") + thisButtonId.id = "thisButtonId" + thisButtonId.classList.add("themeTextColor") + thisButtonId.innerText = "setup" + const logRecdCnt = document.createElement("div") + logRecdCnt.id = "logRecdCnt" + logRecdCnt.innerText = "-1" + const devType = document.createElement("div") + devType.id = "devType" + devType.innerText = "" + const hdrCellWidth = document.createElement("div") + hdrCellWidth.id = "hdrCellWidth" + hdrCellWidth.innerText = "0,66.7px,97.8px,94.4px,80px,70px,66.7px" + const versionText = document.createElement("div") + versionText.id = "versionText" + versionText.innerText = version + + const infoText = document.createElement("div") + infoText.id = "infoText" + infoText.innerText = '' // Button Bar - const buttonBar = document.createElement("div"); - buttonBar.id = "buttonBar"; - buttonBar.class = "buttonBar"; + const buttonBar = document.createElement("div") + buttonBar.id = "buttonBar" + buttonBar.class = "buttonBar" // Name Buttons - const btnName0 = document.createElement('btnName'); - btnName0.id = "btnName0"; - btnName0.classList.add("btnBaseFormat"); - btnName0.style.setProperty('visibility', 'visible'); - btnName0.innerText = "Setup"; - const btnName1 = document.createElement('btnName'); - btnName1.id = "btnName1"; - btnName1.classList.add("btnBaseFormat"); - btnName1.classList.add("btnHidden"); - const btnName2 = document.createElement('btnName'); - btnName2.id = "btnName2"; - btnName2.classList.add("btnBaseFormat"); - btnName2.classList.add("btnHidden"); - const btnName3 = document.createElement('btnName'); - btnName3.id = "btnName3"; - btnName3.classList.add("btnBaseFormat"); - btnName3.classList.add("btnHidden"); - const btnName4 = document.createElement('btnName'); - btnName4.id = "btnName4"; - btnName4.classList.add("btnBaseFormat"); - btnName4.classList.add("btnHidden"); - const btnName5 = document.createElement('btnName'); - btnName5.id = "btnName5"; - btnName5.classList.add("btnBaseFormat"); - btnName5.classList.add("btnHidden"); - const btnName6 = document.createElement('btnName'); - btnName6.id = "btnName6"; - btnName6.classList.add("btnBaseFormat"); - btnName6.classList.add("btnHidden"); - const btnName7 = document.createElement('btnName'); - btnName7.id = "btnName7"; - btnName7.classList.add("btnBaseFormat"); - btnName7.classList.add("btnHidden"); - const btnName8 = document.createElement('btnName'); - btnName8.id = "btnName8"; - btnName8.classList.add("btnBaseFormat"); - btnName8.classList.add("btnHidden"); - const btnName9 = document.createElement('btnName'); - btnName9.id = "btnName9"; - btnName9.classList.add("btnBaseFormat"); - btnName9.classList.add("btnHidden"); + const btnName0 = document.createElement('btnName') + btnName0.id = "btnName0" + btnName0.classList.add("btnBaseFormat") + btnName0.style.setProperty('visibility', 'visible') + btnName0.innerText = "Setup" + const btnName1 = document.createElement('btnName') + btnName1.id = "btnName1" + btnName1.classList.add("btnBaseFormat") + btnName1.classList.add("btnHidden") + const btnName2 = document.createElement('btnName') + btnName2.id = "btnName2" + btnName2.classList.add("btnBaseFormat") + btnName2.classList.add("btnHidden") + const btnName3 = document.createElement('btnName') + btnName3.id = "btnName3" + btnName3.classList.add("btnBaseFormat") + btnName3.classList.add("btnHidden") + const btnName4 = document.createElement('btnName') + btnName4.id = "btnName4" + btnName4.classList.add("btnBaseFormat") + btnName4.classList.add("btnHidden") + const btnName5 = document.createElement('btnName') + btnName5.id = "btnName5" + btnName5.classList.add("btnBaseFormat") + btnName5.classList.add("btnHidden") + const btnName6 = document.createElement('btnName') + btnName6.id = "btnName6" + btnName6.classList.add("btnBaseFormat") + btnName6.classList.add("btnHidden") + const btnName7 = document.createElement('btnName') + btnName7.id = "btnName7" + btnName7.classList.add("btnBaseFormat") + btnName7.classList.add("btnHidden") + const btnName8 = document.createElement('btnName') + btnName8.id = "btnName8" + btnName8.classList.add("btnBaseFormat") + btnName8.classList.add("btnHidden") + const btnName9 = document.createElement('btnName') + btnName9.id = "btnName9" + btnName9.classList.add("btnBaseFormat") + btnName9.classList.add("btnHidden") /* Action Select Box */ - const btnAction = document.createElement('select'); - btnAction.id = "btnAction"; - btnAction.style.setProperty('visibility', 'visible'); - btnAction.setDefault; - btnAction.classList.add("btnBaseFormat"); - btnAction.classList.add("btnAction"); - - var btnActionOptA = document.createElement("option"); - var btnActionOptATxt = document.createTextNode("Actions"); - btnActionOptA.setAttribute("value", "action"); - btnActionOptA.setAttribute("id", "optAction"); - btnActionOptA.classList.add("btnActionOptionTransparent"); - btnActionOptA.appendChild(btnActionOptATxt); - btnAction.appendChild(btnActionOptA); - - var btnActionOptG = document.createElement("optGroup"); - btnActionOptG.setAttribute("label", "———— Global Actions ————"); - btnActionOptG.classList.add("btnActionOptionGroup"); - btnAction.appendChild(btnActionOptG); - - var btnActionOptG1 = document.createElement("option"); - var btnActionOptG1Txt = document.createTextNode("Restart iCloud3"); - btnActionOptG1.setAttribute("value", "restart"); - btnActionOptG1.classList.add("btnActionOption"); - btnActionOptG1.appendChild(btnActionOptG1Txt); - btnAction.appendChild(btnActionOptG1); - - var btnActionOptG2 = document.createElement("option"); - var btnActionOptG2Txt = document.createTextNode("Pause Polling"); - btnActionOptG2.setAttribute("value", "pause"); - btnActionOptG2.classList.add("btnActionOption"); - btnActionOptG2.appendChild(btnActionOptG2Txt); - btnAction.appendChild(btnActionOptG2); - - var btnActionOptG3 = document.createElement("option"); - var btnActionOptG3Txt = document.createTextNode("Resume Polling"); - btnActionOptG3.setAttribute("value", "resume"); - btnActionOptG3.classList.add("btnActionOption"); - btnActionOptG3.appendChild(btnActionOptG3Txt); - btnAction.appendChild(btnActionOptG3); - - var btnActionOptG7 = document.createElement("option"); - var btnActionOptG7Txt = document.createTextNode("Request iOS App Locations"); - btnActionOptG7.setAttribute("value", "location"); - btnActionOptG7.classList.add("btnActionOption"); - btnActionOptG7.appendChild(btnActionOptG7Txt); - btnAction.appendChild(btnActionOptG7); - - var btnActionOptG4 = document.createElement("option"); - var btnActionOptG4Txt = document.createTextNode("Show Tracking Monitors"); - btnActionOptG4.setAttribute("value", "dev-log_level: eventlog"); - btnActionOptG4.setAttribute("id", "optEvlog"); - btnActionOptG4.classList.add("btnActionOption"); - btnActionOptG4.appendChild(btnActionOptG4Txt); - btnAction.appendChild(btnActionOptG4); - - var btnActionOptG8 = document.createElement("option"); - var btnActionOptG8Txt = document.createTextNode("Show Startup Log, Errors & Alerts"); - btnActionOptG8.setAttribute("value", "dev-refresh_event_log"); - btnActionOptG8.setAttribute("id", "optStartuplog"); - btnActionOptG8.classList.add("btnActionOption"); - btnActionOptG8.appendChild(btnActionOptG8Txt); - btnAction.appendChild(btnActionOptG8); - - var btnActionOptG5 = document.createElement("option"); - var btnActionOptG5Txt = document.createTextNode("Export Event Log"); - btnActionOptG5.setAttribute("value", "dev-export_event_log"); - btnActionOptG5.classList.add("btnActionOption"); - btnActionOptG5.appendChild(btnActionOptG5Txt); - btnAction.appendChild(btnActionOptG5); - - var btnActionOptG6 = document.createElement("option"); - var btnActionOptG6Txt = document.createTextNode("Start HA Debug Logging"); - btnActionOptG6.setAttribute("value", "dev-log_level: debug"); - btnActionOptG6.setAttribute("id", "optHalog"); - btnActionOptG6.classList.add("btnActionOption"); - btnActionOptG6.appendChild(btnActionOptG6Txt); - btnAction.appendChild(btnActionOptG6); + const btnAction = document.createElement('select') + btnAction.id = "btnAction" + btnAction.style.setProperty('visibility', 'visible') + btnAction.setDefault + btnAction.classList.add("btnBaseFormat") + btnAction.classList.add("btnAction") + + var btnActionOptA = document.createElement("option") + var btnActionOptATxt = document.createTextNode("Actions") + btnActionOptA.setAttribute("value", "action") + btnActionOptA.setAttribute("id", "optAction") + btnActionOptA.classList.add("btnActionOptionTransparent") + btnActionOptA.appendChild(btnActionOptATxt) + btnAction.appendChild(btnActionOptA) + + var btnActionOptG = document.createElement("optGroup") + btnActionOptG.setAttribute("label", "———— Global Actions ————") + btnActionOptG.classList.add("btnActionOptionGroup") + btnAction.appendChild(btnActionOptG) + + var btnActionOptG1 = document.createElement("option") + var btnActionOptG1Txt = document.createTextNode("Restart iCloud3") + btnActionOptG1.setAttribute("value", "restart") + btnActionOptG1.classList.add("btnActionOption") + btnActionOptG1.appendChild(btnActionOptG1Txt) + btnAction.appendChild(btnActionOptG1) + + var btnActionOptG2 = document.createElement("option") + var btnActionOptG2Txt = document.createTextNode("Pause Polling") + btnActionOptG2.setAttribute("value", "pause") + btnActionOptG2.classList.add("btnActionOption") + btnActionOptG2.appendChild(btnActionOptG2Txt) + btnAction.appendChild(btnActionOptG2) + + var btnActionOptG3 = document.createElement("option") + var btnActionOptG3Txt = document.createTextNode("Resume Polling") + btnActionOptG3.setAttribute("value", "resume") + btnActionOptG3.classList.add("btnActionOption") + btnActionOptG3.appendChild(btnActionOptG3Txt) + btnAction.appendChild(btnActionOptG3) + + var btnActionOptG7 = document.createElement("option") + var btnActionOptG7Txt = document.createTextNode("Update All Locations") + btnActionOptG7.setAttribute("value", "location") + btnActionOptG7.classList.add("btnActionOption") + btnActionOptG7.appendChild(btnActionOptG7Txt) + btnAction.appendChild(btnActionOptG7) + + var btnActionOptG4 = document.createElement("option") + var btnActionOptG4Txt = document.createTextNode("Show Tracking Monitors") + btnActionOptG4.setAttribute("value", "dev-log_level: eventlog") + btnActionOptG4.setAttribute("id", "optEvlog") + btnActionOptG4.classList.add("btnActionOption") + btnActionOptG4.appendChild(btnActionOptG4Txt) + btnAction.appendChild(btnActionOptG4) + + var btnActionOptG8 = document.createElement("option") + var btnActionOptG8Txt = document.createTextNode("Show Startup Log, Errors & Alerts") + btnActionOptG8.setAttribute("value", "dev-refresh_event_log") + btnActionOptG8.setAttribute("id", "optStartuplog") + btnActionOptG8.classList.add("btnActionOption") + btnActionOptG8.appendChild(btnActionOptG8Txt) + btnAction.appendChild(btnActionOptG8) + + var btnActionOptG5 = document.createElement("option") + var btnActionOptG5Txt = document.createTextNode("Export Event Log") + btnActionOptG5.setAttribute("value", "dev-export_event_log") + btnActionOptG5.classList.add("btnActionOption") + btnActionOptG5.appendChild(btnActionOptG5Txt) + btnAction.appendChild(btnActionOptG5) + + var btnActionOptG6 = document.createElement("option") + var btnActionOptG6Txt = document.createTextNode("Start HA Debug Logging") + btnActionOptG6.setAttribute("value", "dev-log_level: debug") + btnActionOptG6.setAttribute("id", "optHalog") + btnActionOptG6.classList.add("btnActionOption") + btnActionOptG6.appendChild(btnActionOptG6Txt) + btnAction.appendChild(btnActionOptG6) //--------------------------------------------------------- - var btnActionOptD = document.createElement("optGroup"); - btnActionOptD.setAttribute("label", "———— Device Actions ————"); - btnActionOptD.classList.add("btnActionOptionGroup"); - btnAction.appendChild(btnActionOptD); - - var btnActionOptD1 = document.createElement("option"); - var btnActionOptD1Txt = document.createTextNode("Pause Polling"); - btnActionOptD1.setAttribute("value", "dev-pause"); - btnActionOptD1.classList.add("btnActionOption"); - btnActionOptD1.appendChild(btnActionOptD1Txt); - btnAction.appendChild(btnActionOptD1); - - var btnActionOptD2 = document.createElement("option"); - var btnActionOptD2Txt = document.createTextNode("Resume Polling"); - btnActionOptD2.setAttribute("value", "dev-resume"); - btnActionOptD2.classList.add("btnActionOption"); - btnActionOptD2.appendChild(btnActionOptD2Txt); - btnAction.appendChild(btnActionOptD2); - - var btnActionOptD3 = document.createElement("option"); - var btnActionOptD3Txt = document.createTextNode("Request iOS App Location"); - btnActionOptD3.setAttribute("value", "dev-location"); - btnActionOptD3.classList.add("btnActionOption"); - btnActionOptD3.appendChild(btnActionOptD3Txt); - btnAction.appendChild(btnActionOptD3); - - var btnActionOptBL = document.createElement("option"); - var btnActionOptBLTxt = document.createTextNode(""); - btnActionOptBL.classList.add("btnActionOption"); + var btnActionOptD = document.createElement("optGroup") + btnActionOptD.setAttribute("label", "———— Selected Phone ————") + btnActionOptD.classList.add("btnActionOptionGroup") + btnAction.appendChild(btnActionOptD) + + var btnActionOptD1 = document.createElement("option") + var btnActionOptD1Txt = document.createTextNode("Pause Polling") + btnActionOptD1.setAttribute("value", "dev-pause") + btnActionOptD1.classList.add("btnActionOption") + btnActionOptD1.appendChild(btnActionOptD1Txt) + btnAction.appendChild(btnActionOptD1) + + var btnActionOptD2 = document.createElement("option") + var btnActionOptD2Txt = document.createTextNode("Resume Polling") + btnActionOptD2.setAttribute("value", "dev-resume") + btnActionOptD2.classList.add("btnActionOption") + btnActionOptD2.appendChild(btnActionOptD2Txt) + btnAction.appendChild(btnActionOptD2) + + var btnActionOptD3 = document.createElement("option") + var btnActionOptD3Txt = document.createTextNode("Update Phone's Location") + btnActionOptD3.setAttribute("value", "dev-location") + btnActionOptD3.classList.add("btnActionOption") + btnActionOptD3.appendChild(btnActionOptD3Txt) + btnAction.appendChild(btnActionOptD3) + + var btnActionOptBL = document.createElement("option") + var btnActionOptBLTxt = document.createTextNode("") + btnActionOptBL.classList.add("btnActionOption") btnActionOptBL.appendChild(btnActionOptBLTxt) - btnAction.appendChild(btnActionOptBL); - var btnActionOptV = document.createElement("optGroup"); - btnActionOptV.setAttribute("label", "—— Event Log v"+version+" ———"); - btnActionOptV.classList.add("btnActionOptionGroup"); - btnAction.appendChild(btnActionOptV); + btnAction.appendChild(btnActionOptBL) + var btnActionOptV = document.createElement("optGroup") + btnActionOptV.setAttribute("label", "—— Event Log v"+version+" ———") + btnActionOptV.classList.add("btnActionOptionGroup") + btnAction.appendChild(btnActionOptV) //------------------------------------------------------------- - const btnRefresh = document.createElement('btnName'); - btnRefresh.id = "btnRefresh"; - btnRefresh.classList.add("btnRefresh"); - btnRefresh.style.setProperty('visibility', 'visible'); - btnRefresh.innerHTML=`` + var btnHelpIcon24 = document.createElement('IMG') + btnHelpIcon24.id = "btnHelpIcon24" + btnHelpIcon24.setAttribute("alt", "iCloud3 Documentation") + //btnHelpIcon24.style.setProperty('visibility', 'visible') + btnHelpIcon24.setAttribute("src", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAYCAYAAADkgu3FAAAEEElEQVRIS72WfUzUZRzAP7zccefB8fY7DhDwmIgY/2S2dC1QBDFXbNp8bxlGNl9oDUyjmvhS4ZhQKIQvCVe4Nhthjj9glQqVscbmJNecJAZXdLyI3h0HCNzBtYN+7Afd8dJaz7/fl8/z/T7fl8eD/+l4zMRJB4VcQUi4D7HJamKDZfj9YcdU30tz3xD3Gh7RdQNsM/lxC1oF3k+peeJAKBmCjNUOB5pBsNjALhtFrvTEF+g0DFN7sgP9R/38AjjcAV2C3lCh3aMlZ7GKHW1D3HkwxLUOGzd+tdPaPkh/lBx1vDcLgxU8Od+HZK2MqIY+SivNFBWZMbuC/QP0ui+P5UeiV3oh1Js5ntXOl024NnY6TFcR+koQ2xICeNNq49bRTnYXmjFMhU0CHVQSkbeAS22j2Mq72Zdnpkk0KAokaqvABpuDeBk0numk+kgf3aL8vIbEjRo+ljkw5LaTXmilRwqbAK0CRfkiTkbIWPmhkS05Zn4WFd8PYMGrApdH4HEvD4btDuTAtdIOtuf10yXqVWpJ3hjC51etnE1p4xgwIsomQIUCSdmhVH3zkD1rjXwhKqwDn/IY8kYh2zxCldHGdzo565WerPaC/LAWcqQ3/0FHzjN+HDjaTtIRE7cmgV4CVdliyv4cJTD6LuuAUVFhOWirYjDIPbjbOETi879jqtOhW+LNdRuoI1tQS0Gv+SF8EMHVIRtXdrXwTi0MOeVjEW3xYeHFWL5vsnJiaRtFUsPrkYQvUXDxvp36uFZynbK6+UTEKWmwg19kC4FTH745moLYeaTtMJBw4e93HAOdEUh7Wcun+h6e29vFT5NuCLKAeWjMA/SfA0uFFlWiL+e9PdjqKnVO29owNqQGo9/ZSmJF/3j6xkBN0WTqFBxMMrDi5gDG6brcGEOBA/aPOCg5YeLd4of0TtUvEFi2T0tN6QN27u+kZs6gc8GkrA+iCgc/Hr7Pi6ctmFxdSgTpu9i1t4fqCdB0qZM6qgknc5mK4h47m+NbqXQX+UTqfiOpYoCbsyoGqbPb0RyKlHPseCepeRa+dQdyWwzTlbfU2TY58WsCSLzcz6VqSaPOurydiu4aVuok1581qWoyL5h4+2wft11FNG3DOg2mG0GiQ71A9qZgCgs7ePZwL19PBc1qBDmNpEP1s24y3zOPP6R4PtGQsCmI7cWdFByycE8qKxdY+UIIJTMOVdFoYk14oKm3UnjKROVXkik9NYocBbq0EDY/7U+W1UbTrNaE6MS5+HZreStORUbHMM3GR1zpsNM4dfEJSpbH+LDWz5uwhl5KKnvnsPhEmHOVr1CzNEtLRoiMFAcILlZ5l2GQmtPdlOWPF8fcVrk0PeLnRPBhUYI/MZGe+BtHsdT1cuc/+ZxMN+/+jewvN+q9KPbla94AAAAASUVORK5CYII=") + + var btnHelpIcon24Hover = document.createElement('IMG') + btnHelpIcon24Hover.id = "btnHelpIcon24Hover" + btnHelpIcon24Hover.setAttribute("alt", "iCloud3 Documentation") + btnHelpIcon24Hover.classList.add("btnHidden") + btnHelpIcon24Hover.setAttribute("src", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAYCAYAAADkgu3FAAAELUlEQVRIS72Wf0zUdRjHXwRc3kiUeSASB5RGKIv5a0FNjt8YphsYqNA08AdThBmQRhqgaMgJVxCIiMApyq8IcdSOmT+gNNZoyMWckxxLlvHDNCB1kw6OdrA77vjN1vr++zzv5/V9ns/zeZ6PEf/TZzQtxzN8DmKBFfNtHHnLxxHzBXPp/L2HW/WtqJ620dzQTVOTaro4k4M8PU1wenMlW/fvYJ7Im6EhS/qf9zGgGsDEVIBQ+BLQRVd7LRVZcvK/uA0MTQacGLRz30I27knA9vVtdN2/S9/j6/R1NnHv19949OAZYjtzXnVejNmC1Vi+7IPFQjtaGnK5UpnJ2czeiWDjQRExy4iRyjEVilDWH0cW+zVK5YTi4YAh4dYEbg/lDfePeP6khdNHdlMoax8LMwTFHLAlMvUiD+6r+KZoL7mpSp0gOdMO3y1BqFTOmJg2UppXQ97hhzp7WoEESfBJBKbt5CeFky97pA8bBXl6zuFQURYiWw/KP9+MNOEXnWP8MXsCd15CPbicF4z/YXBAAFynOjeMrNRunV9OpQ+S4BIar51mp28KMKi1jYIOyrwIjavi5+/2sH1thU4cEPAiB4tSUavjeNpbxcOO77F2CEQo9MbYWIr7ogSDMl24kYDLmv0UHvEi63CLIch/qxnphYV0/2GB/ysBgFondnVdiLSqHYHgHspGCVHreyirc8B26U0GVOZ4ic0NQKGRIqI+u8Zg/1Wkuw5SW9uvsY9kFLh5MSnlP9CmTCdoRaaBsOymDfZLy+n9s551TknDtgt1ttg7NTAwMBcvscW4LlO0ZmDnuIHEbe5Unx8+xxHQsbwNrPvgLAr5u3wa9ZOBMDLSFMF8S7p7n1GZ30d6sRkrJQUYm2zByFiKx5jSacTnaoNY7i8nLUJCWfFw+UZA1cporB0OEO7lRmtzx5S3/EZHBuqheNSDORSkH6Ik++9x/gkZqwjeq6AiN4L0eMXsQUfzffEOrMKIH8lJfp/SUz0T/pQWVCvfRWJUzcxKpx+pQBGN06pseh9tYr1z5aSZa0uXEuFFVXHzzJpBP5riTiJW4hTkx/05mXplUtCkzTBVe+tHey/UGVc/CTcvXaSmZvSi6vtM2d4ax8kurH6QfUl+uPlH8+35Tyg5fWfCjKa8sBrFVCNIGzFNHodfiIxS2TvIki+PA81oBGlU+kNVcS6a7KPDB6n7Us+44xcSxlfZGaQnthnYThR5sGZjzvRDVavSrgmB0JJb9TKqv6ykunp0So9N48MEB1Zv2ITL27E8f6Kc2ZrQBtEsvqDdHyN22sHjzlb+6rhKT2fj+MUnckW8ZC3CuYu43ZDD5dksPi1Ms8qXua0gLHYHFla+qIdEE6zybrraFZSfKuSMVNMcs1zl+uXRPk7MRa/h4r4EG/E8ujv6aKq7+988TqYceLM3/gvZOcgoRP8sgAAAAABJRU5ErkJggg==") + + const btnHelp = document.createElement('A') + btnHelp.id = "btnHelp" + btnHelp.classList.add("btnHelp") + btnHelp.style.setProperty('visibility', 'visible') + btnHelp.setAttribute('href', 'https://gcobb321.github.io/icloud3/#/') + btnHelp.setAttribute('target', '_blank') + btnHelp.appendChild(btnHelpIcon24) + btnHelp.appendChild(btnHelpIcon24Hover) + + //btnHelp.innerHTML=`` + + //------------------------------------------------------------- + var btnRefreshIcon24 = document.createElement('IMG') + btnRefreshIcon24.id = "btnRefreshIcon24" + btnRefreshIcon24.setAttribute("alt", "Refresh") + //btnRefreshIcon24.style.setProperty('visibility', 'visible') + btnRefreshIcon24.setAttribute("src", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAYCAYAAADkgu3FAAADLUlEQVRIS72WbWhTVxjHf6lJkxCT+HKTW7W6ltoXt0+V4UR0Wis4cf2gyMQPumJRdFNGfS37sFnHFHHRKr5ssLZuIijx5YusbLRaFcoQirGINE63RGvS1rrG3lSbJubKrU1Im5Q0Ejzf7jnPc37n+Z/nPs9R8Z6GKhmnHHSZOqzTtRSUmiiYqsH4JERvcx9Of4BHLa/oaoVgsn3GBC0B9TwTc3dnUSFoWCrLWAbgRRBCmjCZ+gwmAp3uQRqOeak/2s89QB4LmBD0jQFxq0hVoYENrgDtzwNc8wZpfRDiv44B+mdlYvpITd5UHR/P0FIqapjV4ueU3UdNjQ9fIlgcaPtEPjw0k3r9BIRmHwcrO7joILGzsmG5gayNU1i3aBK7pCBtpnZWKPP+OVxXqbhpuM/3yvcI0B492Qc+4LIrTLCum68P+HAk0z6y/quFT9dYOKmRcXtDTBfVSBkyhw3tXB0BWgK6unyOZWtYfMTD2iofd8cLidjZRUo/m8KfKngEeFQytjiQTaBkRxaX/vqfrcs9XEgVMiyXc1glJSniQevBUFtI7dMwk3P/GdI4nCpImsMNVYyfDBlx0q3Vkne+gJsOicPFLmpShSj2/UV8PtqvM4Q57yHnonf0s0DZlyJn6ntY+VUXf78LKJnPUNY5ctmWo2NPiZv5d17iSeY0nvVnhRgVO4sTKRpRukFHstEXq5H/9aOp6IkBpVu6hmmsmmzOvHzq8eDc319yJxpROpIhVk5nLj9NMqh37nKFxLN+uqOgdKR3BLTZiPBjNk2BII2bHvJtAwRGVIZ0/LDKhrdyqFpoZHd1ByX7emmLHCBa69JVgtZYOdck8csyF/uB13EgZSK2qP7WzbYffG8vcjyjTmDxaisnlKL6XQflNomeWL+x24QKS7OE7Xgv9ivDF5oIWKUjp8zKFwvMVEpBHNWdbLH5cI+2HbPxbRHZW2SgwjuI0/OKRm+I26Mbn6Dnk9lalhvVTGvp44S9L4XGFzmJ0srnmyiuFKmwalgmg5CglXe5B/jjdDe1h/zcT7mVx4YdeZwIWvIXmZk9MwOzJ8yL6320p+VxMp4ESMXmDVKARSiFsInBAAAAAElFTkSuQmCC") + + var btnRefreshIcon24Hover = document.createElement('IMG') + btnRefreshIcon24Hover.id = "btnRefreshIcon24Hover" + btnRefreshIcon24Hover.setAttribute("alt", "Refresh") + btnRefreshIcon24Hover.classList.add("btnHidden") + //btnRefreshIcon24Hover.style.setProperty('visibility', 'hidden') + btnRefreshIcon24Hover.setAttribute("src", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAYCAYAAADkgu3FAAADLklEQVRIS72Wa0iTYRTHf6+50Vhq4swyF5XdKBIssQtmMyO7QkYXDCrLim5SdpWKSruarbTMypp2BcNsHwqli2kXJIzlkohWWEnlptlFVqBpLbZYm22ii9Hz7X3ec87vPec57/k/Av9pCR1yFPFdkYt70D1wEGOiB+Ht54X+7Wcel+lo+VpNZXkdGk1LR3HaBykUngwJH8GCTQn4yCZgMvnT3NRIa0srniIxEkk3wIChppjLmXnkHHkKmNoDOgctXRvArJXJBA1eiOHNcxo/3qFRr+Hli9c0vPuGvI83/YcFI/ULw793NL4Bfagqz+ZWQQZnM744gzmCFicOJTEtD5FEhrZsP8qkK2i1Tp0tAefE92TmkjiGj9tIk7GKcO8plv1HX0sRhHuESXeaH9uCEjcHsXzfVd69aeFa7mqy92k7qv2f9wfORBI5+zhiUQ0f9IH4BRjp4pHOSOn1tiCFoivbcjORBY0n//A80pKfdBpiNcwqiCZ88g0EoRqoxUNQOoK2KqOIW1/Io5srWRJz2WXI73LpEBAwWZrCCWjSAinpKhV1732Z1M9c458ugyqMdxEEm5/J5OFYupnzgknNv0e1Np3Y0AyXIWYHzbfpDn4NBh9igi/ZzmjPyRlMXXSWorxpbF/18J9AHTj97jq1dg09+24mPmo0uspat4AefPCyxInwN9oycjco6bCEkFAT9a9EbEmwA7m7dOeKY8Ui36vfC7NHUHi+0paRO5rBvt5FukOe0u4bWjM2BqC+UG8DuaO9raC45TJW7S3hR/Nt0pZtpbi4ue1kcMcPa4548X4yIRGbUKVEkbmrysq3zTp3jaDI2ZeoKDnF0ompwA9HkHnHfqgWnVvDsd2Wg+zUOpg7nohZWZahmrMjnhxlg71f+zIhlvjzuEyJ+mgBarXlQJ2udcl9CZsxl5CxSTQZtZxKWYFKWfO3bfvCF7tiC/IhCXzU6/hUe5vP+gpH4ZONQj4gBolXL56WZ3HDFeGzfopZyoeODmV+UgK+PSby0yRzIuV1GGqKyD+h4nTaM9el3D5v6+XEWzaQkHEDCJT7UFfbiKb0uXsuJ53qgM4b/QIdc0go4VmAXgAAAABJRU5ErkJggg==") + + const btnRefresh = document.createElement('btnName') + btnRefresh.id = "btnRefresh" + btnRefresh.classList.add("btnRefresh") + btnRefresh.style.setProperty('visibility', 'visible') + btnRefresh.appendChild(btnRefreshIcon24) + btnRefresh.appendChild(btnRefreshIcon24Hover) + + //btnRefresh.innerHTML=`` + // Message Bar - const eltInfoBar = document.createElement("div"); - eltInfoBar.id = "eltInfoBar"; - - const eltInfoName = document.createElement("div"); - eltInfoName.id = "eltInfoName"; - eltInfoName.innerText = "Select Person"; - eltInfoName.style.color = "firebrick" - - const eltInfoTime = document.createElement("div"); - eltInfoTime.id = "eltInfoTime"; - eltInfoTime.innerText = "setup"; - eltInfoTime.style.color = "firebrick" - - const eltInfoMsgPopup = document.createElement("div"); - eltInfoMsgPopup.id = "eltInfoMsgPopup"; - eltInfoMsgPopup.classList.add("eltInfoMsgPopup"); - eltInfoMsgPopup.classList.add("eltInfoMsgPopupHidden"); - eltInfoMsgPopup.style.setProperty('zIndex', '9999'); - - const tblEvlogContainer = document.createElement("div"); + const statusBar = document.createElement("div") + statusBar.id = "statusBar" + + const statusName = document.createElement("div") + statusName.id = "statusName" + statusName.innerText = "Select Person" + statusName.style.color = "firebrick" + + const statusTime = document.createElement("div") + statusTime.id = "statusTime" + statusTime.innerText = "setup" + statusTime.style.color = "firebrick" + + const statusMsgPopup = document.createElement("div") + statusMsgPopup.id = "statusMsgPopup" + statusMsgPopup.classList.add("statusMsgPopup") + statusMsgPopup.classList.add("statusMsgPopupHidden") + statusMsgPopup.style.setProperty('zIndex', '9999') + + //Event Table + const tblEvlogContainer = document.createElement("div") tblEvlogContainer.id = "tblEvlogContainer" const tblEvlog = document.createElement("TABLE") @@ -268,7 +314,7 @@ class iCloud3EventLogCard extends HTMLElement { tblEvlogBody.classList.add("tblEvlogBody") // Style - const cssStyle = document.createElement('style'); + const cssStyle = document.createElement('style') cssStyle.textContent = ` /* Text special colors */ .blue {color: blue;} @@ -365,16 +411,15 @@ class iCloud3EventLogCard extends HTMLElement { width: 100%; /*border: 1px solid dodgerblue;*/ } - #thisButtonId, #logRecdCnt, #devType, #hdrCellWidth { + #thisButtonId, #logRecdCnt, #devType, #hdrCellWidth, #versionText { /*font-size: 2px;*/ color: transparent; - width: 25px; + width: 5px; float: left; /*border: 1px solid green;*/ } - - #versionText { - color: silver; + #infoText { + color: dodgerblue; float: right; } @@ -385,12 +430,12 @@ class iCloud3EventLogCard extends HTMLElement { } /* Message Bar setup */ - #eltInfoBar { + #statusBar { position: relative; width: 100%; /*border: 1px solid dodgerblue;*/ } - #eltInfoName { + #statusName { width: 40%; color: firebrick; float: left; @@ -399,7 +444,7 @@ class iCloud3EventLogCard extends HTMLElement { margin: 2px 0px 4px 0px; /*border: 1px solid var(--label-badge-red);*/ } - #eltInfoTime { + #statusTime { margin: 2px 2px 4px 0px; color: firebrick; float: right; @@ -407,7 +452,7 @@ class iCloud3EventLogCard extends HTMLElement { font-weight: 400; /*border: 1px solid green;*/ } - .eltInfoMsgPopup { + .statusMsgPopup { position: relative; width: 85%; margin-left: auto; @@ -422,7 +467,7 @@ class iCloud3EventLogCard extends HTMLElement { -moz-box-shadow: 5px 5px 23px 3px rgba(0,0,0,0.75); box-shadow: 3px 3px 20px 3px rgba(0,0,0,0.75); } - .eltInfoMsgPopupHidden { + .statusMsgPopupHidden { height: 0px; width: 0px; visibility: hidden; @@ -561,17 +606,35 @@ class iCloud3EventLogCard extends HTMLElement { } .btnHover {border: 1px solid var(--primary-color);} + /* Helph Select Button */ + #btnHelp { + display: inline-block; + visibility: visible; + color: var(--primary-text-color); + background-color: transparent; + /*height: 28px;*/ + /*width: 30px;*/ + margin: 0px 0px 0px 4px; + float: right; + } + .btnHelp { + border: 0px solid transparent; + background-color: transparent; + box-shadow: transparent; + } + /* Refresh Select Button */ #btnRefresh { display: inline-block; visibility: visible; color: var(--primary-text-color); background-color: transparent; - height: 24px; - margin: -4px 2px; + /*height: 28px;*/ + /*width: 30px;*/ + margin: 0px 0px 0px 4px; float: right; - box-sizing: border-box; } + .btnRefresh { border: 0px solid transparent; background-color: transparent; @@ -655,37 +718,39 @@ class iCloud3EventLogCard extends HTMLElement { ::-webkit-scrollbar {width: 1px;} ::-webkit-scrollbar-thumb {background: rgba(var(--rgb-accent-color), 0.7);} } - */ + */ - `; + ` // Build title - titleBar.appendChild(title); - titleBar.appendChild(btnRefresh); + titleBar.appendChild(title) + titleBar.appendChild(btnHelp) + titleBar.appendChild(btnRefresh) - utilityBar.appendChild(thisButtonId); - utilityBar.appendChild(logRecdCnt); - utilityBar.appendChild(devType); - utilityBar.appendChild(hdrCellWidth); - utilityBar.appendChild(versionText); + utilityBar.appendChild(thisButtonId) + utilityBar.appendChild(logRecdCnt) + utilityBar.appendChild(devType) + utilityBar.appendChild(hdrCellWidth) + utilityBar.appendChild(versionText) + utilityBar.appendChild(infoText) // Create Buttons - buttonBar.appendChild(btnName0); - buttonBar.appendChild(btnName1); - buttonBar.appendChild(btnName2); - buttonBar.appendChild(btnName3); - buttonBar.appendChild(btnName4); - buttonBar.appendChild(btnName5); - buttonBar.appendChild(btnAction); - buttonBar.appendChild(btnName6); - buttonBar.appendChild(btnName7); - buttonBar.appendChild(btnName8); - buttonBar.appendChild(btnName9); + buttonBar.appendChild(btnName0) + buttonBar.appendChild(btnName1) + buttonBar.appendChild(btnName2) + buttonBar.appendChild(btnName3) + buttonBar.appendChild(btnName4) + buttonBar.appendChild(btnName5) + buttonBar.appendChild(btnAction) + buttonBar.appendChild(btnName6) + buttonBar.appendChild(btnName7) + buttonBar.appendChild(btnName8) + buttonBar.appendChild(btnName9) // Build Message Bar - eltInfoBar.appendChild(eltInfoName); - eltInfoBar.appendChild(eltInfoTime); - eltInfoBar.appendChild(eltInfoMsgPopup) + statusBar.appendChild(statusName) + statusBar.appendChild(statusTime) + statusBar.appendChild(statusMsgPopup) tblEvlog.appendChild(tblEvlogHdr) tblEvlog.appendChild(tblEvlogBody) @@ -695,33 +760,37 @@ class iCloud3EventLogCard extends HTMLElement { background.appendChild(titleBar) background.appendChild(utilityBar) background.appendChild(buttonBar) - background.appendChild(eltInfoBar) + background.appendChild(statusBar) background.appendChild(tblEvlogContainer) - background.appendChild(cssStyle); + background.appendChild(cssStyle) - card.appendChild(background); - root.appendChild(card); + card.appendChild(background) + root.appendChild(card) // Click & Mouse Events for (let i = 0; i <= 9; i++) { let buttonId = 'btnName' + i let button = root.getElementById(buttonId) - button.addEventListener("mousedown", event => { this._nameButtonPress(buttonId); }); - button.addEventListener("mouseover", event => { this._btnClassMouseOver(buttonId); }); - button.addEventListener("mouseout", event => { this._btnClassMouseOut(buttonId); }); + button.addEventListener("mousedown", event => { this._nameButtonPress(buttonId); }) + button.addEventListener("mouseover", event => { this._btnClassMouseOver(buttonId); }) + button.addEventListener("mouseout", event => { this._btnClassMouseOut(buttonId); }) } - btnAction.addEventListener("change", event => { this._commandButtonPress("btnAction"); }); - //btnAction.addEventListener("mouseover", event => { this._btnClassMouseOver("btnAction"); }); - //btnAction.addEventListener("mouseout", event => { this._btnClassMouseOut("btnAction"); }); + btnAction.addEventListener("change", event => { this._commandButtonPress("btnAction"); }) + //btnAction.addEventListener("mouseover", event => { this._btnClassMouseOver("btnAction"); }) + //btnAction.addEventListener("mouseout", event => { this._btnClassMouseOut("btnAction"); }) - btnRefresh.addEventListener("mousedown", event => { this._commandButtonPress("btnRefresh"); }); - btnRefresh.addEventListener("mouseover", event => { this._btnClassMouseOver("btnRefresh"); }); - btnRefresh.addEventListener("mouseout", event => { this._btnClassMouseOut("btnRefresh"); }); + //btnHelp.addEventListener("mousedown", event => { this._commandButtonPress("btnHelp"); }) + btnHelp.addEventListener("mouseover", event => { this._btnClassMouseOver("btnHelp"); }) + btnHelp.addEventListener("mouseout", event => { this._btnClassMouseOut("btnHelp"); }) + + btnRefresh.addEventListener("mousedown", event => { this._commandButtonPress("btnRefresh"); }) + btnRefresh.addEventListener("mouseover", event => { this._btnClassMouseOver("btnRefresh"); }) + btnRefresh.addEventListener("mouseout", event => { this._btnClassMouseOut("btnRefresh"); }) // Add to root - this._config = config; + this._config = config } // Create card. @@ -733,8 +802,8 @@ class iCloud3EventLogCard extends HTMLElement { const root = this.shadowRoot this._hass = hass const thisButtonId = root.getElementById("thisButtonId") - const eltInfoTime = root.getElementById("eltInfoTime") - const eltInfoMsgPopup = root.getElementById("eltInfoMsgPopup") + const statusTime = root.getElementById("statusTime") + const statusMsgPopup = root.getElementById("statusMsgPopup") try { const updateTimeAttr = hass.states['sensor.icloud3_event_log'].attributes['update_time'] @@ -744,17 +813,17 @@ class iCloud3EventLogCard extends HTMLElement { this._nameButtonPress(this._currentButtonId()) } - //this._displayNameMsgL('/'+eltInfoTime.innerText + '/'+updateTimeAttr+'/') - if (eltInfoTime.innerText.indexOf(updateTimeAttr) == -1) { + //this._displayNameMsgL('/'+statusTime.innerText + '/'+updateTimeAttr+'/') + if (statusTime.innerText.indexOf(updateTimeAttr) == -1) { this._setupEventLogTable('hass') } - eltInfoMsgPopup.classList.add('eltInfoMsgPopupHidden') + statusMsgPopup.classList.add('statusMsgPopupHidden') } catch(err) { - const eltInfoMsgPopup = root.getElementById("eltInfoMsgPopup") - const eltInfoTime = root.getElementById("eltInfoTime") + const statusMsgPopup = root.getElementById("statusMsgPopup") + const statusTime = root.getElementById("statusTime") - if (eltInfoMsgPopup.classList.contains('eltInfoMsgPopupHidden') == false) { + if (statusMsgPopup.classList.contains('statusMsgPopupHidden') == false) { return } const msgRestarting = '

iCloud3 is restarting

' @@ -762,13 +831,13 @@ class iCloud3EventLogCard extends HTMLElement { if (err.name == 'TypeError') { if (err.message.indexOf('attributes') > -1) { - //if (eltInfoTime.innerText == 'setup') { + //if (statusTime.innerText == 'setup') { if (thisButtonId.innerText == "setup") { - eltInfoMsgPopup.innerHTML = msgNotRunning + statusMsgPopup.innerHTML = msgNotRunning } else { - eltInfoMsgPopup.innerHTML = msgRestarting + statusMsgPopup.innerHTML = msgRestarting } - eltInfoMsgPopup.classList.remove('eltInfoMsgPopupHidden') + statusMsgPopup.classList.remove('statusMsgPopupHidden') } else if (err.message.indexOf('undefined') == -1) { alert(err) } @@ -783,8 +852,8 @@ class iCloud3EventLogCard extends HTMLElement { /* Cycle through the sensor.icloud3_event_log attributes and build the names on the buttons, and make them visible. */ - const root = this.shadowRoot; - const hass = this._hass; + const root = this.shadowRoot + const hass = this._hass const thisButtonId = root.getElementById("thisButtonId") const btnName0 = root.getElementById("btnName0") const filtername = hass.states['sensor.icloud3_event_log'].attributes['filtername'] @@ -806,13 +875,13 @@ class iCloud3EventLogCard extends HTMLElement { if (i < names.length) { button.innerText = names[i] - button.style.setProperty('visibility', 'visible'); + button.style.setProperty('visibility', 'visible') button.classList.remove('btnHidden') button.classList.add('btnNotSelected') //} else { // button.innerText = "Device-"+i - // button.style.setProperty('visibility', 'visible'); + // button.style.setProperty('visibility', 'visible') // button.classList.remove('btnHidden') } } @@ -824,10 +893,10 @@ class iCloud3EventLogCard extends HTMLElement { build the event log table */ - const root = this.shadowRoot; - const hass = this._hass; + const root = this.shadowRoot + const hass = this._hass const tblEvlog = root.getElementById("tblEvlog") - const eltInfoTime = root.getElementById("eltInfoTime") + const statusTime = root.getElementById("statusTime") const hdrCellWidth = root.getElementById("hdrCellWidth") const logRecdCnt = root.getElementById("logRecdCnt") const devType = root.getElementById("devType") @@ -966,6 +1035,8 @@ class iCloud3EventLogCard extends HTMLElement { maxStatZoneLength = 9 if (tStat == 'stationary') {tStat = 'stationry'} if (tZone == 'stationary') {tZone = 'stationry'} + if (tStat == 'Stationary') {tStat = 'Stationry'} + if (tZone == 'Stationary') {tZone = 'Stationry'} } if (tStat.length > maxStatZoneLength) { tStat = tStat.substr(0, maxStatZoneLength) + "
" + tStat.substr(maxStatZoneLength, tStat.length) @@ -1091,7 +1162,7 @@ class iCloud3EventLogCard extends HTMLElement { && tText.indexOf("Complete") == -1) { initializationRecdFound = true } - if (tText.indexOf("Error") >= 0) { + if (tText.indexOf(" Error ") >= 0) { classErrorMsg = ' errorMsg' if (initializationRecdFound == false) { alertErrorMsg = "iCloud3 Error Msg at "+tTime @@ -1270,7 +1341,7 @@ class iCloud3EventLogCard extends HTMLElement { //--------------------------------------------------------------------------- _resize_header_width() { - const root = this.shadowRoot; + const root = this.shadowRoot const tblEvlog = root.getElementById("tblEvlog") const devType = root.getElementById("devType") var rowCnt = tblEvlog.rows.length @@ -1298,7 +1369,7 @@ class iCloud3EventLogCard extends HTMLElement { } //--------------------------------------------------------------------------- _setupDevType() { - const root = this.shadowRoot; + const root = this.shadowRoot const devType = root.getElementById("devType") //iPhone (portrait) width=375, ,height=768 @@ -1335,8 +1406,8 @@ class iCloud3EventLogCard extends HTMLElement { and then the Event Log was displayed in another, the Log revs are for the selected device but the name highlighter will be for the precious device selected. */ - const root = this.shadowRoot; - const hass = this._hass; + const root = this.shadowRoot + const hass = this._hass this.namesAttr = hass.states['sensor.icloud3_event_log'].attributes['names'] var filtername = hass.states['sensor.icloud3_event_log'].attributes['filtername'] const namesAttr = this.namesAttr @@ -1375,8 +1446,8 @@ class iCloud3EventLogCard extends HTMLElement { /* Handle the button press events. Get the devicename, do an 'icloud3_update' event_log devicename' service call to have the event_log attribute populated. */ - const root = this.shadowRoot; - const hass = this._hass; + const root = this.shadowRoot + const hass = this._hass this.namesAttr = hass.states['sensor.icloud3_event_log'].attributes['names'] const namesAttr = this.namesAttr const names = Object.values(namesAttr) @@ -1388,9 +1459,9 @@ class iCloud3EventLogCard extends HTMLElement { var lastButtonId = this._currentButtonId() var lastButtonPressed = root.getElementById(lastButtonId) var buttonPressX = buttonPressId.substr(-1) - var eltInfoName = names[buttonPressX]+" ("+devicenames[buttonPressX]+")" + var statusName = names[buttonPressX]+" ("+devicenames[buttonPressX]+")" - this._displayNameMsgL(eltInfoName) + this._displayNameMsgL(statusName) thisButtonId.innerText = buttonPressId lastButtonPressed.classList.remove('btnSelected') @@ -1409,8 +1480,8 @@ class iCloud3EventLogCard extends HTMLElement { /* Handle the button press events. Get the devicename, do an 'icloud3_update' event_log devicename' service call to have the event_log attribute populated. */ - const root = this.shadowRoot; - const hass = this._hass; + const root = this.shadowRoot + const hass = this._hass this.namesAttr = hass.states['sensor.icloud3_event_log'].attributes['names'] const namesAttr = this.namesAttr const devicenames = Object.keys(namesAttr) @@ -1465,15 +1536,27 @@ class iCloud3EventLogCard extends HTMLElement { //--------------------------------------------------------------------------- _btnClassMouseOver(buttonId) { - const root = this.shadowRoot; + const root = this.shadowRoot const button = root.getElementById(buttonId) - const thisButtonId = root.getElementById("thisButtonId") + const btnHelp = root.getElementById("bthHelp") + const btnHelpIcon24 = root.getElementById("btnHelpIcon24") + const btnHelpIcon24Hover = root.getElementById("btnHelpIcon24Hover") + const btnRefreshIcon24 = root.getElementById("btnRefreshIcon24") + const btnRefreshIcon24Hover = root.getElementById("btnRefreshIcon24Hover") const versionText = root.getElementById("versionText") + const infoText = root.getElementById("infoText") const devType = root.getElementById("devType") - if (buttonId == "btnRefresh") { - this._displayTimeMsgR("Refresh Event Log") - versionText.style.setProperty('visibility', 'visible'); + if (buttonId == "btnHelp") { + btnHelpIcon24Hover.classList.remove('btnHidden') + btnHelpIcon24.classList.add('btnHidden') + this._displayInfoText("iCloud3 User Maual") + this._displayTimeMsgR("Version "+versionText.textContent) + + } else if (buttonId == "btnRefresh") { + btnRefreshIcon24Hover.classList.remove('btnHidden') + btnRefreshIcon24.classList.add('btnHidden') + this._displayInfoText("Refresh Event Log") } else if (buttonId == "btnAction") { this._displayTimeMsgR("Show Action Command List") @@ -1484,16 +1567,29 @@ class iCloud3EventLogCard extends HTMLElement { //--------------------------------------------------------------------------- _btnClassMouseOut(buttonId) { - const root = this.shadowRoot; - const hass = this._hass; + const root = this.shadowRoot + const hass = this._hass this.logLevelDebug = hass.states['sensor.icloud3_event_log'].attributes['log_level_debug'] const button = root.getElementById(buttonId) + const btnHelp = root.getElementById("bthHelp") + const btnHelpIcon24 = root.getElementById("btnHelpIcon24") + const btnHelpIcon24Hover = root.getElementById("btnHelpIcon24Hover") + const btnRefreshIcon24 = root.getElementById("btnRefreshIcon24") + const btnRefreshIcon24Hover = root.getElementById("btnRefreshIcon24Hover") const versionText = root.getElementById('versionText') + const infoText = root.getElementById("infoText") const devType = root.getElementById("devType") const thisButtonId = root.getElementById("thisButtonId") - if (buttonId == 'btnRefresh') { - versionText.style.setProperty('visibility', 'hidden') + if (buttonId == 'btnHelp') { + btnHelpIcon24.classList.remove('btnHidden') + btnHelpIcon24Hover.classList.add('btnHidden') + this._displayInfoText('') + + } else if (buttonId == 'btnRefresh') { + btnRefreshIcon24.classList.remove('btnHidden') + btnRefreshIcon24Hover.classList.add('btnHidden') + this._displayInfoText('') } if (devType.innerText == "") {button.classList.remove("btnHover")} @@ -1501,42 +1597,50 @@ class iCloud3EventLogCard extends HTMLElement { } //--------------------------------------------------------------------------- _currentButtonId() { - const root = this.shadowRoot; + const root = this.shadowRoot const thisButtonId = root.getElementById("thisButtonId") return thisButtonId.innerText } //--------------------------------------------------------------------------- _displayTimeMsgR(msg) { - // Display message before time field - const root = this.shadowRoot; - const hass = this._hass; - const eltInfoTime = root.getElementById("eltInfoTime") + // Display text below action button + const root = this.shadowRoot + const hass = this._hass + const statusTime = root.getElementById("statusTime") const updateTimeAttr = hass.states['sensor.icloud3_event_log'].attributes['update_time'] - //if (eltInfoTime.innerText.startsWith("●●")) { + //if (statusTime.innerText.startsWith("●●")) { //pass if (msg == "") { - eltInfoTime.innerHTML = "Refreshed: " + updateTimeAttr + statusTime.innerHTML = "Refreshed: " + updateTimeAttr } else { - eltInfoTime.innerHTML = msg + statusTime.innerHTML = msg } } //--------------------------------------------------------------------------- _displayNameMsgL(msg) { - /* Display test messages */ - const root = this.shadowRoot; - const eltInfoName = root.getElementById("eltInfoName") - eltInfoName.innerHTML = msg + /* Display text below name button */ + const root = this.shadowRoot + const statusName = root.getElementById("statusName") + statusName.innerHTML = msg + } + + //--------------------------------------------------------------------------- + _displayInfoText(msg) { + /* Display text in area below refresh & help buttons */ + const root = this.shadowRoot + const infoText = root.getElementById("infoText") + infoText.innerText = msg } //--------------------------------------------------------------------------- getCardSize() { - return 1; + return 1 } } -customElements.define('icloud3-event-log-card', iCloud3EventLogCard); +customElements.define('icloud3-event-log-card', iCloud3EventLogCard) diff --git a/custom_components/icloud3/pyicloud_ic3.py b/custom_components/icloud3/pyicloud_ic3.py index a63eb64..4edea47 100644 --- a/custom_components/icloud3/pyicloud_ic3.py +++ b/custom_components/icloud3/pyicloud_ic3.py @@ -24,7 +24,7 @@ to generate a Authentication error code rather than generating it's own 2SA Needed Exception """ -from six import PY2, string_types +from six import PY2, string_types, text_type from uuid import uuid1 import inspect import json @@ -103,22 +103,34 @@ def request(self, method, url, **kwargs): # pylint: disable=arguments-differ if self.service.password_filter not in request_logger.filters: request_logger.addFilter(self.service.password_filter) - request_logger.debug(f"{method}, {url}, {kwargs.get('data', '')}") + request_logger.debug(f"106 REQUEST -- {25*'-'}") + request_logger.debug(f"106 REQUEST -- {method}, {url}, {kwargs.get('data', '')}") #request_logger.info(f"{method}, {url}, {kwargs.get("data", "")}"") kwargs_retry_flag = kwargs.get("retried", None) kwargs.pop("retried", None) + response = super(PyiCloudSession, self).request(method, url, **kwargs) + request_logger.debug(f"115 RESPONSE -- {response}, StatusCode-{response.status_code}, okStatus-{response.ok}") + request_logger.debug(f"110 RESPONSE -- {25*'-'}") + content_type = response.headers.get("Content-Type", "").split(";")[0] json_mimetypes = ["application/json", "text/json"] kwargs_retry_flag = True if not response.ok and content_type not in json_mimetypes: + try: + data = response.json() + request_logger.debug(f"120 RESPONSE ERROR DATA -- {data}") + except: + pass + if (kwargs_retry_flag is None and (response.status_code == AUTHENTICATION_REQUIRED or response.status_code == DEVICE_STATUS_ERROR)): message = (f"Reauthentication Required for Account: {self.service.user['apple_id']}") + request_logger.debug(f"133 MESSAGE -- {message}") api_error = PyiCloudAPIResponseException( message, response.status_code, retry=True @@ -139,7 +151,7 @@ def request(self, method, url, **kwargs): # pylint: disable=arguments-differ request_logger.warning("Failed to parse response with JSON mimetype") return response - request_logger.debug(data) + #request_logger.debug(f"150 RESPONSE DATA -- {data}") #request_logger.info(data) reason = data.get("errorMessage") diff --git a/custom_components/icloud3/services.yaml b/custom_components/icloud3/services.yaml index 5f41292..9be5441 100644 --- a/custom_components/icloud3/services.yaml +++ b/custom_components/icloud3/services.yaml @@ -1,3 +1,6 @@ +# yamllint disable rule:document-start +# yamllint disable rule:line-length + icloud3_update: description: This service allows you to change the way iCloud3 operates. fields: @@ -24,7 +27,7 @@ icloud3_restart: account_name: description: account_name of the iCloud3 custom component specified in the Configuration Variables section described at the beginning of this document. (Required) example: gary_icloud - + icloud3_lost_iphone: description: This service will play the Lost iPhone sound on a specific device. fields: diff --git a/development area - v2.2.1/change log.txt b/development area - v2.2.1/change log.txt deleted file mode 100644 index 30e3193..0000000 --- a/development area - v2.2.1/change log.txt +++ /dev/null @@ -1,49 +0,0 @@ -v2.2.1f (10/29/2020) -------- -1. Bug fix - Fixed an error introduced in v2.2.1e dealing with authentication error checking. -2. Parameter change - Changed 'display_zone_name:' to 'display_zone_fname' to match what iCloud3 was doing before v2.2.1e. -3. If the zone name is displayed, it will be reformatted before it is displayed. For example, 'the_shores' will be displayed as 'TheShores'. - -v2.2.1e (10/28/2020) -------- -1. Enhancement - Updated the iOS App tracking method to request the phone's location when the Next Update Time is reached in the same manner as the iCloud tracking methods. Using the tracking method 'iosapp' eliminates all iCloud account polling, relies only on the data provided by the iOS app and provides a reliable tracking method when access to the iCloud accounts is limited due to 2fa authentication/verification issues. At night, the iOS App usually shuts down when the phone is asleep. When the phone becomes inactive and is not responding to location requests, a message is displayed in the Event Log and on the iCloud3 information screen, the interval is increased until the phone wakes up and the iOS App becomes active again. When iOS App wakes up and begins responding to the location request, polling will automatically resume. Note: If the iOS App is running in the background, it still may not wake up and respond to the location requests. In that case, the iOS App must be restarted. iCloud3 will detect the 'launch' trigger issued by the iOS App and begin polling as normal. -2. Bug fix - The iOS App device_tracker entity contained the zone's friendly name on enter/exit events while iCloud3 used the zone name for it's device_tracker entity. This led to extra zone triggers zone mismatch errors when the names were different. This has been fixed. -3. Updated - Changed the way zones are handled to better support overlapping zones, particularilly when you have two zones with a center at the same location with different sizes. For example, your Home zone has a radius of 100m and you have a second zone at the Home zone's location with a radius of 500m. The larger zone will help wake up the iOS App before you reach the Home zone. Another example is having a city size zone to display the city's name instead of Away in the Zone and State entities on the Event Log and iCloud3 information screen. -4. New parameter - 'display_zone_name: True/False', Default: False. iCloud3 (and the iOS App) displays the zone's friendly name in the Zone and iOSApp state fields on the Event Log and the iCloud3 information screen. If the friendly name is too long to be displayed, it will be truncated. With this parameter, the zone's name rather than it's friendly name, is displayed. -5. New parameter - 'time_format: 12/24', Default depends on the unit_of_measurement (mi=12, km=24). This parameter overrides the unit_of_measurement time format. -6. Enhancement - Added error handling to the iCloud authentication/verification process. - - -v2.2.1c (10/20/2020) -------- -1. Bug fix - The iOS App reports the device_tracker state with the zone's friendly_name. iCloud3 was handling zones by the zone name. This caused problems handling zone change detection and, at times created a iOS App/iCloud3 state mismatch error. -2. Bug fix - If (1) the phone was turned off and not available when HA restarted or (2) became unreachable for an extended period of time while HA/iCloud3 was running (cell service down, turned off, airplane mode, etc.), the phone would be in a not_set state or the location would become older and older. This would hang up iCloud3 in a 5-second update loop, polling iCloud for location data when none was available. It will now retry the data request 4-times at a 15-second interval. The interval will increase to 1, 5, 15, 30, 1 hr and then the max_interval (4-hrs) and remain there until the phone comes back online. If the phone is then turned on, it will be picked up on the next successful location data request and returned and then tracked as normal. -3. Bug fix - When old location messages were added to the Event Log if the last located time is old and the phone is in a zone. -4. Bug fix - If the unit_of_measurement not being set to 'km' if the parameter was in the config_ic3.yaml file. It was being set correctly if it was in the HA configuration.file. -5. Bug fix - Fixed 'Lost Pnone' notification. It will now work with all tracking methods using the iOS App Notifications platform. -6. If the phone's name started with iPhone (or iPad), the first character of the sensor.xxx friendly_name attribute would be a '-'. The '-' was removed. Also removed the '-' between the phone's name and the attribute name to match the iOS App friendly_name formatting. -7. New configuration parameter - 'display_zone_name: True/False(default)' The 'device_tracker.[devicename]' entity displays the zone's friendly_ name, which is then displayed on the lovelace card. The name is truncated if it is longer than 10-12 letters. This option, if True, will display the zone name itself instead of the friendly_name. -8. Added some logger debug statements to 'pyicloud_ic3.py' to capture icloud request/response events that might help pinpoint what is happening when iCloud needs to do an account verification. -To turn this on, add the following to your configuration.yaml file: -logger: - default: info - logs: - custom_components.icloud3.pyicloud_ic3: debug - - - -v2.2.1b (10/14/2020) --------------------- -1. Fixes a Region Entered trigger being discarded when the distance to the center of the zone is between the zone's radius and 100m when the radius is less than 100m. The iOS App issues the Region Enter trigger when the distance is less than 100m, regardless of the zone size. iCloud3 looks at the zone size and not the 100m prezone size like the iOS App. -2. A location will not be considered old if it's age is less than 1-minute to reduce the number of location upates that are discarded. -3. Updated sensor icons. - -v2.2.1a (10/13/2020) --------------------- -1. Display an Alert message in the Event Log if the config_ic3.yaml file is found in the icloud3 directory. -2. Restart iCloud3 to reinitialize location information when the iCloud account is verified with the 6-digit authentication code. Previously, iCloud3 was resuming and, in some cases, would not receive location information from iCloud Location Services on the next poll. -3. Fixed the iOS App monitor so it would show the iOS App gps accuracy rather than the last iCloud value. -4. Fixed a determining the polling interval if the last poll was from iCloud data and it was discarded due to poor gps accuracy and the next poll was from the iOS App with good gps accuracy. -5. Added the 'noiosapp' value to the track_devices parameter that indicates the devicename does not have the iOS App installed on it. Example ' - gary_iphone > gary-456@email.com, gary.png, noiosapp' -6. Added the results of the interval calculation to the Event Log Tracking Monitor display. -7. Changed Event Log > Actions > Request iOS App Location to 'Update Location' to issue an iOS App Locate Reuest if the iOS App is installed or an iCloud Location request if it is not installed. \ No newline at end of file diff --git a/development area - v2.2.1/device_tracker.py b/development area - v2.2.1/device_tracker.py deleted file mode 100644 index 73ddc75..0000000 --- a/development area - v2.2.1/device_tracker.py +++ /dev/null @@ -1,9541 +0,0 @@ -""" -Platform that supports scanning iCloud. -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/device_tracker.icloud/ - - -Special Note: I want to thank Walt Howd, (iCloud2 fame) who inspired me to - tackle this project. I also want to give a shout out to Kovács Bálint, - Budapest, Hungary who wrote the Python WazeRouteCalculator and some - awesome HA guys (Petro31, scop, tsvi, troykellt, balloob, Myrddyn1, - mountainsandcode, diraimondo, fabaff, squirtbrnr, and mhhbob) who - gave me the idea of using Waze in iCloud3. - ...Gary Cobb aka GeeksterGary, Vero Beach, Florida, USA - -Thanks to all -""" - -#pylint: disable=bad-whitespace, bad-indentation -#pylint: disable=bad-continuation, import-error, invalid-name, bare-except -#pylint: disable=too-many-arguments, too-many-statements, too-many-branches -#pylint: disable=too-many-locals, too-many-return-statements -#pylint: disable=unused-argument, unused-variable -#pylint: disable=too-many-instance-attributes, too-many-lines - -VERSION = '2.2.1f' - -''' -v2.2.1f -------- -1. Bug fix - Fixed an error introduced in v2.2.1e dealing with authentication error checking. -2. Parameter change - Changed 'dislay_zone_name:' to 'display_zone_fname' to match what iCloud3 was doing before v2.2.1e. -3. If the zone name is displayed, it will be reformatted before it is displayed. For example, 'the_shores' will be displayed as 'TheShores'. - -v2.2.1e -------- -1. Enhancement - Updated the iOS App tracking method to request the phone's location when the Next Update Time is reached in the same manner as the iCloud tracking methods. Using the tracking method 'iosapp' eliminates all iCloud account polling, relies only on the data provided by the ios app and provides a reliable tracking method when access to the iCloud accounts is limited due to 2fa authentication/verification issues. At night, the iOS App usually shuts down when the phone is asleep. When it becomes inactive, a message is displayed in the Event Log and on the iCloud3 information screen, the interval is increased until the phone wakes up and the iOS App becomes active. Polling will be resumed when the phone wakes up, the iOS App wakes up and begins responding to the location request. Note: If the iOS App is running in the background, it may not wake up and respond to the location requests. In that case, the iOS App must be restarted. Then, iCloud3 will detect the 'launch' trigger and begin polling the phone on a regular basis. -2. Updated - Changed the way zones are handled to better support overlapping zones, particularilly when you have two zones with a center at the same location with different sizes. For example, your Home zone with a radius of 100m and you have a second zone with the Home zone's location with a radius of 500m. The larger zone will help wake up the iOS App before you reach the Home zone. Another example is having a city size zone that will then display the city's name instead of Away. -3. New parameter - 'display_zone_name: True/False', Default: False. iCloud3 (and the iOS App) displays the zone's friendly name in the Zone (and device_tracker state) field on the Event Log and the iCloud3 information screen. If the friendly name is too long to be displayed, it will be truncated. With this parameter, the zone's name rather than it's friendly name, is displayed. -4. New parameter - 'time_format: 12/24', Default depends on the unit_of_measurement (mi=12, km=24). This parameter overrides the unit_of_measurement time format. - -v2.2.1c -------- -1. Bug fix - The iOS App reports the device_tracker state with the zone's friendly_name. iCloud3 was handling zones by the zone name. This caused problems handling zone change detection and, at times created a iOS App/iCloud3 state mismatch error. -2. Bug fix - If (1) the phone was turned off and not available when HA restarted or (2) became unreachable for an extended period of time while HA/iCloud3 was running (cell service down, turned off, airplane mode, etc.), the phone would be in a not_set state or the location would become older and older. This would hang up iCloud3 in a 5-second update loop, polling iCloud for location data when none was available. It will now retry the data request 4-times at a 15-second interval. The interval will increase to 1, 5, 15, 30, 1 hr and then the max_interval (4-hrs) and remain there until the phone comes back online. If the phone is then turned on, it will be picked up on the next successful location data request and returned and then tracked as normal. -3. Bug fix - When old location messages were added to the Event Log if the last located time is old and the phone is in a zone. -4. Bug fix - If the unit_of_measurement not being set to 'km' if the parameter was in the config_ic3.yaml file. It was being set correctly if it was in the HA configuration.file. -5. Bug fix - Fixed 'Lost Pnone' notification. It will now work with all tracking methods using the iOS App Notifications platform. -6. If the phone's name started with iPhone (or iPad), the first character of the sensor.xxx friendly_name attribute would be a '-'. The '-' was removed. Also removed the '-' between the phone's name and the attribute name to match the iOS App friendly_name formatting. -7. New configuration parameter - 'display_zone_name: True/False(default)' The 'device_tracker.[devicename]' entity displays the zone's friendly_ name, which is then displayed on the lovelace card. The name is truncated if it is longer than 10-12 letters. This option, if True, will display the zone name itself instead of the friendly_name. -8. Added some logger debug statements to 'pyicloud_ic3.py' to capture icloud request/response events that might help pinpoint what is happening when iCloud needs to do an account verification. -To turn this on, add the following to your configuration.yaml file: -logger: - default: info - logs: - custom_components.icloud3.pyicloud_ic3: debug - - -v2.2.1b -------- -1. Fixes a Region Entered trigger being discarded when the distance to the center of the zone is between the zone's radius and 100m when the radius is less than 100m. The iOS App issues the Region Enter trigger when the distance is less than 100m, regardless of the zone size. iCloud3 looks at the zone size and not the 100m prezone size like the iOS App. -2. A location will not be considered old if it's age is less than 1-minute to reduce the number of location upates that are discarded. -3. Updated sensor icons. - -v2.2.1a -------- -1. Display an Alert message in the Event Log if the config_ic3.yaml file is found in the icloud3 directory. -2. Restart iCloud3 to reinitialize location information when the iCloud account is verified with the 6-digit authentication code. Previously, iCloud3 was resuming and, in some cases, would not receive location information from iCloud Location Services on the next poll. -3. Fixed the iOS App monitor so it would show the iOS App gps accuracy rather than the last iCloud value. -4. Fixed a determining the polling interval if the last poll was from iCloud data and it was discarded due to poor gps accuracy and the next poll was from the iOS App with good gps accuracy. -5. Added the 'noiosapp' value to the track_devices parameter that indicates the devicename does not have the iOS App installed on it. -6. Added the results of the interval calculation to the Event Log Tracking Monitor display. -7. Changed Event Log > Actions > Request iOS App Location to 'Update Location' to issue an iOS App Locate Request if the iOS App is installed or an iCloud Location request if it is not installed. -''' -#Symbols = •▶¦▶ ●►◄ ▬ ▲▼◀▶ oPhone=►▶► - -import logging -import os -import sys -import shutil -import time -import datetime -import json -import voluptuous as vol - -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -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.components.device_tracker import ( - PLATFORM_SCHEMA, DOMAIN, ATTR_ATTRIBUTES) -#from homeassistant.components.device_tracker import DeviceScanner - -_LOGGER = logging.getLogger(__name__) - -#Changes in device_tracker entities are not supported in HA v0.94 and -#legacy code is being used for the DeviceScanner. Try to import from the -#.legacy directory and retry from the normal directory if the .legacy -#directory does not exist. -#try: -# from homeassistant.components.device_tracker.legacy import DeviceScanner -# HA_DEVICE_TRACKER_LEGACY_MODE = True -#except ImportError: -# from homeassistant.components.device_tracker import DeviceScanner - -HA_DEVICE_TRACKER_LEGACY_MODE = False - -#Vailidate that Waze is available and can be used -try: - import WazeRouteCalculator - WAZE_IMPORT_SUCCESSFUL = True -except ImportError: - WAZE_IMPORT_SUCCESSFUL = False - pass - -try: - from .pyicloud_ic3 import PyiCloudService - from .pyicloud_ic3 import ( - PyiCloudFailedLoginException, - PyiCloudNoDevicesException, - PyiCloudServiceNotActivatedException, - PyiCloudAPIResponseException, - PyiCloud2SARequiredException, - ) - PYICLOUD_IC3_IMPORT_SUCCESSFUL = True -except ImportError: - PYICLOUD_IC3_IMPORT_SUCCESSFUL = False - pass - -DEBUG_TRACE_CONTROL_FLAG = False - -HA_ENTITY_REGISTRY_FILE_NAME ='/config/.storage/core.entity_registry' -ENTITY_REGISTRY_FILE_KEY = 'core.entity_registry' -STORAGE_KEY_ICLOUD = 'icloud' -STORAGE_KEY_ENTITY_REGISTRY = 'core.entity_registry' -STORAGE_VERSION = 1 -STORAGE_DIR = ".storage" - -CONF_ACCOUNT_NAME = 'account_name' -CONF_GROUP = 'group' -CONF_DEVICENAME = 'device_name' -CONF_NAME = 'name' -CONF_TRACKING_METHOD = 'tracking_method' -CONF_IOSAPP_REQUEST_LOC_MAX_CNT = 'iosapp_request_loc_max_cnt' -CONF_TRACK_DEVICES = 'track_devices' -CONF_TRACK_DEVICE = 'track_device' -CONF_UNIT_OF_MEASUREMENT = 'unit_of_measurement' -CONF_TIME_FORMAT = 'time_format' -CONF_INTERVAL = 'interval' -CONF_BASE_ZONE = 'base_zone' -CONF_INZONE_INTERVAL = 'inzone_interval' -CONF_DISPLAY_ZONE_FNAME = 'display_zone_fname' -CONF_CENTER_IN_ZONE = 'center_in_zone' -CONF_STATIONARY_STILL_TIME = 'stationary_still_time' -CONF_STATIONARY_INZONE_INTERVAL = 'stationary_inzone_interval' -CONF_STATIONARY_ZONE_OFFSET = 'stationary_zone_offset' -CONF_MAX_INTERVAL = 'max_interval' -CONF_TRAVEL_TIME_FACTOR = 'travel_time_factor' -CONF_GPS_ACCURACY_THRESHOLD = 'gps_accuracy_threshold' -CONF_OLD_LOCATION_THRESHOLD = 'old_location_threshold' -CONF_IGNORE_GPS_ACC_INZONE = 'ignore_gps_accuracy_inzone' -CONF_HIDE_GPS_COORDINATES = 'hide_gps_coordinates' -CONF_WAZE_REGION = 'waze_region' -CONF_WAZE_MAX_DISTANCE = 'waze_max_distance' -CONF_WAZE_MIN_DISTANCE = 'waze_min_distance' -CONF_WAZE_REALTIME = 'waze_realtime' -CONF_DISTANCE_METHOD = 'distance_method' -CONF_COMMAND = 'command' -CONF_CREATE_SENSORS = 'create_sensors' -CONF_EXCLUDE_SENSORS = 'exclude_sensors' -CONF_ENTITY_REGISTRY_FILE = 'entity_registry_file_name' -CONF_LOG_LEVEL = 'log_level' -CONF_CONFIG_IC3_FILE_NAME = 'config_ic3_file_name' -CONF_LEGACY_MODE = 'legacy_mode' -CONF_EVENT_LOG_CARD_DIRECTORY = 'event_log_card_directory' -CONF_DEVICE_STATUS = 'device_status' -CONF_DISPLAY_TEXT_AS = 'display_text_as' - -# entity attributes (iCloud FmF & FamShr) -ATTR_ICLOUD_TIMESTAMP = 'timeStamp' -ATTR_ICLOUD_HORIZONTAL_ACCURACY = 'horizontalAccuracy' -ATTR_ICLOUD_VERTICAL_ACCURACY = 'verticalAccuracy' -ATTR_ICLOUD_BATTERY_STATUS = 'batteryStatus' -ATTR_ICLOUD_BATTERY_LEVEL = 'batteryLevel' -ATTR_ICLOUD_DEVICE_CLASS = 'deviceClass' -ATTR_ICLOUD_DEVICE_STATUS = 'deviceStatus' -ATTR_ICLOUD_LOW_POWER_MODE = 'lowPowerMode' - -# device data attributes -ATTR_LOCATION = 'location' -ATTR_ATTRIBUTES = 'attributes' -ATTR_RADIUS = 'radius' -ATTR_FRIENDLY_NAME = 'friendly_name' -ATTR_NAME = 'name' -ATTR_ISOLD = 'isOld' -ATTR_DEVICE_CLASS = 'device_class' - -# entity attributes -ATTR_ZONE = 'zone' -ATTR_ZONE_TIMESTAMP = 'zone_timestamp' -ATTR_LAST_ZONE = 'last_zone' -ATTR_GROUP = 'group' -ATTR_TIMESTAMP = 'timestamp' -ATTR_TIMESTAMP_TIME = 'timestamp_time' -ATTR_AGE = 'age' -ATTR_TRIGGER = 'trigger' -ATTR_BATTERY = 'battery' -ATTR_BATTERY_LEVEL = 'battery_level' -ATTR_BATTERY_STATUS = 'battery_status' -ATTR_INTERVAL = 'interval' -ATTR_ZONE_DISTANCE = 'zone_distance' -ATTR_CALC_DISTANCE = 'calc_distance' -ATTR_WAZE_DISTANCE = 'waze_distance' -ATTR_WAZE_TIME = 'travel_time' -ATTR_DIR_OF_TRAVEL = 'dir_of_travel' -ATTR_TRAVEL_DISTANCE = 'travel_distance' -ATTR_DEVICE_STATUS = 'device_status' -ATTR_LOW_POWER_MODE = 'low_power_mode' -ATTR_TRACKING = 'tracking' -ATTR_DEVICENAME_IOSAPP = 'iosapp_device' -ATTR_AUTHENTICATED = 'authenticated' -ATTR_LAST_UPDATE_TIME = 'last_update' -ATTR_NEXT_UPDATE_TIME = 'next_update' -ATTR_LAST_LOCATED = 'last_located' -ATTR_INFO = 'info' -ATTR_GPS_ACCURACY = 'gps_accuracy' -ATTR_GPS = 'gps' -ATTR_LATITUDE = 'latitude' -ATTR_LONGITUDE = 'longitude' -ATTR_POLL_COUNT = 'poll_count' -ATTR_ICLOUD3_VERSION = 'icloud3_version' -ATTR_VERT_ACCURACY = 'vertical_accuracy' -ATTR_ALTITUDE = 'altitude' -ATTR_BADGE = 'badge' -ATTR_EVENT_LOG = 'event_log' -ATTR_PICTURE = 'entity_picture' - -#General constants -HOME = 'home' -NOT_HOME = 'not_home' -NOT_SET = 'not_set' -STATIONARY = 'stationary' -AWAY_FROM = 'AwayFrom' -AWAY = 'Away' -PAUSED = 'Paused' -PAUSED_CAPS = 'PAUSED' -UNKNOWN = 'Unknown' -NEVER = 'Never' -ERROR = 'Error' -TIMESTAMP_ZERO = '0000-00-00 00:00:00' -HHMMSS_ZERO = '00:00:00' -HIGH_INTEGER = 9999999999 -UTC_TIME = True -LOCAL_TIME = False -NUMERIC = True -NEW_LINE = '\n' - -SENSOR_EVENT_LOG_ENTITY = 'sensor.icloud3_event_log' - -DEVICE_ATTRS_BASE = { - ATTR_LATITUDE: 0, - ATTR_LONGITUDE: 0, - ATTR_BATTERY: 0, - ATTR_BATTERY_LEVEL: 0, - ATTR_BATTERY_STATUS: '', - ATTR_GPS_ACCURACY: 0, - ATTR_VERT_ACCURACY: 0, - ATTR_TIMESTAMP: TIMESTAMP_ZERO, - ATTR_ICLOUD_TIMESTAMP: HHMMSS_ZERO, - ATTR_TRIGGER: '', - ATTR_DEVICE_STATUS: '', - ATTR_LOW_POWER_MODE: '', - } - -INITIAL_LOCATION_DATA = { - ATTR_NAME: '', - ATTR_DEVICE_CLASS: 'iPhone', - ATTR_BATTERY_LEVEL: 0, - ATTR_BATTERY_STATUS: UNKNOWN, - ATTR_DEVICE_STATUS: UNKNOWN, - ATTR_LOW_POWER_MODE: False, - ATTR_TIMESTAMP: 0, - ATTR_TIMESTAMP_TIME: HHMMSS_ZERO, - ATTR_AGE: HIGH_INTEGER, - ATTR_LATITUDE: 0.0, - ATTR_LONGITUDE: 0.0, - ATTR_ALTITUDE: 0.0, - ATTR_ISOLD: False, - ATTR_GPS_ACCURACY: 0, - ATTR_VERT_ACCURACY: 0, - } - -TRACE_ATTRS_BASE = { - ATTR_NAME: '', - ATTR_ZONE: '', - ATTR_LAST_ZONE: '', - ATTR_ZONE_TIMESTAMP: '', - ATTR_LATITUDE: 0, - ATTR_LONGITUDE: 0, - ATTR_TRIGGER: '', - ATTR_TIMESTAMP: TIMESTAMP_ZERO, - ATTR_ZONE_DISTANCE: 0, - ATTR_INTERVAL: 0, - ATTR_DIR_OF_TRAVEL: '', - ATTR_TRAVEL_DISTANCE: 0, - ATTR_WAZE_DISTANCE: '', - ATTR_CALC_DISTANCE: 0, - ATTR_LAST_LOCATED: '', - ATTR_LAST_UPDATE_TIME: '', - ATTR_NEXT_UPDATE_TIME: '', - ATTR_POLL_COUNT: '', - ATTR_INFO: '', - ATTR_BATTERY: 0, - ATTR_BATTERY_LEVEL: 0, - ATTR_GPS: 0, - ATTR_GPS_ACCURACY: 0, - ATTR_VERT_ACCURACY: 0, - } - -TRACE_ICLOUD_ATTRS_BASE = { - CONF_NAME: '', - ATTR_ICLOUD_DEVICE_STATUS: '', - ATTR_ISOLD: False, - ATTR_LATITUDE: 0, - ATTR_LONGITUDE: 0, - ATTR_ICLOUD_TIMESTAMP: 0, - ATTR_ICLOUD_HORIZONTAL_ACCURACY: 0, - ATTR_ICLOUD_VERTICAL_ACCURACY: 0, - 'positionType': 'Wifi', - } - -SENSOR_DEVICE_ATTRS = [ - 'zone', - 'zone_name1', - 'zone_name2', - 'zone_name3', - 'last_zone', - 'last_zone_name1', - 'last_zone_name2', - 'last_zone_name3', - 'zone_timestamp', - 'base_zone', - 'zone_distance', - 'calc_distance', - 'waze_distance', - 'travel_time', - 'dir_of_travel', - 'interval', - 'info', - 'last_located', - 'last_update', - 'next_update', - 'poll_count', - 'travel_distance', - 'trigger', - 'battery', - 'battery_status', - 'gps_accuracy', - 'vertical accuracy', - 'badge', - 'name', - ] - -SENSOR_ATTR_FORMAT = { - 'zone_distance': 'dist', - 'calc_distance': 'dist', - 'waze_distance': 'diststr', - 'travel_distance': 'dist', - 'battery': '%', - 'dir_of_travel': 'title', - 'altitude': 'm-ft', - 'badge': 'badge', - } - -#---- iPhone Device Tracker Attribute Templates ----- -SENSOR_ATTR_FNAME = { - 'zone': 'Zone', - 'zone_name1': 'Zone1', - 'zone_name2': 'Zone2', - 'zone_name3': 'Zone3', - 'last_zone': 'Last Zone', - 'last_zone_name1': 'Last Zone1', - 'last_zone_name2': 'Last Zone2', - 'last_zone_name3': 'Last Zone3', - 'zone_timestamp': 'Zone Timestamp', - 'base_zone': 'Base Zone', - 'zone_distance': 'Zone Distance', - 'calc_distance': 'Calc Dist', - 'waze_distance': 'Waze Dist', - 'travel_time': 'Travel Time', - 'dir_of_travel': 'Direction', - 'interval': 'Interval', - 'info': 'Info', - 'last_located': 'Last Located', - 'last_update': 'Last Update', - 'next_update': 'Next Update', - 'poll_count': 'Poll Count', - 'travel_distance': 'Travel Dist', - 'trigger': 'Trigger', - 'battery': 'Battery', - 'battery_status': 'Battery Status', - 'gps_accuracy': 'GPS Accuracy', - 'vertical_accuracy': 'Vertical Accuracy', - 'badge': 'Badge', - 'name': 'Name', - } - -SENSOR_ATTR_ICON = { - 'zone': 'mdi:cellphone-iphone', - 'last_zone': 'mdi:map-clock-outline', - 'base_zone': 'mdi:map-clock', - 'zone_timestamp': 'mdi:clock-in', - 'zone_distance': 'mdi:map-marker-distance', - 'calc_distance': 'mdi:map-marker-distance', - 'waze_distance': 'mdi:map-marker-distance', - 'travel_time': 'mdi:clock-outline', - 'dir_of_travel': 'mdi:compass-outline', - 'interval': 'mdi:clock-start', - 'info': 'mdi:information-outline', - 'last_located': 'mdi:history', - 'last_update': 'mdi:history', - 'next_update': 'mdi:update', - 'poll_count': 'mdi:counter', - 'travel_distance': 'mdi:map-marker-distance', - 'trigger': 'mdi:flash-outline', - 'battery': 'mdi:battery-outline', - 'battery_status': 'mdi:battery-outline', - 'gps_accuracy': 'mdi:map-marker-radius', - 'altitude': 'mdi:image-filter-hdr', - 'vertical_accuracy': 'mdi:map-marker-radius', - 'badge': 'mdi:shield-account', - 'name': 'mdi:account', - 'entity_log': 'mdi:format-list-checkbox', - } - -SENSOR_ID_NAME_LIST = { - 'zon': 'zone', - 'zon1': 'zone_name1', - 'zon2': 'zone_name2', - 'zon3': 'zone_name3', - 'bzon': 'base_zone', - 'lzon': 'last_zone', - 'lzon1': 'last_zone_name1', - 'lzon2': 'last_zone_name2', - 'lzon3': 'last_zone_name3', - 'zonts': 'zone_timestamp', - 'zdis': 'zone_distance', - 'cdis': 'calc_distance', - 'wdis': 'waze_distance', - 'tdis': 'travel_distance', - 'ttim': 'travel_time', - 'dir': 'dir_of_travel', - 'intvl': 'interval', - 'lloc': 'last_located', - 'lupdt': 'last_update', - 'nupdt': 'next_update', - 'cnt': 'poll_count', - 'info': 'info', - 'trig': 'trigger', - 'bat': 'battery', - 'batstat': 'battery_status', - 'alt': 'altitude', - 'gpsacc': 'gps_accuracy', - 'vacc': 'vertical_accuracy', - 'badge': 'badge', - 'name': 'name', - } - -ATTR_TIMESTAMP_FORMAT = '%Y-%m-%d %H:%M:%S.%f' -APPLE_DEVICE_TYPES = ['iphone', 'ipad', 'ipod', 'watch', 'iwatch', 'icloud', - 'iPhone', 'iPad', 'iPod', 'Watch', 'iWatch', 'iCloud'] -FMF_FAMSHR_LOCATION_FIELDS = ['altitude', 'latitude', 'longitude', 'timestamp', - 'horizontalAccuracy', 'verticalAccuracy', ATTR_ICLOUD_BATTERY_STATUS] - -#Convert zone/state value to display value -ZONE_TO_DISPLAY_BASE = { - None: "not_home", - "not_home": "Away", - "not_set": "NotSet", - "away": "Away", - "stationary": "Stationary", - "nearzone": "NearZone", - "near_zone": "NearZone", - } -#Convert state non-fname value to internal zone/state value -STATE_TO_ZONE_BASE = { - "NotSet": "not_set", - "Away": "not_home", - "away": "not_home", - "NotHome": "not_home", - "nothome": "not_home", - "Stationary": "stationary", - "stationary": "stationary", - "NearZone": "nearzone", - "NearZone": "near_zone", - } - -#icloud_update commands -CMD_ERROR = 1 -CMD_INTERVAL = 2 -CMD_PAUSE = 3 -CMD_RESUME = 4 -CMD_WAZE = 5 -CMD_LOCATION = 6 - -#Other constants -IOSAPP_DT_ENTITY = True -ICLOUD_DT_ENTITY = False -ICLOUD_LOCATION_DATA_ERROR = False - -STATIONARY_LAT_90 = 90 -STATIONARY_LONG_180 = 180 -STAT_ZONE_NEW_LOCATION = True -STAT_ZONE_MOVE_TO_BASE = False -STATIONARY_ZONE_1KM_LAT = 0.008983 #Subtract/add from home zone latitude to make stat zone location -STATIONARY_ZONE_1KM_LONG= 0.010094 -EVLOG_RECDS_PER_DEVICE = 1000 #Used to calculate the max recds to store -EVENT_LOG_CLEAR_SECS = 600 #Clear event log data interval -EVENT_LOG_CLEAR_CNT = 15 #Number of recds to display when clearing event log -ICLOUD3_ERROR_MSG = "ICLOUD3 ERROR-SEE EVENT LOG" - -#Devicename config parameter file extraction -DI_DEVICENAME = 0 -DI_DEVICE_TYPE = 1 -DI_NAME = 2 -DI_EMAIL = 3 -DI_BADGE_PICTURE = 4 -DI_IOSAPP_ENTITY = 5 -DI_IOSAPP_SUFFIX = 6 -DI_ZONES = 7 - -#Waze status codes -WAZE_REGIONS = ['US', 'NA', 'EU', 'IL', 'AU'] -WAZE_USED = 0 -WAZE_NOT_USED = 1 -WAZE_PAUSED = 2 -WAZE_OUT_OF_RANGE = 3 -WAZE_NO_DATA = 4 - -#Interval range table used for setting the interval based on a retry count -#The key is starting retry count range, the value is the interval (in minutes) -#poor_location_gps cnt, icloud_authentication cnt (default) -POOR_LOC_GPS_CNT = 1.1 -AUTH_ERROR_CNT = 1.2 -RETRY_INTERVAL_RANGE_1 = {0:.25, 4:1, 8:5, 12:30, 16:60, 20:120, 24:240} -#request iosapp location retry cnt -IOSAPP_REQUEST_LOC_CNT = 2.1 -RETRY_INTERVAL_RANGE_2 = {0:.5, 4:2, 8:30, 12:60, 14:120, 16:240, 18:480} - -#Used by the 'update_method' in the polling_5_sec loop -IOSAPP_UPDATE = "IOSAPP" -ICLOUD_UPDATE = "ICLOUD" - -#The event_log lovelace card will display the event in a special color if -#the text starts with a special character: -# $ - SeaGreen * - Purple -# $$ - DodgerBlue ** - BlueViolet -# $$$ - Blue *** - OrangeRed -EVLOG_DEBUG = "$$" -EVLOG_ALERT = "***" -EVLOG_NOTICE = "**" - -#tracking_method config parameter being used -FMF = 'fmf' #Find My Friends -FAMSHR = 'famshr' #icloud Family-Sharing -IOSAPP = 'iosapp' #HA IOS App v1.5x or v2.x -IOSAPP1 = 'iosapp1' #HA IOS App v1.5x only -FMF_FAMSHR = [FMF, FAMSHR] -IOSAPP_IOSAPP1 = [IOSAPP, IOSAPP1] - -TRK_METHOD_NAME = { - FMF: 'Find My Friends', - FAMSHR: 'Family Sharing', - IOSAPP: 'IOS App', - IOSAPP1: 'IOS App v1', - } -TRK_METHOD_SHORT_NAME = { - FMF: 'FmF', - FAMSHR: 'FamShr', - IOSAPP: 'IOSApp', - IOSAPP1: 'IOSApp1', - } -DEVICE_TYPE_FNAME = { - 'iphone': 'iPhone', - 'phone': 'iPhone', - 'ipad': 'iPad', - 'iwatch': 'iWatch', - 'watch': 'iWatch', - 'ipod': 'iPod', - } - -#iOS App Triggers defined in \iOS/Shared/Location/LocatioTrigger.swift -BACKGROUND_FETCH = 'Background Fetch' -BKGND_FETCH = 'Bkgnd Fetch' -GEOGRAPHIC_REGION_ENTERED = 'Geographic Region Entered' -GEOGRAPHIC_REGION_EXITED = 'Geographic Region Exited' -IBEACON_REGION_ENTERED = 'iBeacon Region Entered' -IBEACON_REGION_EXITED = 'iBeacon Region Exited' -REGION_ENTERED = 'Region Entered' -REGION_EXITED = 'Region Exited' -INITIAL = 'Initial' -MANUAL = 'Manual' -LAUNCH = "Launch", -SIGNIFICANT_LOC_CHANGE = 'Significant Location Change' -SIGNIFICANT_LOC_UPDATE = 'Significant Location Update' -SIG_LOC_CHANGE = 'Sig Loc Change' -PUSH_NOTIFICATION = 'Push Notification' -REQUEST_IOSAPP_LOC = 'Request iOSApp Loc' -IOSAPP_LOC_CHANGE = "iOSApp Loc Change" - -#Trigger is converted to abbreviation after getting last_update_trigger -IOS_TRIGGER_ABBREVIATIONS = { - GEOGRAPHIC_REGION_ENTERED: REGION_ENTERED, - GEOGRAPHIC_REGION_EXITED: REGION_EXITED, - IBEACON_REGION_ENTERED: REGION_ENTERED, - IBEACON_REGION_EXITED: REGION_EXITED, - SIGNIFICANT_LOC_CHANGE: SIG_LOC_CHANGE, - SIGNIFICANT_LOC_UPDATE: SIG_LOC_CHANGE, - PUSH_NOTIFICATION: REQUEST_IOSAPP_LOC, - BACKGROUND_FETCH: BKGND_FETCH, - } -IOS_TRIGGERS_VERIFY_LOCATION = [ - INITIAL, - LAUNCH, - MANUAL, - IOSAPP_LOC_CHANGE, - BKGND_FETCH, - SIG_LOC_CHANGE, - REQUEST_IOSAPP_LOC, - ] -IOS_TRIGGERS_ENTER = [ - REGION_ENTERED, - 'Test Entered', - ] -IOS_TRIGGERS_EXIT = [ - REGION_EXITED, - 'Test Exited' - ] -IOS_TRIGGERS_ENTER_EXIT = [ - REGION_ENTERED, - REGION_EXITED, - ] - -#Lists to hold the group names, group objects and iCloud device configuration -#The ICLOUD3_GROUPS is filled in on each platform load, the GROUP_OBJS is -#filled in after the polling timer is setup. -ICLOUD3_GROUPS = [] -ICLOUD3_GROUP_OBJS = {} -ICLOUD3_TRACKED_DEVICES = {} -''' -DEVICE_STATUS_SET = [ - 'deviceModel', 'rawDeviceModel', 'deviceStatus', - 'batteryStatus', 'batteryLevel', 'id', 'lowPowerMode', - 'deviceDisplayName', 'name', 'fmlyShare', - 'location', - 'locationCapable', 'locationEnabled', 'isLocating', - 'remoteLock', 'activationLocked', 'lockedTimestamp', - 'lostModeCapable', 'lostModeEnabled', 'locFoundEnabled', - 'lostDevice', 'lostTimestamp', - 'remoteWipe', 'wipeInProgress', 'wipedTimestamp', - 'isMac'] -''' -#Default values are ["batteryLevel", "deviceDisplayName", "deviceStatus", "name"] -DEVICE_STATUS_SET = [ - ATTR_ICLOUD_DEVICE_CLASS, - ATTR_ICLOUD_BATTERY_STATUS, - ATTR_ICLOUD_LOW_POWER_MODE, - ATTR_LOCATION - ] -DEVICE_STATUS_CODES = { - '200': 'online', - '201': 'offline', - '203': 'pending', - '204': 'unregistered', - '0': '' - } -SERVICE_SCHEMA = vol.Schema({ - vol.Optional(CONF_GROUP): cv.slugify, - vol.Optional(CONF_DEVICENAME): cv.slugify, - vol.Optional(CONF_INTERVAL): cv.slugify, - vol.Optional(CONF_COMMAND): cv.string - }) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Optional(CONF_PASSWORD, default=''): cv.string, - vol.Optional(CONF_GROUP, default='group'): cv.slugify, - vol.Optional(CONF_TRACKING_METHOD, default=FMF): cv.slugify, - vol.Optional(CONF_IOSAPP_REQUEST_LOC_MAX_CNT, default=HIGH_INTEGER): cv.string, - vol.Optional(CONF_ENTITY_REGISTRY_FILE): cv.string, - vol.Optional(CONF_CONFIG_IC3_FILE_NAME, default=''): cv.string, - vol.Optional(CONF_EVENT_LOG_CARD_DIRECTORY, default='www/custom_cards'): cv.string, - vol.Optional(CONF_LEGACY_MODE, default=False): cv.boolean, - vol.Optional(CONF_DISPLAY_TEXT_AS, default=[]): vol.All(cv.ensure_list, [cv.string]), - - #-----►►General Attributes ---------- - vol.Optional(CONF_UNIT_OF_MEASUREMENT, default='mi'): cv.slugify, - vol.Optional(CONF_TIME_FORMAT, default=0): cv.string, - vol.Optional(CONF_INZONE_INTERVAL, default='2 hrs'): cv.string, - vol.Optional(CONF_DISPLAY_ZONE_FNAME, default=False): cv.boolean, - vol.Optional(CONF_CENTER_IN_ZONE, default=False): cv.boolean, - vol.Optional(CONF_MAX_INTERVAL, default='4 hrs'): cv.string, - vol.Optional(CONF_TRAVEL_TIME_FACTOR, default=.60): cv.string, - vol.Optional(CONF_GPS_ACCURACY_THRESHOLD, default=100): cv.string, - vol.Optional(CONF_OLD_LOCATION_THRESHOLD, default='-1 min'): cv.string, - vol.Optional(CONF_IGNORE_GPS_ACC_INZONE, default=True): cv.boolean, - vol.Optional(CONF_HIDE_GPS_COORDINATES, default=False): cv.boolean, - vol.Optional(CONF_LOG_LEVEL, default=''): cv.string, - vol.Optional(CONF_DEVICE_STATUS, default='online, pending'): cv.string, - - #-----►►Filter, Include, Exclude Devices ---------- - vol.Optional(CONF_TRACK_DEVICES, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_TRACK_DEVICE, default=[]): vol.All(cv.ensure_list, [cv.string]), - - #-----►►Waze Attributes ---------- - vol.Optional(CONF_DISTANCE_METHOD, default='waze'): cv.string, - vol.Optional(CONF_WAZE_REGION, default='US'): cv.string, - vol.Optional(CONF_WAZE_MAX_DISTANCE, default=1000): cv.string, - vol.Optional(CONF_WAZE_MIN_DISTANCE, default=1): cv.string, - vol.Optional(CONF_WAZE_REALTIME, default=False): cv.boolean, - - #-----►►Other Attributes ---------- - vol.Optional(CONF_STATIONARY_INZONE_INTERVAL, default='30 min'): cv.string, - vol.Optional(CONF_STATIONARY_STILL_TIME, default='8 min'): cv.string, - vol.Optional(CONF_STATIONARY_ZONE_OFFSET, default='1,0'): cv.string, - vol.Optional(CONF_CREATE_SENSORS, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_EXCLUDE_SENSORS, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_COMMAND): cv.string, - }) - -DEFAULT_CONFIG_VALUES = { - CONF_UNIT_OF_MEASUREMENT: 'mi', - CONF_TIME_FORMAT: 0, - CONF_INZONE_INTERVAL: '2 hrs', - CONF_DISPLAY_ZONE_FNAME: False, - CONF_CENTER_IN_ZONE: False, - CONF_MAX_INTERVAL: '4 hrs', - CONF_TRAVEL_TIME_FACTOR: .60, - CONF_GPS_ACCURACY_THRESHOLD: 100, - CONF_OLD_LOCATION_THRESHOLD: '-1 min', - CONF_IGNORE_GPS_ACC_INZONE: True, - CONF_HIDE_GPS_COORDINATES: False, - CONF_LOG_LEVEL: '', - CONF_DEVICE_STATUS: 'online, pending', - CONF_TRACK_DEVICES: [], - CONF_TRACK_DEVICE: [], - CONF_DISTANCE_METHOD: 'waze', - CONF_LEGACY_MODE: False, - CONF_WAZE_REGION: 'US', - CONF_WAZE_MAX_DISTANCE: 1000, - CONF_WAZE_MIN_DISTANCE: 1, - CONF_WAZE_REALTIME: False, - CONF_STATIONARY_INZONE_INTERVAL: '30 min', - CONF_STATIONARY_STILL_TIME: '8 min', - CONF_STATIONARY_ZONE_OFFSET: '1, 0', - CONF_EVENT_LOG_CARD_DIRECTORY: 'www/custom_cards', - CONF_DISPLAY_TEXT_AS: [], - } - -#============================================================================== -# -# SYSTEM LEVEL FUNCTIONS -# -#============================================================================== -def _combine_lists(parm_lists): - ''' - Take a list of lists and return a single list of all of the items. - [['a,b,c'],['d,e,f']] --> ['a','b','c','d','e','f'] - ''' - new_list = [] - for lists in parm_lists: - lists_items = lists.split(',') - for lists_item in lists_items: - new_list.append(lists_item) - - return new_list - -#-------------------------------------------------------------------- -def _test(parm1, parm2): - return f"{parm1}-{parm2}" - -#-------------------------------------------------------------------- -def TRACE(desc, v1='+++', v2='', v3='', v4='', v5=''): - ''' - Display a message or variable in the HA log file - ''' - if desc != '': - if v1 == '+++': - value_str = f"►TRACE►► {desc}" - else: - value_str = (f"►TRACE►► {desc} = |{v1}|-|{v2}|-" - f"|{v3}|-|{v4}|-|{v5}|") - _LOGGER.info(value_str) - -#-------------------------------------------------------------------- -def instr(string, find_string): - if find_string is None: - return False - else: - return str(string).find(find_string) >= 0 - -#-------------------------------------------------------------------- -def isnumber(string): - - try: - test_number = float(string) - - return True - except: - return False - -#-------------------------------------------------------------------- -def inlist(string, list_items): - for item in list_items: - if str(string).find(item) >= 0: - return True - - return False - -#-------------------------------------------------------------------- -def format_gps(latitude, longitude, accuracy, latitude_to=None, longitude_to=None): - '''Format the GPS string for logs & messages''' - - accuracy_text = (f"/{accuracy}m)") if accuracy > 0 else "" - gps_to_text = (f" to ({round(latitude_to, 6)}, {round(longitude_to, 6)})") if latitude_to else "" - gps_text = f"({round(latitude, 6)}, {round(longitude, 6)}){accuracy_text}{gps_to_text}" - return gps_text -#============================================================================== -# -# CREATE THE ICLOUD3 DEVICE TRACKER PLATFORM -# -#============================================================================== -def setup_scanner(hass, config: dict, see, discovery_info=None): - """Set up the iCloud3 Device Tracker""" - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - group = config.get(CONF_GROUP) - base_zone = config.get(CONF_BASE_ZONE) - tracking_method = config.get(CONF_TRACKING_METHOD) - track_devices = config.get(CONF_TRACK_DEVICES) - track_devices.extend(config.get(CONF_TRACK_DEVICE)) - log_level = config.get(CONF_LOG_LEVEL) - device_status = config.get(CONF_DEVICE_STATUS) - entity_registry_file = config.get(CONF_ENTITY_REGISTRY_FILE) - config_ic3_file_name = config.get(CONF_CONFIG_IC3_FILE_NAME) - event_log_card_directory = config.get(CONF_EVENT_LOG_CARD_DIRECTORY) - iosapp_request_location_max_cnt = int(config.get(CONF_IOSAPP_REQUEST_LOC_MAX_CNT)) - legacy_mode = config.get(CONF_LEGACY_MODE) - display_text_as = config.get(CONF_DISPLAY_TEXT_AS) - - #make sure the same group is not specified in more than one platform. If so, - #append with a number - if group in ICLOUD3_GROUPS or group == 'group': - group = f"{group}{len(ICLOUD3_GROUPS)+1}" - ICLOUD3_GROUPS.append(group) - ICLOUD3_TRACKED_DEVICES[group] = track_devices - - #Changes in device_tracker entities are not supported in HA v0.94 and - #legacy code is being used for the DeviceScanner. Try to import from the - #.legacy directory and retry from the normal directory if the .legacy - #directory does not exist. - #try: - # if legacy_mode: - # from homeassistant.components.device_tracker.legacy import DeviceScanner - # else: - # from homeassistant.components.device_tracker import DeviceScanner - - # HA_DEVICE_TRACKER_LEGACY_MODE = legacy_mode - - #except ImportError: - # from homeassistant.components.device_tracker import DeviceScanner - # HA_DEVICE_TRACKER_LEGACY_MODE = False - - log_msg =(f"Setting up iCloud3 v{VERSION} device tracker for User: {username}, " - f"Group: {group}") - if HA_DEVICE_TRACKER_LEGACY_MODE: - log_msg = (f"{log_msg}, using device_tracker.legacy code") - _LOGGER.info(log_msg) - - inzone_interval_str = config.get(CONF_INZONE_INTERVAL) - max_interval_str = config.get(CONF_MAX_INTERVAL) - display_zone_fname = config.get(CONF_DISPLAY_ZONE_FNAME) - center_in_zone_flag = config.get(CONF_CENTER_IN_ZONE) - gps_accuracy_threshold = config.get(CONF_GPS_ACCURACY_THRESHOLD) - old_location_threshold_str = config.get(CONF_OLD_LOCATION_THRESHOLD) - ignore_gps_accuracy_inzone_flag = config.get(CONF_IGNORE_GPS_ACC_INZONE) - hide_gps_coordinates = config.get(CONF_HIDE_GPS_COORDINATES) - unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) - time_format = config.get(CONF_TIME_FORMAT) - - stationary_inzone_interval_str = config.get(CONF_STATIONARY_INZONE_INTERVAL) - stationary_still_time_str = config.get(CONF_STATIONARY_STILL_TIME) - stationary_zone_offset = config.get(CONF_STATIONARY_ZONE_OFFSET) - - sensor_ids = _combine_lists(config.get(CONF_CREATE_SENSORS)) - exclude_sensor_ids = _combine_lists(config.get(CONF_EXCLUDE_SENSORS)) - - travel_time_factor = config.get(CONF_TRAVEL_TIME_FACTOR) - waze_realtime = config.get(CONF_WAZE_REALTIME) - distance_method = config.get(CONF_DISTANCE_METHOD).lower() - waze_region = config.get(CONF_WAZE_REGION) - waze_region = waze_region.upper() - waze_max_distance = config.get(CONF_WAZE_MAX_DISTANCE) - waze_min_distance = config.get(CONF_WAZE_MIN_DISTANCE) - - if waze_region not in WAZE_REGIONS: - log_msg = (f"Invalid Waze Region ({waze_region}). Valid Values are: " - "NA=US or North America, EU=Europe, IL=Isreal, AU=Australia") - _LOGGER.error(log_msg) - - waze_region = 'US' - waze_max_distance = 0 - waze_min_distance = 0 - -#--------------------------------------------- - ICLOUD3_GROUP_OBJS[group] = Icloud3( - hass, see, username, password, group, base_zone, - tracking_method, track_devices, - iosapp_request_location_max_cnt, inzone_interval_str, - max_interval_str, display_zone_fname, center_in_zone_flag, - gps_accuracy_threshold, old_location_threshold_str, - stationary_inzone_interval_str, stationary_still_time_str, - stationary_zone_offset, - ignore_gps_accuracy_inzone_flag, hide_gps_coordinates, - sensor_ids, exclude_sensor_ids, - unit_of_measurement, time_format, - travel_time_factor, distance_method, - waze_region, waze_realtime, waze_max_distance, waze_min_distance, - log_level, device_status, display_text_as, - entity_registry_file, config_ic3_file_name, event_log_card_directory - ) - - -#-------------------------------------------------------------------- - - def _service_callback_update_icloud(call): - """Call the update function of an iCloud group.""" - - groups = call.data.get(CONF_GROUP, ICLOUD3_GROUP_OBJS) - devicename = call.data.get(CONF_DEVICENAME) - command = call.data.get(CONF_COMMAND) - - for group in groups: - if group in ICLOUD3_GROUP_OBJS: - ICLOUD3_GROUP_OBJS[group]._service_handler_icloud_update( - group, devicename, command) - - hass.services.register(DOMAIN, 'icloud3_update', - _service_callback_update_icloud, schema=SERVICE_SCHEMA) - - -#-------------------------------------------------------------------- - def _service_callback__start_icloud3(call): - """Reset an iCloud group.""" - - groups = call.data.get(CONF_GROUP, ICLOUD3_GROUP_OBJS) - for group in groups: - if group in ICLOUD3_GROUP_OBJS: - ICLOUD3_GROUP_OBJS[group]._start_icloud3() - - hass.services.register(DOMAIN, 'icloud3_restart', - _service_callback__start_icloud3, schema=SERVICE_SCHEMA) - -#-------------------------------------------------------------------- - def _service_callback_setinterval(call): - """Call the setinterval function of an iCloud group.""" - - groups = call.data.get(CONF_GROUP, ICLOUD3_GROUP_OBJS) - interval = call.data.get(CONF_INTERVAL) - devicename = call.data.get(CONF_DEVICENAME) - - for group in groups: - if group in ICLOUD3_GROUP_OBJS: - ICLOUD3_GROUP_OBJS[group]._service_handler_icloud_setinterval( - group, interval, devicename) - - hass.services.register(DOMAIN, 'icloud3_set_interval', - _service_callback_setinterval, schema=SERVICE_SCHEMA) - -#-------------------------------------------------------------------- - def _service_callback_lost_iphone(call): - """Call the lost iPhone function if the device is found.""" - - groups = call.data.get(CONF_GROUP, ICLOUD3_GROUP_OBJS) - devicename = call.data.get(CONF_DEVICENAME) - - for group in groups: - if group in ICLOUD3_GROUP_OBJS: - ICLOUD3_GROUP_OBJS[group]._service_handler_lost_iphone( - group, devicename) - - hass.services.register(DOMAIN, 'icloud3_lost_iphone', - _service_callback_lost_iphone, schema=SERVICE_SCHEMA) - - # Tells the bootstrapper that the component was successfully initialized - return True - - -#==================================================================== -class Icloud3:#(DeviceScanner): - """Representation of an iCloud3 platform""" - - def __init__(self, - hass, see, username, password, group, base_zone, - tracking_method, track_devices, - iosapp_request_location_max_cnt, inzone_interval_str, - max_interval_str, display_zone_fname, center_in_zone_flag, - gps_accuracy_threshold, old_location_threshold_str, - stationary_inzone_interval_str, stationary_still_time_str, - stationary_zone_offset, - ignore_gps_accuracy_inzone_flag, hide_gps_coordinates, - sensor_ids, exclude_sensor_ids, - unit_of_measurement, time_format, - travel_time_factor, distance_method, - waze_region, waze_realtime, waze_max_distance, waze_min_distance, - log_level, device_status, display_text_as, - entity_registry_file, config_ic3_file_name, event_log_card_directory - ): - - - """Initialize the iCloud3 device tracker.""" - self.hass_configurator_request_id = {} - - self.hass = hass - self.see = see - self.username = username - self.username_base = username.split('@')[0] - self.password = password - - self.polling_5_sec_loop_running = False - self.api = None - self.entity_registry_file = entity_registry_file - self.config_ic3_file_name = config_ic3_file_name - self.event_log_card_directory = event_log_card_directory - self.group = group - self.base_zone = HOME - self.verification_inprocess_flag = False - self.verification_code = None - self.trusted_device = None - self.trusted_device_id = None - self.trusted_devices = None - self.tracking_method_config = tracking_method - - self.iosapp_request_loc_max_cnt= iosapp_request_location_max_cnt - self.start_icloud3_request_flag = False - self.start_icloud3_inprocess_flag = False - self.authenticated_time = 0 - self.log_level = log_level - self.log_level_eventlog_flag = False - self.device_status_online = list(device_status.replace(" ","").split(",")) - self.device_status_online.append('') - - self.attributes_initialized_flag = False - self.track_devices = track_devices - self.distance_method_waze_flag = (distance_method.lower() == 'waze') - self.inzone_interval_secs = self._time_str_to_secs(inzone_interval_str) - self.max_interval_secs = self._time_str_to_secs(max_interval_str) - self.display_zone_fname_flag = display_zone_fname - self.center_in_zone_flag = center_in_zone_flag - self.gps_accuracy_threshold = int(gps_accuracy_threshold) - self.old_location_threshold = self._time_str_to_secs(old_location_threshold_str) - self.ignore_gps_accuracy_inzone_flag = ignore_gps_accuracy_inzone_flag - self.check_gps_accuracy_inzone_flag = not self.ignore_gps_accuracy_inzone_flag - self.hide_gps_coordinates = hide_gps_coordinates - self.sensor_ids = sensor_ids - self.exclude_sensor_ids = exclude_sensor_ids - self.unit_of_measurement = unit_of_measurement - self.time_format = int(time_format) - self.travel_time_factor = float(travel_time_factor) - self.e_seconds_local_offset_secs = 0 - self.waze_region = waze_region - self.waze_min_distance = waze_min_distance - self.waze_max_distance = waze_max_distance - self.waze_realtime = waze_realtime - self.stationary_inzone_interval_str = stationary_inzone_interval_str - self.stationary_still_time_str = stationary_still_time_str - self.stationary_zone_offset = stationary_zone_offset - - self.display_text_as = display_text_as - self.display_text_as_list = {} - - - #define & initialize fields to carry across icloud3 restarts - self._define_event_log_fields() - self._define_usage_counters() - self._define_iosapp_message_fields() - - #add HA event that will call the _polling_loop_5_sec_icloud function - #on a 5-second interval. The interval is offset by 1-second for each - #group to avoid update conflicts. - self.start_icloud3_initial_load_flag = True - self._start_icloud3() - - self.start_icloud3_initial_load_flag = False -#-------------------------------------------------------------------- - def _start_icloud3(self): - """ - Start iCloud3, Define all variables & tables, Initialize devices - """ - - #check to see if restart is in process - if self.start_icloud3_inprocess_flag: - return - - try: - self.start_timer = time.time() - self._initialize_debug_control(self.log_level) - - self.start_icloud3_inprocess_flag = True - self.start_icloud3_request_flag = False - self.initial_locate_complete_flag = False - self.startup_log_msgs = '' - self.startup_log_msgs_prefix = '' - devicename = '' - - self._define_device_fields() - self._define_device_status_fields() - self._define_device_tracking_fields() - self._define_zone_fields() - self._define_device_zone_fields() - self._define_tracking_control_fields() - self._define_sensor_fields(self.start_icloud3_initial_load_flag) - self._setup_tracking_method(self.tracking_method_config) - - event_msg = (f"^^^ Initializing iCloud3 v{VERSION} > " - f"{dt_util.now().strftime('%A, %b %d')}") - self._save_event_halog_info("*", event_msg) - - self.startup_log_msgs_prefix = NEW_LINE - self._check_config_ic3_yaml_parameter_file() - self._check_ic3_event_log_file_version() - self._initialize_um_formats(self.unit_of_measurement) - - for item in self.display_text_as: - from_to_text = item.split(">") - self.display_text_as_list[from_to_text[0].strip()] = from_to_text[1].strip() - - event_msg = (f"Stage 1 > Prepare iCloud3 for {self.username}") - self._save_event_halog_info("*", event_msg) - - self._display_info_status_msg("", "Loading conf_ic3.yaml") - - self._display_info_status_msg("", "Loading Zones") - self._initialize_zone_tables() - self._define_stationary_zone_fields(self.stationary_inzone_interval_str, - self.stationary_still_time_str) - - self._display_info_status_msg("", "Initializing Waze Route Tracker") - self._initialize_waze_fields(self.waze_region, self.waze_min_distance, - self.waze_max_distance, self.waze_realtime) - - for devicename_cnt in self.count_update_iosapp: - self._display_usage_counts(devicename_cnt) - - except Exception as err: - _LOGGER.exception(err) - - try: - self.startup_log_msgs_prefix = NEW_LINE - self._update_sensor_ic3_event_log("") - event_msg = (f"Stage 2 > Set up tracking method & identify devices") - self._save_event_halog_info("*", event_msg) - - event_msg = (f"Preparing Tracking Method > {self.trk_method_name}") - self._save_event_halog_info("*", event_msg) - - self._display_info_status_msg("", "Setting up Tracked Devices") - self._setup_tracked_devices_config_parm(self.track_devices) - - self.this_update_secs = self._time_now_secs() - self.icloud3_started_secs = self.this_update_secs - - if self.CONF_TRK_METHOD_FMF_FAMSHR: - event_msg = (f"Stage 2a > Authenticating iCloud Account, Extract devices") - self._save_event_halog_info("*", event_msg) - self._display_info_status_msg("", "Authenticating iCloud Account") - self._pyicloud_initialize_device_api() - - if self.api: - if self.TRK_METHOD_FMF: - self._setup_tracked_devices_for_fmf() - - elif self.TRK_METHOD_FAMSHR: - self._setup_tracked_devices_for_famshr() - - elif self.CONF_TRK_METHOD_FMF_FAMSHR: - event_msg = (f"iCloud3 Error > iCloud Account Authentication needed > " - f"Will use iOS App Tracking Method until the iCloud " - f"account is authentication is complete. See the HA " - f"Notification area to continue. iCloud3 will then be restarted.") - self._save_event_halog_info("*", event_msg) - - if self.TRK_METHOD_IOSAPP: - if self.CONF_TRK_METHOD_FMF_FAMSHR: - event_msg = (f"{EVLOG_ALERT}iCloud Alert > FmF or FamShr Tracking Method is disabled until " - f"Verification has been completed. iOS App Trackimg Method " - f"wil be used.") - self._save_event_halog_info("*", event_msg) - event_msg = (f"Stage 2b > Setup iOS App Tracking Method") - self._save_event_halog_info("*", event_msg) - self._setup_tracked_devices_for_iosapp() - - except Exception as err: - _LOGGER.exception(err) - - try: - self.startup_log_msgs_prefix = NEW_LINE - self._update_sensor_ic3_event_log("") - event_msg = (f"Stage 3 > Identify iOS App entities, Verify tracked devices") - self._save_event_halog_info("*", event_msg) - - iosapp_entities = self._get_entity_registry_entities('mobile_app') - notify_devicenames = self._get_mobile_app_notify_devicenames() - - self.track_devicename_list == '' - for devicename in self.devicename_verified: - error_log_msg = None - self._display_info_status_msg(devicename, "Verifing Device") - - #Devicename config parameter is OK, now check to make sure the - #entity for device name has been setup by iosapp correctly. - #If the devicename is valid, it will be tracked - self._initialize_iosapp_message_fields(devicename) - if self.devicename_verified.get(devicename): - self.tracking_device_flag[devicename] = True - self.tracked_devices.append(devicename) - - self.track_devicename_list = (f"{self.track_devicename_list}, {devicename}") - if self.iosapp_monitor_dev_trk_flag.get(devicename): - self._setup_monitored_iosapp_entities(devicename, iosapp_entities, - notify_devicenames, self.devicename_iosapp_suffix.get(devicename)) - - self.track_devicename_list = (f"{self.track_devicename_list} " - f"({self.devicename_iosapp_entity.get(devicename).replace(devicename, ' ... ')})") - - event_msg = (f"Verified Device > {self._format_fname_devicename(devicename)}") - self._save_event_halog_info("*", event_msg) - self._display_info_status_msg(devicename, "Verified Device") - - if self.iosapp_monitor_dev_trk_flag.get(devicename): - event_msg = (f"iOS App monitoring > {self._format_fname_devicename(devicename)} > " - f"CRLF• device_tracker.{self.devicename_iosapp_entity.get(devicename)}, " - f"CRLF• sensor.{self.iosapp_last_trigger_entity.get(devicename)}") - self._save_event_halog_info("*", event_msg) - - event_msg = (f"iOS App location requests sent to > {self._format_fname_devicename(devicename)} > " - f"{self._format_list(self.notify_iosapp_entity.get(devicename))}") - self._save_event_halog_info("*", event_msg) - - #Send a message to all devices during startup - if self.broadcast_msg != '': - self._send_message_to_device(devicename, self.broadcast_msg) - else: - event_msg = (f"iOS App monitoring > device_tracker.{devicename}") - self._save_event_halog_info("*", event_msg) - - #If the devicename is not valid & verified, it will not be tracked - else: - if self.TRK_METHOD_FAMSHR: - event_msg = (f"iCloud3 Error for {self._format_fname_devicename(devicename)}/{devicename} > " - f"The iCloud Account for {self.username} did not return any " - f"device information for this device when setting up " - f"{self.trk_method_name}." - f"CRLF{'-'*25}" - f"CRLF 1. Restart iCloud3 on the Event_log " - f"screen or restart HA." - f"CRLF 2. Verify the devicename on the track_devices " - f"parameter if the error persists." - f"CRLF 3. Refresh the Event Log in your " - f"browser to refresh the list of devices.") - self._save_event_halog_error("*", event_msg) - - event_msg = (f"Not Tracking Device > " - f"{self._format_fname_devicename(devicename)}") - self._save_event_halog_info("*", event_msg) - - #Reset msg sent to all devices during startup - self.broadcast_msg = '' - - #Now that the devices have been set up, finish setting up - #the Event Log Sensor - self._setup_event_log_base_attrs(self.start_icloud3_initial_load_flag) - self._setup_sensors_custom_list(self.start_icloud3_initial_load_flag) - self._display_info_status_msg(devicename, "Define Sensors") - - #nothing to do if no devices to track - if self.track_devicename_list == '': - event_msg = (f"iCloud3 Error for {self.username} > No devices to track. " - f"Setup aborted. Check `track_devices` parameter and verify the " - f"device name matches the iPhone Name on the `Settings>General>About` " - f"screen on the devices to be tracked.") - self._save_event_halog_error("*", event_msg) - - self._update_sensor_ic3_event_log("*") - self.start_icloud3_inprocess_flag = False - return False - - self.track_devicename_list = (f"{self.track_devicename_list[1:]}") - event_msg = (f"Tracking Devices > " - f"CRLF• {self.track_devicename_list.replace(', ','CRLF• ')}") - self._save_event_halog_info("*", event_msg) - - self.startup_log_msgs_prefix = NEW_LINE - self._update_sensor_ic3_event_log("*") - event_msg = (f"Stage 4 > Configure tracked devices") - self._save_event_halog_info("*", event_msg) - - for devicename in self.tracked_devices: - event_msg = (f"Configuring Device > {self._format_fname_devicename(devicename)}") - - if len(self.track_from_zone.get(devicename)) > 1: - w = str(self.track_from_zone.get(devicename)) - w = w.replace("[", "") - w = w.replace("]", "") - w = w.replace("'", "") - event_msg += (f"CRLF• Track from zones > {w}") - - event_msg += self._display_info_status_msg(devicename, "Initialize Tracking Fields") - self._initialize_device_status_fields(devicename) - self._initialize_device_tracking_fields(devicename) - self._initialize_usage_counters(devicename, self.start_icloud3_initial_load_flag) - event_msg += self._display_info_status_msg(devicename, "Initialize Zones") - self._initialize_device_zone_fields(devicename) - event_msg += self._display_info_status_msg(devicename, - f"Initialize Stationary Zone > {self._format_zone_name(devicename, STATIONARY)}") - self._update_stationary_zone( - devicename, - self.stat_zone_base_lat, - self.stat_zone_base_long, - STAT_ZONE_MOVE_TO_BASE) - - #Initialize the new attributes - event_msg += self._display_info_status_msg(devicename, "Update HA Device Entities") - - iosapp_data_flag = False - if self.iosapp_monitor_dev_trk_flag.get(devicename): - iosapp_state, iosapp_dev_attrs, iosapp_data_flag = \ - self._get_iosapp_device_tracker_state_attributes( - devicename, HOME, {}) - - if iosapp_data_flag: - initial_lat = iosapp_dev_attrs[ATTR_LATITUDE] - initial_long = iosapp_dev_attrs[ATTR_LONGITUDE] - else: - initial_lat = self.zone_home_lat - initial_long = self.zone_home_long - - kwargs = self._setup_base_kwargs( - devicename, - initial_lat, - initial_long, - 0, 0) - - attrs = self._initialize_attrs(devicename) - self._update_device_attributes(devicename, kwargs, attrs, '_start_icloud3') - - if devicename not in self.sensor_prefix_name: - event_msg += self._setup_sensor_base_attrs(devicename) - - event_msg += self._display_info_status_msg(devicename, "Initialize Sensor Fields") - self._update_device_sensors(devicename, kwargs) - self._update_device_sensors(devicename, attrs) - - self._save_event_halog_info("*", event_msg) - - self._display_info_status_msg(devicename, "Initialize Event Log") - self._save_event_halog_info("*", "Initialize Event Log Sensor") - - except Exception as err: - _LOGGER.exception(err) - - for devicename in self.tracked_devices: - if self.log_level_debug_flag or self.log_level_eventlog_flag: - self._display_usage_counts(devicename, force_display=(not self.start_icloud3_initial_load_flag)) - - self.startup_log_msgs_prefix = NEW_LINE - self._update_sensor_ic3_event_log("*") - event_msg = (f"^^^ Initializing iCloud3 v{VERSION} > Complete, " - f"Took {round(time.time()-self.start_timer, 2)} sec") - self._save_event_halog_info("*", event_msg) - self._display_info_status_msg(devicename, "Setup Complete, Locating Devices") - self._update_sensor_ic3_event_log("*") - - self.start_icloud3_inprocess_flag = False - - if self.log_level_debug_flag == False: - self.startup_log_msgs = (NEW_LINE + '-'*55 + - self.startup_log_msgs.replace("CRLF", NEW_LINE) + - NEW_LINE + '-'*55) - _LOGGER.info(self.startup_log_msgs) - self.startup_log_msgs = '' - - if self.polling_5_sec_loop_running == False: - self.polling_5_sec_loop_running = True - track_utc_time_change(self.hass, self._polling_loop_5_sec_device, - second=[0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]) - - return True - -######################################################### -# -# This function is called every 5 seconds by HA. Cycle through all -# of the iCloud devices to see if any of the ones being tracked need -# to be updated. If so, we might as well update the information for -# all of the devices being tracked since PyiCloud gets data for -# every device in the account -# -######################################################### - def _polling_loop_5_sec_device(self, now): - try: - fct_name = "_polling_loop_5_sec_device" - - if self.start_icloud3_request_flag: #via service call - self._start_icloud3() - - if (self.any_device_being_updated_flag - or self.start_icloud3_inprocess_flag): - return - - except Exception as err: - _LOGGER.exception(err) - return - - self.this_update_secs = self._time_now_secs() - self.this_update_time = dt_util.now().strftime('%H:%M:%S') - this_minute = int(dt_util.now().strftime('%M')) - this_5sec_loop_second = int(dt_util.now().strftime('%S')) - - #Reset counts on new day, check for daylight saving time new offset - if self.this_update_time.endswith(':00:00'): - self._timer_tasks_every_hour() - - if self.this_update_time == HHMMSS_ZERO: - self._timer_tasks_midnight() - - elif self.this_update_time == '01:00:00': - self._timer_tasks_1am() - - #Test code to check ios monitor, display it every minute - #if self.this_update_time.endswith(':00'): - # self.last_iosapp_msg['gary_iphone'] = "" - - try: - if self.this_update_secs >= self.event_log_clear_secs and \ - self.log_level_debug_flag == False: - self._update_sensor_ic3_event_log('clear_log_items') - - if self.this_update_secs >= self.authentication_error_retry_secs: - self._pyicloud_authenticate_account() - - for devicename in self.tracked_devices: - devicename_zone = self._format_devicename_zone(devicename, HOME) - - if (self.tracking_device_flag.get(devicename) is False - or self.next_update_time.get(devicename_zone) == PAUSED): - continue - - update_method = None - self.state_change_flag[devicename] = False - - if self.state_last_poll.get(devicename) == NOT_SET: - self._display_info_status_msg(devicename, "Getting Initial Device State/Triggers") - - #get tracked_device (device_tracker.) state & attributes - #icloud & ios app v1 use this entity - ic3_entity_id = self.device_tracker_entity_ic3.get(devicename) - ic3dev_state = self._get_state(ic3_entity_id) - - #Will be not_set with a last_poll value if the iosapp has not be set up - if (ic3dev_state == NOT_SET - and self.state_last_poll.get(devicename) != NOT_SET): - event_msg = "iOSApp state will be updated after iOSApp starts" - self._save_event(devicename,event_msg) - continue - - #Extract only attrs needed to update the device - ic3dev_attrs = self._get_device_attributes(ic3_entity_id) - ic3dev_attrs_avail = {k: v for k, v in ic3dev_attrs.items() if k in DEVICE_ATTRS_BASE} - ic3dev_data = {**DEVICE_ATTRS_BASE, **ic3dev_attrs_avail} - - ic3dev_latitude = ic3dev_data[ATTR_LATITUDE] - ic3dev_longitude = ic3dev_data[ATTR_LONGITUDE] - ic3dev_gps_accuracy = ic3dev_data[ATTR_GPS_ACCURACY] - ic3dev_battery = ic3dev_data[ATTR_BATTERY_LEVEL] - ic3dev_trigger = ic3dev_data[ATTR_TRIGGER] - ic3dev_timestamp_secs = self._timestamp_to_secs(ic3dev_data[ATTR_TIMESTAMP]) - iosapp_state = ic3dev_state - iosapp_dev_attrs = {ATTR_LATITUDE: ic3dev_latitude, - ATTR_LONGITUDE: ic3dev_longitude, - ATTR_GPS_ACCURACY: ic3dev_gps_accuracy, - ATTR_BATTERY_LEVEL: ic3dev_battery} - - #iosapp uses the device_tracker._# entity for - #location info and sensor._last_update_trigger entity - #for trigger info. Get location data and trigger. - #Use the trigger/timestamp if timestamp is newer than current - #location timestamp. - - update_reason = "" - ios_update_reason = "" - update_via_iosapp_flag = False - last_located_secs_plus_5 = self.last_located_secs.get(devicename) + 5 - - if self.iosapp_monitor_dev_trk_flag.get(devicename): - iosapp_state, iosapp_dev_attrs, iosapp_data_flag = \ - self._get_iosapp_device_tracker_state_attributes( - devicename, iosapp_state, iosapp_dev_attrs) - - if iosapp_data_flag == False and self.iosapp_monitor_error_cnt.get(devicename) > 120: - self.iosapp_monitor_error_cnt[devicename] = 0 - event_msg = (f"iOS App data not available, iCloud data used") - self._save_event(devicename, event_msg) - - entity_id = self.device_tracker_entity_iosapp.get(devicename) - iosapp_state_changed_time, iosapp_state_changed_secs, iosapp_state_changed_timestamp, \ - iosapp_state_age_secs, iosapp_state_age_str = \ - self._get_entity_last_changed_time(entity_id, devicename) - - iosapp_trigger, iosapp_trigger_changed_time, iosapp_trigger_changed_secs, \ - iosapp_trigger_age_secs, iosapp_trigger_age_str = \ - self._get_iosapp_device_sensor_trigger(devicename) - - iosapp_data_msg = "" if iosapp_data_flag else "(Using iC3 data) " - iosapp_msg = (f"iOSApp Monitor {iosapp_data_msg}> " - f"Trigger-{iosapp_trigger}@{iosapp_trigger_changed_time} (%tage ago), " - f"State-{iosapp_state}@{iosapp_state_changed_time} (%sage ago), " - f"GPS-{format_gps(iosapp_dev_attrs[ATTR_LATITUDE], iosapp_dev_attrs[ATTR_LONGITUDE], iosapp_dev_attrs[ATTR_GPS_ACCURACY])}, " - f"LastiC3UpdtTime-{self.last_update_time.get(devicename_zone)}, ") - - #Initialize if first time through - if self.last_iosapp_trigger.get(devicename) == '': - self.last_iosapp_state[devicename] = iosapp_state - self.last_iosapp_state_changed_time[devicename] = iosapp_state_changed_time - self.last_iosapp_state_changed_secs[devicename] = iosapp_state_changed_secs - self.last_iosapp_trigger[devicename] = iosapp_trigger - self.last_iosapp_trigger_changed_time[devicename] = iosapp_trigger_changed_time - self.last_iosapp_trigger_changed_secs[devicename] = iosapp_trigger_changed_secs - - if self.TRK_METHOD_IOSAPP: - update_via_iosapp_flag = True - ios_update_reason = "Initial Locate" - - if iosapp_data_flag == False: - update_via_iosapp_flag = False - ios_update_reason = "No data received from the iOS App, using iC3 data" - - elif (iosapp_state == NOT_SET - and self.iosapp_request_loc_retry_cnt.get(devicename) == 0): - update_via_iosapp_flag = True - ios_update_reason = "Reinitialize iOSApp location" - - elif (instr(iosapp_state, STATIONARY) - and iosapp_dev_attrs[ATTR_LATITUDE] == self.stat_zone_base_lat - and iosapp_dev_attrs[ATTR_LONGITUDE] == self.stat_zone_base_long): - ios_update_reason = "Stat Zone Base Locatioon" - - #State changed - elif iosapp_state != self.last_iosapp_state.get(devicename): - update_via_iosapp_flag = True - ios_update_reason = (f"State Change-{iosapp_state}") - - #trigger time is after last locate - #elif iosapp_trigger_changed_secs > (self.last_located_secs.get(devicename) + 5): - elif iosapp_trigger_changed_secs > last_located_secs_plus_5: - update_via_iosapp_flag = True - ios_update_reason = (f"Trigger Change-{iosapp_trigger}") - - #State changed more than 5-secs after last locate - #elif iosapp_state_changed_secs > (self.last_located_secs.get(devicename) + 5): - elif iosapp_state_changed_secs > last_located_secs_plus_5: - update_via_iosapp_flag = True - iosapp_trigger = "iOSApp Loc Update" - iosapp_trigger_changed_secs = iosapp_state_changed_secs - ios_update_reason = (f"iOSApp Loc Update@{iosapp_state_changed_time}") - - #Prevent duplicate update if State & Trigger changed at the same time - #and state change was handled on last cycle - elif (iosapp_trigger_changed_secs == iosapp_state_changed_secs - or iosapp_trigger_changed_secs <= last_located_secs_plus_5): - # or iosapp_trigger_changed_secs <= (self.last_located_secs.get(devicename) + 5)): - self.last_iosapp_trigger[devicename] = iosapp_trigger - ios_update_reason = "Already Processed" - - #Trigger changed more than 5-secs after last trigger - elif (iosapp_trigger_changed_secs > (self.last_iosapp_trigger_changed_secs.get(devicename) + 5)): - update_via_iosapp_flag = True - ios_update_reason = (f"Trigger Time@{self._secs_to_time(iosapp_trigger_changed_secs)}") - - #Bypass if trigger contains ic3 date stamp suffix (@hhmmss) - elif instr(iosapp_trigger, '@'): - ios_update_reason = "Trigger Already Processed" - - #elif iosapp_trigger_changed_secs <= (self.last_located_secs.get(devicename) + 5): - elif iosapp_trigger_changed_secs <= last_located_secs_plus_5: - ios_update_reason = "Trigger Before Last Locate" - - else: - ios_update_reason = "Failed Update Tests" - - iosapp_msg += (f"WillUpdate-{update_via_iosapp_flag}") - - #Show iOS App monitor every half hour - if self.this_update_time.endswith(':00:00') or self.this_update_time.endswith(':30:00'): - self.last_iosapp_msg[devicename] = "" - - if (iosapp_msg != self.last_iosapp_msg.get(devicename)): - self.last_iosapp_msg[devicename] = iosapp_msg - - iosapp_msg = iosapp_msg.replace("%tage", iosapp_trigger_age_str) - iosapp_msg = iosapp_msg.replace("%sage", iosapp_state_age_str) - iosapp_msg += (f", {ios_update_reason}") - self._log_debug_msg(devicename, iosapp_msg) - self._evlog_debug_msg(devicename, iosapp_msg) - - #This devicename is entering a zone also assigned to another device. The ios app - #will move issue a Region Entered trigger and the state is the other devicename's - #stat zone name. Create this device's stat zone at the current location to get the - #zone tables in sync. Must do this before processing the state/trigger change or - #this devicename will use this trigger to start a timer rather than moving it ineo - #the stat zone. - if update_via_iosapp_flag: - if (iosapp_trigger in IOS_TRIGGERS_ENTER): - if (instr(iosapp_state, STATIONARY) - and instr(iosapp_state, devicename) == False - and self.in_stationary_zone_flag.get(devicename) == False): - - event_msg = (f"Stationary Zone Entered > iOS App used another device's " - f"Stationary Zone, changed {iosapp_state} to " - f"{self._format_zone_name(devicename, STATIONARY)}") - self._save_event(devicename, event_msg) - - latitude = self.zone_lat.get(iosapp_state) - longitude = self.zone_long.get(iosapp_state) - - self._update_stationary_zone( - devicename, - latitude, - longitude, - STAT_ZONE_NEW_LOCATION, 'poll 5-EnterTrigger') - - iosapp_state = self._format_zone_name(devicename, STATIONARY) - - elif (iosapp_trigger in IOS_TRIGGERS_EXIT): - if (self.in_stationary_zone_flag.get(devicename)): - self._update_stationary_zone( - devicename, - self.stat_zone_base_lat, - self.stat_zone_base_long, - STAT_ZONE_MOVE_TO_BASE, 'poll 5-ExitTrigger') - - #Discard another devices Stationary Zone triggers - #if instr(iosapp_state, STATIONARY) and instr(iosapp_state, devicename) == False: - # continue - - if instr(iosapp_state, STATIONARY): - iosapp_state = STATIONARY - - #---------------------------------------------------------------------------- - ic3dev_battery = self._get_attr(iosapp_dev_attrs, ATTR_BATTERY_LEVEL, NUMERIC) - self.last_battery[devicename] = ic3dev_battery - - if update_via_iosapp_flag: - iosapp_trigger_age_secs = self._secs_since(iosapp_trigger_changed_secs) - age = iosapp_trigger_changed_secs - self.this_update_secs - ic3dev_state = iosapp_state - ic3dev_latitude = iosapp_dev_attrs[ATTR_LATITUDE] - ic3dev_longitude = iosapp_dev_attrs[ATTR_LONGITUDE] - ic3dev_gps_accuracy = iosapp_dev_attrs[ATTR_GPS_ACCURACY] - ic3dev_trigger = iosapp_trigger - ic3dev_timestamp_secs = iosapp_trigger_changed_secs - ic3dev_data[ATTR_LATITUDE] = ic3dev_latitude - ic3dev_data[ATTR_LONGITUDE] = ic3dev_longitude - ic3dev_data[ATTR_TIMESTAMP] = iosapp_state_changed_timestamp - ic3dev_data[ATTR_GPS_ACCURACY] = ic3dev_gps_accuracy - ic3dev_data[ATTR_BATTERY_LEVEL] = ic3dev_battery - ic3dev_data[ATTR_ALTITUDE] = self._get_attr(iosapp_dev_attrs, ATTR_ALTITUDE, NUMERIC) - ic3dev_data[ATTR_VERT_ACCURACY] = self._get_attr(iosapp_dev_attrs, ATTR_VERT_ACCURACY, NUMERIC) - - self.last_iosapp_state[devicename] = iosapp_state - self.last_iosapp_state_changed_time[devicename] = iosapp_state_changed_time - self.last_iosapp_state_changed_secs[devicename] = iosapp_state_changed_secs - self.last_iosapp_trigger[devicename] = iosapp_trigger - self.last_iosapp_trigger_changed_time[devicename] = iosapp_trigger_changed_time - self.last_iosapp_trigger_changed_secs[devicename] = iosapp_trigger_changed_secs - - #Check ios app for activity every 6-hours - #Issue ios app Location update 15-min before 6-hour alert - elif (self.this_update_time in ['23:45:00', '05:45:00', '11:45:00', '17:45:00'] - and iosapp_state_age_secs > 20700 - and iosapp_trigger_age_secs > 20700): - self._iosapp_request_loc_update(devicename) - - #No activity, display Alert msg in Event Log - elif (self.this_update_time in ['00:00:00', '06:00:00', '12:00:00', '18:00:00'] - and iosapp_state_age_secs > 21600 - and iosapp_trigger_age_secs > 21600): - event_msg = (f"{EVLOG_ALERT}iOS App Alert > No iOS App updates for more than 6 hours > " - f"Device-{self.device_tracker_entity_iosapp.get(devicename)}, " - f"LastTrigger-{iosapp_trigger}@{iosapp_trigger_changed_time} ({iosapp_trigger_age_str}), " - f"LastState-{iosapp_state}@{iosapp_state_changed_time} ({iosapp_state_age_str})") - self._save_event_halog_info(devicename, event_msg) - event_msg = (f"Last iOS App update from {self.device_tracker_entity_iosapp.get(devicename)}" - f"—{iosapp_trigger_age_str} ago") - self._display_info_status_msg(devicename, event_msg) - - zone = self._format_zone_name(devicename, ic3dev_state) - event_msg = "" - update_reason = "" - - dist_from_zone_m = self._zone_distance_m( - devicename, - zone, - ic3dev_latitude, - ic3dev_longitude) - - dist_from_home_m = self._zone_distance_m( - devicename, - HOME, - ic3dev_latitude, - ic3dev_longitude) - - zone_radius_m = self.zone_radius_m.get( - zone, - self.zone_radius_m.get(HOME)) - zone_radius_accuracy_m = zone_radius_m + self.gps_accuracy_threshold - - if (self.TRK_METHOD_IOSAPP and self.zone_current.get(devicename) == ''): - update_method = IOSAPP_UPDATE - update_reason = "Initial Locate" - - #device_tracker.see svc call from automation wipes out - #latitude and longitude. Reset via icloud update. - elif ic3dev_latitude == 0: - update_method = ICLOUD_UPDATE - update_reason = (f"GPS=(0, 0) {self.state_last_poll.get(devicename)}/{ic3dev_state}") - ic3dev_trigger = "RefreshLocation" - device_offline_msg = (f"May be Offline, No Location " - f"(#{self.poor_location_gps_cnt.get(devicename)})") - self._display_info_status_msg(devicename, device_offline_msg) - self._save_event(devicename,device_offline_msg) - - #Update the device if it wasn't completed last time. - elif self.state_last_poll.get(devicename) == NOT_SET: - update_method = ICLOUD_UPDATE - ic3dev_trigger = update_reason = "Initial Locate" - self._display_info_status_msg(devicename, "Locating Device via iCloud") - - #If the state changed since last time and it was a trigger - #related to another Stationary Zone, discard it. - elif (ic3dev_state != self.state_last_poll.get(devicename) - and ic3dev_state != STATIONARY - and instr(ic3dev_state, STATIONARY) - and instr(ic3dev_state, devicename) == False): - update_method = None - update_reason = "Other Device StatZone" - event_msg = (f"Discarded > Another device's Stationary Zone trigger detected, " - f"Zone-{ic3dev_state}") - self._save_event(devicename, event_msg) - - elif (self.stat_zone_timer.get(devicename, 0) > 0 - and self.this_update_secs >= self.stat_zone_timer.get(devicename) - and self.poor_location_gps_cnt.get(devicename) == 0): - event_msg = (f"Move into Stationary Zone Timer reached > {devicename}, " - f"Expired-{self._secs_to_time(self.stat_zone_timer.get(devicename, -1))}") - self._save_event(devicename, event_msg) - - update_method = ICLOUD_UPDATE - ic3dev_trigger = "MoveIntoStatZone" - update_reason = "Move into Stat Zone " - - #The state can be changed via device_tracker.see service call - #with a different location_name in an automation or by an - #ios app notification that a zone is entered or exited. If - #by the ios app, the trigger is 'Geographic Region Exited' or - #'Geographic Region Entered'. In iosapp 2.0, the state is - #changed without a trigger being posted and will be picked - #up here anyway. - elif (ic3dev_state != self.state_last_poll.get(devicename) - and ic3dev_timestamp_secs > last_located_secs_plus_5): - self.count_state_changed[devicename] += 1 - self.state_change_flag[devicename] = True - update_method = IOSAPP_UPDATE - update_reason = "State Change" - event_msg = (f"iOSApp State Change detected > " - f"{self.state_last_poll.get(devicename)} to {ic3dev_state}") - self._save_event(devicename, event_msg) - - elif (ic3dev_trigger != self.trigger.get(devicename) - and instr(iosapp_state, STATIONARY) - and iosapp_dev_attrs[ATTR_LATITUDE] == self.stat_zone_base_lat - and iosapp_dev_attrs[ATTR_LONGITUDE] == self.stat_zone_base_long): - update_method = ICLOUD_UPDATE - update_reason = "Verify Location" - event_msg = (f"Discarded > Entering Stationary Base Zone") - self._save_event(devicename, event_msg) - - elif (ic3dev_trigger != self.trigger.get(devicename) - and ic3dev_timestamp_secs > last_located_secs_plus_5): - update_method = IOSAPP_UPDATE - self.count_trigger_changed[devicename] += 1 - update_reason = "Trigger Change" - event_msg = (f"iOSApp Trigger Change detected > {ic3dev_trigger}") - self._save_event(devicename, event_msg) - - #If iOS App tracking and next update time reached and message not sent, request location. iOS App should respond within 20-seconds and create a Location Request - #trigger. Do this 4-times and then display message and wait. - elif (self.TRK_METHOD_IOSAPP - and self._check_next_update_time_reached_devicename(devicename)): - self._iosapp_request_loc_update(devicename) - - else: - update_reason = f"OlderTrigger-{ic3dev_trigger}" - - if (self.TRK_METHOD_IOSAPP - and update_method == ICLOUD_UPDATE): - update_method = IOSAPP_UPDATE - - #Update because of state or trigger change. - #Accept the location data as it was sent by ios if the trigger - #is for zone enter, exit, manual or push notification, - #or if the last trigger was already handled by ic3 ( an '@hhmmss' - #was added to it. - #If the trigger was sometning else (Significant Location Change, - #Background Fetch, etc, check to make sure it is not old or - #has poor gps info. - if (update_method == IOSAPP_UPDATE - or update_method == ICLOUD_UPDATE and self.TRK_METHOD_IOSAPP): - self.iosapp_request_loc_sent_flag[devicename] = False - - #If exit trigger flag is not set, set it now if Exiting zone - #If already set, leave it alone and reset when enter Zone (v2.1x) - if self.got_exit_trigger_flag.get(devicename) == False: - self.got_exit_trigger_flag[devicename] = (ic3dev_trigger in IOS_TRIGGERS_EXIT) - - if ic3dev_trigger in IOS_TRIGGERS_ENTER: - if (zone in self.zone_lat - and dist_from_zone_m > self.zone_radius_m.get(zone)*2 - and dist_from_zone_m < HIGH_INTEGER): - event_msg = (f"Enter Zone Trigger Conflict Moving into Zone > " - f"Zone-{zone}, Distance-{dist_from_zone_m}m, " - f"ZoneVerifyDist-{self.zone_radius_m.get(zone)*2}m, " - f"GPS-{format_gps(ic3dev_latitude, ic3dev_longitude, ic3dev_gps_accuracy)}") - self._save_event_halog_info(devicename, event_msg) - - ic3dev_latitude = self.zone_lat.get(zone) - ic3dev_longitude = self.zone_long.get(zone) - ic3dev_data[ATTR_LATITUDE] = ic3dev_latitude - ic3dev_data[ATTR_LONGITUDE] = ic3dev_longitude - - #RegionExit trigger overrules old location, gps accuracy and other checks - elif (ic3dev_trigger in IOS_TRIGGERS_EXIT - and ic3dev_timestamp_secs > last_located_secs_plus_5): - update_method = IOSAPP_UPDATE - update_reason = "Region Exit" - - #Check info if Background Fetch, Significant Location Update, - #Push, Manual, Initial - #elif (ic3dev_trigger in IOS_TRIGGERS_VERIFY_LOCATION): - #If not an enter/exit trigger, verify the location - elif (ic3dev_trigger not in IOS_TRIGGERS_ENTER_EXIT): - poor_location_gps_flag, discard_reason = self._check_poor_location_gps( - devicename, - ic3dev_timestamp_secs, - ic3dev_gps_accuracy) - - #If old location, discard - if poor_location_gps_flag: - location_age = self._secs_since(ic3dev_timestamp_secs) - event_msg = (f"Old Location or Poor GPS " - f"(#{self.poor_location_gps_cnt.get(devicename)}) > " - f"Located-{self._secs_to_time(ic3dev_timestamp_secs)} " - f"({self._format_age(location_age)}), " - f"GPS-{format_gps(ic3dev_latitude, ic3dev_longitude, ic3dev_gps_accuracy)}, " - f"OldLocThreshold-{self._secs_to_time_str(self.old_location_secs.get(devicename))}") - - self._save_event(devicename, event_msg) - update_method = ICLOUD_UPDATE - update_reason = "OldLoc PoorGPS" - - #If got these triggers and not old location check a few - #other things - else: - update_reason = (f"{ic3dev_trigger}") - self.last_iosapp_trigger[devicename] = ic3dev_trigger - - #if the zone is a stationary zone and no exit trigger, - #the zones in the ios app may not be current. - if (dist_from_zone_m >= zone_radius_m * 2 - and instr(zone, STATIONARY) - and instr(self.zone_last.get(devicename), STATIONARY)): - event_msg = ("Outside Stationary Zone without " - "Exit Trigger > iOS App Zone info may need to be reloaded. " - "Check `iOS App Configuration > Location` screen for stat zones. " - "Force close iOS App and reload if necessary. " - f"Zone-{zone}, " - f"Distance-{self._format_dist_m(dist_from_zone_m)}, " - f"StatZoneTestDist-{zone_radius_m * 2}m") - #self._save_event_halog_info(devicename, event_msg) - - self.iosapp_stat_zone_action_msg_cnt[devicename] += 1 - if self.iosapp_stat_zone_action_msg_cnt.get(devicename) < 5: - message = { - "title": "iCloud3/iOSApp Zone Action Needed", - "message": event_msg, - "data": {"subtitle": "Stationary Zone Exit "\ - "Trigger was not recieved"}} - self._send_message_to_device(devicename, message) - - #Check to see if currently in a zone. If so, check the zone distance. - #If new location is outside of the zone and inside radius*4, discard - #by treating it as poor GPS - if self._is_inzone_zonename(zone): - outside_no_exit_trigger_flag, info_msg = \ - self._check_outside_zone_no_exit(devicename, zone, ic3dev_trigger, - ic3dev_latitude, ic3dev_longitude) - if outside_no_exit_trigger_flag: - update_method = None - ios_update_reason = None - self._save_event_halog_info(devicename, info_msg) - - #update via icloud to verify location if less than home_radius*10 - elif (dist_from_zone_m <= zone_radius_m * 10 - and self.TRK_METHOD_FMF_FAMSHR): - event_msg = (f"Verify Location Needed > " - f"Zone-{zone}, " - f"Distance-{self._format_dist_m(dist_from_zone_m)}m, " - f"ZoneVerifyDist-{self._format_dist_m(zone_radius_m*10)}m, " - f"GPS-{format_gps(ic3dev_latitude, ic3dev_longitude, ic3dev_gps_accuracy)}") - self._save_event_halog_info(devicename, event_msg) - update_method = ICLOUD_UPDATE - update_reason = "Verify Location" - else: - pass - else: - pass - - if (ic3dev_data[ATTR_LATITUDE] is None or ic3dev_data[ATTR_LONGITUDE] is None): - update_method = ICLOUD_UPDATE - - device_monitor_msg = (f"Device Monitor > UpdateMethod-{update_method}, " - f"UpdateReason-{update_reason}, " - f"State-{ic3dev_state}, " - f"Trigger-{ic3dev_trigger}, " - f"LastLoc-{self._secs_to_time(ic3dev_timestamp_secs)}, " - f"Zone-{zone}, " - f"HomeDist-{self._format_dist_m(dist_from_home_m)}, " - f"inZone-{self._is_inzone_zonename(zone)}, " - f"GPS-{format_gps(ic3dev_latitude, ic3dev_longitude, ic3dev_gps_accuracy)}, " - f"StateThisPoll-{ic3dev_state}, " - f"StateLastPoll-{self.state_last_poll.get(devicename)}") - if self.last_device_monitor_msg.get(devicename) != device_monitor_msg: - self._evlog_debug_msg(devicename, device_monitor_msg) - self.last_device_monitor_msg[devicename] = device_monitor_msg - - if (self._device_online(devicename) == False - or self.state_this_poll == NOT_SET): - device_offline_msg = (f"May be Offline, Never Located " - f"(#{self.poor_location_gps_cnt.get(devicename)})") - self._display_info_status_msg(devicename, device_offline_msg) - - #Save trigger and time for cycle even if trigger was discarded. - self.trigger[devicename] = ic3dev_trigger - self.last_located_secs[devicename] = ic3dev_timestamp_secs - - if update_method == ICLOUD_UPDATE: - if self.TRK_METHOD_FMF_FAMSHR: - self._update_device_icloud(update_reason, devicename) - - elif self.TRK_METHOD_IOSAPP: - update_method = IOSAPP_UPDATE - self.iosapp_request_loc_sent_flag[devicename] = False - - if update_method == IOSAPP_UPDATE: - self.state_this_poll[devicename] = ic3dev_state - self.iosapp_update_flag[devicename] = True - - update_method = self._update_device_iosapp(devicename, update_reason, ic3dev_data) - - #Retry with iCloud of an error or no data - if (update_method == ICLOUD_UPDATE - and self.TRK_METHOD_FMF_FAMSHR): - self._update_device_icloud(update_reason, devicename) - - self.any_device_being_updated_flag = False - - #If less than 90 secs to the next update for any devicename:zone, display time to - #the next update in the NextUpdt time field, e.g, 1m05s or 0m15s. - if devicename in self.track_from_zone: - for zone in self.track_from_zone.get(devicename): - devicename_zone = self._format_devicename_zone(devicename, zone) - if devicename_zone in self.next_update_secs: - age_secs = self._secs_to(self.next_update_secs.get(devicename_zone)) - #if (age_secs <= 90 and age_secs >= -15): - # self._display_time_till_update_info_msg(devicename_zone, age_secs) - self._display_time_till_update_info_msg(devicename_zone, age_secs) - - - if update_method is not None: - self.device_being_updated_flag[devicename] = False - self.state_change_flag[devicename] = False - self._log_debug_msgs_trace_flag = False - self.update_in_process_flag = False - - #End devicename in self.tracked_devices loop - - except Exception as err: - _LOGGER.exception(err) - log_msg = (f"Device Update Error, Error-{ValueError}") - self._log_error_msg(log_msg) - - self.update_in_process_flag = False - self._log_debug_msgs_trace_flag = False - - #Cycle thru all devices and check to see if devices next update time - #will occur in 5-secs. If so, request a location update now so it - #it might be current on the next update. - if ((this_5sec_loop_second % 15) == 10) and self.TRK_METHOD_FMF_FAMSHR: - self._polling_loop_10_sec_loc_prefetch(now) - - #Cycle thru all devices and check to see if devices need to be - #updated via every 15 seconds - if ((this_5sec_loop_second % 15) == 0) and self.TRK_METHOD_FMF_FAMSHR: - self._polling_loop_15_sec_icloud(now) - - #Refresh the Event Log after completing the first locate, display info - #for the first devicename - if self.tracked_devices: - devicename = self.tracked_devices[0] - if (self.initial_locate_complete_flag == False - and self.state_this_poll.get(devicename, NOT_SET) != NOT_SET): - self.initial_locate_complete_flag = True - self._update_sensor_ic3_event_log(devicename) - -#-------------------------------------------------------------------- - def _retry_update(self, devicename): - #This flag will be 'true' if the last update for this device - #was not completed. Do another update now. - self.device_being_updated_retry_cnt[devicename] = 0 - while (self.device_being_updated_flag.get(devicename) - and self.device_being_updated_retry_cnt.get(devicename) < 4): - self.device_being_updated_retry_cnt[devicename] += 1 - - log_msg = (f"{self._format_fname_devtype(devicename)} " - f"Retrying Update, Update was not completed in last cycle, " - f"Retry #{self.device_being_updated_retry_cnt.get(devicename)}") - self._save_event_halog_info(devicename, log_msg) - - self.device_being_updated_flag[devicename] = True - self._log_debug_msgs_trace_flag = True - - self._wait_if_update_in_process() - update_reason = (f"Retry Update #{self.device_being_updated_retry_cnt.get(devicename)}") - - self._update_device_icloud(update_reason, devicename) - -######################################################### -# -# This function is called every 10 seconds. Cycle through all -# of the devices to see if any of the ones being tracked will -# be updated on the 15-sec polling loop in 5-secs. If so, request a -# location update now so it might be current when the device -# is updated. -# -######################################################### - def _polling_loop_10_sec_loc_prefetch(self, now): - - try: - if self.next_update_secs is None: - return - - for devicename_zone in self.next_update_secs: - devicename = devicename_zone.split(':')[0] - if self.state_this_poll.get(devicename) == NOT_SET: - continue - - time_till_update = self.next_update_secs.get(devicename_zone) - \ - self.this_update_secs - location_data = self.location_data.get(devicename) - - if time_till_update <= 10: - location_data = self.location_data.get(devicename) - location_age = location_data[ATTR_AGE] - - if location_age > 15: - if self.TRK_METHOD_FMF_FAMSHR: - self._refresh_pyicloud_devices_location_data(location_age, devicename) - - elif self.TRK_METHOD_IOSAPP: - self._iosapp_request_loc_update(devicename) - break - - except Exception as err: - _LOGGER.exception(err) - - return - -######################################################### -# -# Update the device on a state or trigger change was recieved from the ios app -# ●►●◄►●▬▲▼◀►►●◀ oPhone=►▶ -######################################################### - def _update_device_iosapp(self, devicename, update_reason, ic3dev_data): - """ - Update the devices location using data from the iOS App - """ - - if self.start_icloud3_inprocess_flag: - return - - fct_name = "_update_device_ios_trigger" - - self.any_device_being_updated_flag = True - return_code = IOSAPP_UPDATE - - self.iosapp_request_loc_retry_cnt[devicename] = 0 - self.iosapp_request_loc_sent_secs[devicename] = 0 - self.iosapp_request_loc_sent_flag[devicename] = False - - try: - devicename_zone = self._format_devicename_zone(devicename, HOME) - - if self.next_update_time.get(devicename_zone) == PAUSED: - self._save_event(devicename,"2047 update_iosapp, paused") - return - elif ATTR_LATITUDE not in ic3dev_data or ATTR_LONGITUDE not in ic3dev_data: - self._save_event(devicename,f"2050 update_iosapp, ic3_dev_data={ic3dev_data}") - return - elif ic3dev_data[ATTR_LATITUDE] is None or ic3dev_data[ATTR_LONGITUDE] is None: - self._save_event(devicename,f"2053 update_iosapp, lat=None, ic3_dev_data={ic3dev_data}") - return - - self._log_start_finish_update_banner('▼▼▼', devicename, "iOSApp", update_reason) - self._trace_device_attributes(devicename, 'IC3DEV_DATA', fct_name, ic3dev_data) - event_msg = (f"iOS App update started ({update_reason.split('@')[0]})") - self._save_event(devicename, event_msg) - - self.update_timer[devicename] = time.time() - - entity_id = self.device_tracker_entity_ic3.get(devicename) - state = self._get_state(entity_id) - latitude = ic3dev_data[ATTR_LATITUDE] - longitude = ic3dev_data[ATTR_LONGITUDE] - timestamp = self._timestamp_to_time(ic3dev_data[ATTR_TIMESTAMP]) - timestamp = self.this_update_time if timestamp == HHMMSS_ZERO else timestamp - gps_accuracy = ic3dev_data[ATTR_GPS_ACCURACY] - battery = ic3dev_data[ATTR_BATTERY_LEVEL] - battery_status = ic3dev_data[ATTR_BATTERY_STATUS] - device_status = ic3dev_data[ATTR_DEVICE_STATUS] - low_power_mode = ic3dev_data[ATTR_LOW_POWER_MODE] - altitude = self._get_attr(ic3dev_data, ATTR_ALTITUDE, NUMERIC) - vertical_accuracy = self._get_attr(ic3dev_data, ATTR_VERT_ACCURACY, NUMERIC) - - location_isold_attr = False - poor_location_gps_flag = False - self.poor_location_gps_cnt[devicename] = 0 - self.poor_location_gps_msg[devicename] = False - self.poor_gps_flag[devicename] = False #v2.2.1 - self.device_status[devicename] = device_status - - attrs = {} - - #-------------------------------------------------------- - try: - if self.device_being_updated_flag.get(devicename): - info_msg = "Last update not completed, retrying" - else: - info_msg = "Updating" - info_msg = (f"{info_msg} {self.fname.get(devicename)}") - - self._display_info_status_msg(devicename, info_msg) - self.device_being_updated_flag[devicename] = True - - except Exception as err: - _LOGGER.exception(err) - attrs = self._internal_error_msg(fct_name, err, 'UpdateAttrs1') - - try: - for zone in self.track_from_zone.get(devicename): - #If the state changed, only process the zone that changed - #to avoid delays caused calculating travel time by other zones - if (self.state_change_flag.get(devicename) - and self.state_this_poll.get(devicename) != zone - and zone != HOME): - continue - - #discard trigger if outsize zone with no exit trigger - if self._is_inzone_zonename(zone): - discard_flag, discard_msg = \ - self._check_outside_zone_no_exit(devicename, zone, '', - latitude, longitude) - - if discard_flag: - self._save_event(devicename, discard_msg) - continue - - self._set_base_zone_name_lat_long_radius(zone) - self._log_start_finish_update_banner('▼-▼', devicename, "iOSApp", zone) - - attrs = self._determine_interval( - devicename, - latitude, - longitude, - battery, - gps_accuracy, - poor_location_gps_flag, - self.last_located_secs.get(devicename), - timestamp, - "iOSApp") - - if attrs != {}: - self._update_device_sensors(devicename, attrs) - self._log_start_finish_update_banner('▲-▲', devicename, "iOSApp", zone) - - except Exception as err: - attrs = self._internal_error_msg(fct_name, err, 'DetInterval') - self.any_device_being_updated_flag = False - return ICLOUD_UPDATE - - try: - #attrs should not be empty, but catch it and do an icloud update - #if it is and no data is available. Exit without resetting - #device_being_update_flag so an icloud update will be done. - if attrs == {} and self.TRK_METHOD_FMF_FAMSHR: - self.any_device_being_updated_flag = False - self.iosapp_request_loc_sent_secs[devicename] = 0 - - event_msg = ("iOS update was not completed, " - f"will retry with {self.trk_method_short_name}") - self._save_event_halog_debug(devicename, event_msg) - - return ICLOUD_UPDATE - - #Note: Final prep and update device attributes via - #device_tracker.see. The gps location, battery, and - #gps accuracy are not part of the attrs variable and are - #reformatted into device attributes by 'See'. The gps - #location goes to 'See' as a "(latitude, longitude)" pair. - #'See' converts them to ATTR_LATITUDE and ATTR_LONGITUDE - #and discards the 'gps' item. - - log_msg = (f"LOCATION ATTRIBUTES, State-{self.state_last_poll.get(devicename)}, " - f"Attrs-{attrs}") - self._log_debug_msg(devicename, log_msg) - - #If location is empty or trying to set to the Stationary Base Zone Location, - #discard the update and try again in 15-sec - check_gps = self._update_last_latitude_longitude( - devicename, - latitude, - longitude, - (f"iOSApp-{update_reason}")) - - if check_gps == False: - self.any_device_being_updated_flag = False - return ICLOUD_UPDATE - - self.count_update_iosapp[devicename] += 1 - self.last_battery[devicename] = battery - self.last_gps_accuracy[devicename] = gps_accuracy - self.last_located_time[devicename] = self._time_to_12hrtime(timestamp) - - if altitude is None: - altitude = 0 - - attrs[ATTR_LAST_LOCATED] = self._time_to_12hrtime(timestamp) - attrs[ATTR_DEVICE_STATUS] = self.device_status.get(devicename) - attrs[ATTR_LOW_POWER_MODE] = low_power_mode - attrs[ATTR_BATTERY] = battery - attrs[ATTR_BATTERY_STATUS] = battery_status - attrs[ATTR_ALTITUDE] = round(altitude, 2) - attrs[ATTR_VERT_ACCURACY] = vertical_accuracy - attrs[ATTR_POLL_COUNT] = self._format_poll_count(devicename) - - except Exception as err: - _LOGGER.exception(err) - - try: - kwargs = self._setup_base_kwargs( - devicename, - latitude, - longitude, - battery, - gps_accuracy) - - self._update_device_attributes(devicename, kwargs, attrs, 'Final Update') - self._update_device_sensors(devicename, kwargs) - self._update_device_sensors(devicename, attrs) - self._update_stationary_zone_location(devicename) - - self.seen_this_device_flag[devicename] = True - self.device_being_updated_flag[devicename] = False - - except Exception as err: - _LOGGER.exception(err) - log_msg = (f"{self._format_fname_devtype(devicename)} " - f"Error Updating Device, {err}") - self._log_error_msg(log_msg) - return_code = ICLOUD_UPDATE - - try: - event_msg = (f"IOS App update complete") - self._save_event(devicename, event_msg) - - self._log_start_finish_update_banner('▲▲▲', devicename, "iOSApp", update_reason) - - entity_id = self.device_tracker_entity_ic3.get(devicename) - ic3dev_attrs = self._get_device_attributes(entity_id) - #self._trace_device_attributes(devicename, 'AFTER.FINAL', fct_name, ic3dev_attrs) - - return_code = IOSAPP_UPDATE - - except KeyError as err: - self._internal_error_msg(fct_name, err, 'iosUpdateMsg') - return_code = ICLOUD_UPDATE - - except Exception as err: - _LOGGER.exception(err) - self._internal_error_msg(fct_name, err, 'OverallUpdate') - self.device_being_updated_flag[devicename] = False - return_code = ICLOUD_UPDATE - - self.any_device_being_updated_flag = False - #self.iosapp_request_loc_sent_secs[devicename] = 0 - return return_code - -######################################################### -# -# Using the iosapp tracking method or iCloud is disabled -# so trigger the iosapp to send a -# location transaction -# -######################################################### - def _iosapp_request_loc_update(self, devicename, reissue_msg=""): - ''' - Send location request to phone. Check to see if one has been sent but not responded to - and, if true, set interval based on the retry count. - ''' - - try: - age = self._secs_since(self.iosapp_request_loc_sent_secs.get(devicename)) - event_msg = "Requested iOSApp Loc" - - #Save initial sent time if not sent before, otherwise increase retry cnt and - #set new interval time - if self.iosapp_request_loc_sent_flag.get(devicename) == False: - self.iosapp_request_loc_sent_secs[devicename] = self.this_update_secs - self.iosapp_request_loc_retry_cnt[devicename] = 0 - event_msg += (f"@{self.this_update_time}") - else: - self.iosapp_request_loc_retry_cnt[devicename] += 1 - event_msg += (f"@{self._secs_to_time(self.iosapp_request_loc_sent_secs.get(devicename))}, " - f"({self._format_age(age)}), " - f"#{self.iosapp_request_loc_retry_cnt.get(devicename)}") - - if self.iosapp_request_loc_retry_cnt.get(devicename) > 10: - event_msg += ", May be offline/asleep" - - self._determine_interval_after_error( - devicename, - event_msg, - counter=IOSAPP_REQUEST_LOC_CNT) - - self.iosapp_request_loc_sent_flag[devicename] = True - self.iosapp_request_loc_cnt[devicename] += 1 - - message = {"message": "request_location_update"} - return_code = self._send_message_to_device(devicename, message) - - #self.hass.async_create_task( - # self.hass.services.async_call('notify', entity_id, service_data)) - - self._save_event_halog_info(devicename, event_msg) - - if return_code: - attrs = {} - attrs[ATTR_POLL_COUNT] = self._format_poll_count(devicename) - attrs[ATTR_INFO] = event_msg - self._update_device_sensors(devicename, attrs) - else: - event_msg = f"{EVA_NOTICE}{event_msg} > Failed to send message" - self._save_event_halog_info(devicename, event_msg) - - except Exception as err: - _LOGGER.exception(err) - error_msg = (f"iCloud3 Error > An error occurred sending a location request > " - f"Device-{self.notify_iosapp_entity.get(devicename)}, Error-{err}") - self._save_event_halog_error(devicename, error_msg) - - -######################################################### -# -# This function is called every 15 seconds. Cycle through all -# of the iCloud devices to see if any of the ones being tracked need -# to be updated. If so, we might as well update the information for -# all of the devices being tracked since PyiCloud gets data for -# every device in the account. -# -######################################################### - def _polling_loop_15_sec_icloud(self, now): - """Called every 15-sec to check iCloud update""" - - if self.any_device_being_updated_flag: - return - elif self.TRK_METHOD_IOSAPP: - return - - fct_name = "_polling_loop_15_sec_icloud" - - self.this_update_secs = self._time_now_secs() - this_update_time = dt_util.now().strftime(self.um_time_strfmt) - - try: - for devicename in self.tracked_devices: - update_reason = "Location Update" - devicename_zone = self._format_devicename_zone(devicename, HOME) - - if (self.tracking_device_flag.get(devicename) is False - or self.next_update_time.get(devicename_zone) == PAUSED): - continue - elif (self.state_this_poll.get(devicename) == NOT_SET - and self.next_update_secs.get(devicename_zone) > self.this_update_secs): - continue - - self.iosapp_update_flag[devicename] = False - update_method = None - event_msg = "" - - # If the state changed since last poll, force an update - # This can be done via device_tracker.see service call - # with a different location_name in an automation or - # from entering a zone via the IOS App. - entity_id = self.device_tracker_entity_ic3.get(devicename) - state = self._get_state(entity_id) - - if state != self.state_last_poll.get(devicename): - update_method = ICLOUD_UPDATE - update_reason = "State Change" - event_msg = (f"State Change detected for {devicename} > " - f"{self.state_last_poll.get(devicename)} to " - f"{state}") - self._save_event_halog_info(devicename, event_msg) - - if update_method == ICLOUD_UPDATE: - if 'nearzone' in state: - state = 'near_zone' - - self.state_this_poll[devicename] = state - self.next_update_secs[devicename_zone] = 0 - - attrs = {} - attrs[ATTR_INTERVAL] = '0 sec' - attrs[ATTR_NEXT_UPDATE_TIME] = HHMMSS_ZERO - self._update_device_sensors(devicename, attrs) - - #This flag will be 'true' if the last update for this device - #was not completed. Do another update now. - if (self.device_being_updated_flag.get(devicename) - and self.device_being_updated_retry_cnt.get(devicename) > 4): - self.device_being_updated_flag[devicename] = False - self.device_being_updated_retry_cnt[devicename] = 0 - self._log_debug_msgs_trace_flag = False - - log_msg = (f"{self._format_fname_devtype(devicename)} Cancelled update retry") - self._log_info_msg(log_msg) - - update_via_other_devicename = None - if self._check_in_zone_and_before_next_update(devicename): - continue - - elif self.device_being_updated_flag.get(devicename): - update_method = ICLOUD_UPDATE - self._log_debug_msgs_trace_flag = True - self.device_being_updated_retry_cnt[devicename] += 1 - - update_reason = "Retry Last Update" - event_msg = (f"{self.trk_method_short_name} update not completed, retrying") - self._save_event_halog_info(devicename, event_msg) - - elif self.next_update_secs.get(devicename_zone) == 0: - update_method = ICLOUD_UPDATE - self._log_debug_msgs_trace_flag = False - update_reason = "State Change/Resume" - event_msg = "State Change or Resume Polling Requested" - self._save_event(devicename, event_msg) - - else: - update_via_other_devicename = self._check_next_update_time_reached() - #TODO. Pia update from other device flag into update routine so it won't update of old loc and from another def and next update time not reached - if (update_via_other_devicename != devicename - and self.poor_location_gps_cnt.get(devicename) > 0 - and self.next_update_secs.get(devicename_zone) > self.this_update_secs): - pass - - elif update_via_other_devicename is not None: - self._log_debug_msgs_trace_flag = False - retry_suffix = "" if self.poor_location_gps_cnt.get(devicename) == 0 else " (Retry)" - update_method = ICLOUD_UPDATE - update_reason = f"Next Update Time reached{retry_suffix}" - self.trigger[devicename] = f"NextUpdateTime{retry_suffix}" - event_msg = (f"{update_reason} > {update_via_other_devicename}") - self._save_event(devicename, event_msg) - - elif update_method == ICLOUD_UPDATE: - retry_suffix = "" if self.poor_location_gps_cnt.get(devicename) == 0 else " (Retry)" - event_msg = (f"Next Update Time reached{retry_suffix} > {devicename}") - self._save_event(devicename, event_msg) - - if update_method == ICLOUD_UPDATE: - self._wait_if_update_in_process() - self.update_in_process_flag = True - - if self._check_authentication_2sa_code_needed(): - #Only display error once - if instr(self.info_notification, "ICLOUD"): - self.update_in_process_flag = False - return - - self.info_notification = (f"THE ICLOUD 2FA CODE IS NEEDED TO VERIFY " - f"THE ACCOUNT FOR {self.username}") - for devicename in self.tracked_devices: - self._display_info_status_msg(devicename, self.info_notification) - - log_msg = (f"iCloud3 Error > The iCloud 2fa code for account " - f"{self.username} needs to be verified. Use the HA " - f"Notifications area on the HA Sidebar at the bottom " - f"the HA main screen.") - self._save_event_halog_error(devicename, log_msg) - - # Trigger the next step immediately - self._icloud_show_trusted_device_request_form() - return - - self._update_device_icloud(update_reason, update_via_other_devicename) - - self.update_in_process_flag = False - - except Exception as err: #ValueError: - _LOGGER.exception(err) - - log_msg = (f"iCloud/FmF API Error, Error-{ValueError}") - self._log_error_msg(log_msg) - self.api.authenticate() #Reset iCloud - self.authenticated_time = time.time() - self._update_device_icloud('iCloud/FmF Reauth', update_via_other_devicename) #Retry update devices - - self.update_in_process_flag = False - self._log_debug_msgs_trace_flag = False - - -######################################################### -# -# Cycle through all iCloud devices and update the information for the devices -# being tracked -# ●►●◄►●▬▲▼◀►►●◀ oPhone=►▶ -######################################################### - def _update_device_icloud(self, update_reason='Check iCloud', - arg_devicename=None, arg_via_other_devicename=None): - - """ - Request device information from iCloud (if needed) and update - device_tracker information. - - arg_devicename - - None = Update all devices - Not None = Update specified device - arg_other-devicename - - None = Update all devices - Not None = One device in the list reached the next update time. - Do not update another device that now has poor location - gps after all the results have been determined if - their update time has not been reached. - - """ - - if self.TRK_METHOD_IOSAPP: - return - elif (self.start_icloud3_inprocess_flag and update_reason != 'Initial Locate'): - return - elif self.any_device_being_updated_flag: - return - fct_name = "_update_device_icloud" - - self.any_device_being_updated_flag = True - self.base_zone = HOME - - try: - for devicename in self.tracked_devices: - zone = self.zone_current.get(devicename) - devicename_home_zone = self._format_devicename_zone(devicename) - - if arg_devicename and devicename != arg_devicename: - continue - elif self.next_update_time.get(devicename_home_zone) == PAUSED: - continue - elif (self.state_this_poll.get(devicename) == NOT_SET - and self.next_update_secs.get(devicename_home_zone) > self.this_update_secs): - continue - - #If the device is in a zone, and was in the same zone on the - #last poll of another device on the account and this device - #update time has not been reached, do not update device - #information. Do this in case this device currently has bad gps - #and really doesn't need to be polled at this time anyway. - #If the iOS App triggered the update and it was not done by the - #iosapp_update routine, do one now anyway. - if (self._check_in_zone_and_before_next_update(devicename) - and arg_devicename is None): - continue - - event_msg = (f"{self.trk_method_short_name} update started " - f"({update_reason.split('@')[0]})") - self._save_event_halog_debug(devicename, event_msg) - - self._log_start_finish_update_banner('▼▼▼', devicename, - self.trk_method_short_name, update_reason) - - self.update_timer[devicename] = time.time() - self.iosapp_request_loc_sent_secs[devicename] = 0 - do_not_update_flag = False - location_time = 0 - - #Updating device info. Get data from FmF or FamShr and update - if self.TRK_METHOD_FMF: - valid_data_flag = self._get_fmf_data(devicename) - - elif self.TRK_METHOD_FAMSHR: - valid_data_flag = self._get_famshr_data(devicename) - - #An error ocurred accessing the iCloud account. This can be a - #Authentication error or an error retrieving the loction dataevligale - #if ic3dev_data[0] is False: - if valid_data_flag == ICLOUD_LOCATION_DATA_ERROR: - self.icloud_acct_auth_error_cnt += 1 - self._determine_interval_after_error( - devicename, - "iCloud Offline (Authentication or Location Error)", - counter=AUTH_ERROR_CNT) - - if (self.interval_seconds.get(devicename) != 15 - and self.icloud_acct_auth_error_cnt > 2): - log_msg = ("iCloud3 Error > An error occurred accessing " - f"the iCloud account {self.username} for {devicename}. This can be an account " - "authentication issue or no location data is " - "available. Retrying at next update time. " - "Retry #{self.icloud_acct_auth_error_cnt}") - self._save_event_halog_error("*", log_msg) - - if self.icloud_acct_auth_error_cnt > 20: - self._setup_tracking_method(IOSAPP) - log_msg = ("iCloud3 Error > More than 20 iCloud Authentication " - "errors. Resetting to use tracking_method . " - "Restart iCloud3 at a later time to see if iCloud " - "Loction Services is available.") - self._save_event_halog_error("*", log_msg) - - break - else: - self.icloud_acct_auth_error_cnt = 0 - - #icloud data overrules device data which may be stale - location_data = self.location_data.get(devicename) - latitude = location_data[ATTR_LATITUDE] - longitude = location_data[ATTR_LONGITUDE] - gps_accuracy = 0 - - if latitude != 0 and longitude != 0: - timestamp = location_data[ATTR_TIMESTAMP] - location_isold_attr = location_data[ATTR_ISOLD] - last_located_secs = location_data[ATTR_TIMESTAMP] - location_time = location_data[ATTR_TIMESTAMP_TIME] - battery = location_data[ATTR_BATTERY_LEVEL] - battery_status = location_data[ATTR_BATTERY_STATUS] - device_status = location_data[ATTR_DEVICE_STATUS] - low_power_mode = location_data[ATTR_LOW_POWER_MODE] - altitude = location_data[ATTR_ALTITUDE] - gps_accuracy = location_data[ATTR_GPS_ACCURACY] - vertical_accuracy = location_data[ATTR_VERT_ACCURACY] - - location_age = self._secs_since(location_data.get(ATTR_TIMESTAMP)) - location_data[ATTR_AGE] = location_age - self.location_data[devicename][ATTR_BATTERY_LEVEL] = battery - self.last_located_secs[devicename] = last_located_secs - self.device_status[devicename] = device_status - - poor_location_gps_flag, discard_reason = self._check_poor_location_gps( - devicename, - last_located_secs, - gps_accuracy) - - #Discard if no location coordinates - if latitude == 0 or longitude == 0: - location_time = NEVER - location_age = HIGH_INTEGER - self.poor_location_gps_cnt[devicename] += 1 - self.device_status[devicename] = UNKNOWN - discard_reason = (f"No location data " - f"(#{self.poor_location_gps_cnt.get(devicename)})") - device_offline_msg = (f"May be Offline, No Location " - f"(#{self.poor_location_gps_cnt.get(devicename)})") - info_msg = discard_reason - self._determine_interval_after_error( - devicename, - device_offline_msg) - do_not_update_flag = True - - #Check to see if currently in a zone. If so, check the zone distance. - #If new location is outside of the zone and inside radius*4, discard - #by treating it as poor GPS - elif (self._isnot_inzone_zonename(zone) - or self.state_this_poll.get(devicename) == NOT_SET): - outside_no_exit_trigger_flag = False - info_msg= '' - else: - outside_no_exit_trigger_flag, info_msg = \ - self._check_outside_zone_no_exit(devicename, zone, '', - latitude, longitude) - - if self.icloud_acct_auth_error_cnt > 0: - pass - - #If initializing, nothing is set yet - #elif (self.state_this_poll.get(devicename) == NOT_SET - # and location_age < 300): - # pass - - #If no location data - elif do_not_update_flag: - pass - - #Ignore old location when in a zone, let normal next time update - #check process - elif (poor_location_gps_flag - and self.this_update_secs < self.next_update_secs.get(devicename_home_zone) - and (self._is_inzone(devicename) - or devicename != arg_via_other_devicename)): - self.poor_location_gps_cnt[devicename] -= 1 - pass - - #elif device_status not in ["online", "pending", ""]: - elif self._device_online(devicename) == False: - do_not_update_flag = True - info_msg = "Device Offline" - self._determine_interval_after_error( - devicename, - info_msg) - event_msg = (f"{EVLOG_ALERT}iCloud Alert > {devicename} is Offline > Tracking is delayed, " - f"Status-{self.device_status.get(devicename)}, " - f"OnlineStatus-{self.device_status_online}") - self._save_event(devicename, event_msg) - - #elif self.state_this_poll.get(devicename) == NOT_SET: - # pass - - #Outside zone, no exit trigger check - elif outside_no_exit_trigger_flag: - self.poor_gps_flag[devicename] = True - self.poor_location_gps_cnt[devicename] += 1 - do_not_update_flag = True - self._determine_interval_after_error( - devicename, - info_msg) - - #Discard if location is old or poor gps - elif poor_location_gps_flag: - info_msg = (f"{discard_reason}") - do_not_update_flag = True - self._determine_interval_after_error( - devicename, - info_msg) - - #discard if outside home zone and less than zone_radius+self.gps_accuracy_threshold due to gps errors - dist_from_home_m = self._calc_distance_m(latitude, longitude, - self.zone_home_lat, self.zone_home_long) - - if do_not_update_flag: - location_age_str = "" if location_time == NEVER else (f" ({self._format_age(location_age)})") - event_msg = (f"Discarded > {info_msg} > " - f"GPS-{format_gps(latitude, longitude, gps_accuracy)}, " - f"Located-{location_time}{location_age_str}, " - f"NextUpdate-{self.next_update_time.get(self._format_devicename_zone(devicename))}, " - f"OldLocThreshold-{self._secs_to_time_str(self.old_location_secs.get(devicename))}") - if self._device_online(devicename) == False: - event_msg += (f", DeviceStatus-{self.device_status.get(devicename)}") - self._save_event(devicename, event_msg) - - self._log_start_finish_update_banner('▲▲▲', devicename, - self.trk_method_short_name, update_reason) - continue - - #-------------------------------------------------------- - try: - if self.device_being_updated_flag.get(devicename): - info_msg = "Retrying > Last update not completed" - event_msg = info_msg - else: - info_msg = "Updating" - event_msg = (f"Updating Device > GPS-{format_gps(latitude, longitude, gps_accuracy)}, " - f"Located-{location_time} ({self._format_age(location_age)})") - info_msg = f"{info_msg} {self.fname.get(devicename)}" - self._display_info_status_msg(devicename, info_msg) - self._save_event(devicename, event_msg) - - #set device being updated flag. This is checked in the - #'_polling_loop_15_sec_icloud' loop to make sure the last update - #completed successfully (Waze has a compile error bug that will - #kill update and everything will sit there until the next poll. - #if this is still set in '_polling_loop_15_sec_icloud', repoll - #immediately!!! - self.device_being_updated_flag[devicename] = True - - except Exception as err: - attrs = self._internal_error_msg(fct_name, err, 'UpdateAttrs1') - - try: - for zone in self.track_from_zone.get(devicename): - self._set_base_zone_name_lat_long_radius(zone) - - self._log_start_finish_update_banner('▼-▼', devicename, - self.trk_method_short_name, zone) - self.location_data[devicename][ATTR_BATTERY_LEVEL] = battery - attrs = self._determine_interval( - devicename, - latitude, - longitude, - battery, - gps_accuracy, - poor_location_gps_flag, - last_located_secs, - location_time, - "icld") - if attrs != {}: - self._update_device_sensors(devicename, attrs) - - self._log_start_finish_update_banner('▲-▲', devicename, - self.trk_method_short_name,zone) - - except Exception as err: - attrs = self._internal_error_msg(fct_name, err, 'DetInterval') - continue - - try: - #Note: Final prep and update device attributes via - #device_tracker.see. The gps location, battery, and - #gps accuracy are not part of the attrs variable and are - #reformatted into device attributes by 'See'. The gps - #location goes to 'See' as a "(latitude, longitude)" pair. - #'See' converts them to ATTR_LATITUDE and ATTR_LONGITUDE - #and discards the 'gps' item. - log_msg = (f"LOCATION ATTRIBUTES, " - f"State-{self.state_last_poll.get(devicename)}, " - f"Attrs-{attrs}") - self._log_debug_msg(devicename, log_msg) - - self.count_update_icloud[devicename] += 1 - if not poor_location_gps_flag: - self._update_last_latitude_longitude( - devicename, latitude, longitude, - (f"iCloud-{update_reason}")) - - if altitude is None: - altitude = -2 - - #attrs[ATTR_DEVICE_STATUS] = device_status - attrs[ATTR_DEVICE_STATUS] = self.device_status.get(devicename) - attrs[ATTR_LOW_POWER_MODE] = low_power_mode - attrs[ATTR_BATTERY] = battery - attrs[ATTR_BATTERY_STATUS] = battery_status - attrs[ATTR_ALTITUDE] = round(altitude, 2) - attrs[ATTR_VERT_ACCURACY] = vertical_accuracy - attrs[ATTR_POLL_COUNT] = self._format_poll_count(devicename) - attrs[ATTR_AUTHENTICATED] = self._secs_to_timestamp(self.authenticated_time) - - except Exception as err: - attrs = self._internal_error_msg(fct_name, err, 'SetAttrs') - - try: - kwargs = self._setup_base_kwargs(devicename, - latitude, longitude, battery, gps_accuracy) - - self._update_device_attributes(devicename, kwargs, attrs, 'Final Update') - self._update_device_sensors(devicename, kwargs) - self._update_device_sensors(devicename, attrs) - self._update_stationary_zone_location(devicename) - - self.seen_this_device_flag[devicename] = True - self.device_being_updated_flag[devicename] = False - - except Exception as err: - log_msg = (f"{self._format_fname_devtype(devicename)} Error Updating Device, {err}") - self._log_error_msg(log_msg) - - _LOGGER.exception(err) - - try: - event_msg = (f"{self.trk_method_short_name} update completed") - self._save_event(devicename, event_msg) - - self._log_start_finish_update_banner('▲▲▲', devicename, - self.trk_method_short_name, update_reason) - - except KeyError as err: - self._internal_error_msg(fct_name, err, 'icloudUpdateMsg') - - except Exception as err: - _LOGGER.exception(err) - self._internal_error_msg(fct_name, err, 'OverallUpdate') - self.device_being_updated_flag[devicename] = False - - self.any_device_being_updated_flag = False - -######################################################### -# -# Get iCloud device & location info when using the -# FmF (Find-my-Friends / Find Me) tracking method. -# -######################################################### - def _get_fmf_data(self, devicename): - ''' - Get the location data from Find My Friends. - - location_data-{ - 'locationStatus': None, - 'location': { - 'isInaccurate': False, - 'altitude': 0.0, - 'address': {'formattedAddressLines': ['123 Main St', - 'Your City, NY', 'United States'], - 'country': 'United States', - 'streetName': 'Main St, - 'streetAddress': '123 Main St', - 'countryCode': 'US', - 'locality': 'Your City', - 'stateCode': 'NY', - 'administrativeArea': 'New York'}, - 'locSource': None, - 'latitude': 12.34567890, - 'floorLevel': 0, - 'horizontalAccuracy': 65.0, - 'labels': [{'id': '79f8e34c-d577-46b4-a6d43a7b891eca843', - 'latitude': 12.34567890, - 'longitude': -45.67890123, - 'info': None, - 'label': '_$!!$_', - 'type': 'friend'}], - 'tempLangForAddrAndPremises': None, - 'verticalAccuracy': 0.0, - ATTR_ICLOUD_BATTERY_STATUS: None, - 'locationId': 'a6b0ee1d-be34-578a-0d45-5432c5753d3f', - 'locationTimestamp': 0, - 'longitude': -45.67890123, - 'timestamp': 1562512615222}, - 'id': 'NDM0NTU2NzE3', - 'status': None} - ''' - - fct_name = "_get_fmf_data" - from .pyicloud_ic3 import PyiCloudNoDevicesException - - log_msg = (f"= = = Prep Data From FmF = = = (Now-{self.this_update_secs})") - self._log_debug_msg(devicename, log_msg) - - try: - self._display_info_status_msg(devicename, "Getting iCloud Location") - location_data = self.location_data.get(devicename) - - log_msg = (f"LOCATION DATA-{location_data})") - self._log_debug_msg(devicename, log_msg) - - location_age = location_data[ATTR_AGE] - - if location_age > self.old_location_secs.get(devicename): - if self._refresh_pyicloud_devices_location_data(location_age, devicename): - self._display_info_status_msg(devicename, "Location Data Old, Refreshing") - location_data = self.location_data.get(devicename) - - evlog_msg = (f"Refreshed FmF iCloud location data") - self._evlog_debug_msg(devicename, evlog_msg) - else: - self._display_info_status_msg(devicename, "No iCloud Location Available") - if self.icloud_acct_auth_error_cnt > 3: - self._log_error_msg(f"iCloud3 Error > No Location Data " - f"Returned for {devicename}") - return ICLOUD_LOCATION_DATA_ERROR - - if self._device_online(devicename): - self._display_info_status_msg(devicename, "iCloud Location Data Available") - - return True - - except Exception as err: - _LOGGER.exception(err) - self._log_error_msg("General iCloud Location Data Error") - return ICLOUD_LOCATION_DATA_ERROR - -######################################################### -# -# Get iCloud device & location info when using the -# FamShr (Family Sharing) tracking method. -# -######################################################### - def _get_famshr_data(self, devicename): - ''' - Extract the data needed to determine location, direction, interval, - etc. from the iCloud data set. - - Sample data set is: - {'isOld': False, 'isInaccurate': False, 'altitude': 0.0, 'positionType': 'Wifi', - 'latitude': 27.72690098883266, 'floorLevel': 0, 'horizontalAccuracy': 65.0, - 'locationType': '', 'timeStamp': 1587306847548, 'locationFinished': True, - 'verticalAccuracy': 0.0, 'longitude': -80.3905776599289} - ''' - - fct_name = "_get_famshr_data" - - log_msg = (f"= = = Prep Data From FamShr = = = (Now-{self.this_update_secs})") - self._log_debug_msg(devicename, log_msg) - - try: - self._display_info_status_msg(devicename, "Getting iCloud Location") - location_data = self.location_data.get(devicename) - - log_msg = (f"LOCATION DATA-{location_data})") - self._log_debug_msg(devicename, log_msg) - - location_age = location_data[ATTR_AGE] - - if location_age > self.old_location_secs.get(devicename): - if self._refresh_pyicloud_devices_location_data(location_age, devicename): - self._display_info_status_msg(devicename, "Location Data Old, Refreshing") - location_data = self.location_data.get(devicename) - - evlog_msg = (f"Refreshed FamShr iCloud location data") - self._evlog_debug_msg(devicename, evlog_msg) - else: - self._display_info_status_msg(devicename, "No iCloud Location Available") - if self.icloud_acct_auth_error_cnt > 3: - self._log_error_msg(f"iCloud3 Error > No Location Data " - f"Returned for {devicename}") - return ICLOUD_LOCATION_DATA_ERROR - self._display_info_status_msg(devicename, "iCloud Location Data Available") - return True - - except Exception as err: - _LOGGER.exception(err) - self._log_error_msg("General iCloud FamShr Location Data Error") - return ICLOUD_LOCATION_DATA_ERROR - -#---------------------------------------------------------------------------- - def _refresh_pyicloud_devices_location_data(self, location_age, arg_devicename): - ''' - Authenticate pyicloud & refresh device & location data. This calls the - function to update 'self.location_data' for each device being tracked. - - Return: True if device data was updated successfully - False if api error or no device data returned - ''' - - try: - event_msg=(f"Sending location request to iCloud > Last Updated-") - if location_age == HIGH_INTEGER: - event_msg += (f"Never (Initial Locate)") - else: - event_msg += (f"{self.location_data.get(arg_devicename)[ATTR_TIMESTAMP_TIME]} " - f"({self._format_age(location_age)})") - self._save_event_halog_info(arg_devicename, event_msg) - - exit_get_data_loop = False - authenticated_pyicloud_flag = False - self.count_pyicloud_location_update += 1 - pyicloud_start_call_time = time.time() - - while exit_get_data_loop == False: - try: - if self.api is None: - return False - - if self.TRK_METHOD_FMF: - self.api.friends.refresh_client() - devices = self.api.friends - locations = devices.locations - - for location in locations: - if ATTR_LOCATION in location: - contact_id = location['id'] - if contact_id in self.fmf_id: - devicename = self.fmf_id[contact_id] - self._update_location_data(devicename, location) - - elif self.TRK_METHOD_FAMSHR: - api_devices = {} - api_devices = self.api.devices - api_device_data = api_devices.response["content"] - - for device in api_device_data: - if device: - device_data_name = device[ATTR_NAME] - if device_data_name in self.api_device_devicename: - devicename = self.api_device_devicename.get(device_data_name) - if devicename in self.tracked_devices: - self._update_location_data(devicename, device) - - except PyiCloud2SARequiredException as err: - if authenticated_pyicloud_flag: - return False - - authenticated_pyicloud_flag = True - self._check_authentication_2sa_code_needed() - if self.api is not None: - self._pyicloud_authenticate_account(devicename=arg_devicename) - - except PyiCloudAPIResponseException as err: - if authenticated_pyicloud_flag: - return False - - authenticated_pyicloud_flag = True - self._pyicloud_authenticate_account(devicename=arg_devicename) - - except Exception as err: - _LOGGER.exception(err) - - else: - exit_get_data_loop = True - - update_took_time = time.time() - pyicloud_start_call_time - self.time_pyicloud_calls += update_took_time - - return True - - except Exception as err: - _LOGGER.exception(err) - - return False - -#---------------------------------------------------------------------------- - def _update_location_data(self, devicename, device_data): - ''' - Extract the location_data dictionary table from the device - data returned from pyicloud for the devicename device. This data is used to - determine the update interval, accuracy, location, etc. - ''' - try: - if device_data is None: - return False - elif ATTR_LOCATION not in device_data: - return False - elif device_data[ATTR_LOCATION] == {}: - return - elif device_data[ATTR_LOCATION] is None: - return - - self._log_level_debug_rawdata("update_location_data (device_data)", device_data) - - try: - timestamp_field = ATTR_TIMESTAMP if self.TRK_METHOD_FMF else ATTR_ICLOUD_TIMESTAMP - timestamp = device_data[ATTR_LOCATION][timestamp_field] / 1000 - - if timestamp == self.location_data.get(devicename)[ATTR_TIMESTAMP]: - location_age = self.location_data.get(devicename)[ATTR_AGE] - debug_msg = (f"DEVICE NOT UPDATED > Will Refresh, Located-{self._secs_to_time(timestamp)} " - f"({self._format_age(location_age)})") - self._log_debug_msg(devicename, debug_msg) - return - - except: - return - - iosapp_battery = self.last_battery.get(devicename, -1) - icloud_battery = int(device_data.get(ATTR_ICLOUD_BATTERY_LEVEL, 0) * 100) - - location_data = {} - location_data[ATTR_NAME] = device_data.get(ATTR_NAME, "") - location_data[ATTR_DEVICE_CLASS] = device_data.get(ATTR_ICLOUD_DEVICE_CLASS, "") - location_data[ATTR_BATTERY_LEVEL] = icloud_battery if icloud_battery > 0 else iosapp_battery - location_data[ATTR_BATTERY_STATUS] = device_data.get(ATTR_ICLOUD_BATTERY_STATUS, "") - - device_status_code = device_data.get(ATTR_ICLOUD_DEVICE_STATUS, 0) - location_data[ATTR_DEVICE_STATUS] = DEVICE_STATUS_CODES.get(device_status_code, "") - location_data[ATTR_LOW_POWER_MODE] = device_data.get(ATTR_ICLOUD_LOW_POWER_MODE, "") - - location = device_data[ATTR_LOCATION] - location_data[ATTR_TIMESTAMP] = timestamp - location_data[ATTR_TIMESTAMP_TIME] = self._secs_to_time(timestamp) - location_data[ATTR_AGE] = self._secs_since(timestamp) - location_data[ATTR_LATITUDE] = location.get(ATTR_LATITUDE, 0) - location_data[ATTR_LONGITUDE] = location.get(ATTR_LONGITUDE, 0) - location_data[ATTR_ALTITUDE] = round(location.get(ATTR_ALTITUDE, 0), 1) - location_data[ATTR_ISOLD] = location.get(ATTR_ISOLD, False) - location_data[ATTR_GPS_ACCURACY] = int(round(location.get(ATTR_ICLOUD_HORIZONTAL_ACCURACY, 0), 0)) - location_data[ATTR_VERT_ACCURACY] = int(round(location.get(ATTR_ICLOUD_VERTICAL_ACCURACY, 0), 0)) - - self.location_data[devicename] = location_data - - debug_msg = (f"UPDATE LOCATION > Located-{location_data[ATTR_TIMESTAMP_TIME]} " - f"({self._format_age(location_data[ATTR_AGE])})") - self._log_debug_msg(devicename, debug_msg) - self._log_debug_msg(devicename, location_data) - - return True - - except Exception as err: - _LOGGER.exception(err) - - return False - -######################################################### -# -# Calculate polling interval based on zone, distance from home and -# battery level. Setup triggers for next poll -# -######################################################### - def _determine_interval(self, devicename, latitude, longitude, - battery, gps_accuracy, poor_location_gps_flag, - last_located_secs, location_time, - ios_icld = ''): - """Calculate new interval. Return location based attributes""" - - fct_name = "_determine_interval" - - base_zone_home_flag = (self.base_zone == HOME) - devicename_zone = self._format_devicename_zone(devicename) - - try: - location_data = self._get_distance_data( - devicename, - latitude, - longitude, - gps_accuracy, - poor_location_gps_flag) - - log_msg = (f"Location_data-{location_data}") - self._log_debug_interval_msg(devicename, log_msg) - - #Abort and Retry if Internal Error - if (location_data[0] == ERROR): - return location_data[1] #(attrs) - - self._display_info_status_msg(devicename, "● Calculating Tracking Info ●") - - zone = location_data[0] - dir_of_travel = location_data[1] - dist_from_zone_km = location_data[2] - dist_from_zone_moved_km = location_data[3] - dist_last_poll_moved_km = location_data[4] - waze_dist_from_zone_km = location_data[5] - calc_dist_from_zone_km = location_data[6] - waze_dist_from_zone_moved_km = location_data[7] - calc_dist_from_zone_moved_km = location_data[8] - waze_dist_last_poll_moved_km = location_data[9] - calc_dist_last_poll_moved_km = location_data[10] - waze_time_from_zone = location_data[11] - last_dist_from_zone_km = location_data[12] - last_dir_of_travel = location_data[13] - dir_of_trav_msg = location_data[14] - timestamp = location_data[15] - - last_located_secs = self.last_located_secs.get(devicename) - - except Exception as err: - attrs_msg = self._internal_error_msg(fct_name, err, 'SetLocation') - return attrs_msg - - try: - log_msg = (f"DETERMINE INTERVAL Entered, " - "location_data-{location_data}") - self._log_debug_interval_msg(devicename, log_msg) - - # The following checks the distance from home and assigns a - # polling interval in minutes. It assumes a varying speed and - # is generally set so it will poll one or twice for each distance - # group. When it gets real close to home, it switches to once - # each 15 seconds so the distance from home will be calculated - # more often and can then be used for triggering automations - # when you are real close to home. When home is reached, - # the distance will be 0. - - waze_time_msg = "" - calc_interval = round(self._km_to_mi(dist_from_zone_km) / 1.5) * 60 - if self.waze_status == WAZE_USED: - waze_interval = round(waze_time_from_zone * 60 * self.travel_time_factor , 0) - else: - waze_interval = 0 - interval = 15 - interval_multiplier = 1 - interval_str = '' - - inzone_flag = (self._is_inzone_zonename(zone)) - not_inzone_flag = (self._isnot_inzone_zonename(zone)) - was_inzone_flag = (self._was_inzone(devicename)) - wasnot_inzone_flag = (self._wasnot_inzone(devicename)) - inzone_home_flag = (zone == self.base_zone) #HOME) - was_inzone_home_flag = \ - (self.state_last_poll.get(devicename) == self.base_zone) #HOME) - near_zone_flag = (zone == 'near_zone') - - log_msg = (f"Zone-{zone} ,IZ-{inzone_flag}, NIZ-{not_inzone_flag}, " - f"WIZ-{was_inzone_flag}, WNIZ-{wasnot_inzone_flag}, " - f"IZH-{inzone_home_flag}, WIZH-{was_inzone_home_flag}, " - f"NZ-{near_zone_flag}") - self._log_debug_interval_msg(devicename, log_msg) - - interval_method = '' - log_msg = '' - interval_method_im = '' - old_location_secs_msg = '' - - except Exception as err: - attrs_msg = self._internal_error_msg(fct_name, err, 'SetupZone') - return attrs_msg - - try: - if inzone_flag: - #Reset got zone exit trigger since now in a zone for next - #exit distance check. Also reset Stat Zone timer and dist moved. - self.got_exit_trigger_flag[devicename] = False - self.stat_zone_timer[devicename] = 0 - self.stat_zone_moved_total[devicename] = 0 - - #Note: If state is 'near_zone', it is reset to NOT_HOME when - #updating the device_tracker state so it will not trigger a state chng - if self.state_change_flag.get(devicename): - if inzone_flag: - if (STATIONARY in zone): - interval = self.stat_zone_inzone_interval - interval_method = "1sz-Stationary" - log_msg = f"Zone-{zone}" - - #inzone & old location - elif poor_location_gps_flag: - interval = self._get_interval_for_error_retry_cnt(devicename, POOR_LOC_GPS_CNT) - #self.poor_location_gps_cnt.get(devicename)) - interval_method = '1iz-OldLocPoorGPS' - - else: - interval = self.inzone_interval_secs - interval_method="1ez-EnterZone" - - #entered 'near_zone' zone if close to HOME and last is NOT_HOME - elif (near_zone_flag and wasnot_inzone_flag and - calc_dist_from_zone_km < 2): - interval = 15 - dir_of_travel = 'NearZone' - interval_method="1nzniz-EnterNearZone" - - #entered 'near_zone' zone if close to HOME and last is NOT_HOME - elif (near_zone_flag and was_inzone_flag - and calc_dist_from_zone_km < 2): - interval = 15 - dir_of_travel = 'NearZone' - interval_method="1nziz-EnterNearZone" - - #exited HOME zone - elif (not_inzone_flag and was_inzone_home_flag): - interval = 240 - dir_of_travel = AWAY_FROM - interval_method="1ehz-ExitHomeZone" - - #exited 'other' zone - elif (not_inzone_flag and was_inzone_flag): - interval = 120 - dir_of_travel = 'left_zone' - interval_method="1ez-ExitZone" - - #entered 'other' zone - else: - interval = 240 - interval_method="1zc-ZoneChanged" - - log_msg = (f"Zone-{zone}, Last-{self.state_last_poll.get(devicename)}, " - f"This-{self.state_this_poll.get(devicename)}") - self._log_debug_interval_msg(devicename, log_msg) - - #inzone & poor gps & check gps accuracy when inzone - elif (self.poor_gps_flag.get(devicename) - and inzone_flag - and self.check_gps_accuracy_inzone_flag): - interval = 300 #poor accuracy, try again in 5 minutes - interval_method = '2pgpsiz-PoorGPSinZone' - - elif self.poor_gps_flag.get(devicename): - interval = self._get_interval_for_error_retry_cnt(devicename, POOR_LOC_GPS_CNT) - interval_method = '2pgps-PoorGPS' - - elif self.override_interval_seconds.get(devicename) > 0: - interval = self.override_interval_seconds.get(devicename) - interval_method = '3ior-Override' - - elif (STATIONARY in zone): - interval = self.stat_zone_inzone_interval - interval_method = "4sz-Stationary" - log_msg = f"Zone-{zone}" - - elif poor_location_gps_flag: - interval = self._get_interval_for_error_retry_cnt(devicename, POOR_LOC_GPS_CNT) - interval_method = '4old-OldLocPoorGPS' - log_msg = f"Cnt-{self.poor_location_gps_cnt.get(devicename)}" - - elif (inzone_home_flag - or (dist_from_zone_km < .05 - and dir_of_travel == 'towards')): - interval = self.inzone_interval_secs - interval_method = '4ihz-InHomeZone' - log_msg = f"Zone-{zone}" - - elif zone == 'near_zone': - interval = 15 - interval_method = '4nz-NearZone' - log_msg = f"Zone-{zone}, Dir-{dir_of_travel}" - - #in another zone and inzone time > travel time - elif (inzone_flag - and self.inzone_interval_secs > waze_interval): - interval = self.inzone_interval_secs - interval_method = '4iz-InZone' - log_msg = f"Zone-{zone}" - - elif dir_of_travel in ('left_zone', NOT_SET): - interval = 150 - if inzone_home_flag: - dir_of_travel = AWAY_FROM - else: - dir_of_travel = NOT_SET - interval_method = '5ni-NeedInfo' - log_msg = f"ZoneLeft-{zone}" - - elif dist_from_zone_km < 2.5 and self.went_3km.get(devicename): - interval = 15 #1.5 mi=real close and driving - interval_method = '10a-<2.5km' - - elif dist_from_zone_km < 3.5: #2 mi=30 sec - interval = 30 - interval_method = '10b-<3.5km' - - elif waze_time_from_zone > 5 and waze_interval > 0: - interval = waze_interval - interval_method = '10c-WazeTime' - log_msg = f"TimeFmHome-{waze_time_from_zone}" - - elif dist_from_zone_km < 5: #3 mi=1 min - interval = 60 - interval_method = '10d-<5km' - - elif dist_from_zone_km < 8: #5 mi=2 min - interval = 120 - interval_method = '10e-<8km' - - elif dist_from_zone_km < 12: #7.5 mi=3 min - interval = 180 - interval_method = '10f-<12km' - - - elif dist_from_zone_km < 20: #12 mi=10 min - interval = 600 - interval_method = '10g-<20km' - - elif dist_from_zone_km < 40: #25 mi=15 min - interval = 900 - interval_method = '10h-<40km' - - elif dist_from_zone_km > 150: #90 mi=1 hr - interval = 3600 - interval_method = '10i->150km' - - else: - interval = calc_interval - interval_method = '20calc-Calculated' - log_msg = f"Value-{self._km_to_mi(dist_from_zone_km)}/1.5" - - except Exception as err: - attrs_msg = self._internal_error_msg(fct_name, err, 'SetInterval') - - try: - #if haven't moved far for X minutes, put in stationary zone - #determined in get_dist_data with dir_of_travel - if dir_of_travel == STATIONARY: - interval = self.stat_zone_inzone_interval - interval_method = "21stat-Stationary" - - if self.in_stationary_zone_flag.get(devicename) is False: - rtn_code = self._update_stationary_zone( - devicename, - latitude, - longitude, - STAT_ZONE_NEW_LOCATION, '21-Stationary') - - self.in_stationary_zone_flag[devicename] = rtn_code - if rtn_code: - self.zone_current[devicename] = self._format_zone_name(devicename, STATIONARY) - self.zone_timestamp[devicename] = dt_util.now().strftime(self.um_date_time_strfmt) - interval_method_im = "●Set.Stationary.Zone" - zone = STATIONARY - dir_of_travel = 'in_zone' - inzone_flag = True - not_inzone_flag = False - else: - dir_of_travel = NOT_SET - if (dir_of_travel in ('', AWAY_FROM) - and interval < 180 - and interval > 0): - interval_method += ',xx30dot-Away(<3min)' - - if (dir_of_travel in ('', AWAY_FROM) - and interval < 180 - and interval > 30): - interval = 180 - interval_method += ',30dot-Away(<3min)' - - elif (dir_of_travel == AWAY_FROM - and not self.distance_method_waze_flag): - interval_multiplier = 2 #calc-increase timer - interval_method += ',30dot-Away(Calc)' - elif (dir_of_travel == NOT_SET - and interval > 180): - interval = 180 - interval_method += ',301dor->180s' - - #15-sec interval (close to zone) and may be going into a stationary zone, - #increase the interval - elif (interval == 15 - and devicename in self.stat_zone_timer - and self.this_update_secs >= self.stat_zone_timer.get(devicename)+45): - interval = 30 - interval_method += ',31st-StatTimer+45' - - except Exception as err: - attrs_msg = self._internal_error_msg(fct_name, err, 'SetStatZone') - _LOGGER.exception(err) - - - try: - #Turn off waze close to zone flag to use waze after leaving zone - if inzone_flag: - self.waze_close_to_zone_pause_flag = False - if (self.iosapp_update_flag.get(devicename) - and interval < 180 - and self.override_interval_seconds.get(devicename) == 0): - interval_method += ',xx33ios-iosAppTrigger' - #if triggered by ios app (Zone Enter/Exit, Manual, Fetch, etc.) - #and interval < 3 min, set to 3 min. Leave alone if > 3 min. - if (self.iosapp_update_flag.get(devicename) - and interval < 180 - and interval > 30 - and self.override_interval_seconds.get(devicename) == 0): - interval = 180 - interval_method += ',33ios-iosAppTrigger' - - #if changed zones on this poll reset multiplier - if self.state_change_flag.get(devicename): - interval_multiplier = 1 - - #Check accuracy again to make sure nothing changed, update counter - if self.poor_gps_flag.get(devicename): - interval_multiplier = 1 - - except Exception as err: - attrs_msg = self._internal_error_msg(fct_name, err, 'ResetStatZone') - return attrs_msg - - try: - #Real close, final check to make sure interval is not adjusted - if (interval <= 60 - or (battery > 0 and battery <= 33 and interval >= 120)): - interval_multiplier = 1 - - interval = interval * interval_multiplier - interval, x = divmod(interval, 15) - interval = interval * 15 - - #check for max interval - if interval > self.max_interval_secs and not_inzone_flag: - interval_str = (f"{self._secs_to_time_str(self.max_interval_secs)}({self._secs_to_time_str(interval)})") - interval = self.max_interval_secs - interval_method += (f",40-Max") - else: - interval_str = self._secs_to_time_str(interval) - - interval_debug_msg = (f"●Interval-{interval_str} ({interval_method}, {log_msg}), " - f"●DirOfTrav-{dir_of_trav_msg}, " - f"●State-{self.state_last_poll.get(devicename)}->, " - f"{self.state_this_poll.get(devicename)}, " - f"Zone-{zone}") - event_msg = (f"Interval basis: {interval_method}, {log_msg}, Direction {dir_of_travel}") - #self._save_event(devicename, event_msg) - - if interval_multiplier > 1: - interval_method += (f"x{interval_multiplier}") - interval_debug_msg = (f"{interval_debug_msg}, " - f"Multiplier-{interval_multiplier} ({interval_method})") - - #check if next update is past midnight (next day), if so, adjust it - next_poll = round((self.this_update_secs + interval)/15, 0) * 15 - - # Update all dates and other fields - self.next_update_secs[devicename_zone] = next_poll - self.next_update_time[devicename_zone] = self._secs_to_time(next_poll) - self.interval_seconds[devicename_zone] = interval - self.interval_str[devicename_zone] = interval_str - self.last_update_secs[devicename_zone] = self.this_update_secs - self.last_update_time[devicename_zone] = self._time_to_12hrtime(self.this_update_time) - - #-------------------------------------------------------------------------------- - #Calculate the old_location age check based on the direction and if there are - #multiple zones being tracked from - - zi=self.track_from_zone.get(devicename).index(self.base_zone) - if zi == 0: - self.old_location_secs[devicename] = HIGH_INTEGER - - ic3dev_old_location_secs = self.old_location_secs.get(devicename) - new_old_location_secs = self._determine_old_location_secs(zone, interval) - select='' - if inzone_flag: - select='inzone' - ic3dev_old_location_secs = new_old_location_secs - - #Other base_zones are calculated before home zone, use smallest value - elif new_old_location_secs < ic3dev_old_location_secs: - select='ols < self.ols' - ic3dev_old_location_secs = new_old_location_secs - - elif base_zone_home_flag and ic3dev_old_location_secs == HIGH_INTEGER: - select='zone-home & HIGH_INTEGER' - ic3dev_old_location_secs = new_old_location_secs - - self.old_location_secs[devicename] = ic3dev_old_location_secs - old_location_secs_msg = self._secs_to_time_str(self.old_location_secs.get(devicename)) - - #-------------------------------------------------------------------------------- - #if more than 3km(1.8mi) then assume driving, used later above - if dist_from_zone_km > 3: # 1.8 mi - self.went_3km[devicename] = True - elif dist_from_zone_km < .03: # home, reset flag - self.went_3km[devicename] = False - - except Exception as err: - attrs_msg = self._internal_error_msg(fct_name, err, 'SetTimes') - - #-------------------------------------------------------------------------------- - try: - log_msg = (f"►INTERVAL FORMULA, {interval_debug_msg}") - self._log_debug_interval_msg(devicename, log_msg) - - if self.log_level_intervalcalc_flag == False: - interval_debug_msg = '' - - event_msg = (f"{EVLOG_DEBUG}Interval Results > " - f"Interval-{interval_str} ({interval_method}), " - f"LastUpdate-{self.last_update_time.get(devicename_zone)}, " - f"NextUpdate-{self.next_update_time.get(devicename_zone)}, " - f"DistTraveled-{self._format_dist(calc_dist_last_poll_moved_km)}, " - f"CurrZone-{zone}") - if self.override_interval_seconds.get(devicename) > 0: - event_msg += (f", OverrideInterval-{self._secs_to_time_str(self.override_interval_seconds.get(devicename))}") - self._save_event_halog_info(devicename, event_msg, log_title="") - - except Exception as err: - attrs_msg = self._internal_error_msg(fct_name, err, 'ShowMsgs') - - - try: - #if 'NearZone' zone, do not change the state - if near_zone_flag: - zone = NOT_HOME - - log_msg = (f"DIR OF TRAVEL ATTRS, Direction-{dir_of_travel}, " - f"LastDir-{last_dir_of_travel}, " - f"Dist-{dist_from_zone_km}, " - f"LastDist-{last_dist_from_zone_km}, " - f"SelfDist-{self.zone_dist.get(devicename_zone)}, " - f"Moved-{dist_from_zone_moved_km}," - f"WazeMoved-{waze_dist_from_zone_moved_km}") - self._log_debug_interval_msg(devicename, log_msg) - - #if poor gps and moved less than 1km, redisplay last distances - if (self.state_change_flag.get(devicename) == False - and self.poor_gps_flag.get(devicename) - and dist_last_poll_moved_km < 1): - dist_from_zone_km = self.zone_dist.get(devicename_zone) - waze_dist_from_zone_km = self.waze_dist.get(devicename_zone) - calc_dist_from_zone_km = self.calc_dist.get(devicename_zone) - waze_time_msg = self.waze_time.get(devicename_zone) - - else: - waze_time_msg = self._format_waze_time_msg(waze_time_from_zone) - - #save for next poll if poor gps - self.zone_dist[devicename_zone] = dist_from_zone_km - self.waze_dist[devicename_zone] = waze_dist_from_zone_km - self.waze_time[devicename_zone] = waze_time_msg - self.calc_dist[devicename_zone] = calc_dist_from_zone_km - - except Exception as err: - attrs_msg = self._internal_error_msg(fct_name, err, 'SetDistDir') - - #-------------------------------------------------------------------------------- - try: - #Save last and new state, set attributes - #If first time thru, set the last state to the current state - #so a zone change will not be triggered next time - - if self.state_last_poll.get(devicename) == NOT_SET: - last_state = zone - - #When put into stationary zone, also set last_poll so it - #won't trigger again on next cycle as a state change - elif (instr(zone, STATIONARY) - or instr(self.state_this_poll.get(devicename), STATIONARY)): - zone = STATIONARY - last_state = STATIONARY - - #elif self.state_this_poll.get(devicename) == AWAY: - # self.state_last_poll[devicename] = NOT_HOME - else: - last_state = self.state_this_poll.get(devicename) - - #Make sure the new 'last state' value is the internal value for - #the state (e.g., Away-->not_home) to reduce state change triggers later. - last_state = STATE_TO_ZONE_BASE.get(last_state, last_state) - self.state_last_poll[devicename] = last_state - - self.state_this_poll[devicename] = zone - self.last_located_time[devicename] = self._time_to_12hrtime(location_time) - location_age = self._secs_since(last_located_secs) - location_age_str = self._format_age(location_age) - if (poor_location_gps_flag and self.poor_gps_flag.get(devicename) == False): - location_age_str = (f"Old-{location_age_str}") - - log_msg = (f"LOCATION TIME-{devicename} location_time-{location_time}, " - f"loc_time_secs-{self._secs_to_time(last_located_secs)} ({location_age_str})") - self._log_debug_msg(devicename, log_msg) - - attrs = {} - attrs[ATTR_ZONE] = self.zone_current.get(devicename) - attrs[ATTR_ZONE_TIMESTAMP] = str(self.zone_timestamp.get(devicename)) - attrs[ATTR_LAST_ZONE] = self.zone_last.get(devicename) - attrs[ATTR_LAST_UPDATE_TIME] = self._time_to_12hrtime(self.this_update_time) - attrs[ATTR_LAST_LOCATED] = self._time_to_12hrtime(location_time) - - attrs[ATTR_INTERVAL] = interval_str - attrs[ATTR_NEXT_UPDATE_TIME] = self._secs_to_time(next_poll) - - attrs[ATTR_WAZE_TIME] = '' - if self.waze_status == WAZE_USED: - attrs[ATTR_WAZE_TIME] = waze_time_msg - attrs[ATTR_WAZE_DISTANCE] = self._km_to_mi(waze_dist_from_zone_km) - elif self.waze_status == WAZE_NOT_USED: - attrs[ATTR_WAZE_DISTANCE] = 'NotUsed' - elif self.waze_status == WAZE_NO_DATA: - attrs[ATTR_WAZE_DISTANCE] = 'NoData' - elif self.waze_status == WAZE_OUT_OF_RANGE: - if waze_dist_from_zone_km < 1: - attrs[ATTR_WAZE_DISTANCE] = '' - elif waze_dist_from_zone_km < self.waze_min_distance: - attrs[ATTR_WAZE_DISTANCE] = 'DistLow' - else: - attrs[ATTR_WAZE_DISTANCE] = 'DistHigh' - elif dir_of_travel == 'in_zone': - attrs[ATTR_WAZE_DISTANCE] = '' - elif self.waze_status == WAZE_PAUSED: - attrs[ATTR_WAZE_DISTANCE] = PAUSED - elif waze_dist_from_zone_km > 0: - attrs[ATTR_WAZE_TIME] = waze_time_msg - attrs[ATTR_WAZE_DISTANCE] = self._km_to_mi(waze_dist_from_zone_km) - else: - attrs[ATTR_WAZE_DISTANCE] = '' - - attrs[ATTR_ZONE_DISTANCE] = self._km_to_mi(dist_from_zone_km) - attrs[ATTR_CALC_DISTANCE] = self._km_to_mi(calc_dist_from_zone_km) - attrs[ATTR_DIR_OF_TRAVEL] = dir_of_travel - attrs[ATTR_TRAVEL_DISTANCE] = self._km_to_mi(dist_last_poll_moved_km) - - info_msg = self._format_info_attr( - devicename, - battery, - gps_accuracy, - dist_last_poll_moved_km, - zone) - - attrs[ATTR_INFO] = interval_debug_msg + info_msg - - #save for event log - if type(waze_time_msg) != str: waze_time_msg = '' - self.last_tavel_time[devicename_zone] = waze_time_msg - self.last_distance_str[devicename_zone] = (f"{self._km_to_mi(dist_from_zone_km)} {self.unit_of_measurement}") - self._trace_device_attributes(devicename, 'RESULTS', fct_name, attrs) - - zone = self.zone_current.get(devicename) - - event_msg = (f"Results: From-{self.zone_to_display.get(self.base_zone)} > CurrZone-" - f"{self.zone_to_display.get(zone)}, ") - event_msg +=(f"GPS-{format_gps(latitude, longitude, gps_accuracy)}, " - f"Interval-{interval_str}") - event_msg +=(f" ({interval_method}), ") if self.log_level_debug_flag else ", " - event_msg +=(f"Dist-{self._km_to_mi(dist_from_zone_km)} {self.unit_of_measurement}, " - f"TravTime-{waze_time_msg} ({dir_of_travel}), " - f"NextUpdt-{self._secs_to_time(next_poll)}, " - f"Located-{self._time_to_12hrtime(location_time)} ({location_age_str}), " - f"OldLocThreshold-{old_location_secs_msg}") - if self.stat_zone_timer.get(devicename) > 0: - event_msg += (f", WillMoveIntoStatZoneAfter-{self._secs_to_time(self.stat_zone_timer.get(devicename))} " - f"({self._format_dist(self.stat_zone_moved_total.get(devicename))})") - self._save_event_halog_info(devicename, event_msg, log_title="") - - return attrs - - except Exception as err: - attrs_msg = self._internal_error_msg(fct_name, err, 'SetAttrs') - _LOGGER.exception(err) - return attrs_msg - -######################################################### -# -# iCloud FmF or FamShr authentication returned an error or no location -# data is available. Update counter and device attributes and set -# retry intervals based on current retry count. -# -######################################################### - def _determine_interval_after_error(self, devicename, info_msg, counter=POOR_LOC_GPS_CNT): - ''' - Handle errors where the device can not be or should not be updated with - the current data. The update will be retried 4 times on a 15 sec interval. - If the error continues, the interval will increased based on the retry - count using the following cycles: - 1-4 - 15 sec - 5-8 - 1 min - 9-12 - 5min - 13-16 - 15min - >16 - 30min - - The following errors use this routine: - - iCloud Authentication errors - - FmF location data not available - - Old location - - Poor GPS Acuracy - ''' - - fct_name = "_determine_interval_after_error" - - base_zone_home_flag = (self.base_zone == HOME) - devicename_zone = self._format_devicename_zone(devicename) - - try: - interval = self._get_interval_for_error_retry_cnt(devicename, counter) - - #check if next update is past midnight (next day), if so, adjust it - next_poll = round((self.this_update_secs + interval)/15, 0) * 15 - - # Update all dates and other fields - interval_str = self._secs_to_time_str(interval) - next_updt_str = self._secs_to_time(next_poll) - - self.interval_seconds[devicename_zone] = interval - self.last_update_secs[devicename_zone] = self.this_update_secs - self.last_update_time[devicename_zone] = self._time_to_12hrtime(self.this_update_time) - self.next_update_secs[devicename_zone] = next_poll - self.next_update_time[devicename_zone] = next_updt_str - self.interval_str[devicename_zone] = interval_str - self.count_update_ignore[devicename] += 1 - - attrs = {} - attrs[ATTR_LAST_UPDATE_TIME] = self._time_to_12hrtime(self.this_update_time) - attrs[ATTR_INTERVAL] = interval_str - attrs[ATTR_NEXT_UPDATE_TIME] = next_updt_str - attrs[ATTR_POLL_COUNT] = self._format_poll_count(devicename) - - #If located more than 12 hrs ago, use dhms ago instead of time - location_age = self._secs_since(self.last_located_secs.get(devicename)) - if location_age > 42300: - attrs[ATTR_LAST_LOCATED] = f"{self.format_age(location_age)}" - info_msg = f"May be Offline, {info_msg}, LastLocated-{self.format_age(location_age)}" - self._save_event(devicename,info_msg) - - attrs[ATTR_INFO] = (f"●● {info_msg} ●●") - - this_zone = self.state_this_poll.get(devicename) - this_zone = self._format_zone_name(devicename, this_zone) - last_zone = self.state_last_poll.get(devicename) - last_zone = self._format_zone_name(devicename, last_zone) - - if self._is_inzone(devicename): - latitude = self.zone_lat.get(this_zone) - longitude = self.zone_long.get(this_zone) - - elif self._was_inzone(devicename): - latitude = self.zone_lat.get(last_zone) - longitude = self.zone_long.get(last_zone) - else: - latitude = self.last_lat.get(devicename) - longitude = self.last_long.get(devicename) - - if latitude is None or longitude is None: - latitude = self.last_lat.get(devicename) - longitude = self.last_long.get(devicename) - if latitude is None or longitude is None: - latitude = self.zone_lat.get(last_zone) - longitude = self.zone_long.get(last_zone) - if latitude is None or longitude is None: - event_msg = "Aborting update, no location data" - self._save_event_halog_error(devicename,event_msg) - return - - kwargs = self._setup_base_kwargs(devicename, - latitude, longitude, 0, 0) - - self._update_device_attributes(devicename, kwargs, attrs, 'DetIntlErrorRetry') - self._update_device_sensors(devicename, kwargs) - self._update_device_sensors(devicename, attrs) - self._update_stationary_zone_location(devicename) - - self.device_being_updated_flag[devicename] = False - - #Set the lat/long for the Error Msg if not_set - if this_zone == NOT_SET: - latitude = 0 - longitude = 0 - - event_msg = (f"{EVLOG_DEBUG}Discarded Results: {self.zone_to_display.get(self.base_zone)} > " - f"CurrZone-{this_zone}, " - f"GPS-{format_gps(latitude, longitude, 0)}, " - f"Interval-{interval_str}, " - f"NextUpdt-{next_updt_str}, " - f"OldLocPoorGpsCnt-#{self.poor_location_gps_cnt.get(devicename)}, " - f"UpdateErrorCnt-#{self.count_update_ignore.get(devicename)}, " - f"InfoMsg-{info_msg}") - self._save_event(devicename, event_msg) - - except Exception as err: - _LOGGER.exception(err) - -######################################################### -# -# UPDATE DEVICE LOCATION & INFORMATION ATTRIBUTE FUNCTIONS -# -######################################################### - def _get_distance_data(self, devicename, latitude, longitude, - gps_accuracy, poor_location_gps_flag): - """ Determine the location of the device. - Returns: - - zone (current zone from lat & long) - set to HOME if distance < home zone radius - - dist_from_zone_km (mi or km) - - dist_traveled (since last poll) - - dir_of_travel (towards, away_from, stationary, in_zone, - left_zone, near_home) - """ - - fct_name = '_get_distance_data' - - try: - if latitude is None or longitude is None: - attrs = self._internal_error_msg(fct_name, 'lat/long=None', 'NoLocation') - return (ERROR, attrs) - - base_zone_home_flag = (self.base_zone == HOME) - devicename_zone = self._format_devicename_zone(devicename) - - log_msg = ("GET DEVICE DISTANCE DATA Entered") - self._log_debug_interval_msg(devicename, log_msg) - - last_dir_of_travel = NOT_SET - last_dist_from_zone_km = 0 - last_waze_time = 0 - last_lat = self.base_zone_lat - last_long = self.base_zone_long - ic3dev_timestamp_secs = 0 - - zone = self.base_zone - calc_dist_from_zone_km = 0 - calc_dist_last_poll_moved_km = 0 - calc_dist_from_zone_moved_km = 0 - - - #Get the devicename's icloud3 attributes - entity_id = self.device_tracker_entity_ic3.get(devicename) - attrs = self._get_device_attributes(entity_id) - - self._trace_device_attributes(devicename, 'READ', fct_name, attrs) - - except Exception as err: - _LOGGER.exception(err) - error_msg = (f"Entity-{entity_id}, Err-{err}") - attrs = self._internal_error_msg(fct_name, error_msg, 'GetAttrs') - return (ERROR, attrs) - - try: - #Not available if first time after reset - if self.state_last_poll.get(devicename) != NOT_SET: - log_msg = ("Distance info available") - if ATTR_TIMESTAMP in attrs: - ic3dev_timestamp_secs = attrs[ATTR_TIMESTAMP] - ic3dev_timestamp_secs = self._timestamp_to_time(ic3dev_timestamp_secs) - else: - ic3dev_timestamp_secs = 0 - - last_dist_from_zone_km_s = self._get_attr(attrs, ATTR_ZONE_DISTANCE, NUMERIC) - last_dist_from_zone_km = last_dist_from_zone_km_s - - last_waze_time = self._get_attr(attrs, ATTR_WAZE_TIME) - last_dir_of_travel = self._get_attr(attrs, ATTR_DIR_OF_TRAVEL) - last_dir_of_travel = last_dir_of_travel.replace('*', '', 99) - last_dir_of_travel = last_dir_of_travel.replace('?', '', 99) - last_lat = self.last_lat.get(devicename) - last_long = self.last_long.get(devicename) - - #get last interval - interval_str = self.interval_str.get(devicename_zone) - interval = self._time_str_to_secs(interval_str) - - this_lat = latitude - this_long = longitude - - except Exception as err: - _LOGGER.exception(err) - attrs = self._internal_error_msg(fct_name, err, 'SetupLocation') - return (ERROR, attrs) - - try: - zone = self._get_zone(devicename, this_lat, this_long) - - log_msg = (f"LAT-LONG GPS INITIALIZED {zone}, LastDirOfTrav-{last_dir_of_travel}, " - f"LastGPS=({last_lat}, {last_long}), ThisGPS=({this_lat}, {this_long}), " - f"UsingGPS=({latitude}, {longitude}), GPS.Accur-{gps_accuracy}, " - f"GPS.Threshold-{self.gps_accuracy_threshold}") - self._log_debug_interval_msg(devicename, log_msg) - - except Exception as err: - _LOGGER.exception(err) - attrs = self._internal_error_msg(fct_name, err, 'GetCurrZone') - return (ERROR, attrs) - - try: - # Get Waze distance & time - # Will return [error, 0, 0, 0] if error - # [out_of_range, dist, time, info] if - # last_dist_from_zone_km > - # last distance from home - # [ok, 0, 0, 0] if zone=home - # [ok, distFmHome, timeFmHome, info] if OK - - calc_dist_from_zone_km = self._calc_distance_km(this_lat, this_long, - self.base_zone_lat, self.base_zone_long) - calc_dist_last_poll_moved_km = self._calc_distance_km(last_lat, last_long, - this_lat, this_long) - calc_dist_from_zone_moved_km= (calc_dist_from_zone_km - last_dist_from_zone_km) - calc_dist_from_zone_km = self._round_to_zero(calc_dist_from_zone_km) - calc_dist_last_poll_moved_km = self._round_to_zero(calc_dist_last_poll_moved_km) - calc_dist_from_zone_moved_km= self._round_to_zero(calc_dist_from_zone_moved_km) - - if self.distance_method_waze_flag: - #If waze paused via icloud_command or close to a zone, default to pause - if self.waze_manual_pause_flag or self.waze_close_to_zone_pause_flag: - self.waze_status = WAZE_PAUSED - else: - self.waze_status = WAZE_USED - else: - self.waze_status = WAZE_NOT_USED - - log_msg = (f"Zone-{devicename_zone}, wStatus-{self.waze_status}, " - f"calc_dist-{calc_dist_from_zone_km}, wManualPauseFlag-{self.waze_manual_pause_flag}, " - f"CloseToZoneFlag-{self.waze_close_to_zone_pause_flag}") - self._log_debug_interval_msg(devicename, log_msg) - - #Make sure distance and zone are correct for HOME, initialize - if calc_dist_from_zone_km <= .05 or zone == self.base_zone: - zone = self.base_zone - calc_dist_from_zone_km = 0 - calc_dist_last_poll_moved_km = 0 - calc_dist_from_zone_moved_km = 0 - self.waze_status = WAZE_PAUSED - - #Near zone & towards or in near_zone - elif (calc_dist_from_zone_km < 1 - and last_dir_of_travel in ('towards', 'near_zone')): - self.waze_status = WAZE_PAUSED - self.waze_close_to_zone_pause_flag = True - - log_msg = "Using Calc Method (near Home & towards or Waze off)" - self._log_debug_interval_msg(devicename, log_msg) - - #Determine if Waze should be used based on calculated distance - elif (calc_dist_from_zone_km > self.waze_max_distance - or calc_dist_from_zone_km < self.waze_min_distance): - self.waze_status = WAZE_OUT_OF_RANGE - - #Initialize Waze default fields - waze_dist_from_zone_km = calc_dist_from_zone_km - waze_time_from_zone = 0 - waze_dist_last_poll_moved_km = calc_dist_last_poll_moved_km - waze_dist_from_zone_moved_km = calc_dist_from_zone_moved_km - self.waze_history_data_used_flag[devicename_zone] = False - - #Use Calc if close to home, Waze not accurate when close - log_msg = (f"Zone-{devicename_zone}, Status-{self.waze_status}, " - f"calc_dist-{calc_dist_from_zone_km}, " - f"ManualPauseFlag-{self.waze_manual_pause_flag}," - f"CloseToZoneFlag-{self.waze_close_to_zone_pause_flag}") - self._log_debug_interval_msg(devicename, log_msg) - - except Exception as err: - _LOGGER.exception(err) - attrs = self._internal_error_msg(fct_name, err, 'InitializeDist') - return (ERROR, attrs) - - try: - if self.waze_status == WAZE_USED: - try: - #See if another device is close with valid Waze data. - #If so, use it instead of calling Waze again. event_msg will have - #msg for log file if history was used - - waze_dist_time_info = self._get_waze_from_data_history( - devicename, - calc_dist_from_zone_km, - this_lat, - this_long) - - #No Waze data from close device. Get it from Waze - if waze_dist_time_info is None: - waze_dist_time_info = self._get_waze_data( - devicename, - this_lat, this_long, - last_lat, last_long, - zone, - last_dist_from_zone_km) - - self.waze_status = waze_dist_time_info[0] - - if self.waze_status == WAZE_USED: - waze_dist_from_zone_km = waze_dist_time_info[1] - waze_time_from_zone = waze_dist_time_info[2] - waze_dist_last_poll_moved_km = waze_dist_time_info[3] - waze_dist_from_zone_moved_km= round(waze_dist_from_zone_km - last_dist_from_zone_km, 2) - waze_time_msg = self._format_waze_time_msg(waze_time_from_zone) - - #Save new Waze data or retimestamp data from another - #device. - if (gps_accuracy <= self.gps_accuracy_threshold - and waze_dist_from_zone_km > 0 - and poor_location_gps_flag is False): - self.waze_distance_history[devicename_zone] = \ - [self._time_now_secs(), - this_lat, - this_long, - waze_dist_time_info] - - else: - self.waze_distance_history[devicename_zone] = [] - - except Exception as err: - _LOGGER.exception(err) - self.waze_status = WAZE_NO_DATA - - except Exception as err: - attrs = self._internal_error_msg(fct_name, err, 'WazeNoData') - self.waze_status = WAZE_NO_DATA - - try: - #don't reset data if poor gps, use the best we have - if zone == self.base_zone: - distance_method = 'Home/Calc' - dist_from_zone_km = 0 - dist_last_poll_moved_km = 0 - dist_from_zone_moved_km = 0 - elif self.waze_status == WAZE_USED: - distance_method = 'Waze' - dist_from_zone_km = waze_dist_from_zone_km - dist_last_poll_moved_km = waze_dist_last_poll_moved_km - dist_from_zone_moved_km = waze_dist_from_zone_moved_km - else: - distance_method = 'Calc' - dist_from_zone_km = calc_dist_from_zone_km - dist_last_poll_moved_km = calc_dist_last_poll_moved_km - dist_from_zone_moved_km = calc_dist_from_zone_moved_km - - if dist_from_zone_km > 99: dist_from_zone_km = int(dist_from_zone_km) - if dist_last_poll_moved_km > 99: dist_last_poll_moved_km = int(dist_last_poll_moved_km) - if dist_from_zone_moved_km> 99: dist_from_zone_moved_km= int(dist_from_zone_moved_km) - - dist_from_zone_moved_km= self._round_to_zero(dist_from_zone_moved_km) - - log_msg = (f"DISTANCES CALCULATED, " - f"Zone-{zone}, Method-{distance_method}, " - f"LastDistFmHome-{self._format_dist(last_dist_from_zone_km)}, " - f"WazeStatus-{self.waze_status}") - self._log_debug_interval_msg(devicename, log_msg) - log_msg = (f"DISTANCES ...Waze, " - f"Dist-{self._format_dist(waze_dist_from_zone_km)}, " - f"LastPollMoved-{self._format_dist(waze_dist_last_poll_moved_km)}, " - f"FmHomeMoved-{self._format_dist(waze_dist_from_zone_moved_km)}, " - f"Time-{waze_time_from_zone}, " - f"Status-{self.waze_status}") - self._log_debug_interval_msg(devicename, log_msg) - log_msg = (f"DISTANCES ...Calc, " - f"Dist-{self._format_dist(calc_dist_from_zone_km)}, " - f"LastPollMoved-{self._format_dist(calc_dist_last_poll_moved_km)}, " - f"FmHomeMoved-{self._format_dist(calc_dist_from_zone_moved_km)}") - self._log_debug_interval_msg(devicename, log_msg) - - #if didn't move far enough to determine towards or away_from, - #keep the current distance and add it to the distance on the next - #poll - if (dist_from_zone_moved_km> -.3 and dist_from_zone_moved_km< .3): - dist_from_zone_moved_km+= \ - self.dist_from_zone_km_small_move_total.get(devicename) - self.dist_from_zone_km_small_move_total[devicename] = \ - dist_from_zone_moved_km - else: - self.dist_from_zone_km_small_move_total[devicename] = 0 - - except Exception as err: - _LOGGER.exception(err) - attrs = self._internal_error_msg(fct_name, err, 'CalcDist') - return (ERROR, attrs) - - try: - section = "dir_of_trav" - dir_of_travel = '' - dir_of_trav_msg = '' - if zone not in (NOT_HOME, 'near_zone'): - dir_of_travel = 'in_zone' - dir_of_trav_msg = (f"Zone-{zone}") - - elif last_dir_of_travel == "in_zone": - dir_of_travel = 'left_zone' - dir_of_trav_msg = (f"LastZone-{last_dir_of_travel}") - - elif dist_from_zone_moved_km<= -.3: #.18 mi - dir_of_travel = 'towards' - dir_of_trav_msg = (f"Dist-{self._format_dist(dist_from_zone_moved_km)}") - - elif dist_from_zone_moved_km>= .3: #.18 mi - dir_of_travel = AWAY_FROM - dir_of_trav_msg = (f"Dist-{self._format_dist(dist_from_zone_moved_km)}") - - elif self.poor_gps_flag.get(devicename): - dir_of_travel = 'Poor.GPS' - dir_of_trav_msg = ("Poor.GPS-{gps_accuracy}") - - else: - #didn't move far enough to tell current direction - dir_of_travel = (f"{last_dir_of_travel}?") - dir_of_trav_msg = (f"Moved-{self._format_dist(dist_last_poll_moved_km)}") - - #If moved more than stationary zone limit (~.06km(200ft)), - #reset check StatZone still timer and check again next poll - #Use calc distance rather than waze for better accuracy - section = "test if home" - - if (calc_dist_from_zone_km > self.stat_min_dist_from_zone_km - and zone == NOT_HOME): - - section = "test moved" - #Reset stationary zone timer - if (calc_dist_last_poll_moved_km > self.stat_dist_move_limit - or devicename not in self.stat_zone_moved_total - or self.this_update_secs > self.stat_zone_timer.get(devicename)+120): - section = "test moved-reset stat zone " - - event_msg = (f"Stat Zone > Reset Timer, ") - if calc_dist_last_poll_moved_km > self.stat_dist_move_limit: - event_msg += (f"MovedOverLimit-{(calc_dist_last_poll_moved_km > self.stat_dist_move_limit)}, ") - if devicename not in self.stat_zone_moved_total: - event_msg += "Initial Setup, " - if (self.this_update_secs > self.stat_zone_timer.get(devicename)+120): - event_msg += (f"ResetTimer-{(self.this_update_secs > self.stat_zone_timer.get(devicename)+120)}, ") - - event_msg += (f"Moved-{self._format_dist(calc_dist_last_poll_moved_km)}, " - f"Timer-{self._secs_to_time(self.stat_zone_timer.get(devicename))}, ") - - self.stat_zone_moved_total[devicename] = 0 - self.stat_zone_timer[devicename] = self.this_update_secs + self.stat_zone_still_time - - event_msg += (f" to {self._secs_to_time(self.stat_zone_timer.get(devicename))}") - self._evlog_debug_msg(devicename, event_msg) - - #If moved less than the stationary zone limit, update the - #distance moved and check to see if now in a stationary zone - elif devicename in self.stat_zone_moved_total: - section = "StatZonePrep" - self.stat_zone_moved_total[devicename] += calc_dist_last_poll_moved_km - if self.stat_zone_timer.get(devicename) > 0: - stat_zone_timer_left = self.stat_zone_timer.get(devicename) - self.this_update_secs - else: - stat_zone_timer_left = HIGH_INTEGER - - log_msg = (f"Stat Zone Movement > " - f"TotalMoved-{self._format_dist(self.stat_zone_moved_total.get(devicename))}, " - f"Timer-{self._secs_to_time(self.stat_zone_timer.get(devicename))}, " - f"TimerLeft- {stat_zone_timer_left} secs, " - f"TimerExpired-{stat_zone_timer_left <= 0}" - f"UnderMoveLimit-{self.stat_zone_moved_total.get(devicename) <= self.stat_dist_move_limit}, " - f"DistFmZone-{self._format_dist(dist_from_zone_km)}") - self._evlog_debug_msg(devicename, log_msg) - self._log_debug_msg(devicename, log_msg) - section = "CheckNowinzone_stationary" - - #See if moved less than the stationary zone movement limit - #If updating via the ios app and the current state is stationary, - #make sure it is kept in the stationary zone - if (self.stat_zone_moved_total.get(devicename) <= self.stat_dist_move_limit - and stat_zone_timer_left <= 0): - dir_of_travel = STATIONARY - dir_of_trav_msg = (f"Age-{self._secs_to(self.stat_zone_timer.get(devicename))}s, " - f"Moved-{self.stat_zone_moved_total.get(devicename)}") - else: - self.stat_zone_moved_total[devicename] = 0 - - section = "Finalize" - dir_of_trav_msg = (f"{dir_of_travel}({dir_of_trav_msg})") - log_msg = (f"DIR OF TRAVEL DETERMINED, {dir_of_trav_msg}") - self._log_debug_interval_msg(devicename, log_msg) - - dist_from_zone_km = self._round_to_zero(dist_from_zone_km) - dist_from_zone_moved_km = self._round_to_zero(dist_from_zone_moved_km) - dist_last_poll_moved_km = self._round_to_zero(dist_last_poll_moved_km) - waze_dist_from_zone_km = self._round_to_zero(waze_dist_from_zone_km) - calc_dist_from_zone_moved_km = self._round_to_zero(calc_dist_from_zone_moved_km) - waze_dist_last_poll_moved_km = self._round_to_zero(waze_dist_last_poll_moved_km) - calc_dist_last_poll_moved_km = self._round_to_zero(calc_dist_last_poll_moved_km) - last_dist_from_zone_km = self._round_to_zero(last_dist_from_zone_km) - - log_msg = (f"GET DEVICE DISTANCE DATA Complete, " - f"CurrentZone-{zone}, DistFmHome-{dist_from_zone_km}, " - f"DistFmHomeMoved-{dist_from_zone_moved_km}, " - f"DistLastPollMoved-{dist_last_poll_moved_km}") - self._log_debug_interval_msg(devicename, log_msg) - - distance_data = (zone, - dir_of_travel, - dist_from_zone_km, - dist_from_zone_moved_km, - dist_last_poll_moved_km, - waze_dist_from_zone_km, - calc_dist_from_zone_km, - waze_dist_from_zone_moved_km, - calc_dist_from_zone_moved_km, - waze_dist_last_poll_moved_km, - calc_dist_last_poll_moved_km, - waze_time_from_zone, - last_dist_from_zone_km, - last_dir_of_travel, - dir_of_trav_msg, - ic3dev_timestamp_secs) - - log_msg = (f"DISTANCE DATA-{devicename}-{distance_data}") - self._log_debug_msg(devicename, log_msg) - evlog_msg = (f"Distance Data > DirOfTrav-{dir_of_travel}, " - f"LastDirOfTrav-{last_dir_of_travel}, " - f"DistFmZone-{self._format_dist(dist_from_zone_km)}, " - f"LastDistFmZone-{self._format_dist(last_dist_from_zone_km)}, " - f"DistFmZoneMoved-{self._format_dist(dist_from_zone_moved_km)}, " - f"DistFmLastPollMoved-{self._format_dist(dist_last_poll_moved_km)}") - self._evlog_debug_msg(devicename, evlog_msg) - - return distance_data - - except Exception as err: - _LOGGER.exception(err) - attrs = self._internal_error_msg(fct_name+section, err, 'Finalize') - return (ERROR, attrs) - -#-------------------------------------------------------------------------------- - def _determine_old_location_secs(self, zone, interval): - """ - Calculate the time between the location timestamp and now (age) a - location record must be before it is considered old - """ - if self.old_location_threshold > 0: - return self.old_location_threshold - - old_location_secs = 60 - if self._is_inzone_zonename(zone): - old_location_secs = interval * .025 #inzone interval --> 2.5% - if old_location_secs < 90: old_location_secs = 90 - - elif interval < 90: - old_location_secs = 60 #60 secs if < 1.5 min - - else: - old_location_secs = interval * .125 #12.5% of the interval - - if old_location_secs < 61: - old_location_secs = 61 - elif old_location_secs > 600: - old_location_secs = 600 - - #IOS App old location time minimum is 3 min - if self.TRK_METHOD_IOSAPP and old_location_secs < 180: - old_location_secs == 180 - - return old_location_secs - -######################################################### -# -# DEVICE ATTRIBUTES ROUTINES -# -######################################################### - def _get_state(self, entity_id): - """ - Get current state of the device_tracker entity - (zone's name, actual state (home, away) or zone's - friendly name) and return it's zone name - """ - - try: - entity_state = self.hass.states.get(entity_id).state - - state = self.state_to_zone.get(entity_state, entity_state.lower()) - - except Exception as err: - #When starting iCloud3, the device_tracker for the iosapp might - #not have been set up yet. Catch the entity_id error here. - #_LOGGER.exception(err) - state = NOT_SET - - return state -#-------------------------------------------------------------------- - def _set_state(self, entity_id, state_value, attrs_value): - """ - General routine for setting the state and attributes of an entity_id - """ - - try: - self.hass.states.set(entity_id, state_value, attrs_value) - - except Exception as err: - log_msg = (f"Error updating entity > {entity_id}, StateValue-{state_value}, " - f"AttrsValue-{attrs_value}") - self._log_error_msg(log_msg) - _LOGGER.exception(err) - -#-------------------------------------------------------------------- - def _get_entity_last_changed_time(self, entity_id, devicename): - """ - Get entity's last changed time attribute - Last changed time format '2019-09-09 14:02:45.12345+00:00' (utc value) - Return time, seconds, timestamp - """ - - try: - timestamp_utc = str(self.hass.states.get(entity_id).last_changed) - - timestamp_utc = timestamp_utc.split(".")[0] - secs = self._timestamp_to_secs(timestamp_utc, UTC_TIME) - hhmmss = self._secs_to_time(secs) - timestamp = self._secs_to_timestamp(secs) - age_secs = self.this_update_secs - secs - dhms_age_str = self._secs_to_dhms_str(age_secs) - - return hhmmss, secs, timestamp, age_secs, dhms_age_str - - except Exception as err: - #_LOGGER.exception(err) - return '', 0, TIMESTAMP_ZERO, 0, '' -#-------------------------------------------------------------------- - def _get_device_attributes(self, entity_id): - """ Get attributes of the device """ - - try: - ic3dev_data = self.hass.states.get(entity_id) - ic3dev_attrs = ic3dev_data.attributes - - retry_cnt = 0 - while retry_cnt < 10: - if ic3dev_attrs: - break - retry_cnt += 1 - log_msg = (f"No attribute data returned for {entity_id}. Retrying #{retry_cnt}") - self._log_debug_msg('*', log_msg) - - except (KeyError, AttributeError): - ic3dev_attrs = {} - pass - - except Exception as err: - _LOGGER.exception(err) - ic3dev_attrs = {} - ic3dev_attrs[ATTR_TRIGGER] = (f"Error {err}") - - return dict(ic3dev_attrs) - -#-------------------------------------------------------------------- - @staticmethod - def _get_attr(attributes, attribute_name, numeric = False): - ''' Get an attribute out of the attrs attributes if it exists''' - if attribute_name in attributes: - return attributes[attribute_name] - elif numeric: - return 0 - else: - return '' - -#-------------------------------------------------------------------- - def _update_device_attributes(self, devicename, kwargs: str = None, - attrs: str = None, fct_name: str = UNKNOWN): - """ - Update the device and attributes with new information - On Entry, kwargs = {} or contains the base attributes. - - Trace the interesting attributes if debugging. - - Full set of attributes is: - 'gps': (27.726639, -80.3904565), 'battery': 61, 'gps_accuracy': 65.0 - 'dev_id': 'lillian_iphone', 'host_name': 'Lillian', - 'location_name': HOME, 'source_type': 'gps', - 'attributes': {'interval': '2 hrs', 'last_update': '10:55:17', - 'next_update': '12:55:15', 'travel_time': '', 'distance': 0, - 'calc_distance': 0, 'waze_distance': 0, 'dir_of_travel': 'in_zone', - 'travel_distance': 0, 'info': ' ●Battery-61%', - 'group': 'gary_icloud', 'authenticated': '02/22/19 10:55:10', - 'last_located': '10:55:15', 'device_status': 'online', - ATTR_LOW_POWER_MODE: False, 'battery_status': 'Charging', - 'tracked_devices': 'gary_icloud/gary_iphone, - gary_icloud/lillian_iphone', 'trigger': 'iCloud', - 'timestamp': '2019-02-22T10:55:17.543', 'poll_count': '1:0:1'} - - {'source_type': 'gps', 'latitude': 27.726639, 'longitude': -80.3904565, - 'gps_accuracy': 65.0, 'battery': 93, 'zone': HOME, - 'last_zone': HOME, 'zone_timestamp': '03/13/19, 9:47:35', - 'trigger': 'iCloud', 'timestamp': '2019-03-13T09:47:35.405', - 'interval': '2 hrs', 'travel_time': '', 'distance': 0, - 'calc_distance': 0, 'waze_distance': '', 'last_located': '9:47:34', - 'last_update': '9:47:35', 'next_update': '11:47:30', - 'poll_count': '1:0:2', 'dir_of_travel': 'in_zone', - 'travel_distance': 0, 'info': ' ●Battery-93%', - 'battery_status': 'NotCharging', 'device_status': - 'online', ATTR_LOW_POWER_MODE: False, - 'authenticated': '03/13/19, 9:47:26', - 'tracked_devices': 'gary_icloud/gary_iphone, gary_icloud/lillian_iphone', - 'group': 'gary_icloud', 'friendly_name': 'Gary', - 'icon': 'mdi:cellphone-iphone', - 'entity_picture': '/local/gary-caller_id.png'} - """ - - zone = self.zone_current.get(devicename) - ic3dev_state = self.state_this_poll.get(devicename) - ic3dev_state_fname = ic3dev_state - - ####################################################################### - #The current zone is based on location of the device after it is looked - #up in the zone tables. - #The ic3dev_state is from the original trigger value when the poll started. - #If the device went from one zone to another zone, an enter/exit trigger - #may not have been issued. If the trigger was the next update time - #reached, the ic3dev_state and zone many now not match. (v2.0.2) - - if ic3dev_state == NOT_SET or zone == NOT_SET or zone == '': - pass - - #If ic3dev_state is 'stationary' and in a stationary zone, nothing to do - elif ic3dev_state == STATIONARY and instr(zone, STATIONARY): - pass - - #If state is 'stationary' and in another zone, reset the state to the - #current zone that was based on the device location. - #If the state is in a zone but not the current zone, change the state - #to the current zone that was based on the device location. - elif ((ic3dev_state == STATIONARY - and self._is_inzone(zone)) - or (self._is_inzone(ic3dev_state) - and self._is_inzone(zone) - and ic3dev_state != zone)): - if self._check_overlapping_zone(ic3dev_state, zone) == False: - event_msg = (f"State/Zone mismatch > Resetting iC3 State ({ic3dev_state}) " - f"to ({zone})") - self._save_event(devicename, event_msg) - ic3dev_state = zone - - elif ic3dev_state == HOME: - self.waze_distance_history = {} - - ic3dev_state_fname = self.zone_to_display.get(ic3dev_state, ic3dev_state) - - #Update the device timestamp - if not attrs: - attrs = {} - if ATTR_TIMESTAMP in attrs: - timestamp = attrs[ATTR_TIMESTAMP] - else: - timestamp = dt_util.now().strftime(ATTR_TIMESTAMP_FORMAT)[0:19] - attrs[ATTR_TIMESTAMP] = timestamp - - #Calculate and display how long the update took - update_took_time = round(time.time() - self.update_timer.get(devicename), 2) - if update_took_time > 3 and ATTR_INFO in attrs: - attrs[ATTR_INFO] = f"{attrs[ATTR_INFO]} • Took {update_took_time}s" - - attrs[ATTR_NAME] = self.fname.get(devicename) - attrs[ATTR_GROUP] = self.group - attrs[ATTR_TRACKING] = (f"{self.track_devicename_list} ({self.trk_method_short_name})") - attrs[ATTR_ICLOUD3_VERSION] = VERSION - - #Add update time to trigger to be able to detect trigger change by iOS App - #and by iC3. - new_trigger = (f"{self.trigger.get(devicename)}@").split('@')[0] - new_trigger = (f"{new_trigger}@{self.last_located_time.get(devicename)}") - - self.trigger[devicename] = new_trigger - attrs[ATTR_TRIGGER] = new_trigger - - #Update sensor._last_update_trigger if IOS App detected - #and iCloud3 has been running for at least 10 secs to let HA & - #mobile_app start up to avoid error if iC3 loads before the mobile_app - - #Set the gps attribute and update the attributes via self.see - if kwargs == {} or not kwargs: - kwargs = self._setup_base_kwargs( - devicename, - self.last_lat.get(devicename), - self.last_long.get(devicename), - 0, 0) - - kwargs['dev_id'] = devicename - kwargs['host_name'] = self.fname.get(devicename) - kwargs['location_name'] = ic3dev_state_fname - kwargs['source_type'] = 'gps' - kwargs[ATTR_ATTRIBUTES] = attrs - self.see(**kwargs) - - if ic3dev_state == "Not Set": - ic3dev_state = "not_set" - - self.state_this_poll[devicename] = ic3dev_state.lower() - - self._trace_device_attributes(devicename, 'WRITE', fct_name, kwargs) - - if timestamp == '': #Bypass if not initializing - return - - retry_cnt = 1 - timestamp = timestamp[10:] #Strip off date - - #Quite often, the attribute update has not actually taken - #before other code is executed and errors occur. - #Reread the attributes of the ones just updated to make sure they - #were updated corectly. Verify by comparing the timestamps. If - #differet, retry the attribute update. HA runs in multiple threads. - try: - entity_id = self.device_tracker_entity_ic3.get(devicename) - while retry_cnt < 99: - chk_see_attrs = self._get_device_attributes(entity_id) - chk_timestamp = str(chk_see_attrs.get(ATTR_TIMESTAMP)) - chk_timestamp = chk_timestamp[10:] - - if timestamp == chk_timestamp: - break - - if (retry_cnt % 10) == 0: - time.sleep(1) - retry_cnt += 1 - - self.see(**kwargs) - - except Exception as err: - _LOGGER.exception(err) - - return - -#-------------------------------------------------------------------- - def _setup_base_kwargs(self, devicename, latitude, longitude, - battery, gps_accuracy): - - #check to see if device set up yet - ic3dev_state = self.state_this_poll.get(devicename) - zone_name = None - - if latitude == self.zone_home_lat: - pass - elif ic3dev_state == NOT_SET: - zone_name = self.base_zone - - #if in zone, replace lat/long with zone center lat/long - elif self._is_inzone_zonename(ic3dev_state): - zone_name = self._format_zone_name(devicename, ic3dev_state) - - debug_msg=(f"SETUP BASE KWARGS zone_name-{zone_name}, inzone-state-{self._is_inzone_zonename(ic3dev_state)}") - self._log_debug_msg(devicename, debug_msg) - - if zone_name and self._is_inzone_zonename(ic3dev_state): - zone_lat = self.zone_lat.get(zone_name) - zone_long = self.zone_long.get(zone_name) - zone_dist = self._calc_distance_m(latitude, longitude, zone_lat, zone_long) - - debug_msg=(f"zone_lat/long=({zone_lat}, {zone_long}), " - f"lat-long=({latitude}, {longitude}), zone_dist-{zone_dist}, " - f"zone-radius-{self.zone_radius_km.get(zone_name, 100)}") - self._log_debug_msg(devicename, debug_msg) - - #Move center of stationary zone to new location if more than 10m from old loc - if (instr(zone_name, STATIONARY) - and self.in_stationary_zone_flag.get(devicename) - and zone_dist > 10): - self._update_stationary_zone( - devicename, - latitude, - longitude, - STAT_ZONE_NEW_LOCATION,'_setup_base_kwargs') - - #inside zone, move to center - elif (self.center_in_zone_flag - and zone_dist <= self.zone_radius_m.get(zone_name, 100) - and (latitude != zone_lat or longitude != zone_long)): - event_msg = (f"Moving to zone center > {zone_name}, " - f"GPS-{format_gps(latitude, longitude, gps_accuracy, zone_lat, zone_long)}, " - f"Distance-{self._format_dist_m(zone_dist)}") - self._save_event(devicename, event_msg) - self._log_debug_msg(devicename, event_msg) - - latitude = zone_lat - longitude = zone_long - self.last_lat[devicename] = zone_lat - self.last_long[devicename] = zone_long - - gps_lat_long = (latitude, longitude) - kwargs = {} - kwargs['gps'] = gps_lat_long - kwargs[ATTR_BATTERY] = int(battery) - kwargs[ATTR_GPS_ACCURACY] = gps_accuracy - - return kwargs - -#-------------------------------------------------------------------- - def _format_entity_id(self, devicename): - return (f"{DOMAIN}.{devicename}") - -#-------------------------------------------------------------------- - def _format_fname_devtype(self, devicename): - if devicename == "*": - return "" - else: - return (f"{self.fname.get(devicename, '')} ({self.device_type.get(devicename, '')})") - -#-------------------------------------------------------------------- - def _format_fname_devicename(self, devicename): - if devicename == "*": - return "" - else: - return (f"{self.fname.get(devicename, '')} ({devicename})") - -#-------------------------------------------------------------------- - def _format_fname_zone(self, zone): - return (f"{self.zone_to_display.get(zone, zone.title())}") - -#-------------------------------------------------------------------- - def _format_devicename_zone(self, devicename, zone = None): - if zone is None: - zone = self.base_zone - return (f"{devicename}:{zone}") -#-------------------------------------------------------------------- - def _format_list(self, arg_list): - formatted_list = str(arg_list) - formatted_list = formatted_list.replace("[", "").replace("]", "") - formatted_list = formatted_list.replace("{", "").replace("}", "") - formatted_list = formatted_list.replace("'", "").replace(",", "CRLF• ") - return (f"CRLF• {formatted_list}") -#-------------------------------------------------------------------- - def _format_age(self, secs): - return (f"{self._secs_to_time_str(secs)} ago") -#-------------------------------------------------------------------- - def _trace_device_attributes(self, devicename, description, - fct_name, attrs): - - try: - #Extract only attrs needed to update the device - if attrs is None: - return - - attrs_in_attrs = {} - if (instr(description, "iCloud") or instr(description, "FamShr")): - attrs_base_elements = TRACE_ICLOUD_ATTRS_BASE - if ATTR_LOCATION in attrs: - attrs_in_attrs = attrs[ATTR_LOCATION] - elif 'Zone' in description: - attrs_base_elements = attrs - else: - attrs_base_elements = TRACE_ATTRS_BASE - if ATTR_ATTRIBUTES in attrs: - attrs_in_attrs = attrs[ATTR_ATTRIBUTES] - - trace_attrs = {k: v for k, v in attrs.items() \ - if k in attrs_base_elements} - - trace_attrs_in_attrs = {k: v for k, v in attrs_in_attrs.items() \ - if k in attrs_base_elements} - - #trace_attrs = attrs - - ls = self.state_last_poll.get(devicename) - cs = self.state_this_poll.get(devicename) - log_msg = (f"{description} Attrs ___ ({fct_name})") - self._log_debug_msg(devicename, log_msg) - - log_msg = (f"{description} Last State-{ls}, This State-{cs}") - self._log_debug_msg(devicename, log_msg) - - log_msg = (f"{description} Attrs-{trace_attrs}{trace_attrs_in_attrs}") - self._log_debug_msg(devicename, log_msg) - - - except Exception as err: - pass - #_LOGGER.exception(err) - - return - -#-------------------------------------------------------------------- - def _broadcast_message(self, service_data): - '''Send a message to all devices ''' - - for devicename in self.tracked_devices: - self._send_message_to_device(devicename, service_data) - -#-------------------------------------------------------------------- - def _send_message_to_device(self, devicename, service_data): - ''' - Send a message to the device. An example message is: - service_data = { - "title": "iCloud3/iOSApp Zone Action Needed", - "message": "The iCloud3 Stationary Zone may "\ - "not be loaded in the iOSApp. Force close "\ - "the iOSApp from the iOS App Switcher. "\ - "Then restart the iOSApp to reload the HA zones. "\ - f"Distance-{dist_from_zone_m} m, " - f"StatZoneTestDist-{zone_radius_m * 2} m", - "data": {"subtitle": "Stationary Zone Exit "\ - "Trigger was not received"}} - ''' - try: - if self.notify_iosapp_entity.get(devicename, None) == None: - return - - if service_data.get('message') != "request_location_update": - evlog_msg = (f"{EVLOG_NOTICE}Sending Message to Device > " - f"Message-{service_data.get('message')}") - self._save_event_halog_info(devicename, evlog_msg) - - for notify_devicename in self.notify_iosapp_entity.get(devicename): - entity_id = (f"mobile_app_{notify_devicename}") - self.hass.services.call("notify", entity_id, service_data) - - #self.hass.async_create_task( - # self.hass.services.async_call('notify', entity_id, service_data)) - - return True - - except Exception as err: - event_msg = (f"iCloud3 Error > An error occurred sending a message to device " - f"{notify_devicename} via notify.{entity_id} service. " - f"CRLF• Message-{str(service_data)}") - if instr(err, "notify/none"): - event_msg += "CRLF• The devicename can not be found." - devicename = "*" - else: - event_msg += f"CRLF• Error-{err}" - self._save_event_halog_error(devicename, event_msg) - - return False - -#-------------------------------------------------------------------- - def _get_iosapp_device_tracker_state_attributes(self, devicename, ic3_state, ic3_dev_attrs): - ''' - Return the state and attributes of the ios app device tracker. - The ic3 device tracker state and attributes are returned if - the ios app data is not available or an error occurs. - ''' - try: - entity_id = self.device_tracker_entity_iosapp.get(devicename, None) - if entity_id: - iosapp_state = self._get_state(entity_id) - iosapp_dev_attrs = self._get_device_attributes(entity_id) - - #iosapp state is the friendly name of the zone, ic3 keeps the state - #by zone name, not the friendly name. Convert iosapp state back to zone - #entity name to match ic3 state_this_poll and state_last_poll, - #state-zone name mismatch errors and state change detected trigger - #when it really didn't - - if ATTR_LATITUDE in iosapp_dev_attrs: - self.iosapp_monitor_error_cnt[devicename] = 0 - - return iosapp_state, iosapp_dev_attrs, True - - except Exception as err: - self.iosapp_monitor_error_cnt[devicename] += 1 - #_LOGGER.exception(err) - - return ic3_state, ic3_dev_attrs, False - - -#-------------------------------------------------------------------- - def _get_iosapp_device_sensor_trigger(self, devicename): - - entity_id = (f"sensor.{self.iosapp_last_trigger_entity.get(devicename)}") - - try: - if self.iosapp_monitor_dev_trk_flag.get(devicename): - trigger = self.hass.states.get(entity_id).state - - trigger_time, trigger_time_secs, trigger_timestamp, \ - trigger_age_secs, trigger_age_str = \ - self._get_entity_last_changed_time(entity_id, devicename) - - trigger_abbrev = IOS_TRIGGER_ABBREVIATIONS.get(trigger, trigger) - - return (trigger_abbrev, trigger_time, trigger_time_secs, - trigger_age_secs, trigger_age_str) - - else: - return '', TIMESTAMP_ZERO, 0, 0, '' - - except Exception as err: - #_LOGGER.exception(err) - return '', TIMESTAMP_ZERO, 0, 0, '' - -#-------------------------------------------------------------------- - def _get_iosapp_device_sensor_battery_level(self, devicename) -> int: - return -1 - - entity_id = (f"sensor.{self.iosapp_battery_level_entity.get(devicename)}") - - try: - if self.iosapp_monitor_dev_trk_flag.get(devicename): - battery_level = self.hass.states.get(entity_id).state - return int(battery_level) - - else: - return 0 - - except Exception as err: - #_LOGGER.exception(err) - return 0 - -######################################################### -# -# DEVICE ZONE ROUTINES -# -######################################################### - def _get_zone(self, devicename, latitude, longitude): - - ''' - Get current zone of the device based on the location """ - - This is the same code as (active_zone/async_active_zone) in zone.py - but inserted here to use zone table loaded at startup rather than - calling hass on all polls - ''' - zone_selected_dist = HIGH_INTEGER - zone_selected = None - zones_msg = "" - iosapp_zone_msg = "" - - zone_iosapp = self.state_this_poll.get(devicename) if self._is_inzone_zonename(self.state_this_poll.get(devicename)) else None - - for zone in self.zone_lat: - #Skip another device's stationary zone or if at base location - if (instr(zone, STATIONARY) and instr(zone, devicename) == False): - continue - - zone_dist = self._calc_distance_km(latitude, longitude, - self.zone_lat.get(zone), self.zone_long.get(zone)) - - #Do not check Stat Zone if radius=1 (at base loc) but include in log_msg - if self.zone_radius_m.get(zone) > 1: - in_zone_flag = zone_dist < self.zone_radius_km.get(zone) - closer_zone_flag = zone_selected is None or zone_dist < zone_selected_dist - smaller_zone_flag = (zone_dist == zone_selected_dist - and self.zone_radius_km.get(zone) <= self.zone_radius_km.get(zone_selected)) - - if in_zone_flag and (closer_zone_flag or smaller_zone_flag): - zone_selected_dist = zone_dist - zone_selected = zone - iosapp_zone_msg = "" - - #If closer than 200m, keep zone name for ios app state override f - #ios app is in a Zone but dist is still outside a zone's radius - elif (zone_iosapp and zone == zone_iosapp and zone_dist < .2): - zone_selected_dist = zone_dist - zone_selected = zone - iosapp_zone_msg = " (Using iOSApp State)" - - if (instr(zone, STATIONARY)): - zones_msg += self.zone_to_display.get(STATIONARY) - else: - zones_msg += self.zone_to_display.get(zone) - zones_msg += (f"-{self._format_dist(zone_dist)}" - f"/r{round(self.zone_radius_m.get(zone))}, ") - - if zone_selected is None: - zone_selected = NOT_HOME - zone_selected_dist = 0 - - #If not in a zone and was in a Stationary Zone, Exit the zone and reset everything - if self.in_stationary_zone_flag.get(devicename): - self._update_stationary_zone( - devicename, - self.stat_zone_base_lat, - self.stat_zone_base_long, - STAT_ZONE_MOVE_TO_BASE, '_get_zone') - - elif instr(zone,'nearzone'): - zone_selected = 'near_zone' - - event_msg = (f"Selected Zone > ") - - if self.display_zone_fname_flag: - event_msg += ZONE_TO_DISPLAY_BASE.get(zone_selected, zone_selected) - else: - event_msg += self.zone_to_display.get(zone_selected, NOT_HOME) - - event_msg += (f"{iosapp_zone_msg} > {zones_msg[:-2]}") - - self._save_event(devicename, event_msg) - self._log_debug_msg(devicename, event_msg) - - #Get distance between zone selected and current zone to see if they overlap - if self._check_overlapping_zone(self.zone_current.get(devicename), zone_selected): - self.zone_current[devicename] = zone_selected - - #If the zone changed from a previous poll, save it and set the new one - if (self.zone_current.get(devicename) != zone_selected): - self.zone_last[devicename] = self.zone_current.get(devicename) - - #First time thru, initialize zone_last - if (self.zone_last.get(devicename) == ''): - self.zone_last[devicename] = zone_selected - - self.zone_current[devicename] = zone_selected - self.zone_timestamp[devicename] = \ - dt_util.now().strftime(self.um_date_time_strfmt) - - log_msg = (f"GET ZONE RESULTS, Zone-{zone_selected}, " - f"{format_gps(latitude, longitude, 0)}, " - f"StateThisPoll-{self.state_this_poll.get(devicename)}, " - f"LastZone-{self.zone_last.get(devicename)}, " - f"ThisZone-{self.zone_current.get(devicename)}") - self._log_debug_msg(devicename, log_msg) - - return zone_selected - -#-------------------------------------------------------------------- - def _check_overlapping_zone(self, zone1, zone2): - ''' - zone1 and zone2 overlap if their distance between centers is less than 2m - ''' - try: - if zone1 == zone2: - return True - - if zone1 == "": zone1 = HOME - zone_dist = self._calc_distance_m( - self.zone_lat.get(zone1), self.zone_long.get(zone1), - self.zone_lat.get(zone2), self.zone_long.get(zone2)) - - return (zone_dist <= 2) - - except: - return False - -#-------------------------------------------------------------------- - @staticmethod - def _get_zone_names(zone_name): - """ - Make zone_names 1, 2, & 3 out of the zone_name value for sensors - - name1 = home --> Home - not_home --> Away - gary_iphone_stationary --> Stationary - name2 = gary_iphone_stationary --> Gary Iphone Stationary - office_bldg_1 --> Office Bldg 1 - name3 = gary_iphone_stationary --> GaryIphoneStationary - office__bldg_1 --> Office Bldg1 - """ - if zone_name: - if instr(zone_name, STATIONARY): - name1 = STATIONARY - elif instr(zone_name, NOT_HOME): - name1 = AWAY - else: - name1 = zone_name.title() - - if zone_name == ATTR_ZONE: - badge_state = name1 - - name2 = zone_name.title().replace('_', ' ') - name3 = zone_name.title().replace('_', '') - else: - name1 = NOT_SET - name2 = 'Not Set' - name3 = 'NotSet' - - return [zone_name, name1, name2, name3] - -#-------------------------------------------------------------------- - @staticmethod - def _format_zone_name(devicename, zone): - ''' - The Stationary zone info is kept by 'devicename_stationary'. Other zones - are kept as 'zone'. Format the name based on the zone. - ''' - return f"{devicename}_stationary" if zone == STATIONARY else zone - -#-------------------------------------------------------------------- - def _set_base_zone_name_lat_long_radius(self, zone): - ''' - Set the base_zone's name, lat, long & radius - ''' - self.base_zone = zone - self.base_zone_name = self.zone_to_display.get(zone) - self.base_zone_lat = self.zone_lat.get(zone) - self.base_zone_long = self.zone_long.get(zone) - self.base_zone_radius_km = float(self.zone_radius_km.get(zone)) - - return - -#-------------------------------------------------------------------- - def _zone_distance_m(self, devicename, zone, latitude, longitude): - ''' - Get the distance from zone `zone` - ''' - - zone_dist = HIGH_INTEGER - - if self.zone_lat.get(zone): - zone_name = self._format_zone_name(devicename, zone) - - zone_dist = self._calc_distance_m( - latitude, - longitude, - self.zone_lat.get(zone_name), - self.zone_long.get(zone_name)) - - log_msg = (f"ZONE DIST {devicename}, Zone-{zone_name}, " - f"CurrGPS-{format_gps(latitude, longitude, 0)}, " - f"ZoneGPS-{format_gps(self.zone_lat.get(zone_name), self.zone_long.get(zone_name), 0)}, " - f"Dist-{zone_dist}m") - #self._log_debug_msg(devicename, log_msg) - - return zone_dist - -#-------------------------------------------------------------------- - def _is_inzone(self, devicename): - return (self.state_this_poll.get(devicename) != NOT_HOME) - - def _isnot_inzone(self, devicename): - return (self.state_this_poll.get(devicename) == NOT_HOME) - - def _was_inzone(self, devicename): - return (self.state_last_poll.get(devicename) != NOT_HOME) - - def _wasnot_inzone(self, devicename): - return (self.state_last_poll.get(devicename) == NOT_HOME) - - def _is_inzone_stationary(self, devicename): - return (instr(self.state_this_poll.get(devicename), STATIONARY)) - - def _isnot_inzone_stationary(self, devicename): - return (instr(self.state_this_poll.get(devicename), STATIONARY) == False) - - def _was_inzone_stationary(self, devicename): - return (instr(self.state_last_poll.get(devicename), STATIONARY)) - - def _wasnot_inzone_stationary(self, devicename): - return (instr(self.state_last_poll.get(devicename), STATIONARY) == False) - - @staticmethod - def _is_inzone_zonename(zone): - return (zone != NOT_HOME) - - @staticmethod - def _isnot_inzone_zonename(zone): - #_LOGGER.warning("_isnot_inzone_zonename = %s",(zone == NOT_HOME)) - return (zone == NOT_HOME) -#-------------------------------------------------------------------- - def _wait_if_update_in_process(self, devicename=None): - #An update is in process, must wait until done - wait_cnt = 0 - while self.update_in_process_flag: - wait_cnt += 1 - if devicename: - attrs = {} - attrs[ATTR_INTERVAL] = (f"WAIT-{wait_cnt}") - - self._update_device_sensors(devicename, attrs) - - time.sleep(2) - -#-------------------------------------------------------------------- - def _update_last_latitude_longitude(self, devicename, latitude, longitude, called_from=""): - #Make sure that the last latitude/longitude is not set to the - #base stationary one before updating. If it is, do not save them - - if latitude is None or longitude is None: - error_msg = (f"Discarded > Undefined GPS Coordinates, " - f"UpdateRequestedBy-{called_from}") - - elif latitude == self.stat_zone_base_lat and longitude == self.stat_zone_base_long: - error_msg = (f"Discarded > Can not set current location to Stationary " - f"Base Zone location {format_gps(latitude, longitude, 0)}, " - f"UpdateRequestedBy-{called_from}") - else: - self.last_lat[devicename] = latitude - self.last_long[devicename] = longitude - return True - - self._save_event_halog_info(devicename, error_msg) - return False - -#-------------------------------------------------------------------- - @staticmethod - def _latitude_longitude_none(latitude, longitude): - return (latitude is None or longitude is None) - -#-------------------------------------------------------------------- - def _update_stationary_zone(self, devicename, latitude, longitude, enter_exit_flag, calledfrom=''): - """ Set, Enter, Exit, Move stationary zone """ - - try: - - event_log_devicename = "*" if self.start_icloud3_inprocess_flag else devicename - stat_zone_name = self._format_zone_name(devicename, STATIONARY) - self.stat_zone_update_location[stat_zone_name] = "" #clear update location - - if latitude is None or longitude is None: - return - - stat_zone_dist = self._calc_distance_m(latitude, longitude, - self.zone_lat.get(stat_zone_name), self.zone_long.get(stat_zone_name)) - stat_home_dist = self._calc_distance_m(latitude, longitude, - self.zone_home_lat, self.zone_home_long,) - - #Make sure stationary zone is not being moved to another zone's location unless it a - #Stationary Zone - for zone in self.zone_lat: - if instr(zone, STATIONARY) == False: - zone_dist = self._calc_distance_m(latitude, longitude, self.zone_lat.get(zone), self.zone_long.get(zone)) - if zone_dist < self.zone_radius_m.get(zone): - event_msg = (f"Move into stationary zone cancelled > Too close to zone-{zone}, Dist-{zone_dist}m") - self._save_event(devicename, event_msg) - self.stat_zone_timer[devicename] = self.this_update_secs + self.stat_zone_still_time - - return False - - self.zone_lat[stat_zone_name] = latitude - self.zone_long[stat_zone_name] = longitude - self.zone_passive[stat_zone_name] = not enter_exit_flag - self.stat_zone_moved_total[devicename] = 0 - - attrs = {} - attrs[CONF_NAME] = stat_zone_name - attrs[ATTR_LATITUDE] = latitude - attrs[ATTR_LONGITUDE] = longitude - attrs['icon'] = (f"mdi:{self.stat_zone_devicename_icon.get(devicename)}") - attrs[ATTR_FRIENDLY_NAME] = stat_zone_name - - #If Stationary zone is exited, move it back to it's base location - #and reduce it's size - if enter_exit_flag == STAT_ZONE_MOVE_TO_BASE: - attrs['passive'] = True - attrs[ATTR_RADIUS] = 1 - self.zone_radius_m[stat_zone_name] = 1 - self.zone_radius_km[stat_zone_name] = .1 - self.stat_zone_timer[devicename] = 0 - self.in_stationary_zone_flag[devicename] = False - - self.stat_zone_update_location[stat_zone_name] = attrs - - if self.start_icloud3_inprocess_flag == False: - event_msg = (f"Reset Stationary Zone Location > {stat_zone_name}, " - f"Moved to Base Location-{format_gps(latitude, longitude, 0)}, " - f"DistFromHome-{self._format_dist_m(stat_home_dist)}") - self._save_event(devicename, event_msg) - - else: - #self.zone_by_fname[stat_zone_name] = stat_zone_name - self.zone_to_display[stat_zone_name] = 'Stationary' - self.state_to_zone[stat_zone_name] = stat_zone_name - - return True - - #Set Stationary Zone at new location - attrs['passive'] = False - attrs[ATTR_RADIUS] = self.stat_zone_radius_m - self.zone_radius_m[stat_zone_name] = self.stat_zone_radius_m - self.zone_radius_km[stat_zone_name] = self.stat_zone_radius_km - self.in_stationary_zone_flag[devicename] = True - - self.stat_zone_update_location[stat_zone_name] = attrs - - self._trace_device_attributes(stat_zone_name, "SET.STAT.ZONE", "SetStatZone", attrs) - - if stat_zone_dist > self.stat_zone_radius_m: - event_msg = (f"Moving Into Stationary Zone >") - else: - event_msg = (f"Moving Stationary Zone Location >") - event_msg += (f" {stat_zone_name}, " - f"GPS-{format_gps(latitude, longitude, 0)}, " - f"DistFromHome-{self._format_dist_m(stat_home_dist)}") - if stat_zone_dist <= self.stat_zone_radius_m: - event_msg += (f", DistFromLastLoc-{stat_zone_dist}m") - if self.stat_zone_timer.get(devicename) > 0: - event_msg += (f", StationarySince-{self._secs_to_time(self.stat_zone_timer.get(devicename) - self.stat_zone_still_time)}") - self._save_event_halog_info(event_log_devicename, event_msg) - - self.stat_zone_timer[devicename] = 0 - - return True - - except Exception as err: - _LOGGER.exception(err) - log_msg = (f"►INTERNAL ERROR (UpdtStatZone-{err})") - self._log_error_msg(log_msg) - - return False - -#-------------------------------------------------------------------- - def _update_stationary_zone_location(self, devicename): - ''' move the stationary zone to it's new location if necessary ''' - - try: - stat_zone_name = self._format_zone_name(devicename, STATIONARY) - - attrs = self.stat_zone_update_location.get(stat_zone_name, '') - if attrs != '': - self._set_state("zone." + stat_zone_name, "zoning", attrs) - - - except Exception as err: - _LOGGER.exception(err) - pass - -#-------------------------------------------------------------------- - def _reset_stat_zone_time(self): - ''' returns the initial value of the stationary zone timer ''' - return self.this_update_secs + self.stat_zone_still_time - -#-------------------------------------------------------------------- - def _update_device_sensors(self, arg_devicename, attrs:dict): - ''' - Update/Create sensor for the device attributes - - sensor_device_attrs = ['distance', 'calc_distance', 'waze_distance', - 'travel_time', 'dir_of_travel', 'interval', 'info', - 'last_located', 'last_update', 'next_update', - 'poll_count', 'trigger', 'battery', 'battery_state', - 'gps_accuracy', 'zone', 'last_zone', 'travel_distance'] - - sensor_attrs_format = {'distance': 'dist', 'calc_distance': 'dist', - 'travel_distance': 'dist', 'battery': '%', - 'dir_of_travel': 'title'} - ''' - try: - if not attrs: - return - - #check to see if arg_devicename is really devicename_zone - if instr(arg_devicename, ":") == False: - devicename = arg_devicename - prefix_zone = self.base_zone - else: - devicename = arg_devicename.split(':')[0] - prefix_zone = arg_devicename.split(':')[1] - - badge_state = None - badge_zone = None - badge_dist = None - base_entity = self.sensor_prefix_name.get(devicename, devicename) - - if prefix_zone == HOME: - base_entity = (f"sensor.{self.sensor_prefix_name.get(devicename, devicename)}") - attr_fname_prefix = self.sensor_attr_fname_prefix.get(devicename) - else: - base_entity = (f"sensor.{prefix_zone}_{self.sensor_prefix_name.get(devicename, devicename)}") - attr_fname_prefix = (f"{prefix_zone.replace('_', ' ', 99).title()}_" - f"{self.sensor_attr_fname_prefix.get(devicename)}") - - for attr_name in SENSOR_DEVICE_ATTRS: - sensor_entity = (f"{base_entity}_{attr_name}") - if attr_name in attrs: - state_value = attrs.get(attr_name) - else: - continue - - sensor_attrs = {} - if attr_name in SENSOR_ATTR_FORMAT: - format_type = SENSOR_ATTR_FORMAT.get(attr_name) - if format_type == 'dist': - sensor_attrs[CONF_UNIT_OF_MEASUREMENT] = self.unit_of_measurement - - elif format_type == 'diststr': - try: - x = (state_value / 2) - sensor_attrs[CONF_UNIT_OF_MEASUREMENT] = self.unit_of_measurement - except: - sensor_attrs[CONF_UNIT_OF_MEASUREMENT] = '' - elif format_type == '%': - sensor_attrs[CONF_UNIT_OF_MEASUREMENT] = '%' - elif format_type == 'title': - state_value = state_value.title().replace('_', ' ') - elif format_type == 'kph-mph': - sensor_attrs[CONF_UNIT_OF_MEASUREMENT] = self.um_kph_mph - elif format_type == 'm-ft': - sensor_attrs[CONF_UNIT_OF_MEASUREMENT] = self.um_m_ft - - if attr_name in SENSOR_ATTR_ICON: - sensor_attrs['icon'] = SENSOR_ATTR_ICON.get(attr_name) - - if attr_name in SENSOR_ATTR_FNAME: - sensor_attrs[ATTR_FRIENDLY_NAME] = (f"{attr_fname_prefix}{SENSOR_ATTR_FNAME.get(attr_name)}") - - self._update_device_sensors_hass(devicename, base_entity, attr_name, - state_value, sensor_attrs) - - if attr_name == 'zone': - zone_names = self._get_zone_names(state_value) - if badge_state is None: - badge_state = zone_names[1] - self._update_device_sensors_hass(devicename, base_entity, - "zone_name1", zone_names[1], sensor_attrs) - - self._update_device_sensors_hass(devicename, base_entity, - "zone_name2", zone_names[2], sensor_attrs) - - self._update_device_sensors_hass(devicename, base_entity, - "zone_name3", zone_names[3], sensor_attrs) - - elif attr_name == 'last_zone': - zone_names = self._get_zone_names(state_value) - - self._update_device_sensors_hass(devicename, base_entity, - "last_zone_name1", zone_names[1], sensor_attrs) - - self._update_device_sensors_hass(devicename, base_entity, - "last_zone_name2", zone_names[2], sensor_attrs) - - self._update_device_sensors_hass(devicename, base_entity, - "last_zone_name3", zone_names[3], sensor_attrs) - - elif attr_name == 'zone_distance': - if state_value and float(state_value) > 0: - badge_state = (f"{state_value} {self.unit_of_measurement}") - - if badge_state: - self._update_device_sensors_hass(devicename, - base_entity, - "badge", - badge_state, - self.sensor_badge_attrs.get(devicename)) - return True - - except Exception as err: - _LOGGER.exception(err) - log_msg = (f"►INTERNAL ERROR (UpdtSensorUpdate-{err})") - self._log_error_msg(log_msg) - - return False -#-------------------------------------------------------------------- - def _update_device_sensors_hass(self, devicename, base_entity, attr_name, - state_value, sensor_attrs): - - try: - state_value = state_value[0:250] - except: - pass - - if attr_name in self.sensors_custom_list: - sensor_entity = (f"{base_entity}_{attr_name}") - - self._set_state(sensor_entity, state_value, sensor_attrs) - -#-------------------------------------------------------------------- - def _format_info_attr(self, devicename, battery, - gps_accuracy, dist_last_poll_moved_km, - zone): - - """ - Initialize info attribute - """ - devicename_zone = self._format_devicename_zone(devicename) - try: - info_msg = '' - if self.info_notification != '': - info_msg = f"●●{self.info_notification}●●" - self.info_notification = '' - - if self.base_zone != HOME: - info_msg += (f" • Base.Zone: {self.base_zone_name}") - - if (self.TRK_METHOD_IOSAPP - and self.tracking_method_config != IOSAPP): - info_msg += (f" • Track.Method: {self.trk_method_short_name}") - - if self.override_interval_seconds.get(devicename) > 0: - info_msg += " • Overriding.Interval" - - if zone == 'near_zone': - info_msg += " • NearZone" - - if battery > 0: - info_msg += f" • Battery-{battery}%" - - if gps_accuracy > self.gps_accuracy_threshold: - info_msg += (f" • Poor.GPS.Accuracy, Dist-{self._format_dist(gps_accuracy)}") - if self.poor_location_gps_cnt.get(devicename) > 0: - info_msg += (f" (#{self.poor_location_gps_cnt.get(devicename)})") - if (self._is_inzone_zonename(zone) - and self.ignore_gps_accuracy_inzone_flag): - info_msg += " - Discarded" - - old_cnt = self.poor_location_gps_cnt.get(devicename) - if old_cnt > 16: - location_age = self._secs_since(self.last_located_secs.get(devicename)) - info_msg += (f" • May Be Offline, Located-{self._format_age(location_age)}, " - f"OldCnt-#{old_cnt}") - - if self.stat_zone_timer.get(devicename) > 0: - info_msg += (f" • MoveIntoStatZone@{self._secs_to_time(self.stat_zone_timer.get(devicename))}") - - if self.waze_data_copied_from.get(devicename) is not None: - copied_from = self.waze_data_copied_from.get(devicename) - if devicename != copied_from: - info_msg += (f" • Using Waze data from {self.fname.get(copied_from)}") - - except Exception as err: - _LOGGER.exception(err) - info_msg += (f"Error setting up info attribute-{err}") - self._log_error_msg(info_msg) - - return info_msg - -#-------------------------------------------------------------------- - def _display_info_status_msg(self, devicename_zone, info_msg, start_icloud3_initial_load_flag=False): - ''' - Display a status message in the info sensor. If the devicename_zone - parameter contains the base zone (devicename:zone), display only for that - devicename_zone, otherwise (devicename), display for all zones for - the devicename. - ''' - try: - if start_icloud3_initial_load_flag: - return - - save_base_zone = self.base_zone - - if devicename_zone == '': - if self.tracked_devices: - devicename = self.tracked_devices[0] - devicename_zone_list = self.track_from_zone.get(devicename) - else: - devicename = self.track_devices[0].split('>')[0].strip() - devicename_zone_list = [self.base_zone] - - elif instr(devicename_zone, ':'): - devicename = devicename_zone.split(':')[0] - devicename_zone_list = [devicename_zone.split(':')[1]] - else: - devicename = devicename_zone - devicename_zone_list = self.track_from_zone.get(devicename) - - elapsed_time = '' - - for zone in devicename_zone_list: - self.base_zone = zone - attrs = {} - attrs[ATTR_INFO] = f"●● {info_msg}{elapsed_time} ●●" - self._update_device_sensors(devicename, attrs) - - except Exception as err: - _LOGGER.exception(err) - pass - - self.base_zone = save_base_zone - - #return formatted msg for event log - return (f"CRLF• {info_msg}") - -#-------------------------------------------------------------------- - def _update_count_update_ignore_attribute(self, devicename, info = None): - self.count_update_ignore[devicename] += 1 - - try: - attrs = {} - attrs[ATTR_POLL_COUNT] = self._format_poll_count(devicename) - - self._update_device_sensors(devicename, attrs) - - except: - pass -#-------------------------------------------------------------------- - def _format_poll_count(self, devicename): - - return (f"{self.count_update_icloud.get(devicename)}:" - f"{self.count_update_iosapp.get(devicename)}:" - f"{self.count_update_ignore.get(devicename)}") - -#-------------------------------------------------------------------- - def _display_usage_counts(self, devicename, force_display=False): - - - try: - if devicename not in self.count_update_icloud: - self._initialize_usage_counters(devicename, True) - - total_count = self.count_update_icloud.get(devicename) + \ - self.count_update_iosapp.get(devicename) + \ - self.count_update_ignore.get(devicename) + \ - self.count_state_changed.get(devicename) + \ - self.count_trigger_changed.get(devicename) + \ - self.iosapp_request_loc_cnt.get(devicename) - - - pyi_avg_time_per_call = self.time_pyicloud_calls / \ - (self.count_pyicloud_authentications + self.count_pyicloud_location_update) \ - if self.count_pyicloud_location_update > 0 else 0 - #If updating the devicename's info_msg, only add to the event log - #and info_msg if the counter total is divisible by 5. - total_count = 1 - hour = int(dt_util.now().strftime('%H')) - if force_display: - pass - elif (hour % 3) != 0: - return (None, 0) - elif total_count == 0: - return (None, 0) - - # ¤s= Table start, Row start - # ¤e=
Row end, Table end - # §= Row end, next row start - # » = - # «LT- = Col start, 40% width - # ¦LC-= Col end, next col start-width 40% - # ¦RT-= Col end, next col start-width 10% - # ¦RC-= Col end, next col start-width 40% - - count_msg = (f"¤s") - state_trig_count = self.count_state_changed.get(devicename) + self.count_trigger_changed.get(devicename) - - if self.TRK_METHOD_FMF_FAMSHR: - count_msg += (f"«HS¦LH-Device Counts¦RH-iCloud Counts»HE" - f"«LT-State/Trigger Chgs¦LC-{state_trig_count}¦RT-Authentications¦RC-{self.count_pyicloud_authentications}»" - f"«LT-iCloud Updates¦LC-{self.count_update_icloud.get(devicename)}¦RT-Total iCloud Loc Rqsts¦RC-{self.count_pyicloud_location_update}»" - f"«LT-iOS App Updates¦LC-{self.count_update_iosapp.get(devicename)}¦RT-Time/Locate (secs)¦RC-{round(pyi_avg_time_per_call, 2)}»") - else: - count_msg += (f"«HS¦LH-Device Counts¦RH-iOS App Counts»HE" - f"«LT-State/Triggers Chgs¦LC-{state_trig_count}¦RT-iOS Locate Requests¦RC-{self.iosapp_request_loc_cnt.get(devicename)}»" - f"«LT-iCloud Updates¦LC-{self.count_update_icloud.get(devicename)}¦RT-iOS App Updates¦RC-{self.count_update_iosapp.get(devicename)}»") - - count_msg += (f"«LT-Discarded¦LC-{self.count_update_ignore.get(devicename)}¦RT-Waze Routes¦RC-{self.count_waze_locates.get(devicename)}»" - f"¤e") - - self._save_event(devicename, f"{count_msg}") - - except Exception as err: - _LOGGER.exception(err) - - return (None, 0) - -######################################################### -# -# Perform tasks on a regular time schedule -# -######################################################### - def _timer_tasks_every_hour(self): - for devicename in self.tracked_devices: - self._display_usage_counts(devicename) - -#-------------------------------------------------------------------- - def _timer_tasks_midnight(self): - for devicename in self.tracked_devices: - devicename_zone = self._format_devicename_zone(devicename, HOME) - - event_msg = (f"^^^ iCloud3 v{VERSION} Daily Summary > " - f"{dt_util.now().strftime('%A, %b %d')}") - self._save_event_halog_info(devicename, event_msg) - - event_msg = (f"Tracking Devices > {self.track_devicename_list}") - self._save_event_halog_info(devicename, event_msg) - - if self.iosapp_monitor_dev_trk_flag.get(devicename): - event_msg = (f"iOS App monitoring > {self._format_fname_devicename(devicename)} > " - f"CRLF• device_tracker.{self.devicename_iosapp_entity.get(devicename)}, " - f"CRLF• sensor.{self.iosapp_last_trigger_entity.get(devicename)}") - self._save_event_halog_info(devicename, event_msg) - else: - event_msg = (f"iOS App monitoring > device_tracker.{devicename}") - self._save_event_halog_info(devicename, event_msg) - - self.count_pyicloud_authentications = 0 - self.count_pyicloud_location_update = 0 - self.time_pyicloud_calls = 0.0 - self._initialize_usage_counters(devicename, True) - - for devicename_zone in self.waze_distance_history: - self.waze_distance_history[devicename_zone] = '' - - self._check_authentication_2sa_code_needed() - -#-------------------------------------------------------------------- - def _timer_tasks_1am(self): - self._calculate_time_zone_offset() - -######################################################### -# -# VARIABLE DEFINITION & INITIALIZATION FUNCTIONS -# -######################################################### - def _define_tracking_control_fields(self): - self.icloud3_started_secs = 0 - self.icloud_acct_auth_error_cnt = 0 - self.immediate_retry_flag = False - self.time_zone_offset_seconds = self._calculate_time_zone_offset() - self.setinterval_cmd_devicename = None - self.update_in_process_flag = False - self.track_devicename_list = '' - self.any_device_being_updated_flag = False - self.tracked_devices_config_parm = {} #config file item for devicename - self.tracked_devices = [] - - self.trigger = {} #device update trigger - self.last_iosapp_trigger = {} #last trigger issued by iosapp - self.got_exit_trigger_flag = {} #iosapp issued exit trigger leaving zone - self.device_being_updated_flag = {} - self.device_being_updated_retry_cnt = {} - self.last_iosapp_state = {} - self.last_iosapp_state_changed_time = {} - self.last_iosapp_state_changed_secs = {} - self.last_iosapp_trigger = {} - self.last_iosapp_trigger_changed_time= {} - self.last_iosapp_trigger_changed_secs= {} - - self.iosapp_update_flag = {} - self.iosapp_monitor_dev_trk_flag = {} - self.iosapp_monitor_error_cnt = {} - self.iosapp_last_trigger_entity = {} #sensor entity extracted from entity_registry - self.iosapp_battery_level_entity = {} #sensor entity extracted from entity_registry - - self.iosapp_stat_zone_action_msg_cnt = {} - self.authenticated_time = 0 - self.authentication_error_cnt = 0 - self.authentication_error_retry_secs = HIGH_INTEGER - self.info_notification = '' - self.broadcast_msg = '' - - this_update_time = dt_util.now().strftime('%H:%M:%S') -#-------------------------------------------------------------------- - def _define_event_log_fields(self): - self.event_log_table = [] - self.event_log_base_attrs = '' - self.log_table_max_items = 999 - self.event_log_clear_secs = HIGH_INTEGER - self.event_log_sensor_state = '' - self.event_log_last_devicename = '*' - -#-------------------------------------------------------------------- - def _define_device_fields(self): - ''' - Dictionary fields for each devicename - ''' - self.fname = {} #name made from status[CONF_NAME] - self.badge_picture = {} #devicename picture from badge setup - self.api_devices = None #icloud.api.devices obj - self.api_device_devicename = {} #icloud.api.device obj for each devicename - self.data_source = {} - self.device_type = {} - self.device_status = {} - self.devicename_iosapp_entity = {} - self.devicename_iosapp_suffix = {} - self.notify_iosapp_entity = {} - self.devicename_verified = {} #Set to True in mode setup fcts - self.fmf_id = {} - self.fmf_devicename_email = {} - self.seen_this_device_flag = {} - self.device_tracker_entity_ic3 = {} - self.device_tracker_entity_iosapp = {} - self.track_from_zone = {} #Track device from other zone - -#-------------------------------------------------------------------- - def _define_device_status_fields(self): - ''' - Dictionary fields for each devicename - ''' - self.tracking_device_flag = {} - self.state_this_poll = {} - self.state_last_poll = {} - self.zone_last = {} - self.zone_current = {} - self.zone_timestamp = {} - self.state_change_flag = {} - self.location_data = {} - self.last_located_time = {} - self.last_located_secs = {} #device timestamp in seconds - self.went_3km = {} #>3 km/mi, probably driving - -#-------------------------------------------------------------------- - def _initialize_device_status_fields(self, devicename): - ''' - Make domain name entity ids for the device_tracker and - sensors for each device so we don't have to do it all the - time. Then check to see if 'sensor.geocode_location' - exists. If it does, then using iosapp version 2. - ''' - entity_id = (f"device_tracker.{devicename}") - self.device_tracker_entity_ic3[devicename] = entity_id - self.state_this_poll[devicename] = self._get_state(entity_id) - self.state_last_poll[devicename] = NOT_SET - self.device_status[devicename] = UNKNOWN - self.zone_last[devicename] = '' - self.zone_current[devicename] = '' - self.zone_timestamp[devicename] = '' - self.state_change_flag[devicename] = False - self.location_data[devicename] = INITIAL_LOCATION_DATA - self.last_located_time[devicename] = HHMMSS_ZERO - self.last_located_secs[devicename] = 0 - self.went_3km[devicename] = False - - self.trigger[devicename] = 'iCloud3' - self.got_exit_trigger_flag[devicename] = False - self.seen_this_device_flag[devicename] = False - self.device_being_updated_flag[devicename] = False - self.device_being_updated_retry_cnt[devicename] = 0 - - self.iosapp_update_flag[devicename] = False - - #iosapp v2 entity info - self.last_iosapp_state[devicename] = '' - self.last_iosapp_state_changed_time[devicename] = '' - self.last_iosapp_state_changed_secs[devicename] = 0 - self.last_iosapp_trigger[devicename] = '' - self.last_iosapp_trigger_changed_time[devicename] = '' - self.last_iosapp_trigger_changed_secs[devicename] = 0 - self.iosapp_monitor_error_cnt[devicename] = 0 - - -#-------------------------------------------------------------------- - def _initialize_um_formats(self, unit_of_measurement): - #Define variables, lists & tables - if unit_of_measurement == 'mi': - if self.time_format != 24: self.time_format = 12 - self.um_km_mi_factor = 0.62137 - self.um_m_ft = 'ft' - self.um_kph_mph = 'mph' - else: - if self.time_format != 12: self.time_format = 24 - self.um_km_mi_factor = 1 - self.um_m_ft = 'm' - self.um_kph_mph = 'kph' - - if self.time_format == 12: - self.um_time_strfmt = '%I:%M:%S' - self.um_time_strfmt_ampm = '%I:%M:%S%P' - self.um_date_time_strfmt = '%Y-%m-%d %I:%M:%S' - else: - self.um_time_strfmt = '%H:%M:%S' - self.um_time_strfmt_ampm = '%H:%M:%S' - self.um_date_time_strfmt = '%Y-%m-%d %H:%M:%S' - -#-------------------------------------------------------------------- - def _setup_tracking_method(self, tracking_method): - ''' - tracking_method: method - tracking_method: method, iosapp1 - - tracking_method can have a secondary option to use iosappv1 even if iosv2 is - on the devices - ''' - - trk_method_split = (f"{tracking_method}_").split('_') - trk_method_primary = trk_method_split[0] - trk_method_secondary = trk_method_split[1] - - self.TRK_METHOD_FMF = (trk_method_primary == FMF) - self.TRK_METHOD_FAMSHR = (trk_method_primary == FAMSHR) - self.TRK_METHOD_FMF_FAMSHR = (trk_method_primary in FMF_FAMSHR) - self.CONF_TRK_METHOD_FMF_FAMSHR = (trk_method_primary in FMF_FAMSHR) - if (self.TRK_METHOD_FMF_FAMSHR and PYICLOUD_IC3_IMPORT_SUCCESSFUL is False): - trk_method_primary = IOSAPP - - self.TRK_METHOD_IOSAPP = (trk_method_primary in IOSAPP_IOSAPP1) - - self.trk_method_config = trk_method_primary - self.trk_method = trk_method_primary - self.trk_method_name = TRK_METHOD_NAME.get(trk_method_primary) - self.trk_method_short_name = TRK_METHOD_SHORT_NAME.get(trk_method_primary) - - if self.TRK_METHOD_FMF_FAMSHR and self.password == '': - event_msg = ("iCloud3 Error > The password is required for the " - f"{self.trk_method_short_name} tracking method. The " - f"iOS App tracking_method will be used.") - self._save_event_halog_error("*", event_msg) - - self._setup_iosapp_tracking_method() -#-------------------------------------------------------------------- - def _setup_iosapp_tracking_method(self): - """ Change tracking method to IOSAPP if FMF or FAMSHR error """ - self.trk_method_short_name = TRK_METHOD_SHORT_NAME.get(IOSAPP) - self.trk_method_config = IOSAPP - self.trk_method = IOSAPP - self.trk_method_name = TRK_METHOD_NAME.get(IOSAPP) - self.TRK_METHOD_IOSAPP = True - self.TRK_METHOD_FMF = False - self.TRK_METHOD_FAMSHR = False - self.TRK_METHOD_FMF_FAMSHR = False - -#-------------------------------------------------------------------- - def _define_device_tracking_fields(self): - #times, flags - self.update_timer = {} - self.override_interval_seconds = {} - self.dist_from_zone_km_small_move_total = {} - self.this_update_secs = 0 - - #location, gps - self.poor_location_gps_cnt = {} # override interval while < 4 - self.poor_location_gps_msg = {} - self.old_location_secs = {} - self.poor_gps_flag = {} - self.last_lat = {} - self.last_long = {} - self.last_battery = {} #used to detect iosapp v2 change - self.last_gps_accuracy = {} #used to detect iosapp v2 change - -#-------------------------------------------------------------------- - def _initialize_device_tracking_fields(self, devicename): - #times, flags - self.update_timer[devicename] = time.time() - self.override_interval_seconds[devicename] = 0 - self.dist_from_zone_km_small_move_total[devicename] = 0 - - #location, gps - self.poor_location_gps_cnt[devicename] = 0 - self.poor_location_gps_msg[devicename] = False - self.old_location_secs[devicename] = 90 #Timer (secs) before a location is old - self.poor_gps_flag[devicename] = False - self.last_lat[devicename] = self.zone_home_lat - self.last_long[devicename] = self.zone_home_long - self.last_battery[devicename] = 0 - self.last_gps_accuracy[devicename] = 0 - self.stat_zone_timer[devicename] = 0 - self.stat_zone_moved_total[devicename] = 0 - - stat_zone_name = self._format_zone_name(devicename, STATIONARY) - self.stat_zone_update_location[stat_zone_name] = '' - - #Other items - self.data_source[devicename] = '' - self.last_iosapp_msg[devicename] = '' - self.last_device_monitor_msg[devicename] = '' - self.iosapp_stat_zone_action_msg_cnt[devicename]= 0 - -#-------------------------------------------------------------------- - def _define_usage_counters(self): - self.count_update_icloud = {} - self.count_update_iosapp = {} - self.count_update_ignore = {} - self.count_state_changed = {} - self.count_trigger_changed = {} - self.count_waze_locates = {} - self.time_waze_calls = {} - self.count_pyicloud_authentications = 0 - self.count_pyicloud_location_update = 0 - self.time_pyicloud_calls = 0.0 - -#-------------------------------------------------------------------- - def _initialize_usage_counters(self, devicename, clear_counters=True): - if devicename not in self.count_update_icloud or clear_counters: - self.count_update_icloud[devicename] = 0 - self.count_update_iosapp[devicename] = 0 - self.count_update_ignore[devicename] = 0 - self.count_state_changed[devicename] = 0 - self.count_trigger_changed[devicename] = 0 - self.count_waze_locates[devicename] = 0 - self.time_waze_calls[devicename] = 0.0 -#-------------------------------------------------------------------- - def _define_iosapp_message_fields(self): - self.iosapp_request_loc_sent_flag = {} - self.iosapp_request_loc_sent_secs = {} - self.iosapp_request_loc_cnt = {} - self.iosapp_request_loc_retry_cnt = {} - -#-------------------------------------------------------------------- - def _initialize_iosapp_message_fields(self, devicename): - self.iosapp_request_loc_sent_flag[devicename] = False - self.iosapp_request_loc_sent_secs[devicename] = 0 - self.iosapp_request_loc_cnt[devicename] = 0 - self.iosapp_request_loc_retry_cnt[devicename] = 0 - -#-------------------------------------------------------------------- - def _initialize_next_update_time(self, devicename): - for zone in self.track_from_zone.get(devicename): - devicename_zone = self._format_devicename_zone(devicename, zone) - - self.next_update_time[devicename_zone] = HHMMSS_ZERO - self.next_update_secs[devicename_zone] = 0 - -#-------------------------------------------------------------------- - def _define_sensor_fields(self, initial_load_flag): - #Prepare sensors and base attributes - - if initial_load_flag: - self.sensor_devicenames = [] - self.sensors_custom_list = [] - self.sensor_badge_attrs = {} - self.sensor_prefix_name = {} - self.sensor_attr_fname_prefix = {} - -#-------------------------------------------------------------------- - def _define_zone_fields(self): - ''' - Dictionary fields for zone friendly names - ''' - self.zones = [] - self.zone_to_display= dict(ZONE_TO_DISPLAY_BASE) - self.state_to_zone = dict(STATE_TO_ZONE_BASE) - self.zone_lat = {} - self.zone_long = {} - self.zone_radius_km = {} - self.zone_radius_m = {} - self.zone_passive = {} - -#-------------------------------------------------------------------- - def _define_device_zone_fields(self): - ''' - Dictionary fields for each devicename_zone - ''' - self.last_tavel_time = {} - self.interval_seconds = {} - self.interval_str = {} - self.last_distance_str = {} - self.last_update_time = {} - self.last_update_secs = {} - self.next_update_secs = {} - self.next_update_time = {} - self.next_update_in_secs = {} - self.next_update_devicenames= [] - - #used to calculate distance traveled since last poll - self.waze_time = {} - self.waze_dist = {} - self.calc_dist = {} - self.zone_dist = {} - - self.last_ic3dev_timestamp_ses = {} - -#-------------------------------------------------------------------- - def _initialize_device_zone_fields(self, devicename): - #interval, distances, times - - for zone in self.track_from_zone.get(devicename): - devicename_zone = self._format_devicename_zone(devicename, zone) - - self.last_tavel_time[devicename_zone] = '' - self.interval_seconds[devicename_zone] = 0 - self.interval_str[devicename_zone] = '0 sec' - self.last_distance_str[devicename_zone] = '' - self.last_update_time[devicename_zone] = HHMMSS_ZERO - self.last_update_secs[devicename_zone] = 0 - self.next_update_time[devicename_zone] = HHMMSS_ZERO - self.next_update_secs[devicename_zone] = 0 - self.next_update_in_secs[devicename_zone] = 0 - - self.waze_history_data_used_flag[devicename_zone] = False - self.waze_time[devicename_zone] = 0 - self.waze_dist[devicename_zone] = 0 - self.calc_dist[devicename_zone] = 0 - self.zone_dist[devicename_zone] = 0 - - try: - #set up stationary zone icon for devicename - first_initial = self.fname.get(devicename)[0].lower() - - if devicename in self.stat_zone_devicename_icon: - icon = self.stat_zone_devicename_icon.get(devicename) - elif (f"alpha-{first_initial}-box") not in self.stat_zone_devicename_icon: - icon_name = (f"alpha-{first_initial}-box") - elif (f"alpha-{first_initial}-circle") not in self.stat_zone_devicename_icon: - icon_name = (f"alpha-{first_initial}-circle") - elif (f"alpha-{first_initial}-box-outline") not in self.stat_zone_devicename_icon: - icon_name = (f"alpha-{first_initial}-box-outline") - elif (f"alpha-{first_initial}-circle-outline") not in self.stat_zone_devicename_icon: - icon_name = (f"alpha-{first_initial}-circle-outline") - else: - icon_name = (f"alpha-{first_initial}") - - self.stat_zone_devicename_icon[devicename] = icon_name - self.stat_zone_devicename_icon[icon_name] = devicename - - stat_zone_name = self._format_zone_name(devicename, STATIONARY) - self.zone_to_display[stat_zone_name] = "Stationary" - - except Exception as err: - _LOGGER.exception(err) - self.stat_zone_devicename_icon[devicename] = 'account' - -#-------------------------------------------------------------------- - def _initialize_waze_fields(self, waze_region, waze_min_distance, - waze_max_distance, waze_realtime): - #Keep distance data to be used by another device if nearby. Also keep - #source of copied data so that device won't reclone from the device - #using it. - self.waze_region = waze_region.upper() - self.waze_realtime = waze_realtime - - min_dist_msg = (f"{waze_min_distance} {self.unit_of_measurement}") - max_dist_msg = (f"{waze_max_distance} {self.unit_of_measurement}") - - if self.unit_of_measurement == 'mi': - self.waze_min_distance = self._mi_to_km(waze_min_distance) - self.waze_max_distance = self._mi_to_km(waze_max_distance) - min_dist_msg += (f" ({self.waze_min_distance}km)") - max_dist_msg += (f" ({self.waze_max_distance}km)") - else: - self.waze_min_distance = float(waze_min_distance) - self.waze_max_distance = float(waze_max_distance) - - self.waze_distance_history = {} - self.waze_data_copied_from = {} - self.waze_history_data_used_flag = {} - - self.waze_manual_pause_flag = False #If Paused vid iCloud command - self.waze_close_to_zone_pause_flag = False #pause if dist from zone < 1 flag - - if self.distance_method_waze_flag: - log_msg = (f"Set Up Waze > Region-{self.waze_region}, " - f"MinDist-{min_dist_msg}, " - f"MaxDist-{max_dist_msg}, " - f"Realtime-{self.waze_realtime}") - self._log_info_msg(log_msg) - self._save_event("*", log_msg) - -#-------------------------------------------------------------------- - def _initialize_attrs(self, devicename): - attrs = {} - attrs[ATTR_NAME] = '' - attrs[ATTR_ZONE] = NOT_SET - attrs[ATTR_LAST_ZONE] = NOT_SET - attrs[ATTR_ZONE_TIMESTAMP] = '' - attrs[ATTR_INTERVAL] = '' - attrs[ATTR_WAZE_TIME] = '' - attrs[ATTR_ZONE_DISTANCE] = 0 - attrs[ATTR_CALC_DISTANCE] = 0 - attrs[ATTR_WAZE_DISTANCE] = 0 - attrs[ATTR_LAST_LOCATED] = HHMMSS_ZERO - attrs[ATTR_LAST_UPDATE_TIME] = HHMMSS_ZERO - attrs[ATTR_NEXT_UPDATE_TIME] = HHMMSS_ZERO - attrs[ATTR_POLL_COUNT] = '0:0:0' - attrs[ATTR_DIR_OF_TRAVEL] = '' - attrs[ATTR_TRAVEL_DISTANCE] = 0 - attrs[ATTR_TRIGGER] = '' - attrs[ATTR_TIMESTAMP] = dt_util.utcnow().isoformat()[0:19] - attrs[ATTR_AUTHENTICATED] = '' - attrs[ATTR_BATTERY] = 0 - attrs[ATTR_BATTERY_STATUS] = UNKNOWN - attrs[ATTR_INFO] = "● HA is initializing dev_trk attributes ●" - attrs[ATTR_ALTITUDE] = 0 - attrs[ATTR_VERT_ACCURACY] = 0 - attrs[ATTR_DEVICE_STATUS] = UNKNOWN - attrs[ATTR_LOW_POWER_MODE] = '' - attrs[CONF_GROUP] = self.group - attrs[ATTR_PICTURE] = self.badge_picture.get(devicename) - attrs[ATTR_TRACKING] = (f"{self.track_devicename_list} ({self.trk_method_short_name})") - attrs[ATTR_ICLOUD3_VERSION] = VERSION - - return attrs - -#-------------------------------------------------------------------- - def _initialize_zone_tables(self): - ''' - Get friendly name of all zones to set the device_tracker state - ''' - - try: - if self.start_icloud3_initial_load_flag == False: - self.hass.services.call(ATTR_ZONE, "reload") - except: - pass - - log_msg = (f"Reloading Zone.yaml config file") - self._log_debug_msg("*", log_msg) - - zones = self.hass.states.entity_ids(ATTR_ZONE) - zone_msg = '' - - for zone in zones: - zone_name = zone.split(".")[1] #zone='zone.'+zone_name - - try: - self.zones.append(zone_name.lower()) - zone_data = self.hass.states.get(zone).attributes - self._log_debug_msg("*",f"zone-{zone_name}, data-{zone_data}") - - if instr(zone_name.lower(), STATIONARY): - self.zone_to_display[zone_name] = 'Stationary' - - if ATTR_LATITUDE in zone_data: - self.zone_lat[zone_name] = zone_data.get(ATTR_LATITUDE, 0) - self.zone_long[zone_name] = zone_data.get(ATTR_LONGITUDE, 0) - self.zone_passive[zone_name] = zone_data.get('passive', True) - self.zone_radius_m[zone_name] = int(zone_data.get(ATTR_RADIUS, 100)) - self.zone_radius_km[zone_name] = round(self.zone_radius_m[zone_name]/1000, 4) - - zone_fname = zone_data.get(ATTR_FRIENDLY_NAME, zone_name) - if self.display_zone_fname_flag: - self.zone_to_display[zone_name] = zone_fname - self.state_to_zone[zone_fname] = zone_name - else: - zone_name_title = zone_name.replace('_',' ').title().replace(' ', '') - self.zone_to_display[zone_name] = zone_name_title - self.state_to_zone[zone_fname] = zone_name - self.state_to_zone[zone_name_title] = zone_name - - else: - log_msg = (f"Error loading zone {zone_name} > No data was returned from HA. " - f"Zone data returned is `{zone_data}`") - self._log_error_msg(log_msg) - self._save_event("*", log_msg) - - except KeyError: - self.zone_passive[zone_name] = False - - except Exception as err: - _LOGGER.exception(err) - - zone_msg = (f"{zone_msg}{zone_name}/{self.zone_to_display.get(zone_name)} " - f"(r{self.zone_radius_m[zone_name]}m), ") - - log_msg = (f"Set up Zones > {zone_msg[:-2]}") - self._save_event_halog_info("*", log_msg) - - self.zone_home_lat = self.zone_lat.get(HOME) - self.zone_home_long = self.zone_long.get(HOME) - self.zone_home_radius_km = float(self.zone_radius_km.get(HOME)) - self.zone_home_radius_m = self.zone_radius_m.get(HOME) - - self.base_zone = HOME - self.base_zone_name = self.zone_to_display.get(HOME) - self.base_zone_lat = self.zone_lat.get(HOME) - self.base_zone_long = self.zone_long.get(HOME) - self.base_zone_radius_km = float(self.zone_radius_km.get(HOME)) - - return - -#-------------------------------------------------------------------- - def _define_stationary_zone_fields(self, stationary_inzone_interval_str, - stationary_still_time_str): - #create dynamic zone used by ios app when stationary - self.stat_zone_inzone_interval = self._time_str_to_secs(stationary_inzone_interval_str) - self.stat_zone_still_time = self._time_str_to_secs(stationary_still_time_str) - self.stat_zone_half_still_time = self.stat_zone_still_time / 2 - self.in_stationary_zone_flag = {} - self.stat_zone_devicename_icon = {} #icon to be used for a devicename - self.stat_zone_moved_total = {} #Total of small distances - self.stat_zone_timer = {} #Time when distance set to 0 - self.stat_zone_update_location = {} #zonename/attrs to be updated after track results - self.stat_min_dist_from_zone_km = round(self.zone_home_radius_km * 2.5, 2) - self.stat_dist_move_limit = round(self.zone_home_radius_km * 1.5, 2) - self.stat_zone_radius_km = round(self.zone_home_radius_km * 2, 2) - self.stat_zone_radius_m = self.zone_home_radius_m * 2 - if self.stat_zone_radius_km > .1: self.stat_zone_radius_km = .1 - if self.stat_zone_radius_m > 100: self.stat_zone_radius_m = 100 - - #Offset the stat zone from the Home zone based on the stationary_zone_offset parameter - self.stat_zone_base_long = self.zone_home_long - if instr(self.stationary_zone_offset, "("): - szo = self.stationary_zone_offset.replace("(", "").replace(")", "") - self.stat_zone_base_lat = float(szo.split(',')[0]) - self.stat_zone_base_long = float(szo.split(',')[1]) - else: - offset_lat = float(self.stationary_zone_offset.split(',')[0]) * STATIONARY_ZONE_1KM_LAT - offset_long = float(self.stationary_zone_offset.split(',')[1]) * STATIONARY_ZONE_1KM_LONG - self.stat_zone_base_lat = self.zone_home_lat + offset_lat - self.stat_zone_base_long = self.zone_home_long + offset_long - - dist = self._calc_distance_km(self.zone_home_lat, self.zone_home_long, - self.stat_zone_base_lat, self.stat_zone_base_long) - - log_msg = (f"Set Initial Stationary Zone Location > " - f"GPS-{format_gps(self.stat_zone_base_lat, self.stat_zone_base_long, 0)}, " - f"Radius-{self.stat_zone_radius_m}m, DistFromHome-{dist}km") - self._log_debug_msg("*", log_msg) - self._save_event("*", log_msg) - -#-------------------------------------------------------------------- - def _initialize_debug_control(self, log_level): - #string set using the update_icloud command to pass debug commands - #into icloud3 to monitor operations or to set test variables - # interval - toggle display of interval calulation method in info fld - # debug - log 'debug' messages to the log file under the 'info' type - # debug_rawdata - log data read from records to the log file - # eventlog - Add debug items to ic3 event log - # debug+eventlog - Add debug items to HA log file and ic3 event log - - self.log_level_debug_flag = (instr(log_level, 'debug') or DEBUG_TRACE_CONTROL_FLAG) - self.log_level_debug_rawdata_flag = (instr(log_level, 'rawdata') and self.log_level_debug_flag) - self._log_debug_msgs_trace_flag = self.log_level_debug_flag - - self.log_level_intervalcalc_flag = DEBUG_TRACE_CONTROL_FLAG or instr(log_level, 'intervalcalc') - self.log_level_eventlog_flag = self.log_level_eventlog_flag or instr(log_level, 'eventlog') - - self.debug_counter = 0 - self.last_iosapp_msg = {} #can be used to compare changes in debug msgs - self.last_device_monitor_msg = {} - -######################################################### -# -# INITIALIZE PYICLOUD DEVICE API -# DEVICE SETUP SUPPORT FUNCTIONS FOR MODES FMF, FAMSHR, IOSAPP -# -######################################################### - def _pyicloud_initialize_device_api(self): - #See if pyicloud_ic3 is available - if (PYICLOUD_IC3_IMPORT_SUCCESSFUL == False and self.CONF_TRK_METHOD_FMF_FAMSHR): - event_msg = ("iCloud3 Error > An error was encountered setting up the `pyicloud_ic3.py` " - f"module. Either the module was not found or there was an error loading it." - f"The {self.trk_method_short_name} Location Service is disabled and the " - f"iOS App tracking_method will be used.") - self._save_event_halog_error("*", event_msg) - - self._setup_iosapp_tracking_method() - - else: - #Set up pyicloud cookies directory & file names - try: - self.icloud_cookies_dir = self.hass.config.path(STORAGE_DIR, STORAGE_KEY_ICLOUD) - self.icloud_cookies_file = (f"{self.icloud_cookies_dir}/" - f"{self.username.replace('@','').replace('.','')}") - if not os.path.exists(self.icloud_cookies_dir): - os.makedirs(self.icloud_cookies_dir) - - except Exception as err: - _LOGGER.exception(err) - - if self.TRK_METHOD_IOSAPP: - self.api = None - - elif self.CONF_TRK_METHOD_FMF_FAMSHR: - event_msg = ("iCloud Web Services interface (pyicloud_ic3.py) > Verified") - self._save_event_halog_info("*", event_msg) - - self._pyicloud_authenticate_account(initial_setup=True) - if self.api is not None: - self._check_authentication_2sa_code_needed(initial_setup=True) - -#-------------------------------------------------------------------- - def _pyicloud_authenticate_account(self, devicename="", initial_setup=False): - ''' - Authenticate the iCloud Acount via pyicloud - If successful - self.api to the api of the pyicloudservice for the username - If not - set self.api = None - ''' - try: - self.count_pyicloud_authentications += 1 - self.authenticated_time = time.time() - - self.api = PyiCloudService(self.username, self.password, - cookie_directory=self.icloud_cookies_dir, - verify=True) - self.time_pyicloud_calls += (time.time() - self.authenticated_time) - - if self.authentication_error_retry_secs != HIGH_INTEGER: - self.authentication_error_cnt = 0 - self.authentication_error_retry_secs = HIGH_INTEGER - self._setup_tracking_method(self.tracking_method_config) - - event_msg = (f"iCloud Account Authentication Successful > {self.username}") - self._save_event_halog_info("*", event_msg) - - except (PyiCloudFailedLoginException, PyiCloudNoDevicesException, - PyiCloudAPIResponseException) as err: - - self._authentication_error() - return - - except (PyiCloud2SARequiredException) as err: - self._check_authentication_2sa_code_neede() - return - - def _authentication_error(self): - #Set up for retry in X minutes - self.authentication_error_cnt += 1 - if self.authentication_error_cnt >= 8: - retry_secs = 3600 #1 hours - elif self.authentication_error_cnt >= 4: - retry_secs = 1800 #30 minutes - else: - retry_secs = 900 #15 minutes - - self.authentication_error_retry_secs = time.time() + retry_secs - auth_retry_time = self._secs_to_time(self.authentication_error_retry_secs) - - event_msg = (f"iCloud3 Error > An error occurred authenticating " - f"CRLFThe iCloud Web Services may be down or the Username/Password" - f" may be invalid. The {self.trk_method_short_name} tracking method " - f"is disabled and the iOS App tracking method will be used. " - f"CRLFThe authentication will be retried in " - f"{self._secs_to_time_str(retry_secs)} at {auth_retry_time}.") - self._save_event_halog_error("*", event_msg) - - self._setup_iosapp_tracking_method() - self.api = None - -#-------------------------------------------------------------------- - def _check_authentication_2sa_code_needed(self, initial_setup=False): - ''' - Make sure iCloud is still available and doesn't need to be authenticationd - in 15-second polling loop - - Returns True if Authentication is needed. - Returns False if Authentication succeeded - ''' - if self.CONF_TRK_METHOD_FMF_FAMSHR == False: - return - elif initial_setup: - pass - elif self.start_icloud3_inprocess_flag: - return False - - fct_name = "icloud_authenticate_account" - - from .pyicloud_ic3 import PyiCloudService - - try: - if initial_setup == False: - if self.api is None: - event_msg = ("iCloud/FmF API Error, No device API information " - "for devices. Resetting iCloud") - self._save_event_halog_error(event_msg) - - self._start_icloud3() - - elif self.start_icloud3_request_flag: #via service call - event_msg = ("iCloud Restarting, Reset command issued") - self._save_event_halog_error(event_msg) - self._start_icloud3() - - if self.api is None: - event_msg = ("iCloud reset failed, no device API information " - "after reset") - self._save_event_halog_error(event_msg) - - return True #Authentication needed - - if self.api.requires_2sa: - from .pyicloud_ic3 import PyiCloudException - try: - if self.trusted_device is None: - self._icloud_show_trusted_device_request_form() - return True #Authentication needed - - if self.verification_code is None: - self._icloud_show_verification_code_entry_form() - - devicename = list(self.tracked_devices.keys())[0] - self._display_info_status_msg(devicename, '') - return True #Authentication needed - - self.api.authenticate() - self.authenticated_time = time.time() - - event_msg = (f"iCloud/FmF Authentication, Devices-{self.api.devices}") - self._save_event_halog_info("*", event_msg) - - if self.api.requires_2sa: - raise Exception('Unknown failure') - - self.trusted_device = None - self.verification_code = None - - except PyiCloudException as error: - event_msg = (f"iCloud3 Error > Setting up 2FA: {error}") - self._save_event_halog_error(event_msg) - - return True #Authentication needed, Authentication Failed - - return False #Authentication not needed, (Authenticationed OK) - - except Exception as err: - _LOGGER.exception(err) - x = self._internal_error_msg(fct_name, err, 'AuthiCloud') - return True - -#-------------------------------------------------------------------- - def _setup_tracked_devices_for_fmf(self): - ''' - Cycle thru the Find My Friends contact data. Extract the name, id & - email address. Scan fmf_email config parameter to tie the fmf_id in - the location record to the devicename. - - email --> devicename <--fmf_id - ''' - ''' - contact-{ - 'emails': ['gary678tw@', 'gary_2fa_acct@email.com'], - 'firstName': 'Gary', - 'lastName': '', - 'photoUrl': 'PHOTO;X-ABCROP-RECTANGLE=ABClipRect_1&64&42&1228&1228& - //mOVw+4cc3VJSJmspjUWg==; - VALUE=uri:https://p58-contacts.icloud.com:443/186297810/wbs/ - 0123efg8a51b906789fece - 'contactId': '8590AE02-7D39-42C1-A2E8-ACCFB9A5E406',60110127e5cb19d1daea', - 'phones': ['(222)\xa0m456-7899'], - 'middleName': '', - 'id': 'ABC0DEFGH2NzE3'} - - cycle thru config>track_devices devicename/email parameter - looking for a match with the fmf contact record emails item - fmf_devicename_email: - 'gary_iphone' = 'gary_2fa_acct@email.com' - 'gary_2fa_acct@email.com' = 'gary_iphone@' - - or - - 'gary_iphone' = 'gary678@' - 'gary678@' = 'gary_iphone@gmail' - - emails: - ['gary456tw@', 'gary_2fa_acct@email.com] - - When complete, erase fmf_devicename_email and replace it with full - email list - ''' - try: - if self.api is not None: - api_friends = self.api.friends - - except (PyiCloudServiceNotActivatedException, PyiCloudNoDevicesException): - self.api = None - - if self.api is None: - if self._pyicloud_authenticate_account(initial_setup=True): - event_msg = (f"iCloud3 Error for {self.username} > " - f"No information was returned from the iCloud Location Services. " - f"iCloud {self.trk_method_short_name} Location Service is disabled. " - f"iCloud3 will use the IOS App tracking_method until your account is atuthenticated.") - self._save_event_halog_error("*", event_msg) - self._setup_iosapp_tracking_method() - return - - try: - if api_friends is None: - event_msg = (f"iCloud3 Error for {self.username} > " - f"No FmF data was returned from Apple Web Services. " - f"CRLF{'-'*25}" - f"CRLF 1. Verify that the tracked devices have been added " - f"to the Contacts list for this iCloud account." - f"CRLF 2. Verify that the tracked devices have been set up in the " - f"FindMe App and they can be located. " - f"CRLF 3. See the iCloud3 Documentation, `Setting Up your iCloud " - f"Account/Find-my-Friends Tracking Method`." - f"CRLFThe {self.trk_method_short_name} Location Service " - f"is disabled and the IOS App tracking_method will be used.") - self._save_event_halog_error("*", event_msg) - - self._setup_iosapp_tracking_method() - return - - self._log_level_debug_rawdata("iCloud FmF Raw Data - (api_friends.data)", api_friends.data) - - #cycle thru al contacts in fmf recd - devicename_contact_emails = {} - contacts_valid_emails = '' - - #Get contacts data from non-2fa account. If there are no contacts - #in the fmf data, use the following data in the fmf data - for contact in api_friends.following: - contact_emails = contact.get('invitationAcceptedHandles') - contact_id = contact.get('id') - - self._log_level_debug_rawdata("iCloud FmF Raw Data - (api_friends.following) 5715", contact) - - #cycle thru the emails on the tracked_devices config parameter - for parm_email in self.fmf_devicename_email: - if instr(parm_email, '@') == False: - continue - - #cycle thru the contacts emails - matched_friend = False - devicename = self.fmf_devicename_email.get(parm_email) - - for contact_email in contact_emails: - if instr(contacts_valid_emails, contact_email) == False: - contacts_valid_emails += "CRLF• " + contact_email - - if contact_email.startswith(parm_email): - #update temp list with full email from contact recd - matched_friend = True - devicename_contact_emails[contact_email] = devicename - devicename_contact_emails[devicename] = contact_email - - self.fmf_id[contact_id] = devicename - self.fmf_id[devicename] = contact_id - self.devicename_verified[devicename] = True - - log_msg = (f"Matched FmF Contact > " - f"{self._format_fname_devicename(devicename)} " - f"with {contact_email}, Id: {contact_id}") - self._log_info_msg(log_msg) - break - - log_msg = (f"FmF contact list email addresses found in the FindMy app for {self.username} > " - f"{contacts_valid_emails}") - self._save_event_halog_info("*", log_msg) - - for devicename in self.devicename_verified: - if self.devicename_verified.get(devicename) is False: - parm_email = self.fmf_devicename_email.get(devicename) - devicename_contact_emails[devicename] = parm_email - log_msg = (f"iCloud3 Error for {self._format_fname_devicename(devicename)} > " - f"The email address `{parm_email}` is invalid " - f"or is not in the FmF contact list and will not be tracked." - f"CRLF{'-'*25}" - f"CRLF 1. Open the FindMy App. Select People." - f"CRLF 2. Verify that the person associated with this email address " - f"is listed. The list must include the person associated with the " - f"iCloud account being used. " - f"CRLF 3. Press `Share My Location` and follow the steps to add the " - f"person/email address to track, or" - f"CRLF 4. Press the person's name, then press Contact to verify their " - f"email address." - f"CRLF 5. Restart Home Assistant.") - self._save_event_halog_error("*", log_msg) - - self.fmf_devicename_email = {} - self.fmf_devicename_email.update(devicename_contact_emails) - - except Exception as err: - self._setup_iosapp_tracking_method() - _LOGGER.exception(err) - -#-------------------------------------------------------------------- - def _setup_tracked_devices_for_famshr(self): - ''' - Verify that the devicenames in the track_devices list are valid - and set it's the verified flag. Retry once if any are not verified. - ''' - try: - devicename_list_tracked, devicename_list_not_tracked = \ - self._verify_tracked_devices_for_famshr() - unverified_devicenames = [k for k in self.devicename_verified if self.devicename_verified.get(k) == False] - - #Refresh & retry if an unverfied devicename - if unverified_devicenames != []: - event_msg = (f"{EVLOG_ALERT}iCloud Alert > iCloud did not return device data for any tracked devices > " - f"{self._format_list(unverified_devicenames)}, Authentication & Verification will be retried") - self._save_event_halog_info("*", event_msg) - - self._pyicloud_authenticate_account(initial_setup=True) - - devicename_list_tracked, devicename_list_not_tracked = \ - self._verify_tracked_devices_for_famshr() - unverified_devicenames = [k for k in self.devicename_verified if self.devicename_verified.get(k) == False] - - if unverified_devicenames == []: - event_msg = (f"{EVLOG_ALERT}iCloud Alert > Authentication & Verification Retry Successful") - else: - event_msg = (f"Verification not successful for devices > " - f"{self._format_list(unverified_devicenames)}") - self._save_event_halog_info("*", event_msg) - - if devicename_list_not_tracked != '': - event_msg = (f"Not Tracking Devices >{devicename_list_not_tracked}") - if devicename_list_tracked != '': - self._save_event_halog_info("*", event_msg) - else: - event_msg = (f"iCloud3 Error > {event_msg}") - self._save_event_halog_error("*", event_msg) - - if devicename_list_tracked != '': - event_msg = (f"Tracking Devices >{devicename_list_tracked}") - self._save_event_halog_info("*", event_msg) - - return - - except Exception as err: - _LOGGER.exception(err) - - event_msg = (f"iCloud3 Error for {self.username} > " - "Error Authenticating account or no data was returned from " - "iCloud Web Services. Web Services may be down or the " - "Username/Password may be invalid.") - self._save_event_halog_error("*", event_msg) - return False - -#-------------------------------------------------------------------- - def _verify_tracked_devices_for_famshr(self): - ''' - Get the device info from iCloud Web Svcs via pyicloud. Then cycle through - the iCloud devices and for each device, see if it is in the list of devicenames - to be tracked. If in the list, set it's verified flag to True. - - Returns the devices being tracked and the devices in the iCloud list that are - not being tracked. - ''' - try: - devicename_list_tracked = "" - devicename_list_not_tracked = "" - - if self.api is not None: - api_devices = self.api.devices - - api_device_content = api_devices.response["content"] - - except (PyiCloudServiceNotActivatedException, PyiCloudNoDevicesException): - self.api = None - - if self.api is None: - event_msg = (f"iCloud3 Error for {self.username} > " - f"No devices were returned from the iCloud Location Services. " - f"iCloud {self.trk_method_short_name} Location Service is disabled. " - f"iCloud3 will use the IOS App tracking_method until your account is authenticated.") - self._save_event_halog_error("*", event_msg) - self._setup_iosapp_tracking_method() - return "", "" - - try: - self._log_level_debug_rawdata("FamShr iCloud Data - (devices) 5826", api_device_content) - - for device in api_device_content: - device_content_name = device[ATTR_NAME] - devicename = slugify(device_content_name) - device_type = device[ATTR_ICLOUD_DEVICE_CLASS] - self._log_level_debug_rawdata((f"FamShr iCloud Data - {devicename}"), - device, log_rawdata=self.log_level_debug_flag) - - if devicename in self.devicename_verified: - self.devicename_verified[devicename] = True - - self.api_device_devicename[device_content_name] = devicename - self.api_device_devicename[devicename] = device_content_name - devicename_list_tracked += (f"CRLF• {devicename} ({device_content_name}/{device_type})") - - else: - devicename_list_not_tracked += (f"CRLF• {devicename} ({device_content_name}/{device_type})") - - return devicename_list_tracked, devicename_list_not_tracked - - except Exception as err: - _LOGGER.exception(err) - - event_msg = (f"iCloud3 Error for {self.username} > " - "Error Authenticating account or no data was returned from " - "iCloud Web Services. Web Services may be down or the " - "Username/Password may be invalid.") - self._save_event_halog_error("*", event_msg) - - return "", "" - -#-------------------------------------------------------------------- - def _setup_tracked_devices_for_iosapp(self): - ''' - The devices to be tracked are in the track_devices or the - include_devices config parameters. - ''' - for devicename in self.devicename_verified: - self.devicename_verified[devicename] = True - - event_msg = (f"Verified Device for iOS App Tracking > " - f"{self._format_list(self.devicename_verified)}") - self._save_event_halog_info("*", event_msg) - return - - #-------------------------------------------------------------------- - def _setup_tracked_devices_config_parm(self, config_parameter): - ''' - Set up the devices to be tracked and it's associated information - for the configuration line entry. This will fill in the following - fields based on the extracted devicename: - device_type - friendly_name - fmf email address - sensor.picture name - device tracking flags - tracked_devices list - These fields may be overridden by the routines associated with the - operating mode (fmf, icloud, iosapp) - ''' - - if config_parameter is None: - return - - try: - for track_device_line in config_parameter: - di = self._decode_track_device_config_parms(track_device_line) - - if di is None: - return - - devicename = di[DI_DEVICENAME] - if self._check_devicename_in_another_thread(devicename): - continue - - elif (self.iosapp_monitor_dev_trk_flag.get(devicename) - and devicename == di[DI_IOSAPP_ENTITY]): - event_msg = (f"iCloud3 Error > iCloud3 not tracking {devicename}. " - f"The iCloud3 tracked_device is already assigned to " - f"the IOS App v2 and duplicate names are not allowed for HA " - f"Integration entities. You must change the IOS App v2 " - f"entity name on the HA `Sidebar>Configuration>Integrations` " - f"screen. Then do the following: " - f"CRLF{'-'*25}" - f"CRLF 1. Select the Mobile_App entry for `{devicename}`." - f"CRLF 2. Scroll to the `device_tracker.{devicename}` statement." - f"CRLF 3. Select it." - f"CRLF 4. Click the Settings icon." - f"CRLF 5. Add or change the suffix of the " - f"`device_tracker.{devicename}` Entity ID to another value " - f"(e.g., _2, _10, _iosappv2)." - f"CRLF 6. Restart HA.") - self._save_event_halog_error("*", event_msg) - continue - - email = di[DI_EMAIL] - self.fmf_devicename_email[email] = devicename - self.fmf_devicename_email[devicename] = email - self.device_type[devicename] = di[DI_DEVICE_TYPE] - self.fname[devicename] = di[DI_NAME] - self.badge_picture[devicename] = di[DI_BADGE_PICTURE] - self.devicename_iosapp_entity[devicename] = di[DI_IOSAPP_ENTITY] - self.devicename_iosapp_suffix[devicename] = di[DI_IOSAPP_SUFFIX] - self.track_from_zone[devicename] = di[DI_ZONES] - - self.devicename_verified[devicename] = False - - except Exception as err: - _LOGGER.exception(err) - -#-------------------------------------------------------------------- - def _decode_track_device_config_parms(self, track_device_line): - ''' - This will decode the device's parameter in the configuration file for - the include_devices, sensor_name_prefix, track_devices items in the - format of: - - devicename > email, picture, iosapp, sensornameprefix - - If the item cotains '@', it is an email item, - If the item contains .png or .jpg, it is a picture item. - Otherwise, it is the prefix name item for sensors - - The device_type and friendly names are also returned in the - following order as a list item: - devicename, device_type, friendlyname, email, picture, sensor name - - Various formats: - - Find my Friends: - ---------------- - devicename > email_address - devicename > email_address, badge_picture_name - devicename > email_address, badge_picture_name, iosapp_id, name - devicename > email_address, iosapp_id - devicename > email_address, iosapp_id, name - devicename > email_address, badge_picture_name, name - devicename > email_address, name - - Find my Phone: - -------------- - devicename - devicename > badge_picture_name - devicename > badge_picture_name, name - devicename > iosapp_id - devicename > iosapp_id, name - devicename > name - - - IOS App Version 1: - ------------------ - devicename - devicename > badge_picture_name - devicename > badge_picture_name, name - - IOS App Version 2: - ------------------ - devicename - devicename > iosapp_id - devicename > badge_picture_name, iosapp_id - devicename > badge_picture_name, iosapp_id, name - ''' - - try: - badge_picture = "" - email = "" - fname = "" - device_type = "" - zones = [] - dev_trk_entity_id = "" - dev_trk_iosapp_suffix = "" - dev_trk_device_id = "" - iosapp_monitor_flag = True - - #devicename_parameters = track_device_line.lower().split('>') - devicename_parameters = track_device_line.split('>') - devicename = slugify(devicename_parameters[0].replace(' ', '', 99).lower()) - log_msg = (f"Decoding > {track_device_line}") - self._save_event_halog_info("*", log_msg) - - #If tracking method is IOSAPP or FAMSHR, try to make a friendly - #name from the devicename. If FMF, it will be retrieved from the - #contacts data. If it is specified on the config parms, it will be - #overridden with the specified name later. - - fname, device_type = self._extract_name_device_type(devicename) - self.tracked_devices_config_parm[devicename] = track_device_line - - if len(devicename_parameters) > 1: - parameters = devicename_parameters[1].strip() - parameters = parameters + ',,,,,,' - else: - parameters = '' - - items = parameters.split(',') - for itemx in items: - item_entered = itemx.strip().replace(' ', '_') - item = item_entered.lower() - - if item == '': - continue - elif instr(item, '@'): - email = item - elif instr(item, 'png') or instr(item, 'jpg'): - badge_picture = item - elif item in ['iosappv1', 'noiosapp']: - iosapp_monitor_flag = False - elif item.startswith("_"): - dev_trk_iosapp_suffix = item - dev_trk_entity_id = devicename + dev_trk_iosapp_suffix - elif instr(item, "_"): - dev_trk_entity_id = item - dev_trk_iosapp_suffix = dev_trk_entity_id.replace(devicename, "") - elif item in self.zones: - if item != HOME: - if zones == []: - zones = [item] - else: - zones.append(item) - else: - fname = item_entered - - zones.append(HOME) - if badge_picture and instr(badge_picture, '/') == False: - badge_picture = '/local/' + badge_picture - - event_msg = (f"Results > FriendlyName-{fname}, Email-{email}, " - f"Picture-{badge_picture}, DeviceType-{device_type}") - if zones != []: - event_msg += f", TrackFromZone-{zones}" - - if dev_trk_entity_id != "": - event_msg += (f", iOSAppDevTrkEntity-{dev_trk_entity_id}") - self._save_event("*", event_msg) - - self.iosapp_monitor_dev_trk_flag[devicename] = iosapp_monitor_flag - device_info = [devicename, device_type, fname, email, badge_picture, - dev_trk_entity_id, dev_trk_iosapp_suffix, zones] - - log_msg = (f"Extract Trk_Dev Parm, ic3dev_info-{device_info}") - self._log_debug_msg("*", log_msg) - - except Exception as err: - _LOGGER.exception(err) - - return device_info - - #-------------------------------------------------------------------- - def _setup_monitored_iosapp_entities(self, devicename, iosapp_entities, notify_devicenames, dev_trk_iosapp_suffix): - - #Cycle through the mobile_app 'core.entity_registry' items and see - #if this 'device_tracker.devicename' exists. If so, it is using - #the iosapp v2 component. Return the devicename with the device suffix (_#) - #and the sensor.xxxx_last_update_trigger entity for that device. - - - #self.device_tracker_entity_ic3[devicename] = (f"device_tracker.{devicename}") - if (self.iosapp_monitor_dev_trk_flag.get(devicename) == False): - self.device_tracker_entity_iosapp[devicename] = (f"device_tracker.{devicename}") - self.notify_iosapp_entity[devicename] = (f"ios_{devicename}") - return ("", "", "", "") - - dev_trk_iosapp_suffix = "" if dev_trk_iosapp_suffix == None else dev_trk_iosapp_suffix - dev_trk_entity_id = devicename + dev_trk_iosapp_suffix if dev_trk_iosapp_suffix != "" else "" - sensor_last_trigger_entity = "" - sensor_battery_level_entity = "" - dev_trk_list = "" - dev_trk_device_id = "" - - #Cycle through iosapp_entities in - #.storage/core.entity_registry (mobile_app pltform) and get the - #names of the iosapp device_tracker and sensor.last_update_trigger - #names for this devicename. If iosapp_id suffix or device tracker entity name - # is specified, look for the device_tracker with that number. - dev_trk_entity_cnt = 0 - - log_msg = (f"Scanning {self.entity_registry_file} Entity Registry for " - f"iOS App device_tracker {devicename}") - if dev_trk_entity_id != "": - log_msg += (f", Specified Entity-({dev_trk_entity_id})") - self._log_info_msg(log_msg) - - #Get the entity for the devicename entity specified on the track_devices parameter - if dev_trk_entity_id != "": - device_tracker_entities = [x for x in iosapp_entities \ - if (x['entity_id'] == f"device_tracker.{dev_trk_entity_id}")] - dev_trk_entity_cnt = len(device_tracker_entities) - - #If the entity specified was not found, get all entities for the device - if dev_trk_entity_cnt == 0: - device_tracker_entities = [x for x in iosapp_entities \ - if (x['entity_id'].startswith("device_tracker.") and instr(x['entity_id'], devicename))] - dev_trk_entity_cnt = len(device_tracker_entities) - - #Extract the device_id for each entity found. If more than 1 was found, display an - #error message about duplicate entities and select the last one. - for entity in device_tracker_entities: - entity_id = entity['entity_id'] - dev_trk_entity_id = entity_id.replace("device_tracker.", "") - dev_trk_iosapp_suffix = dev_trk_entity_id.replace(devicename, "") - dev_trk_device_id = entity['device_id'] - dev_trk_list += (f"CRLF• {dev_trk_entity_id} ({dev_trk_iosapp_suffix})") - - log_msg = (f"SCAN ENTITY REG, device_trackers found-[{dev_trk_list}]") - self._log_debug_msg(devicename, log_msg) - - self.iosapp_monitor_dev_trk_flag[devicename] = (dev_trk_entity_cnt > 0) - - if dev_trk_entity_cnt > 1: - self.info_notification = (f"iOS App Device Tracker not specified for " - f"{self._format_fname_devicename(devicename)}. See Event Log for more information.") - dev_trk_list += " ← ← Will be monitored" - event_msg = (f"iCloud3 Setup Error > There are {dev_trk_entity_cnt} iOS App " - f"device_tracker entities for {devicename}, iCloud3 can only monitor one." - f"CRLF{'-'*25}CRLFDo one of the following:" - f"CRLF● Delete the incorrect device_tracker from the Entity Registry, or" - f"CRLF● Add the full entity name or the suffix of the device_tracker entity that " - f"should be monitored to the `{devicename}` track_devices parameter, or" - f"CRLF● Change the device_tracker entity's name of the one that should not be monitored " - f"so it does not start with `{devicename}`." - f"CRLF{'-'*25}CRLFDevice_tracker entities (suffixes) found:" - f"{dev_trk_list}") - self._save_event_halog_error("*", event_msg) - - #Get the sensor.last_update_trigger for deviceID - if dev_trk_device_id: - sensor_last_trigger_entity = \ - self._get_entity_registry_item(devicename, iosapp_entities, dev_trk_device_id, '_last_update_trigger') - sensor_battery_level_entity = '' - - if self.iosapp_monitor_dev_trk_flag.get(devicename) == False: - event_msg = (f"iCloud3 Setup Error > {dev_trk_entity_id} > " - f"The iOS App device_tracker entity was not found in the " - f"Entity Registry." - f"CRLFDevice_tracker entities (suffixes) found -" - f"{dev_trk_list}") - self._save_event_halog_error("*", event_msg) - self.info_notification = ICLOUD3_ERROR_MSG - - dev_trk_entity_id = devicename - dev_trk_iosapp_suffix = '' - sensor_last_trigger_entity = '' - sensor_battery_level_entity = '' - - self.devicename_iosapp_entity[devicename] = dev_trk_entity_id - self.devicename_iosapp_suffix[devicename] = dev_trk_iosapp_suffix - self.iosapp_last_trigger_entity[devicename] = sensor_last_trigger_entity - self.iosapp_battery_level_entity[devicename] = sensor_battery_level_entity - self.device_tracker_entity_iosapp[devicename] = (f"device_tracker.{dev_trk_entity_id}") - - #Extract all notify entitity id's with this devicename in them from hass notify services notify list - notify_devicename_list = [] - for notify_devicename in notify_devicenames: - if instr(notify_devicename, devicename): - notify_devicename_list.append(notify_devicename.replace("mobile_app_", "")) - - self.notify_iosapp_entity[devicename] = notify_devicename_list - - return - -#-------------------------------------------------------------------- - def _get_entity_registry_entities(self, platform): - ''' - Read the /config/.storage/core.entity_registry file and return - the entities for platform ('mobile_app', 'ios', etc) - ''' - - try: - if self.entity_registry_file is None: - self.entity_registry_file = self.hass.config.path( - STORAGE_DIR, STORAGE_KEY_ENTITY_REGISTRY) - - entities = [] - entity_reg_file = open(self.entity_registry_file) - entity_reg_str = entity_reg_file.read() - entity_reg_data = json.loads(entity_reg_str) - entity_reg_entities = entity_reg_data['data']['entities'] - entity_reg_file.close() - - entity_devicename_list = "" - for entity in entity_reg_entities: - if (entity['platform'] == platform): - entities.append(entity) - if entity['entity_id'].startswith("device_tracker."): - entity_devicename_list += (f"CRLF• {entity['entity_id'].replace('device_tracker.', '')} " - f"({entity['original_name']})") - - event_msg = (f"Entity Registry mobile_app device_tracker entities found >{entity_devicename_list}") - self._save_event("*", event_msg) - - except Exception as err: - _LOGGER.exception(err) - pass - - return entities -#-------------------------------------------------------------------- - def _get_mobile_app_notify_devicenames(self): - ''' - Extract notify services devicenames from hass - ''' - try: - notify_devicenames = [] - services = self.hass.services - notify_services = dict(services.__dict__)['_services']['notify'] - - for notify_service in notify_services: - if notify_service.startswith("mobile_app_"): - notify_devicenames.append(notify_service) - except: - pass - - return notify_devicenames -#-------------------------------------------------------------------- - def _get_entity_registry_item(self, devicename, iosapp_entities, device_id, desired_entity_name): - ''' - Scan through the iosapp entities and get the actual ios app entity_id for - the desired entity - ''' - for entity in (x for x in iosapp_entities \ - if x['unique_id'].endswith(desired_entity_name)): - - if (entity['device_id'] == device_id): - real_entity = entity['entity_id'].replace('sensor.', '') - log_msg = (f"Matched iOS App {entity['entity_id']} with " - f"iCloud3 tracked_device {devicename}") - self._log_info_msg(log_msg) - - return real_entity - - return '' - -#-------------------------------------------------------------------- - def _check_valid_ha_device_tracker(self, devicename): - ''' - Validate the 'device_tracker.devicename' entity during the iCloud3 - Stage 2 initialization. If it does not exist, then it has not been set - up in known_devices.yaml (and/or the iosapp) and can not be used ty - the 'see' function thatupdates the location information. - ''' - try: - retry_cnt = 0 - entity_id = self._format_entity_id(devicename) - - while retry_cnt < 10: - ic3dev_data = self.hass.states.get(entity_id) - - if ic3dev_data: - ic3dev_attrs = ic3dev_data.attributes - - if ic3dev_attrs: - return True - retry_cnt += 1 - - #except (KeyError, AttributeError): - # pass - - except Exception as err: - _LOGGER.exception(err) - - return False - -######################################################### -# -# DEVICE SENSOR SETUP ROUTINES -# -######################################################### - def _setup_sensor_base_attrs(self, devicename): - ''' - The sensor name prefix can be the devicename or a name specified on - the track_device configuration parameter ''' - - self.sensor_prefix_name[devicename] = devicename - - attr_prefix_fname = self.sensor_prefix_name.get(devicename) - - #Format sensor['friendly_name'] attribute prefix - attr_prefix_fname = attr_prefix_fname.replace('_','-').title() - attr_prefix_fname = attr_prefix_fname.replace('Ip','-iP') - attr_prefix_fname = attr_prefix_fname.replace('Iw','-iW') - attr_prefix_fname = attr_prefix_fname.replace('--','-') - attr_prefix_fname = attr_prefix_fname.replace('-De-', '-de-') - if attr_prefix_fname.startswith('-'): attr_prefix_fname = attr_prefix_fname[1:] - - self.sensor_attr_fname_prefix[devicename] = (f"{attr_prefix_fname} ") - - badge_attrs = {} - badge_attrs['entity_picture'] = self.badge_picture.get(devicename) - badge_attrs[ATTR_FRIENDLY_NAME] = self.fname.get(devicename) - badge_attrs['icon'] = SENSOR_ATTR_ICON.get('badge') - self.sensor_badge_attrs[devicename] = badge_attrs - - event_msg = "" - for zone in self.track_from_zone.get(devicename): - if zone == 'home': - zone_prefix = '' - else: - zone_prefix = zone + '_' - event_msg = (f"CRLF• Sensor entity prefix > sensor.{zone_prefix}" - f"{self.sensor_prefix_name.get(devicename)}") - - log_msg = (f"Set up sensor name for device, devicename-{devicename}, " - f"entity_base-{self.sensor_prefix_name.get(devicename)}") - self._log_debug_msg(devicename, log_msg) - - #Return text for the event msg during startup - return event_msg - -#-------------------------------------------------------------------- - def _setup_sensors_custom_list(self, initial_load_flag): - ''' - This will process the 'sensors' and 'exclude_sensors' config - parameters if 'sensors' exists, only those sensors wil be displayed. - if 'exclude_sensors' eists, those sensors will not be displayed. - 'sensors' takes ppresidence over 'exclude_sensors'. - ''' - - if initial_load_flag == False: - return - - if self.sensor_ids != []: - self.sensors_custom_list = [] - for sensor_id in self.sensor_ids: - id = sensor_id.lower().strip() - if id in SENSOR_ID_NAME_LIST: - self.sensors_custom_list.append(SENSOR_ID_NAME_LIST.get(id)) - - elif self.exclude_sensor_ids != []: - self.sensors_custom_list.extend(SENSOR_DEVICE_ATTRS) - for sensor_id in self.exclude_sensor_ids: - id = sensor_id.lower().strip() - if id in SENSOR_ID_NAME_LIST: - if SENSOR_ID_NAME_LIST.get(id) in self.sensors_custom_list: - self.sensors_custom_list.remove(SENSOR_ID_NAME_LIST.get(id)) - else: - self.sensors_custom_list.extend(SENSOR_DEVICE_ATTRS) - - -######################################################### -# -# DEVICE STATUS SUPPORT FUNCTIONS FOR GPS ACCURACY, OLD LOC DATA, ETC -# -######################################################### - def _check_poor_location_gps_only(self, devicename, timestamp_secs, gps_accuracy): - poor_location_gps_flag, discard_reason = self._check_poor_location_gps( - devicename, - last_located_secs, - gps_accuracy, - checkOnly=True) - return poor_location_gps_flag - - def _check_poor_location_gps(self, devicename, timestamp_secs, gps_accuracy, checkOnly=False): - """ - If this is checked in the icloud location cycle, - check if the location isold flag. Then check to see if - the current timestamp is the same as the timestamp on the previous - poll. - - If this is checked in the iosapp cycle, the trigger transaction has - already updated the lat/long so - you don't want to discard the record just because it is old. - If in a zone, use the trigger but check the distance from the - zone when updating the device. - - Update the old_location_poor_gps_cnt if just_check=False - """ - - try: - discard_reason = "" - location_age = int(self._secs_since(timestamp_secs)) - location_age_str = (f"{self._format_age(location_age)}") - old_location_flag = (location_age > self.old_location_secs.get(devicename)) - poor_gps_flag = (gps_accuracy > self.gps_accuracy_threshold) - - if self._is_inzone(devicename): - old_location_flag = False - poor_gps_flag = (poor_gps_flag \ - and self.ignore_gps_accuracy_inzone_flag == False) - - if old_location_flag and poor_gps_flag: - discard_reason = (f"OldLocation-{location_age_str}, GPSAccuracy-{gps_accuracy}m") - elif old_location_flag: - discard_reason = (f"OldLocation-{location_age_str}") - elif poor_gps_flag: - discard_reason = (f"GPSAccuracy-{gps_accuracy}m") - - #Do not update status and count if just checking poor loc gps status - if checkOnly: - return (old_location_flag or poor_gps_flag) - - if old_location_flag or poor_gps_flag: - self.poor_gps_flag[devicename] = True - self.poor_location_gps_cnt[devicename] += 1 - discard_reason += (f" (#{self.poor_location_gps_cnt.get(devicename)})") - else: - self.poor_gps_flag[devicename] = False - self.poor_location_gps_cnt[devicename] = 0 - - log_msg = (f"CHECK ISOLD/GPS ACCURACY, Time-{self._secs_to_time(timestamp_secs)}, " - f"isOldFlag-{old_location_flag}, Age-{location_age_str}, " - f"GPSAccuracy-{gps_accuracy}m, GPSAccuracyFlag-{poor_gps_flag}", - f"Results-{self.poor_gps_flag.get(devicename)}, " - f"DiscardReason-{discard_reason}") - self._log_debug_msg(devicename, log_msg) - - except Exception as err: - _LOGGER.exception(err) - self.poor_gps_flag[devicename] = False - self.poor_location_gps_cnt[devicename] = 0 - - log_msg = ("►INTERNAL ERROR (ChkOldLocPoorGPS)") - self._log_error_msg(log_msg) - - return (self.poor_gps_flag.get(devicename), discard_reason) - -#-------------------------------------------------------------------- - def _check_next_update_time_reached(self, devicename=None): - ''' - Cycle through the next_update_secs for all devices and - determine if one of them is earlier than the current time. - If so, the devices need to be updated. - - Return None - Next update time has not been reached on any devicename:zone - devicename - devicename of phone that needs to be updated - ''' - try: - if self.next_update_secs is None: - return None - - self.next_update_devicenames = [] - for devicename_zone in self.next_update_secs: - if (devicename is None or devicename_zone.startswith(devicename)): - time_till_update = self.next_update_secs.get(devicename_zone) - \ - self.this_update_secs - - if time_till_update <= 5: - self.next_update_devicenames.append(devicename_zone.split(':')[0]) - - return (f"{devicename_zone.split(':')[0]}") - - except Exception as err: - _LOGGER.exception(err) - - return None - -#-------------------------------------------------------------------- - def _check_next_update_time_reached_devicename(self, devicename): - ''' - Cycle through the next_update_secs for this devicename and - determine if one of them is earlier than the current time. - If so, the devicename need to be updated. - - Return True - Reached next_update_secs, update devicename - False - Do not update devicename - ''' - try: - if self.next_update_secs is None: - return False - - for devicename_zone in self.next_update_secs: - if devicename_zone.startswith(devicename): - time_till_update = self.next_update_secs.get(devicename_zone) - \ - self.this_update_secs - - if time_till_update <= 5: - return True - - except Exception as err: - _LOGGER.exception(err) - - return False -#-------------------------------------------------------------------- - def _check_in_zone_and_before_next_update(self, devicename): - ''' - If updated because another device was updated and this device is - in a zone and it's next time has not been reached, do not update now - ''' - try: - if (self.state_this_poll.get(devicename) != NOT_SET - and self._is_inzone(devicename) - and self._was_inzone(devicename) - and self._check_next_update_time_reached(devicename) is None): - - return True - - except Exception as err: - _LOGGER.exception(err) - - return False - -#-------------------------------------------------------------------- - def _check_outside_zone_no_exit(self,devicename, zone, trigger, latitude, longitude): - ''' - If the device is outside of the zone and less than the zone radius + gps_acuracy_threshold - and no Geographic Zone Exit trigger was received, it has probably wandered due to - GPS errors. If so, discard the poll and try again later - ''' - trigger = self.trigger.get(devicename) if trigger == '' else trigger - if trigger in IOS_TRIGGERS_ENTER: - return False, '' - - dist_from_zone_m = self._zone_distance_m( - devicename, - zone, - latitude, - longitude) - - zone_radius_m = self.zone_radius_m.get( - zone, - self.zone_radius_m.get(HOME)) - zone_radius_accuracy_m = zone_radius_m + self.gps_accuracy_threshold - - msg = '' - if (dist_from_zone_m > zone_radius_m - and self.got_exit_trigger_flag.get(devicename) == False - and instr(zone, STATIONARY) == False): - if dist_from_zone_m < zone_radius_accuracy_m: - self.poor_gps_flag[devicename] = True - - msg = ("Outside Zone and No Exit Zone trigger, " - f"Keeping in zone > Zone-{zone}, " - f"Distance-{dist_from_zone_m}m, " - f"DiscardDist-{zone_radius_m}m to {zone_radius_accuracy_m}m ") - return True, msg - else: - msg = ("Outside Zone and No Exit Zone trigger but outside threshold, " - f"Exiting zone > Zone-{zone}, " - f"Distance-{dist_from_zone_m}m, " - f"DiscardDist-{zone_radius_m}m to {zone_radius_accuracy_m}m ") - - return False, '' -#-------------------------------------------------------------------- - def _get_interval_for_error_retry_cnt(self, devicename, counter=POOR_LOC_GPS_CNT): - ''' - Get the interval time based on the retry_cnt. - retry_cnt = -1 - Use the poor_location_gps count for devicename - = iosapp_request_loc_sent_retry_cnt - = retry pyicloud authorization count - - Interval range table - key = retry_cnt, value = time in minutes - - poor_location_gps cnt, icloud_authentication cnt (default): - interval_range_1 = {0:.25, 4:1, 8:5, 12:30, 16:60, 20:120, 24:240} - - request iosapp location retry cnt: - interval_range_2 = {0:.5, 4:2, 8:30, 12:60, 16:120} - - ''' - if counter == POOR_LOC_GPS_CNT: - retry_cnt = self.poor_location_gps_cnt.get(devicename, 0) - range_tbl = RETRY_INTERVAL_RANGE_1 - - elif counter == AUTH_ERROR_CNT: - retry_cnt = self.icloud_acct_auth_error_cnt - range_tbl = RETRY_INTERVAL_RANGE_1 - - elif counter == IOSAPP_REQUEST_LOC_CNT: - retry_cnt = self.iosapp_request_loc_retry_cnt.get(devicename) - range_tbl = RETRY_INTERVAL_RANGE_2 - else: - return 60 - - interval = .25 - for k, v in range_tbl.items(): - if k <= retry_cnt: - interval = v - - interval = interval * 60 - - return interval -#-------------------------------------------------------------------- - def _display_time_till_update_info_msg(self, devicename_zone, age_secs): - ''' - Display the secs until the next update in the next update time field. - if between 90s to -90s. if between -90s and -120s, resisplay time - without the age to make sure it goes away. - ''' - - info_msg=(f"{self.next_update_time.get(devicename_zone)} ") - if age_secs <= 90 and age_secs >= -90: - info_msg += (f"({age_secs}s)") - - if age_secs <= 90 and age_secs > -120: - attrs = {} - attrs[ATTR_NEXT_UPDATE_TIME] = info_msg - - self._update_device_sensors(devicename_zone, attrs) - -#-------------------------------------------------------------------- - def _log_device_status_attrubutes(self, status): - - """ - Status-{'batteryLevel': 1.0, 'deviceDisplayName': 'iPhone X', - ATTR_ICLOUD_DEVICE_STATUS: '200', CONF_NAME: 'Gary-iPhone', - 'deviceModel': 'iphoneX-1-2-0', 'rawDeviceModel': 'iPhone10,6', - ATTR_ICLOUD_DEVICE_CLASS: 'iPhone', - 'id':'qyXlfsz1BIOGxcqDxDleX63Mr63NqBxvJcajuZT3y05RyahM3/OMpuHYVN - SUzmWV', 'lowPowerMode': False, 'batteryStatus': 'NotCharging', - 'fmlyShare': False, 'location': {'isOld': False, - 'isInaccurate': False, 'altitude': 0.0, 'positionType': 'GPS' - 'latitude': 27.726843548976, 'floorLevel': 0, - 'horizontalAccuracy': 48.00000000000001, - 'locationType': '', 'timeStamp': 1539662398966, - 'locationFinished': False, 'verticalAccuracy': 0.0, - 'longitude': -80.39036092533418}, 'locationCapable': True, - 'locationEnabled': True, 'isLocating': True, 'remoteLock': None, - 'activationLocked': True, 'lockedTimestamp': None, - 'lostModeCapable': True, 'lostModeEnabled': False, - 'locFoundEnabled': False, 'lostDevice': None, - 'lostTimestamp': '', 'remoteWipe': None, - 'wipeInProgress': False, 'wipedTimestamp': None, 'isMac': False} - """ - - log_msg = (f"ICLOUD DATA, DEVICE ID-{status}, ▶deviceDisplayName-{status['deviceDisplayName']}") - self._log_debug_msg('*', log_msg) - - location = status[ATTR_LOCATION] - - log_msg = (f"ICLOUD DEVICE STATUS/LOCATION, " - f"●deviceDisplayName-{status['deviceDisplayName']}, " - f"●deviceStatus-{status[ATTR_ICLOUD_DEVICE_STATUS]}, " - f"●name-{status[CONF_NAME]}, " - f"●deviceClass-{status[ATTR_ICLOUD_DEVICE_CLASS]}, " - f"●batteryLevel-{status[ATTR_ICLOUD_BATTERY_LEVEL]}, " - f"●batteryStatus-{status[ATTR_ICLOUD_BATTERY_STATUS]}, " - f"●isOld-{location[ATTR_ISOLD]}, " - f"●positionType-{location['positionType']}, " - f"●latitude-{location[ATTR_LATITUDE]}, " - f"●longitude-{location[ATTR_LONGITUDE]}, " - f"●horizontalAccuracy-{location[ATTR_ICLOUD_HORIZONTAL_ACCURACY]}, " - f"●timeStamp-{location[ATTR_ICLOUD_TIMESTAMP]}" - f"({self._timestamp_to_time_utcsecs(location[ATTR_ICLOUD_TIMESTAMP])})") - self._log_debug_msg('*', log_msg) - return True - -#-------------------------------------------------------------------- - def _log_start_finish_update_banner(self, start_finish_symbol, devicename, - method, update_reason): - ''' - Display a banner in the log file at the start and finish of a - device update cycle - ''' - - log_msg = (f"^ {method} ^ {devicename}-{self.group}-{self.base_zone} ^^ " - f"State-{self.state_this_poll.get(devicename)} ^^ {update_reason} ^") - - log_msg2 = log_msg.replace('^', start_finish_symbol, 99).replace(" ",".").upper() - self._log_debug_msg(devicename, log_msg2) - -######################################################### -# -# EVENT LOG ROUTINES -# -######################################################### - def _setup_event_log_base_attrs(self, initial_load_flag): - ''' - Set up the name, picture and devicename attributes in the Event Log - sensor. Read the sensor attributes first to see if it was set up by - another instance of iCloud3 for a different iCloud acount. - ''' - try: - curr_base_attrs = self.hass.states.get(SENSOR_EVENT_LOG_ENTITY).attributes - - base_attrs = {k: v for k, v in curr_base_attrs.items()} - - except (KeyError, AttributeError): - base_attrs = {} - base_attrs["logs"] = "" - - except Exception as err: - _LOGGER.exception(err) - - try: - name_attrs = {} - if self.tracked_devices: - for devicename in self.tracked_devices: - name_attrs[devicename] = self.fname.get(devicename) - else: - name_attrs = {'No Tracked Devices Error'} - - if len(self.tracked_devices) > 0: - self.log_table_max_items = EVLOG_RECDS_PER_DEVICE * len(self.tracked_devices) - if len(self.track_from_zone.get(devicename)) > 1: - self.log_table_max_items = .25 * self.log_table_max_items * len(self.track_from_zone.get(devicename)) - - base_attrs["names"] = name_attrs - - self._set_state(SENSOR_EVENT_LOG_ENTITY, "Initialized", base_attrs) - - self.event_log_base_attrs = {k: v for k, v in base_attrs.items() if k != "logs"} - self.event_log_base_attrs["logs"] = "" - - except Exception as err: - _LOGGER.exception(err) - - return - -#------------------------------------------------------ - def _save_event(self, devicename, event_text): - ''' - Add records to the Event Log table the devicename. If the devicename="*", - the event_text is added to all devicesnames table. - - The event_text can consist of pseudo codes that display a 2-column table (see - _display_usage_counts function for an example and details) - - The event_log lovelace card will display the event in a special color if - the text starts with a special character: - ''' - try: - if (instr(event_text, "▼") or instr(event_text, "▲") - or len(event_text) == 0 - or instr(event_text, "event_log")): - return - - devicename_zone = self._format_devicename_zone(devicename, HOME) - this_update_time = dt_util.now().strftime('%H:%M:%S') - this_update_time = self._time_to_12hrtime(this_update_time, ampm=True) - - if devicename is None: devicename = '*' - - if ((self.start_icloud3_inprocess_flag and self.state_this_poll.get(devicename) == '') - or devicename == "*"): - iosapp_state= '' - zone_names = '' - zone = '' - interval = '' - travel_time = '' - distance = '' - - else: - iosapp_state= self.last_iosapp_state.get(devicename, '') - zone = self.zone_current.get(devicename, AWAY) - interval = self.interval_str.get(devicename_zone, '').split("(")[0] - travel_time = self.last_tavel_time.get(devicename_zone, '') - distance = self.last_distance_str.get(devicename_zone, '') - - if (instr(type(event_text), 'dict') or instr(type(event_text), 'list')): - event_text = str(event_text) - - if instr(zone, STATIONARY): zone = STATIONARY - if instr(iosapp_state, STATIONARY): iosapp_state = STATIONARY - - iosapp_state = self.zone_to_display.get(iosapp_state, iosapp_state.title()) - zone = self.zone_to_display.get(zone, zone.title()) - - if iosapp_state == None: iosapp_state = '' - if zone == None: zone = '' - - if len(event_text) == 0: event_text = 'Info Message' - if event_text.startswith('__'): event_text = event_text[2:] - event_text = event_text.replace('"', '`') - event_text = event_text.replace("'", "`") - event_text = event_text.replace('~','--') - event_text = event_text.replace('Background','Bkgnd') - event_text = event_text.replace('Geographic','Geo') - event_text = event_text.replace('Significant','Sig') - - for from_text in self.display_text_as_list: - event_text = event_text.replace(from_text, self.display_text_as_list.get(from_text)) - - #Keep track of special colors so it will continue on the - #next text chunk - color_symbol = '' - if event_text.startswith('$'): color_symbol = '$' - if event_text.startswith('$$'): color_symbol = '$$' - if event_text.startswith('$$$'): color_symbol = '$$$' - if event_text.startswith('*'): color_symbol = '*' - if event_text.startswith('**'): color_symbol = '**' - if event_text.startswith('***'): color_symbol = '***' - if instr(event_text, ERROR): color_symbol = '!' - char_per_line = 2000 - - #Break the event_text string into chunks of 250 characters each and - #create an event_log recd for each chunk - if len(event_text) < char_per_line: - event_recd = [devicename, this_update_time, - iosapp_state, zone, interval, travel_time, - distance, event_text] - self._insert_event_log_recd(event_recd) - - else: - line_no = int(len(event_text)/char_per_line + .5) - char_per_line = int(len(event_text) / line_no) - event_text+=f" ({len(event_text)}-{line_no}-{char_per_line})" - - if event_text.find("CRLF") > 0: - split_str = "CRLF" - else: - split_str = " " - split_str_end_len = -1 * len(split_str) - word_chunk = event_text.split(split_str) - - line_no = len(word_chunk)-1 - event_chunk = '' - while line_no >= 0: - if len(event_chunk) + len(word_chunk[line_no]) + len(split_str) > char_per_line: - event_recd = [devicename, '', '', '', '', '', '', - (f"{color_symbol}{event_chunk[:split_str_end_len]} ({event_chunk[:split_str_end_len]})")] - self._insert_event_log_recd(event_recd) - - event_chunk = '' - - if len(word_chunk[line_no]) > 0: - event_chunk = word_chunk[line_no] + split_str + event_chunk - - line_no-=1 - - event_recd = [devicename, this_update_time, - iosapp_state, zone, interval, travel_time, - distance, (f"{event_chunk[:split_str_end_len]} ({event_chunk[:split_str_end_len]})")] - self._insert_event_log_recd(event_recd) - - except Exception as err: - _LOGGER.exception(err) - -#------------------------------------------------------- - def _insert_event_log_recd(self, event_recd): - """Add the event recd into the event table""" - - if self.event_log_table is None: - self.event_log_table = [] - - while len(self.event_log_table) >= self.log_table_max_items: - self.event_log_table.pop() - - self.event_log_table.insert(0, event_recd) - -#------------------------------------------------------ - def _update_sensor_ic3_event_log(self, devicename): - """Display the event log""" - - try: - log_attrs = self.event_log_base_attrs.copy() if self.event_log_base_attrs else {} - - attr_recd = {} - attr_event = {} - log_attr_text = "" - if self.log_level_eventlog_flag: log_attr_text += "evlog," - if self.log_level_debug_flag: log_attr_text += "halog" - log_attrs["log_level_debug"] = log_attr_text - - if devicename is None: - return - elif devicename == "clear_log_items": - log_attrs["filtername"] = "ClearLogItems" - elif devicename == "*" : - log_attrs["filtername"] = "Initialize" - else: - log_attrs["filtername"] = self.fname.get(devicename) - - if devicename == 'clear_log_items': - max_recds = EVENT_LOG_CLEAR_CNT - self.event_log_clear_secs = HIGH_INTEGER - devicename = self.event_log_last_devicename - else: - max_recds = HIGH_INTEGER - self.event_log_clear_secs = self.this_update_secs + EVENT_LOG_CLEAR_SECS - self.event_log_last_devicename = devicename - - #The state must change for the recds to be refreshed on the - #Lovelace card. If the state does not change, the new information - #is not displayed. Add the update time to make it unique. - log_update_time = (f"{dt_util.now().strftime('%a, %m/%d')}, " - f"{dt_util.now().strftime(self.um_time_strfmt)}") - log_attrs["update_time"] = log_update_time - self.event_log_sensor_state = (f"{devicename}:{log_update_time}") - - attr_recd = self._update_sensor_ic3_event_log_recds(devicename, max_recds) - log_attrs["logs"] = attr_recd - - self._set_state(SENSOR_EVENT_LOG_ENTITY, self.event_log_sensor_state, log_attrs) - - except Exception as err: - _LOGGER.exception(err) -#------------------------------------------------------ - def _update_sensor_ic3_event_log_recds(self, devicename, max_recds=HIGH_INTEGER): - ''' - Build the event items attribute for the event log sensor. Each item record - is [devicename, time, state, zone, interval, travTime, dist, textMsg] - Select the items for the devicename or '*' and return the string of - the resulting list to be passed to the Event Log - ''' - if devicename == "startup_log": - devicename = "*" - - el_devicename_check=['*', devicename] - - if self.log_level_eventlog_flag: - attr_recd = [el_recd[1:8] for el_recd in self.event_log_table \ - if el_recd[0] in el_devicename_check] - elif devicename == "*": - attr_recd = [el_recd[1:8] for el_recd in self.event_log_table \ - if ((el_recd[0] in el_devicename_check - or el_recd[7].startswith(EVLOG_ALERT)) - and (el_recd[7].startswith(EVLOG_DEBUG) == False))] - else: - attr_recd = [el_recd[1:8] for el_recd in self.event_log_table \ - if (el_recd[0] in el_devicename_check - and el_recd[7].startswith(EVLOG_DEBUG) == False)] - - if max_recds == EVENT_LOG_CLEAR_CNT: - recd_cnt = len(attr_recd) - attr_recd = attr_recd[0:max_recds] - control_recd = ['',' ',' ',' ',' ',' ',f'^^^ Click `Refresh` to display \ - all records ({max_recds} of {recd_cnt} displayed) ^^^'] - attr_recd.insert(0, control_recd) - - control_recd = [HHMMSS_ZERO,'','','','','','Last Record'] - attr_recd.append(control_recd) - - return str(attr_recd) - -#------------------------------------------------------ - def _export_ic3_event_log(self): - ''' Export Event Log to 'config/icloud_event_log.log' ''' - - try: - log_update_time = (f"{dt_util.now().strftime('%a, %m/%d')}, " - f"{dt_util.now().strftime(self.um_time_strfmt)}") - hdr_recd ="devicename\tTime\tiOSApp State\tiC3 Zone\tInterval\tTravel Time\tDistance\tText" - export_recd = (f"{hdr_recd}\n" - f"Tracked Devices: { self.tracked_devices:}\n" - f"Log Update Time: {log_update_time}\n") - - #Prepare Global '*' records. Reverse the list elements using [::-1] and make a string of the results - el_recds = [el_recd for el_recd in self.event_log_table if (el_recd[0] == "*")] - dev_recds = str(el_recds[::-1]) - export_recd += self._export_ic3_event_log_reformat_recds(dev_recds) - - #Prepare recds for each devicename - for devicename in self.tracked_devices: - el_devicename_check=[devicename] - el_recds = [el_recd for el_recd in self.event_log_table \ - if (el_recd[0] == devicename and el_recd[3] != "Device.Cnts")] - dev_recds = str(el_recds[::-1]) - export_recd += self._export_ic3_event_log_reformat_recds(dev_recds) - - ic3_directory = os.path.abspath(os.path.dirname(__file__)) - export_filename = (f"{ic3_directory.split('custom_components')[0]}/icloud3-event-log.log") - export_filename = export_filename.replace("//", "/") - - export_file = open(export_filename, "w") - export_file.write(export_recd) - export_file.close() - - event_msg = (f"iCloud3 Event Log Exported > File {export_filename}") - self._save_event_halog_info("*", event_msg) - - except Exception as err: - _LOGGER.exception(err) - -#-------------------------------------------------------------------- - def _export_ic3_event_log_reformat_recds(self, recd_str): - recd_str = recd_str.replace("[[", "").replace("]]","").replace("], [", "\n") - recd_str = recd_str.replace("''", "'-'").replace("', '","\t") - recd_str = recd_str.replace("'", "").replace("CRLF", ", ") - recd_str = recd_str.replace("*", "-- sys event --") - - for from_text in self.display_text_as_list: - recd_str = recd_str.replace(from_text, self.display_text_as_list.get(from_text)) - - recd_str += "\n\n" - return recd_str - -######################################################### -# -# WAZE ROUTINES -# -######################################################### - def _get_waze_data(self, devicename, - this_lat, this_long, last_lat, - last_long, zone, last_dist_from_zone_km): - - try: - if not self.distance_method_waze_flag: - return (WAZE_NOT_USED, 0, 0, 0) - elif zone == self.base_zone: - return (WAZE_USED, 0, 0, 0) - elif self.waze_status == WAZE_PAUSED: - return (WAZE_PAUSED, 0, 0, 0) - - try: - log_msg = (f"Request Waze Info, CurrentLoc to {self.base_zone}") - self._log_info_msg(log_msg) - waze_from_zone = self._get_waze_distance(devicename, - this_lat, this_long, - self.base_zone_lat, self.base_zone_long) - - waze_status = waze_from_zone[0] - if waze_status == WAZE_NO_DATA: - event_msg = (f"Waze Route Failure > No Response from Waze Servers, " - f"Calc distance will be used") - self._save_event_halog_info(devicename, event_msg) - - return (WAZE_NO_DATA, 0, 0, 0) - - log_msg = (f"Request Waze Info, CurrentLoc to LastLoc") - self._log_info_msg(log_msg) - waze_from_last_poll = self._get_waze_distance(devicename, - last_lat, last_long, this_lat, this_long) - - except Exception as err: - _LOGGER.exception(err) - - if err == "Name 'WazeRouteCalculator' is not defined": - self.distance_method_waze_flag = False - return (WAZE_NOT_USED, 0, 0, 0) - - return (WAZE_NO_DATA, 0, 0, 0) - - try: - waze_dist_from_zone_km = self._round_to_zero(waze_from_zone[1]) - waze_time_from_zone = self._round_to_zero(waze_from_zone[2]) - waze_dist_last_poll = self._round_to_zero(waze_from_last_poll[1]) - - if waze_dist_from_zone_km == 0: - waze_time_from_zone = 0 - else: - waze_time_from_zone = self._round_to_zero(waze_from_zone[2]) - - if ((waze_dist_from_zone_km > self.waze_max_distance) - or (waze_dist_from_zone_km < self.waze_min_distance)): - waze_status = WAZE_OUT_OF_RANGE - - except Exception as err: - log_msg = (f"►INTERNAL ERROR (ProcWazeData)-{err})") - self._log_error_msg(log_msg) - - waze_time_msg = self._format_waze_time_msg(waze_time_from_zone) - event_msg = (f"Waze Route Info: {self.zone_to_display.get(self.base_zone)} > " - f"Dist-{waze_dist_from_zone_km}km, " - f"TravTime-{waze_time_msg}, " - f"DistMovedSinceLastUpdate-{waze_dist_last_poll}km") - self._save_event(devicename, event_msg) - - log_msg = (f"WAZE DISTANCES CALCULATED>, " - f"Status-{waze_status}, DistFromHome-{waze_dist_from_zone_km}, " - f"TimeFromHome-{waze_time_from_zone}, " - f"DistLastPoll-{waze_dist_last_poll}, " - f"WazeFromHome-{waze_from_zone}, " - f"WazeFromLastPoll-{waze_from_last_poll}") - self._log_debug_interval_msg(devicename, log_msg) - - return (waze_status, waze_dist_from_zone_km, waze_time_from_zone, - waze_dist_last_poll) - - except Exception as err: - self._set_waze_not_available_error(err) - - return (WAZE_NO_DATA, 0, 0, 0) - -#-------------------------------------------------------------------- - def _get_waze_distance(self, devicename, from_lat, from_long, to_lat, - to_long): - """ - Example output: - Time 72.42 minutes, distance 121.33 km. - (72.41666666666667, 121.325) - - See https://github.com/home-assistant/home-assistant/blob - /master/homeassistant/components/sensor/waze_travel_time.py - See https://github.com/kovacsbalu/WazeRouteCalculator - """ - - try: - from_loc = f"{from_lat},{from_long}" - to_loc = f"{to_lat},{to_long}" - - retry_cnt = 0 - while retry_cnt < 3: - try: - self.count_waze_locates[devicename] += 1 - waze_call_start_time = time.time() - route = WazeRouteCalculator.WazeRouteCalculator( - from_loc, to_loc, self.waze_region) - - route_time, route_distance = \ - route.calc_route_info(self.waze_realtime) - - self.time_waze_calls[devicename] += (time.time() - waze_call_start_time) - - route_time = round(route_time, 0) - route_distance = round(route_distance, 2) - - return (WAZE_USED, route_distance, route_time) - - except WazeRouteCalculator.WRCError as err: - retry_cnt += 1 - log_msg = (f"Waze Server Error (#{retry_cnt}), Retrying, Type-{err}") - self._log_info_msg(log_msg) - - except Exception as err: - self._set_waze_not_available_error(err) - - return (WAZE_NO_DATA, 0, 0) - -#-------------------------------------------------------------------- - def _set_waze_not_available_error(self, err): - ''' Turn Waze off if connection error ''' - if (instr(err, "www.waze.com") - and instr(err, "HTTPSConnectionPool") - and instr(err, "Max retries exceeded") - and instr(err, "TIMEOUT")): - self.waze_status = WAZE_NOT_USED - event_msg = ("!Waze Server Error > Connection error accessing www.waze.com, " - "Waze is not available, Will use `distance_method: calc`") - self._save_event_halog_error_msg(devicename, event_msg) - else: - log_msg = (f"►INTERNAL ERROR (GetWazeDist-{err})") - self._log_info_msg(log_msg) - -#-------------------------------------------------------------------- - def _get_waze_from_data_history(self, devicename, - curr_dist_from_zone_km, this_lat, this_long): - ''' - Before getting Waze data, look at all other devices to see - if there are any really close. If so, don't call waze but use their - distance & time instead if the data it passes distance and age - tests. - - The other device's distance from home and distance from last - poll might not be the same as this devices current location - but it should be close enough. - - last_waze_data is a list in the following format: - [timestamp, latitudeWhenCalculated, longitudeWhenCalculated, - [distance, time, distMoved]] - - Returns: [ Waze History Data] - ''' - - devicename_zone = self._format_devicename_zone(devicename) - if not self.distance_method_waze_flag: - return None - elif self.waze_status == WAZE_PAUSED: - return None - - #Delete Waze history if entered/exited zone - #v2.2.0rc7 - elif self.trigger.get(devicename) in IOS_TRIGGERS_ENTER_EXIT: - self.waze_distance_history[devicename_zone] = [] - self.waze_history_data_used_flag[devicename_zone] = False - return None - - #Calculate how far the old data can be from the new data before the - #data will be refreshed. - test_distance = curr_dist_from_zone_km * .05 - if test_distance > 5: - test_distance = 5 - - try: - #other_closest_device_data = None - used_data_from_devicename_zone = None - for near_devicename_zone in self.waze_distance_history: - self.waze_history_data_used_flag[devicename_zone] = False - waze_data_other_device = self.waze_distance_history.get(near_devicename_zone) - #Skip if this device doesn't have any Waze data saved or it's for - #another base_zone. - if len(waze_data_other_device) == 0: - continue - elif len(waze_data_other_device[3]) == 0: - continue - elif near_devicename_zone.endswith(':'+self.base_zone) == False: - continue - - waze_data_timestamp = waze_data_other_device[0] - waze_data_latitude = waze_data_other_device[1] - waze_data_longitude = waze_data_other_device[2] - - dist_from_other_waze_data = self._calc_distance_km( - this_lat, this_long, - waze_data_latitude, waze_data_longitude) - - #Find device's waze data closest to my current location - #If close enough, use it regardless of whose it is - if dist_from_other_waze_data < test_distance: - used_data_from_devicename_zone = near_devicename_zone - other_closest_device_data = waze_data_other_device[3] - test_distance = dist_from_other_waze_data - - #Return the waze history data for the other closest device - if used_data_from_devicename_zone is not None: - used_devicename = used_data_from_devicename_zone.split(':')[0] - event_msg = (f"Waze Route History Used: {self.zone_to_display.get(self.base_zone)} > " - f"Dist-{other_closest_device_data[1]}km, " - f"TravTime-{round(other_closest_device_data[2], 0)} min, " - f"UsedInfoFrom-{self._format_fname_devicename(used_devicename)}, " - f"({test_distance}m AwayFromMyLoc)") - self._save_event_halog_info(devicename, event_msg) - - #Return Waze data (Status, distance, time, dist_moved) - self.waze_history_data_used_flag[used_data_from_devicename_zone] = True - self.waze_data_copied_from[devicename_zone] = used_data_from_devicename_zone - return other_closest_device_data - - except Exception as err: - _LOGGER.exception(err) - - return None - -#-------------------------------------------------------------------- - def _format_waze_time_msg(self, waze_time_from_zone): - ''' - Return the message displayed in the waze time field ►► - ''' - - #Display time to the nearest minute if more than 3 min away - if self.waze_status == WAZE_USED: - t = waze_time_from_zone * 60 - r = 0 - if t > 180: - t, r = divmod(t, 60) - t = t + 1 if r > 30 else t - t = t * 60 - - waze_time_msg = self._secs_to_time_str(t) - - else: - waze_time_msg = '' - - return waze_time_msg -#-------------------------------------------------------------------- - def _verify_waze_installation(self): - ''' - Report on Waze Route alculator service availability - ''' - - self._log_info_msg("Verifying Waze Route Service component") - - if (WAZE_IMPORT_SUCCESSFUL and self.distance_method_waze_flag): - self.waze_status = WAZE_USED - else: - self.waze_status = WAZE_NOT_USED - self.distance_method_waze_flag = False - self._log_info_msg("Waze Route Service not available") -######################################################### -# -# MULTIPLE PLATFORM/GROUP ROUTINES -# -######################################################### - def _check_devicename_in_another_thread(self, devicename): - ''' - Cycle through all instances of the ICLOUD3_TRACKED_DEVICES and check - to see if this devicename is also in another the tracked_devices - for group/instance/thread/platform. - If so, return True to reject this devicename and generate an error msg. - - ICLOUD3_TRACKED_DEVICES = { - 'work': ['gary_iphone > gcobb321@gmail.com, gary.png'], - 'group2': ['gary_iphone > gcobb321@gmail.com, gary.png, whse', - 'lillian_iphone > lilliancobb321@gmail.com, lillian.png']} - ''' - try: - for group in ICLOUD3_GROUPS: - if group != self.group and ICLOUD3_GROUPS.index(group) > 0: - tracked_devices = ICLOUD3_TRACKED_DEVICES.get(group) - for tracked_device in tracked_devices: - tracked_devicename = tracked_device.split('>')[0].strip() - if devicename == tracked_devicename: - log_msg = (f"Error: A device can only be tracked in " - f"one platform/group {ICLOUD3_GROUPS}. '{devicename}' was defined multiple " - f"groups and will not be tracked in '{self.group}'.") - self._save_event_halog_error('*', log_msg) - return True - - except Exception as err: - _LOGGER.exception(err) - - return False - -####################################################################### -# -# EXTRACT ICLOUD3 PARAMETERS FROM THE CONFIG_IC3.YAML PARAMETER FILE. -# -# The ic3 parameters are specified in the HA configuration.yaml file and -# processed when HA starts. The 'config_ic3.yaml' file lets you specify -# parameters at HA startup time or when iCloud3 is restarted using the -# Restart-iC3 command on the Event Log screen. When iC3 is restarted, -# the parameters will override those specified at HA startup time. -# -# 1. You can, for example, add new tracked devices without restarting HA. -# 2. You can specify the username, password and tracking method in this -# file but these items are onlyu processed when iC3 initially loads. -# A restart will discard these items -# -# Default file is config/custom_components/icloud3/config-ic3.yaml -# if no '/' on the config_ic3_file_name parameter: -# check the default directory -# if not found, check the /config directory -# -# -####################################################################### - def _check_config_ic3_yaml_parameter_file(self): - - try: - ic3_directory = os.path.abspath(os.path.dirname(__file__)) - self._save_event("*", f"iCloud3 Directory > {ic3_directory}") - - if self.config_ic3_file_name == "": - return - - event_msg = (f"iCloud3 Configuration File > " - f"Specified-{self.config_ic3_file_name}, ") - - #fully qualified name specified ('/' in filename) - if (instr(self.config_ic3_file_name, "/") - and os.path.exists(self.config_ic3_file_name)): - config_filename = self.config_ic3_file_name - - elif os.path.exists(f"/config/{self.config_ic3_file_name}"): - config_filename = (f"/config/{self.config_ic3_file_name}") - - elif os.path.exists(f"{ic3_directory}/{self.config_ic3_file_name}"): - config_filename = (f"{ic3_directory}/{self.config_ic3_file_name}") - - alert_msg = (f"{EVLOG_ALERT}iCloud3 Alert > The `config_ic3.yaml` " - f"configuration file should moved to the `/config` " - f"directory so it is not deleted when HACS updates iCloud3") - self._save_event("*", alert_msg) - - else: - event_msg += "Error-File Not Found" - self._save_event("*", event_msg) - return - - config_filename = config_filename.replace("//", "/") - - event_msg += (f"Found-{config_filename}") - self._save_event("*", event_msg) - - config_ic3_file = open(config_filename) - - except (FileNotFoundError, IOError): - event_msg = (f"iCloud3 Error Opening {config_filename} > File not Found") - self._save_event_halog_error("*", event_msg) - self.info_notification = event_msg - return - - except Exception as err: - _LOGGER.exception(err) - return - - try: - if self.start_icloud3_initial_load_flag: - self.last_config_ic3_items = [] - - #reset any changed items in config_ic3 from the last ic3 reset back to it's default value - for last_config_ic3_item in self.last_config_ic3_items: - self._set_parameter_item(last_config_ic3_item, DEFAULT_CONFIG_VALUES.get(last_config_ic3_item)) - - self.last_config_ic3_items = [] - - parameter_list = [] - parameter_list_name = "" - log_success_msg = "" - log_error_msg = "" - success_msg = "" - error_msg = "" - for config_ic3_recd in config_ic3_file: - parm_recd_flag = True - recd = config_ic3_recd.strip() - if len(recd) < 2 or recd.startswith('#'): - continue - - #Last recd started with a '-' (list item), Add this one to the list being built - if recd.startswith('-'): - parameter_value = recd[1:].strip() - parameter_list.append(parameter_value) - continue - - #Not a list recd but a list exists, update it's parameter value, then process this recd - elif parameter_list != []: - success_msg, error_msg = self._set_parameter_item(parameter_list_name, parameter_list) - log_success_msg += success_msg - log_error_msg += error_msg - parameter_list_name = "" - parameter_list = [] - - #Decode and process the config recd - recd_fields = recd.split(":") - parameter_name = recd_fields[0].strip().lower() - parameter_value = recd_fields[1].replace("'","").strip().lower() - - #Check to see if the parameter is a list parameter. If so start building a list - if parameter_name in [CONF_TRACK_DEVICE, CONF_TRACK_DEVICES, - CONF_CREATE_SENSORS, CONF_EXCLUDE_SENSORS, - CONF_DISPLAY_TEXT_AS]: - parameter_list_name = parameter_name - else: - success_msg, error_msg = self._set_parameter_item(parameter_name, parameter_value) - if success_msg != "": - self.last_config_ic3_items.append(parameter_name) - - log_success_msg += success_msg - log_error_msg += error_msg - - if parameter_list != []: - success_msg, error_msg = self._set_parameter_item(parameter_list_name, parameter_list) - log_success_msg += success_msg - log_error_msg += error_msg - - except Exception as err: - _LOGGER.exception(err) - pass - - config_ic3_file.close() - - if log_error_msg != "": - event_msg = (f"iCloud3 Error decoding '{config_filename}` parameters > " - f"The following parameters can not be handled:CRLF{log_error_msg}") - self._save_event_halog_info("*", event_msg) - - return - -#------------------------------------------------------------------------- - def _set_parameter_item(self, parameter_name, parameter_value): - try: - success_msg = "" - error_msg = "" - #These parameters can not be changed - if parameter_name in [CONF_GROUP, CONF_USERNAME, CONF_PASSWORD, - CONF_TRACKING_METHOD, - CONF_CREATE_SENSORS, CONF_EXCLUDE_SENSORS, - CONF_ENTITY_REGISTRY_FILE, CONF_CONFIG_IC3_FILE_NAME]: - return ("", "") - - - log_msg = (f"{EVLOG_DEBUG}config_ic3 Parameter > {parameter_name}: {parameter_value}") - self._save_event("*", log_msg) - - if parameter_name in [CONF_TRACK_DEVICES, CONF_TRACK_DEVICE]: - self.track_devices = parameter_value - elif parameter_name == CONF_IOSAPP_REQUEST_LOC_MAX_CNT: - self.iosapp_request_loc_max_cnt = int(parameter_value) - elif parameter_name == CONF_UNIT_OF_MEASUREMENT: - self.unit_of_measurement = parameter_value - elif parameter_name == CONF_TIME_FORMAT: - self.time_format = int(parameter_value) - elif parameter_name == CONF_BASE_ZONE: - self.base_zone = parameter_value - elif parameter_name == CONF_INZONE_INTERVAL: - self.inzone_interval_secs = self._time_str_to_secs(parameter_value) - elif parameter_name == CONF_MAX_INTERVAL: - self.max_interval_secs = self._time_str_to_secs(parameter_value) - elif parameter_name == CONF_DISPLAY_ZONE_FNAME: - self.display_zone_fname_flag = (parameter_value == 'true') - elif parameter_name == CONF_CENTER_IN_ZONE: - self.center_in_zone_flag = (parameter_value == 'true') - elif parameter_name == CONF_STATIONARY_STILL_TIME: - self.stationary_still_time_str = parameter_value - elif parameter_name == CONF_STATIONARY_INZONE_INTERVAL: - self.stationary_inzone_interval_str = parameter_value - elif parameter_name == CONF_STATIONARY_ZONE_OFFSET: - self.stationary_zone_offset = parameter_value - elif parameter_name == CONF_TRAVEL_TIME_FACTOR: - self.travel_time_factor = float(parameter_value) - elif parameter_name == CONF_GPS_ACCURACY_THRESHOLD: - self.gps_accuracy_threshold = int(parameter_value) - elif parameter_name == CONF_OLD_LOCATION_THRESHOLD: - self.old_location_threshold = self._time_str_to_secs(parameter_value) - elif parameter_name == CONF_IGNORE_GPS_ACC_INZONE: - self.ignore_gps_accuracy_inzone_flag = (parameter_value == 'true') - self.check_gps_accuracy_inzone_flag = not self.ignore_gps_accuracy_inzone_flag - elif parameter_name == CONF_WAZE_REGION: - self.waze_region = parameter_value - elif parameter_name == CONF_WAZE_MAX_DISTANCE: - self.waze_max_distance = int(parameter_value) - elif parameter_name == CONF_WAZE_MIN_DISTANCE: - self.waze_min_distance = int(parameter_value) - elif parameter_name == CONF_WAZE_REALTIME: - self.waze_realtime = (parameter_value == 'true') - elif parameter_name == CONF_DISTANCE_METHOD: - self.distance_method_waze_flag = (parameter_value == 'waze') - elif parameter_name == CONF_LOG_LEVEL: - self._initialize_debug_control(parameter_value) - elif parameter_name == CONF_EVENT_LOG_CARD_DIRECTORY: - self.event_log_card_directory = parameter_value - elif parameter_name == CONF_DEVICE_STATUS: - self.device_status_online = list(parameter_value.replace(" ","").split(",")) - self.device_status_online.append('') - elif parameter_name == CONF_DISPLAY_TEXT_AS: - self.display_text_as = parameter_value - else: - error_msg = (f"{parameter_name}: {parameter_value}CRLF") - log_msg = (f"Invalid parameter-{parameter_name}: {parameter_value}") - self._log_debug_msg("*", log_msg) - - except Exception as err: - _LOGGER.exception(err) - error_msg = (f"{err}CRLF") - - if error_msg == "": - success_msg = (f"{parameter_name}: {parameter_value}CRLF") - - return (success_msg, error_msg) - -######################################################### -# -# CHECK THE IC3 EVENT LOG VERSION BEING USED -# -# Read the icloud3-event-log-card.js file in the iCloud3 directory and the -# Lovelace Custom Card directory (default=www/custom_cards) and extract -# the current version (Version=x.x.x (mm.dd.yyyy)) comment entry before -# the first 'class' statement. If the version in the ic3 directory is -# newer than the www/custom_cards directory, copy the ic3 version -# to the www/custom_cards directory. -# -# The custom_cards directory can be changed using the event_log_card_directory -# parameter. -# -######################################################### - def _check_ic3_event_log_file_version(self): - try: - ic3_directory = os.path.abspath(os.path.dirname(__file__)) - ic3_evlog_filename = (f"{ic3_directory}/icloud3-event-log-card.js") - if os.path.exists(ic3_evlog_filename) == False: - return - - www_directory = (f"{ic3_evlog_filename.split('custom_components')[0]}" - f"{self.event_log_card_directory}") - www_evlog_filename = (f"{www_directory}/icloud3-event-log-card.js") - www_evlog_file_exists_flag = os.path.exists(www_evlog_filename) - - - except Exception as err: - _LOGGER.exception(err) - - try: - ic3_version, ic3_version_text = self._read_event_log_card_js_file(ic3_evlog_filename) - if www_evlog_file_exists_flag: - www_version, www_version_text = self._read_event_log_card_js_file(www_evlog_filename) - else: - www_version = 0 - www_version_text = UNKNOWN - try: - os.mkdir(www_directory) - except FileExistsError: - pass - except Exception as err: - _LOGGER.exception(err) - pass - - if ic3_version > www_version: - shutil.copy(ic3_evlog_filename, www_evlog_filename) - event_msg = (f"{EVLOG_ALERT}" - f"EventLog Alert > Event Log was updated, " - f"CRLFOldVersion-{www_version_text}, " - f"NewVersion-v{ic3_version_text}, " - f"CRLFCopied-`{ic3_evlog_filename}` to `{www_directory}`" - f"CRLF-----" - f"CRLFThe Event Log Card was updated to v{ic3_version_text}. " - "Refresh your browser and do the following on every tracked " - "devices running the iOS App to load the new version." - "CRLF1. Select HA Sidebar > APP Configuration." - "CRLF2. Scroll to the botton of the General screen." - "CRLF3. Select Reset Frontend Cache, then select Done." - "CRLF4. Display the Event Log, then pull down to refresh the page. " - "You should see the busy spinning wheel as the new version is loaded.") - self._save_event_halog_info("*", event_msg) - self.info_notification = (f"Event Log Card updated to v{ic3_version_text}. " - "See Event Log for more info.") - title = (f"iCloud3 Event Log Card updated to v{ic3_version_text}") - message = ("Refresh the iOS App to load the new version. " - "Select HA Sidebar > APP Configuration. Scroll down. Select Refresh " - "Frontend Cache. Select Done. Pull down to refresh App.") - self.broadcast_msg = { - "title": title, - "message": message, - "data": {"subtitle": "Event Log needs to be refreshed"}} - - else: - event_msg = (f"Event Log Version Check > Current release is being used. " - f"Version-{www_version_text}, {www_directory}") - self._save_event_halog_info("*", event_msg) - - except Exception as err: - _LOGGER.exception(err) - return - -#-------------------------------------- - def _read_event_log_card_js_file(self, evlog_filename): - ''' - Read the records in the the evlog_filename up to the 'class' statement and - extract the version and date. - Return the Version number and the Version text (#.#.#.### (m/d/yyy)) - Return 0, "Unknown" if the Version number was not found - ''' - try: - #Cycle thru the file looking for the Version - evlog_version_text = UNKNOWN - evlog_version_no = 0 - evlog_file = open(evlog_filename) - evlog_version_parts = [0, 0, 0] - - for evlog_recd in evlog_file: - version_pos = evlog_recd.lower().find("version") - if version_pos > 0: - #Make sure it's 'Version=#.#.#.## (m/d/yyyy), get number & date - evlog_recd = evlog_recd.replace("("," (") - version_text = evlog_recd[version_pos:].split("=") - - if len(version_text) == 2: - #Get number portion, then get each number - evlog_version_text = version_text[1].strip() - evlog_version = evlog_version_text.split(" ")[0] - evlog_version_parts = evlog_version.split(".") - break - - #exit if find 'class' recd before 'version' recd - elif instr(evlog_recd, "class"): - break - - evlog_version_parts.append('0') - evlog_version_no = 0 - evlog_version_no += int(evlog_version_parts[0])*100000 - evlog_version_no += int(evlog_version_parts[1])*10000 - evlog_version_no += int(evlog_version_parts[2])*1000 - evlog_version_no += int(evlog_version_parts[3]) - - except FileNotFoundError: - evlog_version_no = 0 - evlog_version_text = UNKNOWN - return (evlog_version_no, evlog_version_text) - - except Exception as err: - _LOGGER.exception(err) - evlog_version_no = HIGH_INTEGER - evlog_version_text = "Error" - - evlog_file.close() - - return (evlog_version_no, evlog_version_text) - -######################################################### -# -# log_, trace_ MESSAGE ROUTINES -# -######################################################### - def _log_info_msg(self, log_msg): - ''' Always add log_msg to HA log ''' - if self.start_icloud3_inprocess_flag and not self.log_level_debug_flag: - self.startup_log_msgs += f"{self.startup_log_msgs_prefix}\n {log_msg}" - self.startup_log_msgs_prefix = "" - else: - _LOGGER.info(log_msg) - -#-------------------------------------- - def _save_event_halog_info(self, devicename, log_msg, log_title=""): - ''' Always display log_msg in Event Log; Always add log_msg to HA log ''' - self._save_event(devicename, log_msg) - - log_msg = str(log_msg).replace("CRLF", ". ") - if devicename != "*": - log_msg = (f"{log_title}{self._format_fname_devtype(devicename)} {log_msg}") - - if self.start_icloud3_inprocess_flag and not self.log_level_debug_flag: - self.startup_log_msgs += f"{self.startup_log_msgs_prefix}\n {log_msg}" - self.startup_log_msgs_prefix = "" - else: - self._log_info_msg(log_msg) - -#-------------------------------------- - def _save_event_halog_debug(self, devicename, log_msg, log_title=""): - ''' Always display log_msg in Event Log; add to HA log only when "log_level: debug" ''' - self._save_event(devicename, f"{log_msg}") - - if devicename != "*": - log_msg = (f"{log_title}{self._format_fname_devtype(devicename)} {log_msg}") - self._log_debug_msg(devicename, log_msg) - -#-------------------------------------- - def _evlog_debug_msg(self, devicename, log_msg, log_title=""): - ''' Only display log_msg in Event Log and HA log when "log_level: eventlog" ''' - self._save_event(devicename, f"{EVLOG_DEBUG}{log_msg}") - - if self.log_level_eventlog_flag: - if devicename != "*": - log_msg = (f"{self._format_fname_devtype(devicename)} {log_msg}") - self._log_debug_msg(devicename, log_msg, log_title) - -#-------------------------------------- - @staticmethod - def _log_warning_msg(log_msg): - _LOGGER.warning(log_msg) - -#-------------------------------------- - @staticmethod - def _log_error_msg(log_msg): - _LOGGER.error(log_msg) - -#-------------------------------------- - def _save_event_halog_error(self, devicename, log_msg): - ''' Always display log_msg in Event Log; always add to HA log ''' - if instr(log_msg, "iCloud3 Error"): - self.info_notification = ICLOUD3_ERROR_MSG - for td_devicename in self.tracked_devices: - self._display_info_status_msg(td_devicename, ICLOUD3_ERROR_MSG) - - self._save_event(devicename, log_msg) - log_msg = (f"{self._format_fname_devtype(devicename)} {log_msg}") - log_msg = str(log_msg).replace("CRLF", ". ") - - if self.start_icloud3_inprocess_flag and not self.log_level_debug_flag: - self.startup_log_msgs += f"{self.startup_log_msgs_prefix}\n {log_msg}" - self.startup_log_msgs_prefix = "" - - self._log_error_msg(log_msg) - -#-------------------------------------- - def _log_debug_msg(self, devicename, log_msg, log_title=""): - ''' Always add log_msg to HA log only when "log_level: debug" ''' - - log_msg = str(log_msg).replace("CRLF", ". ") - if self.log_level_debug_flag: - _LOGGER.info(f"◆{devicename}◆ {log_title}{log_msg}") - else: - _LOGGER.debug(f"◆{devicename}◆ {log_title}{log_msg}") - -#-------------------------------------- - def _log_debug_interval_msg(self, devicename, log_msg): - ''' Add log_msg to HA log only when "log_level: intervalcalc" ''' - if self.log_level_intervalcalc_flag: - _LOGGER.debug(f"◆{devicename}◆ {log_msg}") - - if self.log_level_eventlog_flag: - self._save_event(devicename, (f"{EVLOG_DEBUG}{str(log_msg).replace('►','')}")) - -#-------------------------------------- - def _log_level_debug_rawdata(self, title, data, log_rawdata=False): - ''' Add log_msg to HA log only when "log_level: rawdata" ''' - display_title = title.replace(" ",".").upper() - if self.log_level_debug_rawdata_flag or log_rawdata: - log_msg = (f"▼---------▼--{display_title}--▼---------▼") - self._log_debug_msg("*", log_msg) - log_msg = (f"{data}") - self._log_debug_msg("*", log_msg) - log_msg = (f"▲---------▲--{display_title}--▲---------▲") - self._log_debug_msg("*", log_msg) -#-------------------------------------- - def _log_debug_msg2(self, log_msg): - _LOGGER.debug(log_msg) - -#-------------------------------------- - def _internal_error_msg(self, function_name, err_text: str='', - section_name: str=''): - log_msg = (f"►INTERNAL ERROR-RETRYING ({function_name}:{section_name}-{err_text})") - _LOGGER.error(log_msg) - self._save_event("*", log_msg) - - attrs = {} - attrs[ATTR_INTERVAL] = '0 sec' - attrs[ATTR_NEXT_UPDATE_TIME] = HHMMSS_ZERO - attrs[ATTR_INFO] = log_msg - - return attrs - -######################################################### -# -# TIME & DISTANCE UTILITY ROUTINES -# -######################################################### - @staticmethod - def _time_now_secs(): - ''' Return the epoch seconds in utc time ''' - - return int(time.time()) - -#-------------------------------------------------------------------- - def _secs_to_time(self, e_seconds): - """ Convert seconds to hh:mm:ss """ - - if e_seconds == 0 or e_seconds == HIGH_INTEGER: - return HHMMSS_ZERO - else: - t_struct = time.localtime(e_seconds + self.e_seconds_local_offset_secs) - return time.strftime(self.um_time_strfmt, t_struct).lstrip('0') - -#-------------------------------------------------------------------- - @staticmethod - def _secs_to_time_str(secs): - """ Create the time string from seconds """ - - try: - if secs >= 86400: - time_str = self._secs_to_dhms_str(secs) - elif secs < 60: - time_str = str(round(secs, 0)) + " sec" - elif secs < 3600: - time_str = str(round(secs/60, 1)) + " min" - elif secs == 3600: - time_str = "1 hr" - else: - time_str = str(round(secs/3600, 1)) + " hrs" - - # xx.0 min/hr --> xx min/hr - time_str = time_str.replace('.0 ', ' ') - - except: - time_str = "" - - return time_str -#-------------------------------------------------------------------- - @staticmethod - def _secs_to_dhms_str(secs): - """ Create the time 0d0h0m0s time string from seconds """ - - try: - secs_dhms = float(secs) - dhms_str = "" - if (secs >= 86400): dhms_str += f"{secs_dhms // 86400}d" - secs_dhms = secs_dhms % 86400 - if (secs >= 3600): dhms_str += f"{secs_dhms // 3600}h" - secs_dhms %= 3600 - if (secs >= 60): dhms_str += f"{secs_dhms // 60}m" - secs_dhms %= 60 - dhms_str += f"{secs_dhms}s" - - dhms_str = dhms_str.replace('.0', '') - - except: - dhms_srt = "" - - return dhms_str - -#-------------------------------------------------------------------- - def _secs_since(self, e_secs) -> int: - return round(time.time() - e_secs) -#-------------------------------------------------------------------- - def _secs_to(self, e_secs) -> int: - return round(e_secs - time.time()) -#-------------------------------------------------------------------- - @staticmethod - def _time_to_secs(hhmmss): - """ Convert hh:mm:ss into seconds """ - try: - hh_mm_ss = hhmmss.split(":") - secs = int(hh_mm_ss[0]) * 3600 + int(hh_mm_ss[1]) * 60 + int(hh_mm_ss[2]) - - except: - secs = 0 - - return secs - -#-------------------------------------------------------------------- - def _time_to_12hrtime(self, hhmmss, ampm=False): - - try: - #if self.unit_of_measurement == 'mi' and time_24h is False: - if self.time_format == 12: - hh_mm_ss = hhmmss.split(':') - hhmmss_hh = int(hh_mm_ss[0]) - - ap = 'a' - if hhmmss_hh > 12: - hhmmss_hh -= 12 - ap = 'p' - elif hhmmss_hh == 12: - ap = 'p' - elif hhmmss_hh == 0: - hhmmss_hh = 12 - - ap = '' if ampm == False else ap - - hhmmss = f"{hhmmss_hh}:{hh_mm_ss[1]}:{hh_mm_ss[2]}{ap}" - - except: - pass - - return hhmmss -#-------------------------------------------------------------------- - @staticmethod - def _time_str_to_secs(time_str='30 min') -> int: - """ - Calculate the seconds in the time string. - The time attribute is in the form of '15 sec' ', - '2 min', '60 min', etc - """ - - if time_str == "": - return 0 - - s1 = str(time_str).replace('_', ' ') + " min" - time_part = float((s1.split(" ")[0])) - text_part = s1.split(" ")[1] - - if text_part == 'sec': - secs = time_part - elif text_part == 'min': - secs = time_part * 60 - elif text_part == 'hrs': - secs = time_part * 3600 - elif text_part in ('hr', 'hrs'): - secs = time_part * 3600 - else: - secs = 1200 #default to 20 minutes - - return secs - -#-------------------------------------------------------------------- - def _timestamp_to_time_utcsecs(self, utc_timestamp) -> int: - """ - Convert iCloud timeStamp into the local time zone and - return hh:mm:ss - """ - - ts_local = int(float(utc_timestamp)/1000) + self.time_zone_offset_seconds - hhmmss = dt_util.utc_from_timestamp(ts_local).strftime(self.um_time_strfmt) - if hhmmss[0] == "0": - hhmmss = hhmmss[1:] - - return hhmmss - -#-------------------------------------------------------------------- - def _timestamp_to_time(self, timestamp): - """ - Extract the time from the device timeStamp attribute - updated by the IOS app. - Format is --'timestamp': '2019-02-02 12:12:38.358-0500' - """ - - try: - if timestamp == TIMESTAMP_ZERO: - return HHMMSS_ZERO - - yyyymmdd_hhmmss = (f"{timestamp}.").split(' ')[1] - hhmmss = yyyymmdd_hhmmss.split('.')[0] - - except: - hhmmss = HHMMSS_ZERO - - return hhmmss - -#-------------------------------------------------------------------- - def _timestamp_to_secs_utc(self, utc_timestamp) -> int: - """ - Convert timeStamp seconds (1567604461006) into the local time zone and - return time in seconds. - """ - - try: - ts_local = int(float(utc_timestamp)/1000) + self.time_zone_offset_seconds - - hhmmss = dt_util.utc_from_timestamp(ts_local).strftime('%X') - if hhmmss[0] == "0": - hhmmss = hhmmss[1:] - - except: - hhmmss = 0 - - return self._time_to_secs(hhmmss) - -#-------------------------------------------------------------------- - @staticmethod - def _secs_to_timestamp(secs): - """ - Convert seconds to timestamp - Return timestamp (2020-05-19 09:12:30) - """ - try: - time_struct = time.localtime(secs) - timestamp = time.strftime("%Y-%m-%d %H:%M:%S", time_struct) - - except: - timestamp = "0000-00-00 00:00:00" - - return timestamp - -#-------------------------------------------------------------------- - def _timestamp_to_secs(self, timestamp, utc_local = LOCAL_TIME) -> int: - """ - Convert the timestamp from the device timestamp attribute - updated by the IOS app. - Format is --'timestamp': '2019-02-02T12:12:38.358-0500' - Return epoch seconds - """ - try: - if timestamp is None: - secs = 0 - elif timestamp == '' or timestamp[0:19] == TIMESTAMP_ZERO: - secs = 0 - else: - timestamp = timestamp.replace("T", " ")[0:19] - secs = time.mktime(time.strptime(timestamp, "%Y-%m-%d %H:%M:%S")) - if utc_local is UTC_TIME: - secs += self.time_zone_offset_seconds - - except: - secs = 0 - - return secs -#-------------------------------------------------------------------- - def _calculate_time_zone_offset(self): - """ - Calculate time zone offset seconds - """ - try: - local_zone_offset = dt_util.now().strftime('%z') - local_zone_offset_secs = int(local_zone_offset[1:3])*3600 + \ - int(local_zone_offset[3:])*60 - if local_zone_offset[:1] == "-": - local_zone_offset_secs = -1*local_zone_offset_secs - - t_now = int(time.time()) - t_hhmmss = dt_util.now().strftime('%H%M%S') - l_now = time.localtime(t_now) - l_hhmmss = time.strftime('%H%M%S', l_now) - g_now = time.gmtime(t_now) - g_hhmmss = time.strftime('%H%M%S', g_now) - - if (l_hhmmss == g_hhmmss): - self.e_seconds_local_offset_secs = local_zone_offset_secs - - log_msg = (f"Time Zone Offset, Local Zone-{local_zone_offset} hrs, " - f"{local_zone_offset_secs} secs") - self._log_debug_msg('*', log_msg) - - except Exception as err: - _LOGGER.exception(err) - x = self._internal_error_msg(fct_name, err, 'CalcTZOffset') - local_zone_offset_secs = 0 - - return local_zone_offset_secs - -#-------------------------------------------------------------------- - def _km_to_mi(self, arg_distance): - """ - convert km to miles - """ - - try: - mi = arg_distance * self.um_km_mi_factor - - if mi == 0: - mi = 0 - elif mi <= 10: - mi = round(mi, 2) - elif mi <= 100: - mi = round(mi, 1) - else: - mi = round(mi) - - except: - mi = 0 - return mi - - def _mi_to_km(self, arg_distance): - return round(float(arg_distance) / self.um_km_mi_factor, 2) - -#-------------------------------------------------------------------- - @staticmethod - def _format_dist(dist): - return f"{dist}km" if dist > .5 else f"{round(dist*1000)}m" - - @staticmethod - def _format_dist_m(dist): - return f"{round(dist/1000, 2)}km" if dist > 500 else f"{round(dist)}m" -#-------------------------------------------------------------------- - @staticmethod - def _calc_distance_km(from_lat, from_long, to_lat, to_long): - if from_lat is None or from_long is None or to_lat is None or to_long is None: - return 0 - - d = distance(from_lat, from_long, to_lat, to_long) / 1000 - if d < .05: d = 0 - return round(d, 2) - - @staticmethod - def _calc_distance_m(from_lat, from_long, to_lat, to_long): - if from_lat is None or from_long is None or to_lat is None or to_long is None: - return 0 - - d = distance(from_lat, from_long, to_lat, to_long) - - return round(d, 2) - -#-------------------------------------------------------------------- - @staticmethod - def _round_to_zero(arg_distance): - if abs(arg_distance) < .05: arg_distance = 0 - return round(arg_distance, 2) - -#-------------------------------------------------------------------- - def _add_comma_to_str(self, text): - """ Add a comma to info if it is not an empty string """ - if text: - return f"{text}, " - return '' - -#-------------------------------------------------------------------- - @staticmethod - def _isnumber(string): - - try: - test_number = float(string) - - return True - except: - return False - -#-------------------------------------------------------------------- - def _extract_name_device_type(self, devicename): - '''Extract the name and device type from the devicename''' - - try: - fname = devicename.title() - device_type = '' - - for ic3dev_type in APPLE_DEVICE_TYPES: - if instr(devicename, ic3dev_type): - fnamew = devicename.replace(ic3dev_type, "", 99) - fname = fnamew.replace("_", "", 99) - fname = fname.replace("-", "", 99).title() - device_type = ic3dev_type - return (fname, ic3dev_type) - - except Exception as err: - _LOGGER.exception(err) - - return (fname, "iCloud") - -#-------------------------------------------------------------------- - def _device_online(self, devicename): - ''' Returns True/False if the device is online based on the device_status ''' - if self.TRK_METHOD_IOSAPP: - return True - else: - return (self.device_status.get(devicename) in self.device_status_online) #["online", "pending", ""]) - -######################################################### -# -# These functions handle notification and entry of the -# iCloud Account trusted device verification code. -# -######################################################### - def _icloud_show_trusted_device_request_form(self): - """We need a trusted device.""" - - if self._icloud_valid_api() == False: - return - - self._service_handler_icloud_update(self.group, arg_command='pause') - configurator = self.hass.components.configurator - - event_msg = (f"{EVLOG_ALERT}iCloud Alert > Apple/iCloud Account Verification Required > " - f"Open HA Notifications window to select Trusted Device and to " - f"enter the 6-digit Verification Code.") - self._save_event_halog_info('*', event_msg) - self.info_notification = "Apple/iCloud Account Verification Required, See Event Log." - - device_list = '' - self.trusted_device_list = {} - self.trusted_devices = self.api.trusted_devices - device_list += "ID      Phone Number\n" \ - "––    ––––––––––––\n" - - for trusted_device in self.trusted_devices: - phone_number = trusted_device.get('phoneNumber') - device_list += (f"{trusted_device['deviceId']}      " - f"  {phone_number}\n") - event_msg += (f"#{trusted_device['deviceId']}-{phone_number}, ") - - self.trusted_device_list[trusted_device['deviceId']] = trusted_device - - log_msg = (f"VALID TRUSTED IDs={self.trusted_device_list}") - self._log_debug_msg('*', log_msg) - - description_msg = (f"Account {self.username} needs to be verified. Enter the " - f"ID for the Trusted Device that will receive the " - f"verification code via a text message.\n\n\n{device_list}") - - self.hass_configurator_request_id[self.username] = configurator.request_config( - (f"Select Trusted Device"), - self._icloud_handle_trusted_device_entry, - description = (description_msg), - entity_picture = "/static/images/config_icloud.png", - submit_caption = 'Confirm', - fields = [{'id': 'trusted_device', \ - CONF_NAME: 'Trusted Device ID'}] - ) - -#-------------------------------------------------------------------- - def _icloud_handle_trusted_device_entry(self, callback_data): - """ - Take the device number enterd above, get the api.device info and - have pyiCloud validate the device. - - callbackData-{'trusted_device': '1'} - apiDevices=[{'deviceType': 'SMS', 'areaCode': '', 'phoneNumber': - '********65', 'deviceId': '1'}, - {'deviceType': 'SMS', 'areaCode': '', 'phoneNumber': - '********66', 'deviceId': '2'}] - """ - - if self._icloud_valid_api() == False: - return - - device_id_entered = str(callback_data.get('trusted_device')) - - if device_id_entered in self.trusted_device_list: - self.trusted_device = self.trusted_device_list.get(device_id_entered) - phone_number = self.trusted_device.get('phoneNumber') - event_msg = (f"Verify Trusted Device Id > Device Id Entered-{device_id_entered}, " - f"Phone Number-{phone_number}") - self._save_event("*", event_msg) - - if self.username not in self.hass_configurator_request_id: - request_id = self.hass_configurator_request_id.pop(self.username) - configurator = self.hass.components.configurator - configurator.request_done(request_id) - else: - #By returning, the Device ID Entry screen will remain open for a valid entry - self.trusted_device = None - event_msg = (f"Verify Trusted Device Id > Invalid Device Id Entered-{device_id_entered}") - self._save_event("*", event_msg) - return - - text_msg_send_success = self.api.send_verification_code(self.trusted_device) - if text_msg_send_success: - # Get the verification code, Trigger the next step immediately - self._icloud_show_verification_code_entry_form() - else: - #trusted_device = self.trusted_device_list[trusted_device['deviceId']] - event_msg = (f"iCloud3 Error > Failed to send text verification code to Phone ID #{device_id_entered}. " - f"Restart HA to reset everything and try again later.") - self._save_event_halog_error("*", event_msg) - - self.trusted_device = None - -#------------------------------------------------------ - def _icloud_show_verification_code_entry_form(self, invalid_code_msg=""): - """Return the verification code.""" - - self._service_handler_icloud_update(self.group, arg_command='pause') - - configurator = self.hass.components.configurator - if self.username in self.hass_configurator_request_id: - request_id = self.hass_configurator_request_id.pop(self.username) - configurator = self.hass.components.configurator - configurator.request_done(request_id) - - self.hass_configurator_request_id[self.username] = configurator.request_config( - ("Enter Apple Verification Code"), - self._icloud_handle_verification_code_entry, - description = (f"{invalid_code_msg}Enter the Verification Code sent to the Trusted Device"), - entity_picture = "/static/images/config_icloud.png", - submit_caption = 'Confirm', - fields = [{'id': 'code', CONF_NAME: 'Verification Code'}] - ) - -#-------------------------------------------------------------------- - def _icloud_handle_verification_code_entry(self, callback_data): - """Handle the chosen trusted device.""" - - if self._icloud_valid_api() == False: - return - - from .pyicloud_ic3 import PyiCloudException - self.verification_code = callback_data.get('code') - event_msg = (f"Submit Verification Code > Code-{callback_data}") - self._save_event("*", event_msg) - - try: - valid_code = self.api.validate_verification_code(self.trusted_device, self.verification_code) - if valid_code == False: - invalid_code_text = (f"The code {self.verification_code} in incorrect.\n\n") - self._icloud_show_verification_code_entry_form(invalid_code_msg=invalid_code_text) - return - - event_msg = "Apple/iCloud Account Verification Successful" - self._save_event("*", event_msg) - - except PyiCloudException as error: - - # Reset to the initial 2FA state to allow the user to retry - event_msg = (f"Failed to verify account > Error-{error}") - self._save_event_halog_error("*", event_msg) - - # Trigger the next step immediately - self._icloud_show_trusted_device_request_form() - if valid_code == False: - invalid_code_text = (f"The Verification Code {self.verification_code} in incorrect.\n\n") - - self._icloud_show_verification_code_entry_form(invalid_code_msg=invalid_code_text) - return - if self.username in self.hass_configurator_request_id: - request_id = self.hass_configurator_request_id.pop(self.username) - configurator = self.hass.components.configurator - configurator.request_done(request_id) - - self._setup_tracking_method(self.tracking_method_config) - event_msg = (f"{EVLOG_ALERT}iCloud Alert > iCloud Account Verification completed, " - f"{self.trk_method_short_name} ({self.tracking_method_config}) will be used.") - self._save_event("*", event_msg) - self._start_icloud3() - #self._service_handler_icloud_update(self.group, arg_command='resume') - -#-------------------------------------------------------------------- - def _icloud_valid_api(self): - ''' - Make sure the pyicloud_ic3 api is valid - ''' - - if self.api == None: - event_msg = (f"{EVLOG_ERROR}iCloud3 Error > There was an error logging into the " - f"Apple/iCloud Account when iCloud3 was started. Verification can not " - f"be completed. Review the iCloud3 Event Log for more information.") - self._save_event_halog_error('*', event_msg) - return False - return True - -######################################################### -# -# ICLOUD ROUTINES -# -######################################################### - def _service_handler_lost_iphone(self, group, devicename): - """Call the lost iPhone function if the device is found.""" - - lost_message = ("This phone has been identified as lost. " - "Please contact the owner with it's location.") - message = {"title": "Lost Phone Notification", - "message": lost_message} - self._send_message_to_device(devicename, message) - -#-------------------------------------------------------------------- - def _service_handler_icloud_update(self, group, arg_devicename=None, - arg_command=None): - """ - Authenticate against iCloud and scan for devices. - - - Commands: - - waze reset range = reset the min-max rnge to defaults (1-1000) - - waze toggle = toggle waze on or off - - pause = stop polling for the devicename or all devices - - resume = resume polling devicename or all devices, reset - the interval override to normal interval - calculations - - pause-resume = same as above but toggles between pause and resume - - zone xxxx = updates the devie state to xxxx and updates all - of the iloud3 attributes. This does the see - service call and then an update. - - reset = reset everything and rescans all of the devices - - debug interval = displays the interval formula being used - - debug gps = simulates bad gps accuracy - - debug old = simulates that the location informaiton is old - - info xxx = the same as 'debug' - - location = request location update from ios app - """ - - #If several iCloud groups are used, this will be called for each - #one. Exit if this instance of iCloud is not the one handling this - #device. But if devicename = 'reset', it is an event_log service cmd. - log_msg = (f"iCLOUD3 COMMAND, Device: `{arg_devicename}`, Command: `{arg_command}`") - self._log_debug_msg("*", log_msg) - - self._evlog_debug_msg(arg_devicename, (f"Service Call Command Received > `{arg_command}`")) - - arg_command = (f"{arg_command} ") - arg_command_cmd = arg_command.split(' ')[0].lower() - arg_command_parm = arg_command.split(' ')[1] #original value - arg_command_parmlow = arg_command_parm.lower() - log_level_msg = "" - msg_devicename = arg_devicename if arg_devicename else "All" - log_msg = (f"iCloud3 Command Processed > Device-{msg_devicename}, Command-{arg_command}") - - #System level commands - if arg_command_cmd == 'restart': - self._start_icloud3() - return - - if self.tracked_devices == []: - self._start_icloud3() - elif self.start_icloud3_inprocess_flag is False: - self.start_icloud3_request_flag = True - self._save_event_halog_info("*", log_msg) - return - - elif arg_command_cmd == 'export_event_log': - self._save_event("*", log_msg) - self._export_ic3_event_log() - self._update_sensor_ic3_event_log(arg_devicename) - return - - elif arg_command_cmd == 'refresh_event_log': - self.last_iosapp_msg[arg_devicename] = "" - self._update_sensor_ic3_event_log(arg_devicename) - return - - elif arg_command_cmd == 'startuplog': - self._update_sensor_ic3_event_log("*") - return - - elif arg_command_cmd == "counts": - for devicename in self.count_update_iosapp: - self._display_usage_counts(devicename, force_display=True) - return - - elif arg_command_cmd == 'trusted_device': - self._icloud_show_trusted_device_request_form() - return - - elif arg_command_cmd == 'event_log': - error_msg = ("Error > Event Log v1.0 is being used. You may need to clear your " - "browser cache and then refresh the Event Log page in your browser several times. " - "The latest Event Log version has [Refresh] [Actions] buttons at the top of the screen. " - "You may also need to clear the iOS App cache on each device. Select " - "HA Sidebar>App Configuration, then scroll down, select Reset Frontend Cache and " - "refresh your device by swiping down from the top of the screen.") - self._save_event("*", error_msg) - - self.last_iosapp_msg[arg_devicename] = "" - self._update_sensor_ic3_event_log(arg_devicename) - return - - #command preprocessor, reformat specific commands - elif instr(arg_command_cmd, 'log_level'): - if instr(arg_command_parm, 'debug'): - self.log_level_debug_flag = (not self.log_level_debug_flag) - - if instr(arg_command_parm, 'rawdata'): - self.log_level_debug_rawdata_flag = (not self.log_level_debug_rawdata_flag) - if self.log_level_debug_rawdata_flag: self.log_level_debug_flag = True - - if instr(arg_command_parm, 'intervalcalc'): - self.log_level_intervalcalc_flag = (not self.log_level_intervalcalc_flag) - - if instr(arg_command_parm, 'eventlog'): - self.log_level_eventlog_flag = (not self.log_level_eventlog_flag) - - event_msg = "" - if self.log_level_eventlog_flag: event_msg += "Event Log Details: On, " - if self.log_level_debug_flag: event_msg += "Debug: On, " - if self.log_level_debug_rawdata_flag: event_msg += "+Rawdata: On, " - if self.log_level_intervalcalc_flag: event_msg += "+IntervalCalc: On, " - event_msg = "Logging: Off" if event_msg == "" else event_msg - event_msg = (f"Log Level State > {event_msg}") - self._save_event_halog_debug("*", event_msg) - - self._update_sensor_ic3_event_log(arg_devicename) - - return - - self._save_event_halog_info("*", log_msg) - - #Location level commands - if arg_command_cmd == 'waze': - if self.waze_status == WAZE_NOT_USED: - arg_command_cmd = '' - return - elif arg_command_parmlow == 'reset_range': - self.waze_min_distance = 0 - self.waze_max_distance = HIGH_INTEGER - self.waze_manual_pause_flag = False - self.waze_status = WAZE_USED - elif arg_command_parmlow == 'toggle': - if self.waze_status == WAZE_PAUSED: - self.waze_manual_pause_flag = False - self.waze_status = WAZE_USED - else: - self.waze_manual_pause_flag = True - self.waze_status = WAZE_PAUSED - elif arg_command_parmlow == 'pause': - self.waze_manual_pause_flag = False - self.waze_status = WAZE_USED - elif arg_command_parmlow != 'pause': - self.waze_manual_pause_flag = True - self.waze_status = WAZE_PAUSED - - elif arg_command_cmd == 'zone': #parmeter is the new zone - #if HOME in arg_command_parmlow: #home/not_home is lower case - if self.base_zone in arg_command_parmlow: #home/not_home is lower case - arg_command_parm = arg_command_parmlow - - kwargs = {} - attrs = {} - - self._wait_if_update_in_process(arg_devicename) - self.override_interval_seconds[arg_devicename] = 0 - self.update_in_process_flag = False - self._initialize_next_update_time(arg_devicename) - - self._update_device_icloud('Command', arg_devicename) - - return - - #Device level commands - device_time_adj = 0 - for devicename in self.tracked_devices: - if arg_devicename and devicename != arg_devicename: - continue - - device_time_adj += 3 - devicename_zone = self._format_devicename_zone(devicename, HOME) - - now_secs_str = dt_util.now().strftime('%X') - now_seconds = self._time_to_secs(now_secs_str) - x, update_in_secs = divmod(now_seconds, 15) - update_in_secs = 15 - update_in_secs + device_time_adj - - attrs = {} - - #command processor, execute the entered command - info_msg = None - if arg_command_cmd == 'pause': - cmd_type = CMD_PAUSE - self.next_update_secs[devicename_zone] = HIGH_INTEGER - self.next_update_time[devicename_zone] = PAUSED - self._display_info_status_msg(devicename, PAUSED_CAPS) - - elif arg_command_cmd == 'resume': - cmd_type = CMD_RESUME - self.next_update_time[devicename_zone] = HHMMSS_ZERO - self.next_update_secs[devicename_zone] = 0 - self.override_interval_seconds[devicename] = 0 - self._display_info_status_msg(devicename, 'RESUMING') - self._update_device_icloud('Resuming', devicename) - - elif arg_command_cmd == 'waze': - cmd_type = CMD_WAZE - if self.waze_status == WAZE_USED: - self.next_update_time[devicename_zone] = HHMMSS_ZERO - self.next_update_secs[devicename_zone] = 0 - attrs[ATTR_NEXT_UPDATE_TIME] = HHMMSS_ZERO - attrs[ATTR_WAZE_DISTANCE] = 'Resuming' - self.override_interval_seconds[devicename] = 0 - self._update_device_sensors(devicename, attrs) - attrs = {} - - self._update_device_icloud('Resuming', devicename) - else: - attrs[ATTR_WAZE_DISTANCE] = PAUSED - attrs[ATTR_WAZE_TIME] = '' - - elif arg_command_cmd == 'location': - cmd_type = CMD_LOCATION - self._display_info_status_msg(devicename, 'locating') - if self.iosapp_monitor_dev_trk_flag.get(devicename): - self._iosapp_request_loc_update(devicename) - else: - self.next_update_time[devicename_zone] = HHMMSS_ZERO - self.next_update_secs[devicename_zone] = 0 - self.override_interval_seconds[devicename] = 0 - self._update_device_icloud('Locating', devicename) - self._update_sensor_ic3_event_log(devicename) - - else: - cmd_type = CMD_ERROR - info_msg = (f"INVALID COMMAND > {arg_command_cmd}") - self._display_info_status_msg(devicename, info_msg) - - if attrs: - self._update_device_sensors(devicename, attrs) - - #end for devicename in devs loop - -#-------------------------------------------------------------------- - def _service_handler_icloud_setinterval(self, group, arg_interval=None, - arg_devicename=None): - - """ - Set the interval or process the action command of the given devices. - 'interval' has the following options: - - 15 = 15 minutes - - 15 min = 15 minutes - - 15 sec = 15 seconds - - 5 hrs = 5 hours - - Pause = Pause polling for all devices - (or specific device if devicename - is specified) - - Resume = Resume polling for all devices - (or specific device if devicename - is specified) - - Waze = Toggle Waze on/off - """ - #If several iCloud groups are used, this will be called for each - #one. Exit if this instance of iCloud is not the one handling this - #device. - - if arg_devicename and self.TRK_METHOD_IOSAPP: - if self.iosapp_request_loc_cnt.get(arg_devicename) > self.iosapp_request_loc_max_cnt: - event_msg = (f"Can not Set Interval, location request cnt " - f"exceeded ({self.iosapp_request_location_cnt.get(arg_devicename)} " - f"of {self.iosapp_request_loc_max_cnt})") - self._save_event(arg_devicename, event_msg) - return - - if arg_interval is None: - if arg_devicename is not None: - self._save_event(arg_devicename, "Set Interval Command Error, " - "no new interval specified") - return - - cmd_type = CMD_INTERVAL - new_interval = arg_interval.lower().replace('_', ' ') - -# loop through all devices being tracked and -# update the attributes. Set various flags if pausing or resuming -# that will be processed by the next poll in '_polling_loop_15_sec_icloud' - device_time_adj = 0 - for devicename in self.tracked_devices: - if arg_devicename and devicename != arg_devicename: - continue - - device_time_adj += 3 - devicename_zone = self._format_devicename_zone(devicename, HOME) - - self._wait_if_update_in_process() - - log_msg = (f"SET INTERVAL COMMAND Start {devicename}, " - f"ArgDevname-{arg_devicename}, ArgInterval-{arg_interval}, " - f"New Interval-{new_interval}") - self._log_debug_msg(devicename, log_msg) - self._save_event(devicename, - (f"Set Interval Command handled, New interval {arg_interval}")) - - self.next_update_time[devicename_zone] = HHMMSS_ZERO - self.next_update_secs[devicename_zone] = 0 - self.interval_str[devicename_zone] = new_interval - self.override_interval_seconds[devicename] = self._time_str_to_secs(new_interval) - - now_seconds = self._time_to_secs(dt_util.now().strftime('%X')) - x, update_in_secs = divmod(now_seconds, 15) - time_suffix = 15 - update_in_secs + device_time_adj - - info_msg = 'Updating' - self._display_info_status_msg(devicename, info_msg) - - log_msg = (f"SET INTERVAL COMMAND END {devicename}") - self._log_debug_msg(devicename, log_msg) - -#-------------------------------------------------------------------- diff --git a/development area - v2.2.1/iCloud3 v2.2.1f.zip b/development area - v2.2.1/iCloud3 v2.2.1f.zip deleted file mode 100644 index 4a227c5c091f478be0eefbc11847fa85e5baf94b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 105874 zcmV(xKmOV_{Y*rch5{uPfvHxYhz>M@uTxukWQj_?xiLsh?GK$m0OGd$BFt+I4%1*dOT6GJTBSgnCf-sKY2jNK zB%v3FlQ8zu2-!f0Myq+aNP~15ExaIo{OBf4m&x9fCpXbN+zOIpnxw%ZZBL^oqiDX2 z764A3jKkaMDD=}f7`+MO_H;xQdGh!X{co@gN7G>DokeN5=k>fhpo`*y#T)NsFiXAT z=x*Hdnxg)@-XxfZo8H|`iIy!d zO>U?7H;(S#gtHmSy9#ErD7tESr}y!Ae81R{HJV2&@EY8t0k@gi<6`|@Rv5=oj9-h}U^X3ZErL1Ii^Hw(?I>KP1+>yA+L{N8`>i0pW_eFqo=!_pg)pCZ z6$gvaO_)>>n?<8wW+TO6x{4QuU=j9eu}Z?Ru1L$13AaH!C4i~|AtWiPg>jjt@$?Gf zX_pQps_)}R-|b!udi^tR-)ro)ciTIY2BkI{_;V`~9zWU=pU01O+87hp-emgL+wzX4 zZ^JRf%VQpc#gO0eYJ@b1kH94*6U<1g?xyLD7px#}=%f+#k-`JxMsENDN!x9E=Ru4v zN<&Hy7S|!lIiN3~+CUR$!M*=8=oOzja<75*aPm^8Pbh=a=RrEUfy4)?l4WRs1WJsm za}`dY0#z+h^i!yb2@eM%;}I1PIz-YA#}J_ujhM}V>cxZ&`e`_pl#rVNZ+oO>!?(eF zIfKA8K-f=i0GKos6$9*{T8-gNI1m7B>Ht}@EbqIE8}ukk3;1%m90#a@7{mR6*I6z- zRP`WY5KkaH zi!erwDM>OmUux3!E=iXVh*P7+G)jWyvf+iZ>3q6Cc?l^4MJgDLNX2=}Xhw3=@?yXN zBwEbw@eh+kB?Hp#x6?6oz&$5|tOvTJ?o=KU2%ScXQx9%Al@=z6oZoU)NQebgN8<5# z6%y3$B*+QzliM(!PUH_@B&!h3@1)*hdVQ0&GM%$ZR%q!xW}a0_f_NwRZ+Zs!;Os06 zmu>GQaYAxT4FJH9;EK8?qc{u~ zvW5$cIA};v#CK!_2`H}6%9&NWyWkBfvSJ?F4bycLHN+qmoo9`g1o}(z+@+V{Ql=Cj zUqg=(fhA@*(3E|pC0L1fTbU3On;1*+N*X|-=qrf9b&L)cLwX+Edy6RLQe~m4w6dbm zzrBSpvqs=kw)XQ?lFBBhL7b8%7^2lJ)dhklV`#XM7?O=yu)^fgfPhY~ufy0hTa5w3 zF?1)3^fio(7%hRl#adIT0;4Pm2W`41ZAKjf4TX&HjB+B7Pto7XNc1Ae@C;)IWeIqg zf2jL(M8%-G_oCzOqZNqUVj$c_0#0gmZv>H}nx{%n7^Vr3@pJ+^k_0oE{wz$g+K~vy zF!Sa@_bQ@sFrKawGE8>1x97wbSp#9cLFAC45vXKIBdg+Ea$g2H?iCNs1IHOd^vg{+ zTPjhoyH#p=ssL-nTyYriiwG&IO*As8qF^X-XPVwqy9F!@jwp@vh#J83gCQGQrOH5;Z0Q! z((9UslXx0JXzo>wIPd$&6Zl!f-eXH<6UIR}L$wRE(Hd2xX5f6Wq@N@7=i*w(Zpoya z#u!HdkGPjYAb_cv)w0?p=*9u{7<(EZlW%P+JDop%^sIpUDJDu^G-J=(*?qG6%&;8X zS%O@mII&txQ$L#cbLiGpEavd$e7e80+w$Jb_jjLdLMAg+=5ZO*JXT78D&UC0vW5#(1D?nV$lA`Q2^)OEsAR2Z8cu@M+}SjWlxt1qcSr+lLS^wR5ZuBe zCUZe(yyK41;~q3tb9a;Gx?oPd!Fm9&+yQI`zfHj)Y9UO5hzN`RfhRK~*YAm8nVFTE zBRG7}W-;{u+XOAEVqm~Dh6pauK=$^+bkyE#*$s(S7;RTPIss4GYV$_fkGU*3{BcSVy8k%i}~<|qkDtkC7LohBH$kUTdG#$Xpi z7kPK!avI-j+fJLT&$dM82Pw+mQ5DeE3M+B_i>Ybrg;WLH>o2#w9sKun8-KkU(cV;9 zA@ks^ud$oYwr=8NQyKvCKxn%-bgUpYb=Zs`qjXBzAM%wk;h>1!u zEXeXsR&0q*Rx{1M3@c={1WD5h9j!9EyM_!ro;sJLRlJG!e*^_%SZ^)vJ79_DzQxAH7 zGQIYvqo?irU_L_;LXZf?kOEj7Ly64piP$u?DI9kskyXs>XoCdR`OB_j+c=5P`g0Q9 z2Gvzt`B~Aui{dxZ#z0Em6B0p{j45$ukuniC)Zr^_BS+ReCH0ReV|vaUZ$>e`24kTL zQ0+KLNrR$vo;U=VBwQrWg>T=!%_?tCM+A)e2)xEtg9j9SVV0qrqFb6yJBXgCUm(m7 zWJBIz(st^rWWyA~$=V~gd6BD@bgD%x;Hx~}W_#-awoh{`rs2(oXj3i-`2+EruwFMj za_7?fN)yNzRskvV(Ty77#z0Vmc?KTM5VOK$7Yoy088OB_5mJdHwrP|Jva`Llv+K>+ zGEby7j+Qc(HQ|=ESn5=v`CvtwgtLidHd20;!QkrG(3?EulZcIU_Bh7j6=dH8qIfJW z??s@NN89qALMmv~GR5fiM2^x;PlUe&og3Z~y0CEPf})kd7@GN)%aEo& zo*mTOu?WL4mpzUkL^={+JJ@K!Fk;0j%sViLRG4TgW}@g$xQOghWM#)_jJ?N?M8oj~ z;ZtcLvq4G;$V(^(pAnq7XzrU>whUfBZQI=06AXO($XH-k84HXQ3I`)D!fTS^F3maN zSb2Oxb5XaF&{%FVl0*nkDQF0+HN1Z&bgv$XMrx(ALqn0BCrwdiu#q7MAUop!K_0Af3%*kbQAh%>fAq|Bk#S~Sei7<-q1&c0( zF{`*QnmWbQj3pQ_RyWah&O~Rs;W3mcho+pH80ITmk5;GKcCYlmXZ%b-9iKDry;fVqm2(vn|O)a)qxu?WDpN-x` ziz(QJ3P4Ik(X>u5y=QHwU3K5 zab90ctCkZhh6xsLNED<_F>^0eJ<`y*9M;LyhL<4C?L2AoYo6ipJ=U34z!HUk`fAPD&|N2jV{XPEi{{EX^{r%T} z{`+r!>HYoJf53l#{QIx}^7r5Tg0dnuD)*6%U-*}ll~_Jw3jqw%zoSHdP42DlH!CvmiT$z84OB#2@4~Hgn{Y&Ubz0Avu++Eb3(QQfT z#4fL-G0ALoJ)PX^@?6aJIL8Rm1F27073H;+;>?z(C-D+g%lgS0(F(umE)k!fbcRR$ zi&KAaaNIrZASl*9?exxCUS~MG@ado4^UGm(unBSfutE19!fU8U zp4z;raz8o3H4%ZAY#m`?QHUdw4bbWrl-h!nnXQ$#k8cW0`u7G#=?{qvGCIp>)$lF& z*+w2n?copj%oOW#TM{p1x+Hhp@ejM-?HzRe;YH`*+uaNQr2C?C@X9~!A9lTcc3c1| zR@=QE6Y5fo?nCNR+WYHj!9F4P{&#~Zk5Cbam)jkzz!dWygwdhgn~xs}-kAYYwBJMI zzwCU!>-SF2`xirhaCvah9Sn{xPc#z>cu<9>sx4raV9(=6sy3Q&8Bau2@cdp(9n$`( z0(S?n#y$c^!tqIj!PG^~g)dqRdKQTuNh$$NjSvH&ne~ z=is1!c{UV0bE+=g7oHIs663{1|MFadxEQoryp*6)%LvjgsL)m!+6J@;!GaW;t9SOo zKkW{W`-eG56kIJ+$RyGO{d_>`;qrn^gW>T-cW~T4IaGLXdwu2h`sqy^!u6l&kp@5-UffI<7l6L9 zuU*{Ui?jYkR~0WgY$WG;0UhaE#Va-|{NvuCz&_~1qv@S>U^|=k-b}{=dj!{iEcR3> zOfoGLHbG{u*>aJ{U~zfYriEs$FfSf9F)zm zq*7K%Uds-<-y8ZJf#ho06ag?l01`O@YSqVVk5!w@y`%Y&_XQYb^TACFgOQz9Y44Go zqGG4#?o>2LvG=Eu1wZaz^f1nq-|NH zSkLjJd+|zf&P-&qsB}MIjuoRwIm)A)ko;sZRPP*|Aim;s%K-wHxgxUNnMWwB)JgxP zf8KxDy$~X2B>L_=!m{-#Bu)0AAR>aIrXvM4t*wg(z0%gIY!j6zf;Pkz{6*)mr`qGf z^FoV;J-X<1&kj#s@tAX9GmCT*rJmg=m^KjeP>Icx^k-uUn(e!?ku^demW~lg2r^w! zJ27<`NHc1RAbFxr?7@TT4a@B^ijDhL5jD+s0yPyW1XUGr!TDP$IJZE{(F+AwC`Z>C z5aLD=s}exD)+T|~IL$yg!&L*jgW3gFlY>Dqs-p}gu}5otV_OS?p1cc1Mq3981q$$> z9EC#tc@#D%6iTr{QHbL9uWL*y1e(tLzA{1`1Wae-%?Lb+B4KsK3d_h>?1A$jq2x5D ztMcyrC23HetR;^2J0)C+->IOD11x<{18p2muA3PVm&0Q+b)b#AZY;rCDh$UN4RCpW z2n^9Rt_I+3XhvOjzgK}kD_;h|lyK5Fb#hCPJD-fWAN9)zHZ4X<$c&p|fQsA<3qykx zVwh^fWV}>e>sA};8iuA3hqD(3+!cq#bq4&r51)~PCq>}2a_DpLBs*O{RWY2xAkkAF zMoVJmSvGBoxRbST91e9(su~{5vJHRUIV3e)sxHS`4I5iE2bZ%|HII4+!^?{-ZO3NE zCO_IJBPP)oA%fJjG#SxS9Ut^M?w3vO#;J=~;hgn{K0#MN(xy)fn%wR+(8BjNF1rKg z`t8*Gmz`IzWcsJ&#A&Dc5#r1env<)c0eRlR%H3flltRS>YV3#acQ5*dpxgLt@&CQ;?LGcK z5RZ>fPX_~qRf1@sdoPZ8HZC#aRiUrESm43SI`%C~g};3Y-gV4AsI)3yzmH z%QZ>zAn&Q$dWaAAIwvN)hH1RxW&2J!MR^L2UxR$HOO~MOc2Z#4dMv>a6F?wnbSjTsA%P^i;Hp_KZ3MwIUN~pU&RgQ%A(@F?9wziA! zTKhQ|W#F%cG4^XNSKIG0eZ5+*r7$4kNc|*&FEn(i{OhH~f(A)kS2aIm^nReQ=O>md z$ePG#7T67kmVuIMl1#}^9o{d)2AcX3+fwc3zPF4D+eQFWf1sgc+{&h_j~46{C+`*~ zKedxhyvj{A^eggq-Mqq*oM@WKvKKQ=YCh^>a#V1|%`q_)H^+oZ?DTv@#N_B0Q9f}J zSqWKZJfET6dXui)eK*~Bum$xlYLf}WnKTn^GigqQ%w*;KeUo8)U9TWlIH|d21zEc1J`PL*Y z;MSh};*9;zZsDU&PaTRygQ470*3k!*h9M4b^ETxz`sA2P>el5g7yA8j#UZmxHHgeF zRV6aNOs7!x34VCrU!!1bz-o14Lslyv8?sUrsYZCiYUM<5&+!8Ut2C7+ceq}40rUU~ z-r-uAeu)GF<|;J?8uCwWN45MOPGfP9xk%wLM6XQwab}eg)OlRT1WGa1PL&?cKz~;zYLsGi^Snq) z3y$J%nGGUsoBmmeKADfFdvq|I4_0nX`H$PlHuOALZjB%wZ?>>8l};r)1FRUlV$dpB zL3X;R8jptRJcyxGw`v#_HIXn*A6}9fwHn3dPQ}>O;L)5mZYqS*QEW)_*(zr+D;Vpr zlA>8Uz7-CRIMTj|is9{onc+AY*wnaehV;zDkPrv~K%9aG`534n6nZNxgtXsjYlEB-o42pMI59J0#Xxg@3R zO3yBs0YWPbNAF<3BD=!>69BFbsp;8h?XYl7jr;}{;~O?U|7z>=^R3U1z0V)-eg1Us z^Mm&1>C#6m<)3u*DVkU3ybp>3Bn@SMc=#5K#ovhk-qDQ?oFKocRbNvZ_haJo^i2{x z7X)8wGUnR>6NIySbb5r{zNdp@v$YF58M`Nk9LKS`fy7LG7t7rGhci;$PvQ2wmg=TV zTKVR?*W##Iv>+@#+5UttSkQ-N)!9>#@c5C4Klk-cMmOen1|oajhFb33Fb%L00YJ;D znM}FlOL;hWGr%`HHvo7cnqK_n?1B1~qa6k@wT_A7JGo^JfL{gi-r|#B(E=tC?}aza zVy&|XW)3ICGB$ILIOwuXw|UPn!jvwXUB=eH$;{F{rnHJaxv9z@*!(rz&UHc^AkEf2 z(;=k*rbC<{i98x6FL;u>q7!VfJw5b!)mt9S;S~FTz^+K)rFQ}IRHR^+JQjyJ8+jcI;y#1Z8ZOdGI6WK!Ou&`}fp(|(dPy62`2^GR=%Fmyf*!lJ;RRaK6YTN$W z*T42u)aJ8;)fH{SN}u4a6M0@z-aKwoGPuoV7LRc0I0%hnk3ss}Idh24erbx%fa_%n`n&juV}5*eg6eM%^tk&D3jW z?r>(wiL+hK?Ba0siJSS9GR0P?%(f*l963{rEIE2i=9lfv!R66W?|UZmE0oD{G;quO zD)WUyX~JOQ1JW47)4 zmx?=o>k10c_h4b~RKL%LlQ>9pHZ&5}2KQ)_rWgY<&*XxM7hRlB(HdXy(chtoc6+bA ziyQWy?cuh)ukGO*F2da7`}V%F_Z9rv-QL^TrN7VicAst2WUsEV5PL|dxMC3}z(3_XC_lD)J`BH%5lD$N}m@tZFbi2?5 zqzHnJwg?duxAGcDeJ`9oasN5|w}s1bf){k{Dy4jpQ_fd0?yglppQEJ1Xbi9EIV2N6 zU!)ZJ{@rPWi^??M=VY>w7y|qvQu-|07vWeVJa;ITk?Q5=MV86k04_(YB4K-~X;eHytwBQ}_K@2D>;br78f_ZWi3nXbO(HWuX z?0#!-!_$fqiVUK@@tpB+i9}D{_;S0`{_N;-S&zM-Vc!2 z@^=ClJXYtst6iW8Uo*=q+aH$Y0Y-4elU%VtWMKf2EphX9 zkZGEk@oF>m8yw@zeR(bCM};*LXS&L2R9qpQm#>XAVHd3qD!;jKLt3LePq^743x7?9 zcC+`Cf1JrI&tr#OQp8`^?w94aPiZ@+|TTgz7%qP^7;rNNViAa5s@a^PoI)OTP zj&lTb7U%5HKk5z-jx&<{T;IFy9fedqz<>LN18U&kTw9&nH&&J%(CLYb-vEZAv6m9c1v?EED~3AhT%P56%ING9zQvwg$^i~L z_l~ez=S)uA$231nVgsCY=Gz=GohfOO7jHEjR55#2p^6ntiE5W7KL(XhOhMrQ@))yX~`ea6+KBNZe|sMZFz(_ds(gY4`lp7 z9B>`*EuZiV#JmhmKo`*K3#8#f1Y_YzGPvJ={w+cQDTO3;hwahOReh{*$gf?|O{&>4 zl|A{|WN7NFP2TGadGnUqcPet_orIaKs0agPIj+t!;#@Z^JJI3kyF)uAdsw_^L0IKv zPp;GNDTFF_biC!1EN@{ITQ<~s3i$}Ldoq2pD9T|iOgspdh!+6CBy|^*B9=K4p1E>H zaf9yGPD5n9As~K5hcPKdAkO$((e5eAOHyi=F4`n<(VD^$Szj{Ap}-8+$fhK>o-e0M zhP%Vx-r_ul4TU>vWlC`+spY4^sPS6eQ7IqE;Gl=RZ|?^icGqpRybY&sOrq#-`n{m^ z*K0+r>;?l<$MVIT5}@1>j~tLz<0jduo z$ZsU&vYt`aYQgu2I9j&Br8P2>67JEYTFT2Cy?wftvpv9Of_cysRppfalGh?;O>>9V zCRT_?w{3ZYWvdLcFc|Pd#XP*NX&Bg=5Z5c^3 zxoFfbp`kf-zeE0GiL*oapL4e>p zvsn{_!UbKB=F{GumiGf)vDcd*Er@@1i#~jl@1NDVQZ)9o5i2`w5|Y} zi`xwPb}eq^t{<~X-^B|qHM_*Qb?w7?CUXhfhmpXjDQe5J`fYJ9J2Q0JU#Z8`;)~A= zot^D^bc!!Qv)Z+_L(3KQ8di8KnxTbN)S5glx*qL=I!oWE^| zqs-6i<_Kodv<+0At`&CiteiU+@6Ne705hYSVxBq0xD)1{*ZzvCZN+@JhpjFe%$@EG zn40D!Z24>^o}`jsrE`_rNHY_mFuR!isowkqqhQ^MU=LB8vdm1Lt77H;V?{e}^k%D{ z*RMdn94ORc^+|l!jL&ID10wSs<(Dgejjr1Bl#Y_{<*EpvU$Y@AGz~zp|6yA}R{9%& z&HEV|&vge>znRs#{g3(qL9viOcr_SyPd#yG+z~>{ykda7Pf#Q@pGVJXT}>B4&m~PP z-Ot6>O?k(9Lxm-Vw5=`hD3i8!F_85jBC_a_y;-Or=p{b=FieW4M%yO5@dNA=Ze6ty zifa9!F>ZyeN#pfvOn%?+kkM#0uJGRo|Bdlqi2snCtIZ<3<9bqjN#yaqAWGslB}_y1 zOtJji=gI(&f3*?Tnx;*Zu_@pbB?C+3%rBdsU6D4wrZ#RG$W1xpO9p93aT4|;d}I&8 zZb-S~Ap7)ZcXKaO?quT~=f2laCTYv`TkMWPD%B|4 zZzAUDW0v;z!6DdU+zMfqdGCnc`wfE;LbFv6XqHew*3aMF?!5o`7XIwwk0$AUPzwM( z6|m35pD(}v`GzHz@^zIqov&!>@;5(ncTN`|+LZPS_`MkW;>YHm;c}kFUd7cgG8mwdmj9YuIXn;mJY1xqzXeJm{zcD zAri$y6(x-L*iBiGF45gYKZJhT+%xJ>sV|e886Iv25RBlw1a#ZV4f({46UXOhXJu3E ztZwl;o${+XqJu=4?WfT`q|ktwBum{KF1iVqU&C>2|$gz$_pe6#_6Zo_~*?h;_aGF*FTHaSS-?u!OA=_bc zWJwl+Q;;ZyKCIR9Wt0>ksK3iJ^!+=l-tRZ;9@hnZ0+U2qLBjp>aAB!hef8u!?sa=lVOq-xQ&es9C^V@mG+bS zxqB}*Wbe@?$(XmWXFmI)NPb2ux9nk_wYkH41J!6|0Ysq5b#U`>(X8bUiKEYhR^yoW zJS1Lzm#B+ZxLjhe&EqGZTh1P~v5711`pM-A9RA^w2;S zw|x0Vk|=mRAV;EfAxDuYdleu{rP8yKlya}$sC9Vt^bH7Nd%e{g>sGvU--yV#k%`Az{FYPFup3P8R3}Z^R zmFz6}0mHmTNtt1$nDvn!_#;vu?~y;E_K_a?BU0s${mFb|R(@9WsV%*B&O@Kzt9lYp zl9gunKUGW#eL=C-!-H0PHD2j^ZXfFlNW5i zSDVFTC0GbAF@5iO@6;>~E6Z{9CL1qk!~jZ+%Z*Jn(yQw!nPXK^Sds7KFO;=PsIqwx zF{6fxavVsBuGL%aO)2ujs-{0T+AIr!g3l0HZ)HHk=ZR#WyBxnXTdO25)2iOquR za#C*aTahV#m#Uad)pD;SSNv2J@yjLWuKGIUieIlX4R*=7k#f`4qPMKdSXRFHD~T1n zELYC|vPBR{%X(~%e%7#Ht+f&@>E#cp5&yGP)~&k9iWNTV#?;)3Rjj*7YQ-32R5Z0Hv)$SGS)y{Ki#Kagy0HEvT^*nK^2ZAUxnO|p`i_seR@ zOlDm}Qpr7OR)b1z`DU{{qrI<9NzZ!pt)?Vft2wo4SP*R(S*CdB3AuX3+v`OcO4G$d zDOt-(W(u0vHI^@{d-kE_vdOTw;5WbdRbX|tr}@qz_I|VNeV0MLt9QHZE#dI@PWF2Vi4!g!x!mupFvdYPD2mgDWZO9NY;n)+ z^8%fLa61S=Ig{;j{ez;Vf}p#p+!*&gam9br6iuNJ8T?S@PD{rjn=S7jH{qY(qO%>l?<^dONMiAcBU7LZ&(uvMTjEcG|m_i^NDS#Q&2xx zNdLvMlWFsV`#KJpK)ue+7FN>RA9K;Tysy!ZYT~QZQx@F6T3M-bOK506??d(5zfIx_ zwUyl?4^vqR%T`_wmSdEzEunhCY}NPQD#>&l&lguoR7x_z$s6vqB+J;bWE>wLr3Khe zAh!PDr2CDD{wa|*Q($^_8_#)r5yTwEcA-QOvBh~#@CL2f?K0d3f{36?)b zsBAJ>&1Q(`i~As^KchQyoShdRZR_FVNBqFBa_{y&+swq!Nxb!Jyu|c_x(1F`eyW$X z0GF{@eQJj9Hi`Onyt27_eD@DtHlCI=GDy{O0b+(aD{eB<}r%o$igVd&QmCmIh#U3cAi3+ zd;}vX% zv7B4#^kNnRBkh40Nv$$66QCU98Y7$SO0zpHsu^*A-IST5l<_VVlIQ~62URG!ek(!A zyhqNHLiVoK9Jsq-D+8{x%h1FNw)m+UQrecx6yb|=KUPV+Dc6P}BXmiZN;WsVjn>A- zrc_q=zPVY0w%U=?paTkdCh3ENzGXI64u_1{?F|CC8X!ll)OQpWDb9Au z6uXzYJ;mA1X8aQ z`qF1%Z$&yTqB%MiX;I^g(!abVMxc)GV)LwZ^Em}8MgP&=+&c;wN`ddav#7Cw&(~z$ zM$@red1UuvHXUK(W~E6edj<6lg!v+$Z^$9J%P0?~$~Ms_h!*rh|P}yfhvc- zKB4k8}_z;VCdx zX!hWo+YzikNJ-IOo^YC*1+bRwiotNTx+dQyjiXsJYoqJ#eu!^DQnVIpjT2+gj6{)N zLVMeARXe^|!c|rp9VU`Hfs*{?zKt3<{xXK7yv^ej2L8?AwL(?HutvqKhZGLU0}zxq zS_cJl#6#**7hG0B3sW5&VQneMIvS;rnm9R@^T?YOUVL7%g@!kq+kW(;9~nge?4mvC z-|(u>7QY+ghGNX>?L~Cg+@$MCCOje=pYOE1&tG|;A2&ANZxpvDxK;DrO+fO<+s8M) zxY05d1>ysQc;Dm4dU|KwqVj~fZ$+-|gZa$YuL^J=tGQW*6N?t zTpGMdv&_uSv^00nw$M1q=tpw~Lmvv=XKWP~_=epFYi*t%*pv|a(jE z&rqV*xRQ-(p^z$vC**4NVGQ4lma1XM7py?mGu%I<@wO(B`(^QDt3HvsmL2d{vTd{1 zVoLn2bq#cvYzkbb!jWetK8jW9G066|*!QGtYdH=}xn*1GNi$0~Q?xw4)TKY-GWf*9 zR$ocq*#xY>nt<7?YdbwyX>wE56qZqe)a{!!#{4eEygaY0rYTQMQ{*>|fq}awl@Dm>e-dO`=q{#1E3M0vS`b3ZE+VzLD!UTkiOLNf?ep(kmP5 zw;fG3USi^u?=x|E{&%OdBV_)^MS_aoo-c&s-K*OIAQ0-35w} z!jIKQ^>p@?Fb!7AGTHw`D!NpiNRzJlzhIX=1@n8-{8IeacPO+xNE~HICVc|?Oh%?v ziMG*kjm4VW9u*DOY|Nu8^1XrF$kk>MQDb~#V<#Ds2GMsRO`p39+ZLR>9gu=yRZfF( z+6|`IG7^r9Od6V3lmy6)8V|KE?86^YUo5Vr4Y^S`KUTePOPZ?x?G{*i=ji}&=Ax1U zdZN!PR`B~cMqlx`L&RV9JqnF!*U#*}T|lfSb~l@5QI+^KCN^7&B??eg0xAepIGANi zA(_p-?u1d=i?sRKM$YwW@g^IF7jfMm7~Iz~puCQP(N%KyMz-<>y{;7VTviT1cY5h0 z;}4ZFN%1!V%cGcxLB+n|#@1w3Rzqu2Xm?IoWQ0AJR}d4Z))B3IAFI-m)dY$-Uw6&v zkIn71b2Z((R#H_l^72(xdxSFVYIyB-+e1`#lRC7Py(6@V_DHqIEQ6YI*NQ2`uEYCJ zVo!XKI@ArID*Y>DImbpd$x5nF2oK|AJ&!_)%E86S(cgdZyZr4f4-=sGtq&-XZ4yQxp``b$SLC%&o{neg=b znpp0M7!VM1d0LK{sLC-~LjYjWD|qgA}V9#A%SkSfxNv8H6Ms8?3{ z2WSLtP#u-vj5&_Y=>Up0)wnS=XQ5AT$7M-;RvgJk7EibjnPOa>U${Z#vuDwrJ|C$G z-=MPEBgKg2>&U0cjWvjKWU_KHU<;>wRmSWR;Ik;SpZ=fT_aUsR#FsBikf?n)$y!FU z+(I@G1#h^$2UU6lZc(?5BN~y5NU<1cVr=JwwQHj)>lb|TQ;VzTzu}#^HEEZPgvo}8 zK?YY*%(2u1j(7YxqnmzgLL+>y=dIRi@?Y`|`LN$N zI#*ad|1lA-QpyV7G}fB)+Ybnz*48^U!jE560HD?Tpi7x~+!7_>b8h^{_523@JYYdk*9w(eM+7j-6O*VUTRSCQ)@o z2Y+yAqcCVlS0h`JKwGAG?~dchp}d_Eqxz2byvOzEl#{qK2gQCsE6^MDU|&A~wowIZ zSpFsO+>8_+S>je+YEeR*zmpuTqVbR!%-ZFw2l5ezeK$611dKD=RBOeH%SfmI@P`q} zZuPgDSZjOw<|kn&?Qwe$8_~=4g6^4&b;X4xVFgOC+;DiJSOGFS`SOg(J!VWSf8UL|x?8svWLKKMu zcP1BE*W|N5E|Nq$C)!o(A)m7EC$Zb}0W2tgFS~l&0${gV? zyg-rV_3-HZgW<^$mt64MBPw|WP=hKnQKReH(ya3yV-xD8V$TQC$Zzop_el|bMir6b zue0+C2)qLG7J+U;>L2*#^l5kx%wwhOSGG5aVTK0P35jhigmUcr0AWmld&`+@V9lDv zP>|ogv*=GmiF_QF6C~mNEDD2(G7RJBtycw*X7;P{{1~Zx3@HCQeVR0-YBzl9L)({M zjTZL`w70c2K5yciyPkb(F)6L$XM7uV485DrNC&c9_D$a;-B^;%NcQ<>aUUUgU)Ve3D zlfb?L-%kbW%*Wu@t%>QK13J{Ly4x+|vo-@%1*@rvqkQf|!eX#mo(dQKQ!bm-(|Nh1 z>gWsYX1WYXBqCCK{}Tujy8~2q#{c;HG5q}){(gKL|Ksl`w(W{7W~fWqLOgr1cy|V-CNEja-siAM;ntHHJdCY6BE>7K}bU{aBKC^vtws8W%()Y zebT)LJDtkf`x$YfPTX%Fqx;XxbNf#2!)ZQR@R#&#SZn%eB5S3If?b{g{ZmwGrjyrj zHRbpr@l-hE!iL;#uQqDU003xt1@+eVs$)8_u@f6uGUryl?o|z9A)7-l;JfXb1|f9V zhQ`<}FhkV5O|S-(nIY%4d;0~Hk9PQ0&*Cvv3?DwFUrP2tfK;age+A{{WVtV~E*uj0!0tQqXN ze#Q&S9*SDEHw1a36>%>LXt>(pNIp7AGRdmji;pLNM;Sd8v6d&J8lY_zrCxyeUYLf< zF*N*9uQZ?^JsgAh1>4rhE~W~{>U||Q!&R=Ft_16(uny^DDBWoMr^ue3MGxVl0zR2W zTn}hq{aUl%en$SBJM#ta80ugZNy*>M^~fCRAxf^axc4Lj{W5T|Cr25Ew}il>2Y28 zFX>BJg_Lai$NZ{v8yt#t81sXn9)8&AKFA(^*qs`H2EnRY^$M&#T>Y^y8VdtlVR zSIEY83Q0c@yS`@_&KFoXiALcCy@)>#-u*~a5)T4WUilF~C-1&a8bt&IE;5{%i%VB) z;E#2#PZu;QA>!Tf@NKdY#Gck{im*9Ku$QI{O-H3&-m4PEAkA*XKjppNv+1K9EPKoM ze3a4{VrldDDnxVZC0Gwg z;x)1nL*2?C4`g~EA}Jb3wb}B3c&}gvs+v5i>67@B^-Dn-UA_=i)`9;8CKXwv>ADpW z#}@ghKwJ7@4Z};g!*XM87=#GhhSFlU{6qw2x%F_uu#5 zc}4$=Uec?t>G4TxNh{%6f_$gaGjd-Hx`@aMS0DmVtRs1?K5zdC~yjR%2@w0R>< zIuMO|HC{)9T9+cXg-8BQ4uKta-oz=xcEe|Fc`~^GKGfw}Z}6c(T*7mm?TFsSlSQ#4 znL|Hf1+N)1f%2BSW}0|7rVEHS)xJF3{Nr}ZrjTuNsSE?ip_c83AtsYH47F@9j6}E% z+r^XJvN`M^t{m8Haphy>X#)vt)dvTsByUQRfaz7a_KK-U+56gSNO)c3oi>siX54tT zQeJN~)wkwCuv+A&`Q$1;V;6c>Yotgv$X2DBrxQ)cCfR5M^PDu%1?&It!+=*(1@!HH z8$4lGo1*F6T)QwbX6!KkI*iO9S=e|fJAc<>rsihKfQXPscWdDKRto~3&+yFL5Q}uN z3m^Fx0Yf?^%_A7xIA%JM=RbmF8@({(1{idr_PA!^=}Czudx@4&6>K6GwPb(da-I~@ zs7$L8w3i3^5k_Jx6gplnT~qJx)#KeiO3+g9ci^26uccOv`tp2%{4~nLTk;}j1CA&~ z(QYqz!~f!);4%KueX#Iro=NDeXYVo0PHOMcD%uH9{^RJ*DWYA(XyomMC~|VPx}5TB zv_Kon)yMM1Pg0E2SD?cXptb7;N?=pfH9v5$~z_BvxM8&kI+^am5jvVq-`e`Z6u+EZXKxGVto$r|(@8o>;y7|PZFe3_ zCrK!8jMMf^!#$;jca!$}ywH9HEiLDKGGlA($Bylb*HliG4yo+Q52+&vYV@#?@d*~T zdy%p&>pg#desNJ0{Rqa5m}i@|Q0%p_l%&?(fOu-};)Vw&;i74J^c!KpM#GEwn2rLO zLySVzDj}19oL5E@;;Aw=@FXwzslL{_#^J}DZJB#Qw(wJbRK_@;f0b48LP-}j!~={o z^3khwZSD^Uo?74bacW?$f zKnulX(+Oy=IqY@hW?1v9v;AWn%j}48e9BvCFF#>k#>318MxQ#+k|x(OCq=6`_3BRx z$Er3CR3>W$hx8r2#FPL*o?J$uiZRVg(X^X{w6aA!Yes%*fK)}#Bv3>^VW(?uvPPHG zUSufOKiU1MpUz(G>zjSHOvliOYpJMPuB@BpQ0w!bMvFzybxv~~E)kIY-r>Q!ufGIB zi7G>C)Uqa2nbQ6Dtx?AEd)PS3r4NS`V;Z;Gga-oT7$L;pjI+&9{V>%%8U%r#w*CmN zySV++I@^uUi#!=L-u4n<+b+XNux($Ldhl|q>Nm3#>F#ezE0vTLkgsDC;nd3D#E<}=s@KJyr~HB7fb&WBvB&qG*d=Q;WaaH$^Y5J< z>)%hwe}9(j;i9dp>J99%?WeZ4ledQqK(~^iF-n?lXIgsiUtlWaz`eNVs-!N`dy{6Up%^kE$Uz&02A1VAPvJCF( z>pHoV!E2~&zwmo(v31Fa`{UamLii`o^?FpKM776_jr?w)e$s4FmLe2Qu4@9(;t^}X zXF{xQX);m|FVyt-S5uHJ#z|oT{?Sr)RO`9WCmdHCMjFTZedAiC6^xCDpvjY@njJ#Z8u@!B<=Y-J)H5@pa5c`oEExBP{+t7OUE-bolD6utE3(R(j9K#{l>p!4qc zIHhfNlaet4g{9;yjVo`0xdZzl>jk}OJjlbRd^jJEr+7X!*Vp0bnsQAXiO&Zl%P2^x z_qZwmxgi#MIj4HD5(eq`K8Jf;*LNeKrdFFK#Sy z9Qd-Ahw~VG#_u5n&bwVsl9&hfllCv3L8c1PJTd}ElX`Vf>ZTNS z2&y_F+}k;kfO|t+aUl>aO1@dKbHEE_e}Le_6&ft_Q|x{NZG`%7rg7mrJkkrIsyqsU ztewzDx;wDq?SyPZzVv7bBlHtMCvkJWP*V~e(S^gFQ?upV*}4zMU8rJc2n+ zb7JP-u51@gV+wFGW!KqcJkCqrX+xDwZ#1(d!ubLTaq*c4l(HOhDcXsE!bS9igP-yF z%g+c*{`Ewry7j=q7)#^rH?Z@s5SZ?(`TX8uba_5G)gFEft_#Q>aK83pCS6Qmrc#qV zwzWMdxdnPaDRdL!AQ+olPA1yDoEMk}9a5bv>~g}TR9#prWJALw(D?eR(e>Yd%T=D! zo@*S>!875!jAe6>ZOkU;)g95jRWd<}DM&f9?KCDUVz0rqCj*P@&iYnsVo6L>KS)aY zo9@0cOVh;0zc;<3TKiI_1jDw8h5f7IU`uqEc6B&(D9YVx{VtspZ3*l^c~qP0n*I--s1k8ft;-b9#mb#pRqYOR5Ts$F@zGc%K2D3eWnHF?_r$0D5Mu z2rheBI>$e;$#kTg{b%Z3O;3|A=}|zx3|spWMDro1ryf?*_6{YohGrvD94_zpz{ufJ zX*4_T98J%(jXh@u+iV=3N21}%q3YFa0Sm%|>|!)qjixlI%T;k+5~2qlIA|xw zvLi)>-9^L`swiSijl&&#B*}MWgk#qOlKY;?#IZOOVH1!&S~15~pqz+IVAVTD_zur} zw$z7KvM}E|UIc5o$HjHBS8~)k9%T4arOsc@ftD{Jj;|Iu2bYMTB=nv8|o2`#~R6`T8TlKPP)0utE-#z*XMCrACA<iZyKb203WL&t&30kUTP#-7yr8>E3l0Omm((8fxnqmDxb38| zDM`oj@T`Q?TkBG;62vfSk|J+*EbCpT&an}+P3+SZ8N4!w5c>uJkZ-RFbUb9CYoKaF z1llHT0i1wef8`?`Ne0{n?^DiuEAPrj?duvj@tGr)#LWg|0l@zIs5U&zc}n1$Q@_EP z4YReIYNwQB$jKfpL9hSxel|yqvOjD}>7eR$$FA`J&T4K--5&au7aE*7 z1rS-^CW#pLQpyF^%3UDs3KyVxV%|rf(Asa)f%nQmzd&)%#&^Gw1LV4H@&-d<=YXFR z>4W4&(vWPb{4pBnz42yvfe=x9=_gx=sB!XLP;dVX-3Jl{OtaX4PTi@!H)q|vFj0}h zNQIB>11`)KVc+eHOTv)-}tQ3)AU^L#~bZy>b(vWK=>6btwJzqLVTs>v5N$e(^E&ON$Kg#!{bzrK)#XikDds#=;3V#%&rw`BzJh$vE zF0Iu`5JELrF&O~X*l&_wphM~@`rdSk)fak(RaU`Azw>E2SO$G!?6}OR7y0QVzrylv zZ_^+t`DAuBl&d5;MeJFL?!>qC*++z`XX+=bi(`5X4krCbbaiCD)XjaSyz1Rc#?`>` zuZ(PfdvGo}@Z4OFlKJz;;y^z9M1o$Ry;tbrO6EjTR%r+{wfCQV>M3w^`AFaoMj>Be zDOZbJ%5XM!EujOx20(xbg>cl5t?((>n2R2uw9WX!h{0*%L-nUt%uZm3lW_g2;NfnWC9JwM%e`nhT@0ufA zE3zxQ?QExE!1t2!%Hb=+S6(I&)aMLU$g?oy-ey)QdZgRJmut%I|28J zq`nlh3hHWNAWH$;hEccX_rEAU&*M>ZjLW#3G#9*w%{p9@fUjcg zknItqbR^i70_EeQIb0l@W&nuuXjvgHFRu*HSKO%wbdgwu%E+hGCnaN|+y^0%_ewWZ zQi;WU+NKs(%g5WbxhlCRugqbuHeNuVV0+AzI?vUG@I-=v2WjC#`~FSk0ZPZNyur5M z)sMIrEEm^o3sx0U=VN+4=3=bG*Jh%$m*}46?v^eDw^zf~M&}F&Au}8eaHsfXj^A_JnVFdyMzLnIC(Id=pEB!1N6!!yQ0g7zEqp{}9VOG8|AW`7PNQU7dyL!*GtjN!?=pEBeu~N{m57TrJ-(3s$-Q4PC99AD_=XNAnTfn zE^t_yE@9G+n5V%Q#D0pvvK!c_D0;~gm63Z4&rK}vrxV7)h2Jebmt{;jLlRpV?%GD4G4wV!D z(V_tyjnARXz3;%lPlzvV9L&yw=UYby6tK(9FYUhh*X)dbK!33fYgEuRP{BXccXIvN z%2l%-+EmB}Sa9@i=_h|c_sW3(f(HBkXu8Vz-&!eg@yS;ala++aM3jmzjil6#a7{Dzw2xl?54Cf&wvF3mz^@)7U!p_PV|2zq}HgVeEU`f3p1<_i$BbS=3bO zOGCK|b)2Hy4#@V(-A;7AQlqm6qAGR5e5B+Z5h!&bc1Y0DRt$Dgje}ZC5^&6eaX-#P ziCQ@U#_J$+#VAwlh8Ck^z`IMVscXgT*m9>`ImUt_pJhz~8-%u*d?|+KfO*v4xw^h? z<>K8ooxeJt;IQ}N_jY|eCnzRm9-im@CkTmYA_Rj8_#Z^By0STQj9#ppEUzzZ>DBLZ zIPS12hFK`zVs&LY8N*{>B!4e7z_Db2u3f z9SNa_Sw@}eBnss&+yRsK-?tXUb))SMy5c_!E%?T2b}AhcIBQ3KrmoCwb!Bd4bTR8n z{ux>^0O`P>?_zU1L$g(1va24WCzBTlxRe3_RwZ$!q?8>Ne=>Q4vP>EiiWp2-GIE6> zjvF(R=Kff^n#LABxD#p>`>vOMn5ENH%T<#=IeX5%>Qyc=?T|tQ0$eR-8mv*(@1h3V z51)6SAGfihoq~PfRmiE6+9~J}Rk&9uy(6kxdO>`p~`5 ze8_T_@+^tr5+~~zwKiG+a102OC;!!h&%#f@WeCsVFv}UQ)1qyd=0b?h!^>-Cm|8L& zmL^_u%C9y5-Obe9IDFm*;0rMPFW0Atqg9D3tmp1ro8bYfhI; zI?0aqe$HqTC@*x1)8hCafL)FEIbZT#mz&-s}nSx1JO=BKAzgT)Jh~louv(SFg#0P5Fg!TwSW7|(Wr6^kY}Ej6JaPl%V=N3#!7oA$3>8h+&P^oKGikiq9;2RXJT%A%HVw#E(=9 z05amD9N)kGx^VnePmfmVSP8`6kB~k(daHrD9pk0Jw6jEcuKHu`h!!bO)lUKGRwx;p zmc%q)d+qS>_;~p7!MjHyuS}q}mF><|U2AQrvP$W`{hD%C{@&8VCf1vR!`?go%y%!T z-oEZQe6PQB;O{-1e+6~zE70KuE^_}Ke=vOe=mW%lA09ayJ(}9I@b$jo5q%2Hp0RvCGEAH zMmf=J;H)TU2yQcDSXRH@%9;XfTTZYxHuKxF;yv#4+-{p=n<=%aFU%H|DFvMJUCc&P z>$%tn75EGyhtSJjpl&-|08F8&HMzTZ)7uZALr~Q(#Fp^48I8bayiZriH|ZZp7i)7@0;3?B|zg=Xw4dv zLU2=CA!AMYjw)k?j^z3Vh*u%e)%f)L?^|~)ot#BE2TEGlT#InIZZ|PR&bYV7%kmf$ zsJW+vy~X5g1t%fsCc4FDn0(23bxk#@Ay&u!`Z89jnhUCxqO=)PlQ4k6Uz~MO?$yNw zJf#H_yBbY-FVTdqyS_o%elJ_5>LCwdEV9Y^nlSXDEH>51FoPunxzLM+f5=isLwKtO=tyVW(m`n*oz)Q_ zbkmt0-5b#(!c4l4xSfTFjJ>dcHe2mm5xsz*s+EEQDTa(3l=dtqzTdlRk8Q3Xf z7Ioi;enJu`Bv|sN-5|**Q{A#qgXU9b?SC>NT7TKNI0MLYL`!lZUt!s7q&72u3 z`cP{-2mXs%I!Y3fJ(T;@e#cjKD|1-cK7w8@ld#|1U8%N#i^L1BoPcs)dHt;y-t@Uh zsAFDB7>*Z~dy?MBubEbZ2Pxek1ZhuZ)+z3F+f!}Y5l=bZ$umE~T@7D%#li0o!yuVf zi)FWw(l=VzU&_<6XSXJQ(LzPB(Y)+3UoygXzEBAVCY;oRtO#eKZ!y;|GzNgsCTC|( z3;+Pn3X$7$ZO;5*Z(i3ybdgVrV#Qfb_B$Q>;iuc}nqQ7FcQa`TnHF_<&RHj`sVVVc zMiqy?z*Q@YK&_T!g;6!lI1bXlhiW{aeiR8N3-L&^YzCoB2V6*_7|W1brK!dp9RmsF zYbmeFSR+1Nop%KQ%vJDsvJS9As+%-MhWH7@C?6jnM`DUhPWeE*d44r1sQoG36MFe* z#$SU7n8#_LVLY}Xd)22vqa$;4AKchjSz zVw&fdzD)VS$IBrHGmCq+trA$5%b59BzSx(t&ASKhymugMA;Xv6I$^}6P#+ORgBU~? zLQ;&Vl`q;#*W8g{wB{v`Rp&M$INUuavJe( zx*tTPUY9EgUUvtUusz6ROX0+e8MKQg4XY88v5ggruw4hVWNS;Jr6mIG^|xL*0vt2% z^=k0USmQbzG}nXoUl<~y4raXD8~xQS7{Du|2?Q)HMJYP0SM8c@&-%o6d}5{VR9VwS z<`Ass1)Zbnlw&>5cwJma))lkjX?SIh67}jLNLtB&f~+0ptW%g^5~=ahJYO4!W{m)_ z9=W?F#&5NuUZt>O zXGE4mv$wD1gt1Qrzs(yEymZl9C(jz4^4b2Y!hh`)ASUp)5-|2sd1p4!1y~agIuid9 zJx)AWf|?iz2x}hi7!M_jEZm;6UvMoySO7!(sU4YL6*tl24DU*al-)w}ULQ}Si?NaW zRu=*keR{F%$5k2-NJ5$wvwm&l zwm+SFl8>l~4^JqIofh}~oSzo!mvR4n?J_+ow8xV?@{?fvdN*94ETy@=#hNd2nh?V5 zfXE#mjAmgMCvkb4qKR}j;)F`BJBgC-_-^sRVV4{9oQ%ggXJODHt~zNLW3*b%1-JK? zTjuy!QN~c*>fgv2@nWT|dh4$>`Z7urBCC9dlRLBDa*8iUYCZD$+?oz|VviD8B)e0m z1mcI~tx~Uyl)O{iEX*@+%(QRQ3miboN^@$#9oPsaz%};aN)%?-ki@q;%SQ{M+%Azl zPqxz7rMG?2i}@AIa8Y3&sDK39V@CC;UVKs_O5ifjUovD zr;2x26beZo8A(3kP;shE;(&`_Q?a>=atTX{1v*@kMG{wT}+II@R;ck$^-c5ySW98|(1NbyU`y zXls>(TW%w*o;YjO1X_=fKZEE%pXo$q_ARR-TZ>W)U=4xgxI~;8q?A0K6nxXO%C~7;7!%c%v$rW=Wg;V*{VfIjqc=<OPy3?{@U1{F%SZ}K{wVkeqicpV?lO{^zkXc9wR5hvPWnko0Kl*>4F z&~?s&v&ytov}SR*OMkSi#&q{vBr%E7g{HZ^{i$4b5=&-2m1I))V*2w0O;~m=$U36e zsWb2LJKfoYF%fHdEbQJfBH~S|YxBSZu{tjdIS!)DvNS*7=+^N%ucoY0mqaLg!xyqO z&rlxx33pL#ywi^l^x{UHr~A?4<0tt2Bsw}ccyjP0Iz5iyT7LNSYq4o0^`jbqXF9!h zMo*8Q>yWqwY|tOi1s55OFsmOQP2mK)U@%7 z*Id7LG2rQt6#LKT>P_q8eL3Z$*kI=rOxMNMH&jlBxNy7Z?-hUY{qzdfQ#ox-WsI;X zjh?c+oPB4FAfcA|7 zl1mfHi3WASu(ad#d@;J3gU(NV<<)InCwq`$bE%>Ja26jB_XZthLT2a~Wnm6sskcY+ zt-dh*u-UHebdjm0dZ^sAHEwCOE@^CK*Ot6Y$K3frsu?9R~6UhY{icY`NS zxJh!b+*_itipgKX=x&k#V-G4N^FyyO5s)wlP#6ZS{m3Rh&{Ad z5*vE-MC2RXtnkGh$udt5q;FZioq|C1~{FevGK{pEFp8t;7c-gH=L=u(8YnfKNRq!2?NOUwnSul=7!!x!CAhy^Co_2ckb7uR5FU~hJuG2P^0;$-w^ z2hX1$=O#+i1}KdGicQ_x@`0y_SlrqggQY|PQ!Mi9l{r?iTTv}F^O$Pusm#`#dPP&q z3$fb&{PbfrDPCw*@LI7W(~$AtuZe2`U0&f-MGHo_WBeu6a&JeeX~`PN3n2@c@T4~l*uAVVkb6O zljy2VxQ&NB#-r>hRf3a_`ma0=P*B3yCJ;4TtOcd@imIhPG=w&7V9ocuL9izAyIu z_2Et=NGG`o&RuqYt$n9_Fuh7K1z>3N%cEfJ z)P`{G-bZPPz9Mt&ux71$*K!v*GIQlJa-IFd+2B-G5TjW(VA_K!DU)Syr=uolBF@!Q zll%86;{Gh^QHU&GS)O+HQYoUGCEMBaZJ<4K7zZhXnYp6ylnmoEvK}QeIcsbY82B9t zwogy^V}+sd?v~|m0jgxWp7q%PM`msFc#7LG_qhdf8)hVosZt!eEPt%YFdgWTTR;_q ziz;vfG&>T{p4C#CNnVo;CR+>+b6dbLQcxI?HctG$jNdwTrb+ZQ{CPORWYA+1Z>TwE zID6f{3e;ND6Z_mp>ZMZj*5oe6~<}=3Y$52r(VGt}_&V{N-;% z>CWiQY{W(&*1@ua!+CERtg5Ud92SEF52IdYh=*k#O!FJ5CDEFM&svJL_%GREC$ahm zD@hzpNE{_snI=6cpBjAKZ!6OY-90d+R4!Fs9QteJyASdn8jKHwsw>T5m%hjZL3c5x zrgHCC(W3JA-P#WQt{9tyGcCc(5?Uivv>RjO8$q#4#Rh;^nX!=@vNqr0Qw81D?+Nn9 zvEX#V3*5XePRTAwXvZXvd7ZNftBoL!t}pQz%6qr$r5!^0$vq&)T8?3cmqvC|GE(?F zUn*zax_ZdhBXQzxz6%+M1k(3EROPeLMU4kWh z+F4N`LuiZ^E3>kzMW2m+kFFB4>e5szg9=ov=C$qw)-E{p36qcf!ccykz2L=<VC| zTrP!*fx+AcxaH8?Z@#fwP^4+*cFr;9K37$ANRQsAoYO)3ui#%6nenOe z7S5nm%}EMbjp#EHE5_X)`_X(c>!(sFWl^-FT#D|~=? zt`ksL2r9fPuy6*9EDK6PgQ$W7DrYIg&#GX-fy*vh&h%w-G6wuhH5A19KRf-%rI66Jl_{5;X?YevS_wzLbNTdeUrs^ z4eIxDiHggj(iZ3WQFWx)yJe2m60yM6Q1sunc`HgwVY!wOc){fFz zG8YKdS`$brVv&>im!fy9D#iw1<+HUgp|@O%W0q@R)xGBUb@M4AXD3i7-jC>$=+k~Z zjkLNxjH7t;vIm5k34Dq{Qs3>sT=cc>^6N+sg7en|qYj$a$z`sZZ`BJ}^#hi58ySW0 zwX5&G9?lOZe4Elf`arE~BT&vrV<^+wXa!63dNUZLshz6TX7|;H&tM{>Z@#UzJDbs` zlz{K#D$Dh<`t@`%iyi}41TNNJSIJ&F8Iop9;{=)Svg)yLHhYbBQZ=$F%;B8dzdFLg zQ61f_{KETol%k>NE}}t_-`v{yeyiEt>1}Ox z?|;Ahpxr<3?rv?G`B{D(T-u7GDBj%K-r&O623h5ad-;>-kB5eF_V!y3$$#U~TW_Cb zc9+B$Dm-9bQN0Nrg(_?*m(P%q{poZ@yLErk`)t-959j@P&Vnj+$t^+VIcKFtO9VgZxVMwCU#u>LUD#C zbvJBWzE+Y4AvGaK=p`f}3-fn0RYL3B3Lex(P%7Sw=%3HXaQcKUo~I`5hx(#+G=15; z4*DRg2jErgSxT&3?!CA2kq-P;Bevl-)TdP=i{E^s*vv?|Fq+p1LNpzS)xyI&j1Xo_>Hl|I-7-6+Y^pSjp(`R z6uDDKVltOnb*Eq@)?YW>@6j32doL-v3^q~r`59NpNpeG5qMWlDW?_ZZaZBT-A-O)G z#ExWg5v4_5R_9Ev9Qf{p6wD~tTV74-Y3Yf+x9rcV+M-HrRm1;>aF;o`=+5x{ZEJ~~ zZK|ZAc<_q)q?ARfsZ-(N$<1X-wF3sl;jDkI{%SlB8nI1sb-uxZL_B2`Q-x(Vp=h3T z$y{W=i-zfxn~=8WV9QoT%+MS?MKP*gPUl0G+O+KYB~s>+;FKkYckXZ8@9aEiwI1y5 z?rwH=b{d`%A=epLW{kI9Z77lN#EN{o_cvQRyIc3Sw{{z@{8P4P^)X;RdLSzims6tW zZfj%nK?`ch&h~=`o!tjzBto}&Dka4_-s}HMcK=Iu|4Vjxk{utD|Ao8%xNx`RG#5*0 z|2P>^kEvNo}rRO0t_`mNuxA52?t3B;{_&US>H$7bD>zv2%SmRY$_Z#2rIF zw}^f#$P|MV}dmYts(aqv|v?Jt=&6^N9DfSkL z*Zttl!u$W6bU273!(L2I{+3Mb-xXIz+pfNihkz{|zY5x>9}gNS>`oP6@kueu>S}|F%LChmBpqv`Mh^uxx44 z#XC+F7A$d$s#dS{M;cUr|I-7(@}fnLklAgn@5RU-#hWikjjyM82LuUQT-TRFS6OkF z^WSqk7u_RP=VYI7pwo{++`>EwCq?c7m=a+NrMpo0qDdPto&Fe*x1wgIVCD;bT*plut45IY zye97C$pWPUd*wDYRgcSs4Ec@t)gZF!C+NX|f3AIYqbo8k7<0V`@{?5OxR_l^gG%W7 zW&WzPTALn!G3TcQN7TJ3I1nE9l3*j8oZQmjM+I+~aY;P;>dX+r{Y6b43QxvV`Mu(l z%TtV1{x(4!PLU)*Wl}6h%Z*ruzv?VKBYN)8cE(xvCGWmUXirA9_!g`rcO=m-R+g+2 z4E%WeBa+3F>TibL!wf6#5#(y*v|-{ra;ELuRENHUYiGOF%F4IyNbgB(#p%4|f*d#^ z8?-a+hY9jpI$N}g1fsNFkwP+m0#nMD2$eSm@`g5b1T(YL?4aucv&3?S3F$kC{S@`( zP{WGDi%*7zS279U_goTe^I2*=8d0;I>X2fWF&aaA_q!F1?dN2Gcq3}nqjzZ|f^6P1 zRq%(HN=~JiMv|!H_0ANdOosJBf;!yn@w5Gtlkb>d-f-ikW%QE~^q`-fQjIKT}p8v#j*(O@W)8$A1B|bSL#QPI&$M2kMLA+4F zyb+{I>lVUog!Z(6l^1r{@nKEOv2%G{`Wp{6k5Lv+9(XtRA`?KM6g3M(;vprNb#P@w zyT@UZMTlYS8Cm%eFj9&dgs9HlESnFSyi?%ODrcE(RPBg!ZPu>|h+ ztOZqp)}25Tck2VR0J}CD`bF20*Nf#Mj!`w!-OHc3L|K=ci58j1%WxSf0Pa17w(X9O zo%q%8tw~`NxMZfcWj~5#JSvmrD3!BFmZzw|Ni06Vw3Ao?W3y{0b{4z0 z|NV5vDABZwl*D|fBS4k^hG$Yq@!b}iEZB3So^1$8)@gsI8n2n?@}*s6{`~D1%eKI< zTz2n6)Qa$}FH+Ke%Mm8iqf1tX%PT-dD1{H0TT$gUDu+|`@`?I!oL9M%UwI59ydFq| zD{lrWyQDJ(mA6urUn0HVncw$tvzr;OYbpiQ9R%6PN=rG$Fpj0W*-jbXZeSNwpq|bCpnkxZGPg-y>J7% zlvgHYwOJyvo>>?Tw`7VF!{@6(|2Rjs=sHZ;^k8)O^zi5)BKM}}U+h1N4yZ4M2nUtX zom@H^&xLb{4R9!?FNst0yCr+QWr&rS_xbPzyh^$WGQN-st2`tbl6W?v=NN9&2}=wH)x)1ivHm%XnDx_+Xt(`5W70NZo0LL$A=V zF?^9=^X2|G2WOw396SlgHdNH_Lx6vCbNCN8p!ljTfpDAF%=X@X-JBSQH_$yQeDZuq%UO+$kECG9ji%l2?m-DHaqEeXJ@eqNO!+# z3siGd7rKDHL7Chu<`>V-tIWd>ZsoYQFOm9u6vBQXNc8s?L{3`ty?H4!`*r zy|Q!c=A@IZB<`Os?jzguqU>~B$s!{Kb|kkNuW1i>tFSJ25qBvf9pe-{@Nt(U z7YKV)o$miQ-ymrRAL%K%nlhXeu_6{JQj``tEgFjo$(*^GxD(H?PHyG8{537YisYGzdn2V{PKn)H}TvOinL4|y2o7WP&POE^dRSB`z zzr}{cUlk^C9FM?&9IN&5x1x{HDM~m_JJ#c6Sy;v@6e9jOm%r29!m~I8Y-v4Y9;tf( zrU$a_i|&wv)V8&;5!bFj5a?W@J78s63>Z!{7Ys~M@eI)b?pk!p8j~4O&RC{Dq3vNW zCaq|bW_n!Yoma3pB`J!=u{oU1n#Y=CU>tJceg&aEAk1&2h4~eP_;LdI-?5vvpjCLX za^{H5m>*BTVdc_$#>CXz^`9pzff>|7BGN`HL;e9rN?(o`>DBZlL6B7Ig`B8_s2`fq zA>@d|iIF0zt8buyNf1LQDHw)&I{CRt@?Gg}0*7-rYhEYj7=8DzE-VEL4m<>Nn;5GM z_>QW|zh&-^ysZI>*{cQm3CsPhuRAS!>A4Ipd20J&B5N_}u3>P&>qXn3m19_O$L<4} zd}#&hQUYL#is`y&O1CFNY|ws>+)8AV`V5nlJF!7sYh&B(q?eRzVe`jvncF|&$xcen z?F|xD_S}1&VoS5(PcX8HM&E9An-9^qAIDYkP&PNVKhPcMQ1hEDddMu4yoCMvzWH!7 zyFdOD9363<+T=l$2pp(T?%qcC&YOgZiB!RY6G)|=j=`jH4pSX3xqHlnW6C#a%G)HN z7L0C3I^(_O!&2+N>pH{RuOsM8yCdyQzxi;tOmEtq4@__FtK+aK>CdjTKZEAOc85D8 z?oy{^yVT|r(ryX5w9}Gy=>?8#tIQm2xej%>t3-z$(XC-v$b**J z&=<2J4kDkRVe=7M;0}o%-{bADxvU&l=(MO#(>n3?idn-K-O&PEq0p=0=6&$^%H2KE zS)9duXI;kOy})fdsp{vhXwaSUW7=0nd4^Z@g5zl&eo(-)bI-adbE*~N?yCSyK(oJ` zfZ;@Qk-Xf!#~)7ycj}x#`&mtk>$ItUQjyC}nmC+zNJ=3K04&0}nDw)(iHa%P%SCB| zn{;u05D8e;n{TWF5=*ObhB%pnv!jDAZS+gEo#U$*~<+m$YQPBU9t)0h|#C*Bf}RuGO(qX9Fs!tZa)h9S|*0 z&{yLt`RkSP5nvB%ocIm53>$2t_2^{!SH};Vs#PqUqZR>JWSWqDccemaFwgVIWgJ`T zK~AJikm?vvpsEe0!qhYMNympCk|ZK12WhtcK`~Q$50l$?^=h-#n_e~dUv^*D9&UoS z)-lt6bN4rM`!AKqczng9Q<{Bh9SdJ;&|}(1>S!9j4|8T9zZct>lP-82zTa3hfa3{a zi)l}h^5Z1YqFxmZQtwvLqO^C{xhm}*jIEDQW9}`;z4mq!I-;Xov(KSyaOIATyRXS( z^u<)U{^GlCT6Sop$hvvG=v(5UX>votn6gcJUREpL;U>FoFdcUEXfH@6eeJ)uwVk&V zLJeB2M0>7;l2d93gzwPFi<(t#Uech)Tq*OEe8Z*#rT0gPJrgmX$1m`6R$q4q=plj= zyJY>~G9{hIoR=`SL_#?izw9pNdH0Ha{8E_*^zzGJz)JDda<3%V@8xceO^l(5Kl_)L zr^@-N>Ph~_Evw%n_;3`CFzTpA>#o8Q-lqrO22ji<3rhF%v=3i+mEotJB9w^VY&1utfJDgDJfR}bKPVCEDEOe~CpBeYYW zyVTa`dKY$D_xksk-h15(G(XYO));qx>__v30?4P7?`9Ychg7>B=&b0>xjSlMrDu_P^gG|ca) zwz-))Q*14)FS!iyVoGyxzrGtH5U$#YUUB}B;ZMLhuend#+jVJM?QL&2tEq9Vg~Dy?>7 ztJVCp`%0LUJ2l{fzGpk*r_x_QQ8sq5=5@S{o2Yi3>NnmkueGi3rrwZb(262%0k56M zgHwp{i8oK8Q|LhyC&SRBLeHe;BQa%}caoS~{B07;kONA67|T-u^q?qjzVT#iaEqe! z!*N>hSQ4jtI0!N!s8TW+3nO}U9iU)4DA|wK$uG+65uW`|ZL`f_U;JH7r##HfFe4m4 zVXj>g9kAgbq4g%i7Z+Dijlb=6U)LLa{Z1SDI)S5qk{}B5Ms)#qwXjO{o}HQc`|WKC zu?w?XjU5nmL6ZKW_@;6e;QW8{Kr|2aBHr4!se~3V&AFcD*JqaF)emQrvLr(hMrqM8 zMAMYXt$sE0s@jXol?jjl6DgdP;9`Yo31^7Dr5S2>E@nJSwuyNC#Lir6)>isS?J1Y3 z1=}bQR|VlzM+v!{Iw<3?)RdSrpbuw#3baj;)+ey9B=IE92ZscE`7VZgF7LK`H%hZ* ztbecnDwWv9L(>2?)9;a7Iv?v$kt2P%!h6*Tw=UQxC7#_o7n=R^iN(}VG8;$2OVGi2%Oen0&w zwWl-O4K_dUf({}y0wE>18=Qkyd`0vdl*q$@97mIl-cz|Dv`IyK8w_;~E=N+=4?q9t#wtLt0zp8)gF;BsA5`QTYP}JrKrt4x&6USceylEvh=p2A{ z`ohVhkFvS}X|)rqo2q8LRJgAE#KOivhtKONA4zUFREIokb-ld&aO>yt*i|m{4br4` zX@lfES5is)$1z>qI&mRI?z+qnbKfd1dls=5N9GHX;ISgRZkQXg%dD}j3sM;FMS?w` zsTF4y)YR~8DQd*sv#HUyWvC?+P}v{Xx19S)=un#1guA&Cr9#=I>cMzfV&}-KyFafvd0RkEO<*qKXK6ozk^}kVk{)@b8U23cd z!vC%E9geXyi6+?quDX<<$g)bgf&%M(D-}s1aRb35s7PV?EIyh~;@3U*%Q^ikFiOZPBAUOqg339Lbq-L2z#yeM-Yt8(1@@P(l@)}2KY?^V<1o# z@)uzvDL?x1^{I-mBPgJW>GJ*1*bnd)=FTFFcZv?fvUrLdLvAL9yv2hF2tS3jlXl9B zX<%)XfjUna0eS!;LX8LI7X*9M!D^(EdU_!ycY1v~8De`$V&Y7H*GykMX?*0Q3uvpVH8*IdMHv0IN{eZ zm)vwq=BFebM-WanA1c4pqEL(TB86;Y+=)Ap5!Bpdc)Y{miYLtB1e~^CbXzM41*o$? zP!xHI0WNw`5%DN)Zyt(#dewXwE zp9@rp4F$kbU8mLZVDBq$rsay;TwsVbE1~Exj(3hxn+v|2gZ7&~-p7VJ*fp7sj7wUa zs}Ia^ipGzNI&}{AK z?JTiJMWdHrN?jEQh!cAFS5NYMm@q;g&4o9bsm(Jg^?A(&hU8bf#lYy{Uphw%B80ef zh=mZBA-dZyuc2&Fgzg;iTg2#aG^Mrm`9=OtJ;IZ7DKAcGVk=84=C>(GEs|3kxGuSg zpBw^BF_qYx8VS@sF6&|q1+i+uy}7u)95$R|(#&3AwrSHnJ-k=*9vur>?zlv%qmRX&z_zf!^6)A ztl~*}Xdr29Y@rrY>MJ+1CzzeBO2EXWAe|ukc1gD&l3Fo`7elF0Y7C~EyXl)r+YX=P)Fl?7#nW ze>~}bKS5HMXO63tmm%7bXe2n={h60as3YjV-#u7Yb=uuh_AQ^13_~WJq=C6V0$4dnOc7h*;tCO495-qKTX|RyT%%@Q zCIgSL1lv5eSaxzivHHU|8(nL^h5xqY0Nt{O?RU8>65{Gndu^SH9tI_A8|}5XsZm(8 zjRI=4(d8l(#&T7u^@4dJ`#+Kc0+8arh1%*o?Kme$w)X4d(CIV8OGgWB&W~UHFo?Zx z$|!DThOL+F2u8%&d=*lF+WWpV9q7NA4fJktO695jANZ^|E^kRbZ^7^}%mr1dD&Jg% zYs!DzujF6x4ssUde%yKJeCTl?1K)J!YTAgoTw6jP;;RSli1L^q|L$`95X147pB+AZ z+S@&Rc5w1MuF_+%2%|Di*Njnx)LL0~?zqX$fX)67Q{kyGI6LRRSgAkk8qd>q-l`WxT=WHlK@t7sa5S2X$pGR;mC9)Q z>P1(*n<7_F)k%uvN_Ya}A8Txx_!2%ocxU58rrR4tTc<@F)76`yL4X94WK9#ct}qB) zJ=cidq!BC$x1i)OzM@iypv-IwQhK{B@ik}~k=m=|fGZg$;bX~R>ZWPZ`CuH}9$(9i zMT3EGQ~DaT4Hi}R%SmiRB2@{{eEb*3dtaly?^V9TSZxt(jT6A*Z6n#-7G59jb0mRQ zN*N6E+V*KT9`bpK;r;#c0+I25n43$B5(4)4Wg$u$X<7Vj$!r(@siuQ$tK&xaTD=`03#$;LFAqP9USOtq#(r5eNLN}Cx$yJm)y+F5BlF6We@Tm zq27~c2fOs?cx9qhtb3K<;LP=^U_f?%ArS}l951YS&-RCRhw84;{! zG<(FiRm`!yEi|DGC49wSGBrCU1QgR`qQ=>ve>&0Mcw|z@zGvL?vsr&IzM9864Xs8R z=VM|3prp&KzmUF>S%V*f%#5>ZNUVuxV$+aWYjE|z3YoiZ8B=)RUfAEHteO1LX+&D= zb0g4yezZC7j2q$r>-u&ErL~Vk;T|kxx4DQEKt_iJ32Z%^7ZU@ z6}hfX?CLo+);`)hQKUp!wq#^ZC!#tcN#3d*??S&v{;WBKuy)i9tQ*!d&Q1dq8KL=<^XQ|)rtZtZ|~{7CYS zW>3`Tzy+yNHgdg@NWZc`e!qd5tD7;pC(D7-1oLG_euwIjr-AMd?eAb_v-3E#u~{M4 zVAo+%WjNkFoHgayiy)K7oI{64ZY+?6O^#7FJG)`n$c$M=l&R3ci& za*koIgwKpP(R-|}XW|Uac%lK>M$AhY6MeK1Q?>diKki$$bkUgmR|COFFrT%)CR)E) z$#gdg!X{AVM7cM24jbDs%4c?H7m8eC&pzEd+TU%af~T{b+p`*F`IS&KL}Q98M^%1r z<$()LO)G#5zjtOsJXIm>ufIW@2>yNaXhi_uF9qmbm&=IoVwY!vIL{*H` zsQoR$S6o#OT~W{;=S{>Dvw_C70F1IN&8CJm1U9xb)4D{MpOo988pRjZOjnnR&0PV( zCmh>{#OF<08ec7gmojg(os;&{!-J35IAZ@`|73sX>Har+k25<{vyQEr9BIP~q5uShu_>nN<69meM0nz# z=fphlr=Xeh^X>GC&2MMq?%U!nTj)(0r4g8X0Ew69b~OlQ-UNkZ7-e&KVa+>}*=4s9 z8&Yo~YoeIA_}xrDJN)G3m&#uxn5TQm>q?jk6*nTpCSAnAKjAyE6?+0UE4{XIMyrUA zP_!;H+8A*y$DS?K>z?oY+Y3WTrMpsmRT5mmHwV6Sq~MTHUlh>=p?- z-TLtlpf8#k_9fO{&!l=lM4Zt<^r8`L@)jan%a2pW^AB^;>0J6PJ12d!S>Z&Bxh{H;F?wf$o z>ey9T#%cX znA-U#zmPDLk30kv7BiqYj?YkiI=g1YTkmQfnuidW0vi#;YY^vQJTeRj2R`P7VE+ex zd9v>cJSar1WiH(Nyl)EuRJ)7++gI_w_)0ftlmzfAc&INCLL~rVTRSl|?O(9#h4S7S z&*iq}%+$d0c)%N5IA-O5+Q-18mG^jI?7Nw5ncxOt2qJ1+#~7Nh`xWQd&H;-$%pGA} zLD+->MV?=fhB;Gy8)I?O!DNe`){bBf<&u~&C6Hhn)!v&)Q3~vz6prV``4WKC%XjIf zfgI9RW0^g`A`;#s+T4WYM_70+LB-qz)v;Ca%omnkOB&lhy?l3EU%8Rh`L_Gq25?6jmdMA%#ve8Q$`nZH96omlE8($jdH}JIu)c< zt!%xSf({kXcg0bCoxrUeUeeH%DJjCWHrCJ?bvqS6i<-RoaR+UE06m%ulmz8R2v53# z=tpVo@bRm}~Y_WT&L`m{QQmc^JBw(@A>Ch;O$4PUK&6mMEtjMyU;{H<{9F@ z-V&#i#2hU-nr`(MNXql4@+*a?#EQh~bD6vt`c6wp>z!)f%s7m+>gBZO8u1qlWE!jh zR{1?#{jVHdVdo}dbYW#hVS{w^;hQ6-q}~EXWIwgEQIT6*rEwAQ6=Mtg=aH5tBh_bq z3sk{NN!nSX6sSospZTmfneDBN=Cb5uDCbP?$0#1XE$6+&1Lp#{++lOPA2MxGkMNaCIPg@_VoL;X40fawOpnlmldf+BQtTi ziOb}tfXXK0bKKO8-#gv6VT8Hf?T!}BrhnVB*hc1lS0Pvdi4=HXFJ)v*E*5G&lesO_ zpYsdW@C36yNqBi|!srikg&2rJduM7ba|!l>#6{x~Yv61C?8^;32p43ak@B*aqFXJ5 zQq-IFbWl4OBdmJb|`g3**j6IR4T1@>hNpQ+#zG18g!rAd*i>zUi9fW1n~T8zYGCFEkJ9Z@Y;KEeivdGN`040SXe@Bc8TmAP=IqDvmtiC zLy_HM8ZACo2c&?323;4#8g`E4sKkKAQ-r#`yd=sT~*QYXUk z6t6jmzaNsJgYOZZQ%t8F3RzycqD6lUi>b|$^A|b?ue#U4a=r@O*OJqt9+z#){Vs_g z>UY_;uEj-CR7vWjI%2t1#a(IN#if?F(HL22_D)QroW$w)!#;JThdS*+F#Or=UWrA> zcomOYD=1EdcsQU=cZ{gB%uB14G$aXhWmaA8P?-=Xj5k$QsbH5{*tln{)B>j9-D$Q& zPO?0Y{8zSSNHAtdwqx&_Dn)W$pHMw%&R8TA&vVA-TX7RdL*BjMqI2r;8qEPeY+cxaNr{s10KRB zbRK3peVutBC&0E5u_h4l>KaX22F2DD2zChy-d;=S@a;8wj8RD2m~k3j7`H~2bcpsjM!ahj>^;by{+!6s!o&z9jlE9_PC zDyDw6GRi<&MpkVsVo-$u-M>4?+%sM@YMojWuJwV)e;!J_b#ooX*8$kdo}KK*LO-9K zouAJYym-1)76arAKH5r`#+p(tdOd|)tB`gUD9<^cq)KHUcFLu+DyoB=_n@3HZwGrX zwDibYPEXZoP11cK2UD+eOi>*%z4y)D(II_8$^D%0AlQEiZ^WnA)OPuy@^d4FN-TLT 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 z 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

;p$1L`r%T| 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 Hi$VAgPjy$~ diff --git a/development area - v2.2.1/icloud3-event-log-card.js b/development area - v2.2.1/icloud3-event-log-card.js deleted file mode 100644 index 9068111..0000000 --- a/development area - v2.2.1/icloud3-event-log-card.js +++ /dev/null @@ -1,1544 +0,0 @@ -///////////////////////////////////////////////////////////////////////////// -// -// ICLOUD3 EVENT LOG CARD -// -// This custom card displays Event Log records generated by the iCloud3 -// Custom Component. The event log records and control fields are stored -// in the sensor.icloud3_event_log entity. -// -// When iCloud3 starts, the version of this file, 'icloud3-event-log-card.js', -// in the 'custom_components\icloud3' directory (or the current directory of -// the icloud3 platform being run) is compared with the one in 'www/custom_cards'. -// If they do not match, the one in the 'custom_components\icloud3' is copied -// to the 'www\custom_cards' directory. -// -// Version=2.2.1.01 (10/28/2020) -// -///////////////////////////////////////////////////////////////////////////// - -class iCloud3EventLogCard extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - } - //--------------------------------------------------------------------------- - setConfig(config) { - const version = "2.2.1.01" - const cardTitle = "iCloud3 Event Log" - - const root = this.shadowRoot; - const hass = this._hass; - - // Create card elements - const card = document.createElement('ha-card'); - const background = document.createElement('div'); - background.id = "background"; - - // Title Bar - const titleBar = document.createElement("div"); - titleBar.id = "titleBar"; - const title = document.createElement("div"); - title.id = "title"; - title.textContent = cardTitle - - const utilityBar = document.createElement("div"); - utilityBar.id = "utilityBar"; - const thisButtonId = document.createElement("div"); - thisButtonId.id = "thisButtonId"; - thisButtonId.classList.add("themeTextColor"); - thisButtonId.innerText = "setup"; - const logRecdCnt = document.createElement("div"); - logRecdCnt.id = "logRecdCnt"; - logRecdCnt.innerText = "-1"; - const devType = document.createElement("div"); - devType.id = "devType"; - devType.innerText = ""; - const hdrCellWidth = document.createElement("div"); - hdrCellWidth.id = "hdrCellWidth"; - hdrCellWidth.innerText = "0,66.7px,97.8px,94.4px,80px,70px,66.7px"; - const versionText = document.createElement("div"); - versionText.id = "versionText"; - versionText.style.setProperty('visibility', 'hidden'); - versionText.textContent = 'v' + version - - // Button Bar - const buttonBar = document.createElement("div"); - buttonBar.id = "buttonBar"; - buttonBar.class = "buttonBar"; - - // Name Buttons - const btnName0 = document.createElement('btnName'); - btnName0.id = "btnName0"; - btnName0.classList.add("btnBaseFormat"); - btnName0.style.setProperty('visibility', 'visible'); - btnName0.innerText = "Setup"; - const btnName1 = document.createElement('btnName'); - btnName1.id = "btnName1"; - btnName1.classList.add("btnBaseFormat"); - btnName1.classList.add("btnHidden"); - const btnName2 = document.createElement('btnName'); - btnName2.id = "btnName2"; - btnName2.classList.add("btnBaseFormat"); - btnName2.classList.add("btnHidden"); - const btnName3 = document.createElement('btnName'); - btnName3.id = "btnName3"; - btnName3.classList.add("btnBaseFormat"); - btnName3.classList.add("btnHidden"); - const btnName4 = document.createElement('btnName'); - btnName4.id = "btnName4"; - btnName4.classList.add("btnBaseFormat"); - btnName4.classList.add("btnHidden"); - const btnName5 = document.createElement('btnName'); - btnName5.id = "btnName5"; - btnName5.classList.add("btnBaseFormat"); - btnName5.classList.add("btnHidden"); - const btnName6 = document.createElement('btnName'); - btnName6.id = "btnName6"; - btnName6.classList.add("btnBaseFormat"); - btnName6.classList.add("btnHidden"); - const btnName7 = document.createElement('btnName'); - btnName7.id = "btnName7"; - btnName7.classList.add("btnBaseFormat"); - btnName7.classList.add("btnHidden"); - const btnName8 = document.createElement('btnName'); - btnName8.id = "btnName8"; - btnName8.classList.add("btnBaseFormat"); - btnName8.classList.add("btnHidden"); - const btnName9 = document.createElement('btnName'); - btnName9.id = "btnName9"; - btnName9.classList.add("btnBaseFormat"); - btnName9.classList.add("btnHidden"); - - /* Action Select Box */ - const btnAction = document.createElement('select'); - btnAction.id = "btnAction"; - btnAction.style.setProperty('visibility', 'visible'); - btnAction.setDefault; - btnAction.classList.add("btnBaseFormat"); - btnAction.classList.add("btnAction"); - - var btnActionOptA = document.createElement("option"); - var btnActionOptATxt = document.createTextNode("Actions"); - btnActionOptA.setAttribute("value", "action"); - btnActionOptA.setAttribute("id", "optAction"); - btnActionOptA.classList.add("btnActionOptionTransparent"); - btnActionOptA.appendChild(btnActionOptATxt); - btnAction.appendChild(btnActionOptA); - - var btnActionOptG = document.createElement("optGroup"); - btnActionOptG.setAttribute("label", "———— Global Actions ————"); - btnActionOptG.classList.add("btnActionOptionGroup"); - btnAction.appendChild(btnActionOptG); - - var btnActionOptG1 = document.createElement("option"); - var btnActionOptG1Txt = document.createTextNode("Restart iCloud3"); - btnActionOptG1.setAttribute("value", "restart"); - btnActionOptG1.classList.add("btnActionOption"); - btnActionOptG1.appendChild(btnActionOptG1Txt); - btnAction.appendChild(btnActionOptG1); - - var btnActionOptG2 = document.createElement("option"); - var btnActionOptG2Txt = document.createTextNode("Pause Polling"); - btnActionOptG2.setAttribute("value", "pause"); - btnActionOptG2.classList.add("btnActionOption"); - btnActionOptG2.appendChild(btnActionOptG2Txt); - btnAction.appendChild(btnActionOptG2); - - var btnActionOptG3 = document.createElement("option"); - var btnActionOptG3Txt = document.createTextNode("Resume Polling"); - btnActionOptG3.setAttribute("value", "resume"); - btnActionOptG3.classList.add("btnActionOption"); - btnActionOptG3.appendChild(btnActionOptG3Txt); - btnAction.appendChild(btnActionOptG3); - - var btnActionOptG7 = document.createElement("option"); - var btnActionOptG7Txt = document.createTextNode("Update All Locations"); - btnActionOptG7.setAttribute("value", "location"); - btnActionOptG7.classList.add("btnActionOption"); - btnActionOptG7.appendChild(btnActionOptG7Txt); - btnAction.appendChild(btnActionOptG7); - - var btnActionOptG4 = document.createElement("option"); - var btnActionOptG4Txt = document.createTextNode("Show Tracking Monitors"); - btnActionOptG4.setAttribute("value", "dev-log_level: eventlog"); - btnActionOptG4.setAttribute("id", "optEvlog"); - btnActionOptG4.classList.add("btnActionOption"); - btnActionOptG4.appendChild(btnActionOptG4Txt); - btnAction.appendChild(btnActionOptG4); - - var btnActionOptG8 = document.createElement("option"); - var btnActionOptG8Txt = document.createTextNode("Show Startup Log, Errors & Alerts"); - btnActionOptG8.setAttribute("value", "dev-refresh_event_log"); - btnActionOptG8.setAttribute("id", "optStartuplog"); - btnActionOptG8.classList.add("btnActionOption"); - btnActionOptG8.appendChild(btnActionOptG8Txt); - btnAction.appendChild(btnActionOptG8); - - var btnActionOptG5 = document.createElement("option"); - var btnActionOptG5Txt = document.createTextNode("Export Event Log"); - btnActionOptG5.setAttribute("value", "dev-export_event_log"); - btnActionOptG5.classList.add("btnActionOption"); - btnActionOptG5.appendChild(btnActionOptG5Txt); - btnAction.appendChild(btnActionOptG5); - - var btnActionOptG6 = document.createElement("option"); - var btnActionOptG6Txt = document.createTextNode("Start HA Debug Logging"); - btnActionOptG6.setAttribute("value", "dev-log_level: debug"); - btnActionOptG6.setAttribute("id", "optHalog"); - btnActionOptG6.classList.add("btnActionOption"); - btnActionOptG6.appendChild(btnActionOptG6Txt); - btnAction.appendChild(btnActionOptG6); - - //--------------------------------------------------------- - var btnActionOptD = document.createElement("optGroup"); - btnActionOptD.setAttribute("label", "———— Selected Phone ————"); - btnActionOptD.classList.add("btnActionOptionGroup"); - btnAction.appendChild(btnActionOptD); - - var btnActionOptD1 = document.createElement("option"); - var btnActionOptD1Txt = document.createTextNode("Pause Polling"); - btnActionOptD1.setAttribute("value", "dev-pause"); - btnActionOptD1.classList.add("btnActionOption"); - btnActionOptD1.appendChild(btnActionOptD1Txt); - btnAction.appendChild(btnActionOptD1); - - var btnActionOptD2 = document.createElement("option"); - var btnActionOptD2Txt = document.createTextNode("Resume Polling"); - btnActionOptD2.setAttribute("value", "dev-resume"); - btnActionOptD2.classList.add("btnActionOption"); - btnActionOptD2.appendChild(btnActionOptD2Txt); - btnAction.appendChild(btnActionOptD2); - - var btnActionOptD3 = document.createElement("option"); - var btnActionOptD3Txt = document.createTextNode("Update Phone's Location"); - btnActionOptD3.setAttribute("value", "dev-location"); - btnActionOptD3.classList.add("btnActionOption"); - btnActionOptD3.appendChild(btnActionOptD3Txt); - btnAction.appendChild(btnActionOptD3); - - var btnActionOptBL = document.createElement("option"); - var btnActionOptBLTxt = document.createTextNode(""); - btnActionOptBL.classList.add("btnActionOption"); - btnActionOptBL.appendChild(btnActionOptBLTxt) - btnAction.appendChild(btnActionOptBL); - var btnActionOptV = document.createElement("optGroup"); - btnActionOptV.setAttribute("label", "—— Event Log v"+version+" ———"); - btnActionOptV.classList.add("btnActionOptionGroup"); - btnAction.appendChild(btnActionOptV); - //------------------------------------------------------------- - const btnRefresh = document.createElement('btnName'); - btnRefresh.id = "btnRefresh"; - btnRefresh.classList.add("btnRefresh"); - btnRefresh.style.setProperty('visibility', 'visible'); - btnRefresh.innerHTML=`` - - // Message Bar - const eltInfoBar = document.createElement("div"); - eltInfoBar.id = "eltInfoBar"; - - const eltInfoName = document.createElement("div"); - eltInfoName.id = "eltInfoName"; - eltInfoName.innerText = "Select Person"; - eltInfoName.style.color = "firebrick" - - const eltInfoTime = document.createElement("div"); - eltInfoTime.id = "eltInfoTime"; - eltInfoTime.innerText = "setup"; - eltInfoTime.style.color = "firebrick" - - const eltInfoMsgPopup = document.createElement("div"); - eltInfoMsgPopup.id = "eltInfoMsgPopup"; - eltInfoMsgPopup.classList.add("eltInfoMsgPopup"); - eltInfoMsgPopup.classList.add("eltInfoMsgPopupHidden"); - eltInfoMsgPopup.style.setProperty('zIndex', '9999'); - - const tblEvlogContainer = document.createElement("div"); - tblEvlogContainer.id = "tblEvlogContainer" - - const tblEvlog = document.createElement("TABLE") - tblEvlog.id = "tblEvlog" - tblEvlog.classList.add("tblEvlog") - - const tblEvlogHdr = document.createElement("TH") - tblEvlogHdr.id = "tblEvlogHdr" - tblEvlogHdr.classList.add("tblEvlogHeader") - - const tblEvlogBody = document.createElement("BODY") - tblEvlogBody.id = "tblEvlogBody" - tblEvlogBody.classList.add("tblEvlogBody") - - // Style - const cssStyle = document.createElement('style'); - cssStyle.textContent = ` - /* Text special colors */ - .blue {color: blue;} - .teal {color: teal;} - .darkgray {color: darkgray;} - .dimgray {color: dimgray;} - .black {color: var(--primary-text-color);} - .silver {color: silver;} - .darkred {color: darkred;} - .green {color: green;} - .red {color: var(--label-badge-red);} - .redChg {color: var(--label-badge-red);} - .redbox {border: 1px solid var(--label-badge-red); border-collapse: collapse;} - - .iosappRecd {color: teal;} - .errorMsg {color: var(--label-badge-red); border-left: 2px solid var(--label-badge-red);} - .warningMsg {color: green;} - - /* Color for special records */ - /* DarkGoldenRod, Fushia, DeepPink, OrangeRed, #e600e6 (firebrickish), MediumVioletRed */ - .star1 {color: firebrick; border-left: 2px solid firebrick;} - .star2 {color: BlueViolet; border-left: 2px solid BlueViolet;} - .star3 {color: OrangeRed; border-left: 2px solid OrangeRed;} - .dollar1 {color: SeaGreen; border-left: 2px solid SeaGreen;} - .dollar2 {color: Var(--dark-primary-color); border-left: 2px solid var(--dark-primary-color);} - .dollar3 {color: Blue; border-left: 2px solid RoyalBlue;} - - - .trigger {color: var(--primary-text-color); font-weight: 300;} - .normalText {color: var(--primary-text-color);} - .event {colspan: 5;} - - /* Solid bars for update start/complete, startup stage recds, startup date recd */ - .hdrTopBottomShadow {-moz-box-shadow: inset rgba(0, 0, 0, 0.8) 0px 14px 18px -18px, inset #000000 0px -14px 18px -18px; - -webkit-box-shadow: inset rgba(0, 0, 0, 0.8) 0px 14px 18px -18px, inset #000000 0px -14px 18px -18px; - box-shadow: inset rgba(0, 0, 0, 0.8) 0px 14px 18px -18px, inset #000000 0px -14px 18px -18px; - } - .updateRecdHdr {color: white; - background-color: rgba(var(--rgb-primary-color), 0.85); - border-top: 1px solid var(--light-primary-color); - border-bottom: 1px solid var(--light-primary-color); - font-weight: 450; - } - .updateEdgeBar {border-left: 4px solid var(--dark-primary-color);} - .stageRecdHdr {color: white; - background-color: peru; font-weight: 450; - border-top: 1px solid peru; - border-bottom: 1px solid peru; - } - .stageEdgeBar {border-left: 4px solid peru;} - .dateBarHdr {color: white; - background-color: peru; - border-top: 1px solid peru; - border-bottom: 1px solid peru; - } - .noLeftEdge {border-left: none;} - - /* Card Definition */ - ha-card { - background-color: var(--card-background-color); - padding: 10px; - } - #background { - position: relative; - height: 681px; - /*width: 473px;*/ - } - - /* Title Bar set up */ - #titleBar { - position: relative; - display: inline-block; - height: 20px; - margin: 4px 0px -6px 0px; - width: 100%; - //border: 1px solid dodgerblue; - } - #title { - height: 100%; - width: 60%; - text-align: left; - font-size: 24px; - margin: 0px 0px 0px 0px; - float: left; - vertical-align: middle; - color: var(--primary-text-color); - //border: 1px solid var(--label-badge-red); - } - - #utilityBar { - position: relative; - display: inline-block; - margin: 2px 0px -10px 0px; - width: 100%; - /*border: 1px solid dodgerblue;*/ - } - #thisButtonId, #logRecdCnt, #devType, #hdrCellWidth { - /*font-size: 2px;*/ - color: transparent; - width: 25px; - float: left; - /*border: 1px solid green;*/ - } - - #versionText { - color: silver; - float: right; - } - - /* Store the theme's primary text color in the thisButtonId field */ - .themeTextColor { - color: var(--primary-text-color);} - background-color: var(--secondary-text-color);} - } - - /* Message Bar setup */ - #eltInfoBar { - position: relative; - width: 100%; - /*border: 1px solid dodgerblue;*/ - } - #eltInfoName { - width: 40%; - color: firebrick; - float: left; - font-size: 14px; - font-weight: 400; - margin: 2px 0px 4px 0px; - /*border: 1px solid var(--label-badge-red);*/ - } - #eltInfoTime { - margin: 2px 2px 4px 0px; - color: firebrick; - float: right; - font-size: 14px; - font-weight: 400; - /*border: 1px solid green;*/ - } - .eltInfoMsgPopup { - position: relative; - width: 85%; - margin-left: auto; - margin-right: auto; - color: white; - background-color: var(--label-badge-red); - padding: 12px 12px; - font-size: 14px; - font-weight: 400; - z-index: 9999; - -webkit-box-shadow: 5px 5px 23px 3px rgba(0,0,0,0.75); - -moz-box-shadow: 5px 5px 23px 3px rgba(0,0,0,0.75); - box-shadow: 3px 3px 20px 3px rgba(0,0,0,0.75); - } - .eltInfoMsgPopupHidden { - height: 0px; - width: 0px; - visibility: hidden; - border: 0px; - } - - /* Scrollbar */ - ::-webkit-scrollbar {width: 7px;} - ::-webkit-scrollbar-track {background-color: transparent; - border-left: 1px solid rgba(var(--rgb-primary-color), 0.2);} - ::-webkit-scrollbar-thumb {background: rgba(var(--rgb-accent-color), 0.7); - border-radius: 4px;} - ::-webkit-scrollbar-thumb:hover {background: var(--accent-color);} - - /* Event Log Table */ - .txtTblStyle {color: var(--dark-primary-color);} - .txtTblHdr {border-left: 1px solid var(--dark-primary-color);} - .txtTblHdrRow {color: var(--dark-primary-color);} - .txtTblEdge {border-left: 1px solid var(--dark-primary-color);} - - .highlightResults {color: red;} - .inprocessResults {color: firebrick;} - .highlightItem {color: silver;} - .highlightItemChg {color: silver;} - - #tblEvlog { - position: relative; - margin: 0px 0px; - width: 100%; - } - - /* Event Log Table */ - .tblEvlog { - position: sticky; - display: block; - table-layout: fixed; - width: 100%; - border-collapse: collapse; - } - .tblEvlogHdr { - position: sticky; - table-layout: fixed; - display: block; - width: 100%; - height: 16px; - padding: 0px 0px 3px 0px; - border-collapse: collapse; - background-color: rgba(var(--rgb-primary-color), 0.15); - border: 1px solid rgba(var(--rgb-primary-color), 0.3); - } - .tblEvlogHdr tr { - display: block; - } - .tblEvlogBody { - display: block; - table-layout: fixed; - width: 100%; - height: 560px; - border-collapse: collapse; - border: 1px solid rgba(var(--rgb-primary-color), 0.2); - border-top: 1px solid transparent; - overflow-y: scroll; - overflow-x: hidden; - -webkit-overflow-scrolling: touch; - } - .tblEvlogBody tr { - border: 1px solid rgba(var(--rgb-primary-text-color), 0.1); - z-index: 1; - } - - .noTopBorder {border: 1px solid transparent;} - .rowBorder {border-left: 2px solid cyan;} - .tblEvlogBody tr:nth-child(even) {background-color: rgba(var(--rgb-primary-text-color), 0.05);} - .tblEvlogBody tr:nth-child(odd) {background-color: var(--primary-background-color);} - - /* Browser Text */ - .colTime {width: 66.67px; vertical-align: text-top;} - .colStat {width: 92.22px; vertical-align: text-top;} - .colZone {width: 90.00px; vertical-align: text-top;} - .colIntv {width: 76.67px; vertical-align: text-top;} - .colTrav {width: 65.56px; vertical-align: text-top;} - .colDist {width: 62.22px; vertical-align: text-top;} - .colTimeTextRow {color: rgba(var(--rgb-primary-text-color), 0.5); vertical-align: text-top;} - .colText {color: var(--primary-text-color)} - - /* Browser Header */ - .hTime {width: 64.6px; text-align: left; color: var(--primary-text-color); padding-left: 4px;} - .hStat {width: 90.2px; text-align: left; color: var(--primary-text-color);} - .hZone {width: 88.0px; text-align: left; color: var(--primary-text-color);} - .hIntv {width: 73.6px; text-align: left; color: var(--primary-text-color);} - .hTrav {width: 63.6px; text-align: left; color: var(--primary-text-color);} - .hDist {width: 59.1px; text-align: left; color: var(--primary-text-color);} - .hdrBase {text-align: left; color: var(--primary-text-color);} - - /* Buttons */ - .buttonBar { - position: relative; - margin: 8px 0px 8px 0px; - width: 100%; - border: 1px solid blue; - } - .btnBaseFormat { - display: inline-block; - visibility: visible; - font-family: Roboto,sans-serif; - font-size: 14px; - font-weight: bolder; - color: var(--primary-text-color); - /*background-color: transparent;*/ - background-color: rgba(var(--rgb-primary-text-color), 0.05); - text-decoration: none; - text-align: center; - height: 24px; - padding: 1px 4px; - margin: 4px 6px 0px 0px; - border: 1px solid #ff4d4d; - border-radius: 3px; - box-sizing: border-box; - /*border: 1px solid #0080F0;*/ - } - .btnSelected { - color: white; - background-color: darkred; - } - .btnNotSelected { - color: var(--primary-text-color); - background-color: rgba(var(--rgb-primary-text-color), 0.04); - } - .btnHidden { - height: 0px; - width: 0px; - margin: 0px; - padding: 0px; - visibility: hidden; - border: 0px; - } - .btnHover {border: 1px solid var(--primary-color);} - - /* Refresh Select Button */ - #btnRefresh { - display: inline-block; - visibility: visible; - color: var(--primary-text-color); - background-color: transparent; - height: 24px; - margin: -4px 2px; - float: right; - box-sizing: border-box; - } - .btnRefresh { - border: 0px solid transparent; - background-color: transparent; - box-shadow: transparent; - } - svg {fill: #ff4d4d;} - svg:hover {fill: var(--primary-color);} - - /* Action Select Button */ - #btnAction { - color: white; - background-color: darkred; - float: right; - margin: 4px 2px 0 0; - border: 1px solid #ff4d4d; - } - #btnAction:hover {border: 1px solid var(--primary-color);} - .btnAction { - background: darkred; - font-weight: bolder; - height: 24px; - width: 80px; - border-radius: 3px; - overflow: hidden; - -webkit-appearance: none; - -moz-appearance: none; - transition: color 0.3s ease, background-color 0.3s ease, border-bottom-color 0.3s ease; - - /* Action Button Down Arrow */ - background-image: - linear-gradient(#cc0000, #cc0000), - linear-gradient(darkred 43%, transparent 35%), - linear-gradient(-135deg, transparent 58%, darkred 50%), - linear-gradient(-225deg, white 58%, darkred 50%); - background-size: 1px 100%, 22px 26px, 22px 26px, 22px 100%; - background-repeat: no-repeat; - background-position: right 20px center, right bottom, right bottom, right bottom; - - } - .btnAction::-ms-expand { - display: none; - } - .btnActionOptionGroup { - background-color: var(--primary-background-color); - color: var(--primary-text-color); - } - .btnActionOptionTransparent { - background-color: var(--primary-background-color); - color: var(--primary-text-color); - } - .btnActionOption { - background-color: var(--primary-background-color); - color: var(--primary-text-color); - } - - /* IPHONE IPAD Mods */ - /* iPhone with smaller screen*/ - @media only screen and (max-device-width: 640px), - only screen and (max-device-width: 667px), - only screen and (max-width: 480px) { - - ha-card {padding: 4px 4px 4px 4px;} - .btnBaseFormat {margin: 0px 2px 4px 0px; padding: 1px 3px;) - .btnAction {width: 45px; height: 22px;} - .updateRecd {font-weight: 450;} - - .ic3StartupMsg {font-weight: 450;} - .tblEvlogBody tr:nth-child(even) {background-color: #EEF2F5;} - ::-webkit-scrollbar {width: 1px;} - ::-webkit-scrollbar-thumb {background: rgba(var(--rgb-accent-color), 0.7);} - } - - /* iPad ??? - @media only screen - and (min-device-width : 768px) - and (max-device-width : 1024px) { - .updateRecd {font-weight: 450;} - .updateEdgeBar {border-left-width: 3px;} - .ic3StartupMsg {font-weight: 450;} - .tblEvlogBody tr:nth-child(even) {background-color: #EEF2F5;} - ::-webkit-scrollbar {width: 1px;} - ::-webkit-scrollbar-thumb {background: rgba(var(--rgb-accent-color), 0.7);} - } - */ - - `; - - // Build title - titleBar.appendChild(title); - titleBar.appendChild(btnRefresh); - - utilityBar.appendChild(thisButtonId); - utilityBar.appendChild(logRecdCnt); - utilityBar.appendChild(devType); - utilityBar.appendChild(hdrCellWidth); - utilityBar.appendChild(versionText); - - // Create Buttons - buttonBar.appendChild(btnName0); - buttonBar.appendChild(btnName1); - buttonBar.appendChild(btnName2); - buttonBar.appendChild(btnName3); - buttonBar.appendChild(btnName4); - buttonBar.appendChild(btnName5); - buttonBar.appendChild(btnAction); - buttonBar.appendChild(btnName6); - buttonBar.appendChild(btnName7); - buttonBar.appendChild(btnName8); - buttonBar.appendChild(btnName9); - - // Build Message Bar - eltInfoBar.appendChild(eltInfoName); - eltInfoBar.appendChild(eltInfoTime); - eltInfoBar.appendChild(eltInfoMsgPopup) - - tblEvlog.appendChild(tblEvlogHdr) - tblEvlog.appendChild(tblEvlogBody) - tblEvlogContainer.appendChild(tblEvlog) - - // Create Background - background.appendChild(titleBar) - background.appendChild(utilityBar) - background.appendChild(buttonBar) - background.appendChild(eltInfoBar) - background.appendChild(tblEvlogContainer) - background.appendChild(cssStyle); - - card.appendChild(background); - root.appendChild(card); - - // Click & Mouse Events - for (let i = 0; i <= 9; i++) { - let buttonId = 'btnName' + i - let button = root.getElementById(buttonId) - - button.addEventListener("mousedown", event => { this._nameButtonPress(buttonId); }); - button.addEventListener("mouseover", event => { this._btnClassMouseOver(buttonId); }); - button.addEventListener("mouseout", event => { this._btnClassMouseOut(buttonId); }); - } - - btnAction.addEventListener("change", event => { this._commandButtonPress("btnAction"); }); - //btnAction.addEventListener("mouseover", event => { this._btnClassMouseOver("btnAction"); }); - //btnAction.addEventListener("mouseout", event => { this._btnClassMouseOut("btnAction"); }); - - btnRefresh.addEventListener("mousedown", event => { this._commandButtonPress("btnRefresh"); }); - btnRefresh.addEventListener("mouseover", event => { this._btnClassMouseOver("btnRefresh"); }); - btnRefresh.addEventListener("mouseout", event => { this._btnClassMouseOut("btnRefresh"); }); - - // Add to root - this._config = config; - } - - // Create card. - //--------------------------------------------------------------------------- - set hass(hass) { - /* Hass will do this on a regular basis. If this is the first time - through, set up the button names. otherwise, display the event table. - */ - const root = this.shadowRoot - this._hass = hass - const thisButtonId = root.getElementById("thisButtonId") - const eltInfoTime = root.getElementById("eltInfoTime") - const eltInfoMsgPopup = root.getElementById("eltInfoMsgPopup") - - try { - const updateTimeAttr = hass.states['sensor.icloud3_event_log'].attributes['update_time'] - if (thisButtonId.innerText == "setup") { - this._setupDevType() - this._setupButtonNames() - this._nameButtonPress(this._currentButtonId()) - } - - //this._displayNameMsgL('/'+eltInfoTime.innerText + '/'+updateTimeAttr+'/') - if (eltInfoTime.innerText.indexOf(updateTimeAttr) == -1) { - this._setupEventLogTable('hass') - } - eltInfoMsgPopup.classList.add('eltInfoMsgPopupHidden') - } - catch(err) { - const eltInfoMsgPopup = root.getElementById("eltInfoMsgPopup") - const eltInfoTime = root.getElementById("eltInfoTime") - - if (eltInfoMsgPopup.classList.contains('eltInfoMsgPopupHidden') == false) { - return - } - const msgRestarting = '

iCloud3 is restarting

' - const msgNotRunning = '

iCloud3 Status:
     • restarting,
     • not running,
     • not installed,
     • has not been set up or,
     • there are other errors.


Be sure you have setup the iCloud3 device_tracker platform in the HA configuration.yaml file.

Check for iCloud3 load errors in the HA Logs here:
HA Sidebar>Configuration>Logs.

Review the iCloud3 documentation for more information here:
https://gcobb321.github.io/icloud3/#/chapters/1-installing-icloud3

' - - if (err.name == 'TypeError') { - if (err.message.indexOf('attributes') > -1) { - //if (eltInfoTime.innerText == 'setup') { - if (thisButtonId.innerText == "setup") { - eltInfoMsgPopup.innerHTML = msgNotRunning - } else { - eltInfoMsgPopup.innerHTML = msgRestarting - } - eltInfoMsgPopup.classList.remove('eltInfoMsgPopupHidden') - } else if (err.message.indexOf('undefined') == -1) { - alert(err) - } - } else { - alert(err) - } - } - } - - //--------------------------------------------------------------------------- - _setupButtonNames() { - /* Cycle through the sensor.icloud3_event_log attributes and - build the names on the buttons, and make them visible. - */ - const root = this.shadowRoot; - const hass = this._hass; - const thisButtonId = root.getElementById("thisButtonId") - const btnName0 = root.getElementById("btnName0") - const filtername = hass.states['sensor.icloud3_event_log'].attributes['filtername'] - const namesAttr = hass.states['sensor.icloud3_event_log'].attributes['names'] - const names = Object.values(namesAttr) - var nameCnt = names.length - - thisButtonId.innerText = 'btnName0' - - if (nameCnt > 10) {nameCnt = 10} - for (var i = 0; i < nameCnt; i++) { - let buttonId = 'btnName' + i - var button = root.getElementById(buttonId) - - //Get button for data in current sensor.icloud3_event_log - if (filtername == names[i]) { - thisButtonId.innerText = buttonId - } - - if (i < names.length) { - button.innerText = names[i] - button.style.setProperty('visibility', 'visible'); - button.classList.remove('btnHidden') - button.classList.add('btnNotSelected') - - //} else { - // button.innerText = "Device-"+i - // button.style.setProperty('visibility', 'visible'); - // button.classList.remove('btnHidden') - } - } - } - -//--------------------------------------------------------------------------- - _setupEventLogTable(devicenameParm) { - /* Cycle through the sensor.icloud3_event_log attributes and - build the event log table - */ - - const root = this.shadowRoot; - const hass = this._hass; - const tblEvlog = root.getElementById("tblEvlog") - const eltInfoTime = root.getElementById("eltInfoTime") - const hdrCellWidth = root.getElementById("hdrCellWidth") - const logRecdCnt = root.getElementById("logRecdCnt") - const devType = root.getElementById("devType") - const thisButtonId = root.getElementById("thisButtonId") - - var logAttr = hass.states['sensor.icloud3_event_log'].attributes['logs'] - - /* - The Evlog table has been built and displayed but Hass usually calls this routine a - second time. No need to builds tblEvlog again but now go back thru and - update the header cell lengths - */ - - if (logAttr.length == logRecdCnt.innerText) { - if (hdrCellWidth.innerText.startsWith('0,')) { - this._resize_header_width() - } - //this._displayNameMsgL('Return len=/'+logAttr.length+'/'+logRecdCnt.innerText) - return - } - - this._checkNameButtonSelected() - - if (logAttr.length > 0) { - var logEntriesRaw = logAttr.slice(2,-2) - var logEntries = logEntriesRaw.split('], [',99999) - } - - logRecdCnt.innerText = logAttr.length - let row = 0 - var sameTextCnt = 0 - var infoTimeText = "" - - var iPhoneP = false - var iPhoneL = false - var iPad = false - var iPadP = false - var iPadL = false - if (devType.innerText == "phnP") {iPhoneP = true} - else if (devType.innerText == "phnL") {iPhoneL = true} - else if (devType.innerText == "padP") {iPadP = true} - else if (devType.innerText == "padL") {iPadL = true} - if (devType.innerText.startsWith("pad") > 0) {iPad = true} - - /* Field naming conventions (xTime examples appply to all cell fields): - thTime = Header text for Time column - hTime = hTime header Id & Class name - iTime = iTime data cell Id name - classTime = iTime class name - tTime = Time text for current record - nTime = Time text for next record - */ - - var cellWidth = hdrCellWidth.innerText.split(',') - var thTime = "Time" - var thStat = "iOS App" - var thZone = "iC3 Zone" - var thIntv = "Interval" - var thTrav = "Travel" - var thDist = "Distance" - if (iPhoneP) { - thIntv = "Intvl" - thTrav = "Travl" - thDist = "Dist" - } - let logTableHeadHTML = '' - logTableHeadHTML += '' - logTableHeadHTML += '' - logTableHeadHTML += ''+thTime+'' - logTableHeadHTML += ''+thStat+'' - logTableHeadHTML += ''+thZone+'' - logTableHeadHTML += ''+thIntv+'' - logTableHeadHTML += ''+thTrav+'' - logTableHeadHTML += ''+thDist+'' - - logTableHeadHTML += ' ' - logTableHeadHTML += '' - logTableHeadHTML += '' - - let logTableHTML = '' - logTableHTML += '
' - logTableHTML += '' - logTableHTML += logTableHeadHTML - logTableHTML += '' - - /* - Example of log file string: - [['10:54:45', 'home', 'Home', '0 mi', '', '2 hrs', 'Update via iCloud Completed'], - ['10:54:45', 'home', 'Home', '0 mi', '', '2 hrs', 'Interval basis: 4iz-InZone, Zone=home, Dir=in_zone'], - ['10:54:45', 'home', 'Home', '0 mi', '', '2 hrs', 'Location Data Prepared (27.72682, -80.390507)'], - ['10:54:45', 'home', 'Home', '0 mi', '', '2 hrs', 'Preparing Location Data'], ['10:54:45', 'home', 'Home', '0 mi', '', '2 hrs', 'Update via iCloud, nextUpdateTime reached'], - ['10:54:33', 'home', 'Home', '0 mi', '', '2 hrs', 'Update cancelled, Old location data, Age 18.9 min, Retry #1']] - - Data extraction steps: - 1. Drop '[[' and ']]' at each end. - 2. Split on '], ][' to create a list item for each record. - 3. Cycle through list records. Split on ', ' to create each element. - */ - if (logAttr.length == 0) { - return - } - - var completedItemHighlightNextRowFlag = false - var classEdgeBar = '' - var cancelEdgeBarFlag = false - var initializationRecdFound = false - var iosappUpdateCompleteFlag = false - var icloudUpdateCompleteFlag = false - var alertErrorMsg = "" - - for (var i = 0; i < logEntries.length-1; i++) { - var thisRecd = logEntries[i].split("', '",10) - - var tTime = thisRecd[0].slice(1) - var tStat = thisRecd[1] - var tZone = thisRecd[2] - var tIntv = thisRecd[3] - var tTrav = thisRecd[4] - var tDist = thisRecd[5] - var tText = thisRecd[6].slice(0,-1) - - var nextRecd = logEntries[i+1].split("', '",10) - var nStat = nextRecd[1] - var nZone = nextRecd[2] - var nIntv = nextRecd[3] - var nTrav = nextRecd[4] - var nDist = nextRecd[5] - var nText = nextRecd[6].slice(0,-1) - - var thisRecdTestChg = tStat + tZone + tIntv + tTrav + tDist - var nextRecdTestChg = nStat + nZone + nIntv + nTrav + nDist - - var maxStatZoneLength = 10 - if (iPhoneP) { - tText = tText.replace('/icloud3','... .../icloud3') - maxStatZoneLength = 9 - if (tStat == 'stationary') {tStat = 'stationry'} - if (tZone == 'stationary') {tZone = 'stationry'} - if (tStat == 'Stationary') {tStat = 'Stationry'} - if (tZone == 'Stationary') {tZone = 'Stationry'} - } - if (tStat.length > maxStatZoneLength) { - tStat = tStat.substr(0, maxStatZoneLength) + "
" + tStat.substr(maxStatZoneLength, tStat.length) - if (tStat.length > maxStatZoneLength*2) {tStat = tStat.substr(0, maxStatZoneLength*2 + 3) + "..."} - } - if (tZone.length > 8) { - tZone = tZone.substr(0, maxStatZoneLength) + "
" + tZone.substr(maxStatZoneLength, tZone.length) - if (tZone.length > maxStatZoneLength*2) {tZone = tZone.substr(0, maxStatZoneLength*2 + 3) + "..."} - } - if (tText == nText) { - ++sameTextCnt - if (sameTextCnt == 1) {var firstTime = tTime} - continue - } - if (sameTextCnt > 0) { - tTime = firstTime - tText += ' (+'+ sameTextCnt +' more times)' - sameTextCnt = 0 - } - - var classTime = 'colTime' - var classStat = 'colStat' - var classZone = 'colZone' - var classIntv = 'colIntv' - var classTrav = 'colTrav' - var classDist = 'colDist' - var classText = 'colText' - - var highlightResultsFlag = false - //This is set when the previous item was an update complete item - if (completedItemHighlightNextRowFlag) { - highlightResultsFlag = true - completedItemHighlightNextRowFlag = false - - //display the info in var(--label-badge-red) if starting an update - } else if (tText.indexOf("update started") >= 0) { - highlightResultsFlag = true - - //Display info in first row in var(--label-badge-red) - } else if (row == 0) { - highlightResultsFlag = true - } - if (highlightResultsFlag ) { - classTime += ' highlightResults' - classStat += ' highlightResults' - classZone += ' highlightResults' - classIntv += ' highlightResults' - classTrav += ' highlightResults' - classDist += ' highlightResults' - } else { - classTime += ' inprocessResults' - classStat += ' inprocessResults' - classZone += ' inprocessResults' - classIntv += ' inprocessResults' - classTrav += ' inprocessResults' - classDist += ' inprocessResults' - } - - //Set header recd background bar color and turn edge bar on/off - //Set Startup start/complete & stage bar colors and edge bars - var classRecdType = ' normalText' - var classHeaderBar = '' - if (tText.indexOf("update started") >= 0) { - classHeaderBar = ' updateRecdHdr' - cancelEdgeBarFlag = true - } else if (tText.indexOf("update complete") >= 0) { - if (tText.indexOf("iOS App") >=0) { - iosappUpdateCompleteFlag = true - } else { - icloudUpdateCompleteFlag = true - } - completedItemHighlightNextRowFlag = true - classHeaderBar = ' updateRecdHdr' - classEdgeBar = ' updateEdgeBar' - - } else if (tText.startsWith("^^^")) { - cancelEdgeBarFlag = (tText.indexOf("started") >= 0) - classHeaderBar = ' dateBarHdr' - classEdgeBar = ' stageEdgeBar' - tText = tText.replace("^^^", "") - tText = tText.replace("^^^", "") - } else if (tText.indexOf("Stage") >= 0) { - classHeaderBar = ' stageRecdHdr' - classEdgeBar = ' stageEdgeBar' - } else if (tText.indexOf("Warning") >= 0) { - classHeaderBar = ' warningMsg' - } - - if (classHeaderBar != "") { - classHeaderBar = " hdrTopBottomShadow" + classHeaderBar - } - - //Set text color the text starts with a special color character - var classSpecialTextColor = '' - var specialColorFlag = true - if (tText.startsWith("$$$")) { - classSpecialTextColor = ' dollar3' - tText = tText.slice(3) - } else if (tText.startsWith("$$")) { - classSpecialTextColor = ' dollar2' - tText = tText.slice(2) - } else if (tText.startsWith("$")) { - classSpecialTextColor = ' dollar1' - tText = tText.slice(1) - } else if (tText.startsWith("***")) { - classSpecialTextColor = ' star3' - tText = tText.slice(3) - } else if (tText.startsWith("**")) { - classSpecialTextColor = ' star2' - tText = tText.slice(2) - } else if (tText.startsWith("*")) { - classSpecialTextColor = ' star1' - tText = tText.slice(1) - } else if (tText.startsWith("__")) { - classSpecialTextColor = ' normalText' - } else { - specialColorFlag = false - classSpecialTextColor = '' - } - - var classErrorMsg = "" - if (tText.indexOf("Initializing iCloud3") >= 0 - && tText.indexOf("Complete") == -1) { - initializationRecdFound = true - } - if (tText.indexOf(" Error ") >= 0) { - classErrorMsg = ' errorMsg' - if (initializationRecdFound == false) { - alertErrorMsg = "iCloud3 Error Msg at "+tTime - } - } else if (tText.indexOf("iCloud Alert") >= 0) { - classErrorMsg = ' errorMsg' - if (initializationRecdFound == false - && icloudUpdateCompleteFlag == false) { - alertErrorMsg = "iCloud Alert Msg at "+tTime - } - } else if (tText.indexOf("iOS App Alert") >= 0) { - classErrorMsg = ' errorMsg' - if (initializationRecdFound == false - && iosappUpdateCompleteFlag == false) { - alertErrorMsg = "iOS App Alert Msg at "+tTime - } - } else if (tText.indexOf("Alert") >= 0) { - classErrorMsg = ' errorMsg' - if (initializationRecdFound == false) { - alertErrorMsg = "Alert Msg at "+tTime - } - } else if (tText.startsWith("!")) { - classErrorMsg = ' errorMsg' - tText = tText.slice(1) - } - - //Change CRLF in the text string to HTML >b> for a new line - while (tText.indexOf("CRLF•") >= 0) { - tText = tText.replace("CRLF•","
   •") - } - while (tText.indexOf("CRLF") >= 0) { - tText = tText.replace("CRLF", "
") - } - - //If displaying a table, the State & Interval can contain column titles - var classTable = '' - var txtTblFlag = false - if (tText.indexOf("¤s") >= 0) { - txtTblFlag = true - classTable = ' txtTblStyle' - } - - //Build the table HTML from the special characters in the recd - // ¤s=
Table start, Row start - // ¤e=
Row end, Table end - // §= Row end, next row start - // «40= Col start, 40% width - // ¦0= Col end, next col start - // ¦10= Col end, next col start-width 10% - // ¦40= - tText = tText.replace(/¤s/g, '') - tText = tText.replace(/¤e/g, '
') - - tText = tText.replace(/«HS/g, '') - tText = tText.replace(/¦LH-/g, '') - tText = tText.replace(/¦RH-/g, '') - tText = tText.replace(/»HE/g, '') - - tText = tText.replace(/«LT-/g, ' ') - tText = tText.replace(/¦LC-/g, '') - tText = tText.replace(/¦RT-/g, ' ') - tText = tText.replace(/¦RC-/g, '') - - tText = tText.replace(/»/g, '') - - //Abbreviate text if displaying on an iPhone with a smaller display - if (iPhoneP) { - tIntv = tIntv.replace(' sec','s') - tIntv = tIntv.replace(' min','m') - tIntv = tIntv.replace(' hrs','h') - tIntv = tIntv.replace(' hr','h') - - tTrav = tTrav.replace(' sec','s') - tTrav = tTrav.replace(' min','m') - tTrav = tTrav.replace(' hrs','h') - tTrav = tTrav.replace(' hr','h') - - tDist = tDist.replace(' mi','mi') - tDist = tDist.replace(' km','km') - - if (txtTblFlag) { - tText = tText.replace("App Updates", "App Updts") - tText = tText.replace("Rqsts", "") - tText = tText.replace("Trigger Chgs", "Trig Chg") - tText = tText.replace("Locate", "Loc") - } - } - - //Determine if the state/zone/dist/time line should be displayed - var displayStateZoneLineFlag = false - if (thisRecdTestChg != nextRecdTestChg) {displayStateZoneLineFlag = true} - if (classTime.indexOf("highlightResults") >= 0) {displayStateZoneLineFlag = true} - if (tStat == '' && tZone == '') {displayStateZoneLineFlag = false} - if (tText.startsWith("^^^")) {displayStateZoneLineFlag = false} - - if (row > 1) {classTime += classEdgeBar} - - //Display Info Row - if (displayStateZoneLineFlag) { - if (tIntv == '') {tIntv = ' '} - if (tTrav == '') {tTrav = ' '} - if (tDist == '') {tDist = ' '} - - ++row - logTableHTML += '' - logTableHTML += ''+tTime+'' - logTableHTML += ''+tStat+'' - logTableHTML += ''+tZone+'' - logTableHTML += ''+tIntv+'' - logTableHTML += ''+tTrav+'' - logTableHTML += ''+tDist+'' - logTableHTML += '' - } - - //continue - //Display Text Row - //tText = classTime - classTime = classTime.replace("highlightResults", "") - classTime = classTime.replace("inprocessResults", "") - classTime += classRecdType + classHeaderBar - classTime += ' colTimeTextRow' - - if (classTime.indexOf("Hdr") >= 0) { - classText += ' noLeftEdge' - tTime = '' - } - var classTextColor = classHeaderBar + classSpecialTextColor + classTable + classErrorMsg - if (classTextColor != "") { - classText = classText.replace("colText", classTextColor) - } - - ++row - logTableHTML += '' - logTableHTML += ''+tTime+'' - logTableHTML += '' - logTableHTML += ''+tText+'' - logTableHTML += '' - - if (cancelEdgeBarFlag) { - classEdgeBar = '' - cancelEdgeBarFlag = false - } - } - - logTableHTML += '' - //} - logTableHTML += '
' - tblEvlog.innerHTML = logTableHTML - - this._resize_header_width() - - const updateTimeAttr = hass.states['sensor.icloud3_event_log'].attributes['update_time'] - const logLevelDebug = hass.states['sensor.icloud3_event_log'].attributes['log_level_debug'] - const optEvlog = root.getElementById("optEvlog") - const optHalog = root.getElementById("optHalog") - const optStartuplog = root.getElementById("optStartuplog") - - if (logLevelDebug.indexOf("evlog") >= 0) { - optEvlog.text = "Hide Tracking Monitors" - } else { - optEvlog.text = "Show Tracking Monitors" - } - if (logLevelDebug.indexOf("halog") >= 0) { - optHalog.text = "Stop HA Debug Logging" - infoTimeText = "Debug Log, "+updateTimeAttr - } else { - optHalog.text = "Start HA Debug Logging" - } - - if (alertErrorMsg != "") { - infoTimeText = "●● " + alertErrorMsg - } - - this._displayTimeMsgR(infoTimeText) - } - -//--------------------------------------------------------------------------- - _resize_header_width() { - const root = this.shadowRoot; - const tblEvlog = root.getElementById("tblEvlog") - const devType = root.getElementById("devType") - var rowCnt = tblEvlog.rows.length - - //Get, reset and save header cell widths - var hdrCellWidthStr = '' - rowCnt = tblEvlog.rows.length - for (var row = 1; row < rowCnt-1; row++) { - var cellCnt = tblEvlog.rows[row].cells.length - var cellWidth = tblEvlog.rows[row].cells[1].offsetWidth - if (cellCnt > 2 && cellWidth != 0) { - for (var i = 0; i < cellCnt; i++) { - var cellBCRObj = tblEvlog.rows[row].cells[i].getBoundingClientRect() - var cellWidthBCR = cellBCRObj.width + 2 - if (i == 5 && devType.innerText == "") {cellWidthBCR -= 10} - hdrCellWidthStr += cellWidthBCR + 'px,' - tblEvlog.rows[0].cells[i].style.width = cellWidthBCR+'px' - } - //alert(hdrCellWidth.innerText = row + ',' + hdrCellWidthStr) - return - } - } - - return - } -//--------------------------------------------------------------------------- - _setupDevType() { - const root = this.shadowRoot; - const devType = root.getElementById("devType") - - //iPhone (portrait) width=375, ,height=768 - // (landscape) width=724, ,height=375 - //iPad (portrait) width=834, ,height=1092 - // (landscape) width=1112, height=814 - //Windows (portrait) width=1424, height=921 - - var deviceWidth = window.innerWidth - var deviceHeight = window.innerHeight - - const userAgentStr = navigator.userAgent - var userAgentAlamofire = userAgentStr.indexOf("Alamofire") - var userAgentHA = userAgentStr.indexOf("HomeAssistant") - var appleDevice = userAgentAlamofire + userAgentHA - if (appleDevice > 0) { - if (deviceWidth < 400 && deviceHeight < 800) { - devType.innerText = "phnP" - } else if (deviceWidth < 800 && deviceHeight < 400) { - devType.innerText = "phnL" - } else if (deviceWidth < 850 && deviceHeight > 800) { - devType.innerText = "padP" - } else if (deviceWidth > 800 && deviceHeight < 850) { - devType.innerText = "padL" - } - } - //alert('/'+devType.innerText+'/') - } - -//--------------------------------------------------------------------------- - _checkNameButtonSelected() { - /* Simulate name button press using the name returned from HA when building - the tblEvlog table. If the selected name was changed on one device and - and then the Event Log was displayed in another, the Log revs are for the - selected device but the name highlighter will be for the precious device selected. - */ - const root = this.shadowRoot; - const hass = this._hass; - this.namesAttr = hass.states['sensor.icloud3_event_log'].attributes['names'] - var filtername = hass.states['sensor.icloud3_event_log'].attributes['filtername'] - const namesAttr = this.namesAttr - const names = Object.values(namesAttr) - const btnName0 = root.getElementById("btnName0") - var lastButtonId = this._currentButtonId() - var lastButtonPressed = root.getElementById(lastButtonId) - - if (filtername == null) { - lastButtonPressed.classList.add('btnNotSelected') - lastButtonPressed.classList.remove('btnSelected') - - this._displayNameMsgL("Select Person") - return - - } else if (filtername == "Initialize") { - this._setupButtonNames() - this._nameButtonPress("btnName0") - //btnName0.classList.remove('btnNotSelected') - //btnName0.classList.add('btnSelected') - } - - for (var i = 0; i < 10; i++) { - if (names[i] == null) {break} - if (filtername == names[i]) { - let buttonId = 'btnName' + i - - if (buttonId != lastButtonId) { - this._nameButtonPress(buttonId) - } - } - } - } -//--------------------------------------------------------------------------- - _nameButtonPress(buttonPressId) { - /* Handle the button press events. Get the devicename, do an 'icloud3_update' - event_log devicename' service call to have the event_log attribute populated. - */ - const root = this.shadowRoot; - const hass = this._hass; - this.namesAttr = hass.states['sensor.icloud3_event_log'].attributes['names'] - const namesAttr = this.namesAttr - const names = Object.values(namesAttr) - const devicenames = Object.keys(namesAttr) - const logRecdCnt = root.getElementById("logRecdCnt") - const thisButtonId = root.getElementById("thisButtonId") - const thisButtonPressed = root.getElementById(buttonPressId) - - var lastButtonId = this._currentButtonId() - var lastButtonPressed = root.getElementById(lastButtonId) - var buttonPressX = buttonPressId.substr(-1) - var eltInfoName = names[buttonPressX]+" ("+devicenames[buttonPressX]+")" - - this._displayNameMsgL(eltInfoName) - thisButtonId.innerText = buttonPressId - - lastButtonPressed.classList.remove('btnSelected') - lastButtonPressed.classList.add('btnNotSelected') - thisButtonPressed.classList.remove('btnNotSelected') - thisButtonPressed.classList.add('btnSelected') - thisButtonPressed.classList.remove("btnHover") - - this._hass.callService("device_tracker", "icloud3_update", { - device_name: devicenames[buttonPressX], - command: 'refresh_event_log'}) - } - -//--------------------------------------------------------------------------- - _commandButtonPress(actionButton) { - /* Handle the button press events. Get the devicename, do an 'icloud3_update' - event_log devicename' service call to have the event_log attribute populated. - */ - const root = this.shadowRoot; - const hass = this._hass; - this.namesAttr = hass.states['sensor.icloud3_event_log'].attributes['names'] - const namesAttr = this.namesAttr - const devicenames = Object.keys(namesAttr) - const btnAction = root.getElementById('btnAction') - const logRecdCnt = root.getElementById("logRecdCnt") - const thisButtonId = root.getElementById("thisButtonId") - - const btnName0 = root.getElementById('btnName0') - var lastButtonId = this._currentButtonId() - var lastButtonPressed= root.getElementById(lastButtonId) - var buttonPressX = lastButtonId.substr(-1) - var actionDevicename = devicenames[buttonPressX] - - if (actionButton == "btnRefresh") { - this._hass.callService("device_tracker", "icloud3_update", { - device_name: actionDevicename, - command: "refresh_event_log"}) - - } else if (actionButton == "btnAction") { - var actionValue = btnAction.value - var actionIndex = btnAction.selectedIndex - btnAction.options[actionIndex].selected = false - - if (actionValue == "dev-refresh_event_log") { - actionDevicename = "startup_log" - } - - //Device Actions - if (actionValue.startsWith("dev-")) { - actionValue = actionValue.slice(4) - - this._hass.callService("device_tracker", "icloud3_update", { - device_name: actionDevicename, - command: actionValue}) - - //Global Actions - } else { - this._hass.callService("device_tracker", "icloud3_update", { - command: actionValue}) - - if (actionValue == "restart") { - thisButtonId.innerText = "setup" - logRecdCnt.innerText = "-1" - } - } - - //Lose btnAction focus to reset selected option - btnAction.blur() - } - } - -//--------------------------------------------------------------------------- - _btnClassMouseOver(buttonId) { - - const root = this.shadowRoot; - const button = root.getElementById(buttonId) - const thisButtonId = root.getElementById("thisButtonId") - const versionText = root.getElementById("versionText") - const devType = root.getElementById("devType") - - if (buttonId == "btnRefresh") { - this._displayTimeMsgR("Refresh Event Log") - versionText.style.setProperty('visibility', 'visible'); - - } else if (buttonId == "btnAction") { - this._displayTimeMsgR("Show Action Command List") - } - - if (devType.innerText == "") {button.classList.add("btnHover")} - } - //--------------------------------------------------------------------------- - _btnClassMouseOut(buttonId) { - - const root = this.shadowRoot; - const hass = this._hass; - this.logLevelDebug = hass.states['sensor.icloud3_event_log'].attributes['log_level_debug'] - const button = root.getElementById(buttonId) - const versionText = root.getElementById('versionText') - const devType = root.getElementById("devType") - const thisButtonId = root.getElementById("thisButtonId") - - if (buttonId == 'btnRefresh') { - versionText.style.setProperty('visibility', 'hidden') - } - - if (devType.innerText == "") {button.classList.remove("btnHover")} - this._displayTimeMsgR("") - } -//--------------------------------------------------------------------------- - _currentButtonId() { - const root = this.shadowRoot; - const thisButtonId = root.getElementById("thisButtonId") - - return thisButtonId.innerText - } -//--------------------------------------------------------------------------- - _displayTimeMsgR(msg) { - // Display message before time field - const root = this.shadowRoot; - const hass = this._hass; - const eltInfoTime = root.getElementById("eltInfoTime") - const updateTimeAttr = hass.states['sensor.icloud3_event_log'].attributes['update_time'] - - //if (eltInfoTime.innerText.startsWith("●●")) { - //pass - if (msg == "") { - eltInfoTime.innerHTML = "Refreshed: " + updateTimeAttr - } else { - eltInfoTime.innerHTML = msg - } - } - - //--------------------------------------------------------------------------- - _displayNameMsgL(msg) { - /* Display test messages */ - const root = this.shadowRoot; - const eltInfoName = root.getElementById("eltInfoName") - eltInfoName.innerHTML = msg - } - - //--------------------------------------------------------------------------- - getCardSize() { - return 1; - } - -} - - -customElements.define('icloud3-event-log-card', iCloud3EventLogCard); diff --git a/development area - v2.2.1/pyicloud_ic3.py b/development area - v2.2.1/pyicloud_ic3.py deleted file mode 100644 index 4edea47..0000000 --- a/development area - v2.2.1/pyicloud_ic3.py +++ /dev/null @@ -1,924 +0,0 @@ -""" -Customized version of pyicloud.py to support iCloud3 Custom Component - -Platform that supports importing data from the iCloud Location Services -and Find My Friends api routines. Modifications to pyicloud were made -by various people to include: - - Original pyicloud - picklepete & Quantame - - https://github.com/picklepete - - - Updated and maintained by - Quantame - - - Find My Friends Update - Z Zeleznick - -The piclkepete version used imports for the services, utilities and exceptions -modules. They have been modified and are now maintained by Quantame & Z Zeleznick. -These modules have been incorporated into the pyicloud_ic3.py version used by iCloud3. -""" - -VERSION = '2.2.0' - -""" -8/20/20 - - Changed the _raise_error routine to catch the '2sa needed and missing WEB Cookie' error - to generate a Authentication error code rather than generating it's own 2SA Needed Exception -""" - -from six import PY2, string_types, text_type -from uuid import uuid1 -import inspect -import json -import logging -from requests import Session -import sys -from tempfile import gettempdir -from os import path, mkdir -from re import match -import http.cookiejar as cookielib - -LOGGER = logging.getLogger(__name__) - -#Device Status Codes -DEVICE_STATUS_ONLINE = 200 -DEVICE_STATUS_OFFLINE = 201 -DEVICE_STATUS_PENDING = 203 -DEVICE_STATUS_UNREGISTERED = 204 -AUTHENTICATION_REQUIRED = 450 -DEVICE_STATUS_ERROR = 500 - -''' -#Other Device Status Codes -SEND_MESSAGE_MSG_DISPLAYED = 200 -REMOTE_WIPE_STARTED = 200 -REMOVE_DEVICE_SUCCESS = 200 -UPDATE_LOCATION_PREF_SUCCESS = 200 -LOST_MODE_SUCCESS = 200 -PLAY_SOUND_SUCCESS = 200 -PLAY_SOUND_NEEDS_SAFETY_CONFIRM = 203 -SEND_MESSAGE_MSG_SENT = 205 -REMOTE_WIPE_SENT = 205 -LOST_MODE_SENT = 205 -LOCK_SENT = 205 -PLAY_SOUND_SENT = 205 -LOCK_SERVICE_FAILURE = 500 -SEND_MESSAGE_FAILURE = 500 -PLAY_SOUND_FAILURE = 500 -REMOTE_WIPE_FAILURE = 500 -UPDATE_LOCATION_PREF_FAILURE = 500 -LOCK_SUCC_PASSCODE_SET = 2200 -LOCK_SUCC_PASSCODE_NOT_SET_PASSCD_EXISTS = 2201 -LOCK_SUCCESSFUL_2 = 2204 -LOCK_FAIL_PASSCODE_NOT_SET_CONS_FAIL = 2403 -LOCK_FAIL_NO_PASSCD_2 = 2406 -''' - -class PyiCloudPasswordFilter(logging.Filter): - """Password log hider.""" - - def __init__(self, password): - super(PyiCloudPasswordFilter, self).__init__(password) - - def filter(self, record): - message = record.getMessage() - if self.name in message: - record.msg = message.replace(self.name, "*" * 8) - record.args = [] - - return True - - -class PyiCloudSession(Session): - """iCloud session.""" - - def __init__(self, service): - self.service = service - Session.__init__(self) - - def request(self, method, url, **kwargs): # pylint: disable=arguments-differ - - # Charge logging to the right service endpoint - callee = inspect.stack()[2] - module = inspect.getmodule(callee[0]) - request_logger = logging.getLogger(module.__name__).getChild("http") - if self.service.password_filter not in request_logger.filters: - request_logger.addFilter(self.service.password_filter) - - request_logger.debug(f"106 REQUEST -- {25*'-'}") - request_logger.debug(f"106 REQUEST -- {method}, {url}, {kwargs.get('data', '')}") - #request_logger.info(f"{method}, {url}, {kwargs.get("data", "")}"") - - kwargs_retry_flag = kwargs.get("retried", None) - kwargs.pop("retried", None) - - response = super(PyiCloudSession, self).request(method, url, **kwargs) - - request_logger.debug(f"115 RESPONSE -- {response}, StatusCode-{response.status_code}, okStatus-{response.ok}") - request_logger.debug(f"110 RESPONSE -- {25*'-'}") - - content_type = response.headers.get("Content-Type", "").split(";")[0] - json_mimetypes = ["application/json", "text/json"] - - kwargs_retry_flag = True - if not response.ok and content_type not in json_mimetypes: - try: - data = response.json() - request_logger.debug(f"120 RESPONSE ERROR DATA -- {data}") - except: - pass - - if (kwargs_retry_flag is None - and (response.status_code == AUTHENTICATION_REQUIRED - or response.status_code == DEVICE_STATUS_ERROR)): - message = (f"Reauthentication Required for Account: {self.service.user['apple_id']}") - request_logger.debug(f"133 MESSAGE -- {message}") - - api_error = PyiCloudAPIResponseException( - message, response.status_code, retry=True - ) - - request_logger.info(api_error) - kwargs["retried"] = True - return self.request(method, url, **kwargs) - - self._raise_error(response.status_code, response.reason) - - if content_type not in json_mimetypes: - return response - - try: - data = response.json() - except: # pylint: disable=bare-except - request_logger.warning("Failed to parse response with JSON mimetype") - return response - - #request_logger.debug(f"150 RESPONSE DATA -- {data}") - #request_logger.info(data) - - reason = data.get("errorMessage") - reason = reason or data.get("reason") - reason = reason or data.get("errorReason") - if not reason and isinstance(data.get("error"), string_types): - reason = data.get("error") - if not reason and data.get("error"): - reason = "Unknown reason" - - code = data.get("errorCode") - if not code and data.get("serverErrorCode"): - code = data.get("serverErrorCode") - - if reason: - self._raise_error(code, reason) - - return response - - def _raise_error(self, code, reason): - if (self.service.requires_2sa - and reason == "Missing X-APPLE-WEBAUTH-TOKEN cookie"): - code = AUTHENTICATION_REQUIRED - - if code == AUTHENTICATION_REQUIRED or code == DEVICE_STATUS_ERROR: - reason = (f"Authentication Required for Account: {self.service.user['apple_id']}") - api_error = PyiCloudAPIResponseException(reason, code) - #LOGGER.info(api_error) - - raise (api_error) - - elif (self.service.requires_2sa - and reason == "Missing X-APPLE-WEBAUTH-TOKEN cookie"): - raise PyiCloud2SARequiredException(self.service.user["apple_id"]) - - - elif code in ("ZONE_NOT_FOUND", "AUTHENTICATION_FAILED"): - reason = ("Please log into https://icloud.com/ to manually " - "finish setting up your iCloud service") - api_error = PyiCloudServiceNotActivatedException(reason, code) - LOGGER.error(api_error) - - raise (api_error) - - elif code == "ACCESS_DENIED": - reason += (". Please wait a few minutes then try again." - "The remote servers might be trying to throttle requests.") - - api_error = PyiCloudAPIResponseException(reason, code) - - LOGGER.error(api_error) - raise api_error - - -class PyiCloudService(object): - """ - A base authentication class for the iCloud service. Handles the - authentication required to access iCloud services. - - Usage: - from pyicloud import PyiCloudService - pyicloud = PyiCloudService('username@apple.com', 'password') - pyicloud.iphone.location() - """ - - HOME_ENDPOINT = "https://www.icloud.com" - SETUP_ENDPOINT = "https://setup.icloud.com/setup/ws/1" - #"logout_no_services": "https://setup.icloud.com/setup/ws/1/logout", - - def __init__( - self, - apple_id, - password=None, - cookie_directory=None, - verify=True, - client_id=None, - with_family=True, - ): - if password is None: - password = get_password_from_keyring(apple_id) - - self.data = {} - self.client_id = client_id or str(uuid1()).upper() - self.with_family = with_family - self.user = {"apple_id": apple_id, "password": password} - - self.password_filter = PyiCloudPasswordFilter(password) - LOGGER.addFilter(self.password_filter) - - self._base_login_url = f"{self.SETUP_ENDPOINT}/login" - - if cookie_directory: - self._cookie_directory = path.expanduser(path.normpath(cookie_directory)) - else: - self._cookie_directory = path.join(gettempdir(), "pyicloud") - - self.session = PyiCloudSession(self) - self.session.verify = verify - self.session.headers.update( - { - "Origin": self.HOME_ENDPOINT, - "Referer": f"{self.HOME_ENDPOINT}/", - "User-Agent": "Opera/9.52 (X11; Linux i686; U; en)", - } - ) - - cookiejar_path = self._get_cookiejar_path() - self.session.cookies = cookielib.LWPCookieJar(filename=cookiejar_path) - if path.exists(cookiejar_path): - try: - self.session.cookies.load() - LOGGER.debug(f"Read cookies from {cookiejar_path}") - except: # pylint: disable=bare-except - # Most likely a pickled cookiejar from earlier versions. - # The cookiejar will get replaced with a valid one after - # successful authentication. - LOGGER.warning(f"Failed to read cookiejar {cookiejar_path}") - - self.params = { - "clientBuildNumber": "17DHotfix5", - "clientMasteringNumber": "17DHotfix5", - "ckjsBuildVersion": "17DProjectDev77", - "ckjsVersion": "2.0.5", - "clientId": self.client_id, - } - - self.authenticate() - - self._files = None - self._photos = None - - def authenticate(self): - """ - Handles authentication, and persists the X-APPLE-WEB-KB cookie so that - subsequent logins will not cause additional e-mails from Apple. - """ - - LOGGER.info(f"Authenticating as {self.user['apple_id']}") - - data = dict(self.user) - - # We authenticate every time, so "remember me" is not needed - #data.update({"extended_login": False}) - data.update({"extended_login": True}) - - try: - req = self.session.post( - self._base_login_url, params=self.params, data=json.dumps(data) - ) - except PyiCloudAPIResponseException as error: - msg = "Invalid email/password combination." - raise PyiCloudFailedLoginException(msg, error) - - self.data = req.json() - self.params.update({"dsid": self.data["dsInfo"]["dsid"]}) - self._webservices = self.data["webservices"] - - if not path.exists(self._cookie_directory): - mkdir(self._cookie_directory) - self.session.cookies.save() - LOGGER.debug(f"Cookies saved to {self._get_cookiejar_path()}") - - LOGGER.info("Authentication completed successfully") - LOGGER.debug(self.params) - - def _get_cookiejar_path(self): - """Get path for cookiejar file.""" - return path.join( - self._cookie_directory, - "".join([c for c in self.user.get("apple_id") if match(r"\w", c)]), - ) - - @property - def requires_2sa(self): - """Returns True if two-step authentication is required.""" - return ( - self.data.get("hsaChallengeRequired", False) - and self.data["dsInfo"].get("hsaVersion", 0) >= 1 - ) - # FIXME: Implement 2FA for hsaVersion == 2 # pylint: disable=fixme - - @property - def trusted_devices(self): - """Returns devices trusted for two-step authentication.""" - request = self.session.get( - f"{self.SETUP_ENDPOINT}/listDevices", params=self.params - ) - return request.json().get("devices") - - def send_verification_code(self, device): - """Requests that a verification code is sent to the given device.""" - data = json.dumps(device) - request = self.session.post( - f"{self.SETUP_ENDPOINT}/sendVerificationCode", - params=self.params, - data=data, - ) - LOGGER.info(f"Send Trusted Device ID result-{request.json()}") - return request.json().get("success", False) - - #This is called from the iCloud3 in the _icloud_handle_verification_code_entry - #code routine - def validate_verification_code(self, device, code): - """Verifies a verification code received on a trusted device.""" - #LOGGER.info(f"Verification code-{code}") - device.update({'verificationCode': code, 'trustBrowser': True}) - data = json.dumps(device) - - try: - self.session.post( - f"{self.SETUP_ENDPOINT}/validateVerificationCode", - params=self.params, - data=data, - ) - except PyiCloudAPIResponseException as error: - LOGGER.info(f"Verification Error code-{error.code}") - if error.code == -21669: - # Wrong verification code - return False - #raise - - # Re-authenticate, which will both update the HSA data, and - # ensure that we save the X-APPLE-WEBAUTH-HSA-TRUST cookie. - self.authenticate() - - return not self.requires_2sa - - def logout(self): - """ Logout from iCloud """ - try: - req = self.session.post( - f"{self.SETUP_ENDPOINT}/logout", - params=self.params, - ) - except Exception as error: - msg = f"Error Logging out of Apple Web Services - {error}" - api_error = PyiCloudAPIResponseException(msg, 1) - LOGGER.error(api_error) - raise api_error - - def close_session(self): - """ Close current Session """ - try: - req = self.session.close() - - except Exception as error: - msg = f"Error Closing Session - {error}" - api_error = PyiCloudAPIResponseException(msg, 1) - LOGGER.error(api_error) - raise api_error - - - def _get_webservice_url(self, ws_key): - """Get webservice URL, raise an exception if not exists.""" - if self._webservices.get(ws_key) is None: - raise PyiCloudServiceNotActivatedException( - "Webservice not available", ws_key - ) - return self._webservices[ws_key]["url"] - -###################################################################### - @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 - ) - - @property - def friends(self): - """Gets the 'Friends' service.""" - service_root = self._get_webservice_url("fmf") - return FindFriendsService(service_root, self.session, self.params) - - @property - def contacts(self): - """Gets the 'Contacts' service.""" - service_root = self._get_webservice_url("contacts") - return ContactsService(service_root, self.session, self.params) - - def __unicode__(self): - return f"iCloud API: {self.user.get('apple_id')}" - - def __str__(self): - as_unicode = self.__unicode__() - if PY2: - return as_unicode.encode("utf-8", "ignore") - return as_unicode - - def __repr__(self): - return "<{str(self)}>" -#################################################################################### -# -# Find my iPhone service -# -#################################################################################### -"""Find my iPhone service.""" -#import json -#from six import PY2, text_type -#from pyicloud.exceptions import PyiCloudNoDevicesException - -class FindMyiPhoneServiceManager(object): - """The 'Find my iPhone' iCloud service - - This connects to iCloud and return phone data including the near-realtime - latitude and longitude. - """ - - def __init__(self, service_root, session, params, with_family=False): - self.session = session - self.params = params - self.with_family = with_family - - fmip_endpoint = f"{service_root}/fmipservice/client/web" - self._fmip_refresh_url = f"{fmip_endpoint}/refreshClient" - self._fmip_sound_url = f"{fmip_endpoint}/playSound" - self._fmip_message_url = f"{fmip_endpoint}/sendMessage" - self._fmip_lost_url = f"{fmip_endpoint}/lostDevice" - - self._devices = {} - self.refresh_client() - - def refresh_client(self): - """Refreshes the FindMyiPhoneService endpoint, - - This ensures that the location data is up-to-date. - - """ - req = self.session.post( - self._fmip_refresh_url, - params=self.params, - data=json.dumps( - { - "clientContext": { - "fmly": self.with_family, - "shouldLocate": True, - "selectedDevice": "all", - "deviceListVersion": 1, - } - } - ), - ) - self.response = req.json() - - for device_info in self.response["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, - ) - else: - self._devices[device_id].update(device_info) - - if not self._devices: - raise PyiCloudNoDevicesException() - - def __getitem__(self, key): - if isinstance(key, int): - 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): - as_unicode = self.__unicode__() - if PY2: - return as_unicode.encode("utf-8", "ignore") - return as_unicode - - def __repr__(self): - return text_type(self) - - -class AppleDevice(object): - """Apple device.""" - - def __init__( - self, - content, - session, - params, - manager, - sound_url=None, - lost_url=None, - message_url=None, - ): - self.content = content - self.manager = manager - self.session = session - self.params = params - - self.sound_url = sound_url - self.lost_url = lost_url - self.message_url = message_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 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}, - } - ) - 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 - ): - """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, - "sound": sounds, - "userText": True, - "text": message, - } - ) - 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="" - ): - """Send a request to the device to trigger 'lost mode'. - - The device will show the message in `text`, and if a number has - been passed, then the person holding the device can call - the number without entering the passcode. - """ - data = json.dumps( - { - "text": text, - "userText": True, - "ownerNbr": number, - "lostModeEnabled": True, - "trackingEnabled": True, - "device": self.content["id"], - "passcode": newpasscode, - } - ) - self.session.post(self.lost_url, params=self.params, data=data) - - @property - def data(self): - """Gets the device data.""" - return self.content - - def __getitem__(self, key): - return self.content[key] - - def __getattr__(self, attr): - return getattr(self.content, attr) - - def __unicode__(self): - display_name = self["deviceDisplayName"] - name = self["name"] - return f"{display_name}: {name}" - - def __str__(self): - as_unicode = self.__unicode__() - if PY2: - return as_unicode.encode("utf-8", "ignore") - return as_unicode - - def __repr__(self): - return f"" -#################################################################################### -# -# Find my Friends service -# -#################################################################################### -"""Find my Friends service.""" -#from __future__ import absolute_import -#import json - -class FindFriendsService(object): - """ - The 'Find My' (FKA 'Find My Friends') iCloud service - - This connects to iCloud and returns friend data including - latitude and longitude. - """ - - def __init__(self, service_root, session, params): - self.session = session - self.params = params - self._service_root = service_root - #self._friend_endpoint = "%s/fmipservice/client/fmfWeb/initClient" % (self._service_root,) - self._friend_endpoint = f"{self._service_root}/fmipservice/client/fmfWeb/initClient" - self.refresh_always = False - self.response = {} - - def refresh_client(self): - """ - Refreshes all data from 'Find My' endpoint, - """ - params = dict(self.params) - # This is a request payload we mock to fetch the data - mock_payload = json.dumps( - { - "clientContext": { - "appVersion": "1.0", - "contextApp": "com.icloud.web.fmf", - "mapkitAvailable": True, - "productType": "fmfWeb", - "tileServer": "Apple", - "userInactivityTimeInMS": 537, - "windowInFocus": False, - "windowVisible": True, - }, - "dataContext": None, - "serverContext": None, - } - ) - req = self.session.post(self._friend_endpoint, data=mock_payload, params=params) - self.response = req.json() - - @staticmethod - def should_refresh_client_fnc(response): - """Function to override to set custom refresh behavior""" - return not response - - def should_refresh_client(self): - """ - Customizable logic to determine whether the data should be refreshed. - - By default, this returns False. - - Consumers can set `refresh_always` to True or assign their own function - that takes a single-argument (the last reponse) and returns a boolean. - """ - return self.refresh_always or FindFriendsService.should_refresh_client_fnc( - self.response - ) - - @property - def data(self): - """ - Convenience property to return data from the 'Find My' endpoint. - - Call `refresh_client()` before property access for latest data. - """ - if not self.response or self.should_refresh_client(): - self.refresh_client() - return self.response - - def contact_id_for(self, identifier, default=None): - """ - Returns the contact id of your friend with a given identifier - """ - lookup_key = "phones" - if "@" in identifier: - lookup_key = "emails" - - def matcher(item): - """Returns True iff the identifier matches""" - hit = item.get(lookup_key) - if not isinstance(hit, list): - return hit == identifier - return any([el for el in hit if el == identifier]) - - candidates = [ - item.get("id", default) for item in self.contact_details if matcher(item) - ] - if not candidates: - return default - return candidates[0] - - def location_of(self, contact_id, default=None): - """ - Returns the location of your friend with a given contact_id - """ - candidates = [ - item.get("location", default) - for item in self.locations - if item.get("id") == contact_id - ] - if not candidates: - return default - return candidates[0] - - @property - def locations(self): - """Returns a list of your friends' locations""" - return self.data.get("locations", []) - - @property - def followers(self): - """Returns a list of friends who follow you""" - return self.data.get("followers") - - @property - def following(self): - """Returns a list of friends who you follow""" - return self.data.get("following") - - @property - def contact_details(self): - """Returns a list of your friends contact details""" - return self.data.get("contactDetails") - - -#################################################################################### -# -# Exceptions (exceptions.py) -# -#################################################################################### -"""Library exceptions.""" - - -class PyiCloudException(Exception): - """Generic iCloud exception.""" - pass - - -# API -class PyiCloudAPIResponseException(PyiCloudException): - """iCloud response exception.""" - def __init__(self, reason, code=None, retry=False): - self.reason = reason - self.code = code - message = reason or "" - if code: - message += (f" (Error Code {code})") - if retry: - message += ". Retrying ..." - - super(PyiCloudAPIResponseException, self).__init__(message) - - -class PyiCloudServiceNotActivatedException(PyiCloudAPIResponseException): - """iCloud service not activated exception.""" - pass - - -# Login -class PyiCloudFailedLoginException(PyiCloudException): - """iCloud failed login exception.""" - pass - - -class PyiCloud2SARequiredException(PyiCloudException): - """iCloud 2SA required exception.""" - def __init__(self, apple_id): - message = f"Two-Step Authentication (2SA) Required for Account {apple_id}" - super(PyiCloud2SARequiredException, self).__init__(message) - - -class PyiCloudNoStoredPasswordAvailableException(PyiCloudException): - """iCloud no stored password exception.""" - pass - - -# Webservice specific -class PyiCloudNoDevicesException(PyiCloudException): - """iCloud no device exception.""" - pass - - -#################################################################################### -# -# Utilities (utils.py) -# -#################################################################################### -"""Utils.""" -import getpass -import keyring -import sys -#from .exceptions import PyiCloudNoStoredPasswordAvailableException - -KEYRING_SYSTEM = "pyicloud://icloud-password" - - -def get_password(username, interactive=sys.stdout.isatty()): - """Get the password from a username.""" - try: - return get_password_from_keyring(username) - except PyiCloudNoStoredPasswordAvailableException: - if not interactive: - raise - - return getpass.getpass( - "Enter iCloud password for {username}: ".format(username=username,) - ) - - -def password_exists_in_keyring(username): - """Return true if the password of a username exists in the keyring.""" - try: - get_password_from_keyring(username) - except PyiCloudNoStoredPasswordAvailableException: - return False - - return True - - -def get_password_from_keyring(username): - """Get the password from a username.""" - result = keyring.get_password(KEYRING_SYSTEM, username) - if result is None: - raise PyiCloudNoStoredPasswordAvailableException( - "No pyicloud password for {username} could be found " - "in the system keychain. Use the `--store-in-keyring` " - "command-line option for storing a password for this " - "username.".format(username=username,) - ) - - return result - - -def store_password_in_keyring(username, password): - """Store the password of a username.""" - return keyring.set_password(KEYRING_SYSTEM, username, password,) - - -def delete_password_in_keyring(username): - """Delete the password of a username.""" - return keyring.delete_password(KEYRING_SYSTEM, username,) - - -def underscore_to_camelcase(word, initial_capital=False): - """Transform a word to camelCase.""" - words = [x.capitalize() or "_" for x in word.split("_")] - if not initial_capital: - words[0] = words[0].lower() - - return "".join(words) diff --git a/icloud3.zip b/icloud3.zip index 36ebb84e15d6d2a41abf73e78f00f3a5d26889c7..3bc3a748f2f9ffc1491e1be6bc2c61f26dbbad6e 100644 GIT binary patch delta 114812 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

t4b-IIqgMED*6ts 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 z

La7=l*$1S7lnpKFGH?DYS4%fK36WN-k=Ck=X0ZPtR%qGYlwbo*-FwF@iE`98j; zhws*+xHC_BkS~wC`suzYcqJzCVz=EtC&f6oVEH~f6{Kwk=W9{F#SXqY__}$wa)>N+ z@3sAv^_vOR9pswgidu5;@o_WZSLSpa7Nx+ZrL3llJ(O~C*E_C%p2{#Sj=H`dNUqhE$&=!v$|v$PIgpT5^a8(91jhA1ev^^9-D0>Y zSh^G^D6KU`;8@~+4CQWVjym>|b7pJwePt`N434oW@GMULFkNIzk*4EvansVF;R#ct z_$OK0hIzC6^}rMts>uPAnDU$G1f=GPLLKyhXAj(Gdqv$o!e5@! zS3U^+gE=IB&^-D5Wc0(JM?H_}6ZSrQ!g}_Y+vQ{OAtOf28M5cCn;*vOTPgK*9%O8= zp}S_fhwq)M^p{v*sNzN0AVmiSv>+>+Zi5n^-uzI!1?{V&A9UR@8o~uf3=N(6r0&eh(Bm79x0TkO# zOa1Mqc#q6>V2go7P4KOg!xK4y06&PE1?YsN^=f*nutISWByFOE@-hXMf>h6#GKq6N z08R;i4lfMCi3eiLzj*tt2w!TIdvgwT)#(Vlgak1iDbCZ&Nf6OfnWgaF35N7#qtK@H zNWR51yv~jg4)e-?+z3By2!4=Y>2kt(`#b#qcUC{w&K|b^Tc=rY(e9hFgFsZZM^Eib z>pFq^vBCTBQwJA)>xpfZ+y7Dyy1Qst~)op z4_2JpFT;CG4#%rFIlgZ8MImZfvNc6f)_TbctmNY&pq>j|x^IjSxC-TXxWjLf5^)%R zSd=JImSak)3tZ@Ple;5e!^BI$EB6ja2!Xj+8#TY9ID%ivYAxz-F})vC?6rA-K;mD9 zDWEu(hG?*7UoYn1U+;Sp?>%>Cd@y6sO_u=>JzKm)KSE|VGce1sk)^xh7rJ<&ox6}& z#KVPb>SAOMigx1k6DWi)+-@jwZsuu!$Q$zI!81|b@2i8SXYG|7mdsrxR(;E9%gz_7 z>;L8C6zo4pfEXaAcAwNZ22$aO^yFzVsu?q)=%T$^&0>j9jYOQ{a)^T|I9n17@r)=n z?pLTFF9I!N&`K{75UZlfRGEaTvH-^9n?N>x1T@V{+^661^>lJxq*T#lTzpc0doG3x z^!V9lU(A4(DLSiz>ctM{^92NVpbJ{7B=mx(f<8%px=AuHZoYrpUKylxI4YSqDF?%k zI`pDJvngzd4dQ2bD1tLG?U|)LuaOj&=9@D!q|}bj(N58Ic4^z>Lw^ zkQDAwAx6=`VRCtzj}mK=clh~#QeuFCKY^tS9=1_i?;%B<79tg#64 zlT4^+3&=|>=tvn!Zn@20*khfOM~=$pI!T72I=&VRk`nin+nV_Tb1%1lVZ^)Cl^Y~z z$dbFa(*|hlp-s;|kASvJWZQB-BR>FznRnFte4b_^v=5GH%8xj53zF3{eIVS-CcwV* zHt&q!cxmu&tGa*UkL7p}zF`=>s#(24q+iS5GMwn6*s*b%Yw|0IS4G)`4)Akt;Vy7a zDCgKdR#oAya+Kv!*|*?-Y%VncZ6t+aj+_A%*(=o%F7W@l+m+n#2tY`4&pTst_qZqG z#7~J9p%y={zpqjF{V^>E_^^jT?BVWnPq0GaO?wm)MnLVXPy}Dh-HG3u4v(LI=+@WS zbIzSmKPwpVypW1^ZlvcQ=`NaZ;V!&nCU0t$;lUD`D~BY;^B8u23wt|+vHI}j>Cy8K zo_#F{;m_Z9RXg=1D6U*G68U9QbgZTv=qbUs05*xWaXJa4)XvLesS+1R=N$TEWpK41HAV` ziTi-@A*k`_WfM*cZ0lTntKaKr-2$cOqYld){%ZO&C{2$Oh74SPG$ovbckPvbcsHBukl}rG044m=86&!(Sx={$|bv#XVa&^+XL5(Efji>?J`AT}Vyh_?| z5+{XA4|h0!**BUrC?@*X@-_J9&%fyf;`zZdc64%be8L)F_f(>Qa@iE+MpOpGhWq0F z2yT~phJZDGM{QQ48<*nCt1?wn+~ zFIk1my^Wyq#?;8&t_D|L7dCzaf>)j!Yvq89;apIEJ{}|q{vyFys#$5t;_!;#WeNb7 z0TROPdOi;F5bgMoW8!+|giTL8{Di3Ua(j=7yQB1Cnd^wgUre?R7l|%LiY7EO&|WJg z29Jog5)sMgFY3|+hCaK(CZ<2Iig?!xO_BBG0t7k{61xK2#b7=j@K5oB6tAkVl>GPc zyab$oSppWj=pw5GewO?y|11H7-U)+?IlN$FKasNpe-(Knj9-Bl%HbJ>B&aVkkwTFF zA&H?$ckV9*Ujrej&=RAUVwchHYDwHL;#R`zr6-CXy;1_jT0rbTe%|%?RE}n8@8~?? zZjm@=ldv>{j#Q`8N#~!F>dxI`wcT2wvDg@YitRard#y1zZJho(2$ledQm>1KlNFSA zse*FJ;01kq#C*9i0`&S4`knyie^30C_kEU#EHjcUg69R!!2%Zg_rSHto-MC7ujm;b znSSh?IBTF1G8+(2bI)u6#+jz~y)#1G-K%6W21V`$4>~m%)Eq4wCL-fN?glXi91&)J zSBYqZum5^P{bXX*)1&97$0q~Km?M+g2Pn%&6too+G^h>aR6v77O2>NCvQ+roQI2#N zX<49&DF6a~1VeavM#9t5w*7L<1--bz`}pM3gR`7*`8h>}cL22Ct#nR&bQ=!5CW92Q z%0d^-=|%kpR6;zJrpXYVEGbn!R=$pZ$+=Ig7U(Yt>90W!ws5S35%v$l@DFV%lP$Jb zUl8m+q=MVhzFh2X0>65_0;ykFmn-yeuT&UMs5fHF@{O$j$`7AwRv7;3`m#N}zO1h~ zX-#AqEDZn$0I&iI)Ao3|x$&oiF>qBi3G@^m9sz;!!Nn8cp4ZzELsb#@5a0TLVlW84 z5Yjo~9bQ1O&GRrJaR8v`Ksrf~Fk7#CYS@SF>ATkar1$m64qpKxvxMA9Q z@PcDQtE@#HLtzD1kA=E0b=nxI%jsKF!}n?}8M!{iNIO)Exq0Z%Qif5w9XIFeU^r29Wt(5-Fn;!luP&$OH?70DuwWWB;FxmqI^ZE z7h6{+JLP}zjfk)&oA2V^iT)8wH*$$EcmD(0%2x4XkynHNY;9}fWwNokmmc?VBtwzZ zIW2|sI_Y#g#LAHf<}tZKNVop>+xiogFLktDNDuc8L%#((@O(g zK6JDgQpXG+M&2EAm=(|9jBAaYeL>)#KyzFr4@@Bn0#KD}P8}NeyywGn9)+oLr3{n6smk0bmto6E^v#5y_xMcYRZwAw?O7)6r!T1t){X-76B(%Ka%t zhbTrM{V5uMbalrtzB(TCn!;1A9<{E#{#G4&wY-8=!!?$FXtOn3q&JucLvj^tDyJ}# zw{Oqu6jsX-Yv@=&RmwoP-XuKW(+0o;kg|+DTF+>^1X%A@P``tm*Fgtxu%I5xko*zg zz{*?^W_HPb4KxyHIhP9yJ66#SbjB$nIJ+@)7;&`-xmgfgf6^B@<}&n5khNf0zhjCf zvfV7jp&WI8len=iQK?6^vpi{W9W44l@@bpyLYdw+yKLB~8?iA}g;dIp#>mK@Vck}70ry6(S6q$FMqy{hrhd_Yy z1dW&CCke~KJ-qx{(k~)%u{_%x&m5lC% z5PrCS*Rg+y_+_$`vC+-BvZRAknRxs}vSgHz#GDB={;G-@&tdZTiFvQ1!OzXBpbDER zdR3NUs^N4cM@aF3YwuDK@R)B9EAHb}+p_55f#sBCbPaMYa|!P9bJof6(NSmT`25M) zg>6QWuAR>2StMV1tbmJO9%n{guX-N?-8*Z4!H;6+8o1@zbpHNSo#GMwi5KktOJZ!H z8_rNOW3=;GocmY+UNlyEUt!5R1w#~~$f9@V`-w{QuOk^->~}WtJSkEvH){{fAG*@f zi}|iVp({QI99sEDz#;oJFQ)mJd2v7QM?qpl;Wz)zgrzCUvNu>l;x6fQzrA8N`WwW5 z1JrJ@g6zvXws!PO8m(sGv!W)Y`gCWYu8*eN-bz6YZYvqB!WXBOH|3-s0s>75EH}-X z6Z|eSdRbSAYO3yI>>|_c^?0f<0? z0zbu+N{YaWQp`xZ1bh}#v)`JPkjNYw&X4A$c!0-0D5!?Oi{2`K%<-h`qTFD|I8uag zN@zmvbN{#Cl6anx#6zBW7IssqYVt7(Q^?J`asdiO$U@izQ^PF`Qxi&n4vJY=iaS}# zpwlVadZPYdrzUh;m=(^HkR2mcsgWL`a}ED9woQmR4ehrn79r>6n5Kw$i=v&46nz{p zi*p=jbCbkTFMDx>C!y?r6||fNNcc|rC0-7D9rLBs z$qkTK6`~M(-+^h&*(tu(9o>oN9!g=%#R_vu@DJpwKHT}U1Kt}$KG6L`^ep=}>L&xt z&KVC)F;M{1lbr9f3uoW+Qz!ELk%*8vhd~v2xOq1V7@lL|sN^mDz_4K?S|n*Yi+gBY zWnC?xSK}#vr5+U!iLgGPthWFMUDObRxQiL(d}Z;n*G_>C=|Y=?_7ioX z5k@H5F(td8fGF(3sZ|Cs-o-$zWEVt@b+J0J_ME`9B!x7~Q9uK`NU>NvibrdUw9U)Y zCb1uY9dUb3dBt)5Z3k|gNeX=9A3)>_8h}E>8-=F!u=yta>fx3BX2 zEj+`i(XiX&l|{+jR8PZh0cCNFH-H-icV+_Bh$@;Abn1=nz_S~BliwtMB}Hj-0GDa3 z*tjVgkH7jl6O^^M3Pm3%gp^NWf=)i(DOl-$E)iz&k<9>Ms*5u1T?0dS`*hZa+z(mp zNfzFjumkY{17!lN*z+tnl`S6Q9hUB>!*P!bk-M6QP?dwP2go<~0GN4B-cwlcn9cCY7&%U)8JsqwNlRB1={HpsE zW@(%xaKBqhP>62O8#2SQ^!)SWKQQWl8}8D!gUqs%1kl8ie`rrg!KyT+&w7xz4;agr zlvI7jK0rnUa`lInjpDgOu6h>s>%d|Roqo@qWb{P6SMRQRe$seu*m$lN6%74!2(Lt) zK3q1&6Vsad%EtQUt9B=q0JXhIezi&MwDnf9~vu9#x%3SzZTI=!6hnD=e>m&WX7zC0F+L(<^^#x zN$`CWK{${h@WtCl&C%^+W`Fg6`9NtUlw3Se&Ow`64g~U`Du6(36p$QOEI$xDm9UK; z5M7fN0ZoQPdx25Z^T{WNJ4eUokV#-?vvc^t$#MIvb9%nBvv+#Be}05ssZL0te}P3Z zfxOjWEixy;A@FAFH3yXH#m(4!nOta@9r?HAJ5pi;*-@L6%zx(svliBWpDe+Ic6tCS zkOz~f666J_qT11m_ZG?l+wp|%8*D{5Do}_mmfHh~A-W?spchp#iF-0hyEld+FE@mg zZd?L3qccAk`5x@Z!`lS(@2_GMe7$C%lK~m{R-j;M;nOca4@jcA$HEe9Y`NnhN_Jh> z9a7?LLc&qNcM$RgNE{Ub4q8ob=JzTrxa3@QzOK@8cs~# zOSuE0WOw<{ZWrJRctG}3!B<)HSs{WpxR9Yhv7+~O9e%(@%YEWEfR*j<@*GmA_*Gu- zQwn|FKXU?Q``Z&h^aEe+y>p=M%Q({w)4J3XvyAJ{l31;ON63t}=~F!`7yDpjnRR__ z(IZg3NT8u89d>!bLsrJw?d6uVvM8x$Bm`C+Q=}O{{AsjI%Wx!S5{aoqx7XG98O!DJzkYa$BL)%@l_eYioO0aWxxV+26UVB9=aQmLL)92J{q?Yu(mTtcDd zL7UxIX**{(XENDZ(LQv0Qm5cbqfv3EtOEUl0~c0*)pG92$EI!j*<*VACeNX44-9=O zB@`2D4;1xcO@&fn_oi;;Jn8iAK>uaPRZ>B$cmr4U$cl$_50at;9;JFvEMrg)U4sq7 z%2FBwdgvjNMMo&^J?g;1KLQTjGT|wuLsLqGr7WJsvcp>39603ECmU>|K@a(Gu5348 zEVbZ&aY#j6?H-ne{fM{c=tmr0qMy#^W$?7P-LIS!@Vqsg>X*a$Rwu=1i)S+?$)xC} zJeZjtlx{S+eqSl{-ge^8RgWd_Aai;lx^L!GOWc#)2^;;EbT(R3AzxRl~WLww+wuLV==?L$#G65RpZiiGKnE5DHbmV^m++y z;aT;clfDbjC$TvqYWk_^kFNe?*1HI3uUBF*Pi^%lt8i>kd#4K9(!_^uW+Gl$pkq0K zvjDi7@l+Px&e4GE8+1s^=_J6EA;Ma*d2UN1IwPnhplaf2Z`nCo;^<*56L#YcXsK;~ z7AurbS#xAqWh4j|vC`V`bDH2s@_ohkC~G7gt`YOz@4z zhFpsP08l`$zb+p)Ur_jC-~BxcPWHm1-I6Hbf0HiODhkIgRx1L=PDXhui9g+d%rd~r zAS4U_QG-6J&R}9;nVsV62*1zC8AGV4&zFW$O zWRgyWC=y$|@2S2{Y1aqi+jz3#$<$&d;;~J4+Pn}UH#rEybGx9Qjgc=|WxvuL++24X zfA_}39vWYbzpK>HE-*KG+?<&|L@AsulL=TMmW>u0r5t83l?G%38ziHpqo;-{i6fiW zKZT|_T3`;n5Xequw1Nqk*K0LIDHyWXg;@sm@6g4F)VCT6L9_zgwbk4OB&e=r$ICB&^I$lG+klWio7?qHf&+T;6ie4{jZ z3i+CjxsNSn%;=6NaF8pLg%6O8Qy7c@KkbpsQF_L_1R=}L0XlY>fR=Ke_?G} z35%A%g$3US!*X4|O9pY-*sF~@Ot1>p)8UHK#J5ib=7n&ui64#_!YKcC@5l1t>Ch?m zhM?X4E_SMaaU7P9JXFbeeyUJ+ml(8|#mThHt7}d*Fy1g!UzRslAO52m+=yKf+zfmD$_L4al}^-|xTsI}JJtu-Ahb`L z%OnFq{#{OGGxQwPOC}eDMzC-Q-gYS&*VKtd7EHeh!&jQeEu2Db{yx88e{bVYkDox} zLhom-$5HF_jCBj`fsH$7hac>Ldh@}_MLt4Q5BtN`(M1UMAGJPZkS_un6B@gRfW(uX zJ>}`)ZM)Mt+6iq7NCd+r;*odo-tI-7+p^p1`~;kd`D}xFJ=yzsj$T^X9i60WTkw~c zt}yj>=NJR=S|9GgvgIxUe{ZG8C5Yv3dx%QuMGzxf{6&9MDxlf)l4S!*(lOJ(SKv~< zwf_2r7g+23>|pQ7*&(e{(UjhSfA6(FvAxLi+ICRUKR+$p3j4?I-o!#%(LoFSx}?-V zy^p8X(w>1LXj$>k@96CC?0k3cA^>Gb#Cz6h?d+VlTRWc=u=noXe}{)Vdwg~Ubc1d1 z5pdf_d+p8#$AC&^V+!+pw|#tm;`c4Pg1qfFeqKJ@IXiFfSu5ja(s7L=e$L%_|M1DX zIy5>k^D_HJD|sP)wTgcz7HYcL;W?1`CgGplMBJ?Fl8+_QOejf0!2{R3F{&p?Jn9YZ zSSZxEuZmByprolMe;$f1%(;(V1i(p@<-Z||$BGJE$H?)pvK3WYx81u6C={Z$HGL^7 zS7#(9ndZNdkV9jBi%GYa%u-PKi&_k`VPI@&@@>pVDXd;a9SiDnM%!Gw%qn1Y4P!Rb zctUaTXrmph$b%H9IEfKdr502-jlOf0%{IY6@r6s!yz34Jf034B6M?(hTQB} zOg84cwyn=&N=f)-Y*?!1QZxIhO8frzAO_r;r84wDSFPe?rCGs>ll+E`KWta&=jw~H zix|hhqV5)Gg7XM$uN4VE*p~T39K6V=;GqV|J$i3ys#8P3D58A7f_)qJ5)V+;aj3?< z9F=r%7osQ@f9g)buz@*L@EgNSM^eWZa{j1ctYiAM!knC}@7o!rblS^T`hEQS+c*g9cE2a$%#4=<5S)=M26YZ8+Y9 z_6nLIoMEqu)EE|p{$D+`kviUuRO67yEBsZ!qk46CF{SuGDywvx7n7y7y{OvWrX`h8(9!4A>)#vAYfe@u≫KpIa@W~AZ_I#z-#9QBa8f1-5el6i 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�m^te0Ts z&F2y?c|JzG&+BeI0#yW#PlL(imJ1N+D1V!M;_fjV1v$blvNRWq*+NSPRuUoDO&Mkh zP2qsuURgpP!b*!o2z1yfOX&4=c$+0Ou97v_Cod5i4I%oxETL0~Hk1fe(Giv!7Af{b zzb$2o!+J?h@Xk1{BhLa^*ry`WLTQ167AjX>nJAu{j2e5f04jG&m9`50y&_AAzJJPu zCzDp^zUV70yI)yl5`Cq?-y?lhJs3_~-TR_H_wfucPQMyK$&T5yx+(;hzSw0FIvTM| zc<*#lVdSE2sf$+>Td4!n$hXy(sgkoheA|?64({5%aaRC#;lTpF*9f2`Z-S3-uXmr; zyTZsz|DKi$pU+8XMFfX2o>T3vojaGmHir^2^g$Rf?n{;076wbFpU0|Dw90-!QhY4;T zL|$A2-}FKQ#${}`vK~K79Q#gcwkf~)Uk2s{#hRGMs$U%z{ZGU`aCCeKS zc@lmR}VTQn&8MEk>*q)BIow&y!tCJ&Zn>wenSgtGFq z5$mCHm!_bqMFS78>Z+}7G=HsRFaw3B3)o=WGl%t3G>e`la9Bm6ZZp*}&UXgHfo^Ie zX-?U9>8Q}`>v%7yvIaz^f9l<>$;-YE1M7nB9oWFaIZ%h1qN|40vIS}v_W?dVn|0vR zApA$YVTKoNP#g50Z6rMh=?|YZrP1v6hjV9<#ve|AU_^b`F(=mRQGa5rw##I4>wnaZ z-+YZD7{KgdCC0N4GCw&$J~7_`L!yzn{AWIRG!o<9g)WryzH);nF}ulo0v3hs4EG@i z&6@zvWrHi*a@uuT2MmU2(itCR;)%JxG72d!*+a;Rsy`t5{~_>yM@p%sCa=GJ_ST!P zz}L&CaAK!=^P&6p5`XN8U4s|EzN{6rW5EoAjk3Tm-M}708*aN>J?0SfcEP1XdHwQ{%BkhKZ=Z-AY&|&2v-iPk3QeV0|aOclmDXTSwHCk$nn@C6H}HN z6w@}6?>kr9PZxF}WqW>y#xETt0xgIoSl%mQQe^ z4*O>DHH0~3Z&-rTF01qsS5$NQLi_oKQ-bUL+D=9Lc~BdY+Xw1mMat)g(mi63e|Bl0 zd~;#yz%80sKYuKM62nvkm97lTf+xeh>xYHlB4FPDm($HtHoW5{V;S$C3Q{?bW5 zSyG93`ud?M^rc${I*NYwPJa_pBGoljB=&Twt6Ec(B2QB< zgyc2W2$q$$5h&NK5o}f|1}M>qI%x}o-eDR{U?G=bDE7-s2k|UUrg0B~j$P~W!_ob#E!LG!8uF=_tcvYpVgM^18Cg{Qm`O!j; z9=|@{i+>2kHj=K2c(4(b6;C^ z5=u*6CzWN`Zu58dp|~CSm?}@fMs*TqSyNLfPJdA2tjaJdr??&Xlu=xgjpS2aoEq6`O#gvLVc2 zv0dR&x^@6Lfot~JJNO^=9b#}fy|MeoNoEXO#(11DR|T9q0g1s!a6jNW@O&6j(ksBk{S&oMS~ffVn1V4PpJBPz zK1p+NugRY7D)IP?%#`h=%ZdV=va!luJ%1O9v3L`0N(U*ofxJR)eVoILgxsswi)J6% z43XO_G6lt-@73D1ef5E$`vl*m)x8f%by;6Z0xcR?`_}bdnnWGd*z6W*khwZ0QpoO|ani@4@UO_9I`|9P^9#(AWC$yBFG<TV@qBo_MzLw4V6qIuO>od|FJ|7=Rev8&uwJ+@ z`E~^-Kel-@(0;lNJ?2da^E~Ztlb0JtL-$1%Jcl z4`T0E6YUNl9%o%&+DB`hDV2a0Y?}N1v|Jh&Do&FpN9pRRCh;8*2%H@la1TNm7#xyR z?D>7?2KwO!&Oe<6ePM3l$A2}<0?eu7%qvVAFFvDfNa38W(3>d{Bp`nBuLtXP%e|Fs z-}~h8Y~@59XIhhW9Wf%R1F=(cML7FLQL)C+&b#P%2Mq*SP~g z;nThBqeS2PFt4j!v7;T$6_dPfb_67$qjv^>hN^<3ioIY#qxMOLK7T6&+QY6+pVH8f zPU!iqt1lqbt3@xh3HD;f_4HLJm<|gC0*?>IR?d16E|cC{;{(OqR);%#;Wi`U$FM*K zwjDhl!Q*$CX*HBZOshp=?K-yxR5@hXnYDKNwU`T)5IWm7w#a;lpsk5=bCc7IHLq6}4xIK2c5A($Gt>izC%8gd(gg%`CO23T~=kq)G;DatN# z{n>O))~!>voNMj7S&aM0f4f~x1DfXO!J4A<;E?rh(JJ1y{hJ4En%E2vz_MAMbnByR zf2ub?`7Kbq$3M6Ih@cARdCUf$X%fk0Ry~O%luSdc0Ow^xK!3>;__8p`R|JOw^qiL5 ze9kP@G+`!on2UlG+DIm?R{XwP;_^;xamay=AgNJ?Xs6K>q%+5w%ceoM!4`%%kI4-F z25iC;>>oPTFoqPg*->b8_OnIa^vK$A;K^h59tab&7chfBiLW6Fb>Y!j;Q-^RokUIAS7P#s4kUv2VJNs)>ojWh zocSi-!7$Lzn8=^eaF4yLgEO2=yP(r0nByauJ4@$kzebbfY%yN)l#AdkO~>PC{LOe` zFlu1->IHf|CY@Lh83TT04HoGH7xo>PafBAEz_IT28h_1RJr9Z2yEKs&D8_~BMm}kf zk7|u6e(LHhDN9<&5cltreJbBWDLd&}7}#tJG7yqqy4;IJ?WmQh!#qy$ZNewRR4>nU zFKzT3`j>C#y*w8$(?!QUR%Q^tS+3v4F}x5r^tX;MPuiWx$3GYN=jHJsJBW{>SKto$ zjj(0jS${;iCL;mB2Q%hG1dUXi|0*6diTy;FNkBgjo|j#!yZ1G1@SLx0IFBEj`ORjt zdW=2~93}Hs*N7X`Gja4G+ZBGqca78ijkLFo$rwz#y~)gyG6cZDAF)G(M&>y}*X3w8 zHsj7X3j0Aj@>C|olA&q;3;|<=nTBo{(Z%mr^nYl8z^@KTqpZ~^Yxp6-Ki|$)rdi~W zfq>dSNB#jka|%@`+QM%u`w}OD@g=`IHT#}!bf@o6`UHPx0q9P31b}bHhaqPc0JoY` z0Q`2O*L0^izQ%9dGh(I9m)FazmrXOirDVk?%{BWr!!+^oyR=ta@eVPXD4$cSiJ5l! z^?yFjv4?1w#GN0d?$0_d#`BrEmBw3AW=mSSj70oI$1O(|54f!D$7b|V_mlnFAyD6E z=CNOU0a{>lW@t5>8Gp)S){B`@(6Y?X>^L+2l*gGGuBvi1 zO}~NJ{RATzZIw7t|1d(;)oL5+rOuTDk zLl>W*xP9E}Ab>vlsMhTq3i~y&DI=&-ix-&SuAN{MRE(KfoXFvO&qMYk%|A zC33xX^(vdje!;g?)MhFCOCYC39E%FX_d2n3=HTVgjK(~|K z&In;jD{SIn2jdO)vnT=9&q~thx6-p&6p4ccN%{?vAkL*>(;2A+lY%ig?SEyL7@Nku zzo>rUA)9B>iJNb-)1h8n*^6kvBcU>%!S|6+zbJw5a^*1j)aVT;a0tmawB&J+`6@=+ z>Jivwfy{s+FOkoJP41Mzpi6qM@LL8`L(#lt1~Iti<v*x_# zZK07^4Os5r>}3k9BOX)wsDJYbpAhKQH5jaSE-<>EXaLbWCINdoK-Oy(fPEbx>p%;@ zfew)Mr3K(nOVRl{1vrYb-)S_vI;~NkrqcUHzUTfr;`nK)gd~fp3|yGmk9-|$#9T=A|s>UxXuEziJ+QV6u1Jz<5sIh;9s0l zR}xYRHO&bV0bm|e!eVS-_b&#Puym0Oso&|TP<9F`G`*0DNRma1CoJWRX>}RN&IknC zE9gFgm8mG~6AL4UEPwcsf|fuVBq|S zFCzABt}IN?Q5JnLP!@s}D$7*D>dNBtlFUhIRkgpLm|50mN&r!s7#IZJf(DpJEh+uh z)F+J7bk|$QSo$PCF|RLwICCLLjwhl6IUViUkY@n;?=v=R|0{LBT#hJ=IS&pc32UIdCi zYdUphPu7>ao9U&hOSavChMe9l9Trx#@nO*%c80Ja$DNHU-hBNo?!VJ_YU3;H;_J*& zDLtRXS0a=dO@F9zFZO4!@KN}qx#k5k7KI-QZRCashN(DrD|zLwLl-KbB2?3aDl4Zf zD^WIRjizg1`aI=IPgl_@OK9k(lr(f4g)3m5D2uFiJ;E!L#{#cVffqW6@>t+2DqumW zo9By_uAz40J2woZK<$>t0_|4dh1xBT1=_8E1==;w*ME2FdijnM^5s2xcemITj6Z#T zV3-Y0G&aD2x=GW$b=$kX))C|9T2jxdZZN+XRdttr&Ll#%=c{{x29AO^p0V|e5|R{NA?*)PvKOq zIadnEdW_#YG$<5?*cm`WaafZfZePXTE`NJuRf8SBW|Ls!%wR&C1HqmsT-k8^ zys;L;h;C<%+W{FqB936WE?HA?pLymPeZ|!aL*{s+j^8(`J#6N&Z({vP(RPxoS3km4mKUMzh<2{>)<@rEd+s@sA#)OSK;s>S8rA65Gs_OiGyG}Bjatn} zMYM<={!r6*mv!>WJI>{gyiOYJ4W?-X6Jbbd?cuLZ#UZB z{!aR28GZnDva!*BTAy@Kc7OV0IesXG{(pOlET@M1&3>{iddjNbU&f7FM|FSugr2}f zPA<-01-Bgcw|U4K2H(&ix#O#28qQ(EBJ>?SyIdxAw}ejAG?0<06 zyxB1t*qAfAVb>%lJ0wPp>z}^_g?gft&1P7bl!OnJ6E{dy zX;JSh9vjAod(f#(6+Hr3JYtci$1gxkEKz-!+_W+`2+Sw3pBi$`5e=?|KZvCuh!ilq zuEFTyPc^CEC?z3Q4k^C1Az!rL2Y(&N>YT?R*P>&!JI~f)`p^O8`NVWWLDa;4?Vxn? zV<0F?Cx{8*W)W!(y)GvxOV~vT_4Jw)452iX2b{7d?@vBqR`=UrD(yuh3QUf}xy2oF z5FlJHLCvAmxS>!~bL8L8-eK5c2Sj|Ix^bNcn-QqX%?$uG<#H>dcAAjftl)Yabq<*O@P`L{#fUvX2-s)tt|q^@kRqe~ki zu!?bREV_byi#RvjfebNjaDOmTIW!2`2DF>9Xf!~)wk(epVJY#V*i-slCUXh2GMP)D zmC0NJtyJdN)u>18M@mdUgg^C?cwgQC-j_6c`XPzv6U8P~qKk>6FgC0Upr;uK^x6%3dO+ZgGE!xeb5^tRAnymG`b1Ii9^^qO|YX>cB38+4a5fxb^2Wy5vr6!u#^ zd9jOFCLc#Uh#q++iX!fwS7jkWX=`+H4>8$eJpwDt0aeg1kebj*_%kBq)e;NX)14!Wlphd#wP zx_D4Oo73zDIeD8b(8R-3SQpY5nRQFu)Lz-zexj6WbjxGBC3aq(HkL}3Hl{_i@po0) zSlC(Gm=@87-<9P?gUHgxw1_tTu1XsWN1_dBF>Ux=nKl|&mVY*;MYQ2}X?)a)&)oHYiTlZ#FjCc{iAd!l)-v?CT5O@CttF# z7*M)#yK^=-TVz4f)XlJUv~##b37v2)FSpPrN#GxA&0Gqnf>JdI^Z~lYaech^QoOoJOFK6&wd8FF0G^X+x~DywfrM|^ zet%q(FUm=tUz;L>vKSpQ_2d)(-0Al5iEHP8xH@&NwttJNCwoH5XDg)g*&NBpT)a(a za6DZEWz`kp`M z!}5j)i)|3K%OecLnpYv6cb48=J{=?Bmv+AJXLgEd=xy*=Z%PCY_g-LxT?$D$S}TTy z<~VbGfPX=z)BI`aWPR7XLIRqAA_cfw4w#0AkZ&u-S|XVP^K>3@$GIC_&kOZ)W2Fl3fwBL?g3?V(n)B9E)np|q$%zLJ+pp)yG##xZ{zuJgp3P2jF}rc>W_yF(qQ*6 zAHfa2Nv!DC-U|F!Ftg#pQ;s$64Pq=Lk$=RB&Y+ZeP5KF}ZfDvvW7h3<;V>bNHt7zM zSnoq;0*q6^tUKrpVytPen>iPd-$N34tvgDGnIBD40iRJ~8EBTM=sgFH|MCI~hPm^# zed{jl5c;$6i!_Tk`Q?>;4M<9PN2D zG6W>_HSH+P%GY%Os-YT&%(s+8AKLprZ9K6FYb=c@b&{0Co8*VnDv7%)gdD^s~A zM<74-JQG?G#S>p;m++YI0OUdh*2CjmRjppfR4>D8_*C3nNNT!H`{o|nNq@Yh1KCbC zfv?Yn1Y){Y@7Wlhq=*)XU8la*XP)IsffQcW9MCvd_%gmGtQmDcH%ZH9`hxlPJLcs> z7XoCLI3O7I2y9{)^K=jsDZ}ep6q}?IK8cIy(z^9A4gE~8n28$Ek645O|3ajM5?Ico z(rqDX#S%vV*(??;k}*l`pnn&sDlhG12UHI|xap2_q+V z?p(Kj_RevdT0k0o@F zZN3-#d6whG|ZWmbgg*M4HnF0+mZUBZSUgpviIPND|bPgQ2L>k2YBr8@5=-mow+tK%j z96?{*o2;HZ;pEjdadk*qBPJ}Ctl|l$L$U$C_#j8UAx9K-qkp0yS?wfIB_^pp;GBde z*|3cpl$wU;C1-gY&q>NpKh^^dbkUIGZ5bUOmm4TB%XbxhXalwT_~^$-)&~lWJUr$s zJm%X8-yX*LrzbCUkK6JGEa%XSbz`-_i#7j@C&f;4a|R?oamlDrwZtFdm&N3wri|T- zs5?bUzRS@5Q-5xg&~_>`S8}0>C?ru!RnE(oiZoMQ5RJMyWzVQ%_zhs!=JsFu#opt3 zaR0-}XsNpmp}Q`s%h|%1D-}3;w|z_3H#WgECO=K7WI$PsTh6GOu;cRSQJ%7=_icSv ziPFko*VWZVWa~;77SxWV+%D#nmy6A6Z}M=6s32Jaihq5nHlq9rOHw-5Kw}SGx+rG) zQ9C&C4wA9U0y-qXta4_sL+<72imR(9 zWwN%i%1lrHQ|0j)?t5}~LZ?6#qRbF1vb}dQzA+wjz3UyDb%JxW`$YP3{$Z*u2l;4< zgB4bzVt=rM?I1?PeY7AS#W#8e_}XuUb-kmR&kqi^Z6$@kXw<`Ie?J0h_jCw*c)Aav z`g$g^1Q^usl-}XOF z@aoBJ^Pm%u4iVT^U0}-=@5o>Y`Zzv?W1zf^LjGEu&m956mUM(a|+sL(eW9k+c-0fNV(O2#r zdF>tPa`QCsKQp|8#@|2_NKztGf*^SLrQJnQX0!M=K4!o0yoj@uWfL~Pk4H(i7=QQX_pHoVv6#=Z zykzk%zI=-bB(|H)=2^j@f<;{~X=3{XFLBxM0ydW_h*%I=twyqih^cVu4ryf0|u( z$8*kR(U`yd()B(2C4b7}Y*DZ|&*lk7z&ITxi!tAR`6ULQE<4HNn>dXUO#$A`DglWN1>dvpdBT57p$3FLpK|0lxux24A1(?Y9e>Y6A3(Q|%nK>w4g*Oh zaT)W1xZ%H!_?(3K@=LR9yhu>UK=__bqhC0?<~(IHlDT9*0x752o!boE2==PYWRG|( zIFpnOP3(;<2Np>zKq*5mbytM(=q=Pg(k9#rfxhlP|A;qd~?w1FV0Ve^smoJe48Glun!;|58cyzMo zAtMXn@Z{onZ%N7VV6Zm~hdcX&^Y6mlljHq^vm+^Bzg_U+oC@D@n`GV_cB*f8zjR(0 z7V6;6NErJ&2ZtAD1JM%>J6@R~S1mDoI!H~@bzuZUB);%;XE@vy?IL!!8j6ok&Jjes z*b4{Wf>I6z!kYp?b$`eH#bLN5O5PSFRJ%tC`(;Q)2>kXNZ&| z))gjA6R;MiB~$!@T{h2vrokIUNy4EaYK83;Wi+~NwJ*1>G?27|3=rysc-Ip6mv3B| zzEceh6I$15*E)gS(@P!}?M~xl+zN034XT=EWZqLs9e)Z%2CELugG!}6QCPV8SOtp4 zYS1pLfjX~&!kAw#Zd#MzL3HC|hdn??;vdnG*sj(F`Vku)wz1KEv|IL~LM%=v*(zEA z(h54D$v`V`SQAhojEwv~oFoyBIU5rTz#InTV{m~C9|~xm%{?Hy;l&)xZPIx&2brO! zCLg8UwSRi;@H@)V60{jDK%lBC=uLS9bE9N>R{(X-0Z25vSIm%CX67O1E{cNH?bHAl>FCHJY201o}65a9-^ulHnUr_v7J^(U36K|I%w#${MzCI z1@1d-R7Xo!T9ADdl1bk0vj#h?-t_}hzKKqawSU&OCp~|006jh9k?Ss;LD$DQACvdC zGa6+JoV^~Lv9*9NmmAnIJdDR1S2Q4MT0M`Yqtn-O`p)UWndD!)7%hR?z-Onf!SI&k z-k055`P8ygH&8W;)*x}U=~9oTD^Hs$>ln)D2}4IOM7B>@t0hJRKt2M!bm+vBr|Yla zSbvkX$$?ZWz&KmkV;d=3W`y+PYgfB#aM`<}eCZ?((n(=)X$AWc_;O5-{)vnt2U}pX6zYh+kZbiR^Dr-q0oHK zax@`Zl-6mr+r7mcg9z;kuGKEUww@|*sH#}ES!lLRZ?Hf%1>dT#k5&G?KvKUUqHDsa z-ZWzZNOcr_k%c&OpqCJ*A$WICC4_sZ#8v#qO@awrtI1QMS}U^(1eIX+s>gqw!z6@) zYteHGnQ#2ksv@-8_J3+p@F&&s6NDFACXv=^LvN_rBJc$w!?5;jB;JUyX+3mL3>H8V zU+STB9BDy$=t`vEp=J#esr6u6kZ~riP7U}OpFr+D2YBd2gJSwHsKdH|4(RUOz^K5v z=>!HrwE02roh{b-_RTjxWQVZiLZ0;9_uu`HU3>@u+jfX(clgceE;sVik2|A9JQnnaY_f`c?YEpf;U>QrWz(ix4 zZ+9H(Fzgw0Ig>bIVSI2|(6;>_2|$Q=`%zXv>N&pUFjYlz3&z;=f~cNHIn02$+7VFn z>L{V@Wbp3dB!9vAn8`fJn5IFf;+H7Fc{BynV*;~F9i?JHw!>tRR1Az>ljwB#WMbkc zXc*cp@nu*um*&w7G^aYQ0?URNPlHzvjF{d#gaU4`J%$b0S6ha z$kj41r^?I}8e_yEim+-h8PimJl&M)!fDSo>b8Ip0jesKC4eWmTsqAl7WV9X1&NyBd zV4VUfMvGEG8YMma8DBN&OnLe(Ayy^vcjc#v>al`Zzta?Duk21DqbvY zka0XJTYnn9?Iy;)acp%+g27;fJz_B=9&>Xj5-D~ZHNdV|#(PEd3(Y?bYvrz7CIL*$`VY+pgC2GM(5*z=uxnwgLd3&b zjDM2*potHwQ=BAu4I6dS-e+7$l^k!gV&N2vseLc3YHZYXjN0mY5D3)Ek*E$sSbEZ- z=&*4|+R*1>XILu_ez*%DRMEa_cWP|6Pv&_B^KyA_8gEDS>RNq9jVWjr!FtNOtP2a~ zyc$)A3msUfl}4QqOr(2SM7vW=@52s66@T;#I%28=?kj=Dx*??L$dwkb-)OUs`|M3e z4rZ#}Klt`&u+0uog=V>tD@JVL*A|bI zsc}bh-SX(?F8lb8J!+fmmm$>1&V|0H&5Q?on8sNoW%t4Do5x_i-jg$^HRxod>wF6G zhW}{y&A7Tn{VkZ7=rNSrlBZ-6`hQ&>LXYM5k|w@T+lq~@;PQ=2NXk zY%OhFV!Ex=+hw||B;KUIPtsvmkN*loZ%OA+l2mpPq4wO4GRBc9#p%7f^?&BO?|xv{ zV{G|6OK&`FYTQ>L#!V#?M;4~*BAoHA?ILv8-83Feg`;zwfq5Y`i3Zi@!yOVSc~mwL zo)(Loiz)Jsqg_P^Ub+nkbkEN&hUd~?>8WHJ zaprGyUBp-P3wY9KCkLvT0;kcO^jW?Xc;Fa=#DstQ;!pWBn(S+s>RqA54TL!R?|FL#k1QclEsTO zCYw5x?eVIasIvz!*~vXfCURgxoZua}gduHOLo_N>jk?)}1^1)|p0}&t#A%51c#3~o z!J?dW-^YzO@eO1MX$9Q;{xM}KuD!T+FAI*U5HXJYi4 zQyMS+s(L{w{W^`hzHrVhzNkGA2|LrpHe3l{c3GIyuGRe=$C->4=(Rv^XIV0T`8!+A zaj3Zs*l>5C=*~vp6US}}6!0js1Vd|BN7)dcjAVIynYRBx6)EJyx{zH;F#0aYC1Ayu z3%M^d0qY*0lz&aU*6AEMPW2F#v#qm7Lio?yjoD}UttqP|zO27j!9Y%C@jO(=ZdAVC zu>H7+0P@YI$S-Ze)D=`?&O|5Y6R`HDX6t}W9can&T_RIYqJSNc<3<9IaJ(NPL_M4*jzc3h7Lqn2aur>!@pb!6lvITDT7S;8?3UFU5&}Z(&2q;~<J8%>%tRibR`w`GzY6*4W1R#vZ5KHv}t36p9`hqIKRTy z1q0`Qz>0u~|F$xJ z*?<5VwtwQ46UndYqjY2{X($av;bg&1h+z|$BVEKxGFEU1U%AV+#GFZt0(k?3TaxxZh!lUMi+glTj?7R?gSd5F&)EhF1_kX z582dc9~^8@68&C5Qx{xs4R2c zN_?oXVkKD1At#`p>JDgg&n4FWFB<=DTie#AYkOkx%~C3~k^)cLo-1cj>y1D8=((mc zzkirFW;!vOaLttAy*Kkhwa7C+C4bO`@>```vI6Du+aEPwE9JH=sJ~ZWEQ^i4`Vg*B zXg%4TC8sS!5SvgcR@eoWlO?6Po1n1In}kLHRvp^-qFPqdSKz7+U+`7S(i;s9N0))3 z7&vo_IE*QqZuUy`FR$uP`H%6&AIQTMU4OuiFnb6Yp#`steo5Le;o>z`RJUbZ^gMet zmuOjvdltpcE((j!GteKXoXIE+ZXYqY&k+#h{gSabg?$aPBkZ_3gnd_FqA5#kFD}sA zIEL+{=B2p)1)o!Vf9278y>E$7muj}F4gyL;pn{CV7quMvsFzrd#*Wji6rG_GzkmA; zuHE6-EMaaaKXpO#T3KDe>=b<~b@{)F ztfwN>VgL~mtb@RK>pAF8vx^xB^-{w4BYGuCJXN!3oh9-3T9wj%Xb~$Es-yI38WnU! z9ijK+VLsX*0!`@!fXeT%yU}#(2f?!x3yy%(jV>El2btdFt`Lbg@v>re zRm>|JQrTX9{_4Rd{0K`h{{3&Jt0uumwr6VPuG)WF@=B&$|K99XrBWOyDjyYwlLZI| zI!vXZqU$0{7O*=|JllD$Y9>n3U2(SBP_`g1kR$7}>;D$?Oq(g|++&JJ$HBufiN@4Ob?* z{8}4QJ5h2M-J?jzC~l6oKRiAy`C}eT8VJk?bMdoLMw13=prE_W97YnaoE`4c+Ba4f z;N9ctM%*$u%SN}TxPKEaZ}cTbG&+@s68@>i$(s2aJi%ZeqwBq)vMN4BK?|lyfP|-+ zI$IC<%N}%XBjPNY-^S&R+>x{*-4nnZk2A@#jf7uum{-6_3O#hjDmWHbNY%6!ZHQ|ouy0ihbh>UV~7 zwqA)u>9i_uIprnb-+@`8JALfsO#2ugYFwRqG4@s1lJqtW4Rbdk1sH&?magW%RsY(3q*KXTMZ&G|^XuLY;7$v2WQYhU>>8&++C5*$wcbyYwsKr?kL z@r>uB^5^vLIjJ1M@|&`LzJ|9XA)x!9Q+36e^dJt%^e1X?EhpiagXAMejm}X#{vv=zm{`7F)%G3o*=2amitRSCNM{X{f z*BAV5#K_)@lD2nF)GM2l@?E*a67tq9aYNmLrGlb;atYe5edAl#JXfrlt5oULB9;pl z>%?+*?qGAJu0~BPmAjz1b;(2}zPJ5LOvF}VLIiyV_BbEG#&8#BXyAMgB5jP<7Dgmf+63mc@BcaM5%Q zL6qvQT_7dPLgD?h{)L8l?LPlVY{l;5*;9jw@_FL5e`$nCVI5<_$VGy}G4A^Fo419s z?WH0!Rr@sW29o_++p_X?XhW3QV8ieF7%-Ve#syBgQT6s!n|r(xi@%oMxojJ|+(Kk< zCngdZl=_s(XRJsoQNtwOEsAD1tHzC(XrP79zG|fQj9;SdF999bFDX;?rqEem(+@A7 z{KTd!#MIW34rA+0-9W!drtm!!hqmBs7QA?zzeng#8fmu*NP)8l9ooBRX=>lu01I{Ue$SDB?Gd~PeYF=#z3b8U606q9-o`as03 z(*Qp_@?rNGkEiz;*_WM}A;s7a2+OG}{+)OZH2t?oeP>0e-)cw4?8KwI)Aj~~f+xqu zOYn2{YFt!i)RTS2r{rm>ehOjGU&^$nZ}Z1A=qsFhuJFiVs=n|dRGS9m$n-Q6T|X<5 z1p@>EoPn~M0c)W|I>-S)t8g^~NS^|lg`(NO4*;FQh8!RSfJ5OyE{MY;Gtf}1W`8GB z4X>;>K!6jr7l3g**f6^ojNk$7LjN)#2w=eHa^QIgl&b{UhwmR$fk^-i3MXqoG(MS* zx?L>@SP*ww2CDVqtr==R^!;w`SIY2?^bwFMCwGn(e3?sf7@Bm10 z1JFWwZ2(1uaeb*!^SF-u9UM_B6#^j)Nsa(E2>xUBEM(XP9?_kfBCxz_vDWn*iOp~w zs_J=XJC`j04Ryg7H;A|!C|vIQ6e62X?_l!QG(A6OFMn`?Ik{hs$lE?eti-nNwW04K zUV;0a2}z1?cH1{2AkS{Fjr~=Aci~pu7s=;7WkMGWbE%CR3YTm;<#o9ejmX#eM^RFb z+I2B|kv#ezW<154eNrPUOCB3I_|JNUl<) zK;az*7Oz%p_i|i<#f#q!LF2tZ6ugBP`heo)jVd+S0`{^o<1zbF2Gy1=p*s25ABovE zr1Ce{TzDv+E%BJi_^Wv6Aatq;EDFQ=02ctRg;o8)93Ot>9|5QE;Af#Rz;d`v9d#tj zSSjzu&cMpt-1(NI2~PI-`B6oaUEAq^eP`j*qy+hHD>SkKS~Py@(z#n>uOl3v-QqH` zYVwv%pr&jN=y;brlax;|%<<>dK*9&FHoP@FyQgj$Yj5g&2#9Ovul?Xpr=z~x+Wz{J zX@??1I&n+=#kII#r@cMX4$}81Y|2Yjdg;#h9O&j~HR_^54E_9He^NSz&UCiNy5#9~ zFtxe9e9B6m^ou}~WS-bIzqcZfAl}_mjaO4oSw|4LmHO?=tcR9q!zhK>Mft}b8HBPm z^eJQY+Byl8A$Ip}gmclx$`b1t1qIp_%@TiKvd-bi&koy6+SHVN6gqeo^9@_psDq-P zS|7ylKPfKufk$AAluHff>B7XVv!k1S-MklDgIB;iP4V|r>x>kgB5SrFz&?(@yq zsn{6$+ARK9FT0@GA~O(c9;t78PpUWMo3-(1Uhjp$=eg5j8LpX4zK?BCLCqie*GfHl zm-`8Ronm%~2`3zCEERa8^&TtplV?0?>MtrrA>&eFv%UPuztMX1y9z2oVm{;pLII?f zFFJz9c3Ah4diEbG{$N|rZ0k^}sz3Y0vIRTYS-N{=u;L>2^_cOBKjY}LU;YD37!t~2 zMhrdtAbWvwr))t7$x)p25*fRca^cOlpGL%}?SpQ5Ow-3f2FC0)dVj1t36V9Gsc3(n zd81@tLfj7NnGuM3flQ`kf6*XxZq@s?&1jJ&>7{+d|5glDcwi+@e{01%6z*bP0tA8# zK9pHLv3O~u|HP8VPVa?DkBXe}&`rv6f>oB8rv$#=X4wlVi7y8i1S@=NOjp-8(#ua< z^rYMB+iH=)dJo4Fq1QWKRlRQ$HXBXv=$+l&5ZcO@akO)mRlc6(|`Sy`XwCqSkeJ0_zytpDW~WVdqC%LP!q%RYZlV zw%JY~1D&5{SK;W&x2}pG!D$Di8)qm(LJw##vgKPb+;SDG{w8>R6yX|UCYM<>68ooT z_U89YMsH5#36f_~H0}2=@44RM+bHrX#zQ8!4#dQ%DLsxw%Xn1lm{w`DErw z33f|SpXIY@ron5LmGcPp-oz}txvra8;vpCCRgdab(JAeX@{rQ*8>K28Ht~dnjogwn zruIvcb*jD`9PPQmiz+Q&OV!8|q8Bn`ECzfWQ5o_MEDoa|++2P2#^cj`e#%6}T<6(f ze;lxfW#YY7KP6Ti$Nboe6VKzd%v8ROL)R3a_d+BZUSAKJUlEAItGkxFpx*BwjlA9GuHr#rfSH9LKuo-X{C|KeI==(@P}S7 z)W*qP1ao3-8U=z6dVs=xfG2_Y-xX$Ux!YF*U`iW&Cp*SFZv>gmgFi4XUUhpG5k27T zfEmLfsp<1c(#b2-{ux=15!5;ln8>rX#CBF}dO>+Z4ore}=79iSq9;VP3YZ|-1#kuE zLCy<+5pV22+u+fN2Kur9Xqls#dYptZdO_I7@$A5VW#Op>?H{Tty>KL!On3y5UxZaX zfjCv=ual}=O~`c-R-Ji^Q4l7T+nr-w_&J0_TANw0&q|9*8r6M>-lvdKor}9)nGE zmSGbe$aWbvaSp+mj1~h5s2VmAw|?Ay6s!*O8%t-nF8{1-Fg(uD4GC+_z*UWhM>_sv zmDLKvv3OSiH-6!MyA!0d0(;VgY*%1Ufq&!P&0PT~aT2SJBPWVNlPC7bl9sw+3)B1Q zr|^N;k`RI5Ip%~R{1Af?zd_k~L~wY0l28gyqts~rfWvus9Up;OJnHPw6h)y7==KJApq8~XpA@w#m&+(AX_b4s) zE6Y_%B2~5eLzJ_B+T+g<6~4)(N;B zSK!Iw^09qGQE-SB!*H`` zMn*^3#o)92CcA$O?(ml_zGHz^l;Ch;(}46?&cWU|^y(-MUC4bBeR7)m|06fN=W@!S zzjguREw+ioyr>>9$!vIZ3ZQx1^pBItKTl|R9?Y=p@J@0pM$1o7`z|=o^WT%nV7}22 aNjU4jq|;|$5nM1N0v~S6l233A5dR1C#rO>X diff --git a/www/custom_cards/icloud3-event-log-card.js b/www/custom_cards/icloud3-event-log-card.js index 1f976da..3acebd7 100644 --- a/www/custom_cards/icloud3-event-log-card.js +++ b/www/custom_cards/icloud3-event-log-card.js @@ -12,247 +12,293 @@ // If they do not match, the one in the 'custom_components\icloud3' is copied // to the 'www\custom_cards' directory. // -// Version=2.2.0.10 (10/4/2020) +// Version=2.2.1.02 (11/01/2020) // ///////////////////////////////////////////////////////////////////////////// class iCloud3EventLogCard extends HTMLElement { constructor() { - super(); - this.attachShadow({ mode: 'open' }); + super() + this.attachShadow({ mode: 'open' }) } //--------------------------------------------------------------------------- setConfig(config) { - const version = "2.2.0.10" + const version = "2.2.1.02" const cardTitle = "iCloud3 Event Log" - const root = this.shadowRoot; - const hass = this._hass; + const root = this.shadowRoot + const hass = this._hass // Create card elements - const card = document.createElement('ha-card'); - const background = document.createElement('div'); - background.id = "background"; + const card = document.createElement('ha-card') + const background = document.createElement('div') + background.id = "background" // Title Bar - const titleBar = document.createElement("div"); - titleBar.id = "titleBar"; - const title = document.createElement("div"); - title.id = "title"; + const titleBar = document.createElement("div") + titleBar.id = "titleBar" + const title = document.createElement("div") + title.id = "title" title.textContent = cardTitle - const utilityBar = document.createElement("div"); - utilityBar.id = "utilityBar"; - const thisButtonId = document.createElement("div"); - thisButtonId.id = "thisButtonId"; - thisButtonId.classList.add("themeTextColor"); - thisButtonId.innerText = "setup"; - const logRecdCnt = document.createElement("div"); - logRecdCnt.id = "logRecdCnt"; - logRecdCnt.innerText = "-1"; - const devType = document.createElement("div"); - devType.id = "devType"; - devType.innerText = ""; - const hdrCellWidth = document.createElement("div"); - hdrCellWidth.id = "hdrCellWidth"; - hdrCellWidth.innerText = "0,66.7px,97.8px,94.4px,80px,70px,66.7px"; - const versionText = document.createElement("div"); - versionText.id = "versionText"; - versionText.style.setProperty('visibility', 'hidden'); - versionText.textContent = 'v' + version + //Utility base contains hidden variables + const utilityBar = document.createElement("div") + utilityBar.id = "utilityBar" + const thisButtonId = document.createElement("div") + thisButtonId.id = "thisButtonId" + thisButtonId.classList.add("themeTextColor") + thisButtonId.innerText = "setup" + const logRecdCnt = document.createElement("div") + logRecdCnt.id = "logRecdCnt" + logRecdCnt.innerText = "-1" + const devType = document.createElement("div") + devType.id = "devType" + devType.innerText = "" + const hdrCellWidth = document.createElement("div") + hdrCellWidth.id = "hdrCellWidth" + hdrCellWidth.innerText = "0,66.7px,97.8px,94.4px,80px,70px,66.7px" + const versionText = document.createElement("div") + versionText.id = "versionText" + versionText.innerText = version + + const infoText = document.createElement("div") + infoText.id = "infoText" + infoText.innerText = '' // Button Bar - const buttonBar = document.createElement("div"); - buttonBar.id = "buttonBar"; - buttonBar.class = "buttonBar"; + const buttonBar = document.createElement("div") + buttonBar.id = "buttonBar" + buttonBar.class = "buttonBar" // Name Buttons - const btnName0 = document.createElement('btnName'); - btnName0.id = "btnName0"; - btnName0.classList.add("btnBaseFormat"); - btnName0.style.setProperty('visibility', 'visible'); - btnName0.innerText = "Setup"; - const btnName1 = document.createElement('btnName'); - btnName1.id = "btnName1"; - btnName1.classList.add("btnBaseFormat"); - btnName1.classList.add("btnHidden"); - const btnName2 = document.createElement('btnName'); - btnName2.id = "btnName2"; - btnName2.classList.add("btnBaseFormat"); - btnName2.classList.add("btnHidden"); - const btnName3 = document.createElement('btnName'); - btnName3.id = "btnName3"; - btnName3.classList.add("btnBaseFormat"); - btnName3.classList.add("btnHidden"); - const btnName4 = document.createElement('btnName'); - btnName4.id = "btnName4"; - btnName4.classList.add("btnBaseFormat"); - btnName4.classList.add("btnHidden"); - const btnName5 = document.createElement('btnName'); - btnName5.id = "btnName5"; - btnName5.classList.add("btnBaseFormat"); - btnName5.classList.add("btnHidden"); - const btnName6 = document.createElement('btnName'); - btnName6.id = "btnName6"; - btnName6.classList.add("btnBaseFormat"); - btnName6.classList.add("btnHidden"); - const btnName7 = document.createElement('btnName'); - btnName7.id = "btnName7"; - btnName7.classList.add("btnBaseFormat"); - btnName7.classList.add("btnHidden"); - const btnName8 = document.createElement('btnName'); - btnName8.id = "btnName8"; - btnName8.classList.add("btnBaseFormat"); - btnName8.classList.add("btnHidden"); - const btnName9 = document.createElement('btnName'); - btnName9.id = "btnName9"; - btnName9.classList.add("btnBaseFormat"); - btnName9.classList.add("btnHidden"); + const btnName0 = document.createElement('btnName') + btnName0.id = "btnName0" + btnName0.classList.add("btnBaseFormat") + btnName0.style.setProperty('visibility', 'visible') + btnName0.innerText = "Setup" + const btnName1 = document.createElement('btnName') + btnName1.id = "btnName1" + btnName1.classList.add("btnBaseFormat") + btnName1.classList.add("btnHidden") + const btnName2 = document.createElement('btnName') + btnName2.id = "btnName2" + btnName2.classList.add("btnBaseFormat") + btnName2.classList.add("btnHidden") + const btnName3 = document.createElement('btnName') + btnName3.id = "btnName3" + btnName3.classList.add("btnBaseFormat") + btnName3.classList.add("btnHidden") + const btnName4 = document.createElement('btnName') + btnName4.id = "btnName4" + btnName4.classList.add("btnBaseFormat") + btnName4.classList.add("btnHidden") + const btnName5 = document.createElement('btnName') + btnName5.id = "btnName5" + btnName5.classList.add("btnBaseFormat") + btnName5.classList.add("btnHidden") + const btnName6 = document.createElement('btnName') + btnName6.id = "btnName6" + btnName6.classList.add("btnBaseFormat") + btnName6.classList.add("btnHidden") + const btnName7 = document.createElement('btnName') + btnName7.id = "btnName7" + btnName7.classList.add("btnBaseFormat") + btnName7.classList.add("btnHidden") + const btnName8 = document.createElement('btnName') + btnName8.id = "btnName8" + btnName8.classList.add("btnBaseFormat") + btnName8.classList.add("btnHidden") + const btnName9 = document.createElement('btnName') + btnName9.id = "btnName9" + btnName9.classList.add("btnBaseFormat") + btnName9.classList.add("btnHidden") /* Action Select Box */ - const btnAction = document.createElement('select'); - btnAction.id = "btnAction"; - btnAction.style.setProperty('visibility', 'visible'); - btnAction.setDefault; - btnAction.classList.add("btnBaseFormat"); - btnAction.classList.add("btnAction"); - - var btnActionOptA = document.createElement("option"); - var btnActionOptATxt = document.createTextNode("Actions"); - btnActionOptA.setAttribute("value", "action"); - btnActionOptA.setAttribute("id", "optAction"); - btnActionOptA.classList.add("btnActionOptionTransparent"); - btnActionOptA.appendChild(btnActionOptATxt); - btnAction.appendChild(btnActionOptA); - - var btnActionOptG = document.createElement("optGroup"); - btnActionOptG.setAttribute("label", "———— Global Actions ————"); - btnActionOptG.classList.add("btnActionOptionGroup"); - btnAction.appendChild(btnActionOptG); - - var btnActionOptG1 = document.createElement("option"); - var btnActionOptG1Txt = document.createTextNode("Restart iCloud3"); - btnActionOptG1.setAttribute("value", "restart"); - btnActionOptG1.classList.add("btnActionOption"); - btnActionOptG1.appendChild(btnActionOptG1Txt); - btnAction.appendChild(btnActionOptG1); - - var btnActionOptG2 = document.createElement("option"); - var btnActionOptG2Txt = document.createTextNode("Pause Polling"); - btnActionOptG2.setAttribute("value", "pause"); - btnActionOptG2.classList.add("btnActionOption"); - btnActionOptG2.appendChild(btnActionOptG2Txt); - btnAction.appendChild(btnActionOptG2); - - var btnActionOptG3 = document.createElement("option"); - var btnActionOptG3Txt = document.createTextNode("Resume Polling"); - btnActionOptG3.setAttribute("value", "resume"); - btnActionOptG3.classList.add("btnActionOption"); - btnActionOptG3.appendChild(btnActionOptG3Txt); - btnAction.appendChild(btnActionOptG3); - - var btnActionOptG7 = document.createElement("option"); - var btnActionOptG7Txt = document.createTextNode("Request iOS App Locations"); - btnActionOptG7.setAttribute("value", "location"); - btnActionOptG7.classList.add("btnActionOption"); - btnActionOptG7.appendChild(btnActionOptG7Txt); - btnAction.appendChild(btnActionOptG7); - - var btnActionOptG4 = document.createElement("option"); - var btnActionOptG4Txt = document.createTextNode("Show Tracking Monitors"); - btnActionOptG4.setAttribute("value", "dev-log_level: eventlog"); - btnActionOptG4.setAttribute("id", "optEvlog"); - btnActionOptG4.classList.add("btnActionOption"); - btnActionOptG4.appendChild(btnActionOptG4Txt); - btnAction.appendChild(btnActionOptG4); - - var btnActionOptG8 = document.createElement("option"); - var btnActionOptG8Txt = document.createTextNode("Show Startup Log, Errors & Alerts"); - btnActionOptG8.setAttribute("value", "dev-refresh_event_log"); - btnActionOptG8.setAttribute("id", "optStartuplog"); - btnActionOptG8.classList.add("btnActionOption"); - btnActionOptG8.appendChild(btnActionOptG8Txt); - btnAction.appendChild(btnActionOptG8); - - var btnActionOptG5 = document.createElement("option"); - var btnActionOptG5Txt = document.createTextNode("Export Event Log"); - btnActionOptG5.setAttribute("value", "dev-export_event_log"); - btnActionOptG5.classList.add("btnActionOption"); - btnActionOptG5.appendChild(btnActionOptG5Txt); - btnAction.appendChild(btnActionOptG5); - - var btnActionOptG6 = document.createElement("option"); - var btnActionOptG6Txt = document.createTextNode("Start HA Debug Logging"); - btnActionOptG6.setAttribute("value", "dev-log_level: debug"); - btnActionOptG6.setAttribute("id", "optHalog"); - btnActionOptG6.classList.add("btnActionOption"); - btnActionOptG6.appendChild(btnActionOptG6Txt); - btnAction.appendChild(btnActionOptG6); + const btnAction = document.createElement('select') + btnAction.id = "btnAction" + btnAction.style.setProperty('visibility', 'visible') + btnAction.setDefault + btnAction.classList.add("btnBaseFormat") + btnAction.classList.add("btnAction") + + var btnActionOptA = document.createElement("option") + var btnActionOptATxt = document.createTextNode("Actions") + btnActionOptA.setAttribute("value", "action") + btnActionOptA.setAttribute("id", "optAction") + btnActionOptA.classList.add("btnActionOptionTransparent") + btnActionOptA.appendChild(btnActionOptATxt) + btnAction.appendChild(btnActionOptA) + + var btnActionOptG = document.createElement("optGroup") + btnActionOptG.setAttribute("label", "———— Global Actions ————") + btnActionOptG.classList.add("btnActionOptionGroup") + btnAction.appendChild(btnActionOptG) + + var btnActionOptG1 = document.createElement("option") + var btnActionOptG1Txt = document.createTextNode("Restart iCloud3") + btnActionOptG1.setAttribute("value", "restart") + btnActionOptG1.classList.add("btnActionOption") + btnActionOptG1.appendChild(btnActionOptG1Txt) + btnAction.appendChild(btnActionOptG1) + + var btnActionOptG2 = document.createElement("option") + var btnActionOptG2Txt = document.createTextNode("Pause Polling") + btnActionOptG2.setAttribute("value", "pause") + btnActionOptG2.classList.add("btnActionOption") + btnActionOptG2.appendChild(btnActionOptG2Txt) + btnAction.appendChild(btnActionOptG2) + + var btnActionOptG3 = document.createElement("option") + var btnActionOptG3Txt = document.createTextNode("Resume Polling") + btnActionOptG3.setAttribute("value", "resume") + btnActionOptG3.classList.add("btnActionOption") + btnActionOptG3.appendChild(btnActionOptG3Txt) + btnAction.appendChild(btnActionOptG3) + + var btnActionOptG7 = document.createElement("option") + var btnActionOptG7Txt = document.createTextNode("Update All Locations") + btnActionOptG7.setAttribute("value", "location") + btnActionOptG7.classList.add("btnActionOption") + btnActionOptG7.appendChild(btnActionOptG7Txt) + btnAction.appendChild(btnActionOptG7) + + var btnActionOptG4 = document.createElement("option") + var btnActionOptG4Txt = document.createTextNode("Show Tracking Monitors") + btnActionOptG4.setAttribute("value", "dev-log_level: eventlog") + btnActionOptG4.setAttribute("id", "optEvlog") + btnActionOptG4.classList.add("btnActionOption") + btnActionOptG4.appendChild(btnActionOptG4Txt) + btnAction.appendChild(btnActionOptG4) + + var btnActionOptG8 = document.createElement("option") + var btnActionOptG8Txt = document.createTextNode("Show Startup Log, Errors & Alerts") + btnActionOptG8.setAttribute("value", "dev-refresh_event_log") + btnActionOptG8.setAttribute("id", "optStartuplog") + btnActionOptG8.classList.add("btnActionOption") + btnActionOptG8.appendChild(btnActionOptG8Txt) + btnAction.appendChild(btnActionOptG8) + + var btnActionOptG5 = document.createElement("option") + var btnActionOptG5Txt = document.createTextNode("Export Event Log") + btnActionOptG5.setAttribute("value", "dev-export_event_log") + btnActionOptG5.classList.add("btnActionOption") + btnActionOptG5.appendChild(btnActionOptG5Txt) + btnAction.appendChild(btnActionOptG5) + + var btnActionOptG6 = document.createElement("option") + var btnActionOptG6Txt = document.createTextNode("Start HA Debug Logging") + btnActionOptG6.setAttribute("value", "dev-log_level: debug") + btnActionOptG6.setAttribute("id", "optHalog") + btnActionOptG6.classList.add("btnActionOption") + btnActionOptG6.appendChild(btnActionOptG6Txt) + btnAction.appendChild(btnActionOptG6) //--------------------------------------------------------- - var btnActionOptD = document.createElement("optGroup"); - btnActionOptD.setAttribute("label", "———— Device Actions ————"); - btnActionOptD.classList.add("btnActionOptionGroup"); - btnAction.appendChild(btnActionOptD); - - var btnActionOptD1 = document.createElement("option"); - var btnActionOptD1Txt = document.createTextNode("Pause Polling"); - btnActionOptD1.setAttribute("value", "dev-pause"); - btnActionOptD1.classList.add("btnActionOption"); - btnActionOptD1.appendChild(btnActionOptD1Txt); - btnAction.appendChild(btnActionOptD1); - - var btnActionOptD2 = document.createElement("option"); - var btnActionOptD2Txt = document.createTextNode("Resume Polling"); - btnActionOptD2.setAttribute("value", "dev-resume"); - btnActionOptD2.classList.add("btnActionOption"); - btnActionOptD2.appendChild(btnActionOptD2Txt); - btnAction.appendChild(btnActionOptD2); - - var btnActionOptD3 = document.createElement("option"); - var btnActionOptD3Txt = document.createTextNode("Request iOS App Location"); - btnActionOptD3.setAttribute("value", "dev-location"); - btnActionOptD3.classList.add("btnActionOption"); - btnActionOptD3.appendChild(btnActionOptD3Txt); - btnAction.appendChild(btnActionOptD3); - - var btnActionOptBL = document.createElement("option"); - var btnActionOptBLTxt = document.createTextNode(""); - btnActionOptBL.classList.add("btnActionOption"); + var btnActionOptD = document.createElement("optGroup") + btnActionOptD.setAttribute("label", "———— Selected Phone ————") + btnActionOptD.classList.add("btnActionOptionGroup") + btnAction.appendChild(btnActionOptD) + + var btnActionOptD1 = document.createElement("option") + var btnActionOptD1Txt = document.createTextNode("Pause Polling") + btnActionOptD1.setAttribute("value", "dev-pause") + btnActionOptD1.classList.add("btnActionOption") + btnActionOptD1.appendChild(btnActionOptD1Txt) + btnAction.appendChild(btnActionOptD1) + + var btnActionOptD2 = document.createElement("option") + var btnActionOptD2Txt = document.createTextNode("Resume Polling") + btnActionOptD2.setAttribute("value", "dev-resume") + btnActionOptD2.classList.add("btnActionOption") + btnActionOptD2.appendChild(btnActionOptD2Txt) + btnAction.appendChild(btnActionOptD2) + + var btnActionOptD3 = document.createElement("option") + var btnActionOptD3Txt = document.createTextNode("Update Phone's Location") + btnActionOptD3.setAttribute("value", "dev-location") + btnActionOptD3.classList.add("btnActionOption") + btnActionOptD3.appendChild(btnActionOptD3Txt) + btnAction.appendChild(btnActionOptD3) + + var btnActionOptBL = document.createElement("option") + var btnActionOptBLTxt = document.createTextNode("") + btnActionOptBL.classList.add("btnActionOption") btnActionOptBL.appendChild(btnActionOptBLTxt) - btnAction.appendChild(btnActionOptBL); - var btnActionOptV = document.createElement("optGroup"); - btnActionOptV.setAttribute("label", "—— Event Log v"+version+" ———"); - btnActionOptV.classList.add("btnActionOptionGroup"); - btnAction.appendChild(btnActionOptV); + btnAction.appendChild(btnActionOptBL) + var btnActionOptV = document.createElement("optGroup") + btnActionOptV.setAttribute("label", "—— Event Log v"+version+" ———") + btnActionOptV.classList.add("btnActionOptionGroup") + btnAction.appendChild(btnActionOptV) //------------------------------------------------------------- - const btnRefresh = document.createElement('btnName'); - btnRefresh.id = "btnRefresh"; - btnRefresh.classList.add("btnRefresh"); - btnRefresh.style.setProperty('visibility', 'visible'); - btnRefresh.innerHTML=`` + var btnHelpIcon24 = document.createElement('IMG') + btnHelpIcon24.id = "btnHelpIcon24" + btnHelpIcon24.setAttribute("alt", "iCloud3 Documentation") + //btnHelpIcon24.style.setProperty('visibility', 'visible') + btnHelpIcon24.setAttribute("src", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAYCAYAAADkgu3FAAAEEElEQVRIS72WfUzUZRzAP7zccefB8fY7DhDwmIgY/2S2dC1QBDFXbNp8bxlGNl9oDUyjmvhS4ZhQKIQvCVe4Nhthjj9glQqVscbmJNecJAZXdLyI3h0HCNzBtYN+7Afd8dJaz7/fl8/z/T7fl8eD/+l4zMRJB4VcQUi4D7HJamKDZfj9YcdU30tz3xD3Gh7RdQNsM/lxC1oF3k+peeJAKBmCjNUOB5pBsNjALhtFrvTEF+g0DFN7sgP9R/38AjjcAV2C3lCh3aMlZ7GKHW1D3HkwxLUOGzd+tdPaPkh/lBx1vDcLgxU8Od+HZK2MqIY+SivNFBWZMbuC/QP0ui+P5UeiV3oh1Js5ntXOl024NnY6TFcR+koQ2xICeNNq49bRTnYXmjFMhU0CHVQSkbeAS22j2Mq72Zdnpkk0KAokaqvABpuDeBk0numk+kgf3aL8vIbEjRo+ljkw5LaTXmilRwqbAK0CRfkiTkbIWPmhkS05Zn4WFd8PYMGrApdH4HEvD4btDuTAtdIOtuf10yXqVWpJ3hjC51etnE1p4xgwIsomQIUCSdmhVH3zkD1rjXwhKqwDn/IY8kYh2zxCldHGdzo565WerPaC/LAWcqQ3/0FHzjN+HDjaTtIRE7cmgV4CVdliyv4cJTD6LuuAUVFhOWirYjDIPbjbOETi879jqtOhW+LNdRuoI1tQS0Gv+SF8EMHVIRtXdrXwTi0MOeVjEW3xYeHFWL5vsnJiaRtFUsPrkYQvUXDxvp36uFZynbK6+UTEKWmwg19kC4FTH745moLYeaTtMJBw4e93HAOdEUh7Wcun+h6e29vFT5NuCLKAeWjMA/SfA0uFFlWiL+e9PdjqKnVO29owNqQGo9/ZSmJF/3j6xkBN0WTqFBxMMrDi5gDG6brcGEOBA/aPOCg5YeLd4of0TtUvEFi2T0tN6QN27u+kZs6gc8GkrA+iCgc/Hr7Pi6ctmFxdSgTpu9i1t4fqCdB0qZM6qgknc5mK4h47m+NbqXQX+UTqfiOpYoCbsyoGqbPb0RyKlHPseCepeRa+dQdyWwzTlbfU2TY58WsCSLzcz6VqSaPOurydiu4aVuok1581qWoyL5h4+2wft11FNG3DOg2mG0GiQ71A9qZgCgs7ePZwL19PBc1qBDmNpEP1s24y3zOPP6R4PtGQsCmI7cWdFByycE8qKxdY+UIIJTMOVdFoYk14oKm3UnjKROVXkik9NYocBbq0EDY/7U+W1UbTrNaE6MS5+HZreStORUbHMM3GR1zpsNM4dfEJSpbH+LDWz5uwhl5KKnvnsPhEmHOVr1CzNEtLRoiMFAcILlZ5l2GQmtPdlOWPF8fcVrk0PeLnRPBhUYI/MZGe+BtHsdT1cuc/+ZxMN+/+jewvN+q9KPbla94AAAAASUVORK5CYII=") + + var btnHelpIcon24Hover = document.createElement('IMG') + btnHelpIcon24Hover.id = "btnHelpIcon24Hover" + btnHelpIcon24Hover.setAttribute("alt", "iCloud3 Documentation") + btnHelpIcon24Hover.classList.add("btnHidden") + btnHelpIcon24Hover.setAttribute("src", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAYCAYAAADkgu3FAAAELUlEQVRIS72Wf0zUdRjHXwRc3kiUeSASB5RGKIv5a0FNjt8YphsYqNA08AdThBmQRhqgaMgJVxCIiMApyq8IcdSOmT+gNNZoyMWckxxLlvHDNCB1kw6OdrA77vjN1vr++zzv5/V9ns/zeZ6PEf/TZzQtxzN8DmKBFfNtHHnLxxHzBXPp/L2HW/WtqJ620dzQTVOTaro4k4M8PU1wenMlW/fvYJ7Im6EhS/qf9zGgGsDEVIBQ+BLQRVd7LRVZcvK/uA0MTQacGLRz30I27knA9vVtdN2/S9/j6/R1NnHv19949OAZYjtzXnVejNmC1Vi+7IPFQjtaGnK5UpnJ2czeiWDjQRExy4iRyjEVilDWH0cW+zVK5YTi4YAh4dYEbg/lDfePeP6khdNHdlMoax8LMwTFHLAlMvUiD+6r+KZoL7mpSp0gOdMO3y1BqFTOmJg2UppXQ97hhzp7WoEESfBJBKbt5CeFky97pA8bBXl6zuFQURYiWw/KP9+MNOEXnWP8MXsCd15CPbicF4z/YXBAAFynOjeMrNRunV9OpQ+S4BIar51mp28KMKi1jYIOyrwIjavi5+/2sH1thU4cEPAiB4tSUavjeNpbxcOO77F2CEQo9MbYWIr7ogSDMl24kYDLmv0UHvEi63CLIch/qxnphYV0/2GB/ysBgFondnVdiLSqHYHgHspGCVHreyirc8B26U0GVOZ4ic0NQKGRIqI+u8Zg/1Wkuw5SW9uvsY9kFLh5MSnlP9CmTCdoRaaBsOymDfZLy+n9s551TknDtgt1ttg7NTAwMBcvscW4LlO0ZmDnuIHEbe5Unx8+xxHQsbwNrPvgLAr5u3wa9ZOBMDLSFMF8S7p7n1GZ30d6sRkrJQUYm2zByFiKx5jSacTnaoNY7i8nLUJCWfFw+UZA1cporB0OEO7lRmtzx5S3/EZHBuqheNSDORSkH6Ik++9x/gkZqwjeq6AiN4L0eMXsQUfzffEOrMKIH8lJfp/SUz0T/pQWVCvfRWJUzcxKpx+pQBGN06pseh9tYr1z5aSZa0uXEuFFVXHzzJpBP5riTiJW4hTkx/05mXplUtCkzTBVe+tHey/UGVc/CTcvXaSmZvSi6vtM2d4ax8kurH6QfUl+uPlH8+35Tyg5fWfCjKa8sBrFVCNIGzFNHodfiIxS2TvIki+PA81oBGlU+kNVcS6a7KPDB6n7Us+44xcSxlfZGaQnthnYThR5sGZjzvRDVavSrgmB0JJb9TKqv6ykunp0So9N48MEB1Zv2ITL27E8f6Kc2ZrQBtEsvqDdHyN22sHjzlb+6rhKT2fj+MUnckW8ZC3CuYu43ZDD5dksPi1Ms8qXua0gLHYHFla+qIdEE6zybrraFZSfKuSMVNMcs1zl+uXRPk7MRa/h4r4EG/E8ujv6aKq7+988TqYceLM3/gvZOcgoRP8sgAAAAABJRU5ErkJggg==") + + const btnHelp = document.createElement('A') + btnHelp.id = "btnHelp" + btnHelp.classList.add("btnHelp") + btnHelp.style.setProperty('visibility', 'visible') + btnHelp.setAttribute('href', 'https://gcobb321.github.io/icloud3/#/') + btnHelp.setAttribute('target', '_blank') + btnHelp.appendChild(btnHelpIcon24) + btnHelp.appendChild(btnHelpIcon24Hover) + + //btnHelp.innerHTML=`` + + //------------------------------------------------------------- + var btnRefreshIcon24 = document.createElement('IMG') + btnRefreshIcon24.id = "btnRefreshIcon24" + btnRefreshIcon24.setAttribute("alt", "Refresh") + //btnRefreshIcon24.style.setProperty('visibility', 'visible') + btnRefreshIcon24.setAttribute("src", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAYCAYAAADkgu3FAAADLUlEQVRIS72WbWhTVxjHf6lJkxCT+HKTW7W6ltoXt0+V4UR0Wis4cf2gyMQPumJRdFNGfS37sFnHFHHRKr5ssLZuIijx5YusbLRaFcoQirGINE63RGvS1rrG3lSbJubKrU1Im5Q0Ejzf7jnPc37n+Z/nPs9R8Z6GKhmnHHSZOqzTtRSUmiiYqsH4JERvcx9Of4BHLa/oaoVgsn3GBC0B9TwTc3dnUSFoWCrLWAbgRRBCmjCZ+gwmAp3uQRqOeak/2s89QB4LmBD0jQFxq0hVoYENrgDtzwNc8wZpfRDiv44B+mdlYvpITd5UHR/P0FIqapjV4ueU3UdNjQ9fIlgcaPtEPjw0k3r9BIRmHwcrO7joILGzsmG5gayNU1i3aBK7pCBtpnZWKPP+OVxXqbhpuM/3yvcI0B492Qc+4LIrTLCum68P+HAk0z6y/quFT9dYOKmRcXtDTBfVSBkyhw3tXB0BWgK6unyOZWtYfMTD2iofd8cLidjZRUo/m8KfKngEeFQytjiQTaBkRxaX/vqfrcs9XEgVMiyXc1glJSniQevBUFtI7dMwk3P/GdI4nCpImsMNVYyfDBlx0q3Vkne+gJsOicPFLmpShSj2/UV8PtqvM4Q57yHnonf0s0DZlyJn6ntY+VUXf78LKJnPUNY5ctmWo2NPiZv5d17iSeY0nvVnhRgVO4sTKRpRukFHstEXq5H/9aOp6IkBpVu6hmmsmmzOvHzq8eDc319yJxpROpIhVk5nLj9NMqh37nKFxLN+uqOgdKR3BLTZiPBjNk2BII2bHvJtAwRGVIZ0/LDKhrdyqFpoZHd1ByX7emmLHCBa69JVgtZYOdck8csyF/uB13EgZSK2qP7WzbYffG8vcjyjTmDxaisnlKL6XQflNomeWL+x24QKS7OE7Xgv9ivDF5oIWKUjp8zKFwvMVEpBHNWdbLH5cI+2HbPxbRHZW2SgwjuI0/OKRm+I26Mbn6Dnk9lalhvVTGvp44S9L4XGFzmJ0srnmyiuFKmwalgmg5CglXe5B/jjdDe1h/zcT7mVx4YdeZwIWvIXmZk9MwOzJ8yL6320p+VxMp4ESMXmDVKARSiFsInBAAAAAElFTkSuQmCC") + + var btnRefreshIcon24Hover = document.createElement('IMG') + btnRefreshIcon24Hover.id = "btnRefreshIcon24Hover" + btnRefreshIcon24Hover.setAttribute("alt", "Refresh") + btnRefreshIcon24Hover.classList.add("btnHidden") + //btnRefreshIcon24Hover.style.setProperty('visibility', 'hidden') + btnRefreshIcon24Hover.setAttribute("src", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABoAAAAYCAYAAADkgu3FAAADLklEQVRIS72Wa0iTYRTHf6+50Vhq4swyF5XdKBIssQtmMyO7QkYXDCrLim5SdpWKSruarbTMypp2BcNsHwqli2kXJIzlkohWWEnlptlFVqBpLbZYm22ii9Hz7X3ec87vPec57/k/Av9pCR1yFPFdkYt70D1wEGOiB+Ht54X+7Wcel+lo+VpNZXkdGk1LR3HaBykUngwJH8GCTQn4yCZgMvnT3NRIa0srniIxEkk3wIChppjLmXnkHHkKmNoDOgctXRvArJXJBA1eiOHNcxo/3qFRr+Hli9c0vPuGvI83/YcFI/ULw793NL4Bfagqz+ZWQQZnM744gzmCFicOJTEtD5FEhrZsP8qkK2i1Tp0tAefE92TmkjiGj9tIk7GKcO8plv1HX0sRhHuESXeaH9uCEjcHsXzfVd69aeFa7mqy92k7qv2f9wfORBI5+zhiUQ0f9IH4BRjp4pHOSOn1tiCFoivbcjORBY0n//A80pKfdBpiNcwqiCZ88g0EoRqoxUNQOoK2KqOIW1/Io5srWRJz2WXI73LpEBAwWZrCCWjSAinpKhV1732Z1M9c458ugyqMdxEEm5/J5OFYupnzgknNv0e1Np3Y0AyXIWYHzbfpDn4NBh9igi/ZzmjPyRlMXXSWorxpbF/18J9AHTj97jq1dg09+24mPmo0uspat4AefPCyxInwN9oycjco6bCEkFAT9a9EbEmwA7m7dOeKY8Ui36vfC7NHUHi+0paRO5rBvt5FukOe0u4bWjM2BqC+UG8DuaO9raC45TJW7S3hR/Nt0pZtpbi4ue1kcMcPa4548X4yIRGbUKVEkbmrysq3zTp3jaDI2ZeoKDnF0ompwA9HkHnHfqgWnVvDsd2Wg+zUOpg7nohZWZahmrMjnhxlg71f+zIhlvjzuEyJ+mgBarXlQJ2udcl9CZsxl5CxSTQZtZxKWYFKWfO3bfvCF7tiC/IhCXzU6/hUe5vP+gpH4ZONQj4gBolXL56WZ3HDFeGzfopZyoeODmV+UgK+PSby0yRzIuV1GGqKyD+h4nTaM9el3D5v6+XEWzaQkHEDCJT7UFfbiKb0uXsuJ53qgM4b/QIdc0go4VmAXgAAAABJRU5ErkJggg==") + + const btnRefresh = document.createElement('btnName') + btnRefresh.id = "btnRefresh" + btnRefresh.classList.add("btnRefresh") + btnRefresh.style.setProperty('visibility', 'visible') + btnRefresh.appendChild(btnRefreshIcon24) + btnRefresh.appendChild(btnRefreshIcon24Hover) + + //btnRefresh.innerHTML=`` + // Message Bar - const eltInfoBar = document.createElement("div"); - eltInfoBar.id = "eltInfoBar"; - - const eltInfoName = document.createElement("div"); - eltInfoName.id = "eltInfoName"; - eltInfoName.innerText = "Select Person"; - eltInfoName.style.color = "firebrick" - - const eltInfoTime = document.createElement("div"); - eltInfoTime.id = "eltInfoTime"; - eltInfoTime.innerText = "setup"; - eltInfoTime.style.color = "firebrick" - - const eltInfoMsgPopup = document.createElement("div"); - eltInfoMsgPopup.id = "eltInfoMsgPopup"; - eltInfoMsgPopup.classList.add("eltInfoMsgPopup"); - eltInfoMsgPopup.classList.add("eltInfoMsgPopupHidden"); - eltInfoMsgPopup.style.setProperty('zIndex', '9999'); - - const tblEvlogContainer = document.createElement("div"); + const statusBar = document.createElement("div") + statusBar.id = "statusBar" + + const statusName = document.createElement("div") + statusName.id = "statusName" + statusName.innerText = "Select Person" + statusName.style.color = "firebrick" + + const statusTime = document.createElement("div") + statusTime.id = "statusTime" + statusTime.innerText = "setup" + statusTime.style.color = "firebrick" + + const statusMsgPopup = document.createElement("div") + statusMsgPopup.id = "statusMsgPopup" + statusMsgPopup.classList.add("statusMsgPopup") + statusMsgPopup.classList.add("statusMsgPopupHidden") + statusMsgPopup.style.setProperty('zIndex', '9999') + + //Event Table + const tblEvlogContainer = document.createElement("div") tblEvlogContainer.id = "tblEvlogContainer" const tblEvlog = document.createElement("TABLE") @@ -268,7 +314,7 @@ class iCloud3EventLogCard extends HTMLElement { tblEvlogBody.classList.add("tblEvlogBody") // Style - const cssStyle = document.createElement('style'); + const cssStyle = document.createElement('style') cssStyle.textContent = ` /* Text special colors */ .blue {color: blue;} @@ -365,16 +411,15 @@ class iCloud3EventLogCard extends HTMLElement { width: 100%; /*border: 1px solid dodgerblue;*/ } - #thisButtonId, #logRecdCnt, #devType, #hdrCellWidth { + #thisButtonId, #logRecdCnt, #devType, #hdrCellWidth, #versionText { /*font-size: 2px;*/ color: transparent; - width: 25px; + width: 5px; float: left; /*border: 1px solid green;*/ } - - #versionText { - color: silver; + #infoText { + color: dodgerblue; float: right; } @@ -385,12 +430,12 @@ class iCloud3EventLogCard extends HTMLElement { } /* Message Bar setup */ - #eltInfoBar { + #statusBar { position: relative; width: 100%; /*border: 1px solid dodgerblue;*/ } - #eltInfoName { + #statusName { width: 40%; color: firebrick; float: left; @@ -399,7 +444,7 @@ class iCloud3EventLogCard extends HTMLElement { margin: 2px 0px 4px 0px; /*border: 1px solid var(--label-badge-red);*/ } - #eltInfoTime { + #statusTime { margin: 2px 2px 4px 0px; color: firebrick; float: right; @@ -407,7 +452,7 @@ class iCloud3EventLogCard extends HTMLElement { font-weight: 400; /*border: 1px solid green;*/ } - .eltInfoMsgPopup { + .statusMsgPopup { position: relative; width: 85%; margin-left: auto; @@ -422,7 +467,7 @@ class iCloud3EventLogCard extends HTMLElement { -moz-box-shadow: 5px 5px 23px 3px rgba(0,0,0,0.75); box-shadow: 3px 3px 20px 3px rgba(0,0,0,0.75); } - .eltInfoMsgPopupHidden { + .statusMsgPopupHidden { height: 0px; width: 0px; visibility: hidden; @@ -561,17 +606,35 @@ class iCloud3EventLogCard extends HTMLElement { } .btnHover {border: 1px solid var(--primary-color);} + /* Helph Select Button */ + #btnHelp { + display: inline-block; + visibility: visible; + color: var(--primary-text-color); + background-color: transparent; + /*height: 28px;*/ + /*width: 30px;*/ + margin: 0px 0px 0px 4px; + float: right; + } + .btnHelp { + border: 0px solid transparent; + background-color: transparent; + box-shadow: transparent; + } + /* Refresh Select Button */ #btnRefresh { display: inline-block; visibility: visible; color: var(--primary-text-color); background-color: transparent; - height: 24px; - margin: -4px 2px; + /*height: 28px;*/ + /*width: 30px;*/ + margin: 0px 0px 0px 4px; float: right; - box-sizing: border-box; } + .btnRefresh { border: 0px solid transparent; background-color: transparent; @@ -655,37 +718,39 @@ class iCloud3EventLogCard extends HTMLElement { ::-webkit-scrollbar {width: 1px;} ::-webkit-scrollbar-thumb {background: rgba(var(--rgb-accent-color), 0.7);} } - */ + */ - `; + ` // Build title - titleBar.appendChild(title); - titleBar.appendChild(btnRefresh); + titleBar.appendChild(title) + titleBar.appendChild(btnHelp) + titleBar.appendChild(btnRefresh) - utilityBar.appendChild(thisButtonId); - utilityBar.appendChild(logRecdCnt); - utilityBar.appendChild(devType); - utilityBar.appendChild(hdrCellWidth); - utilityBar.appendChild(versionText); + utilityBar.appendChild(thisButtonId) + utilityBar.appendChild(logRecdCnt) + utilityBar.appendChild(devType) + utilityBar.appendChild(hdrCellWidth) + utilityBar.appendChild(versionText) + utilityBar.appendChild(infoText) // Create Buttons - buttonBar.appendChild(btnName0); - buttonBar.appendChild(btnName1); - buttonBar.appendChild(btnName2); - buttonBar.appendChild(btnName3); - buttonBar.appendChild(btnName4); - buttonBar.appendChild(btnName5); - buttonBar.appendChild(btnAction); - buttonBar.appendChild(btnName6); - buttonBar.appendChild(btnName7); - buttonBar.appendChild(btnName8); - buttonBar.appendChild(btnName9); + buttonBar.appendChild(btnName0) + buttonBar.appendChild(btnName1) + buttonBar.appendChild(btnName2) + buttonBar.appendChild(btnName3) + buttonBar.appendChild(btnName4) + buttonBar.appendChild(btnName5) + buttonBar.appendChild(btnAction) + buttonBar.appendChild(btnName6) + buttonBar.appendChild(btnName7) + buttonBar.appendChild(btnName8) + buttonBar.appendChild(btnName9) // Build Message Bar - eltInfoBar.appendChild(eltInfoName); - eltInfoBar.appendChild(eltInfoTime); - eltInfoBar.appendChild(eltInfoMsgPopup) + statusBar.appendChild(statusName) + statusBar.appendChild(statusTime) + statusBar.appendChild(statusMsgPopup) tblEvlog.appendChild(tblEvlogHdr) tblEvlog.appendChild(tblEvlogBody) @@ -695,33 +760,37 @@ class iCloud3EventLogCard extends HTMLElement { background.appendChild(titleBar) background.appendChild(utilityBar) background.appendChild(buttonBar) - background.appendChild(eltInfoBar) + background.appendChild(statusBar) background.appendChild(tblEvlogContainer) - background.appendChild(cssStyle); + background.appendChild(cssStyle) - card.appendChild(background); - root.appendChild(card); + card.appendChild(background) + root.appendChild(card) // Click & Mouse Events for (let i = 0; i <= 9; i++) { let buttonId = 'btnName' + i let button = root.getElementById(buttonId) - button.addEventListener("mousedown", event => { this._nameButtonPress(buttonId); }); - button.addEventListener("mouseover", event => { this._btnClassMouseOver(buttonId); }); - button.addEventListener("mouseout", event => { this._btnClassMouseOut(buttonId); }); + button.addEventListener("mousedown", event => { this._nameButtonPress(buttonId); }) + button.addEventListener("mouseover", event => { this._btnClassMouseOver(buttonId); }) + button.addEventListener("mouseout", event => { this._btnClassMouseOut(buttonId); }) } - btnAction.addEventListener("change", event => { this._commandButtonPress("btnAction"); }); - //btnAction.addEventListener("mouseover", event => { this._btnClassMouseOver("btnAction"); }); - //btnAction.addEventListener("mouseout", event => { this._btnClassMouseOut("btnAction"); }); + btnAction.addEventListener("change", event => { this._commandButtonPress("btnAction"); }) + //btnAction.addEventListener("mouseover", event => { this._btnClassMouseOver("btnAction"); }) + //btnAction.addEventListener("mouseout", event => { this._btnClassMouseOut("btnAction"); }) - btnRefresh.addEventListener("mousedown", event => { this._commandButtonPress("btnRefresh"); }); - btnRefresh.addEventListener("mouseover", event => { this._btnClassMouseOver("btnRefresh"); }); - btnRefresh.addEventListener("mouseout", event => { this._btnClassMouseOut("btnRefresh"); }); + //btnHelp.addEventListener("mousedown", event => { this._commandButtonPress("btnHelp"); }) + btnHelp.addEventListener("mouseover", event => { this._btnClassMouseOver("btnHelp"); }) + btnHelp.addEventListener("mouseout", event => { this._btnClassMouseOut("btnHelp"); }) + + btnRefresh.addEventListener("mousedown", event => { this._commandButtonPress("btnRefresh"); }) + btnRefresh.addEventListener("mouseover", event => { this._btnClassMouseOver("btnRefresh"); }) + btnRefresh.addEventListener("mouseout", event => { this._btnClassMouseOut("btnRefresh"); }) // Add to root - this._config = config; + this._config = config } // Create card. @@ -733,8 +802,8 @@ class iCloud3EventLogCard extends HTMLElement { const root = this.shadowRoot this._hass = hass const thisButtonId = root.getElementById("thisButtonId") - const eltInfoTime = root.getElementById("eltInfoTime") - const eltInfoMsgPopup = root.getElementById("eltInfoMsgPopup") + const statusTime = root.getElementById("statusTime") + const statusMsgPopup = root.getElementById("statusMsgPopup") try { const updateTimeAttr = hass.states['sensor.icloud3_event_log'].attributes['update_time'] @@ -744,17 +813,17 @@ class iCloud3EventLogCard extends HTMLElement { this._nameButtonPress(this._currentButtonId()) } - //this._displayNameMsgL('/'+eltInfoTime.innerText + '/'+updateTimeAttr+'/') - if (eltInfoTime.innerText.indexOf(updateTimeAttr) == -1) { + //this._displayNameMsgL('/'+statusTime.innerText + '/'+updateTimeAttr+'/') + if (statusTime.innerText.indexOf(updateTimeAttr) == -1) { this._setupEventLogTable('hass') } - eltInfoMsgPopup.classList.add('eltInfoMsgPopupHidden') + statusMsgPopup.classList.add('statusMsgPopupHidden') } catch(err) { - const eltInfoMsgPopup = root.getElementById("eltInfoMsgPopup") - const eltInfoTime = root.getElementById("eltInfoTime") + const statusMsgPopup = root.getElementById("statusMsgPopup") + const statusTime = root.getElementById("statusTime") - if (eltInfoMsgPopup.classList.contains('eltInfoMsgPopupHidden') == false) { + if (statusMsgPopup.classList.contains('statusMsgPopupHidden') == false) { return } const msgRestarting = '

iCloud3 is restarting

' @@ -762,13 +831,13 @@ class iCloud3EventLogCard extends HTMLElement { if (err.name == 'TypeError') { if (err.message.indexOf('attributes') > -1) { - //if (eltInfoTime.innerText == 'setup') { + //if (statusTime.innerText == 'setup') { if (thisButtonId.innerText == "setup") { - eltInfoMsgPopup.innerHTML = msgNotRunning + statusMsgPopup.innerHTML = msgNotRunning } else { - eltInfoMsgPopup.innerHTML = msgRestarting + statusMsgPopup.innerHTML = msgRestarting } - eltInfoMsgPopup.classList.remove('eltInfoMsgPopupHidden') + statusMsgPopup.classList.remove('statusMsgPopupHidden') } else if (err.message.indexOf('undefined') == -1) { alert(err) } @@ -783,8 +852,8 @@ class iCloud3EventLogCard extends HTMLElement { /* Cycle through the sensor.icloud3_event_log attributes and build the names on the buttons, and make them visible. */ - const root = this.shadowRoot; - const hass = this._hass; + const root = this.shadowRoot + const hass = this._hass const thisButtonId = root.getElementById("thisButtonId") const btnName0 = root.getElementById("btnName0") const filtername = hass.states['sensor.icloud3_event_log'].attributes['filtername'] @@ -806,13 +875,13 @@ class iCloud3EventLogCard extends HTMLElement { if (i < names.length) { button.innerText = names[i] - button.style.setProperty('visibility', 'visible'); + button.style.setProperty('visibility', 'visible') button.classList.remove('btnHidden') button.classList.add('btnNotSelected') //} else { // button.innerText = "Device-"+i - // button.style.setProperty('visibility', 'visible'); + // button.style.setProperty('visibility', 'visible') // button.classList.remove('btnHidden') } } @@ -824,10 +893,10 @@ class iCloud3EventLogCard extends HTMLElement { build the event log table */ - const root = this.shadowRoot; - const hass = this._hass; + const root = this.shadowRoot + const hass = this._hass const tblEvlog = root.getElementById("tblEvlog") - const eltInfoTime = root.getElementById("eltInfoTime") + const statusTime = root.getElementById("statusTime") const hdrCellWidth = root.getElementById("hdrCellWidth") const logRecdCnt = root.getElementById("logRecdCnt") const devType = root.getElementById("devType") @@ -966,6 +1035,8 @@ class iCloud3EventLogCard extends HTMLElement { maxStatZoneLength = 9 if (tStat == 'stationary') {tStat = 'stationry'} if (tZone == 'stationary') {tZone = 'stationry'} + if (tStat == 'Stationary') {tStat = 'Stationry'} + if (tZone == 'Stationary') {tZone = 'Stationry'} } if (tStat.length > maxStatZoneLength) { tStat = tStat.substr(0, maxStatZoneLength) + "
" + tStat.substr(maxStatZoneLength, tStat.length) @@ -1091,7 +1162,7 @@ class iCloud3EventLogCard extends HTMLElement { && tText.indexOf("Complete") == -1) { initializationRecdFound = true } - if (tText.indexOf("Error") >= 0) { + if (tText.indexOf(" Error ") >= 0) { classErrorMsg = ' errorMsg' if (initializationRecdFound == false) { alertErrorMsg = "iCloud3 Error Msg at "+tTime @@ -1270,7 +1341,7 @@ class iCloud3EventLogCard extends HTMLElement { //--------------------------------------------------------------------------- _resize_header_width() { - const root = this.shadowRoot; + const root = this.shadowRoot const tblEvlog = root.getElementById("tblEvlog") const devType = root.getElementById("devType") var rowCnt = tblEvlog.rows.length @@ -1298,7 +1369,7 @@ class iCloud3EventLogCard extends HTMLElement { } //--------------------------------------------------------------------------- _setupDevType() { - const root = this.shadowRoot; + const root = this.shadowRoot const devType = root.getElementById("devType") //iPhone (portrait) width=375, ,height=768 @@ -1335,8 +1406,8 @@ class iCloud3EventLogCard extends HTMLElement { and then the Event Log was displayed in another, the Log revs are for the selected device but the name highlighter will be for the precious device selected. */ - const root = this.shadowRoot; - const hass = this._hass; + const root = this.shadowRoot + const hass = this._hass this.namesAttr = hass.states['sensor.icloud3_event_log'].attributes['names'] var filtername = hass.states['sensor.icloud3_event_log'].attributes['filtername'] const namesAttr = this.namesAttr @@ -1375,8 +1446,8 @@ class iCloud3EventLogCard extends HTMLElement { /* Handle the button press events. Get the devicename, do an 'icloud3_update' event_log devicename' service call to have the event_log attribute populated. */ - const root = this.shadowRoot; - const hass = this._hass; + const root = this.shadowRoot + const hass = this._hass this.namesAttr = hass.states['sensor.icloud3_event_log'].attributes['names'] const namesAttr = this.namesAttr const names = Object.values(namesAttr) @@ -1388,9 +1459,9 @@ class iCloud3EventLogCard extends HTMLElement { var lastButtonId = this._currentButtonId() var lastButtonPressed = root.getElementById(lastButtonId) var buttonPressX = buttonPressId.substr(-1) - var eltInfoName = names[buttonPressX]+" ("+devicenames[buttonPressX]+")" + var statusName = names[buttonPressX]+" ("+devicenames[buttonPressX]+")" - this._displayNameMsgL(eltInfoName) + this._displayNameMsgL(statusName) thisButtonId.innerText = buttonPressId lastButtonPressed.classList.remove('btnSelected') @@ -1409,8 +1480,8 @@ class iCloud3EventLogCard extends HTMLElement { /* Handle the button press events. Get the devicename, do an 'icloud3_update' event_log devicename' service call to have the event_log attribute populated. */ - const root = this.shadowRoot; - const hass = this._hass; + const root = this.shadowRoot + const hass = this._hass this.namesAttr = hass.states['sensor.icloud3_event_log'].attributes['names'] const namesAttr = this.namesAttr const devicenames = Object.keys(namesAttr) @@ -1465,15 +1536,27 @@ class iCloud3EventLogCard extends HTMLElement { //--------------------------------------------------------------------------- _btnClassMouseOver(buttonId) { - const root = this.shadowRoot; + const root = this.shadowRoot const button = root.getElementById(buttonId) - const thisButtonId = root.getElementById("thisButtonId") + const btnHelp = root.getElementById("bthHelp") + const btnHelpIcon24 = root.getElementById("btnHelpIcon24") + const btnHelpIcon24Hover = root.getElementById("btnHelpIcon24Hover") + const btnRefreshIcon24 = root.getElementById("btnRefreshIcon24") + const btnRefreshIcon24Hover = root.getElementById("btnRefreshIcon24Hover") const versionText = root.getElementById("versionText") + const infoText = root.getElementById("infoText") const devType = root.getElementById("devType") - if (buttonId == "btnRefresh") { - this._displayTimeMsgR("Refresh Event Log") - versionText.style.setProperty('visibility', 'visible'); + if (buttonId == "btnHelp") { + btnHelpIcon24Hover.classList.remove('btnHidden') + btnHelpIcon24.classList.add('btnHidden') + this._displayInfoText("iCloud3 User Maual") + this._displayTimeMsgR("Version "+versionText.textContent) + + } else if (buttonId == "btnRefresh") { + btnRefreshIcon24Hover.classList.remove('btnHidden') + btnRefreshIcon24.classList.add('btnHidden') + this._displayInfoText("Refresh Event Log") } else if (buttonId == "btnAction") { this._displayTimeMsgR("Show Action Command List") @@ -1484,16 +1567,29 @@ class iCloud3EventLogCard extends HTMLElement { //--------------------------------------------------------------------------- _btnClassMouseOut(buttonId) { - const root = this.shadowRoot; - const hass = this._hass; + const root = this.shadowRoot + const hass = this._hass this.logLevelDebug = hass.states['sensor.icloud3_event_log'].attributes['log_level_debug'] const button = root.getElementById(buttonId) + const btnHelp = root.getElementById("bthHelp") + const btnHelpIcon24 = root.getElementById("btnHelpIcon24") + const btnHelpIcon24Hover = root.getElementById("btnHelpIcon24Hover") + const btnRefreshIcon24 = root.getElementById("btnRefreshIcon24") + const btnRefreshIcon24Hover = root.getElementById("btnRefreshIcon24Hover") const versionText = root.getElementById('versionText') + const infoText = root.getElementById("infoText") const devType = root.getElementById("devType") const thisButtonId = root.getElementById("thisButtonId") - if (buttonId == 'btnRefresh') { - versionText.style.setProperty('visibility', 'hidden') + if (buttonId == 'btnHelp') { + btnHelpIcon24.classList.remove('btnHidden') + btnHelpIcon24Hover.classList.add('btnHidden') + this._displayInfoText('') + + } else if (buttonId == 'btnRefresh') { + btnRefreshIcon24.classList.remove('btnHidden') + btnRefreshIcon24Hover.classList.add('btnHidden') + this._displayInfoText('') } if (devType.innerText == "") {button.classList.remove("btnHover")} @@ -1501,42 +1597,50 @@ class iCloud3EventLogCard extends HTMLElement { } //--------------------------------------------------------------------------- _currentButtonId() { - const root = this.shadowRoot; + const root = this.shadowRoot const thisButtonId = root.getElementById("thisButtonId") return thisButtonId.innerText } //--------------------------------------------------------------------------- _displayTimeMsgR(msg) { - // Display message before time field - const root = this.shadowRoot; - const hass = this._hass; - const eltInfoTime = root.getElementById("eltInfoTime") + // Display text below action button + const root = this.shadowRoot + const hass = this._hass + const statusTime = root.getElementById("statusTime") const updateTimeAttr = hass.states['sensor.icloud3_event_log'].attributes['update_time'] - //if (eltInfoTime.innerText.startsWith("●●")) { + //if (statusTime.innerText.startsWith("●●")) { //pass if (msg == "") { - eltInfoTime.innerHTML = "Refreshed: " + updateTimeAttr + statusTime.innerHTML = "Refreshed: " + updateTimeAttr } else { - eltInfoTime.innerHTML = msg + statusTime.innerHTML = msg } } //--------------------------------------------------------------------------- _displayNameMsgL(msg) { - /* Display test messages */ - const root = this.shadowRoot; - const eltInfoName = root.getElementById("eltInfoName") - eltInfoName.innerHTML = msg + /* Display text below name button */ + const root = this.shadowRoot + const statusName = root.getElementById("statusName") + statusName.innerHTML = msg + } + + //--------------------------------------------------------------------------- + _displayInfoText(msg) { + /* Display text in area below refresh & help buttons */ + const root = this.shadowRoot + const infoText = root.getElementById("infoText") + infoText.innerText = msg } //--------------------------------------------------------------------------- getCardSize() { - return 1; + return 1 } } -customElements.define('icloud3-event-log-card', iCloud3EventLogCard); +customElements.define('icloud3-event-log-card', iCloud3EventLogCard)