From 5eb6bd68b7f28ed65c2abd9db507cc8bbef9fe08 Mon Sep 17 00:00:00 2001 From: Mason10198 <31994327+Mason10198@users.noreply.github.com> Date: Thu, 27 Jul 2023 00:41:21 -0500 Subject: [PATCH] Init v0.4.0 --- README.md | 75 +++++++++--- SkyControl.py | 2 +- SkyDescribe.py | 126 ++++++++++---------- SkywarnPlus.py | 306 +++++++++++++++++++++++++++++++++++++++---------- UpdateSWP.py | 2 +- config.yaml | 32 +++++- 6 files changed, 396 insertions(+), 147 deletions(-) diff --git a/README.md b/README.md index a5bed42..edb6a51 100644 --- a/README.md +++ b/README.md @@ -7,21 +7,23 @@ ![Total Downloads](https://img.shields.io/github/downloads/Mason10198/SkywarnPlus/total?label=Downloads&color=f15d24) ![GitHub Repo Size](https://img.shields.io/github/repo-size/Mason10198/SkywarnPlus?label=Size&color=f15d24) -**SkywarnPlus** is an advanced software solution tailored for Asterisk/app_rpt nodes. It is designed to provide important information about local government-issued alerts, thereby broadening the scope and functionality of your node. By intelligently integrating local alert data, SkywarnPlus brings a new layer of relevance and utility to your existing system. **SkywarnPlus** works with all major distributions, including AllstarLink, HAMVOIP, myGMRS, and more. +**SkywarnPlus** is an advanced software solution tailored for Asterisk/app_rpt nodes. It is designed to provide important information about local government-issued alerts in the United States, thereby broadening the scope and functionality of your node. By intelligently integrating local alert data, SkywarnPlus brings a new layer of relevance and utility to your existing system. **SkywarnPlus** works with all major distributions, including AllstarLink, HAMVOIP, myGMRS, and more. ## Key Features -- **Real-Time Weather Alerts:** The software checks the NWS CAP v1.2 API for live weather alerts for user-defined areas. +- **Real-Time Alerts:** The software watches the new NWS v1.2 API for real-time alerts for user-defined areas. -- **Unlimited Area & Node Numbers:** Users can define as many areas and local node numbers as desired. +- **Automatic Announcements:** Alerts, including when all warnings have been cleared, are announced automatically on the node. -- **Automatic Announcements:** Weather alerts, including when all warnings have been cleared, are announced automatically on the node. +- **Human Speech:** Announcements are delivered in a natural, human speech for easier understanding. + +- **Unlimited Area & Node Numbers:** Users can define as many areas and local node numbers as desired. - **Tailmessage Creation:** The software generates tailmessages for the node to continuously inform listeners about active alerts after the initial broadcast. - **Dynamic Changes to Node:** Courtesy tones and node CW / voice ID automatically change according to user-defined alerts, optimizing communication during severe weather. -- **Human Speech:** Announcements are delivered in a natural, human speech for easier understanding. +- **County Identification:** Dynamically and automatically inform listeners which county or counties an alert is affecting - **Efficiency & Speed:** SkywarnPlus is optimized for speed and efficiency to provide real-time information without delay. @@ -31,7 +33,7 @@ - **Detailed Alert Descriptions:** In addition to standard alert announcements, SkywarnPlus includes SkyDescribe, a feature for announcing detailed NWS provided descriptions of alert details. -- **Highly Customizable:** SkywarnPlus is extremely customizable, offering advanced filtering parameters to block certain alerts or types of alerts from different functions. Users can even map DTMF macros or shell commands to specified weather alerts, expanding the software's capabilities according to user needs. +- **Highly Customizable:** SkywarnPlus is extremely customizable, offering advanced filtering parameters to block certain alerts or types of alerts from different functions. Users can easily modify the sound effects and audio files used in SkywarnPlus. Users can even map DTMF macros or shell commands to specified weather alerts, infinitely expanding the software's capabilities according to user needs. - **Pushover Integration:** With Pushover integration, SkywarnPlus can send weather alert notifications directly to your phone or other devices. @@ -194,7 +196,7 @@ tailmessagelist = /tmp/SkywarnPlus/wx-tail SkywarnPlus has the ability to automatically change the node courtesy tone depending on the state of certain weather alerts. This feature can be configured via the `config.yaml` and `rpt.conf` files. -## Configuration +## Configuration Here's an explanation of how it works using the default values in the `config.yaml` file: @@ -211,7 +213,6 @@ CourtesyTones: # Define the sound files for courtesy tones. Tones: - # Audio file to feed Asterisk as ct1 in "normal" mode CT1: Boop.ulaw @@ -249,13 +250,15 @@ With this setup, Asterisk will always use `CT1.ulaw` for "local" traffic, and `C Please note the filenames are case-sensitive, so be sure they match exactly between `rpt.conf` and `config.yaml`. +After initially setting up automatic courtesy tones, the audio files will not refresh until the next time the alert status changes. To refresh immediately, run `/usr/local/bin/SkywarnPlys/SkyControl.py changect normal` to force the CTs to "normal" mode. + # CW / Voice IDs SkywarnPlus has a feature that allows it to automatically change the node ID based on the status of certain weather alerts. This requires the creation of custom audio files for the `NORMAL` and `WX` ID modes. The configuration for this is in the `config.yaml` file, with additional setup needed in the `rpt.conf` file. Let's take a look at how it's done. -## Configuration +## Configuration Here's an example of how the `config.yaml` file should be configured: @@ -272,7 +275,6 @@ IDChange: # Define the sound files for IDs. IDs: - # Audio file to feed Asterisk as ID in "normal" mode NormalID: NORMALID.ulaw @@ -283,7 +285,7 @@ IDChange: RptID: RPTID.ulaw ``` -In this setup, if none of the alerts specified in the IDAlerts list are active, SkywarnPlus replaces the file `RPTID.ulaw` with a duplicate of `NORMALID.ulaw`. +In this setup, if none of the alerts specified in the IDAlerts list are active, SkywarnPlus replaces the file `RPTID.ulaw` with a duplicate of `NORMALID.ulaw`. However, if any of the alerts in the IDAlerts list are currently active, SkywarnPlus will replace `RPTID.ulaw` with a duplicate of `WXID.ulaw`. @@ -294,11 +296,11 @@ To enable these changes, the following setup is required in your `rpt.conf` file idrecording = /usr/local/bin/SkywarnPlus/SOUNDS/ID/RPTID ``` -In this case, Asterisk will always use `RPTID.ulaw` as the node ID. SkywarnPlus effectively changes the contents of the `RPTID.ulaw` file depending on the weather alert status while Asterisk "isn't looking". +In this case, Asterisk will always use `RPTID.ulaw` as the node ID. SkywarnPlus effectively changes the contents of the `RPTID.ulaw` file depending on the weather alert status while Asterisk "isn't looking". Note that filenames are case-sensitive, so be sure they match exactly between `rpt.conf` and `config.yaml`. -This is how you can configure SkywarnPlus to automatically change the node ID depending on weather alert conditions. Please don't hesitate to raise a query if you encounter any issues. +After initially setting up automatic IDs, the audio files will not refresh until the next time the alert status changes. To refresh immediately, run `/usr/local/bin/SkywarnPlys/SkyControl.py changeid normal` to force the ID to "normal" mode. # Pushover Integration @@ -549,9 +551,48 @@ In _most_ cases, any multiple counties that SkywarnPlus is set up to monitor wil # Customizing the Audio Files -SkywarnPlus comes with a library of audio files that can be replaced with any 8kHz mono PCM16 WAV files you want. These are found in the `SOUNDS/` directory by default, along with `DICTIONARY.txt` which explains audio file assignments. +SkywarnPlus comes with a library of audio files that can be replaced with any 8kHz mono PCM16 WAV files you want. These are found in the `SOUNDS/` directory by default, along with `DICTIONARY.txt` which explains audio file assignments. Several customizations can be easily made in `config.yaml`, but the sound files are always available for you to modify directly as well. + +If you'd like to use IDChange or add county identifiers, you must create your own audio files. Follow **[this guide](https://wiki.allstarlink.org/images/d/dd/RecordingSoundFiles.pdf)** on how to record/convert audio files for use with Asterisk/app_rpt. -If you'd like to enable IDChange, you must create your own ID audio files. Follow **[this guide](https://wiki.allstarlink.org/images/d/dd/RecordingSoundFiles.pdf)** on how to create audio files for use with Asterisk/app_rpt. +>**For users wishing to maintain vocal continuity in their SkywarnPlus installation, the original creator of SkywarnPlus (N5LSN) and the woman behind the voice of it's included library of audio recordings (N5LSN XYL) will, for a small fee, record custom audio files for your SkywarnPlus installation. Contact information is readily available via QRZ.** + +## County Identifiers + +SkywarnPlus features the capability to play county-specific audio files to reference the affected area of alerts. It enhances the user's awareness of the geographic area affected by an event, making the system more informative and valuable to users monitoring systems that provide coverage for multiple counties. By assigning unique audio tags to each county, users can immediately recognize which county is affected by an event as soon as it is detected by SkywarnPlus. + +### How to use it? + +To use the county-specific audio tags, you need to specify the audio files to use for each county in your `config.yaml` file. You must create or otherwise aquire these audio files yourself. The audio files must be located in the root of the `SkywarnPlus/SOUNDS/` directory. + +The `config.yaml` explains how to use the free VoiceRSS API to generate these files using a computer synthesized voice. + +Here is an example of how to configure the `config.yaml` to utilize this feature: + +```yaml +Alerting: + # Specify the county codes for which you want to pull weather data. + # Find your county codes at https://alerts.weather.gov/. + # Make sure to use county codes ONLY, NOT zone codes, otherwise you might miss out on alerts. + # + # SkywarnPlus allows adding county-specific audio indicators to each alert in the message. + # To enable this feature, specify an audio file containing a recording of the county name in the + # ROOT of the SOUNDS/ directory as shown in the below example. You must create these files yourself. + # You can use the same VoiceRSS API used for SkyDescribe (see below) to generate these files with a synthetic voice: + # http://api.voicerss.org/?key=[YOUR_API_KEY_HERE]=en-us&f=8khz_16bit_mono&v=John&src=[YOUR COUNTY NAME HERE] + # http://api.voicerss.org/?key=1234567890QWERTY&hl=en-us&f=8khz_16bit_mono&v=John&src=Saline County + CountyCodes: + - DCC001: "County1.wav" + - MDC031: "County2.wav" + - MDC033: "County3.wav" + - VAC013: "County4.wav" + - VAC059: "County5.wav" + - VAC510: "County6.wav" + - VAC107: "County7.wav" + - VAC047: "County8.wav" + - MDC510: "County9.wav" + - VAC683: "County10.wav" +``` # Testing @@ -624,7 +665,7 @@ SkywarnPlus is open-sourced software licensed under the [GPL-3.0 license](LICENS Created by Mason Nelson (N5LSN/WRKF394) -Audio Library voiced by Rachel Nelson +Audio Library voiced by Rachel Nelson (N5LSN/WRKF394 XYL) Skywarn® and the Skywarn® logo are registered trademarks of the National -Oceanic and Atmospheric Administration, used with permission. \ No newline at end of file +Oceanic and Atmospheric Administration, used with permission. diff --git a/SkyControl.py b/SkyControl.py index ae7d0bf..5c8b37f 100644 --- a/SkyControl.py +++ b/SkyControl.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 """ -SkyControl v0.3.5 by Mason Nelson +SkyControl v0.4.0 by Mason Nelson ================================== A Control Script for SkywarnPlus diff --git a/SkyDescribe.py b/SkyDescribe.py index 003063a..f1b41ae 100644 --- a/SkyDescribe.py +++ b/SkyDescribe.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 """ -SkyDescribe v0.3.5 by Mason Nelson +SkyDescribe v0.4.0 by Mason Nelson ================================================== Text to Speech conversion for Weather Descriptions @@ -37,75 +37,71 @@ from ruamel.yaml import YAML from collections import OrderedDict # Use ruamel.yaml instead of PyYAML -yaml = YAML() +YAML = YAML() # Directories and Paths -baseDir = os.path.dirname(os.path.realpath(__file__)) -configPath = os.path.join(baseDir, "config.yaml") +BASE_DIR = os.path.dirname(os.path.realpath(__file__)) +CONFIG_PATH = os.path.join(BASE_DIR, "config.yaml") # Open and read configuration file -with open(configPath, "r") as config_file: - config = yaml.load(config_file) +with open(CONFIG_PATH, "r") as config_file: + config = YAML.load(config_file) # Define tmp_dir -tmp_dir = config.get("DEV", []).get("TmpDir", "/tmp/SkywarnPlus") +TMP_DIR = config.get("DEV", []).get("TmpDir", "/tmp/SkywarnPlus") # Define VoiceRSS settings # get api key, fellback 150 -api_key = config.get("SkyDescribe", []).get("APIKey", "") -language = config.get("SkyDescribe", []).get("Language", "en-us") -speed = config.get("SkyDescribe", []).get("Speed", 0) -voice = config.get("SkyDescribe", []).get("Voice", "John") -max_words = config.get("SkyDescribe", []).get("MaxWords", 150) +API_KEY = config.get("SkyDescribe", []).get("APIKey", "") +LANGUAGE = config.get("SkyDescribe", []).get("Language", "en-us") +SPEED = config.get("SkyDescribe", []).get("Speed", 0) +VOICE = config.get("SkyDescribe", []).get("Voice", "John") +MAX_WORDS = config.get("SkyDescribe", []).get("MaxWords", 150) # Path to the data file -data_file = os.path.join(tmp_dir, "data.json") +DATA_FILE = os.path.join(TMP_DIR, "data.json") # Define logger -logger = logging.getLogger(__name__) +LOGGER = logging.getLogger(__name__) if config.get("Logging", []).get("Debug", False): - logger.setLevel(logging.DEBUG) + LOGGER.setLevel(logging.DEBUG) else: - logger.setLevel(logging.INFO) + LOGGER.setLevel(logging.INFO) # Define formatter -formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s") +FORMATTER = logging.Formatter("%(asctime)s %(levelname)s %(message)s") # Define and attach console handler -ch = logging.StreamHandler() -ch.setLevel(logging.DEBUG) -ch.setFormatter(formatter) -logger.addHandler(ch) +CH = logging.StreamHandler() +CH.setLevel(logging.DEBUG) +CH.setFormatter(FORMATTER) +LOGGER.addHandler(CH) # Define and attach file handler -log_path = os.path.join(tmp_dir, "SkyDescribe.log") -fh = logging.FileHandler(log_path) -fh.setLevel(logging.DEBUG) -fh.setFormatter(formatter) -logger.addHandler(fh) - -if not api_key: - logger.error("SkyDescribe: No VoiceRSS API key found in config.yaml") +LOG_PATH = os.path.join(TMP_DIR, "SkyDescribe.log") +FH = logging.FileHandler(LOG_PATH) +FH.setLevel(logging.DEBUG) +FH.setFormatter(FORMATTER) +LOGGER.addHandler(FH) + +if not API_KEY: + LOGGER.error("SkyDescribe: No VoiceRSS API key found in config.yaml") sys.exit(1) def load_state(): """ Load the state from the state file if it exists, else return an initial state. - - Returns: - OrderedDict: A dictionary containing data. """ - if os.path.exists(data_file): - with open(data_file, "r") as file: + if os.path.exists(DATA_FILE): + with open(DATA_FILE, "r") as file: state = json.load(file) state["alertscript_alerts"] = state.get("alertscript_alerts", []) + + # Process 'last_alerts' and maintain the order of alerts last_alerts = state.get("last_alerts", []) - last_alerts = [ - (tuple(x[0]), x[1]) if isinstance(x[0], list) else x - for x in last_alerts - ] - state["last_alerts"] = OrderedDict(last_alerts) + state["last_alerts"] = OrderedDict((x[0], x[1]) for x in last_alerts) + state["last_sayalert"] = state.get("last_sayalert", []) state["active_alerts"] = state.get("active_alerts", []) return state @@ -222,10 +218,10 @@ def modify_description(description): # Limit the description to a maximum number of words words = description.split() - logger.debug("SkyDescribe: Description has %d words.", len(words)) - if len(words) > max_words: - description = " ".join(words[:max_words]) - logger.info("SkyDescribe: Description has been limited to %d words.", max_words) + LOGGER.debug("SkyDescribe: Description has %d words.", len(words)) + if len(words) > MAX_WORDS: + description = " ".join(words[:MAX_WORDS]) + LOGGER.info("SkyDescribe: Description has been limited to %d words.", MAX_WORDS) return description @@ -244,23 +240,28 @@ def convert_to_audio(api_key, text): base_url = "http://api.voicerss.org/" params = { "key": api_key, - "hl": str(language), + "hl": str(LANGUAGE), "src": text, "c": "WAV", "f": "8khz_16bit_mono", - "r": str(speed), - "v": str(voice), + "r": str(SPEED), + "v": str(VOICE), } - logger.debug( + LOGGER.debug( "SkyDescribe: Voice RSS API URL: %s", base_url + "?" + urllib.parse.urlencode(params), ) response = requests.get(base_url, params=params) response.raise_for_status() + # if responce text contains "ERROR" then log it and exit + if "ERROR" in response.text: + LOGGER.error("SkyDescribe: %s", response.text) + sys.exit(1) - audio_file_path = os.path.join(tmp_dir, "describe.wav") + audio_file_path = os.path.join(TMP_DIR, "describe.wav") + LOGGER.debug("SkyDescribe: Saving audio file to %s", audio_file_path) with open(audio_file_path, "wb") as file: file.write(response.content) return audio_file_path @@ -270,65 +271,70 @@ def main(index_or_title): state = load_state() alerts = list(state["last_alerts"].items()) + # list the alerts in order as a numbered list + LOGGER.debug("SkyDescribe: List of alerts:") + for i, alert in enumerate(alerts): + LOGGER.debug("SkyDescribe: %d. %s", i + 1, alert[0]) + # Determine if the argument is an index or a title if str(index_or_title).isdigit(): index = int(index_or_title) - 1 if index >= len(alerts): - logger.error("SkyDescribe: No alert found at index %d.", index + 1) + LOGGER.error("SkyDescribe: No alert found at index %d.", index + 1) description = "Sky Describe error, no alert found at index {}.".format( index + 1 ) else: alert, alert_data = alerts[index] - (_, description, _) = alert_data + description = alert_data[0]["description"] else: # Argument is not an index, assume it's a title title = index_or_title for alert, alert_data in alerts: if alert == title: # Assuming alert is a title - _, description, _ = alert_data + description = alert_data[0]["description"] break else: - logger.error("SkyDescribe: No alert with title %s found.", title) + LOGGER.error("SkyDescribe: No alert with title %s found.", title) description = "Sky Describe error, no alert found with title {}.".format( title ) - logger.debug("\n\nSkyDescribe: Original description: %s", description) + LOGGER.debug("\n\nSkyDescribe: Original description: %s", description) # If the description is not an error message, extract the alert title if not "Sky Describe error" in description: alert_title = alert # As alert itself is the title now - logger.info("SkyDescribe: Generating description for alert: %s", alert_title) + LOGGER.info("SkyDescribe: Generating description for alert: %s", alert_title) # Add the alert title at the beginning description = "Detailed alert information for {}. {}".format( alert_title, description ) description = modify_description(description) - logger.debug("\n\nSkyDescribe: Modified description: %s\n\n", description) + LOGGER.debug("\n\nSkyDescribe: Modified description: %s\n\n", description) - audio_file = convert_to_audio(api_key, description) + audio_file = convert_to_audio(API_KEY, description) with contextlib.closing(wave.open(audio_file, "r")) as f: frames = f.getnframes() rate = f.getframerate() duration = frames / float(rate) - logger.debug("SkyDescribe: Length of the audio file in seconds: %s", duration) + LOGGER.debug("SkyDescribe: Length of the audio file in seconds: %s", duration) nodes = config["Asterisk"]["Nodes"] for node in nodes: - logger.info("SkyDescribe: Broadcasting description on node %s.", node) + LOGGER.info("SkyDescribe: Broadcasting description on node %s.", node) command = "/usr/sbin/asterisk -rx 'rpt localplay {} {}'".format( node, audio_file.rsplit(".", 1)[0] ) - logger.debug("SkyDescribe: Running command: %s", command) + LOGGER.debug("SkyDescribe: Running command: %s", command) subprocess.run(command, shell=True) # Script entry point if __name__ == "__main__": if len(sys.argv) != 2: - logger.error("Usage: SkyDescribe.py ") + LOGGER.error("Usage: SkyDescribe.py ") sys.exit(1) main(sys.argv[1]) diff --git a/SkywarnPlus.py b/SkywarnPlus.py index 77943d3..e59c2e4 100644 --- a/SkywarnPlus.py +++ b/SkywarnPlus.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 """ -SkywarnPlus v0.3.5 by Mason Nelson +SkywarnPlus v0.4.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 @@ -70,9 +70,6 @@ SOUNDS_PATH = config.get("Alerting", {}).get( "SoundsPath", os.path.join(BASE_DIR, "SOUNDS") ) -# Define countyCodes -COUNTY_CODES = config.get("Alerting", {}).get("CountyCodes", []) - # Create tmp_dir if it doesn't exist if TMP_DIR: os.makedirs(TMP_DIR, exist_ok=True) @@ -278,6 +275,42 @@ F_HANDLER = logging.FileHandler(LOG_FILE) F_HANDLER.setFormatter(LOG_FORMATTER) LOGGER.addHandler(F_HANDLER) +# Get the "CountyCodes" from the config +COUNTY_CODES_CONFIG = config.get("Alerting", {}).get("CountyCodes", []) + +# Log the obtained COUNTY_CODES_CONFIG for debugging +LOGGER.debug(f"COUNTY_CODES_CONFIG: {COUNTY_CODES_CONFIG}") + +# Initialize COUNTY_CODES and COUNTY_WAVS +COUNTY_CODES = [] +COUNTY_WAVS = [] + +# Check the type of "CountyCodes" config to handle both list and dictionary +if isinstance(COUNTY_CODES_CONFIG, list): + # If it's a list, check if it's a list of strings or dictionaries + if all(isinstance(i, str) for i in COUNTY_CODES_CONFIG): + # It's the old format and we can use it directly + COUNTY_CODES = COUNTY_CODES_CONFIG + elif all(isinstance(i, dict) for i in COUNTY_CODES_CONFIG): + # It's a list of dictionaries with WAV files + # We need to separate the county codes and the WAVs + for dic in COUNTY_CODES_CONFIG: + for key, value in dic.items(): + COUNTY_CODES.append(key) + COUNTY_WAVS.append(value) +elif isinstance(COUNTY_CODES_CONFIG, dict): + # If it's a dictionary, it's got WAV files + # We need to separate the county codes and the WAVs + COUNTY_CODES = list(COUNTY_CODES_CONFIG.keys()) + COUNTY_WAVS = list(COUNTY_CODES_CONFIG.values()) +else: + # Invalid format, set it to an empty list + COUNTY_CODES = [] + COUNTY_WAVS = [] + +# Log the final COUNTY_CODES and COUNTY_WAVS for debugging +LOGGER.debug(f"COUNTY_CODES: {COUNTY_CODES}, COUNTY_WAVS: {COUNTY_WAVS}") + # Log some debugging information LOGGER.debug("Base directory: %s", BASE_DIR) LOGGER.debug("Temporary directory: %s", TMP_DIR) @@ -296,12 +329,11 @@ def load_state(): with open(DATA_FILE, "r") as file: state = json.load(file) state["alertscript_alerts"] = state.get("alertscript_alerts", []) + + # Process 'last_alerts' and maintain the order of alerts last_alerts = state.get("last_alerts", []) - last_alerts = [ - (tuple(x[0]), x[1]) if isinstance(x[0], list) else x - for x in last_alerts - ] - state["last_alerts"] = OrderedDict(last_alerts) + state["last_alerts"] = OrderedDict((x[0], x[1]) for x in last_alerts) + state["last_sayalert"] = state.get("last_sayalert", []) state["active_alerts"] = state.get("active_alerts", []) return state @@ -366,11 +398,16 @@ def get_alerts(countyCodes): severity = severity_mapping_words.get(last_word, 0) description = "This alert was manually injected as a test." end_time_utc = current_time + timedelta(hours=1) - alerts[(event)] = ( - severity, - description, - end_time_utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), - ) + county_data = [ + { + "county_code": county_code, + "severity": severity, + "description": description, + "end_time_utc": end_time_utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), + } + for county_code in ["DCC001", "MDC033"] + ] # Create a list of dictionaries + alerts[event] = county_data # Add the list of dictionaries to the alert # We limit the number of alerts to the maximum defined constant. alerts = OrderedDict(list(alerts.items())[:MAX_ALERTS]) @@ -399,6 +436,10 @@ def get_alerts(countyCodes): # Loop over each county code and retrieve alerts from the API. for countyCode in countyCodes: url = "https://api.weather.gov/alerts/active?zone={}".format(countyCode) + # + # WARNING: ONLY USE THIS FOR DEVELOPMENT PURPOSES + # THIS URL WILL RETURN ALL ACTIVE ALERTS IN THE UNITED STATES + # url = "https://api.weather.gov/alerts/active" try: # If we can get a successful response from the API, we process the alerts from the response. response = requests.get(url) @@ -434,12 +475,7 @@ def get_alerts(countyCodes): description = feature["properties"].get("description", "") severity = feature["properties"].get("severity", None) - # If the alert has already been processed, we skip it. - if event in seen_alerts: - continue - - # We check if the event is in a list of globally blocked events. - # If it is, we skip it. + # Check if the event is globally blocked as per the configuration. If it is, skip this event. is_blocked = False for global_blocked_event in GLOBAL_BLOCKED_EVENTS: if fnmatch.fnmatch(event, global_blocked_event): @@ -449,6 +485,7 @@ def get_alerts(countyCodes): ) is_blocked = True break + # If the event is globally blocked, we skip the remaining code in this loop iteration and move to the next one. if is_blocked: continue @@ -459,13 +496,41 @@ def get_alerts(countyCodes): else: severity = severity_mapping_api.get(severity, 0) - # Add the alert to the 'alerts' dictionary and add the event name to 'seen_alerts'. - alerts[(event)] = ( + # Log the alerts and their severity level for debugging purposes. + LOGGER.debug( + "getAlerts: %s - %s - Severity: %s", + countyCode, + event, severity, - description, - end_time_utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ"), ) - seen_alerts.add(event) + + # Check if the event has already been processed (seen). + # If it has been seen, we add a new dictionary to its list of alerts. This dictionary contains details about the alert. + if event in seen_alerts: + alerts[event].append( + { + "county_code": countyCode, # the county code the alert is for + "severity": severity, # the severity level of the alert + "description": description, # a description of the alert + "end_time_utc": end_time_utc.strftime( + "%Y-%m-%dT%H:%M:%S.%fZ" + ), # the time the alert ends in UTC + } + ) + # If the event hasn't been seen before, we create a new list entry in the 'alerts' dictionary for this event. + else: + alerts[event] = [ + { + "county_code": countyCode, # the county code the alert is for + "severity": severity, # the severity level of the alert + "description": description, # a description of the alert + "end_time_utc": end_time_utc.strftime( + "%Y-%m-%dT%H:%M:%S.%fZ" + ), # the time the alert ends in UTC + } + ] + # Add the event to the set of seen alerts. + seen_alerts.add(event) # If the current time is not within the alert's active period, we skip it. else: @@ -481,22 +546,66 @@ def get_alerts(countyCodes): start_time_utc, end_time_utc, ) + else: + LOGGER.debug( + "getAlerts: Skipping %s, missing start or end time.", + feature["properties"]["event"], + ) - # If we cannot get a successful response from the API, we load stored alert data instead. except requests.exceptions.RequestException as e: - LOGGER.error("Failed to retrieve alerts for %s. Reason: %s", countyCode, e) - LOGGER.info("API unreachable. Using stored data instead.") + LOGGER.debug("Failed to retrieve alerts for %s. Reason: %s", countyCode, e) + LOGGER.debug("API unreachable. Using stored data instead.") + + # Load alerts from data.json if os.path.isfile(DATA_FILE): with open(DATA_FILE) as f: data = json.load(f) - alerts = data.get("alerts", {}) + stored_alerts = data.get("last_alerts", []) - # If the number of alerts exceeds the maximum defined constant, we truncate the alerts. - alerts = OrderedDict(list(alerts.items())[:MAX_ALERTS]) + # Filter alerts by end_time_utc + current_time_str = datetime.now(timezone.utc).strftime( + "%Y-%m-%dT%H:%M:%S.%fZ" + ) + LOGGER.debug("Current time: %s", current_time_str) + alerts = {} + for stored_alert in stored_alerts: + event = stored_alert[0] + alert_list = stored_alert[1] + alerts[event] = [] + for alert in alert_list: + end_time_str = alert["end_time_utc"] + if parser.parse(end_time_str) >= parser.parse( + current_time_str + ): + LOGGER.debug( + "getAlerts: Keeping %s because it does not expire until %s", + event, + end_time_str, + ) + alerts[event].append(alert) + else: + LOGGER.debug( + "getAlerts: Removing %s because it expired at %s", + event, + end_time_str, + ) + else: + LOGGER.error("No stored data available.") + break + + alerts = OrderedDict( + sorted( + alerts.items(), + key=lambda item: ( + max([x["severity"] for x in item[1]]), # Max Severity + severity_mapping_words.get(item[0].split()[-1], 0), # Words severity + ), + reverse=True, + ) + ) - # We store the alerts in a file for future use if the API is unreachable. - with open(DATA_FILE, "w") as f: - json.dump({"alerts": dict(alerts)}, f) + # If the number of alerts exceeds the maximum defined constant, we truncate the alerts. + alerts = OrderedDict(list(alerts.items())[:MAX_ALERTS]) return alerts @@ -538,9 +647,7 @@ def say_alerts(alerts): # Check if the filtered alerts are the same as the last run if filtered_alerts == state["last_sayalert"]: - LOGGER.debug( - "sayAlert: The filtered alerts are the same as the last run. Not broadcasting." - ) + LOGGER.debug("sayAlert: alerts are the same as the last broadcast - skipping.") return state["last_sayalert"] = filtered_alerts @@ -564,7 +671,7 @@ def say_alerts(alerts): SOUNDS_PATH, "ALERTS", "EFFECTS", - config.get("Alerting", {}).get("AlertSound", "StartrekWhistle.wav"), + config.get("Alerting", {}).get("AlertSound", "Duncecap.wav.wav"), ) ) @@ -575,29 +682,58 @@ def say_alerts(alerts): ) alert_count = 0 - for alert in filtered_alerts: - try: - index = ALERT_STRINGS.index(alert) - audio_file = AudioSegment.from_wav( - os.path.join( - SOUNDS_PATH, "ALERTS", "SWP_{}.wav".format(ALERT_INDEXES[index]) + for ( + alert, + counties, + ) in alerts.items(): # Now we loop over both alert name and its associated counties + if alert in filtered_alerts: + try: + index = ALERT_STRINGS.index(alert) + audio_file = AudioSegment.from_wav( + os.path.join( + SOUNDS_PATH, "ALERTS", "SWP_{}.wav".format(ALERT_INDEXES[index]) + ) + ) + combined_sound += sound_effect + audio_file + LOGGER.debug( + "sayAlert: Added %s (SWP_%s.wav) to alert sound", + alert, + ALERT_INDEXES[index], + ) + alert_count += 1 + + # Add county names if they exist + for county in counties: + # if its the first county, word_space is 600ms of silence. else it is 400ms + if counties.index(county) == 0: + word_space = AudioSegment.silent(duration=600) + else: + word_space = AudioSegment.silent(duration=400) + county_code = county["county_code"] + if COUNTY_WAVS and county_code in COUNTY_CODES: + index = COUNTY_CODES.index(county_code) + county_name_file = COUNTY_WAVS[index] + LOGGER.debug( + "sayAlert: Adding %s ID %s to %s", + county_code, + county_name_file, + alert, + ) + combined_sound += word_space + AudioSegment.from_wav( + os.path.join(SOUNDS_PATH, "ALERTS", county_name_file) + ) + # if this is the last county name, add 600ms of silence after the county name + if counties.index(county) == len(counties) - 1: + combined_sound += AudioSegment.silent(duration=600) + + except ValueError: + LOGGER.error("sayAlert: Alert not found: %s", alert) + except FileNotFoundError: + LOGGER.error( + "sayAlert: Audio file not found: %s/ALERTS/SWP_%s.wav", + SOUNDS_PATH, + ALERT_INDEXES[index], ) - ) - combined_sound += sound_effect + audio_file - LOGGER.debug( - "sayAlert: Added %s (SWP_%s.wav) to alert sound", - alert, - ALERT_INDEXES[index], - ) - alert_count += 1 - except ValueError: - LOGGER.error("sayAlert: Alert not found: %s", alert) - except FileNotFoundError: - LOGGER.error( - "sayAlert: Audio file not found: %s/ALERTS/SWP_%s.wav", - SOUNDS_PATH, - ALERT_INDEXES[index], - ) if alert_count == 0: LOGGER.debug("sayAlert: All alerts were blocked, not broadcasting any alerts.") @@ -605,7 +741,7 @@ def say_alerts(alerts): alert_suffix = config.get("Alerting", {}).get("SayAlertSuffix", None) if alert_suffix is not None: - suffix_silence = AudioSegment.silent(duration=1000) + suffix_silence = AudioSegment.silent(duration=600) LOGGER.debug("sayAlert: Adding alert suffix %s", alert_suffix) suffix_file = os.path.join(SOUNDS_PATH, alert_suffix) suffix_sound = AudioSegment.from_wav(suffix_file) @@ -685,6 +821,15 @@ def say_allclear(): converted_combined_sound = convert_audio(combined_sound) converted_combined_sound.export(all_clear_file, format="wav") + if config.get("Alerting", {}).get("SayAllClearSuffix", None) is not None: + suffix_file = os.path.join( + SOUNDS_PATH, config.get("Alerting", {}).get("SayAllClearSuffix") + ) + LOGGER.debug("sayAllClear: Adding all clear suffix %s", suffix_file) + suffix_sound = AudioSegment.from_wav(suffix_file) + converted_suffix_sound = convert_audio(suffix_sound) + converted_suffix_sound.export(all_clear_file, format="wav") + node_numbers = config.get("Asterisk", {}).get("Nodes", []) for node_number in node_numbers: LOGGER.info("Broadcasting all clear message on node %s", node_number) @@ -700,6 +845,9 @@ def build_tailmessage(alerts): transmission to update on the weather conditions. """ + # Determine whether the user has enabled county identifiers + county_identifiers = config.get("Tailmessage", {}).get("TailmessageCounties", False) + # Extract only the alert names from the OrderedDict keys alert_names = [alert for alert in alerts.keys()] @@ -723,7 +871,10 @@ def build_tailmessage(alerts): ) ) - for alert in alert_names: + for ( + alert, + counties, + ) in alerts.items(): # Now we loop over both alert name and its associated counties if any( fnmatch.fnmatch(alert, blocked_event) for blocked_event in TAILMESSAGE_BLOCKED_EVENTS @@ -746,6 +897,32 @@ def build_tailmessage(alerts): alert, ALERT_INDEXES[index], ) + + # Add county names if they exist + if county_identifiers: + for county in counties: + # if its the first county, word_space is 600ms of silence. else it is 400ms + if counties.index(county) == 0: + word_space = AudioSegment.silent(duration=600) + else: + word_space = AudioSegment.silent(duration=400) + county_code = county["county_code"] + if COUNTY_WAVS and county_code in COUNTY_CODES: + index = COUNTY_CODES.index(county_code) + county_name_file = COUNTY_WAVS[index] + LOGGER.debug( + "buildTailMessage: Adding %s ID %s to %s", + county_code, + county_name_file, + alert, + ) + combined_sound += word_space + AudioSegment.from_wav( + os.path.join(SOUNDS_PATH, "ALERTS", county_name_file) + ) + # if this is the last county name, add 600ms of silence after the county name + if counties.index(county) == len(counties) - 1: + combined_sound += AudioSegment.silent(duration=600) + except ValueError: LOGGER.error("Alert not found: %s", alert) except FileNotFoundError: @@ -1188,7 +1365,10 @@ def main(): say_allclear() else: if say_alert_enabled: - say_alerts(alerts) + if config["Alerting"].get("SayAlertAll", False): + say_alerts(alerts) + else: + say_alerts({alert: alerts[alert] for alert in added_alerts}) # Check if tailmessage needs to be built enable_tailmessage = config.get("Tailmessage", {}).get("Enable", False) diff --git a/UpdateSWP.py b/UpdateSWP.py index 58ab3d4..b6f511f 100644 --- a/UpdateSWP.py +++ b/UpdateSWP.py @@ -1,7 +1,7 @@ #!/usr/bin/python3 """ -SkywarnPlus Updater v0.3.5 by Mason Nelson +SkywarnPlus Updater v0.4.0 by Mason Nelson =============================================================================== Script to update SkywarnPlus to the latest version. This script will download the latest version of SkywarnPlus from GitHub, and then merge the existing diff --git a/config.yaml b/config.yaml index 15f96ff..94d93e1 100644 --- a/config.yaml +++ b/config.yaml @@ -1,4 +1,4 @@ -# SkywarnPlus v0.3.5 Configuration File +# SkywarnPlus v0.4.0 Configuration File # Author: Mason Nelson (N5LSN/WRKF394) # Please edit this file according to your specific requirements. @@ -35,14 +35,28 @@ Alerting: # Make sure to use county codes ONLY, NOT zone codes, otherwise you might miss out on alerts. # Example: # CountyCodes: - # - ARC121 - # - ARC021 + # - ARC125 + # - ARC119 + # + # SkywarnPlus allows adding county-specific audio indicators to each alert in the message. + # To enable this feature, specify an audio file containing a recording of the county name in the + # ROOT of the SOUNDS/ directory as shown in the below example. You must create these files yourself. + # You can use the same VoiceRSS API used for SkyDescribe (see below) to generate these files with a synthetic voice: + # http://api.voicerss.org/?key=[YOUR_API_KEY_HERE]=en-us&f=8khz_16bit_mono&v=John&src=[YOUR COUNTY NAME HERE] + # http://api.voicerss.org/?key=1234567890QWERTY&hl=en-us&f=8khz_16bit_mono&v=John&src=Saline County + # Example: + # CountyCodes: + # - ARC125: "Saline.wav" + # - ARC119: "Pulaski.wav" CountyCodes: - YOUR_COUNTY_CODE # Enable instant voice announcement when new weather alerts are issued. SayAlert: true + # When a change is detected, make SayAlert say ALL of the currently active alerts, not just newly detected one(s) + SayAlertAll: false + # Specify the WAV file in the SOUNDS/ALERTS directory to use as the alert sound effect AlertSound: Duncecap.wav @@ -54,6 +68,9 @@ Alerting: # Specify the WAV file in the SOUNDS/ALERTS directory to use as the all clear sound effect. AllClearSound: Triangles.wav + + # Specify a WAV file in the root of the SOUNDS directory to be appended to the end of the all clear message. + SayAllClearSuffix: # Specify the WAV file in the SOUNDS/ALERTS directory to use as the alert seperator sound effect AlertSeperator: Woodblock.wav @@ -109,11 +126,16 @@ Tailmessage: # Configuration for the tail message functionality. Requires initial setup in RPT.CONF. # Enable/disable automatic tail message. - Enable: false + Enable: true # Specify a WAV file in the root of the SOUNDS directory to be appended to the end of the tail message. TailmessageSuffix: + # Enable to add county indicators to the tail message + # County indicators must FIRST be configured in the Alerting section where county codes are defined. + # NOTE: This can make your tail message quite long depending on how many counties you have configured. + TailmessageCounties: false + # Specify an alternative path and filename for saving the tail message. # Default is /tmp/SkywarnPlus/wx-tail.wav. TailmessagePath: /tmp/SkywarnPlus/wx-tail.wav @@ -392,4 +414,4 @@ DEV: INJECTALERTS: - Tornado Warning - Tornado Watch - - Severe Thunderstorm Warning + - Severe Thunderstorm Warning \ No newline at end of file