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
wget https://bootstrap.pypa.io/pip/3.5/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
```
@ -152,7 +152,7 @@ Follow the steps below to install:
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.
@ -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.
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.**
@ -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.
### 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.
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
Alerting:

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

@ -1,7 +1,7 @@
#!/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
@ -297,9 +297,7 @@ def main(index_or_title):
description = alert_data[0]["description"]
else:
description = "There are {} unique instances of {}. Describing the first one. {}".format(
unique_instances,
alert,
alert_data[0]["description"]
unique_instances, alert, alert_data[0]["description"]
)
else:
@ -320,9 +318,7 @@ def main(index_or_title):
description = alert_data[0]["description"]
else:
description = "There are {} unique instances of {}. Describing the first one. {}".format(
unique_instances,
alert,
alert_data[0]["description"]
unique_instances, alert, alert_data[0]["description"]
)
break
else:

@ -1,7 +1,7 @@
#!/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
Weather Service and integrates these alerts with an Asterisk/app_rpt based
@ -53,6 +53,7 @@ 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")
# Open and read configuration 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():
"""
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):
with open(DATA_FILE, "r") as 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", [])
# 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", [])
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["active_alerts"] = state.get("active_alerts", [])
return state
# If the state data file does not exist, return a default state
else:
return {
"ct": None,
@ -346,22 +359,33 @@ def load_state():
def save_state(state):
"""
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["last_alerts"] = list(state["last_alerts"].items())
state["last_sayalert"] = list(state["last_sayalert"])
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:
json.dump(state, file, ensure_ascii=False, indent=4)
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
# API's terminology to a numeric scale and from common alert language to the same numeric scale.
# Define mappings to convert severity levels from various terminologies to a numeric scale
severity_mapping_api = {
"Extreme": 4,
"Severe": 3,
@ -371,17 +395,15 @@ def get_alerts(countyCodes):
}
severity_mapping_words = {"Warning": 4, "Watch": 3, "Advisory": 2, "Statement": 1}
# 'alerts' will store the alert data we retrieve.
# 'seen_alerts' is used to keep track of the alerts we've already processed.
# Initialize storage for the alerts and a set to keep track of processed alerts
alerts = OrderedDict()
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)
LOGGER.debug("getAlerts: Current time: %s", current_time)
# The script allows for alert injection for testing purposes.
# If injection is enabled in the configuration, we process these injected alerts first.
# Handle alert injection for development/testing purposes
if config.get("DEV", {}).get("INJECT", False):
LOGGER.debug("getAlerts: DEV Alert Injection Enabled")
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
county_codes_cycle = itertools.cycle(countyCodes)
county_assignment_counter = 1
for alert_info in injected_alerts:
if isinstance(alert_info, dict):
alert_title = alert_info.get("Title", "")
@ -404,6 +428,17 @@ def get_alerts(countyCodes):
end_time_str = alert_info.get("EndTime")
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:
if county not in countyCodes:
LOGGER.error(
@ -431,11 +466,8 @@ def get_alerts(countyCodes):
alert_title
] = 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.
return alerts
return sort_alerts(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.
@ -632,16 +664,52 @@ def get_alerts(countyCodes):
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):
"""
Calculate the time difference between two datetime objects and returns it
as a formatted string.
"""
# Calculate the time difference between the two datetime objects
delta = start_time_utc - current_time
# Determine the sign (used for formatting)
sign = "-" if delta < timedelta(0) else ""
# Decompose the time difference into days, hours, and minutes
days, remainder = divmod(abs(delta.total_seconds()), 86400)
hours, remainder = divmod(remainder, 3600)
minutes, _ = divmod(remainder, 60)
# Return the time difference as a formatted string
return "{}{} days, {} hours, {} minutes".format(
sign, int(days), int(hours), int(minutes)
)
@ -651,34 +719,41 @@ def say_alerts(alerts):
"""
Generate and broadcast severe weather alert sounds on Asterisk.
"""
# Define the path of the alert file
# Load the current state
state = load_state()
# Extract only the alert names from the OrderedDict keys
alert_names = [alert for alert in alerts.keys()]
# Extract only the alert names and associated counties
alert_names_and_counties = {
alert: [county["county_code"] for county in counties]
for alert, counties in alerts.items()
}
filtered_alerts = []
for alert in alert_names:
# Filter out alerts that are blocked based on configuration
filtered_alerts_and_counties = {}
for alert, county_codes in alert_names_and_counties.items():
if any(
fnmatch.fnmatch(alert, blocked_event)
for blocked_event in SAYALERT_BLOCKED_EVENTS
):
LOGGER.debug("sayAlert: blocking %s as per configuration", alert)
continue
filtered_alerts.append(alert)
filtered_alerts_and_counties[alert] = county_codes
# Check if the filtered alerts are the same as the last run
if filtered_alerts == state["last_sayalert"]:
LOGGER.debug("sayAlert: alerts are the same as the last broadcast - skipping.")
if filtered_alerts_and_counties == state.get("last_sayalert", {}):
LOGGER.debug(
"sayAlert: alerts and counties are the same as the last broadcast - skipping."
)
return
state["last_sayalert"] = filtered_alerts
# Update the state with the current alerts
state["last_sayalert"] = filtered_alerts_and_counties
save_state(state)
# Initialize the audio segments and paths
alert_file = "{}/alert.wav".format(TMP_DIR)
word_space = AudioSegment.silent(duration=600)
sound_effect = AudioSegment.from_wav(
os.path.join(
SOUNDS_PATH,
@ -687,7 +762,6 @@ def say_alerts(alerts):
config.get("Alerting", {}).get("AlertSeperator", "Woodblock.wav"),
)
)
intro_effect = AudioSegment.from_wav(
os.path.join(
SOUNDS_PATH,
@ -696,16 +770,16 @@ def say_alerts(alerts):
config.get("Alerting", {}).get("AlertSound", "Duncecap.wav.wav"),
)
)
combined_sound = (
intro_effect
+ word_space
+ AudioSegment.from_wav(os.path.join(SOUNDS_PATH, "ALERTS", "SWP_148.wav"))
)
# Build the combined sound with alerts and county names
alert_count = 0
for alert, counties in alerts.items():
if alert in filtered_alerts:
if alert in filtered_alerts_and_counties:
try:
descriptions = [county["description"] 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.
"""
# 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["last_sayalert"] = []
save_state(state)
# Load sound file paths
# Define file paths for the sounds
all_clear_sound_file = os.path.join(
config.get("Alerting", {}).get("SoundsPath"),
"ALERTS",
@ -845,25 +920,28 @@ def say_allclear():
)
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)
swp_147_sound = AudioSegment.from_wav(swp_147_file)
# Create silence
silence = AudioSegment.silent(duration=600) # 600 ms silence
# Generate silence for spacing between sounds
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
# Add a delay before the sound if configured
if AUDIO_DELAY > 0:
LOGGER.debug("sayAllClear: Prepending audio with %sms of silence", AUDIO_DELAY)
silence = AudioSegment.silent(duration=AUDIO_DELAY)
combined_sound = silence + combined_sound
# Export the combined sound to a file
all_clear_file = os.path.join(TMP_DIR, "allclear.wav")
converted_combined_sound = convert_audio(combined_sound)
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:
suffix_file = os.path.join(
SOUNDS_PATH, config.get("Alerting", {}).get("SayAllClearSuffix")
@ -873,6 +951,7 @@ def say_allclear():
converted_suffix_sound = convert_audio(suffix_sound)
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", [])
for node_number in node_numbers:
LOGGER.info("Broadcasting all clear message on node %s", node_number)
@ -897,6 +976,7 @@ def build_tailmessage(alerts):
# Get the suffix config
tailmessage_suffix = config.get("Tailmessage", {}).get("TailmessageSuffix", None)
# If alerts is empty
if not alerts:
LOGGER.debug("buildTailMessage: No alerts, creating silent tailmessage")
silence = AudioSegment.silent(duration=100)
@ -1345,6 +1425,73 @@ def supermon_back_compat(alerts):
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():
"""
The main function that orchestrates the entire process of fetching and
@ -1353,61 +1500,163 @@ def main():
"""
# Fetch configurations
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)
alertscript_enabled = config["AlertScript"].get("Enable", False)
# Fetch state
ct_alerts = config["CourtesyTones"].get("CTAlerts", [])
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()
# Load old alerts
last_alerts = state["last_alerts"]
# Fetch new alerts
# Fetch new alert data
alerts = get_alerts(COUNTY_CODES)
# If new alerts differ from old ones, process new alerts
if [alert[0] for alert in last_alerts.keys()] != [
alert[0] for alert in alerts.keys()
]:
added_alerts = [
alert for alert in alerts.keys() if alert not in last_alerts.keys()
# 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 = ""
# 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)
if added_alerts:
LOGGER.info("Added: %s", ", ".join(alert for alert in added_alerts))
if removed_alerts:
LOGGER.info("Removed: %s", ", ".join(alert for alert in removed_alerts))
# Placeholder for storing alerts with changed county codes
changed_alerts = {}
state["last_alerts"] = alerts
save_state(state)
# 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)
ct_alerts = config["CourtesyTones"].get("CTAlerts", [])
enable_ct_auto_change = config["CourtesyTones"].get("Enable", False)
for alert, details in changes_details.items():
old_counties_str = (
"["
+ ", ".join(
[
replace_with_county_name(county, county_data)
for county in details["old"]
]
)
+ "]"
)
id_alerts = config["IDChange"].get("IDAlerts", [])
enable_id_auto_change = config["IDChange"].get("Enable", False)
added_msg = ""
if details["added"]:
added_counties_str = (
"["
+ ", ".join(
[
replace_with_county_name(county, county_data)
for county in details["added"]
]
)
+ "]"
)
added_msg = "is now also affecting {}".format(added_counties_str)
pushover_enabled = config["Pushover"].get("Enable", False)
pushover_debug = config["Pushover"].get("Debug", False)
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)
supermon_compat_enabled = config["DEV"].get("SupermonCompat", True)
# 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
save_state(state)
# Send "all clear" messages if alerts have changed but are empty
if not alerts:
LOGGER.info("Alerts cleared")
# Call say_allclear if enabled
if say_all_clear_enabled:
say_allclear()
# Add "Alerts Cleared" to pushover message
pushover_message += "Alerts Cleared\n"
# 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)
# Initialize pushover message
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 if necessary and enabled
change_ct_id_helper(
alerts,
ct_alerts,
@ -1425,24 +1674,46 @@ def main():
pushover_message,
)
# Call alert_script if enabled
if alertscript_enabled:
alert_script(alerts)
# Check if alerts need to be communicated
if len(alerts) == 0:
LOGGER.info("Alerts cleared")
if say_all_clear_enabled:
say_allclear()
else:
# Say alerts if enabled
if say_alert_enabled:
if config["Alerting"].get("SayAlertAll", False):
say_alerts(alerts)
# If say_alert_all is enabled, say all currently active alerts
if say_alert_all:
alerts_to_say = alerts
# Otherwise, only say newly added alerts
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
enable_tailmessage = config.get("Tailmessage", {}).get("Enable", False)
if enable_tailmessage:
# Alerts have changed, update tailmessage if enabled
if ENABLE_TAILMESSAGE:
build_tailmessage(alerts)
if pushover_debug:
pushover_message += (
@ -1451,17 +1722,35 @@ def main():
else "Built WX tailmessage\n"
)
# Send pushover notification
# Send pushover message if enabled
if pushover_enabled:
pushover_message = pushover_message.rstrip("\n")
LOGGER.debug("Sending pushover notification: %s", pushover_message)
send_pushover(pushover_message, title="Alerts Changed")
LOGGER.debug("Sending Pushover message: %s", pushover_message)
send_pushover(pushover_message, title="SkywarnPlus")
# If no changes detected in alerts
else:
# If this is being run interactively, inform the user that nothing has changed
if sys.stdin.isatty():
# list of current alerts, unless there arent any, then current_alerts = "None"
current_alerts = "None" if len(alerts) == 0 else ", ".join(alerts.keys())
# Log list of current alerts, unless there aren't any, then current_alerts = "None"
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("Current alerts: %s.", current_alerts)
# If this is being run non-interactively, only log if debug is enabled
else:
LOGGER.debug("No change in alerts.")

@ -1,7 +1,7 @@
#!/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
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)
# 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
if os.geteuid() != 0:
exit("ERROR: This script must be run as root.")
@ -125,17 +154,10 @@ if not os.path.isfile("SkywarnPlus.py"):
)
exit()
# Prompt for confirmation unless -f flag is present
# Display the warning message
if not args.force:
print("\nThis script will update SkywarnPlus to the latest version.")
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")
display_update_warning()
confirmation = input("\nDo you want to continue with the update? (yes/no) ")
if confirmation.lower() != "yes":
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)
# 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.
# 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:
# 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=1234567890QWERTY&hl=en-us&f=8khz_16bit_mono&v=John&src=Saline County
# Example:
@ -54,6 +55,10 @@ Alerting:
# Enable instant voice announcement when new weather alerts are issued.
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)
SayAlertAll: false

Loading…
Cancel
Save

Powered by TurnKey Linux.