diff --git a/noaacap/DEBIAN/control b/noaacap/DEBIAN/control index 6bba399..fad6076 100644 --- a/noaacap/DEBIAN/control +++ b/noaacap/DEBIAN/control @@ -1,8 +1,8 @@ Package: noaacap -Version: 1.0.0 +Version: 1.4.0 Section: hamradio Priority: extra Maintainer: Dan Srebnick 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 diff --git a/noaacap/etc/noaacap.conf b/noaacap/etc/noaacap.conf index 8be8a1f..d719a54 100644 --- a/noaacap/etc/noaacap.conf +++ b/noaacap/etc/noaacap.conf @@ -1,7 +1,7 @@ [noaacap] myTZ = America/New_York -myZone = NJC025 +myZone = NJZ012 myResend = 30 -adjZone1 = NJZ012 -adjZone2 = NJZ020 +adjZone1 = NJZ013 +adjZone2 = NJZ014 Logging = 2 diff --git a/noaacap/usr/local/bin/noaacap.py b/noaacap/usr/local/bin/noaacap.py index b5f73b0..95658c1 100755 --- a/noaacap/usr/local/bin/noaacap.py +++ b/noaacap/usr/local/bin/noaacap.py @@ -6,11 +6,12 @@ ## ## See /usr/local/share/noaacap/CHANGELOG for change history ## -version = "1.0" +version = "1.4.0" import sys import pytz import datetime +import dateutil.parser import string import requests 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("A weather alert beacon exec for aprx >= 2.9 and Direwolf >= 1.3") 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) # We need this function early in execution @@ -95,7 +96,7 @@ except: log.error("or 30 (for 1h if beacon cycle-size 2m)") ErrExit() -url = 'https://alerts.weather.gov/cap/wwaatmget.php?x=' + myZone + '&y=0' +url = 'https://api.weather.gov/alerts/active.atom?zone=' + myZone try: r = requests.get(url, timeout=2) @@ -110,9 +111,15 @@ if r.status_code != 200: soup = BeautifulSoup(r.text, 'xml') entries = soup.find_all('entry') count = len(entries) -log.debug("Entry count: " + str(count)) 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", @@ -135,91 +142,76 @@ def vtecparse(value): return ProductClass, Action, Office, Phenomena, Significance, ETN, \ EventBegin, EventEnd -def capchildren(children): - k = None - list = {} - for tag in children: - if tag.name == 'valueName': - 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 +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.debug("Processing entry: " + str(count)) - if ("no active" in entries[i].title.string): - if os.path.isfile(dbfile): - os.remove(dbfile) - break - else: - 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) + 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"): + 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 - VTEC = capchildren(entries[i].parameter.children) + try: ProductClass, Action, Office, Phenomena, Significance, ETN, \ - EventBegin, EventEnd = vtecparse(VTEC['VTEC'].string) + EventBegin, EventEnd = vtecparse(VTEC) except: + log.debug("VTEC parse failed") continue #Loop if error parsing P-VTEC - if (ProductClass != "/O"): #Loop if not operational + if ProductClass != "/O": #Loop if not operational continue + EventEnd = "20" + EventEnd + # Is alert expired? - now = datetime.datetime.utcnow() + now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) # Fix exit on exp = '000000T0000Z' try: - exp = datetime.datetime.strptime(EventEnd,'%y%m%dT%H%MZ') + exp = dateutil.parser.parse(EventEnd) except: 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: log.debug("Alert Expired") continue @@ -228,11 +220,14 @@ for i in range(0, count): id = bytes(str(Office + Phenomena + Significance + ETN), 'utf-8') +# log.debug("ID: " + id.decode('utf-8')) + # Do we have this alert? - if alerts.has_key(id): + 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 + " found") + log.debug(id.decode('utf-8') + " " + updated + " has already been sent") # Is resend behavoir desired? if myResend > 0: @@ -250,7 +245,7 @@ for i in range(0, count): else: continue - alerts[id] = updated + 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) @@ -270,32 +265,88 @@ for i in range(0, count): elif 36 <= s <= 61: t = t + chr(s+61) - # Pull specific alert to get compressed UGC zones - try: - rs = requests.get(entries[i].id.string, timeout=2) - except requests.exceptions.Timeout: - log.error("Timeout exception requesting " + url) - ErrExit() - - 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)) + 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 == '': + 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 + 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') if Action == "CAN": @@ -307,47 +358,13 @@ for i in range(0, count): message = exputc + "z," + type + "," + zcs log.info("Msg: " + message) - # Pull discrete zone UGC values - 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. + # 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 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: log.info("Truncated Msg: " + message) @@ -359,7 +376,7 @@ for i in range(0, count): break -if (hit == 0): +if hit == 0: print() log.info("Exiting") diff --git a/noaacap/usr/local/share/noaacap/CHANGELOG b/noaacap/usr/local/share/noaacap/CHANGELOG index d4599da..4de785d 100644 --- a/noaacap/usr/local/share/noaacap/CHANGELOG +++ b/noaacap/usr/local/share/noaacap/CHANGELOG @@ -1,5 +1,21 @@ 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 Release: February 6, 2020 Added ability to specify up to 2 adjacent zones diff --git a/noaacap/usr/local/share/noaacap/LICENSE b/noaacap/usr/local/share/noaacap/LICENSE index 499c68b..7f641c7 100644 --- a/noaacap/usr/local/share/noaacap/LICENSE +++ b/noaacap/usr/local/share/noaacap/LICENSE @@ -1,6 +1,6 @@ BSD 2-Clause License -Copyright (c) 2017-2020, Daniel L. Srebnick +Copyright (c) 2017-2024, Daniel L. Srebnick All rights reserved. Redistribution and use in source and binary forms, with or without diff --git a/noaacap/usr/local/share/noaacap/README b/noaacap/usr/local/share/noaacap/README index 4b54062..d1e5d2e 100644 --- a/noaacap/usr/local/share/noaacap/README +++ b/noaacap/usr/local/share/noaacap/README @@ -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. 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/. @@ -14,7 +17,7 @@ https://github.com/K2IE/noaacap/releases Once you are running aprx >= 2.9 or direwolf >= 1.3, install noaacap: -Install via sudo dpkg -i noaacap-1.0.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. ==> 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 the following command: timedatectl list-timezones | grep America. -myZone should be set to your county code. A list of county codes may be found -at https://alerts.weather.gov/. Only list one county. The program will then -send the alert for all affected zones. You could instead use a zone code if -you want to be more specific. +myZone should be set to your zone code. A list of zone codes may be found +at https://www.weather.gov/pimar/PubZone. Only list one zone. The program +will send the alert for all affected zones that will fit in the allocated 67 +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 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 02/06/2020 +Updated 04/03/2024