From 8bc18f0c3b1579587255ed6c940d65aba47b8558 Mon Sep 17 00:00:00 2001 From: "RZ_MINIX\\rober" Date: Fri, 20 Jun 2025 13:00:58 -0700 Subject: [PATCH] Ver 2.0.0 back to original webhook, and ReadObjectMinIO cn check data --- well-api.py | 713 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 648 insertions(+), 65 deletions(-) diff --git a/well-api.py b/well-api.py index 75f0e1d..d69cce5 100644 --- a/well-api.py +++ b/well-api.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 - +#Vesion 2.0.0 import os import sys import ast @@ -395,21 +395,53 @@ def SaveGenericObjectInBlob(bucket_name, file_name, obj): logger.error(f"Error saving object to blob: {traceback.format_exc()}") return False -def ReadObjectMinIO(bucket_name, file_name): + + +def ReadObjectMinIO(bucket_name, file_name, filter_date=None): + """ + Read object from MinIO with optional date filtering. + + Args: + bucket_name (str): Name of the MinIO bucket + file_name (str): Name of the file/object + filter_date (str, optional): Date string in format "YYYY-MM-DD". + If provided, returns empty string if object + was modified before or on this date. + + Returns: + str: Object content as string, empty string if filtered out, or None on error + """ try: + # If date filtering is requested, check object's last modified date first + if filter_date: + try: + # Get object metadata to check last modified date + stat = miniIO_blob_client.stat_object(bucket_name, file_name) + last_modified = stat.last_modified + + # Parse filter date (assuming format YYYY-MM-DD) + target_date = datetime.datetime.strptime(filter_date, "%Y-%m-%d").date() + + # If object was modified before or on target date, return empty string + if last_modified.date() <= target_date: + return None + + except S3Error as e: + logger.error(f"Error getting metadata for {file_name}: {e}") + return None + except ValueError as e: + logger.error(f"Invalid date format '{filter_date}': {e}") + return None + # Retrieve the object data response = miniIO_blob_client.get_object(bucket_name, file_name) - # Read the data from response data_bytes = response.read() - - # Convert bytes to string and then load into a dictionary + # Convert bytes to string data_string = data_bytes.decode('utf-8') - # Don't forget to close the response response.close() response.release_conn() - return data_string except S3Error as e: @@ -1817,7 +1849,7 @@ def GetDeviceDetailsComplete(cur, deployment_ids, location_id): mac = device_table_record[1] well_id = device_table_record[2] description = device_table_record[3] - alarm_details = device_table_record[36] + alarm_details = device_table_record[16] if description == None: description = "" if device_table_record[5] != None: @@ -2308,11 +2340,17 @@ def filter_short_groups_c_wc(presence_list, filter_size, device_id_str, from_dat filtered_day_str = None if not refresh: - filtered_day_str = ReadObjectMinIO("filtered-presence", filename_day_presence) + filtered_day_str = ReadObjectMinIO("filtered-presence", filename_day_presence, current_date_str) - if filtered_day_str is None: + if filtered_day_str is not None and filtered_day_str != "": + has_larger = bool(re.search(r'\b(?:[2-9]|\d{2,})\.\d+\b', filtered_day_str)) + if has_larger: + filtered_day_str = None + + if filtered_day_str is None or filtered_day_str == "": # Filter the input data input_data = presence_list[input_start_idx:input_end_idx] + print(input_start_idx, input_end_idx, filter_size, device_id_str, from_date, len(input_data)) filtered_data = filter_short_groups_c(input_data, filter_size, device_id_str, from_date) # Extract the portion corresponding to this day @@ -2530,7 +2568,7 @@ def GetLastDurationMinutes(deployment_id, selected_devices, filter, ddate): tzinfo=datetime.timezone(datetime.timedelta(hours=-7)) ) - presence_map = optimized_radar_processing(my_data, start_time, id2well_id, device_id_2_threshold, device_field_indexes, presence_map, data_type) + presence_map = optimized_radar_processing(my_data, start_time_, id2well_id, device_id_2_threshold, device_field_indexes, presence_map, data_type) if myz_data != None: @@ -2937,18 +2975,18 @@ def GetSensorsDetailsFromDeployment(deployment_id, ddate, filter_minutes, fast=T "last_detected_time": last_present_time, "wellness_score_percent": wellness_score_percent, "wellness_descriptor_color": "bg-green-100 text-green-700", - "bedroom_temperature": bedroom_temperature, + "bedroom_temperature": round(bedroom_temperature, 2), "sleep_bathroom_visit_count": sleep_bathroom_visit_count, "bedroom_co2": bedroom_co2, "shower_detected_time": shower_detected_time, "breakfast_detected_time": breakfast_detected_time, - "living_room_time_spent": living_room_time_spent, - "outside_hours": outside_hours, + "living_room_time_spent": round(living_room_time_spent, 2), + "outside_hours": round(outside_hours, 2), "wellness_descriptor": "Great!", "last_seen_alert": "Alert = None", "last_seen_alert_colors": "bg-green-100 text-green-700", #https://tailwindcss.com/docs/colors "most_time_spent_in": "Bedroom", - "sleep_hours": sleep_hours + "sleep_hours": round(sleep_hours, 2) } except Exception as e: print(traceback.format_exc()) @@ -6108,6 +6146,124 @@ def ConvertToBase(time_from_str, time_zone_s): dt = datetime.datetime.strptime(time_from_str, "%Y-%m-%d %H:%M:%S%z") return dt +def GetTimeAndEvents(data): + """ + Calculates non-zero elements and consecutive non-zero groups using itertools. + This is often the most readable and efficient pure Python approach. + """ + # Fast way to count non-zeros since they are all 1.0 + #non_zeros = int(sum(data)) + non_zeros = sum(1 for x in data if x != 0) + # Count groups of non-zero elements + events = sum(1 for key, group in itertools.groupby(data) if key != 0.0) + return non_zeros, events + +def current_date_at_tz(timezone_str): + """ + Returns the current date in the specified timezone in yyyy-mm-dd format. + + Args: + timezone_str (str): Timezone string like "America/Los_Angeles" + + Returns: + str: Current date in yyyy-mm-dd format + """ + # Get the timezone object + tz = pytz.timezone(timezone_str) + + # Get current datetime in the specified timezone + current_dt = datetime.datetime.now(tz) + + # Format as yyyy-mm-dd + return current_dt.strftime('%Y-%m-%d') + + +def GetActivities(device_id, well_id, date_str, filter_size, refresh, timezone_str, radar_threshold_group_st): + #filtered_day has non 0 points that exceeded threshold of radar reads + device_id_str = str(device_id) + + try: + + time_from_str, time_to_str = GetLocalTimeForDate(date_str, timezone_str) + filename_day_presence = f"/{device_id_str}/{device_id_str}_{date_str}_{filter_size}_presence.bin" + filtered_day_str = None + if refresh == False and date_str != current_date_at_tz(timezone_str): + has_larger = False + filtered_day_str = ReadObjectMinIO("filtered-presence", filename_day_presence, date_str) + if filtered_day_str != None and filtered_day_str != "": + has_larger = bool(re.search(r'\b(?:[2-9]|\d{2,})\.\d+\b', filtered_day_str)) + if has_larger: + filtered_day_str = None + if filtered_day_str == None: + + radar_fields_of_interest = [] + + try: + threshold_lst = json.loads(radar_threshold_group_st) + except: + threshold_lst = ["s3_max",12] + radar_fields_of_interest = [threshold_lst[0]] + ids_list = [int(device_id)] + devices_list_str = device_id_str + #sql = get_deployment_radar_only_colapsed_query(devices_list_str, time_from_str, time_to_str, ids_list, radar_fields_of_interest) + sql = get_deployment_radar_10sec_snapped_query_min_max(devices_list_str, time_from_str, time_to_str, ids_list, radar_fields_of_interest) + print(sql) + + with get_db_connection() as conn: + with conn.cursor() as cur: + cur.execute(sql) + my_data = None + my_data = cur.fetchall() + + days_difference = 1 + zeros_list = [0] * 6 * 1440 * days_difference + presence_map = {'presence': {}} + presence_map['presence'][well_id] = zeros_list + + if radar_threshold_group_st == None: + radar_threshold_group_st = '["s3",12]' #last value is threshold to s28 composite + + if len(radar_threshold_group_st) > 8: + radar_threshold_group = json.loads(radar_threshold_group_st) + else: + radar_threshold_group = ["s3",12] + + device_id_2_location = {well_id: ""} + device_id_2_threshold = {well_id: radar_threshold_group} + device_field_indexes = {radar_threshold_group[0].split("_")[0]: 1} #len(radar_fields_of_interest) + id2well_id = {device_id: well_id} + + if len(my_data) > 1: + + start_time_ = my_data[0][0] + parsed_time_ = datetime.datetime.strptime(time_from_str, '%Y-%m-%d %H:%M:%S%z') + + #start_time = datetime.datetime( + #parsed_time.year, + #parsed_time.month, + #parsed_time.day, + #parsed_time.hour, # Adjust for UTC-7 + #parsed_time.minute, + #parsed_time.second, + #tzinfo=datetime.timezone(datetime.timedelta(hours=-7)) + #) + + presence_map = optimized_radar_processing(my_data, start_time_, id2well_id, device_id_2_threshold, device_field_indexes, presence_map, "presence") + + presence_list = filter_short_groups_c_wc(presence_map["presence"][id2well_id[device_id]], filter_size, device_id_str, date_str, date_str, timezone_str) + filtered_day_str = ReadObjectMinIO("filtered-presence", filename_day_presence) + filtered_day = json.loads(filtered_day_str) + else: + filtered_day = json.loads(filtered_day_str) + + non_zeros, events = GetTimeAndEvents(filtered_day) + + return(non_zeros / 360, events) #decas to hours + except Exception as e: + print(filename_day_presence) + print(filtered_day_str) + print(traceback.format_exc()) + return(0, 0) def CreateFullLocationMap(location_image_file, devices_list, selected_date, map_type, force_recreate, chart_type, bw, motion, scale_global, fast, filter_minutes, time_zone_s): #global Id2MACDict @@ -8617,6 +8773,69 @@ def get_deployment_radar_10sec_snapped_query(devices_list_str, time_from_str, ti """ return sql +def get_deployment_radar_10sec_snapped_query_min_max(devices_list_str, time_from_str, time_to_str, ids_list, radar_fields_of_interest): + """ + Generate a TimeScaleDB query for radar readings based on device IDs with time snapped to 10-second intervals. + + Parameters: + devices_list_str (str): Comma-separated string of device IDs + time_from_str (str): Start time for the query + time_to_str (str): End time for the query + ids_list (list): List of device IDs in priority order for sorting + radar_fields_of_interest (list): List of field names required across all devices + + Returns: + str: Generated SQL query + """ + + # Generate the CASE statement for ordering based on the provided ids_list + case_statements = [] + for index, device_id in enumerate(ids_list, start=1): + case_statements.append(f"WHEN {device_id} THEN {index}") + + case_order = "\n ".join(case_statements) + + # Handle fields processing + select_fields = [] + for field in radar_fields_of_interest: + + radar_fields = field.split("_") + field_t = radar_fields[0] + if field_t == "s28": + if radar_fields[1] == "max": + select_fields.append("MAX((s2+s3+s4+s5+s6+s7+s8)/7) AS s28") + else: + select_fields.append("MIN((s2+s3+s4+s5+s6+s7+s8)/7) AS s28") + else: + if radar_fields[1] == "max": + select_fields.append(f"MAX({field_t}) as {field}") + else: + select_fields.append(f"MIN({field_t}) as {field}") + + fields_str = ", ".join(select_fields) + + sql = f""" + SELECT + time_bucket('10 seconds', time) AS ten_seconds, + device_id, + {fields_str} + FROM + radar_readings + WHERE + device_id IN ({devices_list_str}) + AND time >= '{time_from_str}' + AND time < '{time_to_str}' + GROUP BY + ten_seconds, + device_id + ORDER BY + CASE device_id + {case_order} + END, + ten_seconds + """ + return sql + def export_query_to_minio_chunked(connection_params, query, minio_client, bucket_name, blob_name=None, chunksize=10000): """ Export query results to MinIO as CSV in chunks to handle large datasets @@ -11559,6 +11778,58 @@ class RequestParser: else: logger.debug("RequestParser: No body data read") + +def FindDeviceByRole(deployment_id, location_list): + + #For purposes of activity report, Bedroom and Bathroom are determined in order of priority: + #Bedroom: "Bedroom Master", "Bedroom", "Bedroom Guest" (106, 56, 107) + #Bathroom: ""Bathroom Main","Bathroom","Bathroom Guest" (104, 103, 105) + + #location_names_inverted = {"All":-1 ,"?": 0,"Office": 5,"Hallway": 6,"Garage": 7,"Outside": 8,"Conference Room": 9,"Room": 10,"Kitchen": 34, + # "Bedroom": 56,"Living Room": 78,"Bathroom": 102,"Dining Room": 103,"Bathroom Main": ,104,"Bathroom Guest": 105, + # "Bedroom Master": 106, "Bedroom Guest": 107, "Conference Room": 108, "Basement": 109, "Attic": 110, "Other": 200} + + + ttime = datetime.datetime.utcnow().timestamp() + + devices_list, device_ids = GetProximityList(deployment_id, ttime) + + if location_list != []: + for location in location_list: + for device in devices_list: + well_id = device[0] + device_id = device[1] + location_t = device[2] + if location_t == location: + return (device_id, location, well_id) + + else: + conn = get_db_connection() + with conn.cursor() as cur: + + #we need to find beneficiaries from list of deployments + #sql = f'SELECT device_id FROM public.devices where device_id in {device_ids} and other="other"' + sql = "SELECT device_id, location, well_id FROM public.devices WHERE device_id = ANY(%s) AND other = %s" + #print(sql) + cur.execute(sql, (device_ids, "other")) + result = cur.fetchall()#cur.fetchone() + if len(result) > 0: + return result[0] + else: + + devices_list, device_ids = GetProximityList(deployment_id, ttime) + for device in devices_list: + well_id = device[0] + device_id = device[1] + location_t = device[2] + if "Bathroom" in location_t or "Bedroom" in location_t or "Kitchen" in location_t: + pass + else: + return (device_id, location_t, well_id) + + return (0, 0, 0) + + def ensure_date_order(from_date, to_date): """ Ensures that from_date is earlier than to_date. @@ -11584,6 +11855,34 @@ def signum(x): return (x > 0) - (x < 0) +def get_week_days_and_dates(days_back, timezone_str="America/Los_Angeles"): + """ + Generate weekdays and dates from 7 days ago until today for a given timezone. + + Args: + timezone_str (str): Timezone string like "America/Los_Angeles" + + Returns: + list: List of tuples containing (weekday_name, date_string) + """ + # Get the timezone object + tz = pytz.timezone(timezone_str) + + # Get current date in the specified timezone + today = datetime.datetime.now(tz).date() + + # Generate dates from days_back days ago to today + result = [] + for i in range(days_back-1, -1, -1): # days_back days ago to today (inclusive) + date = today - timedelta(days=i) + weekday_name = date.strftime("%A") # Full weekday name + date_string = date.strftime("%Y-%m-%d") # ISO format date + day_of_month = date.day + result.append((date_string, weekday_name, day_of_month)) + + return result + + def filter_short_groups_numpy_orig(presence_list, filter_size, device_id, dates_str): """ Optimized version using NumPy to remove groups of consecutive zeros @@ -12815,7 +13114,10 @@ def optimized_radar_processing(my_data, start_time, id2well_id, device_id_2_thre field_index = field_index_cache[threshold_sig] # Get radar value using cached field index - radar_val = radar_read[field_index] + if field_index >= len(radar_read): + radar_val = radar_read[-1] + else: + radar_val = radar_read[field_index] # Process presence data if data_type == "presence" or data_type == "z-graph" or data_type == "all" or data_type == "multiple": @@ -12880,57 +13182,77 @@ def store_to_file(my_list, filename): print(f"Error: Could not serialize list to JSON. {e}") # e.g. if list contains unsupported types like sets def find_custom_header(headers, name): - """Find a custom header by name in the Telnyx webhook payload""" - if not headers: - return None + """Helper to find a custom header value (case-insensitive name).""" + if not headers: return None for header in headers: - if header.get('name', '').lower() == name.lower(): - return header.get('value') + if header.get('name', '').lower() == name.lower(): return header.get('value') return None -def create_client_state(base_event, call_control_id, prefix): - """Create a base64 encoded client state string as required by Telnyx API""" - # Create the plain text client state string - plain_state = f"{prefix}_{base_event}_{call_control_id[:8]}" if call_control_id else f"{prefix}_{base_event}_unknownccid" - - # Encode to base64 as required by Telnyx API +def encode_state(parts): + """Joins parts with a pipe and base64 encodes the result.""" + plain_state = "|".join(map(str, parts)) base64_state = base64.b64encode(plain_state.encode('utf-8')).decode('ascii') - - logger.debug(f"Client state created: '{plain_state}' -> base64: '{base64_state}'") + # Assuming 'logger' is your app's logger instance + logger.debug(f"Encoded state: '{plain_state}' -> '{base64_state}'") return base64_state -def send_telnyx_command(command, params, api_key): - """Send command to Telnyx API""" +def decode_state(b64_state): + """Decodes a base64 state and splits it by pipe.""" + if not b64_state: return [] + try: + decoded_plain = base64.b64decode(b64_state).decode('utf-8') + parts = decoded_plain.split('|') + logger.debug(f"Decoded state: '{b64_state}' -> '{decoded_plain}' -> {parts}") + return parts + except Exception as e: + logger.error(f"Failed to decode client_state '{b64_state}': {e}") + return [] + +def send_telnyx_command(action_path, params, api_key): + """ + Sends a command to the Telnyx Call Control API actions endpoint. + This function should REPLACE your existing send_telnyx_command. + """ + if not api_key: + logger.error(f"CMDFAIL ('{action_path}'): API_KEY not available.") + return None + + ccid = params.get("call_control_id") + if not ccid: + logger.error(f"CMDFAIL ('{action_path}'): call_control_id missing in params.") + return None + + # Correct endpoint construction for V2 actions + endpoint = f"{TELNYX_API_BASE_URL}/calls/{ccid}/{action_path}" + + # Body should not contain call_control_id for actions API + body = {k: v for k, v in params.items() if k != 'call_control_id'} + headers = { "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json" + "Content-Type": "application/json", + "Accept": "application/json" } - call_control_id = params.pop("call_control_id", None) - data = json.dumps(params) - - logger.debug(f"Sending {command} command to Telnyx API: {data}") - - # Fix for v3 call control IDs - use the correct URL format - if call_control_id and call_control_id.startswith("v3:"): - # For v3 calls - url = f"{TELNYX_API_BASE_URL}/calls/{call_control_id}/{command}" - else: - # For backward compatibility with v2 or other formats - url = f"{TELNYX_API_BASE_URL}/{call_control_id}/{command}" + logger.info(f"SENDCMD ('{action_path}')") + logger.debug(f" Endpoint: POST {endpoint}") + logger.debug(f" JSON Payload: {json.dumps(body, indent=2)}") try: - response = requests.post(url, headers=headers, data=data) - if response.status_code >= 200 and response.status_code < 300: - logger.debug(f"Telnyx command {command} sent successfully.") - return True - else: - logger.error(f"Telnyx rejected {command} command. Status: {response.status_code}") - logger.error(f"Response body: {response.text}\n") - return False - except Exception as e: - logger.exception(f"Error sending Telnyx command {command}: {str(e)}") - return False + response = requests.post(endpoint, json=body, headers=headers, timeout=10) + response.raise_for_status() # Raise HTTPError for bad responses (4xx or 5xx) + logger.info(f"CMDOK ('{action_path}'): Telnyx accepted. Status: {response.status_code}") + return response.json() + except requests.exceptions.HTTPError as e: + logger.error(f"CMDFAIL ('{action_path}'): Telnyx rejected. Status: {e.response.status_code}") + try: + logger.error(f" Telnyx Err Detail: {json.dumps(e.response.json(), indent=2)}") + except json.JSONDecodeError: + logger.error(f" Raw Err Body: {e.response.text[:500]}") + except requests.exceptions.RequestException as e: + logger.exception(f"CMDFAIL ('{action_path}'): Network error") + + return None def StoreToDB(data): event_type = data.get('event_type') @@ -13332,6 +13654,151 @@ def handle_telnyx_webhook2(webhook_data, remote_addr, request_id): # Renamed log # Depending on the error, Telnyx might retry if it doesn't get a 2xx. return "Internal Server Error", 500 +def handle_telnyx_webhook3(webhook_data, remote_addr, request_id): + """ + Processes Telnyx webhook events with full IVR logic for repeating messages. + This function should be added to your well-api.py. + """ + logger.info(f"Processing webhook in handle_telnyx_webhook3 from {remote_addr}, Request-ID: {request_id}") + + # --- ADAPT THIS SECTION to your app's config management --- + # This example assumes config values are accessible as global constants or from a dict. + # Replace these with your actual config access method (e.g., self.config['...']) + config = { + 'api_key': TELNYX_API_KEY, + 'dtmf_timeout_seconds': 10, + 'initial_silence_ms': 500, + 'replay_silence_ms': 100, + 'default_tts_voice': 'female', + 'default_tts_language': 'en-US', + 'client_state_prefix': 'well_api_state', + 'inbound_greeting': 'Thank you for calling. We will be with you shortly.' + } + # --- END ADAPTATION SECTION --- + + try: + StoreToDB(webhook_data) # Call your DB storage function first + + data, payload = webhook_data.get('data', {}), webhook_data.get('data', {}).get('payload', {}) + event_type, record_type, ccid = data.get('event_type'), data.get('record_type'), payload.get('call_control_id') + logger.info(f"EVENT '{event_type}' ({record_type})" + (f", CCID: {ccid}" if ccid else "")) + + if record_type != 'event': + logger.info(f" -> Non-voice event ('{record_type}') received. Ignoring in this handler.") + return True + + b64_client_state = payload.get("client_state") + decoded_parts = decode_state(b64_client_state) + state_name = decoded_parts[0] if decoded_parts else None + if state_name: logger.info(f" State Name Received: '{state_name}'") + + current_api_key = config['api_key'] + + # --- State Machine Logic --- + if event_type == 'call.answered': + if payload.get('direction') == 'incoming': + logger.info(" -> Inbound call detected. Playing generic greeting and hanging up.") + next_state = encode_state(['INBOUND_GREETING_HUP']) + speak_params = {"payload": config['inbound_greeting'], "voice": config['default_tts_voice'], "language": config['default_tts_language'], "call_control_id": ccid, "client_state": next_state} + send_telnyx_command("actions/speak", speak_params, current_api_key) + else: # Outgoing call + audio_url = find_custom_header(payload.get('custom_headers'), 'X-Audio-Url') + tts_payload = find_custom_header(payload.get('custom_headers'), 'X-TTS-Payload') + media_type = "audio" if audio_url else "tts" if tts_payload else "none" + media_value = audio_url or tts_payload + if media_value: + logger.info(f" -> Outbound call. Playing {config['initial_silence_ms']}ms silence buffer.") + next_state = encode_state(['INIT_PLAY_MAIN', media_type, media_value]) + send_telnyx_command("actions/play_silence", {"milliseconds": str(config['initial_silence_ms']), "call_control_id": ccid, "client_state": next_state}, current_api_key) + else: + logger.warning(" -> Outbound call, but no audio/tts payload. Hanging up.") + send_telnyx_command("actions/hangup", {"call_control_id": ccid}, current_api_key) + + elif event_type == 'call.playback.ended': + if state_name == 'INIT_PLAY_MAIN': # Silence ended + logger.info(" -> Silence buffer ended. Playing main message.") + _, media_type, media_value = decoded_parts + next_state = encode_state(['MAIN_MEDIA_PLAYED', media_type, media_value]) + if media_type == "audio": + send_telnyx_command("actions/playback_start", {"audio_url": media_value, "call_control_id": ccid, "client_state": next_state}, current_api_key) + elif media_type == "tts": + params = {"payload": media_value, "voice": config['default_tts_voice'], "language": config['default_tts_language'], "call_control_id": ccid, "client_state": next_state} + send_telnyx_command("actions/speak", params, current_api_key) + elif state_name == 'REPLAY_SILENCE': # Replay silence ended + logger.info(" -> Replay silence ended. Replaying main message.") + _, media_type, media_value = decoded_parts + next_state = encode_state(['REPLAYING_MEDIA', media_type, media_value]) + if media_type == "audio": + send_telnyx_command("actions/playback_start", {"audio_url": media_value, "call_control_id": ccid, "client_state": next_state}, current_api_key) + elif media_type == "tts": + params = {"payload": media_value, "voice": config['default_tts_voice'], "language": config['default_tts_language'], "call_control_id": ccid, "client_state": next_state} + send_telnyx_command("actions/speak", params, current_api_key) + elif state_name in ['MAIN_MEDIA_PLAYED', 'REPLAYING_MEDIA']: # Actual audio file ended + logger.info(f" -> Main audio playback finished. Playing options menu.") + _, media_type, media_value = decoded_parts + next_state = encode_state(['WAITING_DTMF', media_type, media_value]) + options_prompt = "press 0 to repeat the message or press pound to hang up." + gather_params = { + "payload": options_prompt, "voice": config['default_tts_voice'], "language": config['default_tts_language'], + "valid_digits": "0#", "max_digits": 1, "timeout_millis": config['dtmf_timeout_seconds'] * 1000, "terminating_digit": "#", + "call_control_id": ccid, "client_state": next_state + } + send_telnyx_command("actions/gather_using_speak", gather_params, current_api_key) + else: + logger.warning(f" -> Playback ended with unhandled state '{state_name}'. Hanging up.") + send_telnyx_command("actions/hangup", {"call_control_id": ccid}, current_api_key) + + elif event_type == 'call.speak.ended': + if state_name in ['MAIN_MEDIA_PLAYED', 'REPLAYING_MEDIA']: + logger.info(f" -> Main message TTS finished. Playing options menu.") + _, media_type, media_value = decoded_parts + next_state = encode_state(['WAITING_DTMF', media_type, media_value]) + options_prompt = "press 0 to repeat the message or press pound to hang up." + gather_params = { + "payload": options_prompt, "voice": config['default_tts_voice'], "language": config['default_tts_language'], + "valid_digits": "0#", "max_digits": 1, "timeout_millis": config['dtmf_timeout_seconds'] * 1000, "terminating_digit": "#", + "call_control_id": ccid, "client_state": next_state + } + send_telnyx_command("actions/gather_using_speak", gather_params, current_api_key) + elif state_name == 'INBOUND_GREETING_HUP': + logger.info(" -> Inbound greeting finished. Hanging up.") + send_telnyx_command("actions/hangup", {"call_control_id": ccid}, current_api_key) + else: + logger.warning(f" -> Speak ended with unhandled state '{state_name}'. Hanging up.") + send_telnyx_command("actions/hangup", {"call_control_id": ccid}, current_api_key) + + elif event_type == 'call.dtmf.received': + digit = payload.get('digit') + logger.info(f" DTMF Received: Digit='{digit}'") + if digit == '#': + logger.info(" -> '#' received. Terminating call immediately.") + send_telnyx_command("actions/hangup", {"call_control_id": ccid}, current_api_key) + + elif event_type == 'call.gather.ended': + logger.info(f" -> Gather ended. Digits received: '{payload.get('digits')}', Status: '{payload.get('status')}'") + if state_name == 'WAITING_DTMF': + digits = payload.get('digits') + _, media_type, media_value = decoded_parts + if digits == "0": + logger.info(f" -> '0' pressed. Playing {config['replay_silence_ms']}ms silence before replay.") + next_state = encode_state(['REPLAY_SILENCE', media_type, media_value]) + send_telnyx_command("actions/play_silence", {"milliseconds": str(config['replay_silence_ms']), "call_control_id": ccid, "client_state": next_state}, current_api_key) + else: + logger.info(" -> Gather ended with non-repeat condition. Hanging up.") + send_telnyx_command("actions/hangup", {"call_control_id": ccid}, current_api_key) + else: + logger.warning(f" -> Gather ended with unhandled state '{state_name}'.") + + elif event_type == 'call.hangup': + logger.info(f" Call Hangup Event: Cause='{payload.get('cause')}'") + else: + logger.info(f" -> Unhandled Voice Event: '{event_type}' with state '{state_name}'.") + + return True # Return app-specific success + except Exception as e: + logger.exception(f"Error in handle_telnyx_webhook3: {e}") + return False + #==================================== ADD FUNCTIONS BEFORE ============================================ # Main API class @@ -15633,7 +16100,7 @@ class WellApi: tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)) ) - presence_map = optimized_radar_processing(my_data, start_time, id2well_id, device_id_2_threshold, device_field_indexes, presence_map, data_type) + presence_map = optimized_radar_processing(my_data, start_time_, id2well_id, device_id_2_threshold, device_field_indexes, presence_map, data_type) #last_device_id = 0 #for radar_read in my_data: #(datetime.datetime(2025, 4, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200))), 559, 6.512857142857143, 6.91, 9.28) @@ -16009,7 +16476,7 @@ class WellApi: tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200)) ) - presence_map = optimized_radar_processing(my_data, start_time, id2well_id, device_id_2_threshold, device_field_indexes, presence_map, data_type) + presence_map = optimized_radar_processing(my_data, start_time_, id2well_id, device_id_2_threshold, device_field_indexes, presence_map, data_type) #last_device_id = 0 #for radar_read in my_data: #(datetime.datetime(2025, 4, 28, 0, 0, tzinfo=datetime.timezone(datetime.timedelta(days=-1, seconds=61200))), 559, 6.512857142857143, 6.91, 9.28) @@ -16506,6 +16973,119 @@ class WellApi: elif function == "activities_report_details": deployment_id = form_data.get('deployment_id') + + timezone_str = GetTimeZoneOfDeployment(deployment_id) + filterr = form_data.get('filter') + if filterr == None: + filterr = 6 + else: + filterr = int(filterr) + + refresh = form_data.get('refresh') == "1" + ddate = current_date_at_tz(timezone_str) + timee = LocalDateToUTCEpoch(ddate, timezone_str)+5 #add so date boundary is avoided + devices_list, device_ids = GetProximityList(deployment_id, timee) + + #Here we need to add per day: (all based on Z-graph data!) + #Bathroom visits number + #Bathroom time spent + #Sleep weakes number (As breaks in Z-graph indicates in 10PM to 9AM period) + #Sleep length (For now add all times seen in bedroom) + #Kitchen visits number + #Kitchen time spent + #Most frequented room visits number + #Most frequented room time spent + + #Lets find device_id of bathroom sensor + + + bathroom_device_id, location_ba, bathroom_well_id = FindDeviceByRole(deployment_id, ["Bathroom Main", "Bathroom", "Bathroom Guest"]) + bedroom_device_id, location_be, bedroom_well_id = FindDeviceByRole(deployment_id, ["Bedroom Master", "Bedroom", "Bedroom Guest"]) + kitchen_device_id, location_ke, kitchen_well_id = FindDeviceByRole(deployment_id, ["Kitchen"]) + most_present_device_id, location_ot, most_present_well_id = FindDeviceByRole(deployment_id, []) #this will find most_present (as defined in other filed of device record) + + if isinstance(location_ot, int): + other_location = location_names[location_ot] + else: + other_location = location_ot + + #weekly + week_dates = get_week_days_and_dates(7, timezone_str) + month_dates = get_week_days_and_dates(30, timezone_str) + six_months_dates = get_week_days_and_dates(180, timezone_str) + + other_color = Loc2Color[other_location][0] + rgb_string = f"rgb({other_color[0]}, {other_color[1]}, {other_color[2]})" + + rooms_reports = [("Bathroom", "blue", bathroom_device_id, bathroom_well_id), ("Bedroom", "green", bedroom_device_id, bedroom_well_id), ("Kitchen", "red", kitchen_device_id, kitchen_well_id), (other_location, rgb_string, most_present_device_id, most_present_well_id)] + + six_months_report = [] + for room_details in rooms_reports: + device_id = room_details[2] + if device_id > 0: + + well_id = room_details[3] + radar_threshold_group_st = {device[1]: device[5] for device in devices_list}[device_id] + room = {"name": room_details[0],"color": room_details[1]} + data = [] + + for day_activity in six_months_dates: + datee = day_activity[0] + hours, events_count = GetActivities(device_id, well_id, datee, filterr, refresh, timezone_str, radar_threshold_group_st) + + if hours > 18: + print("Too long 6m!!!", device_id, well_id, datee, filterr, refresh, timezone_str, radar_threshold_group_st) + + data_record = { "title": str(day_activity[2]), "events": events_count, "hours": hours} + data.append(data_record) + + room["data"] = data + six_months_report.append(room) + + weekly_report = [] + for room_details in rooms_reports: + device_id = room_details[2] + if device_id > 0: + well_id = room_details[3] + radar_threshold_group_st = {device[1]: device[5] for device in devices_list}[device_id] + room = {"name": room_details[0],"color": room_details[1]} + data = [] + + for day_activity in week_dates: + datee = day_activity[0] + hours, events_count = GetActivities(device_id, well_id, datee, filterr, refresh, timezone_str, radar_threshold_group_st) + data_record = { "title": day_activity[1], "events": events_count, "hours": hours} + data.append(data_record) + + room["data"] = data + weekly_report.append(room) + + monthly_report = [] + for room_details in rooms_reports: + device_id = room_details[2] + if device_id > 0: + well_id = room_details[3] + radar_threshold_group_st = {device[1]: device[5] for device in devices_list}[device_id] + room = {"name": room_details[0],"color": room_details[1]} + data = [] + + for day_activity in month_dates: + datee = day_activity[0] + hours, events_count = GetActivities(device_id, well_id, datee, filterr, refresh, timezone_str, radar_threshold_group_st) + #if datee == "2025-05-20" and device_id == 572: + # print(hours) + if hours > 18: + print("Too long m!!!", device_id, well_id, datee, filterr, refresh, timezone_str, radar_threshold_group_st) + + data_record = { "title": str(day_activity[2]), "events": events_count, "hours": hours} + data.append(data_record) + + room["data"] = data + monthly_report.append(room) + + + + result_dictionary = { "alert_text": "No alert", "alert_color": "bg-green-100 text-green-700", @@ -16515,9 +17095,9 @@ class WellApi: "rooms": [ { "name": "Bathroom", - "color": "purple", + "color": "blue", "data": [ - { "title": "Monday", "events": 186, "hours": 80 }, + { "title": "Monday", "events": 186, "hours": 80.56 }, { "title": "Tuesday", "events": 305, "hours": 200 }, { "title": "Wednesday", "events": 237, "hours": 120 }, { "title": "Thursday", "events": 73, "hours": 190 }, @@ -16528,7 +17108,7 @@ class WellApi: }, { "name": "Bedroom", - "color": "#3b82f6", + "color": "green", "data": [ { "title": "Monday", "events": 186, "hours": 80 }, { "title": "Tuesday", "events": 305, "hours": 200 }, @@ -16541,7 +17121,7 @@ class WellApi: }, { "name": "Kitchen", - "color": "orange", + "color": "red", "data": [ { "title": "Monday", "events": 186, "hours": 80 }, { "title": "Tuesday", "events": 305, "hours": 200 }, @@ -16554,7 +17134,7 @@ class WellApi: }, { "name": "Other", - "color": "hotpink", + "color": "yellow", "data": [ { "title": "Monday", "events": 186, "hours": 80 }, { "title": "Tuesday", "events": 305, "hours": 200 }, @@ -16756,6 +17336,12 @@ class WellApi: ] } + result_dictionary["chart_data"][0]["rooms"] = weekly_report + result_dictionary["chart_data"][1]["rooms"] = monthly_report + result_dictionary["chart_data"][2]["rooms"] = six_months_report + + + payload = result_dictionary #{'result_dictionary': result_dictionary} resp.media = package_response(payload) resp.status = falcon.HTTP_200 @@ -16849,10 +17435,7 @@ class WellApi: details["location_list"] = location_list settings = {"wellness_score": False, "last_seen": False, "sleep_report": True, "activity_report": True, "temperature": True, "humidity": True, "air_pressure": True, "light": True, "air_quality": True, "radar": True, "other_activities": False} details["settings"] = settings - - result_list.append(details) - payload = {'result_list': result_list} resp.media = package_response(payload) resp.status = falcon.HTTP_200