noaacap/noaacap/usr/local/bin/noaacap.py

386 lines
10 KiB
Python
Executable File

#!/usr/bin/python3
## Author: Dan Srebnick, K2IE
## License: BSD-2-Clause (/usr/local/share/noaacap/LICENSE)
## This program is called from aprx.
##
## See /usr/local/share/noaacap/CHANGELOG for change history
##
version = "1.4.2"
import sys
import pytz
import datetime
import dateutil.parser
import string
import requests
from bs4 import BeautifulSoup
from bsddb3 import db
from os import remove
import os.path
import configparser
import re
import logging
from systemd.journal import JournalHandler
log = logging.getLogger('noaacap')
log.addHandler(JournalHandler(SYSLOG_IDENTIFIER='noaacap'))
if len(sys.argv) == 2 and sys.argv[1] == '-v':
print("noaacap.py by K2IE, version " + version + "\n")
print("A weather alert beacon exec for aprx >= 2.9 and Direwolf >= 1.3")
print("Licensed under the BSD 2 Clause license")
print("Copyright 2017-2024 by Daniel L. Srebnick\n")
sys.exit(0)
# We need this function early in execution
def ErrExit():
log.error("Exiting")
print()
exit(0)
conffile = '/etc/noaacap.conf'
if not os.path.isfile(conffile):
log.error(conffile + " not found")
ErrExit()
config = configparser.ConfigParser()
try:
config.read(conffile)
except:
log.error("Check " + conffile + " for proper [noaacap] section heading")
ErrExit()
try:
Logging = config.get('noaacap', 'Logging')
except:
log.error("Check " + conffile + " for proper Logging value in [noaacap] section")
ErrExit()
if Logging == '1':
log.setLevel(logging.INFO)
elif Logging == '2':
log.setLevel(logging.DEBUG)
log.info("Starting")
try:
myTZ = config.get('noaacap', 'myTZ')
except:
log.error("Check " + conffile + " for proper myTZ value in [noaacap] section")
ErrExit()
try:
myZone = config.get('noaacap', 'myZone')
except:
log.error("Check " + conffile + " for proper myZone value in [noaacap] section")
ErrExit()
try:
adjZone1 = config.get('noaacap', 'adjZone1')
except:
adjZone1 = ''
try:
adjZone2 = config.get('noaacap', 'adjZone2')
except:
adjZone2 = ''
try:
myResend = int(config.get('noaacap', 'myResend'))
except:
log.error("Check " + conffile + " for proper myResend value in [noaacap] section")
log.error("Try 0 (for no resend)")
log.error("or 30 (for 1h if beacon cycle-size 2m)")
ErrExit()
url = 'https://api.weather.gov/alerts/active.atom?zone=' + myZone
try:
r = requests.get(url, timeout=2)
except requests.exceptions.Timeout:
log.error("Timeout exception requesting " + url)
ErrExit()
if r.status_code != 200:
log.error(str(r.status_code) + " " + url)
ErrExit()
soup = BeautifulSoup(r.text, 'xml')
entries = soup.find_all('entry')
count = len(entries)
dbfile = '/dev/shm/noaaconf.db'
if count == 0:
log.info("Exiting - no events found")
if os.path.isfile(dbfile):
os.remove(dbfile)
print()
exit(0)
ppmap = '/usr/local/share/noaacap/ppmap.db'
sg = {"W":"WARN ","A":"WATCH","Y":"ADVIS","S":"STMNT","F":"4CAST",
"O":"OUTLK","N":"SYNOP"}
# Define functions used in the for loop
def aprstime(timestr,TZ):
local = pytz.timezone (TZ)
naive = datetime.datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%S")
local_dt = local.localize(naive, is_dst=None)
utc_dt = local_dt.astimezone (pytz.utc)
return utc_dt.strftime ("%d%H%M")
def vtecparse(value):
line = value.split("\n")[0]
ProductClass, Action, Office, Phenomena, Significance, ETN, DTGroup = \
line.split('.')
EventBegin, EventEnd = DTGroup.split('-')
EventEnd = EventEnd.rstrip('/')
return ProductClass, Action, Office, Phenomena, Significance, ETN, \
EventBegin, EventEnd
def move_entry(mylist,entry_val,entry_pos):
z_index = mylist.index(entry_val)
z_value = mylist.pop(z_index)
mylist.insert(entry_pos,z_value)
return mylist
hit = 0
for i in range(0, count):
log.info("Processing entry: " + str(i + 1) + " of " + str(count))
if i == 0:
alerts = db.DB()
alerts.open(dbfile, "Alerts", db.DB_HASH, db.DB_CREATE)
if myResend > 0:
resend = db.DB()
resend.open(dbfile, "Resend", db.DB_HASH, db.DB_CREATE)
pp = db.DB()
pp.open(ppmap, None, db.DB_HASH, db.DB_RDONLY)
updated = entries[i].updated.string
if entries[i].status.string == "Actual":
# Retrieve link for entry i
url = entries[i].id.string + ".cap"
try:
r = requests.get(url, timeout=2)
except requests.exceptions.Timeout:
log.error("Timeout exception requesting " + url)
ErrExit()
if r.status_code != 200:
log.error(str(r1.status_code) + " " + url)
ErrExit()
soup = BeautifulSoup(r.text, 'xml')
VTEC = ''
for j in soup.select('parameter'):
if j.find('valueName').text == 'VTEC':
VTEC = j.find('value').text
log.debug("VTEC String: " + VTEC)
# Parse P-VTEC string and make sure this is an operational ProductClass
try:
ProductClass, Action, Office, Phenomena, Significance, ETN, \
EventBegin, EventEnd = vtecparse(VTEC)
except:
log.debug("VTEC parse failed")
continue #Loop if error parsing P-VTEC
if ProductClass != "/O": #Loop if not operational
continue
EventEnd = "20" + EventEnd
# Is alert expired?
now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
# Fix exit on exp = '000000T0000Z'
try:
exp = dateutil.parser.parse(EventEnd)
except:
exp = now
# log.debug('Time Now: ' + datetime.datetime.strftime(now,"%y-%m-%d %H:%M"))
# log.debug('Expiration: ' + datetime.datetime.strftime(exp,"%y-%m-%d %H:%M"))
if now > exp:
log.debug("Alert Expired")
continue
else:
log.debug("Alert Valid")
id = bytes(str(Office + Phenomena + Significance + ETN), 'utf-8')
# log.debug("ID: " + id.decode('utf-8'))
# Do we have this alert?
if id in alerts:
# Is the updated time unchanged?
# log.debug('ID found in alerts. Now compare last updated time.')
if alerts[id].decode('utf-8') == updated:
log.debug(id.decode('utf-8') + " " + updated + " has already been sent")
# Is resend behavoir desired?
if myResend > 0:
try:
recount = int(resend[id].decode('utf-8')) - 1
except:
resend[id] = bytes(str(myResend), 'utf-8')
recount = int(resend[id].decode('utf-8')) - 1
if recount > 0:
resend[id] = bytes(str(recount), 'utf-8')
log.debug(id.decode('utf-8') + " resend in " + str(recount) +
" iterations")
continue
else:
continue
alerts.put(id, updated.encode('utf-8'))
effutc = aprstime(entries[i].effective.string[0:-6],myTZ)
exputc = aprstime(entries[i].expires.string[0:-6],myTZ)
# Compress effective time to 3 byte
dd = int(effutc[0:2])
hh = int(effutc[2:4])
mm = int(effutc[4:6])
t = ''
for s in (dd, hh, mm):
if 0 <= s <= 9:
t = t + chr(s+48)
elif 10 <= s <= 35:
t = t + chr(s+55)
elif 36 <= s <= 61:
t = t + chr(s+61)
zcs_discrete = []
for j in soup.select('area'):
for k in j.select('geocode'):
if (k.find('valueName').text == 'UGC'):
addzcs = k.find('value').text
zcs_discrete.append(addzcs)
# This handles messages found to contain empty UGC list
if zcs_discrete == []:
log.debug('No UGC list in message')
continue
zcs = ''
sorted_zcs = (sorted(zcs_discrete))
# Move myZone to first position in case of truncation
if myZone in sorted_zcs:
move_entry(sorted_zcs,myZone,0)
log.debug("myZone found in sorted zcs and moved to index 0")
# Move adjacent zones, if present, after myZone
adjZone2Index = 1
if adjZone1 != '' and adjZone1 in sorted_zcs:
adjZone2Index = 2
move_entry(sorted_zcs,adjZone1,1)
log.debug("adjZone1 found in sorted zcs and moved to index 1")
if adjZone2 != '' and adjZone2 in sorted_zcs:
move_entry(sorted_zcs,adjZone2,adjZone2Index)
log.debug("adjZone2 found in sorted zcs and moved to index " + str(adjZone2Index))
prev_item = ''
prev_prefix = ''
prev_suffix = ''
next_item = ''
# next_prefix = ''
next_suffix = ''
k = 0
for j in sorted_zcs:
k += 1
curr_item = j
try:
next_item = sorted_zcs[k]
except:
next_item = ''
if next_item != '':
# next_prefix = next_item[0:3]
next_suffix = next_item[3:6]
i_next_suffix = int(next_suffix)
curr_prefix = curr_item[0:3]
curr_suffix = curr_item[3:6]
i_curr_suffix = int(curr_suffix)
i_curr_plus1 = i_curr_suffix + 1
# Always print entire first item
if k == 1:
zcs = curr_item
# Did prefix change?
elif prev_prefix != curr_prefix:
zcs = zcs + "-" + curr_item
# Current suffix is 1 more than previous
elif i_prev_plus1 == i_curr_suffix:
if i_next_suffix != i_curr_plus1:
zcs = zcs + ">" + curr_suffix
# Current suffix is not 1 more than previous
elif i_prev_plus1 != i_curr_suffix:
zcs = zcs + "-" + curr_suffix
else:
log.error("Unexpected condition during zcs compression")
ErrExit()
# For debugging only
# print (zcs)
prev_item = curr_item
prev_prefix = curr_prefix
prev_suffix = curr_suffix
i_prev_suffix = int(prev_suffix)
i_prev_plus1 = i_prev_suffix + 1
log.debug("ZCS Value: " + zcs)
type = pp[bytes(Phenomena, 'utf-8')].decode('utf-8')
if Action == "CAN":
event = "CANCL"
else:
event = sg[Significance]
hit = 1
message = exputc + "z," + type + "," + zcs
log.info("Msg: " + message)
# Make sure message does not exceed 67 chars. If it does, trim it.
n = 0
while len(message) > 67:
n = zcs.rfind('-')
zcs = zcs[0:n]
message = exputc + "z," + type + "," + zcs
if n > 0:
log.info("Truncated Msg: " + message)
line = "{" + t + "00"
print(":NWS_" + event + ":" + message + line)
if myResend > 0:
resend[id] = bytes(str(myResend), 'utf-8')
break
if hit == 0:
print()
log.info("Exiting")
exit(0)