Init v0.4.0

pull/48/head
Mason10198 2 years ago
parent 1f8ced0499
commit 5eb6bd68b7

@ -7,21 +7,23 @@
![Total Downloads](https://img.shields.io/github/downloads/Mason10198/SkywarnPlus/total?label=Downloads&color=f15d24)
![GitHub Repo Size](https://img.shields.io/github/repo-size/Mason10198/SkywarnPlus?label=Size&color=f15d24)
**SkywarnPlus** is an advanced software solution tailored for Asterisk/app_rpt nodes. It is designed to provide important information about local government-issued alerts, thereby broadening the scope and functionality of your node. By intelligently integrating local alert data, SkywarnPlus brings a new layer of relevance and utility to your existing system. **SkywarnPlus** works with all major distributions, including AllstarLink, HAMVOIP, myGMRS, and more.
**SkywarnPlus** is an advanced software solution tailored for Asterisk/app_rpt nodes. It is designed to provide important information about local government-issued alerts in the United States, thereby broadening the scope and functionality of your node. By intelligently integrating local alert data, SkywarnPlus brings a new layer of relevance and utility to your existing system. **SkywarnPlus** works with all major distributions, including AllstarLink, HAMVOIP, myGMRS, and more.
## Key Features
- **Real-Time Weather Alerts:** The software checks the NWS CAP v1.2 API for live weather alerts for user-defined areas.
- **Real-Time Alerts:** The software watches the new NWS v1.2 API for real-time alerts for user-defined areas.
- **Unlimited Area & Node Numbers:** Users can define as many areas and local node numbers as desired.
- **Automatic Announcements:** Alerts, including when all warnings have been cleared, are announced automatically on the node.
- **Automatic Announcements:** Weather alerts, including when all warnings have been cleared, are announced automatically on the node.
- **Human Speech:** Announcements are delivered in a natural, human speech for easier understanding.
- **Unlimited Area & Node Numbers:** Users can define as many areas and local node numbers as desired.
- **Tailmessage Creation:** The software generates tailmessages for the node to continuously inform listeners about active alerts after the initial broadcast.
- **Dynamic Changes to Node:** Courtesy tones and node CW / voice ID automatically change according to user-defined alerts, optimizing communication during severe weather.
- **Human Speech:** Announcements are delivered in a natural, human speech for easier understanding.
- **County Identification:** Dynamically and automatically inform listeners which county or counties an alert is affecting
- **Efficiency & Speed:** SkywarnPlus is optimized for speed and efficiency to provide real-time information without delay.
@ -31,7 +33,7 @@
- **Detailed Alert Descriptions:** In addition to standard alert announcements, SkywarnPlus includes SkyDescribe, a feature for announcing detailed NWS provided descriptions of alert details.
- **Highly Customizable:** SkywarnPlus is extremely customizable, offering advanced filtering parameters to block certain alerts or types of alerts from different functions. Users can even map DTMF macros or shell commands to specified weather alerts, expanding the software's capabilities according to user needs.
- **Highly Customizable:** SkywarnPlus is extremely customizable, offering advanced filtering parameters to block certain alerts or types of alerts from different functions. Users can easily modify the sound effects and audio files used in SkywarnPlus. Users can even map DTMF macros or shell commands to specified weather alerts, infinitely expanding the software's capabilities according to user needs.
- **Pushover Integration:** With Pushover integration, SkywarnPlus can send weather alert notifications directly to your phone or other devices.
@ -194,7 +196,7 @@ tailmessagelist = /tmp/SkywarnPlus/wx-tail
SkywarnPlus has the ability to automatically change the node courtesy tone depending on the state of certain weather alerts. This feature can be configured via the `config.yaml` and `rpt.conf` files.
## Configuration
## Configuration
Here's an explanation of how it works using the default values in the `config.yaml` file:
@ -211,7 +213,6 @@ CourtesyTones:
# Define the sound files for courtesy tones.
Tones:
# Audio file to feed Asterisk as ct1 in "normal" mode
CT1: Boop.ulaw
@ -249,13 +250,15 @@ With this setup, Asterisk will always use `CT1.ulaw` for "local" traffic, and `C
Please note the filenames are case-sensitive, so be sure they match exactly between `rpt.conf` and `config.yaml`.
After initially setting up automatic courtesy tones, the audio files will not refresh until the next time the alert status changes. To refresh immediately, run `/usr/local/bin/SkywarnPlys/SkyControl.py changect normal` to force the CTs to "normal" mode.
# CW / Voice IDs
SkywarnPlus has a feature that allows it to automatically change the node ID based on the status of certain weather alerts. This requires the creation of custom audio files for the `NORMAL` and `WX` ID modes.
The configuration for this is in the `config.yaml` file, with additional setup needed in the `rpt.conf` file. Let's take a look at how it's done.
## Configuration
## Configuration
Here's an example of how the `config.yaml` file should be configured:
@ -272,7 +275,6 @@ IDChange:
# Define the sound files for IDs.
IDs:
# Audio file to feed Asterisk as ID in "normal" mode
NormalID: NORMALID.ulaw
@ -283,7 +285,7 @@ IDChange:
RptID: RPTID.ulaw
```
In this setup, if none of the alerts specified in the IDAlerts list are active, SkywarnPlus replaces the file `RPTID.ulaw` with a duplicate of `NORMALID.ulaw`.
In this setup, if none of the alerts specified in the IDAlerts list are active, SkywarnPlus replaces the file `RPTID.ulaw` with a duplicate of `NORMALID.ulaw`.
However, if any of the alerts in the IDAlerts list are currently active, SkywarnPlus will replace `RPTID.ulaw` with a duplicate of `WXID.ulaw`.
@ -294,11 +296,11 @@ To enable these changes, the following setup is required in your `rpt.conf` file
idrecording = /usr/local/bin/SkywarnPlus/SOUNDS/ID/RPTID
```
In this case, Asterisk will always use `RPTID.ulaw` as the node ID. SkywarnPlus effectively changes the contents of the `RPTID.ulaw` file depending on the weather alert status while Asterisk "isn't looking".
In this case, Asterisk will always use `RPTID.ulaw` as the node ID. SkywarnPlus effectively changes the contents of the `RPTID.ulaw` file depending on the weather alert status while Asterisk "isn't looking".
Note that filenames are case-sensitive, so be sure they match exactly between `rpt.conf` and `config.yaml`.
This is how you can configure SkywarnPlus to automatically change the node ID depending on weather alert conditions. Please don't hesitate to raise a query if you encounter any issues.
After initially setting up automatic IDs, the audio files will not refresh until the next time the alert status changes. To refresh immediately, run `/usr/local/bin/SkywarnPlys/SkyControl.py changeid normal` to force the ID to "normal" mode.
# Pushover Integration
@ -549,9 +551,48 @@ In _most_ cases, any multiple counties that SkywarnPlus is set up to monitor wil
# Customizing the Audio Files
SkywarnPlus comes with a library of audio files that can be replaced with any 8kHz mono PCM16 WAV files you want. These are found in the `SOUNDS/` directory by default, along with `DICTIONARY.txt` which explains audio file assignments.
SkywarnPlus comes with a library of audio files that can be replaced with any 8kHz mono PCM16 WAV files you want. These are found in the `SOUNDS/` directory by default, along with `DICTIONARY.txt` which explains audio file assignments. Several customizations can be easily made in `config.yaml`, but the sound files are always available for you to modify directly as well.
If you'd like to use IDChange or add county identifiers, you must create your own audio files. Follow **[this guide](https://wiki.allstarlink.org/images/d/dd/RecordingSoundFiles.pdf)** on how to record/convert audio files for use with Asterisk/app_rpt.
If you'd like to enable IDChange, you must create your own ID audio files. Follow **[this guide](https://wiki.allstarlink.org/images/d/dd/RecordingSoundFiles.pdf)** on how to create audio files for use with Asterisk/app_rpt.
>**For users wishing to maintain vocal continuity in their SkywarnPlus installation, the original creator of SkywarnPlus (N5LSN) and the woman behind the voice of it's included library of audio recordings (N5LSN XYL) will, for a small fee, record custom audio files for your SkywarnPlus installation. Contact information is readily available via QRZ.**
## County Identifiers
SkywarnPlus features the capability to play county-specific audio files to reference the affected area of alerts. It enhances the user's awareness of the geographic area affected by an event, making the system more informative and valuable to users monitoring systems that provide coverage for multiple counties. By assigning unique audio tags to each county, users can immediately recognize which county is affected by an event as soon as it is detected by SkywarnPlus.
### How to use it?
To use the county-specific audio tags, you need to specify the audio files to use for each county in your `config.yaml` file. You must create or otherwise aquire these audio files yourself. The audio files must be located in the root of the `SkywarnPlus/SOUNDS/` directory.
The `config.yaml` explains how to use the free VoiceRSS API to generate these files using a computer synthesized voice.
Here is an example of how to configure the `config.yaml` to utilize this feature:
```yaml
Alerting:
# Specify the county codes for which you want to pull weather data.
# Find your county codes at https://alerts.weather.gov/.
# Make sure to use county codes ONLY, NOT zone codes, otherwise you might miss out on alerts.
#
# SkywarnPlus allows adding county-specific audio indicators to each alert in the message.
# To enable this feature, specify an audio file containing a recording of the county name in the
# ROOT of the SOUNDS/ directory as shown in the below example. You must create these files yourself.
# You can use the same VoiceRSS API used for SkyDescribe (see below) to generate these files with a synthetic voice:
# http://api.voicerss.org/?key=[YOUR_API_KEY_HERE]=en-us&f=8khz_16bit_mono&v=John&src=[YOUR COUNTY NAME HERE]
# http://api.voicerss.org/?key=1234567890QWERTY&hl=en-us&f=8khz_16bit_mono&v=John&src=Saline County
CountyCodes:
- DCC001: "County1.wav"
- MDC031: "County2.wav"
- MDC033: "County3.wav"
- VAC013: "County4.wav"
- VAC059: "County5.wav"
- VAC510: "County6.wav"
- VAC107: "County7.wav"
- VAC047: "County8.wav"
- MDC510: "County9.wav"
- VAC683: "County10.wav"
```
# Testing
@ -624,7 +665,7 @@ SkywarnPlus is open-sourced software licensed under the [GPL-3.0 license](LICENS
Created by Mason Nelson (N5LSN/WRKF394)
Audio Library voiced by Rachel Nelson
Audio Library voiced by Rachel Nelson (N5LSN/WRKF394 XYL)
Skywarn® and the Skywarn® logo are registered trademarks of the National
Oceanic and Atmospheric Administration, used with permission.
Oceanic and Atmospheric Administration, used with permission.

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

@ -1,7 +1,7 @@
#!/usr/bin/python3
"""
SkyDescribe v0.3.5 by Mason Nelson
SkyDescribe v0.4.0 by Mason Nelson
==================================================
Text to Speech conversion for Weather Descriptions
@ -37,75 +37,71 @@ from ruamel.yaml import YAML
from collections import OrderedDict
# Use ruamel.yaml instead of PyYAML
yaml = YAML()
YAML = YAML()
# Directories and Paths
baseDir = os.path.dirname(os.path.realpath(__file__))
configPath = os.path.join(baseDir, "config.yaml")
BASE_DIR = os.path.dirname(os.path.realpath(__file__))
CONFIG_PATH = os.path.join(BASE_DIR, "config.yaml")
# Open and read configuration file
with open(configPath, "r") as config_file:
config = yaml.load(config_file)
with open(CONFIG_PATH, "r") as config_file:
config = YAML.load(config_file)
# Define tmp_dir
tmp_dir = config.get("DEV", []).get("TmpDir", "/tmp/SkywarnPlus")
TMP_DIR = config.get("DEV", []).get("TmpDir", "/tmp/SkywarnPlus")
# Define VoiceRSS settings
# get api key, fellback 150
api_key = config.get("SkyDescribe", []).get("APIKey", "")
language = config.get("SkyDescribe", []).get("Language", "en-us")
speed = config.get("SkyDescribe", []).get("Speed", 0)
voice = config.get("SkyDescribe", []).get("Voice", "John")
max_words = config.get("SkyDescribe", []).get("MaxWords", 150)
API_KEY = config.get("SkyDescribe", []).get("APIKey", "")
LANGUAGE = config.get("SkyDescribe", []).get("Language", "en-us")
SPEED = config.get("SkyDescribe", []).get("Speed", 0)
VOICE = config.get("SkyDescribe", []).get("Voice", "John")
MAX_WORDS = config.get("SkyDescribe", []).get("MaxWords", 150)
# Path to the data file
data_file = os.path.join(tmp_dir, "data.json")
DATA_FILE = os.path.join(TMP_DIR, "data.json")
# Define logger
logger = logging.getLogger(__name__)
LOGGER = logging.getLogger(__name__)
if config.get("Logging", []).get("Debug", False):
logger.setLevel(logging.DEBUG)
LOGGER.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
LOGGER.setLevel(logging.INFO)
# Define formatter
formatter = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
FORMATTER = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
# Define and attach console handler
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
ch.setFormatter(formatter)
logger.addHandler(ch)
CH = logging.StreamHandler()
CH.setLevel(logging.DEBUG)
CH.setFormatter(FORMATTER)
LOGGER.addHandler(CH)
# Define and attach file handler
log_path = os.path.join(tmp_dir, "SkyDescribe.log")
fh = logging.FileHandler(log_path)
fh.setLevel(logging.DEBUG)
fh.setFormatter(formatter)
logger.addHandler(fh)
if not api_key:
logger.error("SkyDescribe: No VoiceRSS API key found in config.yaml")
LOG_PATH = os.path.join(TMP_DIR, "SkyDescribe.log")
FH = logging.FileHandler(LOG_PATH)
FH.setLevel(logging.DEBUG)
FH.setFormatter(FORMATTER)
LOGGER.addHandler(FH)
if not API_KEY:
LOGGER.error("SkyDescribe: No VoiceRSS API key found in config.yaml")
sys.exit(1)
def load_state():
"""
Load the state from the state file if it exists, else return an initial state.
Returns:
OrderedDict: A dictionary containing data.
"""
if os.path.exists(data_file):
with open(data_file, "r") as file:
if os.path.exists(DATA_FILE):
with open(DATA_FILE, "r") as file:
state = json.load(file)
state["alertscript_alerts"] = state.get("alertscript_alerts", [])
# Process 'last_alerts' and maintain the order of alerts
last_alerts = state.get("last_alerts", [])
last_alerts = [
(tuple(x[0]), x[1]) if isinstance(x[0], list) else x
for x in last_alerts
]
state["last_alerts"] = OrderedDict(last_alerts)
state["last_alerts"] = OrderedDict((x[0], x[1]) for x in last_alerts)
state["last_sayalert"] = state.get("last_sayalert", [])
state["active_alerts"] = state.get("active_alerts", [])
return state
@ -222,10 +218,10 @@ def modify_description(description):
# Limit the description to a maximum number of words
words = description.split()
logger.debug("SkyDescribe: Description has %d words.", len(words))
if len(words) > max_words:
description = " ".join(words[:max_words])
logger.info("SkyDescribe: Description has been limited to %d words.", max_words)
LOGGER.debug("SkyDescribe: Description has %d words.", len(words))
if len(words) > MAX_WORDS:
description = " ".join(words[:MAX_WORDS])
LOGGER.info("SkyDescribe: Description has been limited to %d words.", MAX_WORDS)
return description
@ -244,23 +240,28 @@ def convert_to_audio(api_key, text):
base_url = "http://api.voicerss.org/"
params = {
"key": api_key,
"hl": str(language),
"hl": str(LANGUAGE),
"src": text,
"c": "WAV",
"f": "8khz_16bit_mono",
"r": str(speed),
"v": str(voice),
"r": str(SPEED),
"v": str(VOICE),
}
logger.debug(
LOGGER.debug(
"SkyDescribe: Voice RSS API URL: %s",
base_url + "?" + urllib.parse.urlencode(params),
)
response = requests.get(base_url, params=params)
response.raise_for_status()
# if responce text contains "ERROR" then log it and exit
if "ERROR" in response.text:
LOGGER.error("SkyDescribe: %s", response.text)
sys.exit(1)
audio_file_path = os.path.join(tmp_dir, "describe.wav")
audio_file_path = os.path.join(TMP_DIR, "describe.wav")
LOGGER.debug("SkyDescribe: Saving audio file to %s", audio_file_path)
with open(audio_file_path, "wb") as file:
file.write(response.content)
return audio_file_path
@ -270,65 +271,70 @@ def main(index_or_title):
state = load_state()
alerts = list(state["last_alerts"].items())
# list the alerts in order as a numbered list
LOGGER.debug("SkyDescribe: List of alerts:")
for i, alert in enumerate(alerts):
LOGGER.debug("SkyDescribe: %d. %s", i + 1, alert[0])
# Determine if the argument is an index or a title
if str(index_or_title).isdigit():
index = int(index_or_title) - 1
if index >= len(alerts):
logger.error("SkyDescribe: No alert found at index %d.", index + 1)
LOGGER.error("SkyDescribe: No alert found at index %d.", index + 1)
description = "Sky Describe error, no alert found at index {}.".format(
index + 1
)
else:
alert, alert_data = alerts[index]
(_, description, _) = alert_data
description = alert_data[0]["description"]
else:
# Argument is not an index, assume it's a title
title = index_or_title
for alert, alert_data in alerts:
if alert == title: # Assuming alert is a title
_, description, _ = alert_data
description = alert_data[0]["description"]
break
else:
logger.error("SkyDescribe: No alert with title %s found.", title)
LOGGER.error("SkyDescribe: No alert with title %s found.", title)
description = "Sky Describe error, no alert found with title {}.".format(
title
)
logger.debug("\n\nSkyDescribe: Original description: %s", description)
LOGGER.debug("\n\nSkyDescribe: Original description: %s", description)
# If the description is not an error message, extract the alert title
if not "Sky Describe error" in description:
alert_title = alert # As alert itself is the title now
logger.info("SkyDescribe: Generating description for alert: %s", alert_title)
LOGGER.info("SkyDescribe: Generating description for alert: %s", alert_title)
# Add the alert title at the beginning
description = "Detailed alert information for {}. {}".format(
alert_title, description
)
description = modify_description(description)
logger.debug("\n\nSkyDescribe: Modified description: %s\n\n", description)
LOGGER.debug("\n\nSkyDescribe: Modified description: %s\n\n", description)
audio_file = convert_to_audio(api_key, description)
audio_file = convert_to_audio(API_KEY, description)
with contextlib.closing(wave.open(audio_file, "r")) as f:
frames = f.getnframes()
rate = f.getframerate()
duration = frames / float(rate)
logger.debug("SkyDescribe: Length of the audio file in seconds: %s", duration)
LOGGER.debug("SkyDescribe: Length of the audio file in seconds: %s", duration)
nodes = config["Asterisk"]["Nodes"]
for node in nodes:
logger.info("SkyDescribe: Broadcasting description on node %s.", node)
LOGGER.info("SkyDescribe: Broadcasting description on node %s.", node)
command = "/usr/sbin/asterisk -rx 'rpt localplay {} {}'".format(
node, audio_file.rsplit(".", 1)[0]
)
logger.debug("SkyDescribe: Running command: %s", command)
LOGGER.debug("SkyDescribe: Running command: %s", command)
subprocess.run(command, shell=True)
# Script entry point
if __name__ == "__main__":
if len(sys.argv) != 2:
logger.error("Usage: SkyDescribe.py <alert index or title>")
LOGGER.error("Usage: SkyDescribe.py <alert index or title>")
sys.exit(1)
main(sys.argv[1])

@ -1,7 +1,7 @@
#!/usr/bin/python3
"""
SkywarnPlus v0.3.5 by Mason Nelson
SkywarnPlus v0.4.0 by Mason Nelson
===============================================================================
SkywarnPlus is a utility that retrieves severe weather alerts from the National
Weather Service and integrates these alerts with an Asterisk/app_rpt based
@ -70,9 +70,6 @@ SOUNDS_PATH = config.get("Alerting", {}).get(
"SoundsPath", os.path.join(BASE_DIR, "SOUNDS")
)
# Define countyCodes
COUNTY_CODES = config.get("Alerting", {}).get("CountyCodes", [])
# Create tmp_dir if it doesn't exist
if TMP_DIR:
os.makedirs(TMP_DIR, exist_ok=True)
@ -278,6 +275,42 @@ F_HANDLER = logging.FileHandler(LOG_FILE)
F_HANDLER.setFormatter(LOG_FORMATTER)
LOGGER.addHandler(F_HANDLER)
# Get the "CountyCodes" from the config
COUNTY_CODES_CONFIG = config.get("Alerting", {}).get("CountyCodes", [])
# Log the obtained COUNTY_CODES_CONFIG for debugging
LOGGER.debug(f"COUNTY_CODES_CONFIG: {COUNTY_CODES_CONFIG}")
# Initialize COUNTY_CODES and COUNTY_WAVS
COUNTY_CODES = []
COUNTY_WAVS = []
# Check the type of "CountyCodes" config to handle both list and dictionary
if isinstance(COUNTY_CODES_CONFIG, list):
# If it's a list, check if it's a list of strings or dictionaries
if all(isinstance(i, str) for i in COUNTY_CODES_CONFIG):
# It's the old format and we can use it directly
COUNTY_CODES = COUNTY_CODES_CONFIG
elif all(isinstance(i, dict) for i in COUNTY_CODES_CONFIG):
# It's a list of dictionaries with WAV files
# We need to separate the county codes and the WAVs
for dic in COUNTY_CODES_CONFIG:
for key, value in dic.items():
COUNTY_CODES.append(key)
COUNTY_WAVS.append(value)
elif isinstance(COUNTY_CODES_CONFIG, dict):
# If it's a dictionary, it's got WAV files
# We need to separate the county codes and the WAVs
COUNTY_CODES = list(COUNTY_CODES_CONFIG.keys())
COUNTY_WAVS = list(COUNTY_CODES_CONFIG.values())
else:
# Invalid format, set it to an empty list
COUNTY_CODES = []
COUNTY_WAVS = []
# Log the final COUNTY_CODES and COUNTY_WAVS for debugging
LOGGER.debug(f"COUNTY_CODES: {COUNTY_CODES}, COUNTY_WAVS: {COUNTY_WAVS}")
# Log some debugging information
LOGGER.debug("Base directory: %s", BASE_DIR)
LOGGER.debug("Temporary directory: %s", TMP_DIR)
@ -296,12 +329,11 @@ def load_state():
with open(DATA_FILE, "r") as file:
state = json.load(file)
state["alertscript_alerts"] = state.get("alertscript_alerts", [])
# Process 'last_alerts' and maintain the order of alerts
last_alerts = state.get("last_alerts", [])
last_alerts = [
(tuple(x[0]), x[1]) if isinstance(x[0], list) else x
for x in last_alerts
]
state["last_alerts"] = OrderedDict(last_alerts)
state["last_alerts"] = OrderedDict((x[0], x[1]) for x in last_alerts)
state["last_sayalert"] = state.get("last_sayalert", [])
state["active_alerts"] = state.get("active_alerts", [])
return state
@ -366,11 +398,16 @@ def get_alerts(countyCodes):
severity = severity_mapping_words.get(last_word, 0)
description = "This alert was manually injected as a test."
end_time_utc = current_time + timedelta(hours=1)
alerts[(event)] = (
severity,
description,
end_time_utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
)
county_data = [
{
"county_code": county_code,
"severity": severity,
"description": description,
"end_time_utc": end_time_utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
}
for county_code in ["DCC001", "MDC033"]
] # Create a list of dictionaries
alerts[event] = county_data # Add the list of dictionaries to the alert
# We limit the number of alerts to the maximum defined constant.
alerts = OrderedDict(list(alerts.items())[:MAX_ALERTS])
@ -399,6 +436,10 @@ def get_alerts(countyCodes):
# Loop over each county code and retrieve alerts from the API.
for countyCode in countyCodes:
url = "https://api.weather.gov/alerts/active?zone={}".format(countyCode)
#
# WARNING: ONLY USE THIS FOR DEVELOPMENT PURPOSES
# THIS URL WILL RETURN ALL ACTIVE ALERTS IN THE UNITED STATES
# url = "https://api.weather.gov/alerts/active"
try:
# If we can get a successful response from the API, we process the alerts from the response.
response = requests.get(url)
@ -434,12 +475,7 @@ def get_alerts(countyCodes):
description = feature["properties"].get("description", "")
severity = feature["properties"].get("severity", None)
# If the alert has already been processed, we skip it.
if event in seen_alerts:
continue
# We check if the event is in a list of globally blocked events.
# If it is, we skip it.
# Check if the event is globally blocked as per the configuration. If it is, skip this event.
is_blocked = False
for global_blocked_event in GLOBAL_BLOCKED_EVENTS:
if fnmatch.fnmatch(event, global_blocked_event):
@ -449,6 +485,7 @@ def get_alerts(countyCodes):
)
is_blocked = True
break
# If the event is globally blocked, we skip the remaining code in this loop iteration and move to the next one.
if is_blocked:
continue
@ -459,13 +496,41 @@ def get_alerts(countyCodes):
else:
severity = severity_mapping_api.get(severity, 0)
# Add the alert to the 'alerts' dictionary and add the event name to 'seen_alerts'.
alerts[(event)] = (
# Log the alerts and their severity level for debugging purposes.
LOGGER.debug(
"getAlerts: %s - %s - Severity: %s",
countyCode,
event,
severity,
description,
end_time_utc.strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
)
seen_alerts.add(event)
# Check if the event has already been processed (seen).
# If it has been seen, we add a new dictionary to its list of alerts. This dictionary contains details about the alert.
if event in seen_alerts:
alerts[event].append(
{
"county_code": countyCode, # the county code the alert is for
"severity": severity, # the severity level of the alert
"description": description, # a description of the alert
"end_time_utc": end_time_utc.strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
), # the time the alert ends in UTC
}
)
# If the event hasn't been seen before, we create a new list entry in the 'alerts' dictionary for this event.
else:
alerts[event] = [
{
"county_code": countyCode, # the county code the alert is for
"severity": severity, # the severity level of the alert
"description": description, # a description of the alert
"end_time_utc": end_time_utc.strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
), # the time the alert ends in UTC
}
]
# Add the event to the set of seen alerts.
seen_alerts.add(event)
# If the current time is not within the alert's active period, we skip it.
else:
@ -481,22 +546,66 @@ def get_alerts(countyCodes):
start_time_utc,
end_time_utc,
)
else:
LOGGER.debug(
"getAlerts: Skipping %s, missing start or end time.",
feature["properties"]["event"],
)
# If we cannot get a successful response from the API, we load stored alert data instead.
except requests.exceptions.RequestException as e:
LOGGER.error("Failed to retrieve alerts for %s. Reason: %s", countyCode, e)
LOGGER.info("API unreachable. Using stored data instead.")
LOGGER.debug("Failed to retrieve alerts for %s. Reason: %s", countyCode, e)
LOGGER.debug("API unreachable. Using stored data instead.")
# Load alerts from data.json
if os.path.isfile(DATA_FILE):
with open(DATA_FILE) as f:
data = json.load(f)
alerts = data.get("alerts", {})
stored_alerts = data.get("last_alerts", [])
# If the number of alerts exceeds the maximum defined constant, we truncate the alerts.
alerts = OrderedDict(list(alerts.items())[:MAX_ALERTS])
# Filter alerts by end_time_utc
current_time_str = datetime.now(timezone.utc).strftime(
"%Y-%m-%dT%H:%M:%S.%fZ"
)
LOGGER.debug("Current time: %s", current_time_str)
alerts = {}
for stored_alert in stored_alerts:
event = stored_alert[0]
alert_list = stored_alert[1]
alerts[event] = []
for alert in alert_list:
end_time_str = alert["end_time_utc"]
if parser.parse(end_time_str) >= parser.parse(
current_time_str
):
LOGGER.debug(
"getAlerts: Keeping %s because it does not expire until %s",
event,
end_time_str,
)
alerts[event].append(alert)
else:
LOGGER.debug(
"getAlerts: Removing %s because it expired at %s",
event,
end_time_str,
)
else:
LOGGER.error("No stored data available.")
break
alerts = OrderedDict(
sorted(
alerts.items(),
key=lambda item: (
max([x["severity"] for x in item[1]]), # Max Severity
severity_mapping_words.get(item[0].split()[-1], 0), # Words severity
),
reverse=True,
)
)
# We store the alerts in a file for future use if the API is unreachable.
with open(DATA_FILE, "w") as f:
json.dump({"alerts": dict(alerts)}, f)
# If the number of alerts exceeds the maximum defined constant, we truncate the alerts.
alerts = OrderedDict(list(alerts.items())[:MAX_ALERTS])
return alerts
@ -538,9 +647,7 @@ def say_alerts(alerts):
# Check if the filtered alerts are the same as the last run
if filtered_alerts == state["last_sayalert"]:
LOGGER.debug(
"sayAlert: The filtered alerts are the same as the last run. Not broadcasting."
)
LOGGER.debug("sayAlert: alerts are the same as the last broadcast - skipping.")
return
state["last_sayalert"] = filtered_alerts
@ -564,7 +671,7 @@ def say_alerts(alerts):
SOUNDS_PATH,
"ALERTS",
"EFFECTS",
config.get("Alerting", {}).get("AlertSound", "StartrekWhistle.wav"),
config.get("Alerting", {}).get("AlertSound", "Duncecap.wav.wav"),
)
)
@ -575,29 +682,58 @@ def say_alerts(alerts):
)
alert_count = 0
for alert in filtered_alerts:
try:
index = ALERT_STRINGS.index(alert)
audio_file = AudioSegment.from_wav(
os.path.join(
SOUNDS_PATH, "ALERTS", "SWP_{}.wav".format(ALERT_INDEXES[index])
for (
alert,
counties,
) in alerts.items(): # Now we loop over both alert name and its associated counties
if alert in filtered_alerts:
try:
index = ALERT_STRINGS.index(alert)
audio_file = AudioSegment.from_wav(
os.path.join(
SOUNDS_PATH, "ALERTS", "SWP_{}.wav".format(ALERT_INDEXES[index])
)
)
combined_sound += sound_effect + audio_file
LOGGER.debug(
"sayAlert: Added %s (SWP_%s.wav) to alert sound",
alert,
ALERT_INDEXES[index],
)
alert_count += 1
# Add county names if they exist
for county in counties:
# if its the first county, word_space is 600ms of silence. else it is 400ms
if counties.index(county) == 0:
word_space = AudioSegment.silent(duration=600)
else:
word_space = AudioSegment.silent(duration=400)
county_code = county["county_code"]
if COUNTY_WAVS and county_code in COUNTY_CODES:
index = COUNTY_CODES.index(county_code)
county_name_file = COUNTY_WAVS[index]
LOGGER.debug(
"sayAlert: Adding %s ID %s to %s",
county_code,
county_name_file,
alert,
)
combined_sound += word_space + AudioSegment.from_wav(
os.path.join(SOUNDS_PATH, "ALERTS", county_name_file)
)
# if this is the last county name, add 600ms of silence after the county name
if counties.index(county) == len(counties) - 1:
combined_sound += AudioSegment.silent(duration=600)
except ValueError:
LOGGER.error("sayAlert: Alert not found: %s", alert)
except FileNotFoundError:
LOGGER.error(
"sayAlert: Audio file not found: %s/ALERTS/SWP_%s.wav",
SOUNDS_PATH,
ALERT_INDEXES[index],
)
)
combined_sound += sound_effect + audio_file
LOGGER.debug(
"sayAlert: Added %s (SWP_%s.wav) to alert sound",
alert,
ALERT_INDEXES[index],
)
alert_count += 1
except ValueError:
LOGGER.error("sayAlert: Alert not found: %s", alert)
except FileNotFoundError:
LOGGER.error(
"sayAlert: Audio file not found: %s/ALERTS/SWP_%s.wav",
SOUNDS_PATH,
ALERT_INDEXES[index],
)
if alert_count == 0:
LOGGER.debug("sayAlert: All alerts were blocked, not broadcasting any alerts.")
@ -605,7 +741,7 @@ def say_alerts(alerts):
alert_suffix = config.get("Alerting", {}).get("SayAlertSuffix", None)
if alert_suffix is not None:
suffix_silence = AudioSegment.silent(duration=1000)
suffix_silence = AudioSegment.silent(duration=600)
LOGGER.debug("sayAlert: Adding alert suffix %s", alert_suffix)
suffix_file = os.path.join(SOUNDS_PATH, alert_suffix)
suffix_sound = AudioSegment.from_wav(suffix_file)
@ -685,6 +821,15 @@ def say_allclear():
converted_combined_sound = convert_audio(combined_sound)
converted_combined_sound.export(all_clear_file, format="wav")
if config.get("Alerting", {}).get("SayAllClearSuffix", None) is not None:
suffix_file = os.path.join(
SOUNDS_PATH, config.get("Alerting", {}).get("SayAllClearSuffix")
)
LOGGER.debug("sayAllClear: Adding all clear suffix %s", suffix_file)
suffix_sound = AudioSegment.from_wav(suffix_file)
converted_suffix_sound = convert_audio(suffix_sound)
converted_suffix_sound.export(all_clear_file, format="wav")
node_numbers = config.get("Asterisk", {}).get("Nodes", [])
for node_number in node_numbers:
LOGGER.info("Broadcasting all clear message on node %s", node_number)
@ -700,6 +845,9 @@ def build_tailmessage(alerts):
transmission to update on the weather conditions.
"""
# Determine whether the user has enabled county identifiers
county_identifiers = config.get("Tailmessage", {}).get("TailmessageCounties", False)
# Extract only the alert names from the OrderedDict keys
alert_names = [alert for alert in alerts.keys()]
@ -723,7 +871,10 @@ def build_tailmessage(alerts):
)
)
for alert in alert_names:
for (
alert,
counties,
) in alerts.items(): # Now we loop over both alert name and its associated counties
if any(
fnmatch.fnmatch(alert, blocked_event)
for blocked_event in TAILMESSAGE_BLOCKED_EVENTS
@ -746,6 +897,32 @@ def build_tailmessage(alerts):
alert,
ALERT_INDEXES[index],
)
# Add county names if they exist
if county_identifiers:
for county in counties:
# if its the first county, word_space is 600ms of silence. else it is 400ms
if counties.index(county) == 0:
word_space = AudioSegment.silent(duration=600)
else:
word_space = AudioSegment.silent(duration=400)
county_code = county["county_code"]
if COUNTY_WAVS and county_code in COUNTY_CODES:
index = COUNTY_CODES.index(county_code)
county_name_file = COUNTY_WAVS[index]
LOGGER.debug(
"buildTailMessage: Adding %s ID %s to %s",
county_code,
county_name_file,
alert,
)
combined_sound += word_space + AudioSegment.from_wav(
os.path.join(SOUNDS_PATH, "ALERTS", county_name_file)
)
# if this is the last county name, add 600ms of silence after the county name
if counties.index(county) == len(counties) - 1:
combined_sound += AudioSegment.silent(duration=600)
except ValueError:
LOGGER.error("Alert not found: %s", alert)
except FileNotFoundError:
@ -1188,7 +1365,10 @@ def main():
say_allclear()
else:
if say_alert_enabled:
say_alerts(alerts)
if config["Alerting"].get("SayAlertAll", False):
say_alerts(alerts)
else:
say_alerts({alert: alerts[alert] for alert in added_alerts})
# Check if tailmessage needs to be built
enable_tailmessage = config.get("Tailmessage", {}).get("Enable", False)

@ -1,7 +1,7 @@
#!/usr/bin/python3
"""
SkywarnPlus Updater v0.3.5 by Mason Nelson
SkywarnPlus Updater v0.4.0 by Mason Nelson
===============================================================================
Script to update SkywarnPlus to the latest version. This script will download
the latest version of SkywarnPlus from GitHub, and then merge the existing

@ -1,4 +1,4 @@
# SkywarnPlus v0.3.5 Configuration File
# SkywarnPlus v0.4.0 Configuration File
# Author: Mason Nelson (N5LSN/WRKF394)
# Please edit this file according to your specific requirements.
@ -35,14 +35,28 @@ Alerting:
# Make sure to use county codes ONLY, NOT zone codes, otherwise you might miss out on alerts.
# Example:
# CountyCodes:
# - ARC121
# - ARC021
# - ARC125
# - ARC119
#
# SkywarnPlus allows adding county-specific audio indicators to each alert in the message.
# To enable this feature, specify an audio file containing a recording of the county name in the
# ROOT of the SOUNDS/ directory as shown in the below example. You must create these files yourself.
# You can use the same VoiceRSS API used for SkyDescribe (see below) to generate these files with a synthetic voice:
# http://api.voicerss.org/?key=[YOUR_API_KEY_HERE]=en-us&f=8khz_16bit_mono&v=John&src=[YOUR COUNTY NAME HERE]
# http://api.voicerss.org/?key=1234567890QWERTY&hl=en-us&f=8khz_16bit_mono&v=John&src=Saline County
# Example:
# CountyCodes:
# - ARC125: "Saline.wav"
# - ARC119: "Pulaski.wav"
CountyCodes:
- YOUR_COUNTY_CODE
# Enable instant voice announcement when new weather alerts are issued.
SayAlert: true
# When a change is detected, make SayAlert say ALL of the currently active alerts, not just newly detected one(s)
SayAlertAll: false
# Specify the WAV file in the SOUNDS/ALERTS directory to use as the alert sound effect
AlertSound: Duncecap.wav
@ -54,6 +68,9 @@ Alerting:
# Specify the WAV file in the SOUNDS/ALERTS directory to use as the all clear sound effect.
AllClearSound: Triangles.wav
# Specify a WAV file in the root of the SOUNDS directory to be appended to the end of the all clear message.
SayAllClearSuffix:
# Specify the WAV file in the SOUNDS/ALERTS directory to use as the alert seperator sound effect
AlertSeperator: Woodblock.wav
@ -109,11 +126,16 @@ Tailmessage:
# Configuration for the tail message functionality. Requires initial setup in RPT.CONF.
# Enable/disable automatic tail message.
Enable: false
Enable: true
# Specify a WAV file in the root of the SOUNDS directory to be appended to the end of the tail message.
TailmessageSuffix:
# Enable to add county indicators to the tail message
# County indicators must FIRST be configured in the Alerting section where county codes are defined.
# NOTE: This can make your tail message quite long depending on how many counties you have configured.
TailmessageCounties: false
# Specify an alternative path and filename for saving the tail message.
# Default is /tmp/SkywarnPlus/wx-tail.wav.
TailmessagePath: /tmp/SkywarnPlus/wx-tail.wav
@ -392,4 +414,4 @@ DEV:
INJECTALERTS:
- Tornado Warning
- Tornado Watch
- Severe Thunderstorm Warning
- Severe Thunderstorm Warning
Loading…
Cancel
Save

Powered by TurnKey Linux.