diff --git a/README.md b/README.md index bac2fff..b3eed3c 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,13 @@ ------ -### Prerelease Version - v3.0.pr1.5 (9/27/2023) +### Prerelease Version - v3.0.rc7 (10/15/2023) ------ [![CurrentVersion](https://img.shields.io/badge/Current_Version-v3.0-blue.svg)](https://github.com/gcobb321/icloud3_v3) [![Type](https://img.shields.io/badge/Type-Custom_Component-orange.svg)](https://github.com/gcobb321/icloud3_v3) [![HACS](https://img.shields.io/badge/HACS-Custom_Repository-orange.svg)](https://github.com/gcobb321/icloud3_v3) -[![ProjectStage](https://img.shields.io/badge/Project_Stage-Prerelease-forestgreen.svg)](https://github/gcobb321/icloud3_v3) [![Released](https://img.shields.io/badge/Released-September,_2023-forestgreen.svg)](https://github.com/gcobb321/icloud3_v3) +[![ProjectStage](https://img.shields.io/badge/Project_Stage-Prerelease-forestgreen.svg)](https://github/gcobb321/icloud3_v3) [![Released](https://img.shields.io/badge/Released-October,_2023-forestgreen.svg)](https://github.com/gcobb321/icloud3_v3) diff --git a/custom_components/icloud3/ChangeLog.txt b/custom_components/icloud3/ChangeLog.txt index 9e20f06..3537f33 100644 --- a/custom_components/icloud3/ChangeLog.txt +++ b/custom_components/icloud3/ChangeLog.txt @@ -1,3 +1,14 @@ +rc7 - Release Candidate 6 (10/7/2023) +............................... +1. yaml Zones - Fixed a problem where zones configured using yaml were not being loaded when iCloud3 started. +2. Zone-Devices Count - New feature - The number of the devices within a zone is displayed with the tracking results on the Event Log. The counts are the numbers (x) after the zone name. For Example: + _Zone > Away (2) > Home-2.45km, IndRivShores-6.53km, School-8.47km (1), Publix-10.3km, ThePoint-11.0km, Quail-12.0km, Warehouse-16.5km (1), GPS-(/±47m)_ + + An item is also posted to the Event Log when another device changes it's zone: + _Zone-Device Counts > Home (4), School (1), Warehouse (1)_ + +3. Stationary Zones - Minor changes to the handling of deleting a stationary zone when all devices had exited from it. + rc6 - Release Candidate 6 (10/7/2023) ............................... 1. Bug Fix - Fixed the error causing the "AttributeError: 'iCloud3_Device' object has no attribute 'interval_secs' message at line 680, in determine_interval_after_error" error. diff --git a/custom_components/icloud3/const.py b/custom_components/icloud3/const.py index 2842e60..d3771fd 100644 --- a/custom_components/icloud3/const.py +++ b/custom_components/icloud3/const.py @@ -4,7 +4,7 @@ # #<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> -VERSION = '3.0.rc6' +VERSION = '3.0.rc7' DOMAIN = 'icloud3' ICLOUD3 = 'iCloud3' @@ -328,6 +328,7 @@ 'Not_Home': 'Away', 'not_set': '──', 'Not_Set': '──', + '──': 'NotSet', # 'stationary': 'Stationary', # 'Stationary': 'Stationary', STATIONARY: STATIONARY_FNAME, diff --git a/custom_components/icloud3/device.py b/custom_components/icloud3/device.py index 605263e..641d0d6 100644 --- a/custom_components/icloud3/device.py +++ b/custom_components/icloud3/device.py @@ -1308,7 +1308,6 @@ def calculate_distance_moved(self): self.loc_data_time_moved_from = self.sensors[LAST_LOCATED_DATETIME] self.loc_data_time_moved_to = self.loc_data_datetime - #-------------------------------------------------------------------- def distance_m(self, to_latitude, to_longitude): to_gps = (to_latitude, to_longitude) diff --git a/custom_components/icloud3/icloud3_main.py b/custom_components/icloud3/icloud3_main.py index ea88c84..2991741 100644 --- a/custom_components/icloud3/icloud3_main.py +++ b/custom_components/icloud3/icloud3_main.py @@ -35,7 +35,7 @@ from .global_variables import GlobalVariables as Gb from .const import (VERSION, - HOME, NOT_HOME, NOT_SET, HIGH_INTEGER, RARROW, RARROW2, CRLF, + HOME, NOT_HOME, NOT_SET, NOT_SET_FNAME, HIGH_INTEGER, STATIONARY, TOWARDS, AWAY_FROM, EVLOG_IC3_STAGE_HDR, ICLOUD, ICLOUD_FNAME, TRACKING_NORMAL, CMD_RESET_PYICLOUD_SESSION, NEAR_DEVICE_DISTANCE, @@ -59,7 +59,7 @@ from .support import determine_interval as det_interval from .helpers.common import (instr, is_zone, is_statzone, isnot_statzone, isnot_zone, - zone_display_as, ) + list_to_str,) from .helpers.messaging import (broadcast_info_msg, post_event, post_error_msg, post_monitor_msg, post_internal_error, open_ic3_log_file, post_alert, clear_alert, @@ -78,6 +78,7 @@ ZD_NAME = 2 ZD_RADIUS = 3 ZD_DISPLAY_AS = 4 +ZD_CNT = 5 #<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>>> class iCloud3: @@ -275,6 +276,15 @@ def _polling_loop_5_sec_device(self, ha_timer_secs): self._display_secs_to_next_update_info_msg(Device) self._clear_loop_control_device() + # Remove all StatZones from HA flagged for removal in StatZone module + # Removing them after the devices have been updated lets HA process the + # statzone 'leave' automation trigger associated with a device before + # the zone is deleted. + if Gb.StatZones_to_delete: + for StatZone in Gb.StatZones_to_delete: + StatZone.remove_ha_zone() + Gb.StatZones_to_delete = [] + #<<<<<<<<<<<<<<<<<<<<>>>>>>>>>>>>>>>>>>>> # UPDATE MONITORED DEVICES @@ -328,14 +338,6 @@ def _polling_loop_5_sec_device(self, ha_timer_secs): Gb.dist_to_other_devices_update_sensor_list = set() - # Remove all StatZones from HA flagged for removal in StatZone module - # Removing them after all devices have been updated lets HA process the statzone 'leave' - # automation trigger associated with a device before the zone is deleted. - if Gb.StatZones_to_delete: - for StatZone in Gb.StatZones_to_delete: - StatZone.remove_ha_zone() - Gb.StatZones_to_delete = [] - Gb.trace_prefix = '' @@ -466,7 +468,7 @@ def _main_5sec_loop_update_tracked_devices_icloud(self, Device): if (Device.is_tracking_resumed or icloud_data_handler.is_icloud_update_needed_timers(Device) or icloud_data_handler.is_icloud_update_needed_general(Device)): - pass + Device.tracking_status = TRACKING_NORMAL else: return @@ -502,7 +504,6 @@ def _main_5sec_loop_update_tracked_devices_icloud(self, Device): self._post_before_update_monitor_msg(Device) self.process_updated_location_data(Device, ICLOUD_FNAME) - Device.tracking_status = TRACKING_NORMAL # Refresh the EvLog if this is an initial locate if self.initial_locate_complete_flag == False: @@ -529,15 +530,17 @@ def _main_5sec_loop_update_monitored_devices(self, Device): if Device.iosapp_monitor_flag and Gb.conf_data_source_IOSAPP: iosapp_data_handler.check_iosapp_state_trigger_change(Device) - if Device.is_next_update_time_reached is False: + if Device.is_tracking_resumed: + Device.tracking_status = TRACKING_NORMAL + elif Device.is_next_update_time_reached is False: Device.calculate_distance_moved() if Device.loc_data_dist_moved_km < .05: return - - if Device.loc_data_latitude == 0: + elif Device.loc_data_latitude == 0: return Device.update_sensors_flag = True + Device.icloud_initial_locate_done = True Device.icloud_update_reason = 'Monitored Device Update' event_msg =(f"Trigger > Moved {format_dist_km(Device.loc_data_dist_moved_km)}") #{Gb.any_device_was_updated_reason}") @@ -835,6 +838,7 @@ def _validate_new_icloud_data(self, Device): def process_updated_location_data(self, Device, update_requested_by): try: devicename = Gb.devicename = Device.devicename + # Device.tracking_status = TRACKING_NORMAL # Makw sure the Device iosapp_state is set to the statzone if the device is in a statzone # and the Device iosapp state value is not_nome. The Device state value can be out of sync @@ -1072,27 +1076,65 @@ def _update_current_zone(self, Device, display_zone_msg=True): elif Device.is_in_statzone and isnot_statzone(zone_selected): statzone.exit_statzone(Device) + zones_cnt_by_zone = self._zones_cnt_by_zone(zone_selected, Device.loc_data_zone) + zones_cnt_summary = [f"{Gb.zone_display_as[_zone]} ({cnt}), " + for _zone, cnt in zones_cnt_by_zone.items()] + zones_cnt_summary_msg = list_to_str(zones_cnt_summary).replace('──', 'NotSet') + # _trace(f"zonsel={zone_selected} devzon={Device.loc_data_zone} {zones_cnt_by_zone=}") + # _trace(f"{zones_cnt_summary_msg}") + zones_distance_list.sort() - zones_distance_list = ', '.join([v.split('|')[1] for v in zones_distance_list]) + zones_distance_msg = zones_cnt_msg = '' + for zone_distance_list in zones_distance_list: + zdl_items = zone_distance_list.split('|') + _zone = zdl_items[1] + _zone_dist = zdl_items[2] + + zones_distance_msg += f"{_zone_dist}, " + if zones_cnt_by_zone.get(_zone, 0) > 0: + zones_distance_msg += f" ({zones_cnt_by_zone[_zone]})" + zones_cnt_msg +=(f"{_zone_dist.split('^')[0] }" + f" ({zones_cnt_by_zone[_zone]}), ") + del zones_cnt_by_zone[zone_selected] + zones_distance_msg = zones_distance_msg.replace('^', '-') if display_zone_msg: selected_zone_msg = other_zones_msg = gps_accuracy_msg = '' + + # Format the Zone Selected Msg (ZoneName (#)) if ZoneSelected.radius_m > 0: selected_zone_msg = f"-{format_dist_m(zone_selected_dist_m)}" + if zone_selected in zones_cnt_by_zone: + selected_zone_msg += f" ({zones_cnt_by_zone[zone_selected]})" + del zones_cnt_by_zone[zone_selected] + + # Format the Zone Not Selected Msg (ZoneName-#km (#)) if (zone_selected == NOT_HOME or (is_statzone(zone_selected) and isnot_statzone(Device.loc_data_zone))): - other_zones_msg = f" > {zones_distance_list}" + other_zones_msg = f"{zones_distance_msg}" + else: + other_zones_msg = zones_cnt_msg + + # Format the Zones with devices when in a zone (ZoneName (#)) + zones_cnt_summary = [f"{Gb.zone_display_as[_zone]} ({cnt}), " + for _zone, cnt in zones_cnt_by_zone.items()] + other_zones_msg += list_to_str(zones_cnt_summary).replace('──', 'NotSet') + + if other_zones_msg: other_zones_msg = f" > {other_zones_msg}" + if zone_selected_dist_m > ZoneSelected.radius_m: gps_accuracy_msg = f", AccuracyAdjustment-{gps_accuracy_adj}m" + # _trace(f"{selected_zone_msg=} " + # f"{other_zones_msg=} " + # f"{zones_cnt_summary_msg=} ") zones_msg =(f"Zone > " f"{ZoneSelected.display_as}" f"{selected_zone_msg}" f"{other_zones_msg}" f"{gps_accuracy_msg}" f", GPS-{Device.loc_data_fgps}") - if ZoneSelected in Gb.StatZones: - zones_msg += f", DevicesInStatZone-{statzone.devices_in_statzone_count(ZoneSelected)}" + post_event(Device.devicename, zones_msg) if other_zones_msg == '': @@ -1114,6 +1156,13 @@ def _update_current_zone(self, Device, display_zone_msg=True): Device.zone_change_datetime = datetime_now() Device.zone_change_secs = time_now_secs() + if NOT_SET not in zones_cnt_by_zone: + # if 'xxx' not in zones_cnt_by_zone: + for _Device in Gb.Devices: + if Device is not _Device: + event_msg = f"Zone-Device Counts > {zones_cnt_summary_msg}" + post_event(_Device.devicename, event_msg) + return ZoneSelected, zone_selected #-------------------------------------------------------------------- @@ -1137,7 +1186,7 @@ def _select_zone(self, Device, latitude=None, longitude=None): gps_accuracy_adj = int(Device.loc_data_gps_accuracy / 2) # [distance from zone, Zone, zone_name, redius, display_as] - zone_data_selected = [HIGH_INTEGER, None, '', HIGH_INTEGER, ''] + zone_data_selected = [HIGH_INTEGER, None, '', HIGH_INTEGER, '', 1] # Exit if no location data is available if Device.no_location_data: @@ -1153,7 +1202,6 @@ def _select_zone(self, Device, latitude=None, longitude=None): and Device.StatZone.distance_m(latitude, longitude) > Device.StatZone.radius_m): statzone.exit_statzone(Device) - # Get a list of all the zones, their distance, size and display_as zones_data = [[Zone.distance_m(latitude, longitude), Zone, Zone.zone, Zone.radius_m, Zone.display_as] for Zone in Gb.Zones @@ -1184,21 +1232,54 @@ def _select_zone(self, Device, latitude=None, longitude=None): Device.iosapp_zone_enter_time = Gb.this_update_time Device.iosapp_zone_enter_zone = zone_selected - # [f"{int(zone_data[ZD_DIST_M]):08}| {zone_data[ZD_DISPLAY_AS]}-{format_dist_m(zone_data[ZD_DIST_M])}" + zones_cnt_by_zone = self._zones_cnt_by_zone(zone_selected, Device.loc_data_zone) + # _trace(f"zonsel={zone_selected} devzon={Device.loc_data_zone} {zones_cnt_by_zone=}") + + # Build an item for each zone (dist-from-zone|zone_name|display_name-##km) zones_distance_list = \ - [f"{int(zone_data[ZD_DIST_M]):08}| {self._format_zone_info(zone_data)}" + [(f"{int(zone_data[ZD_DIST_M]):08}|" + f"{self._format_zone_info(zone_data)}") for zone_data in zones_data if zone_data[ZD_NAME] != zone_selected] return ZoneSelected, zone_selected, zone_selected_dist_m, zones_distance_list #-------------------------------------------------------------------- @staticmethod - def _format_zone_info(zone_data): - statzone_msg = '' - if zone_data[ZD_ZONE] in Gb.StatZones: - statzone_msg = f" ({statzone.devices_in_statzone_count(zone_data[ZD_ZONE])})" - return (f"{zone_data[ZD_DISPLAY_AS]}-{format_dist_m(zone_data[ZD_DIST_M])}" - f"{statzone_msg}") + def _zones_cnt_by_zone(zone_selected, device_zone): + # Get a list of all the zones, their distance, size and display_as + device_zones = [_Device.loc_data_zone for _Device in Gb.Devices] + zones_cnt_by_zone = {zone:device_zones.count(zone) for zone in set(device_zones)} + + # Adjust the zone counts based on the zone selected and the devices current + # zone since the device ha s not been updated yet + zone_selected = NOT_HOME if zone_selected == '' else zone_selected + if device_zone == zone_selected: + return zones_cnt_by_zone + + if zone_selected in zones_cnt_by_zone: + zones_cnt_by_zone[zone_selected] += 1 + else: + zones_cnt_by_zone[zone_selected] = 1 + if device_zone != zone_selected: + zones_cnt_by_zone[device_zone] -= 1 + if zones_cnt_by_zone[device_zone] == 0: + del zones_cnt_by_zone[device_zone] + else: + zones_cnt_by_zone[device_zone] = 1 + + return zones_cnt_by_zone + +#-------------------------------------------------------------------- + def _format_zone_info(self, zone_data): + ''' + Format each device's zone information (display_as, distancee). It is used + to build the info about the zone that was not selected. + + ''' + + return (f"{zone_data[ZD_NAME]}|" + f"{zone_data[ZD_DISPLAY_AS]}^{format_dist_m(zone_data[ZD_DIST_M])}") + #-------------------------------------------------------------------- def _move_into_statzone_if_timer_reached(self, Device): diff --git a/custom_components/icloud3/icloud3_v3.0.rc7.zip b/custom_components/icloud3/icloud3_v3.0.rc7.zip new file mode 100644 index 0000000..bfde3b9 Binary files /dev/null and b/custom_components/icloud3/icloud3_v3.0.rc7.zip differ diff --git a/custom_components/icloud3/support/start_ic3.py b/custom_components/icloud3/support/start_ic3.py index 9124b92..c4e2746 100644 --- a/custom_components/icloud3/support/start_ic3.py +++ b/custom_components/icloud3/support/start_ic3.py @@ -894,6 +894,7 @@ def create_Zones_object(): zone_entities = Gb.hass.states.entity_ids(ZONE) er_zones, zone_entity_data = entity_io.get_entity_registry_data(platform=ZONE) + yaml_zones = [zone for zone in zone_entities if zone.replace('zone.', '') not in er_zones] Gb.state_to_zone = STATE_TO_ZONE_BASE.copy() OldZones_by_zone = Gb.Zones_by_zone.copy() @@ -920,7 +921,9 @@ def create_Zones_object(): # Add HA zones that are saved in the HA Entity Registry. This does not include # current Stationary Zones - for zone in er_zones: + # for zone in er_zones: + for raw_zone in zone_entities: + zone = raw_zone.replace('zone.', '') zone_entity_name = f"zone.{zone}" zone_data = entity_io.get_attributes(zone_entity_name) if (zone_entity_name in zone_entity_data diff --git a/custom_components/icloud3/support/stationary_zone.py b/custom_components/icloud3/support/stationary_zone.py index eaae3dc..2179f00 100644 --- a/custom_components/icloud3/support/stationary_zone.py +++ b/custom_components/icloud3/support/stationary_zone.py @@ -46,10 +46,9 @@ def move_device_into_statzone(Device): if _is_too_close_to_another_zone(Device): return # ''' End of commented out code to test of moving device into a statzone while home - _ha_statzones = ha_statzones() - # Cycle thru existing ic3 StatZones looking for one that can be recreated at a # new location. + _ha_statzones = ha_statzones() for StatZone in Gb.StatZones: if StatZone.passive and StatZone.zone not in _ha_statzones: event_msg = f"Reusing Stationary Zone > {StatZone.fname_id}" @@ -67,10 +66,8 @@ def move_device_into_statzone(Device): StatZone.radius_m = Gb.statzone_radius_m StatZone.passive = False - still_since_secs = Device.statzone_timer - Gb.statzone_still_time_secs _clear_statzone_timer_distance(Device, create_statzone_flag=True) - StatZone.away_attrs[LATITUDE] = latitude StatZone.away_attrs[LONGITUDE] = longitude @@ -82,11 +79,9 @@ def move_device_into_statzone(Device): Device.into_zone_datetime = datetime_now() Device.selected_zone_results = [] - # event_msg =(f"Assigned Stationary Zone > {StatZone.display_as}, > " - # f"StationarySince-{format_time_age(still_since_secs)}") - # post_event(Device.devicename, event_msg) - iosapp_interface.request_location(Device) + + # Move monitored devices into the new StatZone if they should be in it _trigger_monitored_device_update(StatZone, Device, ENTER_ZONE) return True @@ -244,23 +239,36 @@ def ha_statzones(): #-------------------------------------------------------------------- def _trigger_monitored_device_update(StatZone, Device, action): + ''' + When a StatZone is being created, see if any monitored devices are close enough to + the device creating it and, if so, trigger a locate update so they will move into + it. + + When the last device in a StatZone exited from it and there are monitored devices in + it, move all monitored devices in that StatZone out of it. Then trigger an update + redcoat the monitored device as Away + ''' for _Device in Gb.Devices_by_devicename_monitored.values(): - if action == ENTER_ZONE: - dist_m = _Device.distance_m(Device.loc_data_latitude, Device.loc_data_longitude) - event_msg = f"Trigger > Create Stationary Zone > {StatZone.display_as}" - post_event(_Device.devicename, event_msg) + event_msg = "" + if action == ENTER_ZONE and _Device.StatZone is None: + dist_apart_m = _Device.distance_m(Device.loc_data_latitude, Device.loc_data_longitude) + if dist_apart_m <= Gb.statzone_radius_m: + event_msg = f"Trigger > Enter New Stationary Zone > {StatZone.display_as}" + post_event(_Device.devicename, event_msg) elif action == EXIT_ZONE and _Device.StatZone is StatZone: - event_msg = f"Trigger > Remove Stationary Zone > {StatZone.display_as}" + _Device.StatZone = None + event_msg = f"Trigger > Exit Removed Stationary Zone > {StatZone.display_as}" post_event(_Device.devicename, event_msg) else: continue - Gb.force_icloud_update_flag = True - det_interval.update_all_device_fm_zone_sensors_interval(_Device, 5) - _Device.icloud_update_reason = event_msg - _Device.write_ha_sensors_state([NEXT_UPDATE, INTERVAL]) + if event_msg: + Gb.force_icloud_update_flag = True + det_interval.update_all_device_fm_zone_sensors_interval(_Device, 5) + _Device.icloud_update_reason = event_msg + _Device.write_ha_sensors_state([NEXT_UPDATE, INTERVAL]) #-------------------------------------------------------------------- def devices_in_statzone_count(StatZone): diff --git a/custom_components/icloud3/zone.py b/custom_components/icloud3/zone.py index 7a2cb47..247e350 100644 --- a/custom_components/icloud3/zone.py +++ b/custom_components/icloud3/zone.py @@ -20,6 +20,7 @@ ZONE, TITLE, FNAME, NAME, ID, FRIENDLY_NAME, ICON, LATITUDE, LONGITUDE, RADIUS, PASSIVE, STATZONE_RADIUS_1M, ZONE, ) +from .support import iosapp_interface from .helpers.common import (instr, is_statzone, format_gps, zone_display_as,) from .helpers.messaging import (post_event, post_error_msg, post_monitor_msg, log_exception, log_rawdata,_trace, _traceha, ) @@ -70,7 +71,7 @@ def __init__(self, zone, zone_data): self.passive = zone_data.get(PASSIVE, True) self.is_real_zone = (self.radius_m > 0) self.isnot_real_zone = not self.is_real_zone # (Description only zones/Away, not_home, not_set, etc) - self.dist_time_history = [] #Entries are a list - [lat, long, distance, travel time] + self.dist_time_history = [] # Entries are a list - [lat, long, distance, travel time] self.er_zone_id = zone_data.get(ID, zone.lower()) # HA entity_registry id self.entity_id = self.er_zone_id[:6] @@ -190,7 +191,7 @@ def __init__(self, statzone_id): self.fname = f"StatZon{self.statzone_id}" self.fname_id = self.display_as = Gb.zone_display_as[self.zone] = self.fname - #base_attrs is used to move the stationary zone back to it's base + # base_attrs is used to move the stationary zone back to it's base self.base_attrs[NAME] = self.zone self.base_attrs[RADIUS] = STATZONE_RADIUS_1M self.base_attrs[PASSIVE] = True @@ -220,7 +221,7 @@ def __init__(self, statzone_id): self.write_ha_zone_state(self.base_attrs) self.name = self.title = self.display_as - #away_attrs is used to move the stationary zone back to it's base + # away_attrs is used to move the stationary zone back to it's base self.away_attrs = self.base_attrs.copy() self.away_attrs[RADIUS] = Gb.statzone_radius_m self.away_attrs[PASSIVE] = False @@ -233,8 +234,8 @@ def initialize_updatable_items(self): if instr(Gb.statzone_fname, '#') is False: self.fname_id = f"{self.fname} (..._{self.statzone_id})" - self.base_latitude = 0 #Gb.statzone_base_latitude - self.base_longitude = 0 #Gb.statzone_base_longitude + self.base_latitude = 0 + self.base_longitude = 0 self.base_attrs[FRIENDLY_NAME] = self.fname self.base_attrs[LATITUDE] = self.base_latitude @@ -289,11 +290,12 @@ def remove_ha_zone(self): try: Gb.hass.states.async_remove(f"zone.{self.zone}") + Gb.hass.services.call(ZONE, "reload") post_event(f"Removed HA Zone > {self.fname_id}") except Exception as err: + log_exception(err) pass - # log_exception(err) #--------------------------------------------------------------------