From 4cc8509f9c334265dd577a5330d24bb483aa2a4a Mon Sep 17 00:00:00 2001 From: "RZ_MINIX\\rober" Date: Wed, 25 Jun 2025 15:53:03 -0700 Subject: [PATCH] Ernest_tested_6_25_2025 --- well-alerts.py | 792 ++++++++++++++++++++++++++++++------------------- wh1998v2.py | 222 ++++++++++++++ 2 files changed, 710 insertions(+), 304 deletions(-) create mode 100644 wh1998v2.py diff --git a/well-alerts.py b/well-alerts.py index f6d590c..72f9f43 100644 --- a/well-alerts.py +++ b/well-alerts.py @@ -24,6 +24,7 @@ import psutil import requests import copy import re +import ast import random import traceback #import math @@ -1077,7 +1078,6 @@ def load_device_configurations(): mac_to_device_id[device_detail[2]] = device_detail[0] alarm_device_settings_str = GetDeviceAlarmSettings(device_id) - #if alarm_device_settings != "": # print(alarm_device_settings) deployment_id = device_detail[6] @@ -1085,6 +1085,11 @@ def load_device_configurations(): # print(device_id) device_to_deployment[device_id] = deployment_id + if alarm_device_settings_str != None and alarm_device_settings_str != "": + device_alerts_all[device_id] = json.loads(alarm_device_settings_str) + else: + device_alerts_all[device_id] = {} + if deployment_id not in deployment_id_list: deployment_id_list.append(deployment_id) @@ -1124,6 +1129,7 @@ def load_device_configurations(): last_seen = GetRedisFloat('lastseen_'+device_id_s) if last_seen == None: + #Check from DB last_seen = GetLastDetected(device_id_s, radar_threshold_signal, radar_threshold_value) if last_seen == None: last_seen = 0 @@ -1146,7 +1152,7 @@ def load_device_configurations(): if len(alarm_settings_str) > 2: alarm_settings = json.loads(alarm_settings_str) alarm_armed_settings = alarm_settings['enabled'] - alarm_settings["armed"] = alarm_armed_settings + alarm_settings["armed_states"] = alarm_armed_settings alarms_settings_all[deployment_id] = alarm_settings #lets reset bit 1, so armed alarm is recognized on start if present... start of what??? #alarm_armed_settings = ClearBit(alarm_armed_settings, 1) @@ -3084,6 +3090,9 @@ def PrepareMP3(text_to_speak): def SendPhoneCall(phone_nr, text_str): + if phone_nr[0] == "-" or phone_nr[0] == "_": + return + phone_nr = normalize_phone_number(phone_nr) #TELNYX_WEBHOOK_URL_VOICE = "http://eluxnetworks.net:1998/telnyx-webhook" uuid_str = PrepareMP3(text_str) @@ -3124,7 +3133,7 @@ def SendPhoneCall(phone_nr, text_str): def SendAlerts(deployment_id, method, text_str, subject, user_name): global sender - + #return #todo remove it in production if user_name == "" or user_name == None: #real request so send to all caretakers care_takers = care_takers_all[int(deployment_id)] @@ -3222,15 +3231,77 @@ def GetTimeZoneOfDeployment(deployment_id): def StoreLastSentToRedis(deployment_id): alarms_settings = alarms_settings_all[deployment_id] - alarms_settings["last_triggered_utc"] = datetime.datetime.utcnow() - alarm_deployment_settings_str = json.dumps(alarms_settings) - redis_conn.set('alarm_deployment_settings_'+str(deployment_id), alarm_deployment_settings_str) - + alarms_settings["last_triggered_utc"] = datetime.datetime.utcnow().isoformat() + try: + alarm_deployment_settings_str = json.dumps(alarms_settings) + redis_conn.set('alarm_deployment_settings_'+str(deployment_id), alarm_deployment_settings_str) + except Exception as e: + print(f"Error: {e}") def StoreDeviceAlarmsToRedis(device_id, device_alarm_settings): alarm_device_settings_str = json.dumps(device_alarm_settings) redis_conn.set('alarm_device_settings_'+str(device_id), alarm_device_settings_str) +def StoreDeviceAlarmsToDB(device_id, device_alarm_settings): + + alarm_device_settings_str = json.dumps(device_alarm_settings) + + conn = get_db_connection() + if not conn: + return False + + try: + with conn.cursor() as cur: + + cur.execute("UPDATE devices SET alert_details = %s WHERE device_id = %s", + (alarm_device_settings_str, device_id)) + + print(f"Updated alert_details for {device_id} to {alarm_device_settings_str}") + + conn.commit() + # Update the ACL file + return True + except Exception as e: + print(f"Database error: {e}") + conn.rollback() + return False + finally: + conn.close() + +def StoreLastSentToDB(deployment_id): + alarms_settings = alarms_settings_all[deployment_id] + alarm_deployment_settings_str = json.dumps(alarms_settings) + + conn = get_db_connection() + if not conn: + return False + + try: + with conn.cursor() as cur: + + cur.execute("UPDATE deployments SET alarm_details = %s WHERE deployment_id = %s", + (alarm_deployment_settings_str, deployment_id)) + + print(f"Updated alert_details for {deployment_id} to {alarm_deployment_settings_str}") + + conn.commit() + # Update the ACL file + return True + except Exception as e: + print(f"Database error: {e}") + conn.rollback() + return False + finally: + conn.close() + + +def StoreAllToRedisAndDB(deployment_id, device_id): + StoreLastSentToRedis(deployment_id) + StoreLastSentToDB(deployment_id) + device_alarm_settings = device_alerts_all[device_id] + StoreDeviceAlarmsToRedis(device_id, device_alarm_settings) + StoreDeviceAlarmsToDB(device_id, device_alarm_settings) + def SetupTasks(): global local_daily_minute_last @@ -3261,8 +3332,8 @@ def SetupTasks(): else: alarms_settings = json.loads(alarm_deployment_settings_str) - if "armed" in alarms_settings: - alarm_armed_settings_str = alarms_settings["armed"] + if "enabled" in alarms_settings: + alarm_armed_settings_str = alarms_settings["enabled"] else: alarm_armed_settings_str = "000" @@ -3277,14 +3348,6 @@ def SetupTasks(): rearm_policy = "Never" #alarm_armed_settings_str = GetRedisString(f'alarm_armed_settings_{deployment_id}') - #alert_mode bits: - #0: 0 = default, 1 = set (so I can distinquish between nothing set (0s default) and purposefully set to 0s) - #1: Home Security bit 0 = not used, 1 = alarm armed - #2: Warning level environmental condition (Yellow) Temperatures High/Low - #3: Alarm level environmental condition (Red) Temperatures High/Low - #4: Warning level medical condition (Yellow) Too long present/absent - #5: Alarm level medical condition (Red) Too long present/absent - #6: Alarm if alone too long utc_time = datetime.datetime.utcnow() # Calculate minutes since start of day @@ -3316,154 +3379,171 @@ def SetupTasks(): elif rearm_policy == "Never": pass + devices_lst = deployments_devices[deployment_id] if do_rearm: - alarm_armed_settings, alarm_settings_str = GetAlarmSettings(deployment_id) - if len(alarm_settings_str) > 2: + #No need to re-arm Burglar alarm it remains armed untill manually disarmed + alarm_settings_str = GetAlarmSettings(deployment_id) + if alarm_settings_str != "{}": alarm_settings = json.loads(alarm_settings_str) - alarm_settings["armed"] = alarm_armed_settings - alarms_settings_all[deployment_id] = alarm_settings - alarm_armed_settings_str = alarms_settings["armed"] + #alarm_settings["enabled"] bits: + # bit 2: Burglar Alarm (index 0), bit 1: Time alone Alarm is Set (index 1), bit 0: Time alone Warning is Set (index 2) + if alarm_settings["enabled"][1] == "1": #Time alone Alarm + alarm_settings["alone_alarm_armed"] = True - devices_lst = deployments_devices[deployment_id] + if alarm_settings["enabled"][2] == "1": #Time alone Warning + alarm_settings["alone_warning_armed"] = True + + alarms_settings_all[deployment_id] = alarm_settings + StoreLastSentToRedis(deployment_id) + StoreLastSentToDB(deployment_id) + alarm_armed_settings_str = alarms_settings["armed_states"] for device_id in devices_lst: if device_id in device_alerts_all: device_id_s = str(device_id) alarm_device_settings_str = GetDeviceAlarmSettings(device_id) - redis_conn.set('alarm_device_settings_'+device_id_s, alarm_device_settings_str) + StoreDeviceAlarmsToRedis(device_id, alarm_device_settings_str) + StoreDeviceAlarmsToDB(device_id, device_alarm_settings) - if alarm_armed_settings_str != "": - if alarm_armed_settings_str[-1] == "1": #used - #print(alarm_armed_settings_str) - if alarm_armed_settings_str[-2] == "0": #alarm not armed, so compare individual conditions - if alarm_armed_settings_str[-5] == "1" or alarm_armed_settings_str[-6] == "1": #Too long present/absent - devices_lst = deployments_devices[deployment_id] - numbers = [] + if alarm_armed_settings_str[0] == "1": #"100" Burglar alarm + #Burglar alarm is triggerred on sensor read not time so nothing to be done here + pass - for device_id in devices_lst: - if device_id in device_alerts_all: - since_seen = time.time() - GetRedisFloat('lastseen_'+str(device_id)) - numbers.append((since_seen, device_id)) + #lets check if alone + if "1" in alarm_armed_settings_str[1:]: #"010" alone warning or alarm + pass #todo - sorted_numbers = numbers - second_smallest = -1 - if len(numbers) == 1: - smallest = sorted_numbers[0] - if len(numbers) >= 2: - sorted_numbers = sorted(numbers, key=lambda x: x[0]) - smallest = sorted_numbers[0] - second_smallest = sorted_numbers[1] - device_id_to_last_seen = {} - for tpl in sorted_numbers: - last_seen_ago = tpl[0] - device_id = tpl[1] - #device_id_to_last_seen[tpl[1]] = tpl[0] - device_alarm_settings = device_alerts_all[device_id] - enabled_alarms_str = device_alarm_settings["enabled_alarms"] - #lets check larm first, because if satisfied, no need to check for warning - if enabled_alarms_str[-2] == "1": #Too long present alarm - if device_id == smallest[1]: #now present... how long? - if (second_smallest[0] - smallest[0]) > device_alarm_settings["stuck_minutes_alarm"] * 60: - #cancel alarm and warning, until re-armed - enabled_alarms_str = set_character(enabled_alarms_str, 0, "0") - enabled_alarms_str = set_character(enabled_alarms_str, 1, "0") - dev_det = devices_details_map[device_id] - location = (dev_det[4] + " " + dev_det[5].strip()).strip() - method = device_alarm_settings["stuck_alarm_method_1"] - if method.upper() != "PHONE": - SendAlerts(deployment_id, method, f"Alarm: {user_first_last_name} way too long ({int((second_smallest[0] - smallest[0]) / 60)} minutes) in {location}", "", "") - else: - SendAlerts(deployment_id, method, f"Alarm: {user_first_last_name} way too long in {location}", "", "") + numbers = [] + for device_id in devices_lst: + if device_id in device_alerts_all: + since_seen = time.time() - GetRedisFloat('lastseen_'+str(device_id)) + numbers.append((since_seen, device_id)) - device_alarm_settings["enabled_alarms"] = enabled_alarms_str - StoreLastSentToRedis(deployment_id) - device_alerts_all[device_id] = device_alarm_settings - StoreDeviceAlarmsToRedis(device_id, device_alarm_settings) - else: - check_after = device_alarm_settings["stuck_minutes_alarm"] - int((second_smallest[0] - smallest[0])/60) - if check_after < next_run_in_minutes: - next_run_in_minutes = check_after + #In order to understand if somebody is too long in some place, or to long since it visited, we use following logic: + #For too long since last visit: we check if since_seen is larger than absent_minutes_alarm and absent_minutes_warning value + #For too long (stuck) in same place: we check (time_of_last_read_of_the_device - time_of_last_read_of_any_other_device) larger than stuck_minutes_warning and stuck_minutes_alarm value + second_smallest = -1 + if len(numbers) <2: + #needs at least 2 devices for proper operation + pass + else: + sorted_numbers = sorted(numbers, key=lambda x: x[0]) + smallest = sorted_numbers[0] + second_smallest = sorted_numbers[1] + device_id_to_last_seen = {} + for tpl in sorted_numbers: + last_seen_ago = tpl[0] + device_id = tpl[1] + #device_id_to_last_seen[tpl[1]] = tpl[0] + device_alarm_settings = device_alerts_all[device_id] + if "enabled_alarms" in device_alarm_settings: + enabled_alarms_str = device_alarm_settings["enabled_alarms"] + armed_states = "00000000000" + if "armed_states" in device_alarm_settings: + armed_states = device_alarm_settings["armed_states"] + #lets check alarms first, because if satisfied, no need to check for warning + if enabled_alarms_str[BitIndex(1)] == "1" and armed_states[BitIndex(1)] == "1": #Too long present alarm if enabled and not already triggerred (triggered = 0)! + if device_id == smallest[1]: #now present... how long? + if (second_smallest[0] - smallest[0]) > device_alarm_settings["stuck_minutes_alarm"] * 60: + #cancel alarm and warning, until re-armed + armed_states = set_character(armed_states, 0, "0") + armed_states = set_character(armed_states, 1, "0") + dev_det = devices_details_map[device_id] + location = (dev_det[4] + " " + dev_det[5].strip()).strip() + method = device_alarm_settings["stuck_alarm_method_1"] + if method.upper() != "PHONE": + SendAlerts(deployment_id, method, f"Alarm: {user_first_last_name} way too long ({int((second_smallest[0] - smallest[0]) / 60)} minutes) in {location}", "", "") + else: + SendAlerts(deployment_id, method, f"Alarm: {user_first_last_name} way too long in {location}", "", "") - elif enabled_alarms_str[-1] == "1": #Too long present warning - if (second_smallest[0] - smallest[0]) > device_alarm_settings["stuck_minutes_warning"] * 60: - #cancel warning, until re-armed - enabled_alarms_str = set_character(enabled_alarms_str, 0, "0") - dev_det = devices_details_map[device_id] - location = (dev_det[4] + " " + dev_det[5].strip()).strip() - method = device_alarm_settings["stuck_warning_method_0"] - if method.upper() != "PHONE": - SendAlerts(deployment_id, method, f"Warning: {user_first_last_name} too long ({int((second_smallest[0] - smallest[0]) / 60)} minutes) in {location}", "", "") - else: - SendAlerts(deployment_id, method, f"Warning: {user_first_last_name} too long in {location}", "", "") + device_alarm_settings["armed_states"] = armed_states + StoreLastSentToRedis(deployment_id) + device_alerts_all[device_id] = device_alarm_settings + StoreDeviceAlarmsToRedis(device_id, device_alarm_settings) + else: + check_after = device_alarm_settings["stuck_minutes_alarm"] - int((second_smallest[0] - smallest[0])/60) + if check_after < next_run_in_minutes: + next_run_in_minutes = check_after - StoreLastSentToRedis(deployment_id) - device_alarm_settings["enabled_alarms"] = enabled_alarms_str - device_alerts_all[device_id] = device_alarm_settings - StoreDeviceAlarmsToRedis(device_id, device_alarm_settings) - else: - check_after = device_alarm_settings["stuck_minutes_warning"] - int((second_smallest[0] - smallest[0])/60) - if check_after < next_run_in_minutes: - next_run_in_minutes = check_after + elif enabled_alarms_str[BitIndex(0)] == "1" and armed_states[BitIndex(0)] == "1": #Too long present warning + if (second_smallest[0] - smallest[0]) > device_alarm_settings["stuck_minutes_warning"] * 60: + #cancel warning, until re-armed + armed_states = set_character(armed_states, 0, "0") + dev_det = devices_details_map[device_id] + location = (dev_det[4] + " " + dev_det[5].strip()).strip() + method = device_alarm_settings["stuck_warning_method_0"] + if method.upper() != "PHONE": + SendAlerts(deployment_id, method, f"Warning: {user_first_last_name} too long ({int((second_smallest[0] - smallest[0]) / 60)} minutes) in {location}", "", "") + else: + SendAlerts(deployment_id, method, f"Warning: {user_first_last_name} too long in {location}", "", "") + + StoreLastSentToRedis(deployment_id) + device_alarm_settings["armed_states"] = armed_states + device_alerts_all[device_id] = device_alarm_settings + StoreDeviceAlarmsToRedis(device_id, device_alarm_settings) + else: + check_after = device_alarm_settings["stuck_minutes_warning"] - int((second_smallest[0] - smallest[0])/60) + if check_after < next_run_in_minutes: + next_run_in_minutes = check_after - if enabled_alarms_str[-4] == "1": #Too long absent alarm - if last_seen_ago > device_alarm_settings["absent_minutes_alarm"] * 60: + if enabled_alarms_str[BitIndex(3)] == "1" and armed_states[BitIndex(3)] == "1": #Too long absent alarm + if last_seen_ago > device_alarm_settings["absent_minutes_alarm"] * 60: - enabled_alarms_str = set_character(enabled_alarms_str, 3, "0") - enabled_alarms_str = set_character(enabled_alarms_str, 2, "0") - dev_det = devices_details_map[device_id] - location = (dev_det[4] + " " + dev_det[5].strip()).strip() - method = device_alarm_settings["absent_alarm_method_3"] - if method.upper() != "PHONE": - SendAlerts(deployment_id, method, f"Alarm: Way too long since {user_first_last_name} visited {location} ({int(last_seen_ago / 60)} minutes)", "", "") - else: - SendAlerts(deployment_id, method, f"Alarm: Way too long since {user_first_last_name} visited {location}", "", "") - device_alarm_settings["enabled_alarms"] = enabled_alarms_str - device_alerts_all[device_id] = device_alarm_settings - StoreLastSentToRedis(deployment_id) - StoreDeviceAlarmsToRedis(device_id, device_alarm_settings) - else: - check_after = device_alarm_settings["absent_minutes_alarm"] - int(last_seen_ago/60) - if check_after < next_run_in_minutes: - next_run_in_minutes = check_after + armed_states = set_character(armed_states, 3, "0") + armed_states = set_character(armed_states, 2, "0") + dev_det = devices_details_map[device_id] + location = (dev_det[4] + " " + dev_det[5].strip()).strip() + method = device_alarm_settings["absent_alarm_method_3"] + if method.upper() != "PHONE": + SendAlerts(deployment_id, method, f"Alarm: Way too long since {user_first_last_name} visited {location} ({int(last_seen_ago / 60)} minutes)", "", "") + else: + SendAlerts(deployment_id, method, f"Alarm: Way too long since {user_first_last_name} visited {location}", "", "") + device_alarm_settings["armed_states"] = armed_states + device_alerts_all[device_id] = device_alarm_settings + StoreLastSentToRedis(deployment_id) + StoreDeviceAlarmsToRedis(device_id, device_alarm_settings) + else: + check_after = device_alarm_settings["absent_minutes_alarm"] - int(last_seen_ago/60) + if check_after < next_run_in_minutes: + next_run_in_minutes = check_after - if enabled_alarms_str[-3] == "1": #Too long absent alarm - if last_seen_ago > device_alarm_settings["absent_minutes_warning"] * 60: + if enabled_alarms_str[BitIndex(2)] == "1" and armed_states[BitIndex(2)] == "1": #Too long absent alarm + if last_seen_ago > device_alarm_settings["absent_minutes_warning"] * 60: - enabled_alarms_str = set_character(enabled_alarms_str, 2, "0") - dev_det = devices_details_map[device_id] - location = (dev_det[4] + " " + dev_det[5].strip()).strip() - method = device_alarm_settings["absent_warning_method_2"] - if method.upper() != "PHONE": - SendAlerts(deployment_id, method, f"Warning: Too long since {user_first_last_name} visited {location} ({int(last_seen_ago / 60)} minutes)", "", "") - else: - SendAlerts(deployment_id, method, f"Warning: Too long since {user_first_last_name} visited {location}", "", "") + armed_states = set_character(armed_states, 2, "0") + dev_det = devices_details_map[device_id] + location = (dev_det[4] + " " + dev_det[5].strip()).strip() + method = device_alarm_settings["absent_warning_method_2"] + if method.upper() != "PHONE": + SendAlerts(deployment_id, method, f"Warning: Too long since {user_first_last_name} visited {location} ({int(last_seen_ago / 60)} minutes)", "", "") + else: + SendAlerts(deployment_id, method, f"Warning: Too long since {user_first_last_name} visited {location}", "", "") - device_alarm_settings["enabled_alarms"] = enabled_alarms_str - device_alerts_all[device_id] = device_alarm_settings - StoreLastSentToRedis(deployment_id) - StoreDeviceAlarmsToRedis(device_id, device_alarm_settings) - else: - check_after = device_alarm_settings["absent_minutes_warning"] - int(last_seen_ago/60) - if check_after < next_run_in_minutes: - next_run_in_minutes = check_after + device_alarm_settings["armed_states"] = armed_states + device_alerts_all[device_id] = device_alarm_settings + StoreLastSentToRedis(deployment_id) + StoreDeviceAlarmsToRedis(device_id, device_alarm_settings) + else: + check_after = device_alarm_settings["absent_minutes_warning"] - int(last_seen_ago/60) + if check_after < next_run_in_minutes: + next_run_in_minutes = check_after - #"stuck_warning_method_0": "SMS", - #"stuck_alarm_method_1": "PHONE", - #"absent_warning_method_2": "SMS", - #"absent_alarm_method_3": "PHONE", - #"temperature_high_warning_method_4": "SMS", - #"temperature_high_alarm_method_5": "PHONE", - #"temperature_low_warning_method_6": "SMS", - #"temperature_low_alarm_method_7": "PHONE", - #"radar_alarm_method_8":"MSG", - #"pressure_alarm_method_9":"MSG", - #"light_alarm_method_10":"MSG" - #how to determine when user arived here (time of any other place!) - if alarm_armed_settings_str[-7] == "1": #Too long alone - pass #todo + #"stuck_warning_method_0": "SMS", + #"stuck_alarm_method_1": "PHONE", + #"absent_warning_method_2": "SMS", + #"absent_alarm_method_3": "PHONE", + #"temperature_high_warning_method_4": "SMS", + #"temperature_high_alarm_method_5": "PHONE", + #"temperature_low_warning_method_6": "SMS", + #"temperature_low_alarm_method_7": "PHONE", + #"radar_alarm_method_8":"MSG", + #"pressure_alarm_method_9":"MSG", + #"light_alarm_method_10":"MSG" + #how to determine when user arived here (time of any other place!) + if next_run_in_minutes > 1: next_run_in_minutes = 1 @@ -3760,6 +3840,53 @@ def FahrenheitToCelsius(F): C = (F - 32) * 5/9 return C +def BitIndex(bit_nr): + return -bit_nr - 1 + +def RadarLarger(radar, radar_threshold): + + thr_parts = radar_threshold[0].split("_") + field = thr_parts[0] + if field == "s28": + radar_val = sum(radar[1][3:]) / (7 * radar[1][0]) + elif field == "m0": + radar_val = radar[0][5] / radar[1][0] + elif field == "m1": + radar_val = radar[0][6] / radar[1][0] + elif field == "m2": + radar_val = radar[0][7] / radar[1][0] + elif field == "m3": + radar_val = radar[0][8] / radar[1][0] + elif field == "m4": + radar_val = radar[0][9] / radar[1][0] + elif field == "m5": + radar_val = radar[0][10] / radar[1][0] + elif field == "m6": + radar_val = radar[0][11] / radar[1][0] + elif field == "m7": + radar_val = radar[0][12] / radar[1][0] + elif field == "m8": + radar_val = radar[0][13] / radar[1][0] + elif field == "s2": + radar_val = radar[1][3] / radar[1][0] + elif field == "s3": + radar_val = radar[1][4] / radar[1][0] + elif field == "s4": + radar_val = radar[1][5] / radar[1][0] + elif field == "s5": + radar_val = radar[1][6] / radar[1][0] + elif field == "s6": + radar_val = radar[1][7] / radar[1][0] + elif field == "s7": + radar_val = radar[1][8] / radar[1][0] + elif field == "s8": + radar_val = radar[1][9] / radar[1][0] + + if radar_val > radar_threshold[1]: + return radar_val, True + else: + return radar_val, False + def ProcessQueue(): #here we are looking for alarm conditions in data global in_queue @@ -3774,209 +3901,265 @@ def ProcessQueue(): deployment_id = device_to_deployment[device_id] if deployment_id in alarms_settings_all: alarms_settings = alarms_settings_all[deployment_id] - alarm_armed_settings_str = alarms_settings["armed"] + alarm_armed_settings_str = alarms_settings["enabled"] + # bit 2: Burglar Alarm (index 0), bit 1: Time Alone Alarm is Set (index 1), bit 0: Time alone Warning is Set (index 2) - #0: 0 = default, 1 = set (so I can distinquish between nothing set (0s default) and purposefully set to 0s) - #1: Home Security bit 0 = not used, 1 = alarm armed - #2: Warning level environmental condition (Yellow) Temperatures High/Low - #3: Alarm level environmental condition (Red) Temperatures High/Low - #4: Warning level medical condition (Yellow) Too long present/absent - #5: Alarm level medical condition (Red) Too long present/absent - #6: Alarm if alone too long - temp_offset = -16.0 - if alarm_armed_settings_str[-1] == "1": #used + if device_id in device_alerts_all: + device_alarm_settings = device_alerts_all[device_id] message_dict = json.loads(messagein.decode('utf-8')) - #print(alarm_armed_settings_str) - if alarm_armed_settings_str[-2] == "0": #alarm not armed, so compare individual conditions - if alarm_armed_settings_str[-3] == "1" or alarm_armed_settings_str[-4] == "1": #Temperatures Too High/Low + if "enabled_alarms" in device_alarm_settings: + #Lets check temperatures + temp_offset = -16.0 + if "1" in device_alarm_settings["enabled_alarms"][BitIndex(7):BitIndex(3)]: #Temperatures Too High/Low trigger used? + enabled_alarms_str = device_alarm_settings["enabled_alarms"] + armed_states = device_alarm_settings["armed_states"] #1=armed 0=triggered + #print(alarm_armed_settings_str) if "temperature" in message_dict: temperature = message_dict["temperature"] + temp_offset #at this point temperature is in C if temperature > 0 and temperature < 100: #ignore others + if device_id in device_alerts_all: - device_alarm_settings = device_alerts_all[device_id] - enabled_alarms_str = device_alarm_settings["enabled_alarms"] - ''' - { - "enabled_alarms":"000000000101", - "armed_states":"000000000000", - "stuck_minutes_warning":"771.3", - "stuck_warning_method_0":"SMS", - "stuck_minutes_alarm":600, - "stuck_alarm_method_1":"PHONE", - "absent_minutes_warning":"-1013.4", - "absent_warning_method_2":"SMS", - "absent_minutes_alarm":30, - "absent_alarm_method_3":"PHONE", - "temperature_high_warning":"85", - "temperature_high_warning_method_4": - "SMS","temperature_high_alarm":"95", - "temperature_high_alarm_method_5":"PHONE", - "temperature_low_warning":"60", - "temperature_low_warning_method_6":"SMS", - "temperature_low_alarm":"50", - "temperature_low_alarm_method_7":"PHONE", - "radar_alarm_method_8":"MSG", - "pressure_alarm_method_9":"MSG", - "light_alarm_method_10":"MSG", - "smell_alarm_method_11":"EMAIL", - "rearm_policy":"At midnight" - } - ''' + ''' + { + "enabled_alarms":"000000000101", + "armed_states":"000000000000", + "stuck_minutes_warning":"771.3", + "stuck_warning_method_0":"SMS", + "stuck_minutes_alarm":600, + "stuck_alarm_method_1":"PHONE", + "absent_minutes_warning":"-1013.4", + "absent_warning_method_2":"SMS", + "absent_minutes_alarm":30, + "absent_alarm_method_3":"PHONE", + "temperature_high_warning":"85", + "temperature_high_warning_method_4": + "SMS","temperature_high_alarm":"95", + "temperature_high_alarm_method_5":"PHONE", + "temperature_low_warning":"60", + "temperature_low_warning_method_6":"SMS", + "temperature_low_alarm":"50", + "temperature_low_alarm_method_7":"PHONE", + "radar_alarm_method_8":"MSG", + "pressure_alarm_method_9":"MSG", + "light_alarm_method_10":"MSG", + "smell_alarm_method_11":"EMAIL", + "rearm_policy":"At midnight" + } + ''' + #bits in enabled_alarms and armed_states explained + #"stuck_warning_method_0": "SMS", -1 + #"stuck_alarm_method_1": "PHONE", -2 + #"absent_warning_method_2": "SMS", -3 + #"absent_alarm_method_3": "PHONE", -4 + #"temperature_high_warning_method_4": "SMS", -5 + #"temperature_high_alarm_method_5": "PHONE", -6 + #"temperature_low_warning_method_6": "SMS", -7 + #"temperature_low_alarm_method_7": "PHONE", -8 + #"radar_alarm_method_8":"MSG", -9 + #"pressure_alarm_method_9":"MSG", -10 + #"light_alarm_method_10":"MSG" -11 + #"smell_alarm_method_11":"MSG" -12 + #hast ot be enabled and not triggerred to continue comparing + if enabled_alarms_str[BitIndex(5)] == "1" and armed_states[BitIndex(5)] == "1": #Temperatures Too High Alarm! + if temperature > FahrenheitToCelsius(device_alarm_settings["temperature_high_alarm"]): + #cancel alarm and warning, until re-armed + armed_states = set_character(armed_states, 5, "0") + armed_states = set_character(armed_states, 4, "0") + dev_det = devices_details_map[device_id] + location = (dev_det[4] + " " + dev_det[5].strip()).strip() + method = device_alarm_settings["temperature_high_alarm_method_5"] + first_last_name = GetBeneficiaryFromDeployment(deployment_id) + SendAlerts(deployment_id, method, f"Alarm @ {first_last_name}: Temperature too high! ({temperature:.1f} C) in {location}", "", "") + device_alarm_settings["armed_states"] = armed_states + device_alerts_all[device_id] = device_alarm_settings + StoreAllToRedisAndDB(deployment_id, device_id) - #"stuck_warning_method_0": "SMS", -1 - #"stuck_alarm_method_1": "PHONE", -2 - #"absent_warning_method_2": "SMS", -3 - #"absent_alarm_method_3": "PHONE", -4 - #"temperature_high_warning_method_4": "SMS", -5 - #"temperature_high_alarm_method_5": "PHONE", -6 - #"temperature_low_warning_method_6": "SMS", -7 - #"temperature_low_alarm_method_7": "PHONE", -8 - #"radar_alarm_method_8":"MSG", -9 - #"pressure_alarm_method_9":"MSG", -10 - #"light_alarm_method_10":"MSG" -11 - #"smell_alarm_method_11":"MSG" -12 + #at this point we also need to store it to DB, otherwise when HTML GUI is loaded fact that trigger happened will not be reflected! + elif enabled_alarms_str[BitIndex(4)] == "1" and armed_states[BitIndex(4)] == "1": #Temperatures Too High Warning! - if enabled_alarms_str[-6] == "1": #Temperatures Too High Alarm! - if temperature > FahrenheitToCelsius(device_alarm_settings["temperature_high_alarm"]): - #cancel alarm and warning, until re-armed - enabled_alarms_str = set_character(enabled_alarms_str, 5, "0") - enabled_alarms_str = set_character(enabled_alarms_str, 4, "0") + if temperature > FahrenheitToCelsius(device_alarm_settings["temperature_high_warning"]): + #cancel warning, until re-armed + armed_states = set_character(armed_states, 4, "0") + dev_det = devices_details_map[device_id] + location = (dev_det[4] + " " + dev_det[5].strip()).strip() + method = device_alarm_settings["temperature_high_warning_method_4"] + first_last_name = GetBeneficiaryFromDeployment(deployment_id) + SendAlerts(deployment_id, method, f"Warning @ {first_last_name}: Temperature too high! ({temperature:.1f} C) in {location}", "", "") + device_alarm_settings["armed_states"] = armed_states + device_alerts_all[device_id] = device_alarm_settings + StoreAllToRedisAndDB(deployment_id, device_id) + + if armed_states[BitIndex(7)] == "1" and armed_states[BitIndex(7)] == "1": #Temperatures Too Low Alarm! + if temperature < FahrenheitToCelsius(device_alarm_settings["temperature_low_alarm"]): + #cancel alarm and warning, until re-armed + armed_states = set_character(armed_states, 7, "0") + armed_states = set_character(armed_states, 6, "0") + dev_det = devices_details_map[device_id] + location = (dev_det[4] + " " + dev_det[5].strip()).strip() + method = device_alarm_settings["temperature_low_alarm_method_7"] + first_last_name = GetBeneficiaryFromDeployment(deployment_id) + SendAlerts(deployment_id, method, f"Alarm @ {first_last_name} Temperature too low! ({temperature:.1f} C) in {location}", "", "") + device_alarm_settings["armed_states"] = armed_states + device_alerts_all[device_id] = device_alarm_settings + StoreAllToRedisAndDB(deployment_id, device_id) + + elif armed_states[BitIndex(6)] == "1" and armed_states[BitIndex(6)] == "1": #Temperatures Too Low Warning! + + if temperature < FahrenheitToCelsius(device_alarm_settings["temperature_low_warning"]): + #cancel warning, until re-armed + armed_states = set_character(armed_states, 6, "0") dev_det = devices_details_map[device_id] location = (dev_det[4] + " " + dev_det[5].strip()).strip() - method = device_alarm_settings["temperature_high_alarm_method_5"] - SendAlerts(deployment_id, method, f"Alarm @ {first_last_name}: Temperature too high! ({temperature} C) in {location}", "", "") - device_alarm_settings["enabled_alarms"] = enabled_alarms_str + method = device_alarm_settings["temperature_low_warning_method_6"] + first_last_name = GetBeneficiaryFromDeployment(deployment_id) + SendAlerts(deployment_id, method, f"Warning @ {first_last_name} Temperature too low! ({temperature:.1f} C) in {location}", "", "") + device_alarm_settings["armed_states"] = armed_states device_alerts_all[device_id] = device_alarm_settings - StoreLastSentToRedis(deployment_id) - StoreDeviceAlarmsToRedis(device_id, device_alarm_settings) - elif enabled_alarms_str[-5] == "1": #Temperatures Too High Warning! + StoreAllToRedisAndDB(deployment_id, device_id) - if temperature > FahrenheitToCelsius(device_alarm_settings["temperature_high_warning"]): - #cancel alarm and warning, until re-armed - enabled_alarms_str = set_character(enabled_alarms_str, 4, "0") - dev_det = devices_details_map[device_id] - location = (dev_det[4] + " " + dev_det[5].strip()).strip() - method = device_alarm_settings["temperature_high_warning_method_4"] - SendAlerts(deployment_id, method, f"Warning @ {first_last_name}: Temperature too high! ({temperature} C) in {location}", "", "") - device_alarm_settings["enabled_alarms"] = enabled_alarms_str - device_alerts_all[device_id] = device_alarm_settings - StoreLastSentToRedis(deployment_id) - StoreDeviceAlarmsToRedis(device_id, device_alarm_settings) - if enabled_alarms_str[-8] == "1": #Temperatures Too Low Alarm! - if temperature < FahrenheitToCelsius(device_alarm_settings["temperature_low_alarm"]): - #cancel alarm and warning, until re-armed - enabled_alarms_str = set_character(enabled_alarms_str, 7, "0") - enabled_alarms_str = set_character(enabled_alarms_str, 6, "0") - dev_det = devices_details_map[device_id] - location = (dev_det[4] + " " + dev_det[5].strip()).strip() - method = device_alarm_settings["temperature_low_alarm_method_7"] - SendAlerts(deployment_id, method, f"Alarm @ {first_last_name} Temperature too low! ({temperature} C) in {location}", "", "") - device_alarm_settings["enabled_alarms"] = enabled_alarms_str - device_alerts_all[device_id] = device_alarm_settings - StoreLastSentToRedis(deployment_id) - StoreDeviceAlarmsToRedis(device_id, device_alarm_settings) - - elif enabled_alarms_str[-7] == "1": #Temperatures Too Low Warning! - - if temperature < FahrenheitToCelsius(device_alarm_settings["temperature_low_warning"]): - #cancel alarm and warning, until re-armed - enabled_alarms_str = set_character(enabled_alarms_str, 6, "0") - dev_det = devices_details_map[device_id] - location = (dev_det[4] + " " + dev_det[5].strip()).strip() - method = device_alarm_settings["temperature_low_warning_method_6"] - SendAlerts(deployment_id, method, f"Warning @ {first_last_name} Temperature too low! ({temperature} C) in {location}", "", "") - device_alarm_settings["enabled_alarms"] = enabled_alarms_str - device_alerts_all[device_id] = device_alarm_settings - StoreLastSentToRedis(deployment_id) - StoreDeviceAlarmsToRedis(device_id, device_alarm_settings) - - logger.info(f"{tim}, {mac}, {temperature}") + #logger.info(f"{tim}, {mac}, {temperature}") else: #radar packet pass - else: #alarm is armed - if "radar" in message_dict: - radar = message_dict["radar"] + + #lets check if alarm condition + if alarm_armed_settings_str[BitIndex(2)] == "1": #alarm is armed! + if "radar" in message_dict: + enabled_alarms_str = device_alarm_settings["enabled_alarms"] + armed_states = device_alarm_settings["armed_states"] #1=armed 0=triggered + if enabled_alarms_str[BitIndex(8)] == "1" and armed_states[BitIndex(8)] == "1": + radar = message_dict["radar"] + radar_threshold_str = GetRedisString(f"radar_threshold{device_id}") + radar_threshold = ast.literal_eval(radar_threshold_str) + radar_value, larger = RadarLarger(radar, radar_threshold) + print(device_id, radar_value, larger) + if larger: + + #cancel alarm for this room, until re-armed + armed_states = set_character(armed_states, 8, "0") + dev_det = devices_details_map[device_id] + location = (dev_det[4] + " " + dev_det[5].strip()).strip() + method = device_alarm_settings["radar_alarm_method_8"] + first_last_name = GetBeneficiaryFromDeployment(deployment_id) + SendAlerts(deployment_id, method, f"At {first_last_name} burglar alarm is triggered in the {location}", "", "") + device_alarm_settings["armed_states"] = armed_states + device_alerts_all[device_id] = device_alarm_settings + StoreAllToRedisAndDB(deployment_id, device_id) + + + else: pass #alarm not setup for this device #print(f"{deployment_id} not in {alarms_settings_all}") else: - logger.error(f"MAC: {mac} not part of any deployment") + pass + #logger.error(f"MAC: {mac} not part of any deployment") except Exception as e: logger.error(f"Error: {str(e)} {traceback.format_exc()}") -def CheckMessageSends(): +def CheckRedisMessages(): requests_count = 0 - + #print(f"CheckRedisMessages") # Check if queue exists and has items. These items are from manual GUI interactions in alerts page + + #Any test messages requested to be sent? queue_length = redis_conn.llen('send_requests') + if queue_length > 0: - if queue_length == 0: - return 0 + print(f"Processing send_requests message from queue...") - print(f"Processing {queue_length} messages from queue...") + # Process each item + for i in range(queue_length): + item_json = redis_conn.rpop('send_requests') - # Process each item - for i in range(queue_length): - item_json = redis_conn.rpop('send_requests') + if item_json is None: + break - if item_json is None: - break + try: + record = json.loads(item_json) + requests_count += 1 - try: - record = json.loads(item_json) - requests_count += 1 + # Print the record + print(f"Request #{requests_count}:") + for key, value in record.items(): + print(f" {key}: {value}") - # Print the record - print(f"Request #{requests_count}:") - for key, value in record.items(): - print(f" {key}: {value}") + method = record["method"] + location_str = record["location"] + location = location_str.split("_")[2] + deployment_id = record["deployment_id"] + content = record["content"] + feature = record["feature"] + enabledCellContent = record["enabledCellContent"] + currentUnits = record["currentUnits"] + currentAlertTableMode = record["currentAlertTableMode"] #Warning/Alarm + test_only = record["test_only"] + #action = record["action"] + user_name = record["user_name"] + user_first_last_name = GetBeneficiaryFromDeployment(deployment_id) - method = record["method"] - location_str = record["location"] - location = location_str.split("_")[2] - deployment_id = record["deployment_id"] - content = record["content"] - feature = record["feature"] - enabledCellContent = record["enabledCellContent"] - currentUnits = record["currentUnits"] - currentAlertTableMode = record["currentAlertTableMode"] #Warning/Alarm - test_only = record["test_only"] - #action = record["action"] - user_name = record["user_name"] - user_first_last_name = GetBeneficiaryFromDeployment(deployment_id) + if feature == "stuck": + msg_ext = f"{currentAlertTableMode}: {content} {user_first_last_name} is spending more than {enabledCellContent} in {location}" + elif feature == "absent": + msg_ext = f"{currentAlertTableMode}: {content} {user_first_last_name} did not visit {location} in more than {enabledCellContent[1:-1]} {currentUnits}" + elif feature == "tempLow": + msg_ext = f"{currentAlertTableMode}: {content} temperature is lower then {enabledCellContent} {currentUnits} in {location} at {user_first_last_name}" + elif feature == "tempHigh": + msg_ext = f"{currentAlertTableMode}: {content} temperature is higher then {enabledCellContent} {currentUnits} in {location} at {user_first_last_name}" + elif feature == "pressure": + msg_ext = f"{currentAlertTableMode}: {content} door was opened or closed in the {location} at {user_first_last_name}" + elif feature == "radar": + msg_ext = f"{currentAlertTableMode}: {content} motion detected in the {location} at {user_first_last_name}" + else: + msg_ext = f"{currentAlertTableMode}: {content} {feature} in {location} at {user_first_last_name}" - if feature == "stuck": - msg_ext = f"{currentAlertTableMode}: {content} {user_first_last_name} is spending more than {enabledCellContent} in {location}" - elif feature == "absent": - msg_ext = f"{currentAlertTableMode}: {content} {user_first_last_name} did not visit {location} in more than {enabledCellContent[1:-1]} {currentUnits}" - elif feature == "tempLow": - msg_ext = f"{currentAlertTableMode}: {content} temperature is lower then {enabledCellContent} {currentUnits} in {location} at {user_first_last_name}" - elif feature == "tempHigh": - msg_ext = f"{currentAlertTableMode}: {content} temperature is higher then {enabledCellContent} {currentUnits} in {location} at {user_first_last_name}" - elif feature == "pressure": - msg_ext = f"{currentAlertTableMode}: {content} door was opened or closed in the {location} at {user_first_last_name}" - elif feature == "radar": - msg_ext = f"{currentAlertTableMode}: {content} motion detected in the {location} at {user_first_last_name}" - else: - msg_ext = f"{currentAlertTableMode}: {content} {feature} in {location} at {user_first_last_name}" + SendAlerts(deployment_id, method, msg_ext, "Test message", user_name) + #these are testing messages, so do not count them as real triggered... so do not update in REDIS + #StoreLastSentToRedis(deployment_id) - SendAlerts(deployment_id, method, msg_ext, "Test message", user_name) - #these are testing messages, so do not count them as real triggered... so do not update in REDIS - #StoreLastSentToRedis(deployment_id) + print("-" * 40) - print("-" * 40) + except json.JSONDecodeError as e: + logger.error(f"Failed to parse JSON from queue item: {e}") + continue - except json.JSONDecodeError as e: - logger.error(f"Failed to parse JSON from queue item: {e}") - continue + #Any test messages requested to be sent? + queue_length = redis_conn.llen('new_alarms') + if queue_length > 0: + for i in range(queue_length): - print(f"Total requests processed: {requests_count}") + print(f"Processing send_requests message from queue...") + item_json = redis_conn.rpop('new_alarms') + + if item_json is None: + break + + try: + record = json.loads(item_json) + deployment_id = int(record["deployment_id"]) + device_id = int(record["device_id"]) + print(record) + + device_alarms_json = GetRedisString('alarm_device_settings_'+str(device_id)) + deployment_alarms_json = GetRedisString('alarm_deployment_settings_'+str(deployment_id)) + + alarms_settings_all[deployment_id] = json.loads(deployment_alarms_json) + device_alerts_all[device_id] = json.loads(device_alarms_json) + print(device_alarms_json) + print(deployment_alarms_json) + + #method = record["method"] + #location_str = record["location"] + except json.JSONDecodeError as e: + logger.error(f"Failed to parse JSON from queue item: {e}") + continue + + #print(f"Total requests processed: {requests_count}") return requests_count # --- Main Execution --- @@ -4015,6 +4198,7 @@ if __name__ == "__main__": SendAlerts(21, "EMAIL", f"well-alert was started", "Program started", "robster") #SendAlerts(21, "SMS", f"Test: User way too long ({120} minutes) in Bathroom", "") #SendAlerts(21, "PHONE", f"Test: User way too long ({120} minutes) in Bathroom", "") + SendPhoneCall("-4086462191", "Hi Robert. How are you?") #SendPhoneCall("4086462191", "Hi Robert. How are you?", "") #SendPhoneCall("4085505424", "Hi Fred. How are you? Are you hungry?", "") #SendPhoneCall("4087055709", "Hi Bernhard. How are you? Are you hungry?", "") @@ -4033,7 +4217,7 @@ if __name__ == "__main__": # Keep the main thread alive until stop_event is set by signal or error while not stop_event.is_set(): # Can add periodic health checks here if needed - CheckMessageSends() + CheckRedisMessages() time.sleep(1) # Check stop_event periodically logger.info("Stop event received, waiting for monitoring thread to finish...") diff --git a/wh1998v2.py b/wh1998v2.py new file mode 100644 index 0000000..c9e31d7 --- /dev/null +++ b/wh1998v2.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +import os +import json +import requests +import re +import base64 +from flask import Flask, request, Response, jsonify +import logging +import logging.handlers +import sys +import argparse +from dotenv import load_dotenv + +# --- Configuration Loading --- +def load_env_file(filepath): + if not os.path.exists(filepath): return False + print(f"INFO: Loading environment file: {filepath}") + try: + load_dotenv(dotenv_path=filepath, override=True); return True + except Exception as e: print(f"ERROR: Failed to load env file {filepath}: {e}"); return False + +env_loaded = any(load_env_file(os.path.join(os.getcwd(), f)) for f in ['.env', 'env']) +if not env_loaded: print("INFO: No .env or env file found.") + +# --- Global Configuration --- +LOG_LEVEL_DEFAULT = os.environ.get("LOG_LEVEL", "INFO").upper() +LOG_FILE_NAME_DEFAULT = os.environ.get("LOG_FILE_PATH", "webhook.log") +TELNYX_API_KEY_DEFAULT = os.environ.get("TELNYX_API_KEY", None) +TELNYX_API_BASE_URL = os.environ.get("TELNYX_API_BASE_URL", "https://api.telnyx.com/v2") +DEFAULT_TTS_VOICE_DEFAULT = os.environ.get("TELNYX_DEFAULT_TTS_VOICE", "female") +DEFAULT_TTS_LANGUAGE_DEFAULT = os.environ.get("TELNYX_DEFAULT_TTS_LANGUAGE", "en-US") +CLIENT_STATE_PREFIX_DEFAULT = os.environ.get("TELNYX_CLIENT_STATE_PREFIX", "app_state") +DTMF_TIMEOUT_SECONDS_DEFAULT = int(os.environ.get("DTMF_TIMEOUT_SECONDS", 10)) +INBOUND_GREETING_DEFAULT = os.environ.get("INBOUND_GREETING", "Thank you for calling Wellnuo. We are processing your request. Goodbye.") + +# --- Application Setup --- +app = Flask(__name__) +app_logger = logging.getLogger("TelnyxWebhookApp_Fixed") + +# --- Helper Functions --- +def setup_logging(level, file_path, name="TelnyxWebhookApp_Fixed"): + global app_logger; app_logger = logging.getLogger(name) + numeric_level = getattr(logging, level.upper(), logging.INFO) + app_logger.setLevel(numeric_level); app_logger.propagate = False + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - [%(funcName)s:%(lineno)d] - %(message)s') + if app_logger.hasHandlers(): app_logger.handlers.clear() + console_handler = logging.StreamHandler(sys.stdout); console_handler.setFormatter(formatter); app_logger.addHandler(console_handler) + try: + file_handler = logging.handlers.RotatingFileHandler(file_path, maxBytes=10*1024*1024, backupCount=5, encoding='utf-8') + file_handler.setFormatter(formatter); app_logger.addHandler(file_handler) + app_logger.info(f"Logging configured. Level: {level}. File: {os.path.abspath(file_path)}") + except Exception as e: app_logger.error(f"Failed to config file logger at {file_path}: {e}") + +def find_custom_header(headers, name): + if not headers: return None + for header in headers: + if header.get('name', '').lower() == name.lower(): return header.get('value') + return None + +def encode_state(parts): + plain_state = "|".join(map(str, parts)) + base64_state = base64.b64encode(plain_state.encode('utf-8')).decode('ascii') + app_logger.debug(f"Encoded state: '{plain_state}' -> '{base64_state}'") + return base64_state + +def decode_state(b64_state): + if not b64_state: return [] + try: + decoded_plain = base64.b64decode(b64_state).decode('utf-8') + parts = decoded_plain.split('|'); app_logger.debug(f"Decoded state: '{b64_state}' -> '{decoded_plain}' -> {parts}"); return parts + except Exception as e: app_logger.error(f"Failed to decode client_state '{b64_state}': {e}"); return [] + +def send_telnyx_command(action_path, params, api_key): + if not api_key: app_logger.error(f"CMDFAIL ('{action_path}'): API_KEY not set."); return None + ccid = params.get("call_control_id"); + if not ccid: app_logger.error(f"CMDFAIL ('{action_path}'): call_control_id missing."); return None + endpoint = f"{TELNYX_API_BASE_URL}/calls/{ccid}/{action_path}"; + body = {k: v for k, v in params.items() if k != 'call_control_id'} + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "Accept": "application/json"} + app_logger.info(f"SENDCMD ('{action_path}')"); app_logger.debug(f" Endpoint: POST {endpoint}"); app_logger.debug(f" JSON Payload: {json.dumps(body, indent=2)}") + try: + r = requests.post(endpoint, json=body, headers=headers, timeout=10) + r.raise_for_status() + app_logger.info(f"CMDOK ('{action_path}'): Telnyx accepted. Status: {r.status_code}"); return r.json() + except requests.exceptions.HTTPError as e: + app_logger.error(f"CMDFAIL ('{action_path}'): Telnyx rejected. Status: {e.response.status_code}") + try: app_logger.error(f" Telnyx Err Detail: {json.dumps(e.response.json(), indent=2)}") + except json.JSONDecodeError: app_logger.error(f" Raw Err Body: {e.response.text[:500]}") + except requests.exceptions.RequestException as e: app_logger.exception(f"CMDFAIL ('{action_path}'): Network error") + return None + +# --- Webhook Route Handler --- +@app.route('/', methods=['POST']) +def handle_telnyx_webhook(webhook_path_received): + global app_args + if f"/{webhook_path_received}" != app_args.webhook_path: app_logger.warning(f"REQ Unknown Path: '/{webhook_path_received}'"); return "Not found", 404 + app_logger.info(f"REQ <<< Path: '/{webhook_path_received}', From: {request.remote_addr}") + try: + webhook_data = request.get_json(); app_logger.debug(f"REQ Payload Full: {json.dumps(webhook_data, indent=2)}") + 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') + app_logger.info(f"EVENT '{event_type}' ({record_type})" + (f", CCID: {ccid}" if ccid else "")) + + if record_type == 'message': + app_logger.info(f" -> SMS Event received. From: {payload.get('from',{}).get('phone_number')}, Text: '{payload.get('text','')}'") + return Response(status=204) + elif record_type != 'event': + app_logger.warning(f" Unknown Record Type '{record_type}'. Ignoring."); return Response(status=204) + + 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: app_logger.info(f" State Name Received: '{state_name}'") + current_api_key = app_args.api_key + + # --- State Machine Logic --- + if event_type == 'call.answered': + if payload.get('direction') == 'incoming': + app_logger.info(" -> Inbound call detected. Playing generic greeting and hanging up.") + next_state = encode_state(['INBOUND_GREETING_HUP']) + speak_params = {"payload": app_args.inbound_greeting, "voice": app_args.default_tts_voice, "language": app_args.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: + app_logger.info(f" -> Outbound call answered. Playing main message directly.") + 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": + speak_params = {"payload": media_value, "voice": app_args.default_tts_voice, "language": app_args.default_tts_language, "call_control_id": ccid, "client_state": next_state} + send_telnyx_command("actions/speak", speak_params, current_api_key) + else: + app_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 in ['call.speak.ended', 'call.playback.ended']: + app_logger.info(f" Playback/Speak Ended: Status='{payload.get('status')}'") + if state_name == 'INBOUND_GREETING_HUP': + app_logger.info(" -> Inbound greeting finished. Hanging up.") + send_telnyx_command("actions/hangup", {"call_control_id": ccid}, current_api_key) + elif state_name in ['MAIN_MEDIA_PLAYED', 'REPLAYING_MEDIA']: + app_logger.info(f" -> Main message 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": app_args.default_tts_voice, "language": app_args.default_tts_language, + "valid_digits": "0#", "max_digits": 1, "timeout_millis": app_args.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: + app_logger.warning(f" -> Playback/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') + app_logger.info(f" DTMF Received: Digit='{digit}'") + if digit == '#': + app_logger.info(" -> '#' received. Terminating call immediately.") + send_telnyx_command("actions/hangup", {"call_control_id": ccid}, current_api_key) + + elif event_type == 'call.gather.ended': + app_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": + app_logger.info(" -> '0' pressed. Replaying main message.") + # Note: No silence buffer on replay for responsiveness. + 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": + speak_params = {"payload": media_value, "voice": app_args.default_tts_voice, "language": app_args.default_tts_language, "call_control_id": ccid, "client_state": next_state} + send_telnyx_command("actions/speak", speak_params, current_api_key) + else: # Includes '#' having already triggered hangup, timeout, or other hangup condition + app_logger.info(" -> Gather ended with non-repeat condition. Hanging up.") + send_telnyx_command("actions/hangup", {"call_control_id": ccid}, current_api_key) + else: + app_logger.warning(f" -> Gather ended with unhandled state '{state_name}'.") + + elif event_type == 'call.hangup': + app_logger.info(f" Call Hangup Event: Cause='{payload.get('cause')}'") + else: + app_logger.info(f" -> Unhandled Voice Event: '{event_type}' with state '{state_name}'.") + + return Response(status=204) + except Exception as e: app_logger.exception("REQFAIL Unhandled Ex"); return "Internal Server Error", 500 + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description="Telnyx IVR Webhook (Fixed): Async, Repeat, DTMF Hangup.", formatter_class=argparse.RawTextHelpFormatter) + parser.add_argument('--port', type=int, default=1998, help='Port (default: 1998).') + parser.add_argument('--host', default='0.0.0.0', help='Host address (default: 0.0.0.0).') + parser.add_argument('--webhook-path', default='/telnyx-webhook', help="URL path (default: /telnyx-webhook).") + parser.add_argument('--log-level', default=LOG_LEVEL_DEFAULT, choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], help=f'Log level (default: {LOG_LEVEL_DEFAULT}).') + parser.add_argument('--log-file', default=LOG_FILE_NAME_DEFAULT, help=f'Log file path (default: {LOG_FILE_NAME_DEFAULT}).') + parser.add_argument('--api-key', default=TELNYX_API_KEY_DEFAULT, help='Telnyx API Key (default: from env).') + parser.add_argument('--dtmf-timeout-seconds', type=int, default=DTMF_TIMEOUT_SECONDS_DEFAULT, help=f'Timeout for DTMF (default: {DTMF_TIMEOUT_SECONDS_DEFAULT}s).') + parser.add_argument('--default-tts-voice', default=DEFAULT_TTS_VOICE_DEFAULT, help=f'Default TTS voice (default: {DEFAULT_TTS_VOICE_DEFAULT}).') + parser.add_argument('--default-tts-language', default=DEFAULT_TTS_LANGUAGE_DEFAULT, help=f'Default TTS language (default: {DEFAULT_TTS_LANGUAGE_DEFAULT}).') + parser.add_argument('--client-state-prefix', default=CLIENT_STATE_PREFIX_DEFAULT, help=f'Prefix for client_state (default: {CLIENT_STATE_PREFIX_DEFAULT}).') + parser.add_argument('--inbound-greeting', default=INBOUND_GREETING_DEFAULT, help=f'TTS greeting for inbound calls.') + parser.add_argument('--debug', action='store_true', help='Run Flask in debug mode.') + app_args = parser.parse_args() + + if not app_args.webhook_path.startswith('/'): app_args.webhook_path = '/' + app_args.webhook_path + if not app_args.api_key: print("CRITICAL WARNING: Telnyx API Key not set. Voice commands will FAIL.") + + setup_logging("DEBUG" if app_args.debug else app_args.log_level.upper(), app_args.log_file, name="TelnyxWebhookApp_Final") + if not app_args.debug: logging.getLogger('werkzeug').setLevel(logging.WARNING) + + app_logger.info("--- Telnyx Webhook Listener Starting (Final) ---") + app_logger.info(f" Configuration: {vars(app_args)}") + app_logger.info("------------------------------------------------") + + app.run(host=app_args.host, port=app_args.port, debug=app_args.debug, use_reloader=app_args.debug) + + \ No newline at end of file