├── CHANGELOG ├── LICENSE ├── README └── noaacap ├── DEBIAN ├── conffiles ├── control └── copyright ├── etc └── noaacap.conf └── usr └── local ├── bin └── noaacap.py └── share └── noaacap ├── CHANGELOG ├── LICENSE ├── README └── ppmap.db /CHANGELOG: -------------------------------------------------------------------------------- 1 | noaacap/usr/local/share/noaacap/CHANGELOG -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | noaacap/usr/local/share/noaacap/LICENSE -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | noaacap/usr/local/share/noaacap/README -------------------------------------------------------------------------------- /noaacap/DEBIAN/conffiles: -------------------------------------------------------------------------------- 1 | /etc/noaacap.conf 2 | -------------------------------------------------------------------------------- /noaacap/DEBIAN/control: -------------------------------------------------------------------------------- 1 | Package: noaacap 2 | Version: 1.4.0 3 | Section: hamradio 4 | Priority: extra 5 | Maintainer: Dan Srebnick 6 | Architecture: all 7 | Depends: aprx (>=2.9) | direwolf (>=1.3), db-util, python3-lxml, python3-pip, python3-bs4, python3-tz, python3-bsddb3, python3-systemd,python3-dateutil 8 | Description: APRS Weather Alerts beacon exec for aprx & Dire Wolf 9 | -------------------------------------------------------------------------------- /noaacap/DEBIAN/copyright: -------------------------------------------------------------------------------- 1 | Copyright: 2017-2020 Daniel L. Srebnick 2 | License: BSD-2-clause 3 | Files: * 4 | -------------------------------------------------------------------------------- /noaacap/etc/noaacap.conf: -------------------------------------------------------------------------------- 1 | [noaacap] 2 | myTZ = America/New_York 3 | myZone = NJZ012 4 | myResend = 30 5 | adjZone1 = NJZ013 6 | adjZone2 = NJZ014 7 | Logging = 2 8 | -------------------------------------------------------------------------------- /noaacap/usr/local/bin/noaacap.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | ## Author: Dan Srebnick, K2IE 4 | ## License: BSD-2-Clause (/usr/local/share/noaacap/LICENSE) 5 | ## This program is called from aprx. 6 | ## 7 | ## See /usr/local/share/noaacap/CHANGELOG for change history 8 | ## 9 | version = "1.4.0" 10 | 11 | import sys 12 | import pytz 13 | import datetime 14 | import dateutil.parser 15 | import string 16 | import requests 17 | from bs4 import BeautifulSoup 18 | from bsddb3 import db 19 | from os import remove 20 | import os.path 21 | import configparser 22 | import re 23 | import logging 24 | from systemd.journal import JournalHandler 25 | 26 | log = logging.getLogger('noaacap') 27 | log.addHandler(JournalHandler(SYSLOG_IDENTIFIER='noaacap')) 28 | 29 | if len(sys.argv) == 2 and sys.argv[1] == '-v': 30 | print("noaacap.py by K2IE, version " + version + "\n") 31 | print("A weather alert beacon exec for aprx >= 2.9 and Direwolf >= 1.3") 32 | print("Licensed under the BSD 2 Clause license") 33 | print("Copyright 2017-2024 by Daniel L. Srebnick\n") 34 | sys.exit(0) 35 | 36 | # We need this function early in execution 37 | def ErrExit(): 38 | log.error("Exiting") 39 | print() 40 | exit(0) 41 | 42 | conffile = '/etc/noaacap.conf' 43 | 44 | if not os.path.isfile(conffile): 45 | log.error(conffile + " not found") 46 | ErrExit() 47 | 48 | config = configparser.ConfigParser() 49 | 50 | try: 51 | config.read(conffile) 52 | except: 53 | log.error("Check " + conffile + " for proper [noaacap] section heading") 54 | ErrExit() 55 | 56 | try: 57 | Logging = config.get('noaacap', 'Logging') 58 | except: 59 | log.error("Check " + conffile + " for proper Logging value in [noaacap] section") 60 | ErrExit() 61 | 62 | if Logging == '1': 63 | log.setLevel(logging.INFO) 64 | elif Logging == '2': 65 | log.setLevel(logging.DEBUG) 66 | 67 | log.info("Starting") 68 | 69 | try: 70 | myTZ = config.get('noaacap', 'myTZ') 71 | except: 72 | log.error("Check " + conffile + " for proper myTZ value in [noaacap] section") 73 | ErrExit() 74 | 75 | try: 76 | myZone = config.get('noaacap', 'myZone') 77 | except: 78 | log.error("Check " + conffile + " for proper myZone value in [noaacap] section") 79 | ErrExit() 80 | 81 | try: 82 | adjZone1 = config.get('noaacap', 'adjZone1') 83 | except: 84 | adjZone1 = '' 85 | 86 | try: 87 | adjZone2 = config.get('noaacap', 'adjZone2') 88 | except: 89 | adjZone2 = '' 90 | 91 | try: 92 | myResend = int(config.get('noaacap', 'myResend')) 93 | except: 94 | log.error("Check " + conffile + " for proper myResend value in [noaacap] section") 95 | log.error("Try 0 (for no resend)") 96 | log.error("or 30 (for 1h if beacon cycle-size 2m)") 97 | ErrExit() 98 | 99 | url = 'https://api.weather.gov/alerts/active.atom?zone=' + myZone 100 | 101 | try: 102 | r = requests.get(url, timeout=2) 103 | except requests.exceptions.Timeout: 104 | log.error("Timeout exception requesting " + url) 105 | ErrExit() 106 | 107 | if r.status_code != 200: 108 | log.error(str(r.status_code) + " " + url) 109 | ErrExit() 110 | 111 | soup = BeautifulSoup(r.text, 'xml') 112 | entries = soup.find_all('entry') 113 | count = len(entries) 114 | 115 | dbfile = '/dev/shm/noaaconf.db' 116 | if count == 0: 117 | log.info("Exiting - no events found") 118 | if os.path.isfile(dbfile): 119 | os.remove(dbfile) 120 | print() 121 | exit(0) 122 | 123 | ppmap = '/usr/local/share/noaacap/ppmap.db' 124 | 125 | sg = {"W":"WARN ","A":"WATCH","Y":"ADVIS","S":"STMNT","F":"4CAST", 126 | "O":"OUTLK","N":"SYNOP"} 127 | 128 | # Define functions used in the for loop 129 | def aprstime(timestr,TZ): 130 | local = pytz.timezone (TZ) 131 | naive = datetime.datetime.strptime(timestr, "%Y-%m-%dT%H:%M:%S") 132 | local_dt = local.localize(naive, is_dst=None) 133 | utc_dt = local_dt.astimezone (pytz.utc) 134 | return utc_dt.strftime ("%d%H%M") 135 | 136 | def vtecparse(value): 137 | line = value.split("\n")[0] 138 | ProductClass, Action, Office, Phenomena, Significance, ETN, DTGroup = \ 139 | line.split('.') 140 | EventBegin, EventEnd = DTGroup.split('-') 141 | EventEnd = EventEnd.rstrip('/') 142 | return ProductClass, Action, Office, Phenomena, Significance, ETN, \ 143 | EventBegin, EventEnd 144 | 145 | def move_entry(mylist,entry_val,entry_pos): 146 | z_index = mylist.index(entry_val) 147 | z_value = mylist.pop(z_index) 148 | mylist.insert(entry_pos,z_value) 149 | return mylist 150 | 151 | hit = 0 152 | for i in range(0, count): 153 | 154 | log.info("Processing entry: " + str(i + 1) + " of " + str(count)) 155 | if i == 0: 156 | alerts = db.DB() 157 | alerts.open(dbfile, "Alerts", db.DB_HASH, db.DB_CREATE) 158 | if myResend > 0: 159 | resend = db.DB() 160 | resend.open(dbfile, "Resend", db.DB_HASH, db.DB_CREATE) 161 | pp = db.DB() 162 | pp.open(ppmap, None, db.DB_HASH, db.DB_RDONLY) 163 | 164 | updated = entries[i].updated.string 165 | 166 | if entries[i].status.string == "Actual": 167 | 168 | # Retrieve link for entry i 169 | url = entries[i].id.string + ".cap" 170 | 171 | try: 172 | r = requests.get(url, timeout=2) 173 | except requests.exceptions.Timeout: 174 | log.error("Timeout exception requesting " + url) 175 | ErrExit() 176 | 177 | if r.status_code != 200: 178 | log.error(str(r1.status_code) + " " + url) 179 | ErrExit() 180 | 181 | soup = BeautifulSoup(r.text, 'xml') 182 | 183 | VTEC = '' 184 | for j in soup.select('parameter'): 185 | if j.find('valueName').text == 'VTEC': 186 | VTEC = j.find('value').text 187 | 188 | log.debug("VTEC String: " + VTEC) 189 | 190 | # Parse P-VTEC string and make sure this is an operational ProductClass 191 | 192 | try: 193 | ProductClass, Action, Office, Phenomena, Significance, ETN, \ 194 | EventBegin, EventEnd = vtecparse(VTEC) 195 | except: 196 | log.debug("VTEC parse failed") 197 | continue #Loop if error parsing P-VTEC 198 | 199 | if ProductClass != "/O": #Loop if not operational 200 | continue 201 | 202 | EventEnd = "20" + EventEnd 203 | 204 | # Is alert expired? 205 | now = datetime.datetime.now(datetime.timezone.utc).replace(microsecond=0) 206 | # Fix exit on exp = '000000T0000Z' 207 | try: 208 | exp = dateutil.parser.parse(EventEnd) 209 | except: 210 | exp = now 211 | 212 | # log.debug('Time Now: ' + datetime.datetime.strftime(now,"%y-%m-%d %H:%M")) 213 | # log.debug('Expiration: ' + datetime.datetime.strftime(exp,"%y-%m-%d %H:%M")) 214 | 215 | if now > exp: 216 | log.debug("Alert Expired") 217 | continue 218 | else: 219 | log.debug("Alert Valid") 220 | 221 | id = bytes(str(Office + Phenomena + Significance + ETN), 'utf-8') 222 | 223 | # log.debug("ID: " + id.decode('utf-8')) 224 | 225 | # Do we have this alert? 226 | if id in alerts: 227 | # Is the updated time unchanged? 228 | # log.debug('ID found in alerts. Now compare last updated time.') 229 | if alerts[id].decode('utf-8') == updated: 230 | log.debug(id.decode('utf-8') + " " + updated + " has already been sent") 231 | # Is resend behavoir desired? 232 | if myResend > 0: 233 | 234 | try: 235 | recount = int(resend[id].decode('utf-8')) - 1 236 | except: 237 | resend[id] = bytes(str(myResend), 'utf-8') 238 | recount = int(resend[id].decode('utf-8')) - 1 239 | 240 | if recount > 0: 241 | resend[id] = bytes(str(recount), 'utf-8') 242 | log.debug(id.decode('utf-8') + " resend in " + str(recount) + 243 | " iterations") 244 | continue 245 | else: 246 | continue 247 | 248 | alerts.put(id, updated.encode('utf-8')) 249 | 250 | effutc = aprstime(entries[i].effective.string[0:-6],myTZ) 251 | exputc = aprstime(entries[i].expires.string[0:-6],myTZ) 252 | 253 | # Compress effective time to 3 byte 254 | 255 | dd = int(effutc[0:2]) 256 | hh = int(effutc[2:4]) 257 | mm = int(effutc[4:6]) 258 | 259 | t = '' 260 | for s in (dd, hh, mm): 261 | if 0 <= s <= 9: 262 | t = t + chr(s+48) 263 | elif 10 <= s <= 35: 264 | t = t + chr(s+55) 265 | elif 36 <= s <= 61: 266 | t = t + chr(s+61) 267 | 268 | zcs_discrete = [] 269 | for j in soup.select('area'): 270 | for k in j.select('geocode'): 271 | if (k.find('valueName').text == 'UGC'): 272 | addzcs = k.find('value').text 273 | zcs_discrete.append(addzcs) 274 | 275 | # This handles messages found to contain empty UGC list 276 | if zcs_discrete == []: 277 | log.debug('No UGC list in message') 278 | continue 279 | 280 | zcs = '' 281 | sorted_zcs = (sorted(zcs_discrete)) 282 | 283 | # Move myZone to first position in case of truncation 284 | move_entry(sorted_zcs,myZone,0) 285 | 286 | # Move adjacent zones, if present, after myZone 287 | adjZone2Index = 1 288 | if adjZone1 != '' and adjZone1 in sorted_zcs: 289 | adjZone2Index = 2 290 | move_entry(sorted_zcs,adjZone1,1) 291 | log.debug("adjZone1 found in sorted zcs and moved to index 1") 292 | 293 | if adjZone2 != '' and adjZone2 in sorted_zcs: 294 | move_entry(sorted_zcs,adjZone2,adjZone2Index) 295 | log.debug("adjZone2 found in sorted zcs and moved to index " + str(adjZone2Index)) 296 | 297 | prev_item = '' 298 | prev_prefix = '' 299 | prev_suffix = '' 300 | next_item = '' 301 | # next_prefix = '' 302 | next_suffix = '' 303 | 304 | k = 0 305 | for j in sorted_zcs: 306 | k += 1 307 | curr_item = j 308 | 309 | try: 310 | next_item = sorted_zcs[k] 311 | except: 312 | next_item = '' 313 | if next_item != '': 314 | # next_prefix = next_item[0:3] 315 | next_suffix = next_item[3:6] 316 | i_next_suffix = int(next_suffix) 317 | 318 | curr_prefix = curr_item[0:3] 319 | curr_suffix = curr_item[3:6] 320 | i_curr_suffix = int(curr_suffix) 321 | i_curr_plus1 = i_curr_suffix + 1 322 | 323 | # Always print entire first item 324 | if k == 1: 325 | zcs = curr_item 326 | # Did prefix change? 327 | elif prev_prefix != curr_prefix: 328 | zcs = zcs + "-" + curr_item 329 | # Current suffix is 1 more than previous 330 | elif i_prev_plus1 == i_curr_suffix: 331 | if i_next_suffix != i_curr_plus1: 332 | zcs = zcs + ">" + curr_suffix 333 | # Current suffix is not 1 more than previous 334 | elif i_prev_plus1 != i_curr_suffix: 335 | zcs = zcs + "-" + curr_suffix 336 | else: 337 | log.error("Unexpected condition during zcs compression") 338 | ErrExit() 339 | 340 | # For debugging only 341 | # print (zcs) 342 | 343 | prev_item = curr_item 344 | prev_prefix = curr_prefix 345 | prev_suffix = curr_suffix 346 | i_prev_suffix = int(prev_suffix) 347 | i_prev_plus1 = i_prev_suffix + 1 348 | 349 | log.debug("ZCS Value: " + zcs) 350 | type = pp[bytes(Phenomena, 'utf-8')].decode('utf-8') 351 | 352 | if Action == "CAN": 353 | event = "CANCL" 354 | else: 355 | event = sg[Significance] 356 | 357 | hit = 1 358 | message = exputc + "z," + type + "," + zcs 359 | log.info("Msg: " + message) 360 | 361 | # Make sure message does not exceed 67 chars. If it does, trim it. 362 | 363 | n = 0 364 | while len(message) > 67: 365 | n = zcs.rfind('-') 366 | zcs = zcs[0:n] 367 | message = exputc + "z," + type + "," + zcs 368 | 369 | if n > 0: 370 | log.info("Truncated Msg: " + message) 371 | 372 | line = "{" + t + "00" 373 | print(":NWS_" + event + ":" + message + line) 374 | if myResend > 0: 375 | resend[id] = bytes(str(myResend), 'utf-8') 376 | 377 | break 378 | 379 | if hit == 0: 380 | print() 381 | 382 | log.info("Exiting") 383 | exit(0) 384 | -------------------------------------------------------------------------------- /noaacap/usr/local/share/noaacap/CHANGELOG: -------------------------------------------------------------------------------- 1 | noaacap changelog 2 | ## 3 | Version: 1.3.1 4 | Release: April 2, 2024 5 | Fix expiration date parsing issue 6 | ## 7 | Version: 1.3 8 | Release: TBD 9 | Fixed truncation bug in compressed zone list 10 | ## 11 | Version: 1.2 12 | Release: March 27, 2024 13 | Reintroduced compressed zones and other improvements 14 | ## 15 | Version: 1.1 16 | Release: March 25, 2024 17 | Updated to use CAP 1.2 data source 18 | ## 19 | Version: 1.0 20 | Release: February 6, 2020 21 | Added ability to specify up to 2 adjacent zones 22 | ## 23 | Version: 0.9 24 | Release: February 20, 2018 25 | Added timeout for non/slow response of NWS servers 26 | ## 27 | Version: 0.8 28 | Release: January 29, 2018 29 | Added added handling for expired alerts 30 | ## 31 | Version: 0.7 32 | Release: September 28, 2017 33 | Added myResend parameter to control message resends. 34 | Added Logging parameter to control verbosity. 35 | ## 36 | Version: 0.6 37 | Release: September 15, 2017 38 | Converted to Python3 39 | Added systemd journal support for logging and debugging 40 | ## 41 | Version: 0.5 42 | Release: August 31, 2017 43 | Bug fix for long messages to ensure myZone is included 44 | Improve error handling for configuration errors 45 | Add -v command line switch to print version and copyright 46 | ## 47 | Version: 0.4 48 | Release: August 24, 2017 49 | Switched to compressed zcs to improve capacity 50 | Improved messsage length check 51 | ## 52 | Version: 0.3 53 | Release: August 22, 2017 54 | Changes: Implement NWS-CANCL messages, implement DB id consisting of 55 | Office + Phenomena + Significance + ETN 56 | ## 57 | Version: 0.2 58 | Release: August 19, 2017 59 | Changes: Make sure message does not exceed 67 chars 60 | ## 61 | Version: 0.1 62 | Release: August 7, 2017 63 | Initial Release 64 | -------------------------------------------------------------------------------- /noaacap/usr/local/share/noaacap/LICENSE: -------------------------------------------------------------------------------- 1 | BSD 2-Clause License 2 | 3 | Copyright (c) 2017-2024, Daniel L. Srebnick 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 17 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 18 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 19 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 20 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 21 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 22 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 23 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 24 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 25 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 26 | -------------------------------------------------------------------------------- /noaacap/usr/local/share/noaacap/README: -------------------------------------------------------------------------------- 1 | The current release of noaacap is 1.4.0. If you are running older than this 2 | version, please update before asking for support. 3 | 4 | This software is written in python3 and has been tested with aprx 2.9.x. 5 | If you are running a version < 2.9.0 you should first update aprx. The current 6 | release of aprx may be found at http://thelifeofkenneth.com/aprx/debs/. 7 | 8 | It has also been tested by N3TSZ in conjunction with direwolf. Version 1.3 9 | or higher is required. The current release of direwolf may be installed on a 10 | Debian or Raspbian system via: 11 | 12 | sudo apt-get install direwolf 13 | 14 | The current release of noaacap may be found at: 15 | 16 | https://github.com/K2IE/noaacap/releases 17 | 18 | Once you are running aprx >= 2.9 or direwolf >= 1.3, install noaacap: 19 | 20 | Install via sudo dpkg -i noaacap-1.4.deb. Then, to satisfy the dependencies, 21 | run sudo apt-get -f install. 22 | 23 | ==> APRX CONFIGURATION <== 24 | 25 | Add a beacon section similar to the following to /etc/aprx.conf: 26 | 27 | 28 | 29 | beaconmode both 30 | cycle-size 2m 31 | 32 | beacon via WIDE2-1 \ 33 | srccall N0CALL-13 \ 34 | timeout 20 \ 35 | exec /usr/local/bin/noaacap.py 36 | 37 | 38 | 39 | The timeout was needed on my older and slower single core Raspberry Pi Model B. 40 | The aprx default timeout is 10 seconds and the timeout may not be needed on a 41 | RPi 2 or newer. If you see lots of "BEACON EXEC abnormal close" messages in 42 | aprx.log, then you likely need to add the timeout. 43 | 44 | The program will check for a new alert or update every 2 minutes, but only 45 | send if there is a change. 46 | 47 | ==> DIREWOLF CONFIGURATION <== 48 | 49 | Add a line similar to the following to direwolf.conf: 50 | 51 | CBEACON EVERY=2 VIA=WIDE2-1 INFOCMD="/usr/local/bin/noaacap.py" 52 | 53 | ==> NOAACAP CONFIGURATION <== 54 | 55 | Edit /etc/noaacap.conf: 56 | 57 | Do not remove the [noaacap] section header. 58 | 59 | myTZ should be set to the Linux TZ value for the issuing office of your 60 | preferred weather zone. For a complete list of timezones, you may use 61 | the following command: timedatectl list-timezones | grep America. 62 | 63 | myZone should be set to your zone code. A list of zone codes may be found 64 | at https://www.weather.gov/pimar/PubZone. Only list one zone. The program 65 | will send the alert for all affected zones that will fit in the allocated 67 66 | characters. You could instead use a county code if you wish. However the 67 | associated zone may also be sent, wasting characters. 68 | 69 | As an example, if you're located in Lancaster, PA, the map shows that you 70 | are in zone 066. Enter PAZ066 for myZone. 71 | 72 | myResend should be set to 0 for no message resends or to an interval of your 73 | choosing. If noaacap beacons every 2 minutes, a value of 30 would provide 74 | hourly resends. 75 | 76 | adjZone1 and adjZone2 may be optionally specified. If specified, those zones 77 | are guaranteed to be retained in any messages found for myZone that have been 78 | truncated because of length. You must specify a Zone rather than a County. 79 | 80 | Logging values are 0 (quiet), 1 (informational), and 2 (debug). Logs are 81 | written to the systemd journal. You may view the logs in realtime using the 82 | following command: sudo journalctl -f | grep noaacap. 83 | 84 | USE 85 | 86 | It would be good to hear from users of this software. Please email 87 | k2ie@k2ie.net with the callsign-SSID of your APRS station that is sending 88 | weather alerts via this program. 89 | 90 | Dan Srebnick, K2IE 91 | 02/06/2020 92 | Updated 04/03/2024 93 | -------------------------------------------------------------------------------- /noaacap/usr/local/share/noaacap/ppmap.db: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/K2IE/noaacap/4f1e7e49d255209416df61f7d5f64f4ff4f1a23f/noaacap/usr/local/share/noaacap/ppmap.db --------------------------------------------------------------------------------