openwebrxplus/owrx/dsame3/dsame.py

563 lines
21 KiB
Python

# Copyright (C) 2017 Joseph W. Metcalf
# Modified by James Kitchens 2023
# Additional modifications by Matthew R. McDougal 2024
#
# JK Modifications include, but are not limited to, adding multiple language options,
# adding recording features for alerts, implementation of the Mexico SASMEX alert system,
# adding missing data to the ICAO list, implementing proper country detection, implementation of audio transcription,
# and Python 3.x compatibility.
#
# MRM modifications strip recording and transcription to make a simple no-deps decoder of the message content.
#
# Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby
# granted, provided that the above copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING
# ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL,
# DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
# WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE
# USE OR PERFORMANCE OF THIS SOFTWARE.
#
import argparse
import calendar
import datetime
import logging
import string
import subprocess
import sys
import textwrap
import time
from . import defs
def alert_start(JJJHHMM, format1='%j%H%M'):
"""Convert EAS date string to datetime format"""
utc_dt = datetime.datetime.strptime(JJJHHMM, format1).replace(datetime.datetime.utcnow().year)
timestamp = calendar.timegm(utc_dt.timetuple())
return datetime.datetime.fromtimestamp(timestamp)
def fn_dt(dt, format1='%I:%M %p'):
"""Return formated datetime"""
return dt.strftime(format1)
# ZCZC-ORG-EEE-PSSCCC-PSSCCC+TTTT-JJJHHMM-LLLLLLLL-
def format_error(info=''):
logging.warning(' '.join(['INVALID FORMAT', info]))
def time_str(x, type1='hour'):
if x == 1:
return ''.join([str(x), ' ', type1])
elif x >= 2:
return ''.join([str(x), ' ', type1, 's'])
def get_length(TTTT):
hh, mm = TTTT[:2], TTTT[2:]
return ' '.join(filter(None, (time_str(int(hh)), time_str(int(mm), type1='minute'))))
def county_decode(input1, COUNTRY, LANG):
"""Convert SAME county/geographic code to text list"""
P, SS, CCC, SSCCC = input1[:1], input1[1:3], input1[3:], input1[1:]
if COUNTRY == 'US':
if SSCCC in defs.SAME_CTYB:
SAME__LOC = defs.SAME_LOCB
else:
SAME__LOC = defs.SAME_LOCA
if CCC == '000':
if LANG == 'EN':
county = 'ALL'
else:
county = 'TODOS'
else:
county = defs.US_SAME_CODE[SSCCC]
return [' '.join(filter(None, (SAME__LOC[P], county))), defs.US_SAME_AREA[SS]]
elif COUNTRY == 'MX':
if SSCCC in defs.SAME_CTYB:
# noinspection PyUnusedLocal
SAME__LOC = defs.SAME_LOCB
else:
SAME__LOC = defs.SAME_LOCA
if CCC == '000':
if LANG == 'EN':
county = 'COUNTRYWIDE'
else:
county = 'EN TODO EL PAIS'
else:
county = defs.MX_SAME_CODE[SSCCC]
return [' '.join(filter(None, (SAME__LOC[P], county))), defs.MX_SAME_AREA[SS]]
else:
if CCC == '000':
if LANG == 'EN':
county = 'ALL'
else:
county = 'TODOS'
else:
county = defs.CA_SAME_CODE[SSCCC]
return [county, defs.CA_SAME_AREA[SS]]
def get_division(input1, COUNTRY='US', LANG='EN'):
if COUNTRY == 'US':
# noinspection PyBroadException
try:
DIVISION = defs.FIPS_DIVN[input1]
if not DIVISION:
DIVISION = 'areas'
except:
DIVISION = 'counties'
elif COUNTRY == 'MX':
if LANG == 'EN':
# noinspection PyBroadException
try:
DIVISION = defs.FIPS_DIVN[input1]
if not DIVISION:
DIVISION = 'areas'
except:
DIVISION = 'municipalities'
else:
# noinspection PyBroadException
try:
DIVISION = defs.FIPS_DIVN[input1]
if not DIVISION:
DIVISION = 'áreas'
except:
DIVISION = 'municipios'
else:
DIVISION = 'areas'
return DIVISION
def get_event(input1):
event = None
args = parse_arguments()
# noinspection PyBroadException
try:
if args.lang == 'SP':
event = defs.SAME__EEE__SP[input1]
else:
event = defs.SAME__EEE[input1]
except:
if input1[2:] in 'WAESTMN':
event = ' '.join(['Unknown', defs.SAME_UEEE[input1[2:]]])
return event
def get_indicator(input1):
indicator = None
# noinspection PyBroadException
try:
if input1[2:] in 'WAESTMNR':
indicator = input1[2:]
except:
pass
return indicator
def printf(output=''):
output = output.lstrip(' ')
output = ' '.join(output.split())
sys.stdout.write(''.join([output, '\n']))
def alert_end(JJJHHMM, TTTT):
alertstart = alert_start(JJJHHMM)
delta = datetime.timedelta(hours=int(TTTT[:2]), minutes=int(TTTT[2:]))
return alertstart + delta
def alert_length(TTTT):
delta = datetime.timedelta(hours=int(TTTT[:2]), minutes=int(TTTT[2:]))
return delta.seconds
def get_location(STATION=None, TYPE=None):
location = ''
if TYPE == 'NWS':
# noinspection PyBroadException
try:
# CHANGED WITHOUT TESTING
location = defs.ICAO_LIST[STATION]
except:
pass
return location
def check_watch(watch_list, PSSCCC_list, event_list, EEE):
if not watch_list:
watch_list = PSSCCC_list
if not event_list:
event_list = [EEE]
w, p = [], []
w += [item[1:] for item in watch_list]
p += [item[1:] for item in PSSCCC_list]
if (set(w) & set(p)) and EEE in event_list:
return True
else:
return False
def kwdict(**kwargs):
return kwargs
def format_message(command, ORG='WXR', EEE='RWT', PSSCCC=None, TTTT='0030', JJJHHMM='0010000', STATION=None, TYPE=None,
LLLLLLLL=None, COUNTRY='US', LANG='EN', MESSAGE=None, **kwargs):
if PSSCCC is None:
PSSCCC = []
return command.format(ORG=ORG, EEE=EEE, TTTT=TTTT, JJJHHMM=JJJHHMM, STATION=STATION, TYPE=TYPE, LLLLLLLL=LLLLLLLL,
COUNTRY=COUNTRY, LANG=LANG, event=get_event(EEE), type=get_indicator(EEE),
end=fn_dt(alert_end(JJJHHMM, TTTT)), start=fn_dt(alert_start(JJJHHMM)),
organization=defs.SAME__ORG[LANG][ORG]['NAME'][COUNTRY], PSSCCC='-'.join(PSSCCC),
location=get_location(STATION, TYPE), date=fn_dt(datetime.datetime.now(), '%c'),
length=get_length(TTTT), seconds=alert_length(TTTT), MESSAGE=MESSAGE, **kwargs)
def readable_message(ORG='WXR', EEE='RWT', PSSCCC=None, TTTT='0030', JJJHHMM='0010000', STATION=None, TYPE=None,
LLLLLLLL=None, COUNTRY='US', LANG='EN', wraplen=78, noprint=False):
if PSSCCC is None:
PSSCCC = []
location = get_location(STATION, TYPE)
MSG = [format_message(defs.MSG__TEXT[LANG]['MSG1'], ORG=ORG, EEE=EEE, TTTT=TTTT, JJJHHMM=JJJHHMM, STATION=STATION,
TYPE=TYPE, COUNTRY=COUNTRY, LANG=LANG,
article=defs.MSG__TEXT[LANG][defs.SAME__ORG[LANG][ORG]['ARTICLE'][COUNTRY]].title(),
has=defs.MSG__TEXT[LANG]['HAS'] if not defs.SAME__ORG[LANG][ORG]['PLURAL'] else
defs.MSG__TEXT[LANG]['HAVE'],
preposition=defs.MSG__TEXT[LANG]['IN'] if location != '' else '')]
current_state = None
for idx, item in enumerate(PSSCCC):
county, state = county_decode(item, COUNTRY, LANG)
if current_state != state:
DIVISION = get_division(PSSCCC[idx][1:3], COUNTRY, LANG)
output = defs.MSG__TEXT[LANG]['MSG2'].format(conjunction='' if idx == 0 else defs.MSG__TEXT[LANG]['AND'],
state=state, division=DIVISION)
MSG += [''.join(output)]
current_state = state
MSG += [defs.MSG__TEXT[LANG]['MSG3'].format(
county=county if county != state else defs.MSG__TEXT[LANG]['ALL'].upper(),
punc=',' if idx != len(PSSCCC) - 1 else '.')]
MSG += [defs.MSG__TEXT[LANG]['MSG4']]
MSG += [''.join(['(', LLLLLLLL, ')'])]
if not noprint:
printf()
if wraplen > 0:
output = textwrap.wrap(''.join(MSG), 78)
for item in output:
printf(item)
printf()
else:
printf(''.join(MSG))
return ''.join(MSG)
def clean_msg(same):
valid_chars = ''.join([string.ascii_uppercase, string.digits, '+-/*'])
same = same.upper() # Uppercase
msgidx = same.find('ZCZC')
if msgidx != -1:
same = same[msgidx:] # Left Offset
same = ''.join(same.split()) # Remove whitespace
same = ''.join(filter(lambda x: x in valid_chars, same)) # Valid ASCII codes only
slen = len(same) - 1
if same[slen] != '-':
ridx = same.rfind('-')
offset = slen - ridx
if offset <= 8:
same = ''.join([same.ljust(slen + (8 - offset) + 1, '?'), '-']) # Add final dash and/or pad location field
return same
def same_decode_string(same, lang='EN', same_watch=None, event_watch=None, wraplen=0):
msgs = []
while len(same):
# noinspection PyUnusedLocal
tail = same
# noinspection PyBroadException
try:
same = clean_msg(same)
except:
return []
msgidx = same.find('ZCZC')
endidx = same.find('NNNN')
if msgidx != -1 and (endidx == -1 or endidx > msgidx):
# New message
# noinspection PyUnusedLocal
S1, S2 = None, None
# noinspection PyBroadException
try:
S1, S2 = same[msgidx:].split('+', 1)
except:
return []
# noinspection PyBroadException
try:
ZCZC, ORG, EEE, PSSCCC = S1.split('-', 3)
except:
return []
# noinspection PyBroadException
try:
PSSCCC_list = PSSCCC.split('-')
except:
pass
# noinspection PyBroadException
try:
TTTT, JJJHHMM, LLLLLLLL, tail = S2.split('-', 3)
except:
return []
# noinspection PyBroadException
try:
STATION, TYPE = LLLLLLLL.split('/')
except ValueError:
# Station doesn't have to have a /
STATION = LLLLLLLL
TYPE = None
pass
except:
STATION, TYPE = None, None
US_bad_list = []
CA_bad_list = []
MX_bad_list = []
for code in PSSCCC_list:
try:
# noinspection PyUnusedLocal
county = defs.US_SAME_CODE[code[1:]]
except KeyError:
US_bad_list.append(code)
try:
# noinspection PyUnusedLocal
county = defs.CA_SAME_CODE[code[1:]]
except KeyError:
CA_bad_list.append(code)
try:
# noinspection PyUnusedLocal
county = defs.MX_SAME_CODE[code[1:]]
except KeyError:
MX_bad_list.append(code)
if len(US_bad_list) < len(CA_bad_list) and len(US_bad_list) < len(MX_bad_list):
COUNTRY = 'US'
if len(US_bad_list) > len(CA_bad_list) and len(CA_bad_list) < len(MX_bad_list):
COUNTRY = 'CA'
if len(US_bad_list) > len(MX_bad_list) and len(CA_bad_list) > len(MX_bad_list):
COUNTRY = 'MX'
if len(US_bad_list) == len(MX_bad_list) and len(US_bad_list) == len(CA_bad_list):
if type == 'CA':
COUNTRY = 'CA'
elif type == 'MX':
COUNTRY = 'MX'
else:
COUNTRY = 'US'
# noinspection PyUnboundLocalVariable
if COUNTRY == 'CA':
bad_list = CA_bad_list
elif COUNTRY == 'MX':
bad_list = MX_bad_list
elif COUNTRY == 'US':
bad_list = US_bad_list
for code in bad_list:
PSSCCC_list.remove(code)
PSSCCC_list.sort()
if check_watch(same_watch, PSSCCC_list, event_watch, EEE):
msg = {
'originator': ORG,
'event': EEE,
'country': COUNTRY,
'areas': PSSCCC_list,
'start_time': alert_start(JJJHHMM),
'duration': alert_length(TTTT),
'end_time': alert_end(JJJHHMM, TTTT),
'station': LLLLLLLL,
'msg': readable_message(ORG, EEE, PSSCCC_list, TTTT, JJJHHMM, STATION, TYPE, LLLLLLLL, COUNTRY,
lang, wraplen, True)
}
msgs += [msg]
else:
if endidx == -1:
return msgs
else:
tail = same[msgidx:+len('NNNN')]
# Move ahead and look for more
same = tail
return msgs
def same_decode(same, lang, same_watch=None, event_watch=None, call=None, command=None):
args = parse_arguments()
while len(same):
# noinspection PyUnusedLocal
tail = same
# noinspection PyBroadException
try:
same = clean_msg(same)
except:
return
msgidx = same.find('ZCZC')
endidx = same.find('NNNN')
if msgidx != -1 and (endidx == -1 or endidx > msgidx):
# New message
logging.debug('-' * 30)
logging.debug(' '.join([' Identifer found >', 'ZCZC']))
# noinspection PyUnusedLocal
S1, S2 = None, None
# noinspection PyBroadException
try:
S1, S2 = same[msgidx:].split('+', 1)
except:
format_error()
return
# noinspection PyBroadException
try:
ZCZC, ORG, EEE, PSSCCC = S1.split('-', 3)
except:
format_error()
return
logging.debug(' '.join([' Originator found >', ORG]))
logging.debug(' '.join([' Event Code found >', EEE]))
# noinspection PyBroadException
try:
PSSCCC_list = PSSCCC.split('-')
except:
format_error()
# noinspection PyBroadException
try:
TTTT, JJJHHMM, LLLLLLLL, tail = S2.split('-', 3)
except:
format_error()
return
logging.debug(' '.join([' Purge Time found >', TTTT]))
logging.debug(' '.join([' Date Code found >', JJJHHMM]))
logging.debug(' '.join(['Location Code found >', LLLLLLLL]))
# noinspection PyBroadException
try:
STATION, TYPE = LLLLLLLL.split('/')
except ValueError:
# Station doesn't have to have a /
STATION = LLLLLLLL
TYPE = None
pass
except:
STATION, TYPE = None, None
format_error()
# noinspection PyUnboundLocalVariable
logging.debug(' '.join([' SAME Codes found >', str(len(PSSCCC_list))]))
US_bad_list = []
CA_bad_list = []
MX_bad_list = []
for code in PSSCCC_list:
try:
# noinspection PyUnusedLocal
county = defs.US_SAME_CODE[code[1:]]
except KeyError:
US_bad_list.append(code)
try:
# noinspection PyUnusedLocal
county = defs.CA_SAME_CODE[code[1:]]
except KeyError:
CA_bad_list.append(code)
try:
# noinspection PyUnusedLocal
county = defs.MX_SAME_CODE[code[1:]]
except KeyError:
MX_bad_list.append(code)
if len(US_bad_list) < len(CA_bad_list) and len(US_bad_list) < len(MX_bad_list):
COUNTRY = 'US'
if len(US_bad_list) > len(CA_bad_list) and len(CA_bad_list) < len(MX_bad_list):
COUNTRY = 'CA'
if len(US_bad_list) > len(MX_bad_list) and len(CA_bad_list) > len(MX_bad_list):
COUNTRY = 'MX'
if len(US_bad_list) == len(MX_bad_list) and len(US_bad_list) == len(CA_bad_list):
if type == 'CA':
COUNTRY = 'CA'
elif type == 'MX':
COUNTRY = 'MX'
else:
COUNTRY = 'US'
# noinspection PyUnboundLocalVariable
if COUNTRY == 'CA':
bad_list = CA_bad_list
elif COUNTRY == 'MX':
bad_list = MX_bad_list
elif COUNTRY == 'US':
bad_list = US_bad_list
# noinspection PyUnboundLocalVariable
logging.debug(' '.join(['Invalid Codes found >', str(len(bad_list)), ', '.join(bad_list)]))
logging.debug(' '.join([' Country >', COUNTRY]))
logging.debug('-' * 30)
for code in bad_list:
PSSCCC_list.remove(code)
PSSCCC_list.sort()
if check_watch(same_watch, PSSCCC_list, event_watch, EEE):
MESSAGE = readable_message(ORG, EEE, PSSCCC_list, TTTT, JJJHHMM, STATION, TYPE, LLLLLLLL, COUNTRY,
lang, args.wrap)
if command:
if call:
l_cmd = []
for cmd in command:
l_cmd.append(
format_message(cmd, ORG, EEE, PSSCCC_list, TTTT, JJJHHMM, STATION, TYPE, LLLLLLLL,
COUNTRY, lang, MESSAGE))
try:
subprocess.call([call] + l_cmd)
except Exception as detail:
logging.error(detail)
return
pass
else:
f_cmd = format_message(' '.join(command), ORG, EEE, PSSCCC_list, TTTT, JJJHHMM, STATION, TYPE,
LLLLLLLL, COUNTRY, lang, MESSAGE)
printf(f_cmd)
else:
if endidx == -1:
logging.warning('Valid identifer not found.')
return
else:
logging.debug(' '.join(['End of Message found >', 'NNNN', str(msgidx)]))
tail = same[msgidx:+len('NNNN')]
# Move ahead and look for more
same = tail
def parse_arguments():
parser = argparse.ArgumentParser(description=defs.DESCRIPTION, prog=defs.PROGRAM, fromfile_prefix_chars='@')
parser.add_argument('--msg', help='message to decode')
parser.add_argument('--same', nargs='*', help='filter by SAME code')
parser.add_argument('--event', nargs='*', help='filter by event code')
parser.add_argument('--lang', default='EN', help='set language')
parser.add_argument('--loglevel', default=40, type=int, choices=[10, 20, 30, 40, 50], help='set log level')
parser.add_argument('--version', action='version', version=' '.join([defs.PROGRAM, defs.VERSION]),
help='show version infomation and exit')
parser.add_argument('--call', help='call external command')
parser.add_argument('--command', nargs='*', help='command message')
parser.add_argument('--wrap', type=int, default=78, help='line wrap length')
args, unknown = parser.parse_known_args()
return args
def main():
args = parse_arguments()
args.lang = args.lang.upper()
logging.basicConfig(level=args.loglevel, format='%(levelname)s: %(message)s')
if args.msg:
same_decode(args.msg, args.lang, same_watch=args.same, event_watch=args.event,
call=args.call, command=args.command)
else:
for line in sys.stdin:
logging.debug(line)
same_decode(line, args.lang, same_watch=args.same, event_watch=args.event,
call=args.call, command=args.command)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
pass
except Exception as e:
sys.stdout.write('Error: ' + str(e) + '\n')