From f2bc660220f3d7a3021a615812fc654a2541715a Mon Sep 17 00:00:00 2001 From: Mason10198 <31994327+Mason10198@users.noreply.github.com> Date: Sun, 21 Jul 2024 16:52:23 -0500 Subject: [PATCH] v0.8.0 Update --- SkyControl.py | 2 +- SkyDescribe.py | 2 +- SkywarnPlus.py | 328 +++++++++++++++++++++++++++++++++++++++++++++---- config.yaml | 13 +- swp-install | 4 +- 5 files changed, 319 insertions(+), 30 deletions(-) diff --git a/SkyControl.py b/SkyControl.py index 7a11148..d17306c 100644 --- a/SkyControl.py +++ b/SkyControl.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 """ -SkyControl.py v0.7.0 by Mason Nelson +SkyControl.py v0.8.0 by Mason Nelson ================================== A Control Script for SkywarnPlus diff --git a/SkyDescribe.py b/SkyDescribe.py index 644e205..ff2b328 100644 --- a/SkyDescribe.py +++ b/SkyDescribe.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 """ -SkyDescribe.py v0.7.0 by Mason Nelson +SkyDescribe.py v0.8.0 by Mason Nelson ================================================== Text to Speech conversion for Weather Descriptions diff --git a/SkywarnPlus.py b/SkywarnPlus.py index a0f1083..7284316 100644 --- a/SkywarnPlus.py +++ b/SkywarnPlus.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 """ -SkywarnPlus.py v0.7.0 by Mason Nelson +SkywarnPlus.py v0.8.0 by Mason Nelson =============================================================================== SkywarnPlus is a utility that retrieves severe weather alerts from the National Weather Service and integrates these alerts with an Asterisk/app_rpt based @@ -60,11 +60,8 @@ with open(CONFIG_PATH, "r") as config_file: config = yaml.load(config_file) config = json.loads(json.dumps(config)) # Convert config to a normal dictionary -# Check if SkywarnPlus is enabled +# Define whether SkywarnPlus is enabled in config.yaml MASTER_ENABLE = config.get("SKYWARNPLUS", {}).get("Enable", False) -if not MASTER_ENABLE: - print("SkywarnPlus is disabled in config.yaml, exiting...") - exit() # Define tmp_dir and sounds_path TMP_DIR = config.get("DEV", {}).get("TmpDir", "/tmp/SkywarnPlus") @@ -1501,39 +1498,307 @@ def change_id(id): return True -def supermon_back_compat(alerts): +def supermon_back_compat(alerts, county_data): """ - Write alerts to a file for backward compatibility with supermon. + Write alerts to a file for backwards compatibility with Supermon. + Will NOT work on newer Debian systems (ASL3) where SkywarnPlus is not run as the root user. """ + + # Exit with a debug statement if we are not the root user + if os.getuid() != 0: + LOGGER.debug("supermon_back_compat: Not running as root, exiting function") + return + try: # Ensure the target directory exists for /tmp/AUTOSKY os.makedirs("/tmp/AUTOSKY", exist_ok=True) - # Get alert titles (without severity levels) - alert_titles = list(alerts.keys()) + # Construct alert titles with county names + alert_titles_with_counties = [ + "{} [{}]".format( + alert, + ", ".join( + replace_with_county_name(x["county_code"], county_data) + for x in alerts[alert] + ), + ) + for alert in alerts + ] - # Write alert titles to a file, with each title on a new line - with open("/tmp/AUTOSKY/warnings.txt", "w") as file: - file.write("
".join(alert_titles)) + # Check write permissions before writing to the file + if os.access("/tmp/AUTOSKY", os.W_OK): + with open("/tmp/AUTOSKY/warnings.txt", "w") as file: + file.write("
".join(alert_titles_with_counties)) + LOGGER.debug("Successfully wrote alerts to /tmp/AUTOSKY/warnings.txt") + else: + LOGGER.error("No write permission for /tmp/AUTOSKY") except Exception as e: - print("An error occurred while writing to /tmp/AUTOSKY: {}".format(str(e))) + LOGGER.error("An error occurred while writing to /tmp/AUTOSKY: %s", str(e)) try: # Ensure the target directory exists for /var/www/html/AUTOSKY os.makedirs("/var/www/html/AUTOSKY", exist_ok=True) - # Also write to other path sometimes used by Supermon - with open("/var/www/html/AUTOSKY/warnings.txt", "w") as file: - file.write("
".join(alert_titles)) + # Check write permissions before writing to the file + if os.access("/var/www/html/AUTOSKY", os.W_OK): + with open("/var/www/html/AUTOSKY/warnings.txt", "w") as file: + file.write("
".join(alert_titles_with_counties)) + LOGGER.debug( + "Successfully wrote alerts to /var/www/html/AUTOSKY/warnings.txt" + ) + else: + LOGGER.error("No write permission for /var/www/html/AUTOSKY") except Exception as e: - print( - "An error occurred while writing to /var/www/html/AUTOSKY: {}".format( - str(e) + LOGGER.error( + "An error occurred while writing to /var/www/html/AUTOSKY: %s", str(e) + ) + + +def ast_var_update(): + """ + Function to mimic the behavior of the ast_var_update.sh script from Supermon2 in HamVoIP. + Updated Asterisk channel variables for nodes defined in node_info.ini, including CPU load, temperature, and more. + Supermon2 will display these variables in the Node Information section. + """ + LOGGER.debug("ast_var_update: Starting function") + + # Exit if Supermon2 directory does not exist + if not os.path.exists("/srv/http/supermon2"): + LOGGER.debug( + "ast_var_update: Supermon2 directory does not exist, exiting function" + ) + return + + node_info_path = "/usr/local/sbin/supermon/node_info.ini" + allstar_env_path = "/usr/local/etc/allstar.env" + + WX_CODE = "" + WX_LOCATION = "" + NODE = "" + + # Read allstar.env file and extract environment variables + env_vars = {} + try: + LOGGER.debug( + "ast_var_update: Reading environment variables from %s", allstar_env_path + ) + with open(allstar_env_path, "r") as file: + for line in file: + if line.startswith("export "): + key, value = line.split("=", 1) + key = key.split()[1].strip() + value = value.strip().strip('"') + env_vars[key] = value + LOGGER.debug( + "ast_var_update: Found environment variable %s = %s", key, value + ) + except Exception as e: + LOGGER.error("ast_var_update: Error reading %s: %s", allstar_env_path, e) + return + + # Read node_info.ini file and process lines + try: + LOGGER.debug("ast_var_update: Reading node information from %s", node_info_path) + with open(node_info_path, "r") as file: + for line in file: + if line.startswith("NODE="): + node_value = line.split("=", 1)[1].strip().strip('"') + if node_value.startswith("$"): + env_var_name = node_value[1:] + NODE = env_vars.get(env_var_name, "") + else: + NODE = node_value + LOGGER.debug("ast_var_update: NODE set to %s", NODE) + elif line.startswith("WX_CODE="): + WX_CODE = line.split("=", 1)[1].strip().strip('"') + LOGGER.debug("ast_var_update: WX_CODE set to %s", WX_CODE) + elif line.startswith("WX_LOCATION="): + WX_LOCATION = line.split("=", 1)[1].strip().strip('"') + LOGGER.debug("ast_var_update: WX_LOCATION set to %s", WX_LOCATION) + except Exception as e: + LOGGER.error("ast_var_update: Error reading %s: %s", node_info_path, e) + return + + if not NODE: + LOGGER.debug("ast_var_update: No NODE defined, exiting function") + return + + try: + LOGGER.debug("ast_var_update: Retrieving Asterisk registrations") + registrations = ( + subprocess.check_output(["/bin/asterisk", "-rx", "iax2 show registry"]) + .decode("utf-8") + .splitlines()[1:] + ) + except subprocess.CalledProcessError as e: + LOGGER.error("ast_var_update: Error getting Asterisk registrations: %s", e) + registrations = "No Registrations on this server" + + if registrations == "No Registrations on this server" or not registrations: + LOGGER.debug("ast_var_update: No registrations found") + registrations = "No Registrations on this server" + else: + nodes = {} + for reg in registrations: + parts = reg.split() + node_number = parts[2].split("#")[0] + server_ip = parts[0].split(":")[0] + try: + server_domain = ( + subprocess.check_output(["dig", "+short", "-x", server_ip]) + .decode("utf-8") + .strip() + ) + LOGGER.debug( + "ast_var_update: Resolved %s to %s", server_ip, server_domain + ) + except subprocess.CalledProcessError as e: + LOGGER.error( + "ast_var_update: Error resolving domain for IP %s: %s", server_ip, e + ) + server_domain = "Unknown" + + node_value = "Node - {} is {}".format(parts[2], parts[5]) + if "Registered" in node_value: + if "hamvoip" in server_domain: + nodes[node_number] = nodes.get(node_number, "") + "Hamvoip " + else: + nodes[node_number] = nodes.get(node_number, "") + "Allstar " + + registered = "" + for key in nodes: + nodes[key] = nodes[key].replace(" ", ",") + registered += "{} Registered {} ".format(key, nodes[key]) + + registered = registered.strip().rstrip(",") + registrations = registered + LOGGER.debug("ast_var_update: Processed registrations: %s", registrations) + + try: + LOGGER.debug("ast_var_update: Retrieving system uptime") + cpu_up = "Up since {}".format( + subprocess.check_output(["uptime", "-s"]).decode("utf-8").strip() + ) + + LOGGER.debug("ast_var_update: Retrieving CPU load") + cpu_load = ( + subprocess.check_output(["uptime"]) + .decode("utf-8") + .strip() + .split("load ")[1] + ) + + LOGGER.debug("ast_var_update: Retrieving CPU temperature") + cpu_temp = ( + subprocess.check_output(["/opt/vc/bin/vcgencmd", "measure_temp"]) + .decode("utf-8") + .strip() + .split("=")[1][:-2] + ) + cpu_temp = float(cpu_temp) + except subprocess.CalledProcessError as e: + LOGGER.error("ast_var_update: Error retrieving system info: %s", e) + return + + cputemp_disp = "{}C".format( + "lightgreen" if cpu_temp <= 50 else "yellow" if cpu_temp <= 60 else "#fa4c2d", + int(cpu_temp), + ) + LOGGER.debug("ast_var_update: CPU temperature display: %s", cputemp_disp) + + try: + LOGGER.debug("ast_var_update: Retrieving log size info") + logsz = ( + subprocess.check_output(["df", "-h", "/var/log"]) + .decode("utf-8") + .splitlines()[1] + .split() + ) + logs = "Logs - {} {} used, {} remains".format(logsz[2], logsz[4], logsz[3]) + except subprocess.CalledProcessError as e: + LOGGER.error("ast_var_update: Error retrieving log size info: %s", e) + return + + wx = "" + if WX_CODE and WX_LOCATION: + try: + LOGGER.debug("ast_var_update: Retrieving weather info") + wx_info = ( + subprocess.check_output(["/usr/local/sbin/weather.sh", WX_CODE, "v"]) + .decode("utf-8") + .strip() ) + wx = "{}   ({})".format(WX_LOCATION, wx_info) + except subprocess.CalledProcessError as e: + LOGGER.error("ast_var_update: Error retrieving weather info: %s", e) + wx = "{}   (Weather info not available)".format(WX_LOCATION) + + # Filter out the \xb0 character from the string + wx = wx.replace("\xb0", "") + + LOGGER.debug("ast_var_update: Weather info display: %s", wx) + + try: + LOGGER.debug("ast_var_update: Reading alert content from warnings.txt") + with open("/tmp/AUTOSKY/warnings.txt") as f: + alert_content = f.read().strip() + except Exception as e: + LOGGER.error("ast_var_update: Error reading warnings.txt: %s", e) + alert_content = "" + + if not MASTER_ENABLE: + alert = "SkywarnPlus Disabled" + elif not alert_content: + alert = "SkywarnPlus Enabled
No Alerts
" + else: + # Adjusted to remove both '[' and ']' correctly + alert_content_cleaned = alert_content.replace("[", "").replace("]", "") + alert = "SkywarnPlus Enabled
{}
".format( + alert_content ) + LOGGER.debug("ast_var_update: Alert display: %s", alert) + + for ni in NODE.split(): + if ni: + grep_cmd = "grep -q '[[:blank:]]*\\[{}\\]' /etc/asterisk/rpt.conf".format( + ni + ) + if subprocess.call(grep_cmd, shell=True) == 0: + main_cmd = ( + 'asterisk -rx "rpt setvar {} cpu_up=\\"{}\\" cpu_load=\\"{}\\" cpu_temp=\\"{}\\" WX=\\"{}\\" LOGS=\\"{}\\" REGISTRATIONS=\\"{}\\""' + ).format(ni, cpu_up, cpu_load, cputemp_disp, wx, logs, registrations) + alert_cmd = ('asterisk -rx "rpt setvar {} ALERT=\\"{}\\""').format( + ni, alert + ) + + main_cmd = main_cmd.encode("ascii", "ignore").decode("ascii") + alert_cmd = alert_cmd.encode("ascii", "ignore").decode("ascii") + + LOGGER.debug("ast_var_update: Running main command: %s", main_cmd) + try: + subprocess.call(main_cmd, shell=True) + except subprocess.CalledProcessError as e: + LOGGER.error( + "ast_var_update: Error running main command for node %s: %s", + ni, + e, + ) + + LOGGER.debug("ast_var_update: Running alert command: %s", alert_cmd) + try: + subprocess.call(alert_cmd, shell=True) + except subprocess.CalledProcessError as e: + LOGGER.error( + "ast_var_update: Error running alert command for node %s: %s", + ni, + e, + ) + + LOGGER.debug("ast_var_update: Function completed") + def detect_county_changes(old_alerts, new_alerts): """ @@ -1608,6 +1873,7 @@ def main(): processing severe weather alerts, then integrating these alerts into an Asterisk/app_rpt based radio repeater system. """ + # Fetch configurations say_alert_enabled = config["Alerting"].get("SayAlert", False) say_alert_all = config["Alerting"].get("SayAlertAll", False) @@ -1622,10 +1888,20 @@ def main(): supermon_compat_enabled = config["DEV"].get("SupermonCompat", True) say_alerts_changed = config["Alerting"].get("SayAlertsChanged", True) + # Check if SkywarnPlus is enabled + if not MASTER_ENABLE: + print("SkywarnPlus is disabled in config.yaml, exiting...") + if supermon_compat_enabled: + ast_var_update() + exit() + # Load previous alert data to compare changes state = load_state() last_alerts = state["last_alerts"] + # Load county names from YAML file so that county codes can be replaced with county names in messages + county_data = load_county_names(COUNTY_CODES_PATH) + # If data file does not exist, assume this is the first run and initialize data file and CT/ID/Tailmessage files if enabled if not os.path.isfile(DATA_FILE): LOGGER.info("Data file does not exist, assuming first run.") @@ -1641,16 +1917,20 @@ def main(): LOGGER.info("Initializing Tailmessage file") empty_alerts = OrderedDict() build_tailmessage(empty_alerts) + if supermon_compat_enabled: + supermon_back_compat(last_alerts, county_data) # Fetch new alert data alerts = get_alerts(COUNTY_CODES) - # Load county names from YAML file so that county codes can be replaced with county names in messages - county_data = load_county_names(COUNTY_CODES_PATH) - # Placeholder for constructing a pushover message pushover_message = "" + # Update HamVoIP Asterisk channel variables + if supermon_compat_enabled: + supermon_back_compat(alerts, county_data) + ast_var_update() + # Determine which alerts have been added since the last check added_alerts = [alert for alert in alerts if alert not in last_alerts] for alert in added_alerts: @@ -1764,8 +2044,8 @@ def main(): # If alerts have been added, removed if added_alerts or removed_alerts: # Push alert titles to Supermon if enabled - if supermon_compat_enabled: - supermon_back_compat(alerts) + # if supermon_compat_enabled: + # supermon_back_compat(alerts) # Change CT/ID if necessary and enabled change_ct_id_helper( diff --git a/config.yaml b/config.yaml index 4219228..a277ae8 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,4 @@ -# SkywarnPlus v0.7.0 Configuration File +# SkywarnPlus v0.8.0 Configuration File # Author: Mason Nelson (N5LSN/WRKF394) # Please edit this file according to your specific requirements. @@ -438,7 +438,16 @@ DEV: # Specify the TMP directory. TmpDir: /tmp/SkywarnPlus - # Write alert titles to /tmp/AUTOSKY/warnings.txt for Supermon backwards compatibility. + # ATTEMPT to write alerts to the old AUTOSKY files so that Supermon can display them. + # This might not work due to there being several different versions of Supermon, + # file permissions issues, etc at no fault to SkywarnPlus. This is a best-effort feature. + # + # If you are using Supermon2 on HamVoIP, please add your location and ZIP + # to the file /usr/local/sbin/supermon/node_info.ini + # + # DO NOT ADD ast_var_update.sh TO CRON IF YOU ARE USING THIS FEATURE. + # + # Please encourage Supermon developers to add support for SkywarnPlus so this hack can be removed. SupermonCompat: true # Enable test alert injection instead of calling the NWS API by setting 'INJECT' to 'True'. diff --git a/swp-install b/swp-install index 6bc4dcd..d339963 100644 --- a/swp-install +++ b/swp-install @@ -201,9 +201,9 @@ setup_crontab() { fi else if [ "$SYSTEM_TYPE" = "ASL3" ]; then - CRONTAB_ENTRY="*/$CRONTAB_INTERVAL * * * * asterisk pgrep -f /usr/local/bin/SkywarnPlus/SkywarnPlus.py > /dev/null || /usr/local/bin/SkywarnPlus/SkywarnPlus.py" + CRONTAB_ENTRY="*/$CRONTAB_INTERVAL * * * * asterisk /usr/local/bin/SkywarnPlus/SkywarnPlus.py" else - CRONTAB_ENTRY="*/$CRONTAB_INTERVAL * * * * root pgrep -f /usr/local/bin/SkywarnPlus/SkywarnPlus.py > /dev/null || /usr/local/bin/SkywarnPlus/SkywarnPlus.py" + CRONTAB_ENTRY="*/$CRONTAB_INTERVAL * * * * root /usr/local/bin/SkywarnPlus/SkywarnPlus.py" fi CRON_FILE="/etc/cron.d/SkywarnPlus"