├── .gitignore ├── EAS-SAME-to-APRS-MESSAGE-Converter.docx ├── EAS-SAME-to-APRS-MESSAGE-Converter.pdf ├── README.md ├── eas.conf └── eas2aprs.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /EAS-SAME-to-APRS-MESSAGE-Converter.docx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb2osz/eas2aprs/de20eb8ecd088d293300ce888259a49a37a14163/EAS-SAME-to-APRS-MESSAGE-Converter.docx -------------------------------------------------------------------------------- /EAS-SAME-to-APRS-MESSAGE-Converter.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wb2osz/eas2aprs/de20eb8ecd088d293300ce888259a49a37a14163/EAS-SAME-to-APRS-MESSAGE-Converter.pdf -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # eas2aprs 2 | 3 | 4 | ## EAS SAME to APRS Message Converter ## 5 | 6 | The U.S. [National Weather Service](https://www.weather.gov/nwr/) (NWS) operates more than 1,000 VHF FM radio stations that continuously transmit weather information. 7 | 8 | These stations also transmit special warnings about severe weather, disasters (natural & manmade), and public safety. 9 | 10 | Alerts are sent in a digital form known as Emergency Alert System (EAS) Specific Area Message Encoding (SAME). You can [hear a sample here](https://en.wikipedia.org/wiki/Specific_Area_Message_Encoding). 11 | 12 | It is possible to buy radios that decode these messages but what fun is that? We are ham radio operators so we want to build our own from stuff that we already have sitting around. 13 | 14 | It has been suggested that retransmitting these alerts over the APRS network could aid in local situational awareness. 15 | 16 | This is not difficult. 17 | 18 | There are already various open source packages to demodulate these sounds and decipher the resulting text. We just need to gather some of those components and add a little software “glue” to hold them together. 19 | 20 | Here is one possible approach. 21 | -------------------------------------------------------------------------------- /eas.conf: -------------------------------------------------------------------------------- 1 | # The first audio device will be the USB (or other type of) 2 | # "sound card." It normally shows up as card 1 but, 3 | # under some circumstances, it could be different. 4 | # The modem defaults to 1200 bps AFSK so that does not need 5 | # to be specified. 6 | # You do need to specify a method to activate the transmitter. 7 | # Most popular methods are a Raspberry Pi GPIO pin or a GPIO 8 | # pin of the USB audio adapter. 9 | 10 | ADEVICE plughw:1,0 11 | CHANNEL 0 12 | #PTT GPIO 25 13 | PTT CM108 14 | 15 | # The second audio device (1 because numbering starts at 0) 16 | # is the RTL SDR. There is no audio output for this channel. 17 | # The rtl_fm application writes to stdout with a sample rate 18 | # of 24000/sec. We need to be listening to stdin with the 19 | # same sample rate. 20 | 21 | ADEVICE1 stdin null 22 | ARATE 24000 23 | CHANNEL 2 24 | MODEM EAS /1 25 | 26 | -------------------------------------------------------------------------------- /eas2aprs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | # AES SAME to APRS message translator. 4 | # This is just a simple proof of concept which needs more work 5 | # before putting it into general service. 6 | # WB2OSZ, June 2020 7 | 8 | import datetime 9 | import time 10 | import os 11 | #import str 12 | import re 13 | 14 | # Source address for APRS packets. 15 | # You should put your own callsign here. 16 | # Yeah, I know it should be a command line option, but like I said, 17 | # this is just a quick minimal proof of concept that needs more work. 18 | 19 | mycall = 'HAM123' 20 | 21 | # APRS generally uses the destination field for a product id. 22 | # APZxxx is experimental so we will pick something in that name range. 23 | 24 | product_id = 'APZEAS' 25 | 26 | # Here is something interesting you might want to try. 27 | # When direwolf sees "SPEECH" in the destination field, it will send the 28 | # information part to a speech synthesizer rather than transmitting an 29 | # AX.25 frame. In this case, you would want to omit the addressee part. 30 | 31 | #product_id = 'SPEECH' 32 | 33 | # Receive and transmit Queue directories for communication with kissutil. 34 | # Modern versions of Linux have a predefined RAM disk at /dev/shm. 35 | 36 | # kisssutil puts received APRS packets into the receive queue directory. 37 | # Here we remove those packets and process them. 38 | 39 | rq_dir = '/dev/shm/RQ' 40 | 41 | # For transmitting, we simply put a file in the transmit queue. 42 | # kissutil will send it to direwolf to be sent over the radio. 43 | # A transmit channel can optionaally... 44 | 45 | tq_dir = '/dev/shm/TQ' 46 | 47 | # Transmit channel number. 48 | 49 | xmit_chan = 0 50 | 51 | 52 | 53 | 54 | #----- aprs_msg ----- 55 | 56 | # Just glue all of the pieces together. 57 | # The only interesting part is ensuring that the addresee is exactly 9 characters. 58 | 59 | def aprs_msg(src,dst,via,addr,msgtext): 60 | """Create APRS 'message' from given components.""" 61 | 62 | to = addr.ljust(9)[:9] 63 | msg = src + '>' + dst 64 | if via: 65 | msg += ',' + via 66 | msg += '::' + to + ':' + msgtext 67 | return msg 68 | 69 | 70 | 71 | 72 | #----- send_msg ----- 73 | 74 | # Write the given packet to the transmit queue directory. 75 | # If channel is not 0, the packet text is preceded by [chan]. 76 | 77 | def send_msg (chan, msg): 78 | """ Add message to transmit queue directory.""" 79 | 80 | if not os.path.isdir(tq_dir): 81 | os.mkdir(tq_dir) 82 | if not os.path.isdir(rq_dir): 83 | os.mkdir(rq_dir) 84 | 85 | t = datetime.datetime.now() 86 | fname = tq_dir + '/' + t.strftime("%y%m%d.%H%M%S.%f")[:17] 87 | 88 | try: 89 | f = open(fname, 'w') 90 | except: 91 | print ("Failed to open " + fname + " for write") 92 | else: 93 | if chan > 0: 94 | f.write('[' + str(chan) + '] ' + msg + '\n') 95 | else: 96 | f.write(msg + '\n') 97 | f.close() 98 | time.sleep (0.005) # Ensure unique names 99 | 100 | 101 | 102 | #----- process_eas ----- 103 | 104 | # Given an EAS SAME message, this calls an external application to 105 | # convert it to human understandable text. 106 | # The text can exceed the maximum size of an AX.25 frame. 107 | # Luckily, dsame splits it into multiple reasonably sized lines. 108 | # Each of these is transmitted as an APRS "message." 109 | 110 | def process_eas (chan, eas): 111 | """Convert an EAS SAME message to text and transmit.""" 112 | 113 | text = os.popen('./dsame.py --msg "' + eas + '"').read().split("\n") 114 | text2 = list(filter(None, text)) 115 | n = len(text2) 116 | if n: 117 | print ("Transmitting..."); 118 | for i in range(0,n): 119 | msg = aprs_msg (mycall, product_id, '', 'NWS', "[" + str(i+1) + "/" + str(n) + "] " + text2[i]) 120 | print (msg) 121 | send_msg (xmit_chan, msg) 122 | #print ("---") 123 | 124 | 125 | 126 | #----- parse_aprs ----- 127 | 128 | def parse_aprs (packet): 129 | """Parse and APRS packet, possibly prefixed by channel number.""" 130 | 131 | print (packet) 132 | if len(packet) == 0: 133 | return 134 | 135 | chan = '' 136 | # Split into address and information parts. 137 | # There could be a leading '[n]' with a channel number. 138 | m = re.search (r'^(\[.+\] *)?([^:]+):(.+)$', packet) 139 | if m: 140 | chan = m.group(1) # Still enclosed in []. 141 | addrs = m.group(2) 142 | info = m.group(3) 143 | #print ('<>'+addrs+'<>'+info+'<>') 144 | 145 | if info[0] == '}': 146 | # Unwrap third party traffic format 147 | # Preserve any channel. 148 | if chan: 149 | parse_aprs (chan + info[1:]) 150 | else: 151 | parse_aprs (info[1:]) 152 | elif info[0:3] == '{DE': 153 | # APRS "user defined data" format for EAS. 154 | #print ('Process "message" - ' + info) 155 | process_eas (chan, info[3:]) 156 | else: 157 | print ('Not APRS "user defined data" format - ' + info) 158 | else: 159 | print ('Could not split into address & info parts - ' + packet) 160 | 161 | 162 | #----- recv_loop ----- 163 | 164 | def recv_loop(): 165 | """Poll the receive queue directory and call parse_aprs when something found.""" 166 | 167 | if not os.path.isdir(tq_dir): 168 | os.mkdir(tq_dir) 169 | if not os.path.isdir(rq_dir): 170 | os.mkdir(rq_dir) 171 | 172 | while True: 173 | time.sleep(1) 174 | #print ('polling') 175 | try: 176 | files = os.listdir(rq_dir) 177 | except: 178 | print ('Could not get listing of directory ' + rq_dir + '\n') 179 | quit() 180 | 181 | files.sort() 182 | for f in files: 183 | fname = rq_dir + '/' + f 184 | #print (fname) 185 | if os.path.isfile(fname): 186 | print ('---') 187 | print ('Processing ' + fname + ' ...') 188 | with open (fname, 'r') as h: 189 | for m in h: 190 | m.rstrip('\n') 191 | parse_aprs (m.rstrip('\n')) 192 | os.remove(fname) 193 | else: 194 | #print (fname + ' is not an ordinary file - ignore') 195 | pass 196 | 197 | 198 | 199 | #----- start here ----- 200 | 201 | recv_loop() 202 | --------------------------------------------------------------------------------