v0.5.0 update

pull/140/head
Mason10198 2 years ago
parent 6152746657
commit 4b3faf5c86

File diff suppressed because it is too large Load Diff

@ -0,0 +1,287 @@
#!/usr/bin/python3
"""
CountyIDGen.py by Mason Nelson
===============================================================================
This script is a utility for generating WAV audio files corresponding to each
county code defined in the SkywarnPlus config.yaml. The audio files are generated
using the Voice RSS Text-to-Speech API and the settings defined in the config.yaml.
This script will generate the files, save them in the correct location, and automatically
modify the SkywarnPlus config.yaml to utilize them.
This file is part of SkywarnPlus.
SkywarnPlus is free software: you can redistribute it and/or modify it under the terms of
the GNU General Public License as published by the Free Software Foundation, either version 3
of the License, or (at your option) any later version. SkywarnPlus is distributed in the hope
that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with SkywarnPlus. If not, see <https://www.gnu.org/licenses/>.
"""
import os
import io
import re
import sys
import requests
import logging
import zipfile
from datetime import datetime
from ruamel.yaml import YAML
from pydub import AudioSegment
from pydub.silence import split_on_silence
# Initialize YAML
yaml = YAML()
# Directories and Paths
BASE_DIR = os.path.dirname(os.path.realpath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, "config.yaml")
COUNTY_CODES_PATH = os.path.join(BASE_DIR, "CountyCodes.md")
# Load configurations
with open(CONFIG_PATH, "r") as config_file:
config = yaml.load(config_file)
# Logging setup
LOG_CONFIG = config.get("Logging", {})
ENABLE_DEBUG = LOG_CONFIG.get("Debug", False)
LOG_FILE = LOG_CONFIG.get("LogPath", os.path.join(BASE_DIR, "SkywarnPlus.log"))
# Set up logging
LOGGER = logging.getLogger(__name__)
LOGGER.setLevel(logging.DEBUG if ENABLE_DEBUG else logging.INFO)
# Set up log message formatting
LOG_FORMATTER = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
# Set up console log handler
C_HANDLER = logging.StreamHandler()
C_HANDLER.setFormatter(LOG_FORMATTER)
LOGGER.addHandler(C_HANDLER)
# Ensure the directory for the log file exists
log_directory = os.path.dirname(LOG_FILE)
if not os.path.exists(log_directory):
os.makedirs(log_directory)
# Set up file log handler
F_HANDLER = logging.FileHandler(LOG_FILE)
F_HANDLER.setFormatter(LOG_FORMATTER)
LOGGER.addHandler(F_HANDLER)
# Extract API parameters from the config
API_KEY = config["SkyDescribe"]["APIKey"]
LANGUAGE = config["SkyDescribe"]["Language"]
SPEED = str(config["SkyDescribe"]["Speed"])
VOICE = config["SkyDescribe"]["Voice"]
SOUNDS_PATH = config.get("Alerting", {}).get(
"SoundsPath", os.path.join(BASE_DIR, "SOUNDS")
)
def generate_wav(api_key, language, speed, voice, text, output_file):
"""
Convert the given text to audio using the Voice RSS Text-to-Speech API and trims silence.
"""
base_url = "http://api.voicerss.org/"
params = {
"key": api_key,
"hl": language,
"src": text,
"c": "WAV",
"f": "8khz_16bit_mono",
"r": str(speed),
"v": voice,
}
response = requests.get(base_url, params=params)
response.raise_for_status()
# If the response text contains "ERROR" then log it and exit
if "ERROR" in response.text:
LOGGER.error("SkyDescribe: %s", response.text)
sys.exit(1)
# Load the audio data into pydub's AudioSegment
sound = AudioSegment.from_wav(io.BytesIO(response.content))
# Normalize the entire audio clip
target_dBFS = -6.0
gain_difference = target_dBFS - sound.max_dBFS
sound = sound.apply_gain(gain_difference)
# Split track where silence is 100ms or more and get chunks
chunks = split_on_silence(sound, min_silence_len=200, silence_thresh=-40)
# If there are chunks, concatenate all of them
if chunks:
combined_sound = sum(chunks, AudioSegment.empty())
# Export the combined audio
combined_sound.export(output_file, format="wav")
else:
# If there are no chunks, just save the original audio
sound.export(output_file, format="wav")
def sanitize_text_for_tts(text):
"""
Sanitize the text for TTS processing.
Remove characters that aren't alphanumeric or whitespace.
"""
sanitized_text = re.sub(r"[^a-zA-Z0-9\s]", "", text)
return sanitized_text
def backup_existing_files(path, filename_pattern, backup_name):
"""
Backup files matching the filename pattern in the specified path to a zip file.
"""
files_to_backup = [
f
for f in os.listdir(path)
if f.startswith(filename_pattern) and f.endswith(".wav")
]
if not files_to_backup:
return
with zipfile.ZipFile(backup_name, "w") as zipf:
for file in files_to_backup:
zipf.write(os.path.join(path, file), file)
def process_county_codes():
"""
Process county codes and make changes.
"""
new_county_codes = []
for entry in config["Alerting"]["CountyCodes"]:
overwrite = False
if isinstance(entry, str): # County code without WAV file
county_code = entry
elif isinstance(entry, dict): # County code with WAV file
county_code = list(entry.keys())[0]
county_name = county_data.get(county_code)
sanitized_county_name = sanitize_text_for_tts(county_name)
expected_wav_file = "{}.wav".format(sanitized_county_name)
if os.path.exists(os.path.join(SOUNDS_PATH, expected_wav_file)):
if not overwrite:
user_input = input(
"The WAV file for {} ({}) already exists. Do you want to overwrite it? [yes/no]: ".format(
county_name, expected_wav_file
)
).lower()
if user_input != "yes":
LOGGER.info(
"Skipping generation for {} due to user input.".format(
county_name
)
)
new_county_codes.append({county_code: expected_wav_file})
continue # Skip to the next county code
overwrite = True
# At this point, we are sure that we either have a new county code or the user has agreed to overwrite.
county_name = county_data.get(county_code)
if county_name:
sanitized_county_name = sanitize_text_for_tts(county_name)
output_file = os.path.join(
SOUNDS_PATH, "{}.wav".format(sanitized_county_name)
)
generate_wav(
API_KEY, LANGUAGE, SPEED, VOICE, sanitized_county_name, output_file
)
# Add the mapping for the county code to the new list
new_county_codes.append(
{county_code: "{}.wav".format(sanitized_county_name)}
)
# Replace the old CountyCodes list with the new one
config["Alerting"]["CountyCodes"] = new_county_codes
def load_county_codes_from_md(md_file_path):
"""
Load county names from the MD file and return a dictionary mapping county codes to county names.
"""
with open(md_file_path, "r") as file:
lines = file.readlines()
county_data = {}
for line in lines:
if line.startswith("|") and "County Name" not in line and "-----" not in line:
_, county_name, code, _ = line.strip().split("|")
county_data[code.strip()] = county_name.strip()
return county_data
def display_initial_warning():
warning_message = """
============================================================
WARNING: Please read the following information carefully before proceeding.
This utility is designed to generate WAV audio files corresponding to each county code
defined in the SkywarnPlus config.yaml using the Voice RSS Text-to-Speech API. The generated
audio files will be saved in the appropriate location, and the SkywarnPlus config.yaml will
be automatically updated to use them.
However, a few things to keep in mind:
- The script will only attempt to generate WAV files for county codes that are defined in the config.
- Pronunciations for some county names might not be accurate. In such cases, you may need to
manually create the files using VoiceRSS. This might involve intentionally misspelling the county
name to achieve the desired pronunciation.
- This script will attempt to backup any files before it modifies them, but it is always a good idea to
manually back up your existing configuration and files before running this script.
- This script will modify your config.yaml file, so you should ALWAYS double check the changes it makes.
There might be improperly formatted indentations, comments, etc. that you will need to fix manually.
Proceed with caution.
============================================================
"""
print(warning_message)
# Display the initial warning
display_initial_warning()
# Wait for user acknowledgment before proceeding.
user_input = input("Do you want to proceed? [yes/no]: ").lower()
if user_input != "yes":
LOGGER.info("Aborting process due to user input.")
sys.exit()
# Load county names and generate WAV files
backup_date = datetime.now().strftime("%Y%m%d")
backup_name = os.path.join(SOUNDS_PATH, "CountyID_Backup_{}.zip".format(backup_date))
backup_existing_files(SOUNDS_PATH, "", backup_name)
# Load county codes and names
county_data = load_county_codes_from_md(COUNTY_CODES_PATH)
# Call the function to process the county codes
process_county_codes()
# Update config.yaml to reflect the WAV file mappings
for i, county_code in enumerate(config["Alerting"]["CountyCodes"]):
if isinstance(county_code, str):
county_name = county_data.get(county_code)
if county_name:
sanitized_county_name = sanitize_text_for_tts(county_name)
config["Alerting"]["CountyCodes"][i] = {
county_code: "{}.wav".format(sanitized_county_name)
}
# Write the updated config.yaml
with open(CONFIG_PATH, "w") as config_file:
yaml.indent(sequence=4, offset=2)
yaml.dump(config, config_file)
LOGGER.info("County WAV files generation completed.")

@ -120,7 +120,7 @@ Follow the steps below to install:
pacman -S ffmpeg pacman -S ffmpeg
wget https://bootstrap.pypa.io/pip/3.5/get-pip.py wget https://bootstrap.pypa.io/pip/3.5/get-pip.py
python get-pip.py python get-pip.py
pip install pyyaml requests python-dateutil pydub pip install requests python-dateutil pydub
pip install ruamel.yaml==0.15.100 pip install ruamel.yaml==0.15.100
``` ```
@ -152,7 +152,7 @@ Follow the steps below to install:
nano config.yaml nano config.yaml
``` ```
You can find your area code(s) at https://alerts.weather.gov/. Select `County List` to the right of your state, and use the `County Code` associated with the area(s) you want SkywarnPlus to poll for WX alerts. You can find your county codes in the [CountyCodes.md](CountyCodes.md) file included in this repository. Navigate to the file and look for your state and then your specific county to find the associated County Code you'll use in SkywarnPlus to poll for alerts.
## **IMPORTANT**: YOU WILL MISS ALERTS IF YOU USE A **ZONE** CODE. DO NOT USE **ZONE** CODES UNLESS YOU KNOW WHAT YOU ARE DOING. ## **IMPORTANT**: YOU WILL MISS ALERTS IF YOU USE A **ZONE** CODE. DO NOT USE **ZONE** CODES UNLESS YOU KNOW WHAT YOU ARE DOING.
@ -553,7 +553,7 @@ In _most_ cases, any multiple counties that SkywarnPlus is set up to monitor wil
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. 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 use IDChange, 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.
>**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.** >**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.**
@ -561,13 +561,25 @@ If you'd like to use IDChange or add county identifiers, you must create your ow
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. 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? ### Automated Setup using `CountyIDGen.py`
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. To simplify the process of setting up county-specific audio tags, SkywarnPlus provides a utility script called CountyIDGen.py. This script is designed to:
- Generate WAV audio files for each county code defined in the config.yaml using the Voice RSS Text-to-Speech API.
- Save these generated files in the proper directory.
- Modify the config.yaml automatically to reference these files.
To use the script for automated setup, simply make sure you have already set up all of your county codes (`Alerting` section) and VoiceRSS details (`SkyDescribe` section) in `config.yaml`, and then execute the script:
```bash
./CountyIDGen.py
```
### Manual Setup
Manual setup involves creating 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. 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: Here is an example of how to manually configure the `config.yaml` to utilize this feature:
```yaml ```yaml
Alerting: Alerting:

@ -1,7 +1,7 @@
#!/usr/bin/python3 #!/usr/bin/python3
""" """
SkyControl v0.4.2 by Mason Nelson SkyControl.py v0.5.0 by Mason Nelson
================================== ==================================
A Control Script for SkywarnPlus A Control Script for SkywarnPlus

@ -1,7 +1,7 @@
#!/usr/bin/python3 #!/usr/bin/python3
""" """
SkyDescribe v0.4.2 by Mason Nelson SkyDescribe.py v0.5.0 by Mason Nelson
================================================== ==================================================
Text to Speech conversion for Weather Descriptions Text to Speech conversion for Weather Descriptions
@ -297,9 +297,7 @@ def main(index_or_title):
description = alert_data[0]["description"] description = alert_data[0]["description"]
else: else:
description = "There are {} unique instances of {}. Describing the first one. {}".format( description = "There are {} unique instances of {}. Describing the first one. {}".format(
unique_instances, unique_instances, alert, alert_data[0]["description"]
alert,
alert_data[0]["description"]
) )
else: else:
@ -320,9 +318,7 @@ def main(index_or_title):
description = alert_data[0]["description"] description = alert_data[0]["description"]
else: else:
description = "There are {} unique instances of {}. Describing the first one. {}".format( description = "There are {} unique instances of {}. Describing the first one. {}".format(
unique_instances, unique_instances, alert, alert_data[0]["description"]
alert,
alert_data[0]["description"]
) )
break break
else: else:

@ -1,7 +1,7 @@
#!/usr/bin/python3 #!/usr/bin/python3
""" """
SkywarnPlus v0.4.2 by Mason Nelson SkywarnPlus.py v0.5.0 by Mason Nelson
=============================================================================== ===============================================================================
SkywarnPlus is a utility that retrieves severe weather alerts from the National SkywarnPlus is a utility that retrieves severe weather alerts from the National
Weather Service and integrates these alerts with an Asterisk/app_rpt based Weather Service and integrates these alerts with an Asterisk/app_rpt based
@ -53,6 +53,7 @@ yaml = YAML()
# Directories and Paths # Directories and Paths
BASE_DIR = os.path.dirname(os.path.realpath(__file__)) BASE_DIR = os.path.dirname(os.path.realpath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, "config.yaml") CONFIG_PATH = os.path.join(BASE_DIR, "config.yaml")
COUNTY_CODES_PATH = os.path.join(BASE_DIR, "CountyCodes.md")
# Open and read configuration file # Open and read configuration file
with open(CONFIG_PATH, "r") as config_file: with open(CONFIG_PATH, "r") as config_file:
@ -319,19 +320,31 @@ LOGGER.debug("Tailmessage Blocked events: %s", TAILMESSAGE_BLOCKED_EVENTS)
def load_state(): def load_state():
""" """
Load the state from the state file if it exists, else return an initial state. Load the state from the state file if it exists, else return an initial state.
The state file is expected to be a JSON file. If certain keys are missing in the
loaded state, this function will provide default values for those keys.
""" """
# Check if the state data file exists
if os.path.exists(DATA_FILE): if os.path.exists(DATA_FILE):
with open(DATA_FILE, "r") as file: with open(DATA_FILE, "r") as file:
state = json.load(file) state = json.load(file)
# Ensure 'alertscript_alerts' key is present in the state, default to an empty list
state["alertscript_alerts"] = state.get("alertscript_alerts", []) state["alertscript_alerts"] = state.get("alertscript_alerts", [])
# Process 'last_alerts' and maintain the order of alerts # Process 'last_alerts' key to maintain the order of alerts using OrderedDict
# This step is necessary because JSON does not preserve order by default
last_alerts = state.get("last_alerts", []) last_alerts = state.get("last_alerts", [])
state["last_alerts"] = OrderedDict((x[0], x[1]) for x in last_alerts) state["last_alerts"] = OrderedDict((x[0], x[1]) for x in last_alerts)
# Ensure 'last_sayalert' and 'active_alerts' keys are present in the state
state["last_sayalert"] = state.get("last_sayalert", []) state["last_sayalert"] = state.get("last_sayalert", [])
state["active_alerts"] = state.get("active_alerts", []) state["active_alerts"] = state.get("active_alerts", [])
return state return state
# If the state data file does not exist, return a default state
else: else:
return { return {
"ct": None, "ct": None,
@ -346,22 +359,33 @@ def load_state():
def save_state(state): def save_state(state):
""" """
Save the state to the state file. Save the state to the state file.
The state is saved as a JSON file. The function ensures certain keys in the state
are converted to lists before saving, ensuring consistency and ease of processing
when the state is later loaded.
""" """
# Convert 'alertscript_alerts', 'last_sayalert', and 'active_alerts' keys to lists
# This ensures consistency in data format, especially useful when loading the state later
state["alertscript_alerts"] = list(state["alertscript_alerts"]) state["alertscript_alerts"] = list(state["alertscript_alerts"])
state["last_alerts"] = list(state["last_alerts"].items())
state["last_sayalert"] = list(state["last_sayalert"]) state["last_sayalert"] = list(state["last_sayalert"])
state["active_alerts"] = list(state["active_alerts"]) state["active_alerts"] = list(state["active_alerts"])
# Convert 'last_alerts' from OrderedDict to list of items
# This step is necessary because JSON does not natively support OrderedDict
state["last_alerts"] = list(state["last_alerts"].items())
# Save the state to the data file in a formatted manner
with open(DATA_FILE, "w") as file: with open(DATA_FILE, "w") as file:
json.dump(state, file, ensure_ascii=False, indent=4) json.dump(state, file, ensure_ascii=False, indent=4)
def get_alerts(countyCodes): def get_alerts(countyCodes):
""" """
This function retrieves severe weather alerts for specified county codes. Retrieves severe weather alerts for specified county codes and processes them.
""" """
# Mapping dictionaries are defined for converting the severity level from the # Define mappings to convert severity levels from various terminologies to a numeric scale
# API's terminology to a numeric scale and from common alert language to the same numeric scale.
severity_mapping_api = { severity_mapping_api = {
"Extreme": 4, "Extreme": 4,
"Severe": 3, "Severe": 3,
@ -371,17 +395,15 @@ def get_alerts(countyCodes):
} }
severity_mapping_words = {"Warning": 4, "Watch": 3, "Advisory": 2, "Statement": 1} severity_mapping_words = {"Warning": 4, "Watch": 3, "Advisory": 2, "Statement": 1}
# 'alerts' will store the alert data we retrieve. # Initialize storage for the alerts and a set to keep track of processed alerts
# 'seen_alerts' is used to keep track of the alerts we've already processed.
alerts = OrderedDict() alerts = OrderedDict()
seen_alerts = set() seen_alerts = set()
# We retrieve the current time and log it for debugging purposes. # Log current time for reference
current_time = datetime.now(timezone.utc) current_time = datetime.now(timezone.utc)
LOGGER.debug("getAlerts: Current time: %s", current_time) LOGGER.debug("getAlerts: Current time: %s", current_time)
# The script allows for alert injection for testing purposes. # Handle alert injection for development/testing purposes
# If injection is enabled in the configuration, we process these injected alerts first.
if config.get("DEV", {}).get("INJECT", False): if config.get("DEV", {}).get("INJECT", False):
LOGGER.debug("getAlerts: DEV Alert Injection Enabled") LOGGER.debug("getAlerts: DEV Alert Injection Enabled")
injected_alerts = config["DEV"].get("INJECTALERTS", []) injected_alerts = config["DEV"].get("INJECTALERTS", [])
@ -390,6 +412,8 @@ def get_alerts(countyCodes):
max_counties = len(countyCodes) # Assuming countyCodes is a list of counties max_counties = len(countyCodes) # Assuming countyCodes is a list of counties
county_codes_cycle = itertools.cycle(countyCodes) county_codes_cycle = itertools.cycle(countyCodes)
county_assignment_counter = 1
for alert_info in injected_alerts: for alert_info in injected_alerts:
if isinstance(alert_info, dict): if isinstance(alert_info, dict):
alert_title = alert_info.get("Title", "") alert_title = alert_info.get("Title", "")
@ -404,6 +428,17 @@ def get_alerts(countyCodes):
end_time_str = alert_info.get("EndTime") end_time_str = alert_info.get("EndTime")
county_data = [] county_data = []
# If no counties are specified, use the ones provided to the function in an increasing manner
if not specified_counties:
# Limit the number of counties assigned to not exceed max_counties
counties_to_assign = min(county_assignment_counter, max_counties)
specified_counties = [
next(county_codes_cycle) for _ in range(counties_to_assign)
]
county_assignment_counter += 1 # Increment for the next injected alert
for county in specified_counties: for county in specified_counties:
if county not in countyCodes: if county not in countyCodes:
LOGGER.error( LOGGER.error(
@ -431,11 +466,8 @@ def get_alerts(countyCodes):
alert_title alert_title
] = county_data # Add the list of dictionaries to the alert ] = 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])
# If injected alerts are used, we return them here and don't proceed with the function. # If injected alerts are used, we return them here and don't proceed with the function.
return alerts return sort_alerts(alerts)
# Configuration specifies whether to use 'effective' or 'onset' time for alerts. # Configuration specifies whether to use 'effective' or 'onset' time for alerts.
# Depending on the configuration, we set the appropriate keys for start and end time. # Depending on the configuration, we set the appropriate keys for start and end time.
@ -632,16 +664,52 @@ def get_alerts(countyCodes):
return alerts return alerts
def sort_alerts(alerts):
"""
Sorts and limits the alerts based on their severity and word severity.
"""
# Define mapping for converting common alert terminologies to a numeric severity scale
severity_mapping_words = {"Warning": 4, "Watch": 3, "Advisory": 2, "Statement": 1}
# Sort the alerts first by their maximum severity, and then by their word severity
sorted_alerts = OrderedDict(
sorted(
alerts.items(),
key=lambda item: (
max([x["severity"] for x in item[1]]), # Max Severity for the alert
severity_mapping_words.get(
item[0].split()[-1], 0
), # Severity based on last word in the alert title
),
reverse=True, # Sort in descending order
)
)
# Truncate the list of alerts to the maximum allowed number (MAX_ALERTS)
limited_alerts = OrderedDict(list(sorted_alerts.items())[:MAX_ALERTS])
return limited_alerts
def time_until(start_time_utc, current_time): def time_until(start_time_utc, current_time):
""" """
Calculate the time difference between two datetime objects and returns it Calculate the time difference between two datetime objects and returns it
as a formatted string. as a formatted string.
""" """
# Calculate the time difference between the two datetime objects
delta = start_time_utc - current_time delta = start_time_utc - current_time
# Determine the sign (used for formatting)
sign = "-" if delta < timedelta(0) else "" sign = "-" if delta < timedelta(0) else ""
# Decompose the time difference into days, hours, and minutes
days, remainder = divmod(abs(delta.total_seconds()), 86400) days, remainder = divmod(abs(delta.total_seconds()), 86400)
hours, remainder = divmod(remainder, 3600) hours, remainder = divmod(remainder, 3600)
minutes, _ = divmod(remainder, 60) minutes, _ = divmod(remainder, 60)
# Return the time difference as a formatted string
return "{}{} days, {} hours, {} minutes".format( return "{}{} days, {} hours, {} minutes".format(
sign, int(days), int(hours), int(minutes) sign, int(days), int(hours), int(minutes)
) )
@ -651,34 +719,41 @@ def say_alerts(alerts):
""" """
Generate and broadcast severe weather alert sounds on Asterisk. Generate and broadcast severe weather alert sounds on Asterisk.
""" """
# Define the path of the alert file
# Load the current state
state = load_state() state = load_state()
# Extract only the alert names from the OrderedDict keys # Extract only the alert names and associated counties
alert_names = [alert for alert in alerts.keys()] alert_names_and_counties = {
alert: [county["county_code"] for county in counties]
for alert, counties in alerts.items()
}
filtered_alerts = [] # Filter out alerts that are blocked based on configuration
for alert in alert_names: filtered_alerts_and_counties = {}
for alert, county_codes in alert_names_and_counties.items():
if any( if any(
fnmatch.fnmatch(alert, blocked_event) fnmatch.fnmatch(alert, blocked_event)
for blocked_event in SAYALERT_BLOCKED_EVENTS for blocked_event in SAYALERT_BLOCKED_EVENTS
): ):
LOGGER.debug("sayAlert: blocking %s as per configuration", alert) LOGGER.debug("sayAlert: blocking %s as per configuration", alert)
continue continue
filtered_alerts.append(alert) filtered_alerts_and_counties[alert] = county_codes
# Check if the filtered alerts are the same as the last run # Check if the filtered alerts are the same as the last run
if filtered_alerts == state["last_sayalert"]: if filtered_alerts_and_counties == state.get("last_sayalert", {}):
LOGGER.debug("sayAlert: alerts are the same as the last broadcast - skipping.") LOGGER.debug(
"sayAlert: alerts and counties are the same as the last broadcast - skipping."
)
return return
state["last_sayalert"] = filtered_alerts # Update the state with the current alerts
state["last_sayalert"] = filtered_alerts_and_counties
save_state(state) save_state(state)
# Initialize the audio segments and paths
alert_file = "{}/alert.wav".format(TMP_DIR) alert_file = "{}/alert.wav".format(TMP_DIR)
word_space = AudioSegment.silent(duration=600) word_space = AudioSegment.silent(duration=600)
sound_effect = AudioSegment.from_wav( sound_effect = AudioSegment.from_wav(
os.path.join( os.path.join(
SOUNDS_PATH, SOUNDS_PATH,
@ -687,7 +762,6 @@ def say_alerts(alerts):
config.get("Alerting", {}).get("AlertSeperator", "Woodblock.wav"), config.get("Alerting", {}).get("AlertSeperator", "Woodblock.wav"),
) )
) )
intro_effect = AudioSegment.from_wav( intro_effect = AudioSegment.from_wav(
os.path.join( os.path.join(
SOUNDS_PATH, SOUNDS_PATH,
@ -696,16 +770,16 @@ def say_alerts(alerts):
config.get("Alerting", {}).get("AlertSound", "Duncecap.wav.wav"), config.get("Alerting", {}).get("AlertSound", "Duncecap.wav.wav"),
) )
) )
combined_sound = ( combined_sound = (
intro_effect intro_effect
+ word_space + word_space
+ AudioSegment.from_wav(os.path.join(SOUNDS_PATH, "ALERTS", "SWP_148.wav")) + AudioSegment.from_wav(os.path.join(SOUNDS_PATH, "ALERTS", "SWP_148.wav"))
) )
# Build the combined sound with alerts and county names
alert_count = 0 alert_count = 0
for alert, counties in alerts.items(): for alert, counties in alerts.items():
if alert in filtered_alerts: if alert in filtered_alerts_and_counties:
try: try:
descriptions = [county["description"] for county in counties] descriptions = [county["description"] for county in counties]
end_times = [county["end_time_utc"] for county in counties] end_times = [county["end_time_utc"] for county in counties]
@ -831,12 +905,13 @@ def say_allclear():
""" """
Generate and broadcast 'all clear' message on Asterisk. Generate and broadcast 'all clear' message on Asterisk.
""" """
# Empty the last_sayalert list so that the next run will broadcast alerts
# Load current state and clear the last_sayalert list
state = load_state() state = load_state()
state["last_sayalert"] = [] state["last_sayalert"] = []
save_state(state) save_state(state)
# Load sound file paths # Define file paths for the sounds
all_clear_sound_file = os.path.join( all_clear_sound_file = os.path.join(
config.get("Alerting", {}).get("SoundsPath"), config.get("Alerting", {}).get("SoundsPath"),
"ALERTS", "ALERTS",
@ -845,25 +920,28 @@ def say_allclear():
) )
swp_147_file = os.path.join(SOUNDS_PATH, "ALERTS", "SWP_147.wav") swp_147_file = os.path.join(SOUNDS_PATH, "ALERTS", "SWP_147.wav")
# Create AudioSegment objects # Load sound files into AudioSegment objects
all_clear_sound = AudioSegment.from_wav(all_clear_sound_file) all_clear_sound = AudioSegment.from_wav(all_clear_sound_file)
swp_147_sound = AudioSegment.from_wav(swp_147_file) swp_147_sound = AudioSegment.from_wav(swp_147_file)
# Create silence # Generate silence for spacing between sounds
silence = AudioSegment.silent(duration=600) # 600 ms silence silence = AudioSegment.silent(duration=600) # 600 ms of silence
# Combine the sounds with silence in between # Combine the sound clips
combined_sound = all_clear_sound + silence + swp_147_sound combined_sound = all_clear_sound + silence + swp_147_sound
# Add a delay before the sound if configured
if AUDIO_DELAY > 0: if AUDIO_DELAY > 0:
LOGGER.debug("sayAllClear: Prepending audio with %sms of silence", AUDIO_DELAY) LOGGER.debug("sayAllClear: Prepending audio with %sms of silence", AUDIO_DELAY)
silence = AudioSegment.silent(duration=AUDIO_DELAY) silence = AudioSegment.silent(duration=AUDIO_DELAY)
combined_sound = silence + combined_sound combined_sound = silence + combined_sound
# Export the combined sound to a file
all_clear_file = os.path.join(TMP_DIR, "allclear.wav") all_clear_file = os.path.join(TMP_DIR, "allclear.wav")
converted_combined_sound = convert_audio(combined_sound) converted_combined_sound = convert_audio(combined_sound)
converted_combined_sound.export(all_clear_file, format="wav") converted_combined_sound.export(all_clear_file, format="wav")
# Append a suffix to the sound if configured
if config.get("Alerting", {}).get("SayAllClearSuffix", None) is not None: if config.get("Alerting", {}).get("SayAllClearSuffix", None) is not None:
suffix_file = os.path.join( suffix_file = os.path.join(
SOUNDS_PATH, config.get("Alerting", {}).get("SayAllClearSuffix") SOUNDS_PATH, config.get("Alerting", {}).get("SayAllClearSuffix")
@ -873,6 +951,7 @@ def say_allclear():
converted_suffix_sound = convert_audio(suffix_sound) converted_suffix_sound = convert_audio(suffix_sound)
converted_suffix_sound.export(all_clear_file, format="wav") converted_suffix_sound.export(all_clear_file, format="wav")
# Play the "all clear" sound on the configured Asterisk nodes
node_numbers = config.get("Asterisk", {}).get("Nodes", []) node_numbers = config.get("Asterisk", {}).get("Nodes", [])
for node_number in node_numbers: for node_number in node_numbers:
LOGGER.info("Broadcasting all clear message on node %s", node_number) LOGGER.info("Broadcasting all clear message on node %s", node_number)
@ -897,6 +976,7 @@ def build_tailmessage(alerts):
# Get the suffix config # Get the suffix config
tailmessage_suffix = config.get("Tailmessage", {}).get("TailmessageSuffix", None) tailmessage_suffix = config.get("Tailmessage", {}).get("TailmessageSuffix", None)
# If alerts is empty
if not alerts: if not alerts:
LOGGER.debug("buildTailMessage: No alerts, creating silent tailmessage") LOGGER.debug("buildTailMessage: No alerts, creating silent tailmessage")
silence = AudioSegment.silent(duration=100) silence = AudioSegment.silent(duration=100)
@ -1345,6 +1425,73 @@ def supermon_back_compat(alerts):
file.write("<br>".join(alert_titles)) file.write("<br>".join(alert_titles))
def detect_county_changes(old_alerts, new_alerts):
"""
Detect if any counties have been added to or removed from an alert and return the alerts
with added or removed counties.
"""
alerts_with_changed_counties = OrderedDict()
changes_detected = {}
for alert_name, alert_info in new_alerts.items():
if alert_name not in old_alerts:
continue
old_alert_info = old_alerts.get(alert_name, [])
old_county_codes = {info["county_code"] for info in old_alert_info}
new_county_codes = {info["county_code"] for info in alert_info}
added_counties = new_county_codes - old_county_codes
removed_counties = old_county_codes - new_county_codes
added_counties = {
code.replace("{", "").replace("}", "").replace('"', "")
for code in added_counties
}
removed_counties = {
code.replace("{", "").replace("}", "").replace('"', "")
for code in removed_counties
}
if added_counties or removed_counties:
alerts_with_changed_counties[alert_name] = new_alerts[alert_name]
changes_detected[alert_name] = {
"old": old_county_codes,
"added": added_counties,
"removed": removed_counties,
}
return alerts_with_changed_counties, changes_detected
def load_county_names(md_file):
"""
Load county names from separate markdown tables so that county codes can be replaced with county names.
"""
with open(md_file, "r") as f:
lines = f.readlines()
county_data = {}
in_table = False
for line in lines:
if line.startswith("| County Name"):
in_table = True
continue # Skip the header
elif not in_table or line.strip() == "":
continue
else:
name, code = [s.strip() for s in line.split("|")[1:-1]]
county_data[code] = name
return county_data
def replace_with_county_name(county_code, county_data):
"""
Translate county code to county name.
"""
return county_data.get(county_code, county_code)
def main(): def main():
""" """
The main function that orchestrates the entire process of fetching and The main function that orchestrates the entire process of fetching and
@ -1353,61 +1500,163 @@ def main():
""" """
# Fetch configurations # Fetch configurations
say_alert_enabled = config["Alerting"].get("SayAlert", False) say_alert_enabled = config["Alerting"].get("SayAlert", False)
say_alert_all = config["Alerting"].get("SayAlertAll", False)
say_all_clear_enabled = config["Alerting"].get("SayAllClear", False) say_all_clear_enabled = config["Alerting"].get("SayAllClear", False)
alertscript_enabled = config["AlertScript"].get("Enable", False) alertscript_enabled = config["AlertScript"].get("Enable", False)
ct_alerts = config["CourtesyTones"].get("CTAlerts", [])
# Fetch state enable_ct_auto_change = config["CourtesyTones"].get("Enable", False)
id_alerts = config["IDChange"].get("IDAlerts", [])
enable_id_auto_change = config["IDChange"].get("Enable", False)
pushover_enabled = config["Pushover"].get("Enable", False)
pushover_debug = config["Pushover"].get("Debug", False)
supermon_compat_enabled = config["DEV"].get("SupermonCompat", True)
say_alerts_changed = config["Alerting"].get("SayAlertsChanged", True)
# If data file does not exist, assume this is the first run and initialize CT/ID/Tailmessage files if enabled
if not os.path.isfile(DATA_FILE):
LOGGER.info("Data file does not exist, assuming first run.")
if enable_ct_auto_change:
LOGGER.info("Initializing CT files")
change_ct("NORMAL")
if enable_id_auto_change:
LOGGER.info("Initializing ID files")
change_id("NORMAL")
if ENABLE_TAILMESSAGE:
LOGGER.info("Initializing Tailmessage file")
empty_alerts = OrderedDict()
build_tailmessage(empty_alerts)
# Load previous alert data to compare changes
state = load_state() state = load_state()
# Load old alerts
last_alerts = state["last_alerts"] last_alerts = state["last_alerts"]
# Fetch new alerts # Fetch new alert data
alerts = get_alerts(COUNTY_CODES) alerts = get_alerts(COUNTY_CODES)
# If new alerts differ from old ones, process new alerts # Load county names from YAML file so that county codes can be replaced with county names in messages
if [alert[0] for alert in last_alerts.keys()] != [ county_data = load_county_names(COUNTY_CODES_PATH)
alert[0] for alert in alerts.keys()
]: # Placeholder for constructing a pushover message
added_alerts = [ pushover_message = ""
alert for alert in alerts.keys() if alert not in last_alerts.keys()
# 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:
counties_str = (
"["
+ ", ".join(
[
replace_with_county_name(x["county_code"], county_data)
for x in alerts[alert]
] ]
removed_alerts = [ )
alert for alert in last_alerts.keys() if alert not in alerts.keys() + "]"
)
LOGGER.info("Added: {} for {}".format(alert, counties_str))
pushover_message += "Added: {} for {}\n".format(alert, counties_str)
# Determine which alerts have been removed since the last check
removed_alerts = [alert for alert in last_alerts if alert not in alerts]
for alert in removed_alerts:
counties_str = (
"["
+ ", ".join(
[
replace_with_county_name(x["county_code"], county_data)
for x in last_alerts[alert]
] ]
)
+ "]"
)
LOGGER.info("Removed: {} for {}".format(alert, counties_str))
pushover_message += "Removed: {} for {}\n".format(alert, counties_str)
# Placeholder for storing alerts with changed county codes
changed_alerts = {}
# If the list of alerts is not empty
if alerts:
# Compare old and new alerts to detect changes in affected counties
changed_alerts, changes_details = detect_county_changes(last_alerts, alerts)
for alert, details in changes_details.items():
old_counties_str = (
"["
+ ", ".join(
[
replace_with_county_name(county, county_data)
for county in details["old"]
]
)
+ "]"
)
if added_alerts: added_msg = ""
LOGGER.info("Added: %s", ", ".join(alert for alert in added_alerts)) if details["added"]:
if removed_alerts: added_counties_str = (
LOGGER.info("Removed: %s", ", ".join(alert for alert in removed_alerts)) "["
+ ", ".join(
[
replace_with_county_name(county, county_data)
for county in details["added"]
]
)
+ "]"
)
added_msg = "is now also affecting {}".format(added_counties_str)
removed_msg = ""
if details["removed"]:
removed_counties_str = (
"["
+ ", ".join(
[
replace_with_county_name(county, county_data)
for county in details["removed"]
]
)
+ "]"
)
removed_msg = "is no longer affecting {}".format(removed_counties_str)
# Combining the log messages
combined_msg_parts = [f"Changed: {alert} for {old_counties_str}"]
combined_msg_parts = ["Changed: {} for {}".format(alert, old_counties_str)]
if added_msg:
combined_msg_parts.append(added_msg)
if removed_msg:
if (
added_msg
): # if there's an 'added' message, then use 'and' to combine with 'removed' message
combined_msg_parts.append("and")
combined_msg_parts.append(removed_msg)
log_msg = " ".join(combined_msg_parts)
LOGGER.info(log_msg)
pushover_message += log_msg + "\n"
# Process changes in alerts
if added_alerts or removed_alerts or changed_alerts:
# Save the data
state["last_alerts"] = alerts state["last_alerts"] = alerts
save_state(state) save_state(state)
ct_alerts = config["CourtesyTones"].get("CTAlerts", []) # Send "all clear" messages if alerts have changed but are empty
enable_ct_auto_change = config["CourtesyTones"].get("Enable", False) if not alerts:
LOGGER.info("Alerts cleared")
id_alerts = config["IDChange"].get("IDAlerts", []) # Call say_allclear if enabled
enable_id_auto_change = config["IDChange"].get("Enable", False) if say_all_clear_enabled:
say_allclear()
pushover_enabled = config["Pushover"].get("Enable", False) # Add "Alerts Cleared" to pushover message
pushover_debug = config["Pushover"].get("Debug", False) pushover_message += "Alerts Cleared\n"
supermon_compat_enabled = config["DEV"].get("SupermonCompat", True) # If alerts have been added, removed
if added_alerts or removed_alerts:
# Push alert titles to Supermon if enabled
if supermon_compat_enabled: if supermon_compat_enabled:
supermon_back_compat(alerts) supermon_back_compat(alerts)
# Initialize pushover message # Change CT/ID if necessary and enabled
pushover_message = ""
if not added_alerts and not removed_alerts:
pushover_message = "Alerts Cleared\n"
else:
if added_alerts:
pushover_message += "Added: {}\n".format(", ".join(added_alerts))
if removed_alerts:
pushover_message += "Removed: {}\n".format(", ".join(removed_alerts))
# Check if Courtesy Tones (CT) or ID needs to be changed
change_ct_id_helper( change_ct_id_helper(
alerts, alerts,
ct_alerts, ct_alerts,
@ -1425,24 +1674,46 @@ def main():
pushover_message, pushover_message,
) )
# Call alert_script if enabled
if alertscript_enabled: if alertscript_enabled:
alert_script(alerts) alert_script(alerts)
# Check if alerts need to be communicated # Say alerts if enabled
if len(alerts) == 0:
LOGGER.info("Alerts cleared")
if say_all_clear_enabled:
say_allclear()
else:
if say_alert_enabled: if say_alert_enabled:
if config["Alerting"].get("SayAlertAll", False): # If say_alert_all is enabled, say all currently active alerts
say_alerts(alerts) if say_alert_all:
alerts_to_say = alerts
# Otherwise, only say newly added alerts
else: else:
say_alerts({alert: alerts[alert] for alert in added_alerts}) alerts_to_say = {alert: alerts[alert] for alert in added_alerts}
# If County IDs have been set up and there is more than one county code, then also say alerts with county changes
# Only if enabled
if (
changed_alerts
and say_alerts_changed
and COUNTY_WAVS
and len(COUNTY_CODES) > 1
):
alerts_to_say.update(changed_alerts)
# Sort alerts based on severity
alerts_to_say = sort_alerts(alerts_to_say)
# Say the alerts
say_alerts(alerts_to_say)
# If alerts have changed, but none added or removed
elif changed_alerts:
# Say changed alerts only if enabled, County IDs have been set up, and there is more than one county code
if say_alerts_changed and COUNTY_WAVS and len(COUNTY_CODES) > 1:
# Sort alerts based on severity
changed_alerts = sort_alerts(changed_alerts)
# Say the alerts
say_alerts(changed_alerts)
# Check if tailmessage needs to be built # Alerts have changed, update tailmessage if enabled
enable_tailmessage = config.get("Tailmessage", {}).get("Enable", False) if ENABLE_TAILMESSAGE:
if enable_tailmessage:
build_tailmessage(alerts) build_tailmessage(alerts)
if pushover_debug: if pushover_debug:
pushover_message += ( pushover_message += (
@ -1451,17 +1722,35 @@ def main():
else "Built WX tailmessage\n" else "Built WX tailmessage\n"
) )
# Send pushover notification # Send pushover message if enabled
if pushover_enabled: if pushover_enabled:
pushover_message = pushover_message.rstrip("\n") pushover_message = pushover_message.rstrip("\n")
LOGGER.debug("Sending pushover notification: %s", pushover_message) LOGGER.debug("Sending Pushover message: %s", pushover_message)
send_pushover(pushover_message, title="Alerts Changed") send_pushover(pushover_message, title="SkywarnPlus")
# If no changes detected in alerts
else: else:
# If this is being run interactively, inform the user that nothing has changed
if sys.stdin.isatty(): if sys.stdin.isatty():
# list of current alerts, unless there arent any, then current_alerts = "None" # Log list of current alerts, unless there aren't any, then current_alerts = "None"
current_alerts = "None" if len(alerts) == 0 else ", ".join(alerts.keys()) if len(alerts) == 0:
current_alerts = "None"
else:
alert_details = []
for alert, counties in alerts.items():
counties_str = ", ".join(
[
replace_with_county_name(county["county_code"], county_data)
for county in counties
]
)
alert_details.append(f"{alert} ({counties_str})")
current_alerts = "; ".join(alert_details)
LOGGER.info("No change in alerts.") LOGGER.info("No change in alerts.")
LOGGER.info("Current alerts: %s.", current_alerts) LOGGER.info("Current alerts: %s.", current_alerts)
# If this is being run non-interactively, only log if debug is enabled
else: else:
LOGGER.debug("No change in alerts.") LOGGER.debug("No change in alerts.")

@ -1,7 +1,7 @@
#!/usr/bin/python3 #!/usr/bin/python3
""" """
SkywarnPlus Updater v0.4.2 by Mason Nelson UpdateSWP.py by Mason Nelson
=============================================================================== ===============================================================================
Script to update SkywarnPlus to the latest version. This script will download Script to update SkywarnPlus to the latest version. This script will download
the latest version of SkywarnPlus from GitHub, and then merge the existing the latest version of SkywarnPlus from GitHub, and then merge the existing
@ -114,6 +114,35 @@ def remove_duplicate_comments(filename):
f.writelines(new_lines) f.writelines(new_lines)
# Display the initial warning
def display_update_warning():
warning_message = """
============================================================
WARNING: Please read the following information carefully before updating.
This utility is designed to update SkywarnPlus to the latest version by fetching it
directly from GitHub. Before updating:
- A backup of the existing SkywarnPlus directory will be created to ensure safety.
- The updater will attempt to merge your existing config.yaml with the new version's
config.yaml. ALWAYS double-check your config.yaml after updating. This script is not
perfect and may not merge your configuration correctly.
- If you've made significant changes to the SkywarnPlus code, directory structure, or
configuration, this updater might not work correctly. In such cases, manual updating
is recommended.
Remember, this script's primary goal is to help with the updating process. However,
given the complexities of merging and updating, always verify the results yourself to
ensure your system continues to operate as expected.
Proceed with caution.
============================================================
"""
print(warning_message)
# Check for root privileges # Check for root privileges
if os.geteuid() != 0: if os.geteuid() != 0:
exit("ERROR: This script must be run as root.") exit("ERROR: This script must be run as root.")
@ -125,17 +154,10 @@ if not os.path.isfile("SkywarnPlus.py"):
) )
exit() exit()
# Prompt for confirmation unless -f flag is present # Display the warning message
if not args.force: if not args.force:
print("\nThis script will update SkywarnPlus to the latest version.") display_update_warning()
print(
"It will create a backup of the existing SkywarnPlus directory before updating."
)
print(
"Be aware that if you've made significant changes to the code or directory structure, this may cause issues."
)
print("If you've made significant changes, it is recommended to update manually.\n")
print("ALWAYS DOUBLE CHECK YOUR CONFIG.YAML AFTER UPDATING! This script is not perfect and may not merge your config.yaml correctly.\n")
confirmation = input("\nDo you want to continue with the update? (yes/no) ") confirmation = input("\nDo you want to continue with the update? (yes/no) ")
if confirmation.lower() != "yes": if confirmation.lower() != "yes":
log("Update cancelled by user.") log("Update cancelled by user.")

@ -1,4 +1,4 @@
# SkywarnPlus v0.4.2 Configuration File # SkywarnPlus v0.5.0 Configuration File
# Author: Mason Nelson (N5LSN/WRKF394) # Author: Mason Nelson (N5LSN/WRKF394)
# Please edit this file according to your specific requirements. # Please edit this file according to your specific requirements.
@ -41,7 +41,8 @@ Alerting:
# SkywarnPlus allows adding county-specific audio indicators to each alert in the message. # 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 # 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. # 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: # A helper script is provided in the SkywarnPlus repository to help generate these files.
# You can manually 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]&hl=en-us&f=8khz_16bit_mono&v=John&src=[YOUR COUNTY NAME HERE] # http://api.voicerss.org/?key=[YOUR_API_KEY_HERE]&hl=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 # http://api.voicerss.org/?key=1234567890QWERTY&hl=en-us&f=8khz_16bit_mono&v=John&src=Saline County
# Example: # Example:
@ -54,6 +55,10 @@ Alerting:
# Enable instant voice announcement when new weather alerts are issued. # Enable instant voice announcement when new weather alerts are issued.
SayAlert: true SayAlert: true
# Enable SayAlert to "say" any alerts whose list of affected counties has changed, in addition to new alerts.
# Only applies if more than one CountyCode is specified AND County IDs have been setup.
SayAlertsChanged: true
# When a change is detected, make SayAlert say ALL of the currently active alerts, not just newly detected one(s) # When a change is detected, make SayAlert say ALL of the currently active alerts, not just newly detected one(s)
SayAlertAll: false SayAlertAll: false

Loading…
Cancel
Save

Powered by TurnKey Linux.