Compare commits

..

13 Commits

Author SHA1 Message Date
Aaron Johnson f473855e29 Merge branch 'master' of https://github.com/K2IE/noaacap 2024-04-22 15:42:05 -05:00
K2IE 4f1e7e49d2 v1.4 Complete rewrite of zone parsing 2024-04-03 10:27:51 -04:00
K2IE 2680769fcd Fix expiration date parsing issue 2024-04-02 13:19:42 -04:00
K2IE 421aca6699 Fix README typo 2024-03-31 10:32:22 -04:00
K2IE 2e3470c41f v1.3 fixes truncation bug, recommends zone vs county, updated README 2024-03-27 16:32:10 -04:00
K2IE a1eb77aa3d Remove unneeded comments in .py 2024-03-27 10:45:48 -04:00
K2IE 5f357c5706 Reintroduce compressed zones in message 2024-03-27 09:53:46 -04:00
K2IE 03668785ab Further bugfixes 2024-03-25 23:19:21 -04:00
K2IE 64c4b7748e Bug fixes 2024-03-25 19:48:13 -04:00
K2IE be3c44f637 Update deb pkg deps and bump version to 1.1 2024-03-25 12:11:25 -04:00
K2IE df64969adf Update noaacap for CAP 1.2 data source 2024-03-25 11:32:32 -04:00
Aaron Johnson 76f6bda7b2 Merge branch 'master' of https://github.com/K2IE/noaacap 2023-05-05 10:28:51 -05:00
K2IE af645f510f
Update README
Update dpkg reference to v 1.0 of the .deb.
2021-10-06 09:20:56 -04:00
6 changed files with 180 additions and 139 deletions

View File

@ -1,8 +1,8 @@
Package: noaacap Package: noaacap
Version: 1.0.0 Version: 1.4.0
Section: hamradio Section: hamradio
Priority: extra Priority: extra
Maintainer: Dan Srebnick <k2ie at k2ie.net> Maintainer: Dan Srebnick <k2ie at k2ie.net>
Architecture: all Architecture: all
Depends: aprx (>=2.9) | direwolf (>=1.3), db-util, python3-lxml, python3-pip, python3-bs4, python3-tz, python3-bsddb3, python3-systemd Depends: aprx (>=2.9) | direwolf (>=1.3), db-util, python3-lxml, python3-pip, python3-bs4, python3-tz, python3-bsddb3, python3-systemd,python3-dateutil
Description: APRS Weather Alerts beacon exec for aprx & Dire Wolf Description: APRS Weather Alerts beacon exec for aprx & Dire Wolf

View File

@ -1,7 +1,7 @@
[noaacap] [noaacap]
myTZ = America/New_York myTZ = America/New_York
myZone = NJC025 myZone = NJZ012
myResend = 30 myResend = 30
adjZone1 = NJZ012 adjZone1 = NJZ013
adjZone2 = NJZ020 adjZone2 = NJZ014
Logging = 2 Logging = 2

View File

@ -6,11 +6,12 @@
## ##
## See /usr/local/share/noaacap/CHANGELOG for change history ## See /usr/local/share/noaacap/CHANGELOG for change history
## ##
version = "1.0" version = "1.4.0"
import sys import sys
import pytz import pytz
import datetime import datetime
import dateutil.parser
import string import string
import requests import requests
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
@ -29,7 +30,7 @@ if len(sys.argv) == 2 and sys.argv[1] == '-v':
print("noaacap.py by K2IE, version " + version + "\n") print("noaacap.py by K2IE, version " + version + "\n")
print("A weather alert beacon exec for aprx >= 2.9 and Direwolf >= 1.3") print("A weather alert beacon exec for aprx >= 2.9 and Direwolf >= 1.3")
print("Licensed under the BSD 2 Clause license") print("Licensed under the BSD 2 Clause license")
print("Copyright 2017-2020 by Daniel L. Srebnick\n") print("Copyright 2017-2024 by Daniel L. Srebnick\n")
sys.exit(0) sys.exit(0)
# We need this function early in execution # We need this function early in execution
@ -95,7 +96,7 @@ except:
log.error("or 30 (for 1h if beacon cycle-size 2m)") log.error("or 30 (for 1h if beacon cycle-size 2m)")
ErrExit() ErrExit()
url = 'https://alerts.weather.gov/cap/wwaatmget.php?x=' + myZone + '&y=0' url = 'https://api.weather.gov/alerts/active.atom?zone=' + myZone
try: try:
r = requests.get(url, timeout=2) r = requests.get(url, timeout=2)
@ -110,9 +111,15 @@ if r.status_code != 200:
soup = BeautifulSoup(r.text, 'xml') soup = BeautifulSoup(r.text, 'xml')
entries = soup.find_all('entry') entries = soup.find_all('entry')
count = len(entries) count = len(entries)
log.debug("Entry count: " + str(count))
dbfile = '/dev/shm/noaaconf.db' 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' ppmap = '/usr/local/share/noaacap/ppmap.db'
sg = {"W":"WARN ","A":"WATCH","Y":"ADVIS","S":"STMNT","F":"4CAST", sg = {"W":"WARN ","A":"WATCH","Y":"ADVIS","S":"STMNT","F":"4CAST",
@ -135,58 +142,16 @@ def vtecparse(value):
return ProductClass, Action, Office, Phenomena, Significance, ETN, \ return ProductClass, Action, Office, Phenomena, Significance, ETN, \
EventBegin, EventEnd EventBegin, EventEnd
def capchildren(children): def move_entry(mylist,entry_val,entry_pos):
k = None z_index = mylist.index(entry_val)
list = {} z_value = mylist.pop(z_index)
for tag in children: mylist.insert(entry_pos,z_value)
if tag.name == 'valueName': return mylist
k = tag.string
elif tag.name == 'value':
list[k] = tag.string
return list
def parsezcs(zcs):
zonelist = set()
inprogress = 0
newprefix = 1
for z in range(0, len(zcs)):
if not inprogress:
zone = ''
inprogress = 1
if re.match('[A-Z]',zcs[z]):
if newprefix == 1:
prefix = ''
newprefix = 0
prefix = prefix + zcs[z]
if re.match('[0-9]',zcs[z]):
zone = zone + zcs[z]
if zcs[z] == '>':
zonelist.add(prefix + zone)
inprogress = 0
endzone = zcs[z+1:z+4]
z = z + 3
startzone = int(zone) + 1
endzone = int(endzone)
for y in range(startzone, endzone):
zonelist.add(prefix + str(y).zfill(3))
elif zcs[z] == '-':
zonelist.add(prefix + zone)
inprogress = 0
if re.match('[A-Z]',zcs[z+1]):
newprefix = 1
if inprogress:
zonelist.add(prefix + zone)
return zonelist
hit = 0 hit = 0
for i in range(0, count): for i in range(0, count):
log.debug("Processing entry: " + str(count)) log.info("Processing entry: " + str(i + 1) + " of " + str(count))
if ("no active" in entries[i].title.string):
if os.path.isfile(dbfile):
os.remove(dbfile)
break
else:
if i == 0: if i == 0:
alerts = db.DB() alerts = db.DB()
alerts.open(dbfile, "Alerts", db.DB_HASH, db.DB_CREATE) alerts.open(dbfile, "Alerts", db.DB_HASH, db.DB_CREATE)
@ -198,28 +163,55 @@ for i in range(0, count):
updated = entries[i].updated.string updated = entries[i].updated.string
if (entries[i].status.string == "Actual"): 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 # Parse P-VTEC string and make sure this is an operational ProductClass
VTEC = capchildren(entries[i].parameter.children)
try: try:
ProductClass, Action, Office, Phenomena, Significance, ETN, \ ProductClass, Action, Office, Phenomena, Significance, ETN, \
EventBegin, EventEnd = vtecparse(VTEC['VTEC'].string) EventBegin, EventEnd = vtecparse(VTEC)
except: except:
log.debug("VTEC parse failed")
continue #Loop if error parsing P-VTEC continue #Loop if error parsing P-VTEC
if (ProductClass != "/O"): #Loop if not operational if ProductClass != "/O": #Loop if not operational
continue continue
EventEnd = "20" + EventEnd
# Is alert expired? # Is alert expired?
now = datetime.datetime.utcnow() now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0)
# Fix exit on exp = '000000T0000Z' # Fix exit on exp = '000000T0000Z'
try: try:
exp = datetime.datetime.strptime(EventEnd,'%y%m%dT%H%MZ') exp = dateutil.parser.parse(EventEnd)
except: except:
exp = now exp = now
log.debug('Now: ' + datetime.datetime.strftime(now,"%y-%m-%d %H:%M") + \
' Exp: ' + datetime.datetime.strftime(exp,"%y-%m-%d %H:%M")) # 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: if now > exp:
log.debug("Alert Expired") log.debug("Alert Expired")
continue continue
@ -228,11 +220,14 @@ for i in range(0, count):
id = bytes(str(Office + Phenomena + Significance + ETN), 'utf-8') id = bytes(str(Office + Phenomena + Significance + ETN), 'utf-8')
# log.debug("ID: " + id.decode('utf-8'))
# Do we have this alert? # Do we have this alert?
if alerts.has_key(id): if id in alerts:
# Is the updated time unchanged? # Is the updated time unchanged?
# log.debug('ID found in alerts. Now compare last updated time.')
if alerts[id].decode('utf-8') == updated: if alerts[id].decode('utf-8') == updated:
log.debug(id.decode('utf-8') + " " + updated + " found") log.debug(id.decode('utf-8') + " " + updated + " has already been sent")
# Is resend behavoir desired? # Is resend behavoir desired?
if myResend > 0: if myResend > 0:
@ -250,7 +245,7 @@ for i in range(0, count):
else: else:
continue continue
alerts[id] = updated alerts.put(id, updated.encode('utf-8'))
effutc = aprstime(entries[i].effective.string[0:-6],myTZ) effutc = aprstime(entries[i].effective.string[0:-6],myTZ)
exputc = aprstime(entries[i].expires.string[0:-6],myTZ) exputc = aprstime(entries[i].expires.string[0:-6],myTZ)
@ -270,32 +265,88 @@ for i in range(0, count):
elif 36 <= s <= 61: elif 36 <= s <= 61:
t = t + chr(s+61) t = t + chr(s+61)
# Pull specific alert to get compressed UGC zones zcs_discrete = []
try: for j in soup.select('area'):
rs = requests.get(entries[i].id.string, timeout=2) for k in j.select('geocode'):
except requests.exceptions.Timeout: if (k.find('valueName').text == 'UGC'):
log.error("Timeout exception requesting " + url) addzcs = k.find('value').text
ErrExit() zcs_discrete.append(addzcs)
if rs.status_code != 200:
log.error(str(rs.status_code) + " " + rs)
ErrExit()
soup2 = BeautifulSoup(rs.text, 'xml')
parms = soup2.find_all('parameter')
j = len(parms)
zcs = ''
for j in range(0, len(parms)):
if parms[j].valueName.string == 'UGC':
zcs = parms[j].value.string
log.debug('Parameters follow\n' + str(parms))
# This handles messages found to contain empty UGC list # This handles messages found to contain empty UGC list
if zcs == '': if zcs_discrete == []:
log.debug('No UGC list in message') log.debug('No UGC list in message')
continue continue
zcs = ''
sorted_zcs = (sorted(zcs_discrete))
# Move myZone to first position in case of truncation
move_entry(sorted_zcs,myZone,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') type = pp[bytes(Phenomena, 'utf-8')].decode('utf-8')
if Action == "CAN": if Action == "CAN":
@ -307,47 +358,13 @@ for i in range(0, count):
message = exputc + "z," + type + "," + zcs message = exputc + "z," + type + "," + zcs
log.info("Msg: " + message) log.info("Msg: " + message)
# Pull discrete zone UGC values # Make sure message does not exceed 67 chars. If it does, trim it.
soup3 = BeautifulSoup(rs.text, 'xml')
parms = soup3.find_all('geocode')
j = len(parms)
zcs_discrete = ''
for j in range(0, len(parms)):
if parms[j].valueName.string == 'UGC':
zcs_discrete = zcs_discrete + parms[j].value.string + "\n"
# Determine if adjacent zones are in original message prior
# to any necessary truncation.
adjZone1True = 0
adjZone2True = 0
if adjZone1 != '' and adjZone1 in zcs_discrete:
adjZone1True = 1
log.debug("adjZone1 found in alert data")
if adjZone2 != '' and adjZone2 in zcs_discrete:
adjZone2True = 1
log.debug("adjZone2 found in alert data")
# Make sure message does not exceed 67 chars. If it
# does, trim it. Also be certain that myZone is included
# included in the trimmed zcs.
n = 0 n = 0
while len(message) > 67: while len(message) > 67:
n = zcs.rfind('-') n = zcs.rfind('-')
zcs = zcs[0:n] zcs = zcs[0:n]
message = exputc + "z," + type + "," + zcs message = exputc + "z," + type + "," + zcs
if not myZone in parsezcs(zcs):
zcs = myZone + "-" + zcs
message = exputc + "z," + type + "," + zcs
if adjZone1True and not adjZone1 in parsezcs(zcs):
zcs = adjZone1 + "-" + zcs
message = exputc + "z," + type + "," + zcs
if adjZone2True and not adjZone2 in parsezcs(zcs):
zcs = adjZone2 + "-" + zcs
message = exputc + "z," + type + "," + zcs
if n > 0: if n > 0:
log.info("Truncated Msg: " + message) log.info("Truncated Msg: " + message)
@ -359,7 +376,7 @@ for i in range(0, count):
break break
if (hit == 0): if hit == 0:
print() print()
log.info("Exiting") log.info("Exiting")

View File

@ -1,5 +1,21 @@
noaacap changelog noaacap changelog
## ##
Version: 1.3.1
Release: April 2, 2024
Fix expiration date parsing issue
##
Version: 1.3
Release: TBD
Fixed truncation bug in compressed zone list
##
Version: 1.2
Release: March 27, 2024
Reintroduced compressed zones and other improvements
##
Version: 1.1
Release: March 25, 2024
Updated to use CAP 1.2 data source
##
Version: 1.0 Version: 1.0
Release: February 6, 2020 Release: February 6, 2020
Added ability to specify up to 2 adjacent zones Added ability to specify up to 2 adjacent zones

View File

@ -1,6 +1,6 @@
BSD 2-Clause License BSD 2-Clause License
Copyright (c) 2017-2020, Daniel L. Srebnick Copyright (c) 2017-2024, Daniel L. Srebnick
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without

View File

@ -1,3 +1,6 @@
The current release of noaacap is 1.4.0. If you are running older than this
version, please update before asking for support.
This software is written in python3 and has been tested with aprx 2.9.x. This software is written in python3 and has been tested with aprx 2.9.x.
If you are running a version < 2.9.0 you should first update aprx. The current If you are running a version < 2.9.0 you should first update aprx. The current
release of aprx may be found at http://thelifeofkenneth.com/aprx/debs/. release of aprx may be found at http://thelifeofkenneth.com/aprx/debs/.
@ -14,7 +17,7 @@ https://github.com/K2IE/noaacap/releases
Once you are running aprx >= 2.9 or direwolf >= 1.3, install noaacap: Once you are running aprx >= 2.9 or direwolf >= 1.3, install noaacap:
Install via sudo dpkg -i noaacap-0.9.1.deb. Then, to satisfy the dependencies, Install via sudo dpkg -i noaacap-1.4.deb. Then, to satisfy the dependencies,
run sudo apt-get -f install. run sudo apt-get -f install.
==> APRX CONFIGURATION <== ==> APRX CONFIGURATION <==
@ -57,10 +60,14 @@ myTZ should be set to the Linux TZ value for the issuing office of your
preferred weather zone. For a complete list of timezones, you may use preferred weather zone. For a complete list of timezones, you may use
the following command: timedatectl list-timezones | grep America. the following command: timedatectl list-timezones | grep America.
myZone should be set to your county code. A list of county codes may be found myZone should be set to your zone code. A list of zone codes may be found
at https://alerts.weather.gov/. Only list one county. The program will then at https://www.weather.gov/pimar/PubZone. Only list one zone. The program
send the alert for all affected zones. You could instead use a zone code if will send the alert for all affected zones that will fit in the allocated 67
you want to be more specific. characters. You could instead use a county code if you wish. However the
associated zone may also be sent, wasting characters.
As an example, if you're located in Lancaster, PA, the map shows that you
are in zone 066. Enter PAZ066 for myZone.
myResend should be set to 0 for no message resends or to an interval of your myResend should be set to 0 for no message resends or to an interval of your
choosing. If noaacap beacons every 2 minutes, a value of 30 would provide choosing. If noaacap beacons every 2 minutes, a value of 30 would provide
@ -82,3 +89,4 @@ weather alerts via this program.
Dan Srebnick, K2IE Dan Srebnick, K2IE
02/06/2020 02/06/2020
Updated 04/03/2024