├── .gitignore ├── LICENSE ├── README.md ├── images ├── colorized_wsjtx.PNG └── packet_exchange.PNG ├── pywsjtx ├── __init__.py ├── extra │ ├── __init__.py │ ├── latlong_to_grid_square.py │ └── simple_server.py ├── qcolor.py └── wsjtx_packets.py ├── requirements.txt ├── samples ├── color_wsjtx_packets.py ├── dump_wsjtx_packets.py ├── grid_from_gps.py ├── n1mm_arrl_ru.py └── wsjtx_packet_exchanger.py └── setup.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 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # Jetbrains IDE 104 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Brian Moran 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), 4 | to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, 5 | and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 10 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 11 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, 12 | ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A python module to interpret and create WSJT-X UDP packets 2 | 3 | Developed and tested with Python >= 3.6 4 | 5 | Sample programs for: 6 | * Setting the WSJT-X Grid Square from an external GPS 7 | * Coloring callsigns in WSJT-X based on N1MM Logger+ dupe and multiplier status 8 | * JTAlert-X, N1MM Logger+, WSJT-X packet exchange 9 | 10 | 11 | Example screen display for colorized callsigns, showing a new country multiplier in red: 12 | 13 | 14 | 15 | Example screen display for N1MM Logger+, WSJT-X, JTAlert-X in simultaneous use with wsjtx-packet-exchanger.py 16 | 17 | 18 | -------------------------------------------------------------------------------- /images/colorized_wsjtx.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmo/py-wsjtx/e725eb689314eff97298ccc87e84bcc5b8bc1ed9/images/colorized_wsjtx.PNG -------------------------------------------------------------------------------- /images/packet_exchange.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmo/py-wsjtx/e725eb689314eff97298ccc87e84bcc5b8bc1ed9/images/packet_exchange.PNG -------------------------------------------------------------------------------- /pywsjtx/__init__.py: -------------------------------------------------------------------------------- 1 | from pywsjtx.wsjtx_packets import * 2 | from pywsjtx.qcolor import * 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /pywsjtx/extra/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmo/py-wsjtx/e725eb689314eff97298ccc87e84bcc5b8bc1ed9/pywsjtx/extra/__init__.py -------------------------------------------------------------------------------- /pywsjtx/extra/latlong_to_grid_square.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | class GPSException(Exception): 4 | def __init__(self,*args): 5 | super(GPSException, self).__init__(*args) 6 | 7 | # From K6WRU via stackexchange : see https://ham.stackexchange.com/questions/221/how-can-one-convert-from-lat-long-to-grid-square/244#244 8 | # Convert latitude and longitude to Maidenhead grid locators. 9 | # 10 | # Arguments are in signed decimal latitude and longitude. For example, 11 | # the location of my QTH Palo Alto, CA is: 37.429167, -122.138056 or 12 | # in degrees, minutes, and seconds: 37° 24' 49" N 122° 6' 26" W 13 | class LatLongToGridSquare(object): 14 | upper = 'ABCDEFGHIJKLMNOPQRSTUVWX' 15 | lower = 'abcdefghijklmnopqrstuvwx' 16 | 17 | @classmethod 18 | def to_grid(cls,dec_lat, dec_lon): 19 | 20 | if not (-180<=dec_lon<180): 21 | raise GPSException('longitude must be -180<=lon<180, given %f\n'%dec_lon) 22 | if not (-90<=dec_lat<90): 23 | raise GPSException('latitude must be -90<=lat<90, given %f\n'%dec_lat) 24 | 25 | adj_lat = dec_lat + 90.0 26 | adj_lon = dec_lon + 180.0 27 | 28 | grid_lat_sq = LatLongToGridSquare.upper[int(adj_lat/10)] 29 | grid_lon_sq = LatLongToGridSquare.upper[int(adj_lon/20)] 30 | 31 | grid_lat_field = str(int(adj_lat%10)) 32 | grid_lon_field = str(int((adj_lon/2)%10)) 33 | 34 | adj_lat_remainder = (adj_lat - int(adj_lat)) * 60 35 | adj_lon_remainder = ((adj_lon) - int(adj_lon/2)*2) * 60 36 | 37 | grid_lat_subsq = LatLongToGridSquare.lower[int(adj_lat_remainder/2.5)] 38 | grid_lon_subsq = LatLongToGridSquare.lower[int(adj_lon_remainder/5)] 39 | 40 | return grid_lon_sq + grid_lat_sq + grid_lon_field + grid_lat_field + grid_lon_subsq + grid_lat_subsq 41 | 42 | # GPS sentences are encoded 43 | @classmethod 44 | def convert_to_degrees(cls, gps_value, direction): 45 | if direction not in ['N','S','E','W']: 46 | raise GPSException("Invalid direction specifier for lat/long: {}".format(direction)) 47 | 48 | dir_mult = 1 49 | if direction in ['S','W']: 50 | dir_mult = -1 51 | 52 | if len(gps_value) < 3: 53 | raise GPSException("Invalid Value for lat/long: {}".format(gps_value)) 54 | 55 | dot_posn = gps_value.index('.') 56 | 57 | if dot_posn < 0: 58 | raise GPSException("Invalid Format for lat/long: {}".format(gps_value)) 59 | 60 | degrees = gps_value[0:dot_posn-2] 61 | mins = gps_value[dot_posn-2:] 62 | 63 | f_degrees = dir_mult * (float(degrees) + (float(mins) / 60.0)) 64 | return f_degrees 65 | 66 | @classmethod 67 | def GPGLL_to_grid(cls, GPSLLText): 68 | # example: $GPGLL,4740.99254,N,12212.31179,W,223311.00,A,A*70\r\n 69 | try: 70 | components = GPSLLText.split(",") 71 | if components[0]=='$GPGLL': 72 | del components[0] 73 | if components[5] != 'A': 74 | raise GPSException("Not a valid GPS fix") 75 | lat = LatLongToGridSquare.convert_to_degrees(components[0], components[1]) 76 | long = LatLongToGridSquare.convert_to_degrees(components[2], components [3]) 77 | grid = LatLongToGridSquare.to_grid(lat, long) 78 | except GPSException: 79 | grid = "" 80 | return grid 81 | 82 | @classmethod 83 | def GPGGA_to_grid(cls, GPSGAText): 84 | # example: $GPGGA, 161229.487, 3723.2475, N, 12158.3416, W, 1, 07, 1.0, 9.0, M, , , , 0000*18 85 | try: 86 | components = GPSGAText.split(",") 87 | if components[0]=='$GPGGA': 88 | del components[0] 89 | if components[5] == '0': 90 | raise GPSException("Not a valid GPS fix") 91 | lat = LatLongToGridSquare.convert_to_degrees(components[1], components[2]) 92 | long = LatLongToGridSquare.convert_to_degrees(components[3], components [4]) 93 | grid = LatLongToGridSquare.to_grid(lat, long) 94 | except GPSException: 95 | grid = "" 96 | return grid 97 | -------------------------------------------------------------------------------- /pywsjtx/extra/simple_server.py: -------------------------------------------------------------------------------- 1 | # 2 | # In WSJTX parlance, the 'network server' is a program external to the wsjtx.exe program that handles packets emitted by wsjtx 3 | # 4 | # TODO: handle multicast groups. 5 | # 6 | # see dump_wsjtx_packets.py example for some simple usage 7 | # 8 | import socket 9 | import struct 10 | import pywsjtx 11 | import logging 12 | import ipaddress 13 | 14 | class SimpleServer(object): 15 | logger = logging.getLogger() 16 | MAX_BUFFER_SIZE = pywsjtx.GenericWSJTXPacket.MAXIMUM_NETWORK_MESSAGE_SIZE 17 | DEFAULT_UDP_PORT = 2237 18 | # 19 | # 20 | def __init__(self, ip_address='127.0.0.1', udp_port=DEFAULT_UDP_PORT, **kwargs): 21 | self.timeout = None 22 | self.verbose = kwargs.get("verbose",False) 23 | 24 | if kwargs.get("timeout") is not None: 25 | self.timeout = kwargs.get("timeout") 26 | 27 | the_address = ipaddress.ip_address(ip_address) 28 | if not the_address.is_multicast: 29 | self.sock = socket.socket(socket.AF_INET, # Internet 30 | socket.SOCK_DGRAM) # UDP 31 | 32 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 33 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) 34 | self.sock.bind((ip_address, int(udp_port))) 35 | else: 36 | self.multicast_setup(ip_address, udp_port) 37 | 38 | if self.timeout is not None: 39 | self.sock.settimeout(self.timeout) 40 | 41 | def multicast_setup(self, group, port=''): 42 | self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP) 43 | self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) 44 | self.sock.bind(('', port)) 45 | mreq = struct.pack("4sl", socket.inet_aton(group), socket.INADDR_ANY) 46 | self.sock.setsockopt(socket.IPPROTO_IP, socket.IP_ADD_MEMBERSHIP, mreq) 47 | 48 | def rx_packet(self): 49 | try: 50 | pkt, addr_port = self.sock.recvfrom(self.MAX_BUFFER_SIZE) # buffer size is 1024 bytes 51 | return(pkt, addr_port) 52 | except socket.timeout: 53 | if self.verbose: 54 | logging.debug("rx_packet: socket.timeout") 55 | return (None, None) 56 | 57 | def send_packet(self, addr_port, pkt): 58 | bytes_sent = self.sock.sendto(pkt,addr_port) 59 | self.logger.debug("send_packet: Bytes sent {} ".format(bytes_sent)) 60 | 61 | def demo_run(self): 62 | while True: 63 | (pkt, addr_port) = self.rx_packet() 64 | if (pkt != None): 65 | the_packet = pywsjtx.WSJTXPacketClassFactory.from_udp_packet(addr_port, pkt) 66 | print(the_packet) 67 | -------------------------------------------------------------------------------- /pywsjtx/qcolor.py: -------------------------------------------------------------------------------- 1 | # 2 | # Utility class to help out with Qt Color Values. 3 | # 4 | class QCOLOR: 5 | SPEC_RGB = 1 6 | SPEC_INVALID = 0 7 | 8 | def __init__(self, spec, alpha, red, green, blue): 9 | self.spec = spec 10 | self.red = red 11 | self.green = green 12 | self.blue = blue 13 | self.alpha = alpha 14 | 15 | @classmethod 16 | def Black(cls): 17 | return QCOLOR(QCOLOR.SPEC_RGB, 255, 0, 0, 0) 18 | 19 | @classmethod 20 | def Red(cls): 21 | return QCOLOR(QCOLOR.SPEC_RGB, 255, 255, 0, 0) 22 | 23 | @classmethod 24 | def RGBA(cls, alpha, red, green, blue): 25 | return QCOLOR(QCOLOR.SPEC_RGB, alpha, red, green, blue) 26 | 27 | @classmethod 28 | def White(cls): 29 | return QCOLOR(QCOLOR.SPEC_RGB, 255,255,255,255) 30 | 31 | @classmethod 32 | def Uncolor(cls): 33 | return QCOLOR(QCOLOR.SPEC_INVALID, 0,0,0,0) 34 | 35 | 36 | -------------------------------------------------------------------------------- /pywsjtx/wsjtx_packets.py: -------------------------------------------------------------------------------- 1 | 2 | import struct 3 | import datetime 4 | import math 5 | 6 | 7 | class PacketUtil: 8 | @classmethod 9 | # this hexdump brought to you by Stack Overflow 10 | def hexdump(cls, src, length=16): 11 | FILTER = ''.join([(len(repr(chr(x))) == 3) and chr(x) or '.' for x in range(256)]) 12 | lines = [] 13 | for c in range(0, len(src), length): 14 | chars = src[c:c + length] 15 | hex = ' '.join(["%02x" % x for x in chars]) 16 | printable = ''.join(["%s" % ((x <= 127 and FILTER[x]) or '.') for x in chars]) 17 | lines.append("%04x %-*s %s\n" % (c, length * 3, hex, printable)) 18 | return ''.join(lines) 19 | 20 | # timezone tomfoolery 21 | @classmethod 22 | def midnight_utc(cls): 23 | utcnow = datetime.datetime.utcnow() 24 | utcmidnight = datetime.datetime(utcnow.year, utcnow.month, utcnow.day, 0, 0) 25 | return utcmidnight 26 | 27 | #converts a Julian day to a calendar Date 28 | @classmethod 29 | def JDToDateMeeus(cls,jDNum): 30 | F=0.0 31 | 32 | jDNum += 0.5 33 | Z = jDNum #Z == int so I = int part 34 | F = jDNum - Z #F = fractional part 35 | if(Z < 2299161): #Julian? 36 | A = Z 37 | else: #Gregorian 38 | alpha = math.floor((Z - 1867216.25) / 36524.25) 39 | A = Z + 1 + alpha - math.floor(alpha / 4.0) 40 | B = A + 1524 41 | C = math.floor((B - 122.1) /365.25) 42 | D = math.floor(365.25 * C) 43 | E = math.floor((B - D) /30.6001) 44 | day = int(B - D - math.floor(30.6001 * E) + F) 45 | if( E < 14): 46 | month = E - 1 47 | else: 48 | month = E - 13 49 | if(month > 2): 50 | year = C - 4716 51 | else: 52 | year = C - 4715 53 | return (year,month,day) 54 | 55 | 56 | class PacketWriter(object): 57 | def __init__(self ): 58 | self.ptr_pos = 0 59 | self.packet = bytearray() 60 | # self.max_ptr_pos 61 | self.write_header() 62 | 63 | def write_header(self): 64 | self.write_QUInt32(GenericWSJTXPacket.MAGIC_NUMBER) 65 | self.write_QInt32(GenericWSJTXPacket.SCHEMA_VERSION) 66 | 67 | def write_QInt8(self, val): 68 | self.packet.extend(struct.pack('>b', val)) 69 | 70 | def write_QUInt8(self, val): 71 | self.packet.extend(struct.pack('>B', val)) 72 | 73 | def write_QBool(self, val): 74 | self.packet.extend(struct.pack('>?', val)) 75 | 76 | def write_QInt16(self, val): 77 | self.packet.extend(struct.pack('>h', val)) 78 | 79 | def write_QUInt16(self, val): 80 | self.packet.extend(struct.pack('>H', val)) 81 | 82 | def write_QInt32(self, val): 83 | self.packet.extend(struct.pack('>l',val)) 84 | 85 | def write_QUInt32(self, val): 86 | self.packet.extend(struct.pack('>L', val)) 87 | 88 | def write_QInt64(self, val): 89 | self.packet.extend(struct.pack('>q',val)) 90 | 91 | def write_QFloat(self, val): 92 | self.packet.extend(struct.pack('>d', val)) 93 | 94 | def write_QString(self, str_val): 95 | 96 | b_values = str_val 97 | if type(str_val) != bytes: 98 | b_values = str_val.encode() 99 | length = len(b_values) 100 | self.write_QInt32(length) 101 | self.packet.extend(b_values) 102 | 103 | def write_QColor(self, color_val): 104 | # see Qt serialization for QColor format; unfortunately thes serialization is nothing like what's in that. 105 | # It's not correct. Look instead at the wsjt-x configuration settings, where 106 | # color values have been serialized. 107 | # 108 | self.write_QInt8(color_val.spec) 109 | self.write_QUInt8(color_val.alpha) 110 | self.write_QUInt8(color_val.alpha) 111 | 112 | self.write_QUInt8(color_val.red) 113 | self.write_QUInt8(color_val.red) 114 | 115 | self.write_QUInt8(color_val.green) 116 | self.write_QUInt8(color_val.green) 117 | 118 | self.write_QUInt8(color_val.blue) 119 | self.write_QUInt8(color_val.blue) 120 | self.write_QUInt16(0) 121 | 122 | class PacketReader(object): 123 | def __init__(self, packet): 124 | self.ptr_pos = 0 125 | self.packet = packet 126 | self.max_ptr_pos = len(packet)-1 127 | self.skip_header() 128 | 129 | def at_eof(self): 130 | return self.ptr_pos > self.max_ptr_pos 131 | 132 | def skip_header(self): 133 | if self.max_ptr_pos < 8: 134 | raise Exception('Not enough data to skip header') 135 | self.ptr_pos = 8 136 | 137 | def check_ptr_bound(self,field_type, length): 138 | if self.ptr_pos + length > self.max_ptr_pos+1: 139 | raise Exception('Not enough data to extract {}'.format(field_type)) 140 | 141 | ## grab data from the packet, incrementing the ptr_pos on the basis of the data we've gleaned 142 | def QInt32(self): 143 | self.check_ptr_bound('QInt32', 4) # sure we could inspect that, but that is slow. 144 | (the_int32,) = struct.unpack('>l',self.packet[self.ptr_pos:self.ptr_pos+4]) 145 | self.ptr_pos += 4 146 | return the_int32 147 | 148 | 149 | def QInt8(self): 150 | self.check_ptr_bound('QInt8', 1) 151 | (the_int8,) = struct.unpack('>b', self.packet[self.ptr_pos:self.ptr_pos+1]) 152 | self.ptr_pos += 1 153 | return the_int8 154 | 155 | def QInt64(self): 156 | self.check_ptr_bound('QInt64', 8) 157 | (the_int64,) = struct.unpack('>q', self.packet[self.ptr_pos:self.ptr_pos+8]) 158 | self.ptr_pos += 8 159 | return the_int64 160 | 161 | def QFloat(self): 162 | self.check_ptr_bound('QFloat', 8) 163 | (the_double,) = struct.unpack('>d', self.packet[self.ptr_pos:self.ptr_pos+8]) 164 | self.ptr_pos += 8 165 | return the_double 166 | 167 | def QString(self): 168 | str_len = self.QInt32() 169 | if str_len == -1: 170 | return None 171 | self.check_ptr_bound('QString[{}]'.format(str_len),str_len) 172 | (str,) = struct.unpack('{}s'.format(str_len), self.packet[self.ptr_pos:self.ptr_pos + str_len]) 173 | self.ptr_pos += str_len 174 | return str.decode('utf-8') 175 | 176 | def QDateTime(self): 177 | jdnum = self.QInt64() 178 | millis_since_midnight = self.QInt32() 179 | spec = self.QInt8() 180 | offset = 0 181 | if spec == 2: 182 | offset = self.QInt32() 183 | date = PacketUtil.JDToDateMeeus(jdnum) 184 | time = PacketUtil.midnight_utc() + datetime.timedelta(milliseconds=millis_since_midnight) 185 | return QDateTime(date,time,spec,offset) 186 | 187 | class QDateTime(object): 188 | def __init__(self,date,time,spec,offset): 189 | self.date=date 190 | self.time=time 191 | self.spec=spec 192 | self.offset=offset 193 | 194 | def __repr__(self): 195 | return "date {}\n\ttime {}\n\tspec {}\n\toffset {}".format(self.date,self.time,self.spec,self.offset) 196 | 197 | class GenericWSJTXPacket(object): 198 | SCHEMA_VERSION = 3 199 | MINIMUM_SCHEMA_SUPPORTED = 2 200 | MAXIMUM_SCHEMA_SUPPORTED = 3 201 | MINIMUM_NETWORK_MESSAGE_SIZE = 8 202 | MAXIMUM_NETWORK_MESSAGE_SIZE = 2048 203 | MAGIC_NUMBER = 0xadbccbda 204 | 205 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 206 | self.addr_port = addr_port 207 | self.magic = magic 208 | self.schema = schema 209 | self.pkt_type = pkt_type 210 | self.id = id 211 | self.pkt = pkt 212 | 213 | class InvalidPacket(GenericWSJTXPacket): 214 | TYPE_VALUE = -1 215 | def __init__(self, addr_port, packet, message): 216 | self.packet = packet 217 | self.message = message 218 | self.addr_port = addr_port 219 | 220 | def __repr__(self): 221 | return 'Invalid Packet: %s from %s:%s\n%s' % (self.message, self.addr_port[0], self.addr_port[1], PacketUtil.hexdump(self.packet)) 222 | 223 | class HeartBeatPacket(GenericWSJTXPacket): 224 | TYPE_VALUE = 0 225 | 226 | def __init__(self, addr_port: object, magic: object, schema: object, pkt_type: object, id: object, pkt: object) -> object: 227 | GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) 228 | ps = PacketReader(pkt) 229 | the_type = ps.QInt32() 230 | self.wsjtx_id = ps.QString() 231 | self.max_schema = ps.QInt32() 232 | self.version = ps.QInt8() 233 | self.revision = ps.QInt8() 234 | 235 | def __repr__(self): 236 | return 'HeartBeatPacket: from {}:{}\n\twsjtx id:{}\tmax_schema:{}\tschema:{}\tversion:{}\trevision:{}' .format(self.addr_port[0], self.addr_port[1], 237 | self.wsjtx_id, self.max_schema, self.schema, self.version, self.revision) 238 | @classmethod 239 | # make a heartbeat packet (a byte array) we can send to a 'client'. This should be it's own class. 240 | def Builder(cls,wsjtx_id='pywsjtx', max_schema=2, version=1, revision=1): 241 | # build the packet to send 242 | pkt = PacketWriter() 243 | pkt.write_QInt32(HeartBeatPacket.TYPE_VALUE) 244 | pkt.write_QString(wsjtx_id) 245 | pkt.write_QInt32(max_schema) 246 | pkt.write_QInt32(version) 247 | pkt.write_QInt32(revision) 248 | return pkt.packet 249 | 250 | class StatusPacket(GenericWSJTXPacket): 251 | TYPE_VALUE = 1 252 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 253 | GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) 254 | ps = PacketReader(pkt) 255 | the_type = ps.QInt32() 256 | self.wsjtx_id = ps.QString() 257 | self.dial_frequency = ps.QInt64() 258 | 259 | self.mode = ps.QString() 260 | self.dx_call = ps.QString() 261 | 262 | self.report = ps.QString() 263 | self.tx_mode = ps.QString() 264 | 265 | self.tx_enabled = ps.QInt8() 266 | self.transmitting = ps.QInt8() 267 | self.decoding = ps.QInt8() 268 | self.rx_df = ps.QInt32() 269 | self.tx_df = ps.QInt32() 270 | 271 | 272 | self.de_call = ps.QString() 273 | 274 | self.de_grid = ps.QString() 275 | self.dx_grid = ps.QString() 276 | 277 | self.tx_watchdog = ps.QInt8() 278 | self.sub_mode = ps.QString() 279 | self.fast_mode = ps.QInt8() 280 | 281 | # new in wsjtx-2.0.0 282 | self.special_op_mode = ps.QInt8() 283 | 284 | def __repr__(self): 285 | str = 'StatusPacket: from {}:{}\n\twsjtx id:{}\tde_call:{}\tde_grid:{}\n'.format(self.addr_port[0], self.addr_port[1],self.wsjtx_id, 286 | self.de_call, self.de_grid) 287 | str += "\tfrequency:{}\trx_df:{}\ttx_df:{}\tdx_call:{}\tdx_grid:{}\treport:{}\n".format(self.dial_frequency, self.rx_df, self.tx_df, self.dx_call, self.dx_grid, self.report) 288 | str += "\ttransmitting:{}\t decoding:{}\ttx_enabled:{}\ttx_watchdog:{}\tsub_mode:{}\tfast_mode:{}\tspecial_op_mode:{}".format(self.transmitting, self.decoding, self.tx_enabled, self.tx_watchdog, 289 | self.sub_mode, self.fast_mode, self.special_op_mode) 290 | return str 291 | 292 | 293 | class DecodePacket(GenericWSJTXPacket): 294 | TYPE_VALUE = 2 295 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 296 | GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) 297 | # handle packet-specific stuff. 298 | ps = PacketReader(pkt) 299 | the_type = ps.QInt32() 300 | self.wsjtx_id = ps.QString() 301 | self.new_decode = ps.QInt8() 302 | self.millis_since_midnight = ps.QInt32() 303 | self.time = PacketUtil.midnight_utc() + datetime.timedelta(milliseconds=self.millis_since_midnight) 304 | self.snr = ps.QInt32() 305 | self.delta_t = ps.QFloat() 306 | self.delta_f = ps.QInt32() 307 | self.mode = ps.QString() 308 | self.message = ps.QString() 309 | self.low_confidence = ps.QInt8() 310 | self.off_air = ps.QInt8() 311 | 312 | def __repr__(self): 313 | str = 'DecodePacket: from {}:{}\n\twsjtx id:{}\tmessage:{}\n'.format(self.addr_port[0], 314 | self.addr_port[1], 315 | self.wsjtx_id, 316 | self.message) 317 | str += "\tdelta_f:{}\tnew:{}\ttime:{}\tsnr:{}\tdelta_f:{}\tmode:{}".format(self.delta_f, 318 | self.new_decode, 319 | self.time, 320 | self.snr, 321 | self.delta_f, 322 | self.mode) 323 | return str 324 | 325 | class ClearPacket(GenericWSJTXPacket): 326 | TYPE_VALUE = 3 327 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 328 | GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) 329 | # handle packet-specific stuff. 330 | 331 | class ReplyPacket(GenericWSJTXPacket): 332 | TYPE_VALUE = 4 333 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 334 | GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) 335 | # handle packet-specific stuff. 336 | 337 | @classmethod 338 | def Builder(cls, decode_packet): 339 | # build the packet to send 340 | pkt = PacketWriter() 341 | pkt.write_QInt32(ReplyPacket.TYPE_VALUE) 342 | pkt.write_QString(decode_packet.wsjtx_id) 343 | pkt.write_QInt32(decode_packet.millis_since_midnight) 344 | pkt.write_QInt32(decode_packet.snr) 345 | pkt.write_QFloat(decode_packet.delta_t) 346 | pkt.write_QInt32(decode_packet.delta_f) 347 | pkt.write_QString(decode_packet.mode) 348 | pkt.write_QString(decode_packet.message) 349 | pkt.write_QInt8(decode_packet.low_confidence) 350 | pkt.write_QInt8(0) 351 | return pkt.packet 352 | 353 | 354 | class QSOLoggedPacket(GenericWSJTXPacket): 355 | TYPE_VALUE = 5 356 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 357 | GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) 358 | # handle packet-specific stuff. 359 | ps = PacketReader(pkt) 360 | the_type = ps.QInt32() 361 | self.wsjtx_id = ps.QString() 362 | self.datetime_off = ps.QDateTime() 363 | self.call = ps.QString() 364 | self.grid = ps.QString() 365 | self.frequency = ps.QInt64() 366 | self.mode = ps.QString() 367 | self.report_sent = ps.QString() 368 | self.report_recv = ps.QString() 369 | self.tx_power = ps.QString() 370 | self.comments = ps.QString() 371 | self.name = ps.QString() 372 | self.datetime_on = ps.QDateTime() 373 | self.op_call = ps.QString() 374 | self.my_call = ps.QString() 375 | self.my_grid = ps.QString() 376 | self.exchange_sent = ps.QString() 377 | self.exchange_recv = ps.QString() 378 | 379 | def __repr__(self): 380 | str = 'QSOLoggedPacket: call {} @ {}\n\tdatetime:{}\tfreq:{}\n'.format(self.call, 381 | self.grid, 382 | self.datetime_off, 383 | self.frequency) 384 | str += "\tmode:{}\tsent:{}\trecv:{}".format(self.mode, 385 | self.report_sent, 386 | self.report_recv) 387 | return str 388 | 389 | 390 | class ClosePacket(GenericWSJTXPacket): 391 | TYPE_VALUE = 6 392 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 393 | GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) 394 | # handle packet-specific stuff. 395 | 396 | class ReplayPacket(GenericWSJTXPacket): 397 | TYPE_VALUE = 7 398 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 399 | GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) 400 | # handle packet-specific stuff. 401 | 402 | class HaltTxPacket(GenericWSJTXPacket): 403 | TYPE_VALUE = 8 404 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 405 | GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) 406 | # handle packet-specific stuff. 407 | 408 | class FreeTextPacket(GenericWSJTXPacket): 409 | TYPE_VALUE = 9 410 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 411 | GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) 412 | # handle packet-specific stuff. 413 | 414 | @classmethod 415 | def Builder(cls,to_wsjtx_id='WSJT-X', text="", send=False): 416 | # build the packet to send 417 | pkt = PacketWriter() 418 | print('To_wsjtx_id ',to_wsjtx_id,' text ',text, 'send ',send) 419 | pkt.write_QInt32(FreeTextPacket.TYPE_VALUE) 420 | pkt.write_QString(to_wsjtx_id) 421 | pkt.write_QString(text) 422 | pkt.write_QInt8(send) 423 | return pkt.packet 424 | 425 | class WSPRDecodePacket(GenericWSJTXPacket): 426 | TYPE_VALUE = 10 427 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 428 | GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) 429 | # handle packet-specific stuff. 430 | 431 | class LocationChangePacket(GenericWSJTXPacket): 432 | TYPE_VALUE = 11 433 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 434 | GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) 435 | # handle packet-specific stuff. 436 | 437 | @classmethod 438 | def Builder(cls, to_wsjtx_id='WSJT-X', new_grid=""): 439 | # build the packet to send 440 | pkt = PacketWriter() 441 | pkt.write_QInt32(LocationChangePacket.TYPE_VALUE) 442 | pkt.write_QString(to_wsjtx_id) 443 | pkt.write_QString(new_grid) 444 | return pkt.packet 445 | 446 | class LoggedADIFPacket(GenericWSJTXPacket): 447 | TYPE_VALUE = 12 448 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 449 | GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) 450 | # handle packet-specific stuff. 451 | 452 | @classmethod 453 | def Builder(cls, to_wsjtx_id='WSJT-X', adif_text=""): 454 | # build the packet to send 455 | pkt = PacketWriter() 456 | pkt.write_QInt32(LoggedADIFPacket.TYPE_VALUE) 457 | pkt.write_QString(to_wsjtx_id) 458 | pkt.write_QString(adif_text) 459 | return pkt.packet 460 | 461 | class HighlightCallsignPacket(GenericWSJTXPacket): 462 | TYPE_VALUE = 13 463 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 464 | GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) 465 | # handle packet-specific stuff. 466 | 467 | # the callsign field can contain text, callsigns, entire lines. 468 | 469 | @classmethod 470 | def Builder(cls, to_wsjtx_id='WSJT-X', callsign="K1JT", background_color=None, foreground_color=None, highlight_last_only=True ): 471 | # build the packet to send 472 | pkt = PacketWriter() 473 | pkt.write_QInt32(HighlightCallsignPacket.TYPE_VALUE) 474 | pkt.write_QString(to_wsjtx_id) 475 | pkt.write_QString(callsign) 476 | pkt.write_QColor(background_color) 477 | pkt.write_QColor(foreground_color) 478 | pkt.write_QBool(highlight_last_only) 479 | return pkt.packet 480 | 481 | class WSJTXPacketClassFactory(GenericWSJTXPacket): 482 | 483 | PACKET_TYPE_TO_OBJ_MAP = { 484 | HeartBeatPacket.TYPE_VALUE: HeartBeatPacket, 485 | StatusPacket.TYPE_VALUE: StatusPacket, 486 | DecodePacket.TYPE_VALUE: DecodePacket, 487 | ClearPacket.TYPE_VALUE: ClearPacket, 488 | ReplyPacket.TYPE_VALUE: ReplyPacket, 489 | QSOLoggedPacket.TYPE_VALUE: QSOLoggedPacket, 490 | ClosePacket.TYPE_VALUE: ClosePacket, 491 | ReplayPacket.TYPE_VALUE: ReplayPacket, 492 | HaltTxPacket.TYPE_VALUE: HaltTxPacket, 493 | FreeTextPacket.TYPE_VALUE: FreeTextPacket, 494 | WSPRDecodePacket.TYPE_VALUE: WSPRDecodePacket 495 | } 496 | def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): 497 | self.addr_port = addr_port 498 | self.magic = magic 499 | self.schema = schema 500 | self.pkt_type = pkt_type 501 | self.pkt_id = id 502 | self.pkt = pkt 503 | 504 | def __repr__(self): 505 | return 'WSJTXPacketFactory: from {}:{}\n{}' .format(self.addr_port[0], self.addr_port[1], PacketUtil.hexdump(self.pkt)) 506 | 507 | # Factory-like method 508 | @classmethod 509 | def from_udp_packet(cls, addr_port, udp_packet): 510 | if len(udp_packet) < GenericWSJTXPacket.MINIMUM_NETWORK_MESSAGE_SIZE: 511 | return InvalidPacket( addr_port, udp_packet, "Packet too small") 512 | 513 | if len(udp_packet) > GenericWSJTXPacket.MAXIMUM_NETWORK_MESSAGE_SIZE: 514 | return InvalidPacket( addr_port, udp_packet, "Packet too large") 515 | 516 | (magic, schema, pkt_type, id_len) = struct.unpack('>LLLL', udp_packet[0:16]) 517 | 518 | if magic != GenericWSJTXPacket.MAGIC_NUMBER: 519 | return InvalidPacket( addr_port, udp_packet, "Invalid Magic Value") 520 | 521 | if schema < GenericWSJTXPacket.MINIMUM_SCHEMA_SUPPORTED or schema > GenericWSJTXPacket.MAXIMUM_SCHEMA_SUPPORTED: 522 | return InvalidPacket( addr_port, udp_packet, "Unsupported schema value {}".format(schema)) 523 | klass = WSJTXPacketClassFactory.PACKET_TYPE_TO_OBJ_MAP.get(pkt_type) 524 | 525 | if klass is None: 526 | return InvalidPacket( addr_port, udp_packet, "Unknown packet type {}".format(pkt_type)) 527 | 528 | return klass(addr_port, magic, schema, pkt_type, id, udp_packet) 529 | 530 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmo/py-wsjtx/e725eb689314eff97298ccc87e84bcc5b8bc1ed9/requirements.txt -------------------------------------------------------------------------------- /samples/color_wsjtx_packets.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 4 | import pywsjtx.extra.simple_server 5 | 6 | import re 7 | import random 8 | 9 | TEST_MULTICAST = True 10 | 11 | if TEST_MULTICAST: 12 | IP_ADDRESS = '224.1.1.1' 13 | PORT = 5007 14 | else: 15 | IP_ADDRESS = '127.0.0.1' 16 | PORT = 2237 17 | 18 | MY_MAX_SCHEMA = 3 19 | 20 | s = pywsjtx.extra.simple_server.SimpleServer(IP_ADDRESS, PORT, timeout=2.0) 21 | 22 | while True: 23 | 24 | (pkt, addr_port) = s.rx_packet() 25 | if (pkt != None): 26 | the_packet = pywsjtx.WSJTXPacketClassFactory.from_udp_packet(addr_port, pkt) 27 | 28 | if type(the_packet) == pywsjtx.HeartBeatPacket: 29 | max_schema = max(the_packet.max_schema, MY_MAX_SCHEMA) 30 | reply_beat_packet = pywsjtx.HeartBeatPacket.Builder(the_packet.wsjtx_id,max_schema) 31 | s.send_packet(addr_port, reply_beat_packet) 32 | if type(the_packet) == pywsjtx.DecodePacket: 33 | m = re.match(r"^CQ\s+(\S+)\s+", the_packet.message) 34 | if m: 35 | print("Callsign {}".format(m.group(1))) 36 | callsign = m.group(1) 37 | 38 | color_pkt = pywsjtx.HighlightCallsignPacket.Builder(the_packet.wsjtx_id, callsign, 39 | 40 | pywsjtx.QCOLOR.White(), 41 | pywsjtx.QCOLOR.Red(), 42 | True) 43 | 44 | normal_pkt = pywsjtx.HighlightCallsignPacket.Builder(the_packet.wsjtx_id, callsign, 45 | pywsjtx.QCOLOR.Uncolor(), 46 | pywsjtx.QCOLOR.Uncolor(), 47 | True) 48 | s.send_packet(addr_port, color_pkt) 49 | #print(pywsjtx.PacketUtil.hexdump(color_pkt)) 50 | print(the_packet) 51 | 52 | 53 | -------------------------------------------------------------------------------- /samples/dump_wsjtx_packets.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 4 | import pywsjtx.extra.simple_server 5 | 6 | #IP_ADDRESS = '224.1.1.1' 7 | #PORT = 5007 8 | 9 | IP_ADDRESS = '127.0.0.1' 10 | PORT = 2237 11 | 12 | s = pywsjtx.extra.simple_server.SimpleServer(IP_ADDRESS, PORT, timeout=2.0) 13 | 14 | while True: 15 | 16 | (pkt, addr_port) = s.rx_packet() 17 | if (pkt != None): 18 | the_packet = pywsjtx.WSJTXPacketClassFactory.from_udp_packet(addr_port, pkt) 19 | if type(the_packet) == pywsjtx.StatusPacket: 20 | pass 21 | print(the_packet) 22 | 23 | 24 | -------------------------------------------------------------------------------- /samples/grid_from_gps.py: -------------------------------------------------------------------------------- 1 | 2 | # simple example to open a com port (using pyserial) to read NMEA sentences from an attached GPS, 3 | # and supply the grid value to wsjtx. 4 | 5 | # using standard NMEA sentences 6 | import os 7 | import sys 8 | import threading 9 | from datetime import datetime 10 | import serial 11 | import logging 12 | 13 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 14 | import pywsjtx 15 | import pywsjtx.extra.simple_server 16 | import pywsjtx.extra.latlong_to_grid_square 17 | 18 | # Windows communications port we're using. 19 | # TODO: gpsd on linux 20 | 21 | COMPORT = 'COM7' 22 | # Set 'COM7' to the com port number of your GPS. Example 'COM9' 23 | COMPORTBAUDRATE = '9600' 24 | # Set '9600' to the baud rate for your GPS com port. Usually 4800, 9600, 19200, 38400, etc. Example '19200' 25 | IP_ADDRESS = '127.0.0.1' 26 | # Set '127.0.0.1' to the IP address where this script should inject the grid. Example: If you are running both this script and WSJT-X on the same machine, set this to '127.0.0.1' both here AND in WSJT-X Settings>Reporting>UDP Server>UDP Server (In the WSJT-X UDP Server settings, there will be no quotes ' around the IP address.) 27 | PORT = 2237 28 | # Set '2237' to the IP address where this script should inject the grid. Example: If you are running both this script and WSJT-X on the same machine, set this to '2237' both here AND in WSJT-X Settings>Reporting>UDP Server>UDP Server Port Number (In the WSJT-X UDP Server settings, there will be no quotes ' around the port number.) 29 | logging.basicConfig(level=logging.DEBUG) 30 | 31 | class NMEALocation(object): 32 | # parse the NMEA message for location into a grid square 33 | 34 | def __init__(self, grid_changed_callback = None): 35 | self.valid = False 36 | self.grid = "" # this is an attribute, so allegedly doesn't need locking when used with multiple threads. 37 | self.last_fix_at = None 38 | self.grid_changed_callback = grid_changed_callback 39 | 40 | def handle_serial(self,text): 41 | if text.startswith('$GPGLL'): 42 | logging.debug('nmea sentence: {}'.format(text.rstrip())) 43 | grid = pywsjtx.extra.latlong_to_grid_square.LatLongToGridSquare.GPGLL_to_grid(text) 44 | 45 | if grid != "": 46 | self.valid = True 47 | self.last_fix_at = datetime.utcnow() 48 | else: 49 | self.valid = False 50 | 51 | if grid != "" and self.grid != grid: 52 | logging.debug("NMEALocation - grid mismatch old: {} new: {}".format(self.grid,grid)) 53 | self.grid = grid 54 | if (self.grid_changed_callback): 55 | c_thr = threading.Thread(target=self.grid_changed_callback, args=(grid,), kwargs={}) 56 | c_thr.start() 57 | elif text.startswith('$GPGGA'): 58 | logging.debug('nmea sentence: {}'.format(text.rstrip())) 59 | grid = pywsjtx.extra.latlong_to_grid_square.LatLongToGridSquare.GPGGA_to_grid(text) 60 | 61 | if grid != "": 62 | self.valid = True 63 | self.last_fix_at = datetime.utcnow() 64 | else: 65 | self.valid = False 66 | 67 | if grid != "" and self.grid != grid: 68 | logging.debug("NMEALocation - grid mismatch old: {} new: {}".format(self.grid,grid)) 69 | self.grid = grid 70 | if (self.grid_changed_callback): 71 | c_thr = threading.Thread(target=self.grid_changed_callback, args=(grid,), kwargs={}) 72 | c_thr.start() 73 | 74 | class SerialGPS(object): 75 | 76 | def __init__(self): 77 | self.line_handlers = [] 78 | self.comm_thread = None 79 | self.comm_device = None 80 | self.stop_signalled = False 81 | 82 | def add_handler(self, line_handler): 83 | if (not (line_handler is None)) and (not (line_handler in self.line_handlers)): 84 | self.line_handlers.append(line_handler) 85 | 86 | def open(self, comport, baud, line_handler, **serial_kwargs): 87 | if self.comm_device is not None: 88 | self.close() 89 | self.stop_signalled = False 90 | self.comm_device = serial.Serial(comport, baud, **serial_kwargs) 91 | if self.comm_device is not None: 92 | self.add_handler(line_handler) 93 | self.comm_thread = threading.Thread(target=self.serial_worker, args=()) 94 | self.comm_thread.start() 95 | 96 | def close(self): 97 | self.stop_signalled = True 98 | self.comm_thread.join() 99 | 100 | self.comm_device.close() 101 | self.line_handlers = [] 102 | self.comm_device = None 103 | self.stop_signalled = False 104 | 105 | def remove_handler(self, line_handler): 106 | self.line_handlers.remove(line_handler) 107 | 108 | def serial_worker(self): 109 | while (True): 110 | if self.stop_signalled: 111 | return # terminate 112 | line = self.comm_device.readline() 113 | # dispatch the line 114 | # note that bytes are used for readline, vs strings after the decode to utf-8 115 | if line.startswith(b'$'): 116 | try: 117 | str_line = line.decode("utf-8") 118 | for p in self.line_handlers: 119 | p(str_line) 120 | except UnicodeDecodeError as ex: 121 | logging.debug("serial_worker: {} - line: {}".format(ex,[hex(c) for c in line])) 122 | 123 | @classmethod 124 | def example_line_handler(cls, text): 125 | print('serial: ',text) 126 | 127 | # set up the serial_gps to run 128 | # get location data from the GPS, update the grid 129 | # get the grid out of the status message from the WSJT-X instance 130 | 131 | # if we have a grid, and it's not the same as GPS, then make it the same by sending the message. 132 | # But only do that if triggered by a status message. 133 | 134 | 135 | wsjtx_id = None 136 | nmea_p = None 137 | gps_grid = "" 138 | 139 | def example_callback(new_grid): 140 | global gps_grid 141 | print("New Grid! {}".format(new_grid)) 142 | # this sets the 143 | gps_grid = new_grid 144 | 145 | sgps = SerialGPS() 146 | 147 | s = pywsjtx.extra.simple_server.SimpleServer(IP_ADDRESS,PORT) 148 | 149 | print("Starting wsjt-x message server") 150 | 151 | while True: 152 | 153 | (pkt, addr_port) = s.rx_packet() 154 | if (pkt != None): 155 | the_packet = pywsjtx.WSJTXPacketClassFactory.from_udp_packet(addr_port, pkt) 156 | if wsjtx_id is None and (type(the_packet) == pywsjtx.HeartBeatPacket): 157 | # we have an instance of WSJTX 158 | print("wsjtx detected, id is {}".format(the_packet.wsjtx_id)) 159 | print("starting gps monitoring") 160 | wsjtx_id = the_packet.wsjtx_id 161 | # start up the GPS reader 162 | nmea_p = NMEALocation(example_callback) 163 | sgps.open(COMPORT, COMPORTBAUDRATE, nmea_p.handle_serial, timeout=1.2) 164 | 165 | if type(the_packet) == pywsjtx.StatusPacket: 166 | if gps_grid != "" and the_packet.de_grid != gps_grid: 167 | print("Sending Grid Change to wsjtx-x, old grid:{} new grid: {}".format(the_packet.de_grid, gps_grid)) 168 | grid_change_packet = pywsjtx.LocationChangePacket.Builder(wsjtx_id, "GRID:"+gps_grid) 169 | logging.debug(pywsjtx.PacketUtil.hexdump(grid_change_packet)) 170 | s.send_packet(the_packet.addr_port, grid_change_packet) 171 | # for fun, change the TX5 message to our grid square, so we don't have to call CQ again 172 | # this only works if the length of the free text message is less than 13 characters. 173 | # if len(the_packet.de_call <= 5): 174 | # free_text_packet = pywsjtx.FreeTextPacket.Builder(wsjtx_id,"73 {} {}".format(the_packet.de_call, the_packet[0:4]),False) 175 | # s.send_packet(addr_port, free_text_packet) 176 | 177 | print(the_packet) 178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /samples/n1mm_arrl_ru.py: -------------------------------------------------------------------------------- 1 | # 2 | # 3 | # 4 | # Proof of concept -- colorize wsjt-x callsigns on the basis of dupe status in N1MM. 5 | # 6 | # Set up these variables: 7 | # The N1MM Database file - this is a SQLite DB file that has an "s3db" file extension 8 | N1MM_DB_FILE = "C:\\Users\\brian\\Documents\\N1MM Logger+\\Databases\\n9adg_2018.s3db" 9 | 10 | # Download the CTY file from the same place that WL_CTY comes from. This is the country prefix lookup file. 11 | CTY_FILE = "C:\\Users\\brian\\Documents\\N1MM Logger+\\SupportFiles\\CTY.DAT" 12 | 13 | # Make sure CONTESTNR correstponds to the ARRL RU contest in your N1MM Database. 14 | CONTESTNR = 1 15 | 16 | # TODO: Find via Contest_name and START_DATE -- if multiple, show the CONTESTNR for all and have to use that strategy 17 | #CONTEST_NAME="ARRLRTTY" 18 | #START_DATE="2018-12-08 00:0:00" 19 | 20 | TEST_MULTICAST = True 21 | RETRANSMIT_UDP = True 22 | 23 | if TEST_MULTICAST: 24 | IP_ADDRESS = '224.1.1.1' 25 | PORT = 5007 26 | else: 27 | IP_ADDRESS = '127.0.0.1' 28 | PORT = 2238 29 | 30 | RETRANSMIT_IP_ADDRESS = '127.0.0.1' 31 | RETRANSMIT_IP_BOUND_PORT = 0 # 0 means pick an unused port 32 | RETRANSMIT_IP_PORT = 2237 33 | 34 | MY_MAX_SCHEMA = 2 35 | 36 | LOOKUP_THREADS = 4 37 | STP_MAX_SECONDS = 20 # how often do we hit N1MM to see what sections we've worked? 38 | 39 | import sqlite3 40 | import os 41 | import sys 42 | import re 43 | import queue 44 | import threading 45 | #from datetime import datetime 46 | #import serial 47 | import logging 48 | import time 49 | 50 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 51 | import pywsjtx 52 | import pywsjtx.extra.simple_server 53 | 54 | # 55 | # DONE! - listen for UDP packets from N1MM for logged QSOs to use as source of packets; process those and add to the queue, so colorization happens in somewhat realtime 56 | # DONE! - Interpret callsigns calling me to look up & colorize 57 | # K1ABC W9XYZ EN42 58 | # DONE! - Watch exchanges so we can color mults that way 59 | # K1ABC W9XYZ 579 IL 60 | # K1ABC W9XYZ R 559 IL 61 | 62 | 63 | # ------------------- File picker instead of constant? 64 | #import tkinter as tk 65 | #from tkinter import filedialog 66 | 67 | #root = tk.Tk() 68 | #root.withdraw() 69 | 70 | #file_path = filedialog.askopenfilename(title = "Select N1MM Database file to use",filetypes = (("N1MM Database File","*.s3db"),("all files","*.*"))) 71 | 72 | # -- TODO put these in separate files. 73 | #--- N1MM-related 74 | class N1MMLoggerPlus(): 75 | database_path = None 76 | n1mm_sql_connection = None 77 | 78 | def __init__(self, n1mm_database_path, logger=None, **kwargs): 79 | self.verbose_logging = False 80 | self.logger = logger or logging.getLogger(__name__) 81 | self.database_path = n1mm_database_path 82 | #self.n1mm_sql_connection = self.open_n1mm_database(n1mm_database_path) 83 | logging.debug("n1mm database name {}".format(self.database_path)) 84 | 85 | def open_db(self): 86 | if self.n1mm_sql_connection is not None: 87 | logging.error("Database is already open [{}]".format(self.db_file_name)) 88 | raise Exception("Database is already open [{}]".format(self.db_file_name)) 89 | 90 | cx = sqlite3.connect(self.database_path) 91 | cx.row_factory = sqlite3.Row 92 | self.n1mm_sql_connection = cx 93 | 94 | def close_db(self): 95 | if self.n1mm_sql_connection is not None: 96 | self.n1mm_sql_connection.close() 97 | self.database_path = None 98 | self.n1mm_sql_connection = None 99 | else: 100 | logging.info("close called, but connection not open.") 101 | 102 | def get_contest(self, **kwargs): 103 | contest_nr = kwargs.get("contestnr",None) 104 | if contest_nr is not None: 105 | c = self.n1mm_sql_connection.cursor() 106 | c.execute('SELECT * FROM ContestInstance where ContestID = ?', (contest_nr,)) 107 | rows = c.fetchall() 108 | if len(rows) == 0: 109 | raise Exception("Cannot find ContestNR {} in N1MM Database".format(contest_nr)) 110 | self.contestnr = contest_nr 111 | logging.debug("ContestNR {} found".format(contest_nr)) 112 | else: 113 | pass 114 | 115 | def simple_dupe_status(self,callsign): 116 | if self.n1mm_sql_connection is None: 117 | raise Exception("N1MM database is not open") 118 | 119 | c = self.n1mm_sql_connection.cursor() 120 | c.execute('SELECT * FROM DXLOG where ContestNR = ? AND Call = ?', (self.contestnr, callsign,)) 121 | rows = c.fetchall() 122 | logging.debug("dupe_status for {}: worked {} times".format(callsign, len(rows))) 123 | return len(rows)>0 124 | 125 | def prefix_worked_count(self, prefix): 126 | if self.n1mm_sql_connection is None: 127 | raise Exception("N1MM database is not open") 128 | 129 | c = self.n1mm_sql_connection.cursor() 130 | c.execute('SELECT COUNT(1) as C FROM DXLOG where ContestNR = ? AND CountryPrefix=?', (self.contestnr, prefix,)) 131 | rows = c.fetchall() 132 | return rows[0]['C'] 133 | 134 | def sections_for_prefixes(self, country_prefix_list): 135 | from itertools import chain 136 | if self.n1mm_sql_connection is None: 137 | raise Exception("N1MM database is not open") 138 | 139 | c = self.n1mm_sql_connection.cursor() 140 | placeholder = '?' 141 | placeholders = ', '.join(placeholder * len(country_prefix_list)) 142 | query = 'select DISTINCT sect from dxlog where ContestNR = {} AND CountryPrefix in ({}) AND IsMultiplier1<>0 ORDER BY sect ASC' .format(self.contestnr, placeholders) 143 | print(query) 144 | c.execute(query, country_prefix_list) 145 | rows = c.fetchall() 146 | sections = [ item['sect'] for item in rows ] 147 | return sections 148 | 149 | 150 | class Cty: 151 | cty_file_location = None 152 | table = {} 153 | def __init__(self, cty_file_location, **kwargs): 154 | # read the file 155 | self.logger = kwargs.get("logger",logging.getLogger(__name__)) 156 | self.cty_file_location = cty_file_location 157 | 158 | def load(self): 159 | if os.path.isfile(self.cty_file_location)==False: 160 | raise Exception("CTY file [{}] doesn't exist".format(self.cty_file_location)) 161 | fh = open(self.cty_file_location, 'r') 162 | 163 | while True: 164 | # read lines 165 | 166 | first_line = fh.readline() 167 | if first_line is None or len(first_line)==0: 168 | break 169 | first_line = first_line.rstrip() 170 | fields = first_line.split(':') 171 | #logging.debug("Split into fields {}".format(fields)) 172 | if len(fields) != 9: 173 | logging.warning("Wrong number of fields for line [{}] - {}".format(first_line, len(fields))) 174 | raise Exception("Can't parse CTY file - wrong number of fields on line {}".format(first_line)) 175 | continent = fields[3].strip() 176 | primary_prefix = fields[7].strip() 177 | # now read the prefixes 178 | last_line = first_line 179 | prefix_lines = "" 180 | while not last_line.endswith(";"): 181 | last_line = fh.readline().rstrip() 182 | prefix_lines += last_line 183 | 184 | prefix_lines = prefix_lines.rstrip(";") 185 | 186 | prefix_or_callsign = prefix_lines.split(",") 187 | 188 | for p in prefix_or_callsign: 189 | p = p.strip() 190 | if p is None: 191 | continue 192 | 193 | # handle special suffixes 194 | # The following special characters can be applied after an alias prefix: 195 | # (#) Override CQ Zone 196 | # [#] Override ITU Zone 197 | # <#/#> Override latitude/longitude 198 | # {aa} Override Continent 199 | # ~#~ Override local time offset from GMT 200 | p = re.sub(r'\{\d+\}', '', p) 201 | p = re.sub(r'\[\d+\]', '', p) 202 | p = re.sub(r'<\d+/\d+>', '', p) 203 | p = re.sub(r'\{\w\w\}', '', p) 204 | p = re.sub(r'~\d+~', '', p) 205 | exact_match = False 206 | #logging.debug("Adding prefix {} for {}".format(primary_prefix, p)) 207 | self.table[p] = { 'primary_prefix' : primary_prefix, 'continent' : continent } 208 | 209 | fh.close() 210 | 211 | def prefix_for(self, callsign): 212 | # lookup the callsign in the CTY file data, return the prefix. 213 | prefixes = [k for k in self.table.keys() if k==callsign[0:len(k)] or k=="={}".format(callsign)] 214 | prefixes.sort(key = lambda s: 100-len(s)) 215 | #print("Prefixes {}".format(prefixes)) 216 | found_prefix = None 217 | # exact matches first 218 | unique_call = [ c for c in prefixes if c.startswith('=')] 219 | if unique_call: 220 | if len(unique_call) > 1: 221 | logging.warning("More than one UNIQUE call matched: {}".format(unique_call)) 222 | found_prefix = self.table[unique_call[0]]['primary_prefix'] 223 | logging.debug("Prefix for {} is [{}] ({})".format(callsign,found_prefix,unique_call[0])) 224 | return found_prefix 225 | # 226 | if len(prefixes) > 0: 227 | k = prefixes[0] 228 | found_prefix = self.table[k]['primary_prefix'] 229 | logging.debug("Prefix for {} is [{}] ({})".format(callsign, found_prefix, k)) 230 | 231 | return found_prefix 232 | 233 | from threading import RLock 234 | class StateProvinceKeeper: 235 | class __OnlyOne: 236 | 237 | def __init__(self): 238 | self.__already_worked = [] 239 | self.logger = logging.getLogger(__name__) 240 | self.lock = RLock() 241 | 242 | def already_worked(self, section): 243 | self.lock.acquire() 244 | try: 245 | self.logger.debug("Checking section {}".format(section)) 246 | rval = (section in self.__already_worked) 247 | finally: 248 | self.lock.release() 249 | return rval 250 | 251 | def update_already_worked(self, new_list): 252 | self.lock.acquire() 253 | try: 254 | self.logger.debug("Updating logged sections {}".format(new_list)) 255 | self.__already_worked = new_list.copy() 256 | finally: 257 | self.lock.release() 258 | 259 | instance = None 260 | 261 | def __new__(cls): 262 | if not StateProvinceKeeper.instance: 263 | StateProvinceKeeper.instance = StateProvinceKeeper.__OnlyOne() 264 | return StateProvinceKeeper.instance 265 | 266 | def __init__(self,st_pr_db_file_name,**kwargs): 267 | self.db_file_name = st_pr_db_file_name 268 | self.logger = kwargs.get("logger", logging.getLogger(__name__)) 269 | #self.create_db_if_needed() 270 | self.sql_connection = None 271 | 272 | 273 | 274 | class CallsignWorker: 275 | threads = [] 276 | def __init__(self, threadcount, cty, n1mm_db_file_name, n1mm_args, **kwargs ): 277 | self.logger = kwargs.get("logger", logging.getLogger(__name__)) 278 | self.input_queue = queue.Queue() 279 | self.output_queue = queue.Queue() 280 | self.threadcount = threadcount 281 | self.cty = cty 282 | 283 | self.n1mm_db_file_name = n1mm_db_file_name 284 | self.n1mm_args = n1mm_args 285 | 286 | self.threads = [] 287 | self.internal_start_threads() 288 | 289 | def internal_start_threads(self): 290 | for i in range(self.threadcount): 291 | t = threading.Thread(target=self.callsign_lookup_worker) 292 | t.start() 293 | self.threads.append(t) 294 | 295 | def internal_stop_threads(self): 296 | for i in range(self.threadcount): 297 | self.input_queue.put(None) 298 | for t in self.threads: 299 | t.join() 300 | 301 | def stop_threads(self): 302 | self.internal_stop_threads() 303 | 304 | def callsign_lookup_worker(self): 305 | n1mm = N1MMLoggerPlus(self.n1mm_db_file_name,self.logger) 306 | n1mm.open_db() 307 | logging.warning(self.n1mm_args) 308 | n1mm.get_contest(**self.n1mm_args) 309 | 310 | stp = StateProvinceKeeper() 311 | 312 | 313 | while True: 314 | input_pkt = self.input_queue.get() 315 | is_section_mult = False 316 | if input_pkt is None: 317 | break 318 | prefix = self.cty.prefix_for(input_pkt['callsign']) 319 | dupe_status = n1mm.simple_dupe_status(input_pkt['callsign']) 320 | if not dupe_status and input_pkt.get('exchange') and prefix in ['K', 'VE']: 321 | is_section_mult = not stp.already_worked(input_pkt['exchange']) 322 | logging.info("Section multiplier {} {}".format(input_pkt['callsign'], input_pkt['exchange'])) 323 | is_mult = not dupe_status and n1mm.prefix_worked_count(prefix)==0 324 | input_pkt['dupe'] = dupe_status 325 | input_pkt['is_mult'] = is_mult or is_section_mult 326 | input_pkt['is_section_mult'] = is_section_mult 327 | input_pkt['prefix'] = prefix 328 | logging.debug("Thread: {} - Callsign status {} prefix:{} dupe:{} mult:{}".format(threading.current_thread().name, input_pkt['callsign'], prefix, dupe_status, is_mult, )) 329 | self.output_queue.put(input_pkt) 330 | 331 | class Retransmitter: 332 | def __init__(self, ip_address, ip_port, upstream_server, **kwargs): 333 | self.server = pywsjtx.extra.simple_server.SimpleServer(ip_address, ip_port, **kwargs) 334 | self.upstream_server = upstream_server 335 | self.logger = kwargs.get("logger", logging.getLogger(__name__)) 336 | 337 | # wait for packets from the ip_address:ip_port. Resend them to upstream_server 338 | # send packets to port 2237 339 | self._t = threading.Thread(target=self.packet_resender) 340 | self._t.start() 341 | 342 | def send(self, message, from_addr_port, to_addr_port): 343 | addr, port = from_addr_port 344 | self.from_addr_port = (addr,port) #from_addr_port 345 | self.server.send_packet(to_addr_port, message) 346 | logging.debug("sent packet from {} to N1MM Logger port {} {} bytes".format(from_addr_port, to_addr_port, len(message))) 347 | 348 | 349 | def stop_threads(self): 350 | self._t.join() 351 | 352 | def packet_resender(self): 353 | while True: 354 | input_pkt, addr_port = self.server.rx_packet() 355 | if (input_pkt != None and self.from_addr_port != None): 356 | logging.debug("RX from N1MM Logger {}, Sending {} bytes to upstream {}".format(addr_port,len(input_pkt), self.from_addr_port)) 357 | self.upstream_server.send_packet(self.from_addr_port, input_pkt) 358 | 359 | def main(): 360 | logFormatter = logging.Formatter("%(asctime)s [%(threadName)-12.12s] %(message)s") 361 | consoleFormatter = logging.Formatter('%(asctime)s %(message)s') 362 | logging.basicConfig(level=logging.INFO,format='%(asctime)s %(message)s') 363 | 364 | # set up file logging, as well 365 | fileHandler = logging.FileHandler("n1mm_arrl_ru_run.log") 366 | fileHandler.setFormatter(logFormatter) 367 | logging.getLogger().addHandler(fileHandler) 368 | # log to console 369 | if False: 370 | consoleHandler = logging.StreamHandler() 371 | consoleHandler.setFormatter(consoleFormatter) 372 | logging.getLogger().addHandler(consoleHandler) 373 | 374 | logging.getLogger().setLevel(logging.DEBUG) 375 | cty = Cty(CTY_FILE, logger=logging.getLogger()) 376 | cty.load() 377 | 378 | n1mm = N1MMLoggerPlus(N1MM_DB_FILE,logger=logging.getLogger()) 379 | n1mm.open_db() 380 | n1mm.get_contest(contestnr=CONTESTNR) 381 | already_worked = n1mm.sections_for_prefixes(['K','VE']) 382 | print(already_worked) 383 | 384 | stp = StateProvinceKeeper() 385 | stp.update_already_worked(already_worked) 386 | 387 | #print("AZ already worked? {}".format(stp.already_worked("AZ"))) 388 | #stp.open_db() 389 | #stp.add('N9ADG','K','WA') 390 | #stp.add('K9CT','K','IL') 391 | #print("K9CT's section {}".format(stp.section_for('K9CT'))) 392 | 393 | retransmitter = None 394 | 395 | # take calls that are CQing, or replying, etc. and colorize them after the dupe check 396 | 397 | cw = CallsignWorker(LOOKUP_THREADS, cty, N1MM_DB_FILE,{'contestnr':CONTESTNR}) 398 | 399 | # get a callsign 400 | # put on queue 401 | 402 | s = pywsjtx.extra.simple_server.SimpleServer(IP_ADDRESS, PORT, timeout=2.0) 403 | 404 | if RETRANSMIT_UDP: 405 | retransmitter = Retransmitter(RETRANSMIT_IP_ADDRESS,0,s,timeout=0.5) 406 | 407 | mult_foreground_color = pywsjtx.QCOLOR.White() 408 | mult_background_color = pywsjtx.QCOLOR.Red() 409 | 410 | dupe_background_color = pywsjtx.QCOLOR.RGBA(255,211,211,211) # light grey 411 | dupe_foreground_color = pywsjtx.QCOLOR.RGBA(255,169,169,169) # dark grey 412 | 413 | stp_age = time.time() 414 | 415 | while True: 416 | 417 | (pkt, addr_port) = s.rx_packet() 418 | if (pkt != None): 419 | if RETRANSMIT_UDP: 420 | # retransmit to someone else (e.g. N1MM Logger) 421 | retransmitter.send(pkt, addr_port, (RETRANSMIT_IP_ADDRESS, RETRANSMIT_IP_PORT)) 422 | 423 | the_packet = pywsjtx.WSJTXPacketClassFactory.from_udp_packet(addr_port, pkt) 424 | 425 | if type(the_packet) == pywsjtx.HeartBeatPacket: 426 | max_schema = max(the_packet.max_schema, MY_MAX_SCHEMA) 427 | reply_beat_packet = pywsjtx.HeartBeatPacket.Builder(the_packet.wsjtx_id, max_schema) 428 | s.send_packet(addr_port, reply_beat_packet) 429 | 430 | while type(the_packet) == pywsjtx.DecodePacket: 431 | m = re.match(r"^CQ\s+(\S{3,}?)\s+", the_packet.message) or re.match(r"^CQ\s+\S{2}\s+(\S{3,}?)\s+", the_packet.message) 432 | if m: 433 | callsign = m.group(1) 434 | print("Callsign {}".format(callsign)) 435 | 436 | cw.input_queue.put({'callsign':callsign, 'input':the_packet, 'addr_port':addr_port}) 437 | break 438 | # K1ABC W9XYZ 579 IL 439 | # K1ABC W9XYZ R 559 IL 440 | m = re.match(r"(\S{3,}?)\s+(\S{3,}?)(\sR)?\s5\d\d\s([A-Z]{2,3})", the_packet.message) 441 | if m: 442 | callsign = m.group(2) 443 | section = m.group(4) 444 | #print("Callsign {} - section {}".format(callsign, section)) 445 | 446 | cw.input_queue.put({'callsign':callsign, 'input':the_packet, 'addr_port':addr_port, 'exchange':section}) 447 | break 448 | break 449 | print(the_packet) 450 | 451 | # service queue 452 | 453 | while not cw.output_queue.empty(): 454 | resolved = cw.output_queue.get(False) 455 | print("Resolved packet available callsign:{}, dupe:{}, mult:{}".format(resolved['callsign'], resolved['dupe'], resolved['is_mult'])) 456 | wsjtx_id = resolved['input'].wsjtx_id 457 | 458 | if resolved['dupe']: 459 | color_pkt = pywsjtx.HighlightCallsignPacket.Builder(wsjtx_id, resolved['callsign'], 460 | dupe_background_color, 461 | dupe_foreground_color, 462 | True) 463 | s.send_packet(resolved['addr_port'], color_pkt) 464 | 465 | if resolved['is_section_mult']: # color the whole thing 466 | color_pkt = pywsjtx.HighlightCallsignPacket.Builder(wsjtx_id, resolved['input'].message, 467 | mult_background_color, 468 | mult_foreground_color, 469 | True) 470 | s.send_packet(resolved['addr_port'], color_pkt) 471 | 472 | pass 473 | elif resolved['is_mult']: 474 | color_pkt = pywsjtx.HighlightCallsignPacket.Builder(wsjtx_id, resolved['callsign'], 475 | mult_background_color, 476 | mult_foreground_color, 477 | True) 478 | s.send_packet(resolved['addr_port'], color_pkt) 479 | 480 | if not resolved['is_mult'] and not resolved['dupe']: 481 | #color_pkt = pywsjtx.HighlightCallsignPacket.Builder(wsjtx_id, resolved['callsign'], 482 | # pywsjtx.QCOLOR.Uncolor(), 483 | # pywsjtx.QCOLOR.Uncolor(), 484 | # True) 485 | #s.send_packet(resolved['addr_port'], color_pkt) 486 | pass 487 | 488 | if (time.time() - stp_age) > STP_MAX_SECONDS: 489 | logging.debug("Updating sections from N1MM") 490 | already_worked = n1mm.sections_for_prefixes(['K', 'VE']) 491 | stp.update_already_worked(already_worked) 492 | stp_age = time.time() 493 | 494 | if __name__ == "__main__": 495 | main() 496 | -------------------------------------------------------------------------------- /samples/wsjtx_packet_exchanger.py: -------------------------------------------------------------------------------- 1 | # 2 | # A rude work around to get JTAlert v.2, N1MM Logger+ 1.0.7422.0 (first version with WSJTX support), and WSJT-X 2.0 working 3 | # together. 4 | # 5 | # 0. Start this program (python 3.x) 6 | # 1. Start wsjt-x. Configure UDP server port to be 224.1.1.1, port 5007 7 | # 2. Start N1MM. No configuration possible for wsjtx window 8 | # 3. Verify that N1MM's wsjtx window is receiving CQ spots from wsjtx 9 | # 4. Edit the file C:\Users\\AppData\Local\WSJT-X\WSJT-X.ini 10 | # add these two lines above the UDPServer=224.1.1.1 line: 11 | # udpserver=127.0.0.1 12 | # udpserverport=2238 13 | # Put a comment in front of the UDPServer= and UDPServerPort= lines, like this: 14 | # #UDPServer=224.0.0.1 15 | # #UDPServerPort=5007 16 | # 5. Save the file. 17 | # 6. Start JT-Alert (JTAlert will read the .ini file with the 127.0.0.1:2238 values, and start receiving packets 18 | # 7. Comment out the 127.0.0.1:2238 UDPServer lines with # 19 | # 8. Save the file again. 20 | 21 | # You'll have to do this again each time you start up JTAlert to 'fool' it into using port 2238. 22 | # 23 | # Issues: JTALERT-X closes it's socket connection while the settings dialog is open. We do the brute force thing of trying to 24 | # reestablish the connection to the port 25 | 26 | 27 | # WSJT-X talks to a multicast port. This program sits on the multicast port, and forwards packets it receives to other Unicast sockets. 28 | # It also listens on the unicast sockets, and forwards received packets back to WSJT-X. 29 | 30 | # It assumes that there's only ONE other application sending packets to the multicast socket (WSJT-X) 31 | # 32 | 33 | MULTICAST_PORT = { 'ip_address':'224.1.1.1', 'port':5007, 'timeout':0.2 } 34 | FORWARDING_PORTS = [ 35 | { 'ip_address': '127.0.0.1', 'port': 2237, 'timeout':0.2 }, 36 | { 'ip_address': '127.0.0.1', 'port': 2238, 'timeout':0.2 } 37 | ] 38 | 39 | import os 40 | import sys 41 | import logging 42 | 43 | sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) 44 | import pywsjtx 45 | import pywsjtx.extra.simple_server 46 | 47 | # 48 | # The RIGHT way to do a packet redistributor like this is to select or wait on the sockets. 49 | # This one trades a little latency (~.50 seconds for two unicast and one multicast socket) for 50 | # not changing some other code I had around 51 | # 52 | def main(): 53 | logFormatter = logging.Formatter("%(asctime)s [%(threadName)-12.12s] %(message)s") 54 | consoleFormatter = logging.Formatter('%(asctime)s %(message)s') 55 | logging.basicConfig(level=logging.DEBUG, format='%(asctime)s %(message)s') 56 | wsjtx_instance_addr_port = None 57 | servers = [] 58 | master_server = pywsjtx.extra.simple_server.SimpleServer(MULTICAST_PORT['ip_address'], MULTICAST_PORT['port'], 59 | timeout=MULTICAST_PORT['timeout']) 60 | for port_spec in FORWARDING_PORTS: 61 | servers.append({"instance":pywsjtx.extra.simple_server.SimpleServer(port_spec.get('server_ip_address','127.0.0.1'), 62 | port_spec.get('server_port',0), timeout=port_spec['timeout']), 63 | "to_addr_port": (port_spec['ip_address'], port_spec['port']), 64 | "server_spec": port_spec }) 65 | 66 | while (True): 67 | pkt, wsjtx_instance_addr_port = master_server.rx_packet() 68 | if pkt is not None: 69 | logging.debug('RX packet from wsjtx') 70 | for s in servers: 71 | s['instance'].send_packet(s['to_addr_port'],pkt ) 72 | for s in servers: 73 | try: 74 | pkt, srv_instance_addr_port = s['instance'].rx_packet() 75 | if pkt is not None: 76 | logging.debug('RX packet from {}'.format(s['to_addr_port'])) 77 | master_server.send_packet(wsjtx_instance_addr_port,pkt) 78 | except ConnectionResetError: 79 | # reopen the port. 80 | logging.debug("ERROR - Restarting port {}".format(s['to_addr_port'])) 81 | s["instance"] = pywsjtx.extra.simple_server.SimpleServer(port_spec.get('server_ip_address','127.0.0.1'), 82 | port_spec.get('server_port',0), timeout=port_spec['timeout']) 83 | 84 | 85 | if __name__ == "__main__": 86 | main() 87 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmo/py-wsjtx/e725eb689314eff97298ccc87e84bcc5b8bc1ed9/setup.py --------------------------------------------------------------------------------