Initial commit

pull/1/head
Mason10198 3 years ago
parent 6185c026c5
commit e8b7fb92fb

@ -0,0 +1,84 @@
#!/bin/bash
# CONTROL.sh
# A Control Script for SkywarnPlus
#
# This script allows you to change the value of specific keys in the SkywarnPlus config.ini file.
# It's designed to enable or disable certain features of SkywarnPlus from the command line.
# It is case-insensitive, accepting both upper and lower case parameters.
#
# Usage: ./CONTROL.sh <key> <value>
# Example: ./CONTROL.sh sayalert false
# This will set 'SayAlert' to 'False' in the config.ini file.
#
# Supported keys:
# - enable: Enable or disable SkywarnPlus entirely. (Section: SKYWARNPLUS)
# - sayalert: Enable or disable instant alerting when weather alerts change. (Section: Alerting)
# - sayallclear: Enable or disable instant alerting when weather alerts are cleared. (Section: Alerting)
# - tailmessage: Enable or disable building of tail message. (Section: Tailmessage)
# - courtesytone: Enable or disable automatic courtesy tones. (Section: CourtesyTones)
#
# All changes will be made in the config.ini file located in the same directory as the script.
# First, we need to check if the correct number of arguments are passed
if [ "$#" -ne 2 ]; then
echo "Incorrect number of arguments. Please provide the key and the new value."
echo "Usage: $0 <key> <value>"
exit 1
fi
# Get the directory of the script
SCRIPT_DIR=$(dirname $(readlink -f $0))
# Convert the input key into lowercase
KEY=$(echo "$1" | tr '[:upper:]' '[:lower:]')
# Convert the first character of the value to uppercase
VALUE=$(echo "$2" | awk '{for(i=1;i<=NF;i++)sub(/./,toupper(substr($i,1,1)),$i)}1')
# Make sure the provided value is either 'True' or 'False'
if [[ "${VALUE^^}" != "TRUE" && "${VALUE^^}" != "FALSE" ]]; then
echo "Invalid value. Please provide either 'true' or 'false'."
exit 1
fi
# Define the command-line arguments and their corresponding keys in the configuration file
declare -A ARGUMENTS=( ["enable"]="Enable" ["sayalert"]="SayAlert" ["sayallclear"]="SayAllClear" ["tailmessage"]="Enable" ["courtesytone"]="Enable")
# Define the sections in the configuration file that each key belongs to
declare -A SECTIONS=( ["enable"]="SKYWARNPLUS" ["sayalert"]="Alerting" ["sayallclear"]="Alerting" ["tailmessage"]="Tailmessage" ["courtesytone"]="CourtesyTones")
# Define the audio files associated with each key
declare -A AUDIO_FILES_ENABLED=( ["enable"]="SWP85.wav" ["sayalert"]="SWP87.wav" ["sayallclear"]="SWP89.wav" ["tailmessage"]="SWP91.wav" ["courtesytone"]="SWP93.wav")
declare -A AUDIO_FILES_DISABLED=( ["enable"]="SWP86.wav" ["sayalert"]="SWP88.wav" ["sayallclear"]="SWP90.wav" ["tailmessage"]="SWP92.wav" ["courtesytone"]="SWP94.wav")
# Read the node number and path to SOUNDS directory from the config.ini
NODES=$(awk -F " = " '/^Nodes/ {print $2}' "${SCRIPT_DIR}/config.ini" | tr -d ' ' | tr ',' '\n')
# Check if the input key is valid
if [[ ${ARGUMENTS[$KEY]+_} ]]; then
# Get the corresponding key in the configuration file
CONFIG_KEY=${ARGUMENTS[$KEY]}
# Get the section that the key belongs to
SECTION=${SECTIONS[$KEY]}
# Update the value of the key in the configuration file
sed -i "/^\[${SECTION}\]/,/^\[/{s/^${CONFIG_KEY} = .*/${CONFIG_KEY} = ${VALUE}/}" "${SCRIPT_DIR}/config.ini"
# Get the correct audio file based on the new value
if [ "$VALUE" = "True" ]; then
AUDIO_FILE=${AUDIO_FILES_ENABLED[$KEY]}
else
AUDIO_FILE=${AUDIO_FILES_DISABLED[$KEY]}
fi
# Play the corresponding audio message on all nodes
for NODE in $NODES; do
/usr/sbin/asterisk -rx "rpt localplay ${NODE} ${SCRIPT_DIR}/SOUNDS/ALERTS/${AUDIO_FILE%.*}"
done
else
echo "The provided key does not match any configurable item."
exit 1
fi

@ -0,0 +1,244 @@
# SkywarnPlus
SkywarnPlus is an optimized, powerful weather alert system designed for Asterisk/app_rpt repeater controller systems such as [AllStarLink](https://allstarlink.org/) and ~~[HAMVOIP](https://hamvoip.org/)~~ (in progress). It's written in Python and utilizes the new [NWS CAP v1.2 JSON API](https://www.weather.gov/documentation/services-web-api). SkywarnPlus is optimized to be resource-efficient and offers customization options to suit various user needs.
## Features
* **Human Speech Alerts**: Provides a library of recorded human speech for clearer, more understandable alerts.
* **Performance**: Designed for minimal impact on internet bandwidth and storage, reducing unnecessary I/O operations.
* **Alert Coverage**: Allows specifying multiple counties or zones for alerts, ensuring broad coverage.
* **Alert Filtering**: Provides advanced options to block or filter alerts using regular expressions and wildcards.
* **Courtesy Tone Changes**: Changes repeater courtesy tones based on active alerts.
* **Duplicate Alert Removal**: Ensures you receive unique and relevant alerts by automatically removing duplicates.
* **Selective Broadcasting**: Broadcasts alerts on weather conditions' onset or dissipation.
* **Tailmessage Management**: Provides unobtrusive alerting if alert broadcasting is disabled.
* **Pushover Integration**: Sends alerts and debug messages directly to your phone.
* **Multiple Local Nodes**: Supports alert distribution to as many local node numbers as desired.
* **Developer Options**: Provides a testing environment to inject manually defined alerts for testing how the system functions.
## How It Works
SkywarnPlus is a Python-based weather alert system for Asterisk/app_rpt repeater controller systems, leveraging the National Weather Service's (NWS) CAP v1.2 JSON API. The system follows several key steps to deliver timely and accurate weather alerts:
1. **Data Fetching**: The system performs regular API calls to the NWS CAP v1.2 API, which provides comprehensive, real-time data on the latest weather conditions and alerts. The frequency of these calls can be adjusted according to user needs.
2. **Data Parsing**: Upon receiving the API response, SkywarnPlus parses the JSON data to extract the information pertinent to weather alerts. This involves reading the structured JSON data and converting it into an internal format for further processing.
3. **Data Filtering**: The extracted data is then filtered based on user-defined criteria set in the configuration file. This includes narrowing down the information to specific counties or zones of interest, as well as excluding certain types of alerts. The filtering mechanism supports regular expressions and wildcards for more sophisticated filtering rules.
4. **Alert Management**: SkywarnPlus manages the filtered alerts intelligently, ensuring that each alert is unique and relevant. Duplicate alerts are automatically removed from the pool of active alerts to prevent repetition and alert fatigue.
5. **Alert Broadcasting**: The system then broadcasts the alerts according to user-defined settings. You can customize these settings to broadcast alerts when new weather conditions are detected or when existing conditions dissipate. This ensures timely communication of weather changes.
6. **Tailmessage and Courtesy Tones**: In addition to broadcasting alerts, SkywarnPlus also automatically updates tailmessages and changes the repeater courtesy tones when specific alerts are active. These changes add a level of customization and context-awareness to the alert system and can be tailored to individual preferences.
7. **Pushover Integration**: SkywarnPlus integrates with Pushover, a mobile notification service, to send alerts and debug messages directly to your phone. This provides a direct and immediate communication channel, keeping you constantly updated on the latest weather conditions.
8. **Real Human Speech**: To enhance clarity and improve user experience, SkywarnPlus uses a library of real female human speech recordings for alerts. This creates a more natural listening experience compared to synthetic speech and aids in clear communication of alert messages.
9. **Maintenance and Resource Management**: Designed with efficiency in mind, SkywarnPlus minimizes its impact on internet bandwidth and physical storage. The system conducts its operations mindful of resource usage, making it particularly suitable for devices with limited resources, such as Raspberry Pi.
This combination of steps ensures SkywarnPlus provides reliable, timely, and accurate weather alerts, while respecting your system's resources and providing extensive customization options.
# Installation
SkywarnPlus is recommended to be installed at the `/usr/local/bin/SkywarnPlus` location on a Debian (AllStarLink) machine. **HAMVOIP is not yet supported due to Python3.5 issues, but is actively being worked on.**
Follow the steps below to install:
1. **Dependencies**
Install the required dependencies using the following commands:
**Debian**
```bash
apt install python3 python3-pip ffmpeg
pip3 install requests python-dateutil pydub
```
<!--
**Arch**
```bash
sudo pacman -S python python-pip ffmpeg
pip install requests python-dateutil pydub
```
-->
2. **Clone the Repository**
Clone the SkywarnPlus repository from GitHub to the `/usr/local/bin` directory:
```bash
cd /usr/local/bin
git clone https://github.com/mason10198/SkywarnPlus.git
```
3. **Configure CONTROL.sh Permissions**
The CONTROL.sh script must be made executable. Use the chmod command to change the file permissions:
```bash
sudo chmod +x /usr/local/bin/SkywarnPlus/CONTROL.sh
```
4. **Edit Configuration**
Edit the configuration file to suit your system:
```bash
sudo nano SkywarnPlus/config.ini
```
5. **Crontab Entry**
Add a crontab entry to call SkywarnPlus on an interval. Open your crontab file using the `crontab -e` command, and add the following line:
```bash
* * * * * /usr/bin/python3 /usr/local/bin/SkywarnPlus/SkywarnPlus.py
```
This command will execute SkywarnPlus every minute.
# Configuration
Update parameters in the [config.ini](config.ini) file according to your preferences.
Remember you can also use CONTROL.sh to conveniently change specific key-value pairs in the config file from the command line. For example: `./CONTROL.sh sayalert false` would set 'SayAlert' to 'False'.
## Tailmessage and Courtesy Tones
SkywarnPlus offers functionalities such as Tailmessage management and Automatic Courtesy Tones, which require specific configurations in the `rpt.conf` file.
### Tailmessage
Tailmessage functionality requires the `rpt.conf` to be properly set up. Here's an example:
```ini
tailmessagetime = 600000
tailsquashedtime = 30000
tailmessagelist = /usr/local/bin/SkywarnPlus/SOUNDS/wx-tail
```
### Automatic Courtesy Tones
SkywarnPlus can automatically change the repeater courtesy tone whenever certain weather alerts are active. The configuration for this is based on your `rpt.conf` file setup. Here's an example:
```ini
[NODENUMBER]
unlinkedct = ct1
remotect = ct1
linkunkeyct = ct2
[telemetry]
ct1 = /usr/local/bin/SkywarnPlus/SOUNDS/TONES/CT-LOCAL
ct2 = /usr/local/bin/SkywarnPlus/SOUNDS/TONES/CT-LINK
remotetx = /usr/local/bin/SkywarnPlus/SOUNDS/TONES/CT-LOCAL
```
# Control Script
SkywarnPlus comes with a powerful control script (`CONTROL.sh`) that can be used to enable or disable certain SkywarnPlus functions. This script is particularly useful when you want to map DTMF control codes to these functions. An added advantage is that the script provides spoken feedback upon execution, making it even more suitable for DTMF control.
## Usage
To use the CONTROL.sh script, you need to call it with two parameters:
1. The name of the setting you want to change (case insensitive).
2. The new value for the setting (either 'true' or 'false').
For example, to enable the SayAlert function, you would use:
```bash
/usr/local/bin/SkywarnPlus/CONTROL.sh SayAlert true
```
And to disable it, you would use:
```bash
/usr/local/bin/SkywarnPlus/CONTROL.sh SayAlert false
```
## Spoken Feedback
Upon the successful execution of a control command, the `CONTROL.sh` script will provide spoken feedback that corresponds to the change made. For instance, if you execute a command to enable the SayAlert function, the script will play an audio message stating that SayAlert has been enabled. This feature enhances user experience and confirms that the desired changes have been effected.
## Mapping to DTMF Control Codes
You can map the CONTROL.sh script to DTMF control codes in the `rpt.conf` file of your AllStar node. Here is an example of how to do this:
```bash
901 = cmd,/usr/local/bin/SkywarnPlus/CONTROL.sh enable true ; Enables SkywarnPlus
902 = cmd,/usr/local/bin/SkywarnPlus/CONTROL.sh enable false ; Disables SkywarnPlus
903 = cmd,/usr/local/bin/SkywarnPlus/CONTROL.sh sayalert true ; Enables SayAlert
904 = cmd,/usr/local/bin/SkywarnPlus/CONTROL.sh sayalert false ; Disables SayAlert
905 = cmd,/usr/local/bin/SkywarnPlus/CONTROL.sh sayallclear true ; Enables SayAllClear
906 = cmd,/usr/local/bin/SkywarnPlus/CONTROL.sh sayallclear false ; Disables SayAllClear
907 = cmd,/usr/local/bin/SkywarnPlus/CONTROL.sh tailmessage true ; Enables TailMessage
908 = cmd,/usr/local/bin/SkywarnPlus/CONTROL.sh tailmessage false ; Disables TailMessage
909 = cmd,/usr/local/bin/SkywarnPlus/CONTROL.sh courtesytone true ; Enables CourtesyTone
910 = cmd,/usr/local/bin/SkywarnPlus/CONTROL.sh courtesytone false ; Disables CourtesyTone
```
With this setup, you can control SkywarnPlus' functionality using DTMF commands from your node.
# Testing
SkywarnPlus provides the ability to inject predefined alerts, bypassing the call to the NWS API. This feature is extremely useful for testing SkywarnPlus.
To enable this option, modify the following settings in the `[DEV]` section of your `config.ini` file:
```ini
; Enable to inject the below list of test alerts instead of calling the NWS API
INJECT = True
; CASE SENSITIVE, comma & newline separated list of alerts to inject
INJECTALERTS = Tornado Warning,
Tornado Watch,
Severe Thunderstorm Warning
```
# Debugging
Debugging is an essential part of diagnosing issues with SkywarnPlus. To facilitate this, SkywarnPlus provides a built-in debugging feature. Here's how to use it:
1. **Enable Debugging**: The debugging feature can be enabled in the `config.ini` file. Open this file and set the `debug` option under the `[SkywarnPlus]` section to `true`.
```ini
; Logging Options
[Logging]
; Enable more verbose logging
; Either True or False
Debug = False
```
This will allow the program to output detailed information about its operations, which is helpful for identifying any issues or errors.
2. **Open an Asterisk Console**: While debugging SkywarnPlus, it's helpful to have an Asterisk console open in a separate terminal window. This allows you to observe any issues related to Asterisk, such as problems playing audio files.
You can open an Asterisk console with the following command:
```bash
asterisk -rvvv
```
This command will launch an Asterisk console with a verbose output level of 3 (`vvv`), which provides a detailed look at what Asterisk is doing. This can be particularly useful if you're trying to debug issues with audio playback.
3. **Analyze Debugging Output**: With debugging enabled in SkywarnPlus and the Asterisk console open, you can now run SkywarnPlus and observe the detailed output in both terminals. This information can be used to identify and troubleshoot any issues or unexpected behaviors.
Remember, the more detailed your debug output is, the easier it will be to spot any issues. However, please be aware that enabling debug mode can result in large amounts of output, so it should be used judiciously.
If you encounter any issues that you're unable to resolve, please don't hesitate to submit a detailed bug report on the [SkywarnPlus GitHub Repository](https://github.com/mason10198/SkywarnPlus).
# Maintenance and Bug Reporting
SkywarnPlus is actively maintained by a single individual who dedicates their spare time to improve and manage this project. Despite best efforts, the application may have some bugs or areas for improvement.
If you encounter any issues with SkywarnPlus, please check back to the [SkywarnPlus GitHub Repository](https://github.com/mason10198/SkywarnPlus) to see if there have been any updates or fixes since the last time you downloaded it. New commits are made regularly to enhance the system's performance and rectify any known issues.
Bug reporting is greatly appreciated as it helps to improve SkywarnPlus. If you spot a bug, please raise an issue in the GitHub repository detailing the problem. Include as much information as possible, such as error messages, screenshots, and steps to reproduce the issue. This will assist in quickly understanding and resolving the issue.
Thank you for your understanding and assistance in making SkywarnPlus a more robust and reliable system for all.
# Contributing
SkywarnPlus is open-source and welcomes contributions. If you'd like to contribute, please fork the repository and use a feature branch. Pull requests are warmly welcome.
# License
SkywarnPlus is open-sourced software licensed under the [MIT license](LICENSE).

@ -0,0 +1,85 @@
SkywarnPlus Audio Library Dictionary
----------------------------------------
This file does not actually do anything.
This is just a reference for which audio
files correspond to which weather events.
This is useful for those who may want to
replace the audio files with their own.
----------------------------------------
SWP01.wav: Hurricane Force Wind Warning
SWP02.wav: Severe Thunderstorm Warning
SWP03.wav: Severe Thunderstorm Watch
SWP04.wav: Winter Weather Advisory
SWP05.wav: Tropical Storm Warning
SWP06.wav: Special Marine Warning
SWP07.wav: Freezing Rain Advisory
SWP08.wav: Special Weather Statement
SWP09.wav: Excessive Heat Warning
SWP10.wav: Coastal Flood Advisory
SWP11.wav: Coastal Flood Warning
SWP12.wav: Winter Storm Warning
SWP13.wav: Tropical Storm Watch
SWP14.wav: Thunderstorm Warning
SWP15.wav: Small Craft Advisory
SWP16.wav: Extreme Wind Warning
SWP17.wav: Excessive Heat Watch
SWP18.wav: Wind Chill Advisory
SWP19.wav: Storm Surge Warning
SWP20.wav: River Flood Warning
SWP21.wav: Flash Flood Warning
SWP22.wav: Coastal Flood Watch
SWP23.wav: Winter Storm Watch
SWP24.wav: Wind Chill Warning
SWP25.wav: Thunderstorm Watch
SWP26.wav: Fire Weather Watch
SWP27.wav: Dense Fog Advisory
SWP28.wav: Storm Surge Watch
SWP29.wav: River Flood Watch
SWP30.wav: Ice Storm Warning
SWP31.wav: Hurricane Warning
SWP32.wav: High Wind Warning
SWP33.wav: Flash Flood Watch
SWP34.wav: Red Flag Warning
SWP35.wav: Blizzard Warning
SWP36.wav: Tornado Warning
SWP37.wav: Hurricane Watch
SWP38.wav: High Wind Watch
SWP39.wav: Frost Advisory
SWP40.wav: Freeze Warning
SWP41.wav: Wind Advisory
SWP42.wav: Tornado Watch
SWP43.wav: Storm Warning
SWP44.wav: Heat Advisory
SWP45.wav: Flood Warning
SWP46.wav: Gale Warning
SWP47.wav: Freeze Watch
SWP48.wav: Flood Watch
SWP49.wav: Flood Advisory
SWP50.wav: Hurricane Local Statement
SWP51.wav: Beach Hazards Statement
SWP52.wav: Air Quality Alert
SWP53.wav: Severe Weather Statement
SWP54.wav: Winter Storm Advisory
SWP55.wav: Tropical Storm Advisory
SWP56.wav: Blizzard Watch
SWP57.wav: Dust Storm Warning
SWP58.wav: High Surf Advisory
SWP59.wav: Heat Watch
SWP60.wav: Freeze Watch
SWP61.wav: Dense Smoke Advisory
SWP62.wav: Avalanche Warning
SWP85.wav: SkywarnPlus Enabled
SWP86.wav: SkywarnPlus Disabled
SWP87.wav: SayAlert Enabled
SWP88.wav: SayAlert Disabled
SWP89.wav: SayAllClear Enabled
SWP90.wav: SayAllClear Disabled
SWP91.wav: Tailmessage Enabled
SWP92.wav: Tailmessage Disabled
SWP93.wav: CourtesyTone Enabled
SWP94.wav: CourtesyTone Disabled
SWP95.wav: Tic Sound Effect
SWP96.wav: All Clear Message
SWP97.wav: Updated Weather Information Message
SWP98.wav: Error Sound Effect
SWP99.wav: Word Space Silence

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

@ -0,0 +1 @@
ÿ¶¬®ÁF/,4d¸¬®¾J/,2Xº­­¼O1,1O¼­­ºX2,/J¾®¬¸d4,/FÁ®¬¶ÿ6,.AƯ¬´ä8,.>ʯ¬²Ø:--<ϱ¬±Ï<--:ز¬¯Ê>.,8ä´¬¯ÆA.,6ÿ¶¬®ÁF/,4d¸¬®¾J/,2Xº­­¼O1,1O¼­­ºX2,/J¾®¬¸d4,/FÁ®¬¶~6,.AƯ¬´ä8,.>ʯ¬²Ø:--<ϱ¬±Ï<--:ز¬¯Ê>.,8ä´¬¯ÆA.,6ÿ¶¬®ÁF/,4d¸¬®¾J/,2Xº­­¼O1,1O¼­­ºX2,/J¾®¬¸d4,/FÁ®¬¶~6,.AƯ¬´ä8,.>ʯ¬²Ø:--<ϱ¬±Ï<--:ز¬¯Ê>.,8ä´¬¯ÆA.,6~¶¬®ÁF/,4d¸¬®¾J/,3Xº­­¼O1,1O¼­­ºX2,/J¾®¬¸d4,/FÁ®¬¶ÿ6,.AƯ¬´ä8,.>ʯ¬²Ø:--<ϱ¬±Ï<--:س¬¯Ê>.,8å´¬¯ÆA.,6¶¬®ÁF/,4d¸¬®¾J/,3Xº­­¼O1,1O¼­­ºX2,/J¾®¬¸d4,/FÁ®¬¶ÿ6,.AƯ¬´ä8,.>ʯ¬²Ø:--<ϱ¬±Ï<--:ز¬¯Ê>.,8ä´¬¯ÆA.,6ÿ¶¬®ÁF/,4d¸¬®¾J/,2Xº­­¼O1,1O¼­­ºX2,/J¾®¬¸d4,/FÁ®¬¶6,.AƯ¬´ä8,.>ʯ¬³Ø:--<ϱ¬±Ï<--:ز¬¯Ê>.,8ä´¬¯ÆA.,6ÿ¶¬®ÁF/,4d¸¬®¾J/,2Xº­­¼O1,1O¼­­ºX2,/J¾®¬¸d4,/FÁ®¬¶ÿ6,.AƯ¬´ä8,.>ʯ¬²Ø:--<ϱ¬±Ï<--:ز¬¯Ê>.,8ä´¬¯ÆA.,6ÿ¶¬®ÁF/,4d¸¬®¾J/,2Xº­­¼O1,1O¼­­ºX3,/J¾®¬¸d4,/FÁ®¬¶6,.AƯ¬´ä8,.>ʯ¬²Ø:--<ϱ¬±Ï<--:ز¬¯Ê>.,8ä´¬¯ÆA.,6ÿ¶¬®ÁF/,4d¸¬®¾J/,2Xº­­¼O1,1O¼­­ºX2,/J¾®¬¸d4,/FÁ®¬¶6,.AƯ¬´ä8,.>ʯ¬²Ø:--<ϱ¬±Ï<--:ز¬¯Ê>.,8ä´¬¯ÆA.,6~¶¬®ÁF/,4d¸¬®¾J/,2Xº­­¼O1,1O¼­­ºX2,/J¾®¬¸d4,/FÁ®¬¶þ6,.AƯ¬´ä8,.>ʯ¬²Ø:--<ϱ¬±Ï<--:ز¬¯Ê>.,8ä´¬¯ÆA.,6ÿ¶¬®ÁF/,4d¸¬®¾J/,2Xº­­¼O1,1O¼­­ºX2,/J¾®¬¸d4,/FÁ®¬¶ÿ6,.AƯ¬´ä8,.>ʯ¬²Ø:--<ϱ¬±Ï<--:ز¬¯Ê>.,8ä´¬¯ÆA.,6þ¶¬®ÁF/,4d¸¬®¾J/,2Xº­­¼O1,1O¼­­ºX2,/J¾®¬¸d4,/FÁ®¬¶ÿ6,.AƯ¬´ä8,.>ʯ¬²Ø:--<ϱ¬±Ï<--:ز¬¯Ê>.,8ä´¬¯ÆA.,6

@ -0,0 +1 @@
<EFBFBD>テカョャャョオチ珖7/,,.4?dネクッャャョウセンJ9/-,-2=XヘコーュャュイシモO;1-,-1;OモシイュャューコヘX=2-,-/9Jンセウョャャックネd?4.,,/7F暿オョャャョカテ<EFBDB6>C6.,,.5Anニキッャャョエソ腥8/,,.3>]ハケッュャュイスリM:0-,-2<SマサアュャュアサマS<2-,-0:Mリスイュャュッケハ]>3.,,/8H菫エョャャッキニnA5.,,.6Cテカョャャョオチ炻7/,,.4?dネクッャャョウセンJ9/-,-2=XヘコーュャュイシモO;1-,-1;OモシイュャューコヘX=2-,-/9Jンセウョャャックネd?4.,,/7F錝オョャャョカテ<EFBDB6>C6.,,.5Amニキッャャョエソ腥8/,,.3>]ハケッュャュイスリM:0-,-2<SマサアュャュアサマS<2-,-0:Mリスイュャュッケハ]>3.,,/8H菫エョャャッキニnA5.,,.6C<EFBFBD>テカョャャョオチ珖7/,,.4?dネクッャャョウセンJ9/-,-2=XヘコーュャュイシモO;1-,-1;OモシイュャューコヘX=2-,-/9Jンセウョャャックネd?4.,,/7F暿オョャャョカテ<EFBDB6>C6.,,.5Amニキッャャョエソ腥8/,,.3>]ハケッュャュイスリM:0-,-2<SマサアュャュアサマS<2-,-0:Mリスイュャュッケハ]>3.,,/8H菫エョャャッキニnA5.,,.6C<EFBFBD>テカョャャョオチ珖7/,,.4?dネクッャャョウセンJ9/-,-2=XヘコーュャュイシモO;1-,-1;OモシイュャューコヘX=2-,-/9Jンセウョャャックネd?4.,,/7F暿オョャャョカテ<EFBDB6>C6.,,.5Anニキッャャョエソ腥8/,,.3>]ハケッュャュイスリM:0-,-2<SマサアュャュアサマS<2-,-0:Mリスイュャュッケハ]>3.,,/8H菫エョャャッキニnA5.,,.6C<EFBFBD>テカョャャョオチ珖7/,,.4?dネクッャャョウセンJ9/-,-2=XヘコーュャュイシモO;1-,-1;OモシイュャューコヘX=3-,-/9Jンセウョャャックネd?4.,,/7F錝オョャャョカテ<EFBDB6>C6.,,.5Anニキッャャョエソ腥8/,,.3>]ハケッュャュイスリM:0-,-2<SマサアュャュアサマS<2-,-0:Mリスイュャュッケハ]>3.,,/8H菫エョャャッキニnA5.,,.6Cテカョャャョオチ炻7/,,.4?dネクッャャョウセンJ9/-,-2=XヘコーュャュイシモO;1-,-1;OモシイュャューコヘX=3-,-/9Jンセウョャャックネd?4.,,/7F錝オョャャョカテ<EFBDB6>C6.,,.5Amニキッャャョエソ腥8/,,.3>]ハケッュャュイスリM:0-,-2<SマサアュャュアサマS<2-,-0:Mリスイュャュッケハ]>3.,,/8H菫エョャャッキニnA5.,,.6C

@ -0,0 +1 @@
ÿèÑÉÉÞM:24Cਨ±ÿ." (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-­¡ ¨Å6$$6Ĩ ¡­ÿ-! (E¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶E( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (E¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­þ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (E¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Å6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (E¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Å6$$6Ĩ ¡­ÿ-! (E¶¤Ÿ¤¶D( !-­¡ ¨Å6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-þ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­~-! (E¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­ÿ-! (D¶¤Ÿ¤¶E( !-­¡ ¨Ä6$$6Ĩ ¢®þ1((1N´²ºÍ\JHWîÖÌíÝÌÍçI96AÓ²«®Ì6&$/ͨŸ¥¿3" ,Û©Ÿ¤»7# +ý« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ/!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ0!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ/!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ/!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ0!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ/!!.ͨŸ¥¿5&&2Წ²ËJ:;IþÔÕéóÎÀÇqGJyçA*$2±™• D-ee0'6«•Ÿ7 g²½OL²œ˜¥/Ú¡Ÿ¯äϬ¢®.š– Ñ:N¾À3 ¸–‘œÜ&$5sH(<C2B3>\!KÄi49¶š”<C5A1>CH«¨½eĤœ¤;VŸš£ÈPÇ­±:ßš’›¾/-KÐC"ǘ<C387>˜Á#&Hè;(,¿š‘™Ð:·µþDÅ <C2A0>g8¨ž©Ëë³£§L>ž•œ·>?Ê»JN<4E>²,"-T^.<C3AF>´"6ÉÓ;3ÕŸ”˜½.²§²îÜ«<C39C>žÎ.§šž¶Vä±­}3 ¬:,<Øc)<>“ª,8çJ+(QŸ”¬%*ıËEz¨™™´ &³ž£»{¾§£À!(ª—˜ªP;ðºÙ(-¥’“¤<"'Aþ9"£/*äÇG1I¨•¨)"ȧ¬ÍkµŸœ¯'¸››«ó[º¬½)#®•”¡\,3qÚ3*«“<C2AB>žC-ed0'6«•Ÿ7 g²¼NL²œ˜¥/Û¡Ÿ¯åϬ¢®.š– Ñ:N¾À3 ¸–‘œÜ&$5rH(<C2B3>[!JÄh49¶š”<C5A1>CH«¨½eĤœ¤;VŸš£ÈPÇ­±:ßš’›¾/-KÐC"ǘ<C387>˜Á#&Hè;(,¿š‘™Ð:·µýDÅ <C2A0>g8¨ž©Ëë³£§L>ž•œ·>?Ê»JN<4E>²,"-T^.<C3AF>·&#=ÒÝICä¶®·ÝIH^

@ -0,0 +1 @@
ÿèÑÉÉÞM:24Cਨ±ÿ." (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-­¡ ¨Å6$$6Ĩ ¡­ÿ-! (E¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶E( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (E¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­þ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (E¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Å6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (E¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Å6$$6Ĩ ¡­ÿ-! (E¶¤Ÿ¤¶D( !-­¡ ¨Å6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-þ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­~-! (E¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­ÿ-! (D¶¤Ÿ¤¶E( !-­¡ ¨Ä6$$6Ĩ ¢®þ1((1N´²ºÍ\JHWîÖÌíÝÌÍçI96AÓ²«®Ì6&$/ͨŸ¥¿3" ,Û©Ÿ¤»7# +ý« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ/!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ0!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ/!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ/!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ0!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ/!!.ͨŸ¥¿5&&2Წ²ËJ:;IþÔÕéóÎÀÇqGJyçA*$2±™• D-ee0'6«•Ÿ7 g²½OL²œ˜¥/Ú¡Ÿ¯äϬ¢®.š– Ñ:N¾À3 ¸–‘œÜ&$5sH(<C2B3>\!KÄi49¶š”<C5A1>CH«¨½eĤœ¤;VŸš£ÈPÇ­±:ßš’›¾/-KÐC"ǘ<C387>˜Á#&Hè;(,¿š‘™Ð:·µþDÅ <C2A0>g8¨ž©Ëë³£§L>ž•œ·>?Ê»JN<4E>²,"-T^.<C3AF>´"6ÉÓ;3ÕŸ”˜½.²§²îÜ«<C39C>žÎ.§šž¶Vä±­}3 ¬:,<Øc)<>“ª,8çJ+(QŸ”¬%*ıËEz¨™™´ &³ž£»{¾§£À!(ª—˜ªP;ðºÙ(-¥’“¤<"'Aþ9"£/*äÇG1I¨•¨)"ȧ¬ÍkµŸœ¯'¸››«ó[º¬½)#®•”¡\,3qÚ3*«“<C2AB>žC-ed0'6«•Ÿ7 g²¼NL²œ˜¥/Û¡Ÿ¯åϬ¢®.š– Ñ:N¾À3 ¸–‘œÜ&$5rH(<C2B3>[!JÄh49¶š”<C5A1>CH«¨½eĤœ¤;VŸš£ÈPÇ­±:ßš’›¾/-KÐC"ǘ<C387>˜Á#&Hè;(,¿š‘™Ð:·µýDÅ <C2A0>g8¨ž©Ëë³£§L>ž•œ·>?Ê»JN<4E>²,"-T^.<C3AF>·&#=ÒÝICä¶®·ÝIH^

@ -0,0 +1 @@
ÿèÑÉÉÞM:24Cਨ±ÿ." (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-­¡ ¨Å6$$6Ĩ ¡­ÿ-! (E¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶E( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (E¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­þ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (E¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Å6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (E¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Å6$$6Ĩ ¡­ÿ-! (E¶¤Ÿ¤¶D( !-­¡ ¨Å6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-þ­¡ ¨Ä6$$6Ĩ ¡­ÿ-! (D¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ĩ ¡­~-! (E¶¤Ÿ¤¶D( !-ÿ­¡ ¨Ä6$$6Ũ ¡­ÿ-! (D¶¤Ÿ¤¶E( !-­¡ ¨Ä6$$6Ĩ ¢®þ1((1N´²ºÍ\JHWîÖÌíÝÌÍçI96AÓ²«®Ì6&$/ͨŸ¥¿3" ,Û©Ÿ¤»7# +ý« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ/!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ0!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ/!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ/!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ0!!.ͨŸ¥¿3" ,Û©Ÿ¤»7# +ü« £·;$)\¬ ¢³>%(N®¡¡°E&&F¯¡¡®M(%?³¢ ¬[)$;·£ «|+ #7»¤Ÿ©Ü, "3¾¥Ÿ¨Î.!!0ŦŸ¦Æ/!!.ͨŸ¥¿5&&2Წ²ËJ:;IþÔÕéóÎÀÇqGJyçA*$2±™• D-ee0'6«•Ÿ7 g²½OL²œ˜¥/Ú¡Ÿ¯äϬ¢®.š– Ñ:N¾À3 ¸–‘œÜ&$5sH(<C2B3>\!KÄi49¶š”<C5A1>CH«¨½eĤœ¤;VŸš£ÈPÇ­±:ßš’›¾/-KÐC"ǘ<C387>˜Á#&Hè;(,¿š‘™Ð:·µþDÅ <C2A0>g8¨ž©Ëë³£§L>ž•œ·>?Ê»JN<4E>²,"-T^.<C3AF>´"6ÉÓ;3ÕŸ”˜½.²§²îÜ«<C39C>žÎ.§šž¶Vä±­}3 ¬:,<Øc)<>“ª,8çJ+(QŸ”¬%*ıËEz¨™™´ &³ž£»{¾§£À!(ª—˜ªP;ðºÙ(-¥’“¤<"'Aþ9"£/*äÇG1I¨•¨)"ȧ¬ÍkµŸœ¯'¸››«ó[º¬½)#®•”¡\,3qÚ3*«“<C2AB>žC-ed0'6«•Ÿ7 g²¼NL²œ˜¥/Û¡Ÿ¯åϬ¢®.š– Ñ:N¾À3 ¸–‘œÜ&$5rH(<C2B3>[!JÄh49¶š”<C5A1>CH«¨½eĤœ¤;VŸš£ÈPÇ­±:ßš’›¾/-KÐC"ǘ<C387>˜Á#&Hè;(,¿š‘™Ð:·µýDÅ <C2A0>g8¨ž©Ëë³£§L>ž•œ·>?Ê»JN<4E>²,"-T^.<C3AF>·&#=ÒÝICä¶®·ÝIH^

@ -0,0 +1,630 @@
#!/usr/bin/env python3
"""
SkywarnPlus by Mason Nelson (N5LSN/WRKF394)
==================================================
SkywarnPlus is a utility that retrieves severe weather alerts from the National
Weather Service and integrates these alerts with an Asterisk/app_rpt based
radio repeater controller.
This utility is designed to be highly configurable, allowing users to specify
particular counties for which to check for alerts, the types of alerts to include
or block, and how these alerts are integrated into their radio repeater system.
This includes features such as automatic voice alerts and a tail message feature
for constant updates. All alerts are sorted by severity and cover a broad range
of weather conditions such as hurricane warnings, thunderstorms, heat waves, etc.
Configurable through a .ini file, SkywarnPlus serves as a comprehensive and
flexible tool for those who need to stay informed about weather conditions
and disseminate this information through their radio repeater system.
"""
import os
import json
import logging
import requests
import configparser
import shutil
import fnmatch
import subprocess
import time
from datetime import datetime, timezone
from dateutil import parser
from pydub import AudioSegment
# Configuration file handling
baseDir = os.path.dirname(os.path.realpath(__file__))
configPath = os.path.join(baseDir, "config.ini")
config = configparser.ConfigParser()
config.read_file(open(configPath, "r"))
# Fetch values from configuration file
master_enable = config["SKYWARNPLUS"].getboolean("Enable", fallback=False)
if not master_enable:
print("SkywarnPlus is disabled in config.ini, exiting...")
exit()
tmp_dir = config["DEV"].get("TmpDir", fallback="/tmp/SkywarnPlus")
sounds_path = config["Alerting"].get("SoundsPath", fallback="./SOUNDS")
if sounds_path == "./SOUNDS":
sounds_path = os.path.join(baseDir, "SOUNDS")
countyCodes = config["Alerting"]["CountyCodes"].split(",")
# If temporary directory doesn't exist, create it
if not os.path.exists(tmp_dir):
os.makedirs(tmp_dir)
# List of blocked events
blocked_events = config["Blocking"].get("BlockedEvents").split(",")
# Configuration for tailmessage
tailmessage_config = config["Tailmessage"]
# Flag to enable/disable tailmessage
enable_tailmessage = tailmessage_config.getboolean("Enable", fallback=False)
# Path to tailmessage file
tailmessage_file = tailmessage_config.get(
"TailmessagePath", fallback="./SOUNDS/wx-tail.wav"
)
if tailmessage_file == "./SOUNDS/wx-tail.wav":
tailmessage_file = os.path.join(baseDir, "SOUNDS/wx-tail.wav")
# Warning and announcement strings
WS = [
"Hurricane Force Wind Warning",
"Severe Thunderstorm Warning",
"Severe Thunderstorm Watch",
"Winter Weather Advisory",
"Tropical Storm Warning",
"Special Marine Warning",
"Freezing Rain Advisory",
"Special Weather Statement",
"Excessive Heat Warning",
"Coastal Flood Advisory",
"Coastal Flood Warning",
"Winter Storm Warning",
"Tropical Storm Watch",
"Thunderstorm Warning",
"Small Craft Advisory",
"Extreme Wind Warning",
"Excessive Heat Watch",
"Wind Chill Advisory",
"Storm Surge Warning",
"River Flood Warning",
"Flash Flood Warning",
"Coastal Flood Watch",
"Winter Storm Watch",
"Wind Chill Warning",
"Thunderstorm Watch",
"Fire Weather Watch",
"Dense Fog Advisory",
"Storm Surge Watch",
"River Flood Watch",
"Ice Storm Warning",
"Hurricane Warning",
"High Wind Warning",
"Flash Flood Watch",
"Red Flag Warning",
"Blizzard Warning",
"Tornado Warning",
"Hurricane Watch",
"High Wind Watch",
"Frost Advisory",
"Freeze Warning",
"Wind Advisory",
"Tornado Watch",
"Storm Warning",
"Heat Advisory",
"Flood Warning",
"Gale Warning",
"Freeze Watch",
"Flood Watch",
"Flood Advisory",
"Hurricane Local Statement",
"Beach Hazards Statement",
"Air Quality Alert",
"Severe Weather Statement",
"Winter Storm Advisory",
"Tropical Storm Advisory",
"Blizzard Watch",
"Dust Storm Warning",
"High Surf Advisory",
"Heat Watch",
"Freeze Watch",
"Dense Smoke Advisory",
"Avalanche Warning",
"SkywarnPlus Enabled",
"SkywarnPlus Disabled",
"SayAlert Enabled",
"SayAlert Disabled",
"SayAllClear Enabled",
"SayAllClear Disabled",
"Tailmessage Enabled",
"Tailmessage Disabled",
"CourtesyTone Enabled",
"CourtesyTone Disabled",
"Tic Sound Effect",
"All Clear Message",
"Updated Weather Information Message",
"Error Sound Effect",
"Word Space Silence",
]
WA = [
"01",
"02",
"03",
"04",
"05",
"06",
"07",
"08",
"09",
"10",
"11",
"12",
"13",
"14",
"15",
"16",
"17",
"18",
"19",
"20",
"21",
"22",
"23",
"24",
"25",
"26",
"27",
"28",
"29",
"30",
"31",
"32",
"33",
"34",
"35",
"36",
"37",
"38",
"39",
"40",
"41",
"42",
"43",
"44",
"45",
"46",
"47",
"48",
"49",
"50",
"51",
"52",
"53",
"54",
"55",
"56",
"57",
"58",
"59",
"60",
"61",
"62",
"85",
"86",
"87",
"88",
"89",
"90",
"91",
"92",
"93",
"94",
"95",
"96",
"97",
"98",
"99",
]
# Cleanup flag for testing
CLEANSLATE = config["DEV"].get("CLEANSLATE")
if CLEANSLATE == "True":
shutil.rmtree(tmp_dir)
os.mkdir(tmp_dir)
# Configure logging
log_config = config["Logging"]
enable_debug = log_config.getboolean("Debug", fallback=False)
log_file = log_config.get("LogPath", fallback="{}/SkywarnPlus.log".format(tmp_dir))
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG if enable_debug else logging.INFO)
c_handler = logging.StreamHandler()
f_handler = logging.FileHandler(log_file)
c_format = f_format = logging.Formatter("%(asctime)s %(levelname)s %(message)s")
c_handler.setFormatter(c_format)
f_handler.setFormatter(f_format)
logger.addHandler(c_handler)
logger.addHandler(f_handler)
# Debugging stuff
logger.debug("Base directory: {}".format(baseDir))
logger.debug("Temporary directory: {}".format(tmp_dir))
logger.debug("Sounds path: {}".format(sounds_path))
logger.debug("Tailmessage path: {}".format(tailmessage_file))
logger.debug("Blocked events: {}".format(blocked_events))
def getAlerts(countyCodes):
"""
Retrieve severe weather alerts for specified county codes.
Args:
countyCodes (list): List of county codes.
Returns:
alerts (list): List of active weather alerts.
"""
# Check if we need to inject alerts from the config
if config.getboolean("DEV", "INJECT", fallback=False):
logger.debug("DEV Alert Injection Enabled")
alerts = [
alert.strip() for alert in config["DEV"].get("INJECTALERTS").split(",")
]
logger.debug("Injecting alerts: {}".format(alerts))
return alerts
alerts = set() # Change list to set to automatically avoid duplicate alerts
current_time = datetime.now(timezone.utc)
logger.debug("Checking for alerts in {}".format(countyCodes))
for countyCode in countyCodes:
logger.debug("Checking for alerts in {}".format(countyCode))
url = "https://api.weather.gov/alerts/active?zone={}".format(countyCode)
logger.debug("Requesting {}".format(url))
response = requests.get(url)
logger.debug("Response: {}\n\n".format(response.text))
if response.status_code == 200:
alert_data = response.json()
for feature in alert_data["features"]:
expires = feature["properties"].get("expires")
if expires:
expires_time = parser.isoparse(expires)
if expires_time > current_time:
event = feature["properties"]["event"]
for blocked_event in blocked_events:
if fnmatch.fnmatch(event, blocked_event):
logger.debug(
"Blocking {} as per configuration".format(event)
)
break
else:
alerts.add(event) # Add event to set
logger.debug("{}: {}".format(countyCode, event))
else:
logger.error(
"Failed to retrieve alerts for {}, HTTP status code {}, response: {}".format(
countyCode, response.status_code, response.text
)
)
alerts = list(alerts) # Convert set back to list
alerts.sort(key=lambda x: WS.index(x) if x in WS else len(WS))
return alerts
def sayAlert(alerts):
"""
Generate and broadcast severe weather alert sounds on Asterisk.
Args:
alerts (list): List of active weather alerts.
"""
alert_file = "{}/alert.wav".format(sounds_path)
combined_sound = AudioSegment.from_wav(
os.path.join(sounds_path, "ALERTS", "SWP97.wav")
)
sound_effect = AudioSegment.from_wav(
os.path.join(sounds_path, "ALERTS", "SWP95.wav")
)
for alert in alerts:
try:
index = WS.index(alert)
audio_file = AudioSegment.from_wav(
os.path.join(sounds_path, "ALERTS", "SWP{}.wav".format(WA[index]))
)
combined_sound += sound_effect + audio_file
logger.debug("Added {} (SWP{}.wav) to alert sound".format(alert, WA[index]))
except ValueError:
logger.error("Alert not found: {}".format(alert))
except FileNotFoundError:
logger.error(
"Audio file not found: {}/ALERTS/SWP{}.wav".format(
sounds_path, WA[index]
)
)
logger.debug("Exporting alert sound to {}".format(alert_file))
converted_combined_sound = convert_audio(combined_sound)
converted_combined_sound.export(alert_file, format="wav")
logger.debug("Replacing tailmessage with silence")
silence = AudioSegment.silent(duration=100)
converted_silence = convert_audio(silence)
converted_silence.export(tailmessage_file, format="wav")
node_numbers = config["Asterisk"]["Nodes"].split(",")
for node_number in node_numbers:
logger.info("Broadcasting alert on node {}".format(node_number))
command = '/usr/sbin/asterisk -rx "rpt localplay {} {}"'.format(
node_number.strip(), os.path.splitext(os.path.abspath(alert_file))[0]
)
subprocess.run(command, shell=True)
# This keeps Asterisk from playing the tailmessage immediately after the alert
logger.info("Waiting 30 seconds for Asterisk to make announcement...")
time.sleep(30)
def sayAllClear():
"""
Generate and broadcast 'all clear' message on Asterisk.
"""
alert_clear = os.path.join(sounds_path, "ALERTS", "SWP96.wav")
node_numbers = config["Asterisk"]["Nodes"].split(",")
for node_number in node_numbers:
logger.info("Broadcasting all clear message on node {}".format(node_number))
command = '/usr/sbin/asterisk -rx "rpt localplay {} {}"'.format(
node_number.strip(), os.path.splitext(os.path.abspath(alert_clear))[0]
)
subprocess.run(command, shell=True)
def buildTailmessage(alerts):
"""
Build a tailmessage, which is a short message appended to the end of a
transmission to update on the weather conditions.
Args:
alerts (list): List of active weather alerts.
"""
if not alerts:
logger.debug("No alerts, creating silent tailmessage")
silence = AudioSegment.silent(duration=100)
converted_silence = convert_audio(silence)
converted_silence.export(tailmessage_file, format="wav")
return
combined_sound = AudioSegment.empty()
sound_effect = AudioSegment.from_wav(
os.path.join(sounds_path, "ALERTS", "SWP95.wav")
)
for alert in alerts:
try:
index = WS.index(alert)
audio_file = AudioSegment.from_wav(
os.path.join(sounds_path, "ALERTS", "SWP{}.wav".format(WA[index]))
)
combined_sound += sound_effect + audio_file
logger.debug("Added {} (SWP{}.wav) to tailmessage".format(alert, WA[index]))
except ValueError:
logger.error("Alert not found: {}".format(alert))
except FileNotFoundError:
logger.error(
"Audio file not found: {}/ALERTS/SWP{}.wav".format(
sounds_path, WA[index]
)
)
logger.debug("Exporting tailmessage to {}".format(tailmessage_file))
converted_combined_sound = convert_audio(combined_sound)
converted_combined_sound.export(tailmessage_file, format="wav")
def changeCT(ct):
"""
Change the current Courtesy Tone (CT) to the one specified.
The function first checks if the specified CT is already in use, and if it is, it returns without making any changes.
If the CT needs to be changed, it replaces the current CT files with the new ones and updates the state file.
If no CT is specified, the function logs an error message and returns.
Args:
ct (str): The name of the new CT to use. This should be one of the CTs specified in the config file.
Returns:
bool: True if the CT was changed, False otherwise.
"""
tone_dir = config["CourtesyTones"].get("ToneDir", fallback="./SOUNDS/TONES")
if tone_dir == "./SOUNDS/TONES":
tone_dir = os.path.join(sounds_path, "TONES")
local_ct = config["CourtesyTones"].get("LocalCT")
link_ct = config["CourtesyTones"].get("LinkCT")
wx_ct = config["CourtesyTones"].get("WXCT")
rpt_local_ct = config["CourtesyTones"].get("RptLocalCT")
rpt_link_ct = config["CourtesyTones"].get("RptLinkCT")
ct_state_file = os.path.join(tmp_dir, "ct_state.txt")
logger.debug("Tone directory: {}".format(tone_dir))
logger.debug("CT argument: {}".format(ct))
if not ct:
logger.error("ChangeCT called with no CT specified")
return
current_ct = None
if os.path.exists(ct_state_file):
with open(ct_state_file, "r") as file:
current_ct = file.read().strip()
logger.debug("Current CT: {}".format(current_ct))
if ct == current_ct:
logger.debug("Courtesy tones are already {}, no changes made.".format(ct))
return False
if ct == "NORMAL":
logger.info("Changing to NORMAL courtesy tones")
src_file = tone_dir + "/" + local_ct
dest_file = tone_dir + "/" + rpt_local_ct
logger.debug("Copying {} to {}".format(src_file, dest_file))
shutil.copyfile(src_file, dest_file)
src_file = tone_dir + "/" + link_ct
dest_file = tone_dir + "/" + rpt_link_ct
logger.debug("Copying {} to {}".format(src_file, dest_file))
shutil.copyfile(src_file, dest_file)
else:
logger.info("Changing to {} courtesy tone".format(ct))
src_file = tone_dir + "/" + wx_ct
dest_file = tone_dir + "/" + rpt_local_ct
logger.debug("Copying {} to {}".format(src_file, dest_file))
shutil.copyfile(src_file, dest_file)
src_file = tone_dir + "/" + wx_ct
dest_file = tone_dir + "/" + rpt_link_ct
logger.debug("Copying {} to {}".format(src_file, dest_file))
shutil.copyfile(src_file, dest_file)
with open(ct_state_file, "w") as file:
file.write(ct)
return True
def send_pushover_notification(message, title=None, priority=0):
"""
Send a push notification via Pushover service.
The function constructs the payload for the request, including the user key, API token, message, title, and priority.
The payload is then sent to the Pushover API endpoint. If the request fails, an error message is logged.
Args:
message (str): The content of the push notification.
title (str, optional): The title of the push notification. Defaults to None.
priority (int, optional): The priority of the push notification. Defaults to 0.
Returns:
None
"""
pushover_config = config["Pushover"]
user_key = pushover_config.get("UserKey")
token = pushover_config.get("APIToken")
# Remove newline from the end of the message
message = message.rstrip("\n")
url = "https://api.pushover.net/1/messages.json"
payload = {
"token": token,
"user": user_key,
"message": message,
"title": title,
"priority": priority,
}
response = requests.post(url, data=payload)
if response.status_code != 200:
logger.error("Failed to send Pushover notification: {}".format(response.text))
def convert_audio(audio):
"""
Convert audio file to 8000Hz mono for compatibility with Asterisk.
Args:
audio (AudioSegment): Audio file to be converted.
Returns:
AudioSegment: Converted audio file.
"""
return audio.set_frame_rate(8000).set_channels(1)
def main():
"""
Main function of the script, that fetches and processes severe weather
alerts, then integrates these alerts into an Asterisk/app_rpt based radio
repeater system.
"""
say_alert_enabled = config["Alerting"].getboolean("SayAlert", fallback=False)
say_all_clear_enabled = config["Alerting"].getboolean("SayAllClear", fallback=False)
alerts = getAlerts(countyCodes)
tmp_file = "{}/alerts.json".format(tmp_dir)
if os.path.exists(tmp_file):
with open(tmp_file, "r") as file:
old_alerts = json.load(file)
else:
old_alerts = ["init"]
logger.info("No previous alerts file found, starting fresh.")
if old_alerts != alerts:
with open(tmp_file, "w") as file:
json.dump(alerts, file)
ct_alerts = [
alert.strip()
for alert in config["CourtesyTones"].get("CTAlerts").split(",")
]
enable_ct_auto_change = config["CourtesyTones"].getboolean(
"Enable", fallback=False
)
pushover_enabled = config["Pushover"].getboolean("Enable", fallback=False)
pushover_debug = config["Pushover"].getboolean("Debug", fallback=False)
if len(alerts) == 0:
logger.info("No alerts found")
pushover_message = "Alerts Cleared\n"
if not os.path.exists(tmp_file):
with open(tmp_file, "w") as file:
json.dump([], file)
if say_all_clear_enabled:
sayAllClear()
else:
logger.info("Alerts found: {}".format(alerts))
if say_alert_enabled:
sayAlert(alerts)
pushover_message = "\n".join(alerts) + "\n"
if enable_ct_auto_change:
logger.debug(
"CT auto change is enabled, alerts that require a CT change: {}".format(
ct_alerts
)
)
# Check if any alert matches ct_alerts
if set(alerts).intersection(ct_alerts):
for alert in alerts:
if alert in ct_alerts:
logger.debug("Alert {} requires a CT change".format(alert))
if changeCT("WX"): # If the CT was actually changed
if pushover_debug:
pushover_message += "Changed courtesy tones to WX\n"
break
else: # No alerts require a CT change, revert back to normal
logger.debug("No alerts require a CT change, reverting to normal.")
if changeCT("NORMAL"): # If the CT was actually changed
if pushover_debug:
pushover_message += "Changed courtesy tones to NORMAL\n"
else:
logger.debug("CT auto change is not enabled")
if enable_tailmessage:
buildTailmessage(alerts)
if pushover_debug:
if not alerts:
pushover_message += "WX tailmessage removed\n"
else:
pushover_message += "Built WX tailmessage\n"
if pushover_enabled:
pushover_message = pushover_message.rstrip("\n")
logger.debug("Sending pushover notification: {}".format(pushover_message))
send_pushover_notification(pushover_message, title="Alerts Changed")
else:
logger.debug("No change in alerts")
if __name__ == "__main__":
main()

@ -0,0 +1,139 @@
; SkywarnPlus Configuration File
; Please update this file according to your setup and preferences
[SKYWARNPLUS]
; Completely enable/disable SkywarnPlus
Enable = True
; Asterisk Settings
[Asterisk]
; Comma separated list of node numbers to broadcast alerts on
; Example: Nodes = 1998, 1999
Nodes =
; Alerting settings
[Alerting]
; List of county codes to alert on. FIND YOUR COUNTY CODE(S) at https://alerts.weather.gov/
; Example: CountyCodes = ARC121,ARC021,ARC139,ARC027
CountyCodes =
; Enable instant alerting when weather alerts change
; Either True or False
SayAlert = True
; Enable instant alerting when weather alerts are cleared
; Either True or False
SayAllClear = True
; Optional alternate path to the directory where sound files are stored
; Default is SkywarnPlus/SOUNDS
; Example: SoundsPath = /home/repeater/
;SoundsPath =
; Blocking settings
[Blocking]
; CASE SENSITIVE list of events to ignore, comma separated. Wildcards can be used, e.g. *Statement, *Advisory
BlockedEvents =
; Tail message settings
[Tailmessage]
; Enable building of tail message
; Either True or False
Enable = True
; Optional alternate path & filename where tail message should be saved
; Default is SkywarnPlus/SOUNDS/wx-tail.wav
; Example: TailmessagePath = /home/repeater/wx-tail.wav
;TailmessagePath =
; Courtesy tone settings
[CourtesyTones]
; Enable Automatic Courtesy Tones
; Either True or False
Enable = True
; Optional alternate directory where tone files are located
; Default is SkywarnPlus/SOUNDS/TONES
; Example: ToneDir = /home/repeater/TONES
;ToneDir =
; Sound file to use for normal local courtesy tone
; Example: LocalCT = BOOP.ulaw
LocalCT = BOOP.ulaw
; Sound file to use for normal link courtesy tone
; Example: LinkCT = BEEP.ulaw
LinkCT = BEEP.ulaw
; Sound file to use for weather courtesy tone (local and link)
; Example: WXCT = WX-CT.ulaw
WXCT = WX-CT.ulaw
; Sound file rpt.conf is looking for as local courtesy tone
; Example: RptLocalCT = CT-LOCAL.ulaw
RptLocalCT = CT-LOCAL.ulaw
; Sound file rpt.conf is looking for as link courtesy tone
; Example: RptLinkCT = CT-LINK.ulaw
RptLinkCT = CT-LINK.ulaw
; CASE SENSITIVE, comma & newline separated list of alerts that trigger the WX courtesy tone
CTAlerts = Hurricane Force Wind Warning,
Severe Thunderstorm Warning,
Tropical Storm Warning,
Winter Storm Warning,
Thunderstorm Warning,
Extreme Wind Warning,
Storm Surge Warning,
Ice Storm Warning,
Hurricane Warning,
Blizzard Warning,
Tornado Warning,
Tornado Watch
; Pushover settings
; Pushover is a free notification service that SkywarnPlus can use to send alerts
; to your phone or other devices. Visit https://pushover.net/ to sign up for free
[Pushover]
; Enable Pushover integration
; Either True or False
Enable = False
; Your Pushover User Key
UserKey =
; Your Pushover API Token
APIToken =
; Enable more verbose Pushover messaging
; Either True or False
Debug = False
; Logging Options
[Logging]
; Enable more verbose logging
; Either True or False
Debug = False
; Optional alternate log file path
; Default is /tmp/Skywarnplus/SkywarnPlus.log
; Example: LogPath = /path/to/log/file
;LogPath =
; Developer options
[DEV]
; Delete cached data on startup
; Either True or False
CLEANSLATE = False
; Optional alternate TMP directory
; Default is /tmp/SkywarnPlus
; Example: TmpDir = /home/repeater/tmp/SkywarnPlus
;TmpDir = /tmp/SkywarnPlus
; Enable to inject the below list of test alerts instead of calling the NWS API
INJECT = False
; CASE SENSITIVE, comma & newline separated list of alerts to inject
INJECTALERTS = Tornado Warning,
Tornado Watch,
Severe Thunderstorm Warning
Loading…
Cancel
Save

Powered by TurnKey Linux.