diff --git a/.gitignore b/.gitignore index b6e4761..c152563 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# vim files +.*~ + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/arElement.py b/arElement.py new file mode 100644 index 0000000..ea471ef --- /dev/null +++ b/arElement.py @@ -0,0 +1,40 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2020 Richard Ferguson, K3FRG. +# k3frg@arrl.net +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. +# + +import threading + +class arElement(): + def __init__(self): + self._arName = "%s" % (self.__class__.__name__) + + self._arPrintLock = threading.Lock() + + @property + def arName(self): + return self._arName + + @arName.setter + def arName(self, v): + self._arName = "%s:%s" % (self.__class__.__name__, v) + + def arPrint(self, message): + self._arPrintLock.acquire() + print("[%s] %s" % (self._arName, message)) + self._arPrintLock.release() diff --git a/arNetSked.py b/arNetSked.py new file mode 100755 index 0000000..87efe60 --- /dev/null +++ b/arNetSked.py @@ -0,0 +1,620 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2020 Richard Ferguson, K3FRG. +# k3frg@arrl.net +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. +# + +import os +import sys +import re +import datetime as dt +import click +import math +import threading +import signal +import socket +import struct +import binascii + +from arElement import arElement +from arTNCKiss import arTNCKiss + +def td2min(td): + res = td.days * 24*60*60 + res += td.seconds + res /= 60 + return round(res) + +class arNet(arElement, threading.Thread): + def __init__(self, call, txCB): + self._day = 0 # sunday +# self._repeat = 7 # weekly + self._timeofday = 20 * 60 # 8pm + self._interval = 3 # beacon every 3 minutes + self._duration = 30 # becaon for 30 minutes + self._objname = "NET-TEST" # beacon object name + self._objfreq = "146.520" # freq in MHz + self._objtone = "none" # PL Tone + self._objrange = "none" # Repeater range + self._path = "none" # Packet path + self._lat = "0000.00N" # latitude + self._lon = "00000.00W" # longitude + self._comment = "" # comment + + self._dt = dt.datetime.now() + self._stopped = threading.Event() + + self.opcall = call + self.txCB = txCB + + # object mode for beacon text + # 0 = out of time window + # 1 = pre net + # 2 = net active + # 3 = post net (kill beacon) + self.objmode = 0 + + threading.Thread.__init__(self) + arElement.__init__(self) + + @property + def day(self): + return self._day + + @day.setter + def day(self, v): + lut = { + "MON" : 0, + "TUE" : 1, + "WED" : 2, + "THU" : 3, + "FRI" : 4, + "SAT" : 5, + "SUN" : 6, + 0 : 0, + 1 : 1, + 2 : 2, + 3 : 3, + 4 : 4, + 5 : 5, + 6 : 6 + } + av = lut.get(v, -1) + if av < 0: + raise ValueError("Invalid day[%s], valid arguments are SUN,MON,TUE,WED,THU,FRI,SAT" % v) + self._day = av + + @property + def timeofday(self): + return self._timeofday + + @day.setter + def timeofday(self, v): + # expeted format + # HH:MM[AP]M + rem = re.fullmatch("^([0-9]?[0-9]):([0-9][0-9])([AP]M)$", v) + if rem: + h = int(rem.group(1)) + m = int(rem.group(2)) + o = rem.group(3) + else: + raise ValueError("Invalid time[%s], format must match HH:MM[AP]M" % v) + + if not (h > 0 and h <= 12): + raise ValueError("Invalid time[%s], 0 < hours <= 12" % v) + + if not (m >= 0 and m < 60): + raise ValueError("Invalid time[%s], 0 <= minutes < 60" % v) + + if not (o == "AM" or o == "PM"): + raise ValueError("Invalid time[%s], AM or PM" % v) + + if h == 12: + h = 0 + if o == 'PM': + h += 12 + + self._timeofday = h * 60 + m + + @property + def interval(self): + return self._interval + + @interval.setter + def interval(self, v): + vi = int(v) + if vi < 1 or vi > 10: + raise ValueError("Invalid interval[%s], 1 <= interval <= 10" % v) + + self._interval = vi + + @property + def duration(self): + return self._duration + + @duration.setter + def duration(self, v): + vi = int(v) + + if vi < 1 or vi > 60: + raise ValueError("Invalid duration[%s], 1 <= duration <= 60" % v) + + self._duration = vi + + @property + def objname(self): + return self._objname + + @objname.setter + def objname(self, v): + v = v.ljust(9) + if len(v) > 9: + raise ValueError("Invalid objname[%s], len <= 9" % v) + + self._objname = v + self.arName = v + + @property + def objfreq(self): + return self._objfreq + + @objfreq.setter + def objfreq(self, v): + v = v.ljust(7) + if len(v) > 7: + raise ValueError("Invalid objfreq, len <= 7" % v) + + if not re.fullmatch('[\d ]\d\d\.\d\d[\d ]', v): + raise ValueError("Invalid objfreq[%], format [\d ]\d\d.\d\d[\d ]" % v) + + self._objfreq = v + + @property + def objtone(self): + return self._objtone + + @objtone.setter + def objtone(self, v): + v = v.ljust(4) + if len(v) > 4: + raise ValueError("Invalid objtone, len <= 4" % v) + + if v != 'none' and not re.fullmatch('[CDTcdt]\d\d\d', v) \ + and not re.fullmatch('[1l]750', v): + raise ValueError("Invalid objtone[%s], format none, [1l]750 or [CDTcdt]\d\d\d" % v) + + self._objtone = v + + @property + def objrange(self): + return self._objrange + + @objrange.setter + def objrange(self, v): + v = v.ljust(4) + if len(v) > 4: + raise ValueError("Invalid objrange, len <= 4") + + if v != 'none' and not re.fullmatch('R\d\d[mk]', v) \ + and not re.fullmatch('[+-]\d\d\d', v): + raise ValueError("Invalid objrange[%s], format none or R\d\d[mk]" % v) + + self._objrange = v + + @property + def path(self): + return self._path + + @path.setter + def path(self, v): + v = v.ljust(9) + if len(v) > 9: + raise ValueError("Invalid path, len <= 9") + v = v.rstrip() + + if v != 'none' and \ + not re.fullmatch('WIDE[21]-[21]', v) and \ + not re.fullmatch('[A-Za-z]{1,2}\d[A-Za-z]{1,3}-\d{1,2}', v): + raise ValueError("Invalid path[%s], format none or WIDEN-M or call-ssid" % v) + + self._path = v + + @property + def latitude(self): + return self._latitude + + @latitude.setter + def latitude(self, v): + v = v.rjust(8,'0') + + if len(v) > 8: + raise ValueError("Invalid latitude[%s], len == 8" % v) + + if not re.fullmatch('[0-9]{4}\.[0-9]{2}[NS]', v): + raise ValueError("Invalid latitude[%s], format 0000.00[SN]" % v) + + self._latitude = v + + @property + def longitude(self): + return self._longitude + + @longitude.setter + def longitude(self, v): + v = v.rjust(9,'0') + + if len(v) > 9: + raise ValueError("Invalid longitude[%s], len == 9" % v) + + if not re.fullmatch('[0-9]{5}\.[0-9]{2}[EW]', v): + raise ValueError("Invalid longitude[%s], format 00000.00[EW]" % v) + + self._longitude = v + + @property + def comment(self): + return self._comment + + @comment.setter + def comment(self, v): + if len(v) > 32: + raise ValueError("Invalid comment[%s], len <= 32" % v) + + self._comment = v + + def initTime(self): + # initialize _dt to next future net time + # unless net is currently active + + dayshift = self._day - self._dt.weekday() + if dayshift < 0: + dayshift += 7 + + hrs = math.floor(self._timeofday / 60) + mins = self._timeofday % 60 + self._dt = self._dt.replace(hour=hrs, minute=mins, second=0) + \ + dt.timedelta(days=dayshift) + + if td2min(self._dt - dt.datetime.now()) + self._duration < 0: + self._dt += dt.timedelta(days = 7) + + self.arPrint("Time initialized, next NET starts at %s" % self._dt.strftime("%c")) + + def calcWaitTime(self): + # calculate next wait time + # start beacons 30 minutes before net time, every 10 minutes + # once net starts, beacon at interval rate specified + # for duration, beacon 3 times after to kill every 3 minutes + + # calc delta time in minutes, negative after net start + dMin = td2min(self._dt - dt.datetime.now()) + self.arPrint("Minutes until NET start: %d" % dMin) + retVal = 604800 # 1week + if dMin > 30: + #print ("CALC> mode: 0") + #outside net beacon time + self.objmode = 0 + retVal = (dMin - 30) * 60 + elif dMin <= 30 and dMin > 0: + #print ("CALC> mode: 1") + #pre net beacon time + self.objmode = 1 + retVal = (dMin % 10) * 60 + retVal = retVal if retVal != 0 else 10 * 60 + elif dMin <= 0 and (dMin + self._duration) >= 0: + #print ("CALC> mode: 2") + #net beacon time + self.objmode = 2 + retVal = ((dMin+self._duration) % self._interval) * 60 + retVal = retVal if retVal != 0 else self._interval * 60 + elif (dMin + self._duration) < 0 and (dMin + self._interval + self._duration + 7) >= 0: + #print ("CALC> mode: 3") + #post net beacon time + self.objmode = 3 + retVal = 3*60 + else: + #print ("CALC> mode: 4") + #update _dt for next net beacon time + self.objmode = 0 + self._dt += dt.timedelta(days=7) + dMin = td2min(self._dt - dt.datetime.now()) + retVal = (dMin - 30) * 60 + + # safety net to not rapid fire network + retVal = retVal if retVal >= 60 else 60 + self.arPrint("Second delay until next beacon: %s" % retVal) + return retVal + + def stop(self): + self._stopped.set() + self.join() + + def run(self): + self.arPrint("Starting NET element...") + + wt = self.calcWaitTime() # also sets beacon mode + # send out initial beacon if in range + if self.objmode > 0: + self.txCB(self.buildPacket()) + + while not self._stopped.wait(wt): + self.arPrint("Delay complete at %s" % dt.datetime.now()) + wt = self.calcWaitTime() + if not self._stopped.is_set() and self.objmode > 0: + self.txCB(self.buildPacket()) + + + def packHeader(self, call, path): + valb = struct.pack('6s B', 'APZFRG'.encode('utf-8'), 0x70) + + m = re.search("([A-Za-z]{1,2}\d[A-Za-z]{1,3})-(\d{1,2})", call) + if m: + callbase = m.group(1).ljust(6) + callssid = int(m.group(2)) + if callssid < 0 or callssid > 15: + raise ValueError("Callsign SSID out of range, 0 <= ssid <= 15") + else: + raise ValueError("Invalid callsign format, must include ssid") + + vals = [] + vals.append(callbase.encode('utf-8')) + vals.append(0x70|callssid) + valb += struct.pack('6s B', *vals) + + vals = [] + dp = re.search("([A-Za-z]{1,2}\d[A-Za-z]{1,3})-(\d{1,2})", path) + wp = re.search("(WIDE[12])-([12])", path) + if dp: + pathbase = dp.group(1).ljust(6) + pathssid = int(dp.group(2)) + if pathssid < 0 or pathssid > 15: + raise ValueError("Via path SSID out of range, 0 <= ssid <= 15") + + vals.append(pathbase.encode('utf-8')) + vals.append(0x30|pathssid) + valb += struct.pack('6s B', *vals) + elif wp: + pathbase = wp.group(1).ljust(6) + pathssid = int(wp.group(2)) + + vals.append(pathbase.encode('utf-8')) + vals.append(0x30|pathssid) + valb += struct.pack('6s B', *vals) + elif path == 'none': + pass + else: + raise ValueError("Invalid via path format") + + # shift octets, mark last for as final + valbs = b'' + for c in valb[:-1]: + valbs += (c<<1).to_bytes(1, sys.byteorder) + valbs += (valb[-1]<<1|0x1).to_bytes(1, sys.byteorder) + + # add on control field and protocol id + valbs += struct.pack('B B', 0x03, 0xf0) + + return valbs + + def buildPacket(self): + bstr = self.packHeader(self.opcall, self._path) + objc = '*' if self.objmode < 3 else '_' # kill object beacon + objs = 'E' if self.objmode < 3 else '.' # switch to X when killing object + objstr = ";%s%c%s%s/%s%c" % \ + (self._objname, objc, \ + dt.datetime.utcnow().strftime("%d%H%Mz"), \ + self._latitude, \ + self._longitude, \ + objs ) + + objstr += "%sMHz" % self._objfreq + commentlen = 32 + if self._objtone != 'none' or self._objrange != 'none': + objstr += (" %s" % self._objtone) if self._objtone != 'none' else " " + objstr += (" %s" % self._objrange) if self._objrange != 'none' else " " + commentlen -= 10 + + if self.objmode == 1: + objstr += (" @%s" % self._dt.strftime("%I:%M%p")) + commentlen -= 9 + elif self.objmode == 2: + objstr += " ON-AIR" + commentlen -= 7 + else: + objstr += " OFF-AIR" + commentlen -= 8 + + if self._comment: + objstr += " " + self._comment[:commentlen] + + self.arPrint("OBEACON: %s" % objstr) + #print(binascii.hexlify(bstr+objstr.encode('UTF-8'))) + return bstr+objstr.encode('UTF-8') + +class arNetSked(arElement): + def __init__(self, call, skedfile, host, port, verbose): + self._objlist = [] + + self.call = call + self.skedfile = skedfile + self.tnchost = host + self.tncport = port + self.verbose = verbose + + arElement.__init__(self) + + def abortSignal(self, signum, frame): + self.abort() + + def abort(self): + if len(self._objlist): + self.arPrint("Stopping arNetSked") + for o in self._objlist: + o.stop() + + if self.tncsock: + self.arPrint("Closing TNC socket") + try: + self.tncsock.close() + except OSError: + pass + + def start(self): + + # connect to TNC + self.arPrint("Binding TNC client socket...") + + if re.fullmatch("\S\S:\S\S:\S\S:\S\S:\S\S:\S\S", self.tnchost): + self.arPrint("Bluetooth host address detected") + if self.tncport == 8001: + self.arPrint("Setting default bluetooth RFCOMM channel to 1") + self.tncport = 1 + self.tncsock = socket.socket(socket.AF_BLUETOOTH, socket.SOCK_STREAM, socket.BTPROTO_RFCOMM) + + else: + self.tncsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + + try: + self.tncsock.connect((self.tnchost, self.tncport)) + except ConnectionError as e: + self.arPrint("Socket connection refused to %s[%s]" % (self.tnchost, self.tncport)) + exit(1) + except OSError as e: + self.arPrint("Host unavailable %s[%s]" % (self.tnchost, self.tncport)) + exit(1) + + self.tncsock.settimeout(1) # 1s timeout + + self.tnckiss = arTNCKiss(self.recvPacketCB) + + + self.arPrint("Opening schedule file...") + with open(self.skedfile) as f: + # skip first two lines + d = f.readline() + d = f.readline() + lineno = 2 + for line in f: + lineno += 1 + if re.search('^\s*#', line): + continue + # print (line) + line = line.ljust(75) + + objn = arNet(self.call, self.tranPacketCB) + opts = line.split() + try: + objn.day = opts[0] + objn.timeofday = opts[1] + iad = opts[2].split('/') + objn.interval = iad[0] + objn.duration = iad[1] + objn.latitude = opts[3] + objn.longitude = opts[4] + objn.objname = opts[5] + objn.objfreq = opts[6] + objn.objtone = opts[7] + objn.objrange = opts[8] + objn.path = opts[9] + if len(opts) > 9: + objn.comment = " ".join(opts[10:]) + + except ValueError as err: + self.arPrint("Error processing schedule line[%d]" % lineno) + self.arPrint(err) + self.abort() + break + + self._objlist.append(objn) + objn.initTime() + objn.start() + + self.arPrint("NET elements started...") + # wait for net objects to cleanup + ndy = 1 + while ndy > 0: + ndy = 0 + for o in self._objlist: + o.join(1) + ndy += 1 if o.is_alive() else 0 + # discard inbound packets from tnc + more_rx = 1 + while more_rx: + try: + c = self.tncsock.recv(1) + except socket.timeout: + more_rx = 0 + except OSError: + more_rx = 0 + else: + if len(c) == 0: # can this happen? + more_rx = 0 + else: + self.tnckiss.recvChar(c) + + # close socket + try: + self.tncsock.close() + except OSError: + pass + + def tranPacketCB(self, pkt): + #print(binascii.hexlify(frame)) + self.tncsock.sendall(self.tnckiss.framePacket(pkt)) + + def recvPacketCB(self, pkt): + # drop inbound packets + pass + + +@click.command() +@click.option("--schedule", "-s", "sfile", required=True, + help="Schedule file to be processed", + type=click.Path(exists=True, dir_okay=False, readable=True), + ) +@click.option("--call", "-c", "call", required=True, + help="Operator callsign", + ) +@click.option("--host", "-h", "host", required=True, + help="TNC network or bluetooth host", + ) +@click.option("--port", "-p", "port", default=8001, + help="TNC network port or bluetooth channel", + ) +@click.option("--verbose", is_flag=True, help="Verbose output") +def main(sfile, call, host, port, verbose): + """Process schedule for APRS NetSked beacons and + transmit over network TNC KISS server. + """ + + netsked = arNetSked(call, sfile, host, port, verbose) + signal.signal(signal.SIGINT, netsked.abortSignal) +# signal.signal(signal.SIGTERM, netsked.abort) + + try: + netsked.start() + except Exception as e: + exc_type, exc_obj, exc_tb = sys.exc_info() + fname = os.path.split(exc_tb.tb_frame.f_code.co_filename)[1] + print("== EXCEPTION ==") + print("== %s\n== %s" % (exc_type, e)) + print("== File:%s[%s]" % (fname, exc_tb.tb_lineno)) + netsked.abort() + + +if __name__ == "__main__": + main() diff --git a/arTNCKiss.py b/arTNCKiss.py new file mode 100644 index 0000000..ab5dd67 --- /dev/null +++ b/arTNCKiss.py @@ -0,0 +1,115 @@ +#!/usr/bin/python3 +# +# Copyright (C) 2020 Richard Ferguson, K3FRG. +# k3frg@arrl.net +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. +# + +import os +import os.path +import sys +import datetime +import time +import struct + +from arElement import arElement + +#from AQPUtils import msleep, aprint + +# Special Characters +FEND = 0xc0 +FESC = 0xdb +TFEND = 0xdc +TFESC = 0xdd + +# Transmit Commands +DATA_FRAME = 0x00 +CMD_TXDELAY = 0x01 +CMD_P = 0x02 +CMD_SLOTTIME = 0x03 +CMD_TXTAIL = 0x04 +CMD_FULLDUPLEX = 0x05 +CMD_SETHARDWARE = 0x06 +CMD_RETURN = 0xff + +# RX State Machine +ST_IDL = 1 +ST_PKT = 2 +ST_ESC = 3 + +class arTNCKiss(arElement): + def __init__(self, packet_cb): + self._packet_cb = packet_cb + self._rx_state = ST_IDL + self._rx_buf = bytearray(0) + + arElement.__init__(self) + + # byte in, bytearray out + def recvChar(self,c): + hc = struct.unpack("B",c)[0] +# self.arPrint("rx_char> %x" % hc) + + if self._rx_state == ST_IDL: + if hc == FEND: + self._rx_state = ST_PKT + return + elif self._rx_state == ST_PKT: + if hc == FEND: + # packet complete + if len(self._rx_buf) > 0: + # verify command field or drop packet + if self._rx_buf[0] != 0x0: + #self.arPrint("invalid KISS command on packet receive") + pass + else: + self._packet_cb(self._rx_buf[1:]) + self._rx_buf = bytearray(0) + self._rx_state = ST_IDL + elif hc == FESC: + self._rx_state = ST_ESC + else: + self._rx_buf = self._rx_buf + c + elif self._rx_state == ST_ESC: + if hc == TFEND: + self._rx_buf = self._rx_buf + struct.pack("B",0xc0) + self._rx_state = ST_PKT + elif hc == TFESC: + self._rx_buf = self._rx_buf + struct.pack("B",0xdb) + self._rx_state = ST_PKT + else: + pass + #arPrint("invalid KISS escape character!") + + # string in, bytearray out + def framePacket(self,buf): + tx_buf = struct.pack("B B", FEND, DATA_FRAME) + + for c in buf: + hc = c.to_bytes(1, sys.byteorder) + if hc == FEND: + tx_buf = tx_buf + struct.pack("B", FESC) + tx_buf = tx_buf + struct.pack("B", TFEND) + elif hc == FESC: + tx_buf = tx_buf + struct.pack("B", FESC) + tx_buf = tx_buf + struct.pack("B", TFESC) + else: + tx_buf = tx_buf + hc + + tx_buf = tx_buf + struct.pack("B", FEND) + + return tx_buf + diff --git a/example_sked.cfg b/example_sked.cfg new file mode 100644 index 0000000..569806f --- /dev/null +++ b/example_sked.cfg @@ -0,0 +1,4 @@ +DAY TIME RATE LATITUDE LONGITUDE NAME FREQ TONE RA/O PATH COMMENT +--- ------- ---- -------- --------- --------- ------- ---- ---- --------- -------------|--------- +FRI 08:00PM 3/30 0000.00N 00000.00W NET-????? 146.520 none R05m none NetSked Example +SAT 09:00AM 5/45 0000.00N 00000.00W NET-????? 147.265 T100 +060 WIDE2-1 NetSked Example