From 7ca130d841897a0ae8c7b7afa8e2ccd70761920b Mon Sep 17 00:00:00 2001 From: bmo Date: Sat, 3 Feb 2018 09:26:25 -0800 Subject: [PATCH] initial packet decoding / generation and simple server --- LICENSE | 12 + pywsjtx/__init__.py | 3 + pywsjtx/extra/__init__.py | 0 pywsjtx/extra/latlong_to_grid_square.py | 80 ++++++ pywsjtx/extra/simple_server.py | 51 ++++ pywsjtx/wsjtx_packets.py | 361 ++++++++++++++++++++++++ requirements.txt | 0 samples/dump_wsjtx_packets.py | 15 + samples/grid_from_gps.py | 146 ++++++++++ setup.py | 0 10 files changed, 668 insertions(+) create mode 100644 LICENSE create mode 100644 pywsjtx/__init__.py create mode 100644 pywsjtx/extra/__init__.py create mode 100644 pywsjtx/extra/latlong_to_grid_square.py create mode 100644 pywsjtx/extra/simple_server.py create mode 100644 pywsjtx/wsjtx_packets.py create mode 100644 requirements.txt create mode 100644 samples/dump_wsjtx_packets.py create mode 100644 samples/grid_from_gps.py create mode 100644 setup.py diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f58409b --- /dev/null +++ b/LICENSE @@ -0,0 +1,12 @@ +Copyright 2018 Brian Moran + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +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: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +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, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/pywsjtx/__init__.py b/pywsjtx/__init__.py new file mode 100644 index 0000000..cafe97c --- /dev/null +++ b/pywsjtx/__init__.py @@ -0,0 +1,3 @@ +from pywsjtx.wsjtx_packets import * + + diff --git a/pywsjtx/extra/__init__.py b/pywsjtx/extra/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pywsjtx/extra/latlong_to_grid_square.py b/pywsjtx/extra/latlong_to_grid_square.py new file mode 100644 index 0000000..8fbff8c --- /dev/null +++ b/pywsjtx/extra/latlong_to_grid_square.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- + +class GPSException(Exception): + def __init__(self,*args): + Exception.__init__(*args) + +# From K6WRU via stackexchange : see https://ham.stackexchange.com/questions/221/how-can-one-convert-from-lat-long-to-grid-square/244#244 +# Convert latitude and longitude to Maidenhead grid locators. +# +# Arguments are in signed decimal latitude and longitude. For example, +# the location of my QTH Palo Alto, CA is: 37.429167, -122.138056 or +# in degrees, minutes, and seconds: 37° 24' 49" N 122° 6' 26" W +class LatLongToGridSquare(object): + upper = 'ABCDEFGHIJKLMNOPQRSTUVWX' + lower = 'abcdefghijklmnopqrstuvwx' + + @classmethod + def to_grid(cls,dec_lat, dec_lon): + + if not (-180<=dec_lon<180): + raise GPSException('longitude must be -180<=lon<180, given %f\n'%dec_lon) + if not (-90<=dec_lat<90): + raise GPSException('latitude must be -90<=lat<90, given %f\n'%dec_lat) + + adj_lat = dec_lat + 90.0 + adj_lon = dec_lon + 180.0 + + grid_lat_sq = LatLongToGridSquare.upper[int(adj_lat/10)] + grid_lon_sq = LatLongToGridSquare.upper[int(adj_lon/20)] + + grid_lat_field = str(int(adj_lat%10)) + grid_lon_field = str(int((adj_lon/2)%10)) + + adj_lat_remainder = (adj_lat - int(adj_lat)) * 60 + adj_lon_remainder = ((adj_lon) - int(adj_lon/2)*2) * 60 + + grid_lat_subsq = LatLongToGridSquare.lower[int(adj_lat_remainder/2.5)] + grid_lon_subsq = LatLongToGridSquare.lower[int(adj_lon_remainder/5)] + + return grid_lon_sq + grid_lat_sq + grid_lon_field + grid_lat_field + grid_lon_subsq + grid_lat_subsq + + # GPS sentences are encoded + @classmethod + def convert_to_degrees(cls, gps_value, direction): + if direction not in ['N','S','E','W']: + raise GPSException("Invalid direction specifier for lat/long: {}".format(direction)) + + dir_mult = 1 + if direction in ['S','W']: + dir_mult = -1 + + if len(gps_value) < 3: + raise GPSException("Invalid Value for lat/long: {}".format(gps_value)) + + dot_posn = gps_value.index('.') + + if dot_posn < 0: + raise GPSException("Invalid Format for lat/long: {}".format(gps_value)) + + degrees = gps_value[0:dot_posn-2] + mins = gps_value[dot_posn-2:] + + f_degrees = dir_mult * (float(degrees) + (float(mins) / 60.0)) + return f_degrees + + @classmethod + def GPGLL_to_grid(cls, GPSLLText): + # example: $GPGLL,4740.99254,N,12212.31179,W,223311.00,A,A*70\r\n + try: + components = GPSLLText.split(",") + if components[0]=='$GPGLL': + del components[0] + if components[5] != 'A': + raise GPSException("Not a valid GPS fix") + lat = LatLongToGridSquare.convert_to_degrees(components[0], components[1]) + long = LatLongToGridSquare.convert_to_degrees(components[2], components [3]) + grid = LatLongToGridSquare.to_grid(lat, long) + except GPSException: + grid = "" + return grid diff --git a/pywsjtx/extra/simple_server.py b/pywsjtx/extra/simple_server.py new file mode 100644 index 0000000..1ff33ee --- /dev/null +++ b/pywsjtx/extra/simple_server.py @@ -0,0 +1,51 @@ +# +# In WSJTX parlance, the 'network server' is a program external to the wsjtx.exe program that handles packets emitted by wsjtx +# +# TODO: handle multicast groups. +# +# see dump_wsjtx_packets.py example for some simple usage +# +import socket +import struct +import pywsjtx +import logging + +class SimpleServer(object): + logger = logging.getLogger() + MAX_BUFFER_SIZE = pywsjtx.GenericWSJTXPacket.MAXIMUM_NETWORK_MESSAGE_SIZE + DEFAULT_UDP_PORT = 2237 + # + # note that '' and '127.0.0.1' behave differently. On Windows, try '' instead of 127.0.0.1 if you're not receiving any packets. + # + def __init__(self, ip_address='', udp_port=DEFAULT_UDP_PORT, **kwargs): + self.timeout = None + + if kwargs.get("timeout") is not None: + self.timeout = kwargs.get("timeout") + + self.sock = socket.socket(socket.AF_INET, # Internet + socket.SOCK_DGRAM) # UDP + + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1) + + if self.timeout is not None: + self.sock.settimeout(self.timeout) + + self.sock.bind((ip_address, int(udp_port))) + + # can through a sock.timeout exception if it's configured that way. + def rx_packet(self): + pkt, addr_port = self.sock.recvfrom(self.MAX_BUFFER_SIZE) # buffer size is 1024 bytes + return(pkt, addr_port) + + def send_packet(self, addr_port, pkt): + bytes_sent = self.sock.sendto(pkt,addr_port) + self.logger.debug("send_packet: Bytes sent ",bytes_sent) + + def demo_run(self): + while True: + (pkt, addr_port) = self.rx_packet() + if (pkt != None): + the_packet = pywsjtx.WSJTXPacketClassFactory.from_udp_packet(addr_port, pkt) + print(the_packet) diff --git a/pywsjtx/wsjtx_packets.py b/pywsjtx/wsjtx_packets.py new file mode 100644 index 0000000..1ccd53b --- /dev/null +++ b/pywsjtx/wsjtx_packets.py @@ -0,0 +1,361 @@ + +import struct +import datetime + +class PacketUtil: + @classmethod + # this hexdump brought to you by Stack Overflow + def hexdump(cls, src, length=16): + FILTER = ''.join([(len(repr(chr(x))) == 3) and chr(x) or '.' for x in range(256)]) + lines = [] + for c in range(0, len(src), length): + chars = src[c:c + length] + hex = ' '.join(["%02x" % x for x in chars]) + printable = ''.join(["%s" % ((x <= 127 and FILTER[x]) or '.') for x in chars]) + lines.append("%04x %-*s %s\n" % (c, length * 3, hex, printable)) + return ''.join(lines) + + # timezone tomfoolery + @classmethod + def midnight_utc(cls): + utcnow = datetime.datetime.utcnow() + utcmidnight = datetime.datetime(utcnow.year, utcnow.month, utcnow.day, 0, 0) + return utcmidnight + +class PacketWriter(object): + def __init__(self ): + self.ptr_pos = 0 + self.packet = bytearray() + # self.max_ptr_pos + self.write_header() + + def write_header(self): + self.write_QUInt32(GenericWSJTXPacket.MAGIC_NUMBER) + self.write_QInt32(GenericWSJTXPacket.SCHEMA_VERSION) + + def write_QInt8(self, val): + self.packet.extend(struct.pack('>b', val)) + + def write_QInt32(self, val): + self.packet.extend(struct.pack('>l',val)) + + def write_QUInt32(self, val): + self.packet.extend(struct.pack('>L', val)) + + def write_QInt64(self, val): + self.packet.extend(struct.pack('>q',val)) + + def write_QFloat(self, val): + self.packet.extent(struct.pack('>d', val)) + + def write_QString(self, str_val): + length = len(str_val) + self.write_QInt32(length) + b_values = str_val + if type(str_val) != bytes: + b_values = str_val.encode() + self.packet.extend(b_values) + +class PacketReader(object): + def __init__(self, packet): + self.ptr_pos = 0 + self.packet = packet + self.max_ptr_pos = len(packet)-1 + self.skip_header() + + def at_eof(self): + return self.ptr_pos > self.max_ptr_pos + + def skip_header(self): + if self.max_ptr_pos < 8: + raise Exception('Not enough data to skip header') + self.ptr_pos = 8 + + def check_ptr_bound(self,field_type, length): + if self.ptr_pos + length > self.max_ptr_pos+1: + raise Exception('Not enough data to extract {}'.format(field_type)) + + ## grab data from the packet, incrementing the ptr_pos on the basis of the data we've gleaned + def QInt32(self): + self.check_ptr_bound('QInt32', 4) # sure we could inspect that, but that is slow. + (the_int32,) = struct.unpack('>l',self.packet[self.ptr_pos:self.ptr_pos+4]) + self.ptr_pos += 4 + return the_int32 + + def QInt8(self): + self.check_ptr_bound('QInt8', 1) + (the_int8,) = struct.unpack('>b', self.packet[self.ptr_pos:self.ptr_pos+1]) + self.ptr_pos += 1 + return the_int8 + + def QInt64(self): + self.check_ptr_bound('QInt64', 8) + (the_int64,) = struct.unpack('>q', self.packet[self.ptr_pos:self.ptr_pos+8]) + self.ptr_pos += 8 + return the_int64 + + def QFloat(self): + self.check_ptr_bound('QFloat', 8) + (the_double,) = struct.unpack('>d', self.packet[self.ptr_pos:self.ptr_pos+8]) + self.ptr_pos += 8 + return the_double + + def QString(self): + str_len = self.QInt32() + if str_len == -1: + return None + self.check_ptr_bound('QString[{}]'.format(str_len),str_len) + (str,) = struct.unpack('{}s'.format(str_len), self.packet[self.ptr_pos:self.ptr_pos + str_len]) + self.ptr_pos += str_len + return str.decode('utf-8') + +class GenericWSJTXPacket(object): + SCHEMA_VERSION = 2 + MINIMUM_SCHEMA_SUPPORTED = 2 + MAXIMUM_SCHEMA_SUPPORTED = 2 + MINIMUM_NETWORK_MESSAGE_SIZE = 8 + MAXIMUM_NETWORK_MESSAGE_SIZE = 2048 + MAGIC_NUMBER = 0xadbccbda + SCHEMA_VERSION = 2 + + def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): + self.addr_port = addr_port + self.magic = magic + self.schema = schema + self.pkt_type = pkt_type + self.id = id + self.pkt = pkt + +class InvalidPacket(GenericWSJTXPacket): + TYPE_VALUE = -1 + def __init__(self, addr_port, packet, message): + self.packet = packet + self.message = message + self.addr_port = addr_port + + def __repr__(self): + return 'Invalid Packet: %s from %s:%s\n%s' % (self.message, self.addr_port[0], self.addr_port[1], PacketUtil.hexdump(self.packet)) + +class HeartBeatPacket(GenericWSJTXPacket): + TYPE_VALUE = 0 + + def __init__(self, addr_port: object, magic: object, schema: object, pkt_type: object, id: object, pkt: object) -> object: + GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) + ps = PacketReader(pkt) + the_type = ps.QInt32() + self.wsjtx_id = ps.QString() + self.max_schema = ps.QInt32() + self.version = ps.QInt8() + self.revision = ps.QInt8() + + def __repr__(self): + return 'HeartBeatPacket: from {}:{}\n\twsjtx id:{}\tmax_schema:{}\tversion:{}\trevision:{}' .format(self.addr_port[0], self.addr_port[1], + self.wsjtx_id, self.max_schema, self.version, self.revision) + @classmethod + # make a heartbeat packet (a byte array) we can send to a 'client'. This should be it's own class. + def Builder(cls,wsjtx_id='pywsjtx', max_schema=2, version=1, revision=1): + # build the packet to send + pkt = PacketWriter() + pkt.write_QInt32(HeartBeatPacket.TYPE_VALUE) + pkt.write_QString(wsjtx_id) + pkt.write_QInt32(max_schema) + pkt.write_QInt32(version) + pkt.write_QInt32(revision) + return pkt.packet + +class StatusPacket(GenericWSJTXPacket): + TYPE_VALUE = 1 + def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): + GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) + ps = PacketReader(pkt) + the_type = ps.QInt32() + self.wsjtx_id = ps.QString() + self.dial_frequency = ps.QInt64() + + self.mode = ps.QString() + self.dx_call = ps.QString() + + self.report = ps.QString() + self.tx_mode = ps.QString() + + self.tx_enabled = ps.QInt8() + self.transmitting = ps.QInt8() + self.decoding = ps.QInt8() + self.rx_df = ps.QInt32() + self.tx_df = ps.QInt32() + + + self.de_call = ps.QString() + + self.de_grid = ps.QString() + self.dx_grid = ps.QString() + + self.tx_watchdog = ps.QInt8() + self.sub_mode = ps.QString() + self.fast_mode = ps.QInt8() + + + def __repr__(self): + str = 'StatusPacket: from {}:{}\n\twsjtx id:{}\tde_call:{}\tde_grid:{}\n'.format(self.addr_port[0], self.addr_port[1],self.wsjtx_id, + self.de_call, self.de_grid) + 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) + str += "\ttransmitting:{}\t decoding:{}\ttx_enabled:{}\ttx_watchdog:{}\tsub_mode:{}\tfast_mode:{}".format(self.transmitting, self.decoding, self.tx_enabled, self.tx_watchdog, + self.sub_mode, self.fast_mode) + return str + + +class DecodePacket(GenericWSJTXPacket): + TYPE_VALUE = 2 + def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): + GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) + # handle packet-specific stuff. + ps = PacketReader(pkt) + the_type = ps.QInt32() + self.wsjtx_id = ps.QString() + self.new_decode = ps.QInt8() + self.millis_since_midnight = ps.QInt32() + self.time = PacketUtil.midnight_utc() + datetime.timedelta(milliseconds=self.millis_since_midnight) + self.snr = ps.QInt32() + self.delta_t = ps.QFloat() + self.delta_f = ps.QInt32() + self.mode = ps.QString() + self.message = ps.QString() + self.low_confidence = ps.QInt8() + self.off_air = ps.QInt8() + + def __repr__(self): + str = 'DecodePacket: from {}:{}\n\twsjtx id:{}\tmessage:{}\n'.format(self.addr_port[0], + self.addr_port[1], + self.wsjtx_id, + self.message) + str += "\tdelta_f:{}\tnew:{}\ttime:{}\tsnr:{}\tdelta_f:{}\tmode:{}".format(self.delta_f, + self.new_decode, + self.time, + self.snr, + self.delta_f, + self.mode) + return str + +class ClearPacket(GenericWSJTXPacket): + TYPE_VALUE = 3 + def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): + GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) + # handle packet-specific stuff. + +class ReplyPacket(GenericWSJTXPacket): + TYPE_VALUE = 4 + def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): + GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) + # handle packet-specific stuff. + +class QSOLoggedPacket(GenericWSJTXPacket): + TYPE_VALUE = 5 + def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): + GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) + # handle packet-specific stuff. + +class ClosePacket(GenericWSJTXPacket): + TYPE_VALUE = 6 + def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): + GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) + # handle packet-specific stuff. + +class ReplayPacket(GenericWSJTXPacket): + TYPE_VALUE = 7 + def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): + GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) + # handle packet-specific stuff. + +class HaltTxPacket(GenericWSJTXPacket): + TYPE_VALUE = 8 + def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): + GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) + # handle packet-specific stuff. + +class FreeTextPacket(GenericWSJTXPacket): + TYPE_VALUE = 9 + def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): + GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) + # handle packet-specific stuff. + + @classmethod + def Builder(cls,to_wsjtx_id='WSJT-X', text="", send=False): + # build the packet to send + pkt = PacketWriter() + print('To_wsjtx_id ',to_wsjtx_id,' text ',text, 'send ',send) + pkt.write_QInt32(FreeTextPacket.TYPE_VALUE) + pkt.write_QString(to_wsjtx_id) + pkt.write_QString(text) + pkt.write_QInt8(send) + return pkt.packet + +class WSPRDecodePacket(GenericWSJTXPacket): + TYPE_VALUE = 10 + def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): + GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) + # handle packet-specific stuff. + +class LocationChangePacket(GenericWSJTXPacket): + TYPE_VALUE = 11 + def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): + GenericWSJTXPacket.__init__(self, addr_port, magic, schema, pkt_type, id, pkt) + # handle packet-specific stuff. + + @classmethod + def Builder(cls, to_wsjtx_id='WSJT-X', new_grid=""): + # build the packet to send + pkt = PacketWriter() + pkt.write_QInt32(LocationChangePacket.TYPE_VALUE) + pkt.write_QString(to_wsjtx_id) + pkt.write_QString(new_grid) + return pkt.packet + +class WSJTXPacketClassFactory(GenericWSJTXPacket): + + PACKET_TYPE_TO_OBJ_MAP = { + HeartBeatPacket.TYPE_VALUE: HeartBeatPacket, + StatusPacket.TYPE_VALUE: StatusPacket, + DecodePacket.TYPE_VALUE: DecodePacket, + ClearPacket.TYPE_VALUE: ClearPacket, + ReplyPacket.TYPE_VALUE: ReplyPacket, + QSOLoggedPacket.TYPE_VALUE: QSOLoggedPacket, + ClosePacket.TYPE_VALUE: ClosePacket, + ReplayPacket.TYPE_VALUE: ReplayPacket, + HaltTxPacket.TYPE_VALUE: HaltTxPacket, + FreeTextPacket.TYPE_VALUE: FreeTextPacket, + WSPRDecodePacket.TYPE_VALUE: WSPRDecodePacket + } + def __init__(self, addr_port, magic, schema, pkt_type, id, pkt): + self.addr_port = addr_port + self.magic = magic + self.schema = schema + self.pkt_type = pkt_type + self.pkt_id = id + self.pkt = pkt + + def __repr__(self): + return 'WSJTXPacketFactory: from {}:{}\n{}' .format(self.addr_port[0], self.addr_port[1], PacketUtil.hexdump(self.pkt)) + + # Factory-like method + @classmethod + def from_udp_packet(cls, addr_port, udp_packet): + if len(udp_packet) < GenericWSJTXPacket.MINIMUM_NETWORK_MESSAGE_SIZE: + return InvalidPacket( addr_port, udp_packet, "Packet too small") + + if len(udp_packet) > GenericWSJTXPacket.MAXIMUM_NETWORK_MESSAGE_SIZE: + return InvalidPacket( addr_port, udp_packet, "Packet too large") + + (magic, schema, pkt_type, id_len) = struct.unpack('>LLLL', udp_packet[0:16]) + + if magic != GenericWSJTXPacket.MAGIC_NUMBER: + return InvalidPacket( addr_port, udp_packet, "Invalid Magic Value") + + if schema < GenericWSJTXPacket.MINIMUM_SCHEMA_SUPPORTED or schema > GenericWSJTXPacket.MAXIMUM_SCHEMA_SUPPORTED: + return InvalidPacket( addr_port, udp_packet, "Unsupported schema value {}".format(schema)) + klass = WSJTXPacketClassFactory.PACKET_TYPE_TO_OBJ_MAP.get(pkt_type) + + if klass is None: + return InvalidPacket( addr_port, udp_packet, "Unknown packet type {}".format(pkt_type)) + + return klass(addr_port, magic, schema, pkt_type, id, udp_packet) + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/samples/dump_wsjtx_packets.py b/samples/dump_wsjtx_packets.py new file mode 100644 index 0000000..7c4f328 --- /dev/null +++ b/samples/dump_wsjtx_packets.py @@ -0,0 +1,15 @@ + +#sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +import pywsjtx.extra.simple_server + +s = pywsjtx.SimpleServer() + +while True: + (pkt, addr_port) = s.rx_packet() + if (pkt != None): + the_packet = pywsjtx.WSJTXPacketClassFactory.from_udp_packet(addr_port, pkt) + if type(the_packet) == pywsjtx.StatusPacket: + pass + print(the_packet) + + diff --git a/samples/grid_from_gps.py b/samples/grid_from_gps.py new file mode 100644 index 0000000..2705ae1 --- /dev/null +++ b/samples/grid_from_gps.py @@ -0,0 +1,146 @@ +# using standard NMEA sentences +import os +import sys +import threading +from datetime import datetime + +import serial + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +import pywsjtx +import pywsjtx.extra.simple_server +import pywsjtx.extra.latlong_to_grid_square + +class NMEALocation(object): + # parse the NMEA message for location into a grid square + def __init__(self, grid_changed_callback = None): + self.valid = False + self.grid = "" + self.last_fix_at = None + self.grid_changed_callback = grid_changed_callback + + def handle_serial(self,text): + # should be a single line. + if text.startswith('$GPGLL'): + print('nmea sentence: ', text) + grid = pywsjtx.extra.latlong_to_grid_square.LatLongToGridSquare.GPGLL_to_grid(text) + + if grid != "": + self.valid = True + self.last_fix_at = datetime.utcnow() + else: + self.valid = False + + if self.grid != grid: + print("NMEA = grid mismatch old: {} new: {}".format(self.grid,grid)) + self.grid = grid + if (self.grid_changed_callback): + c_thr = threading.Thread(target=self.grid_changed_callback, args=(grid,), kwargs={}) + c_thr.start() + +class SerialGPS(object): + + def __init__(self): + # TODO arbitrate access to line_handlers[] + self.line_handlers = [] + self.comm_thread = None + self.comm_device = None + self.stop_signalled = False + pass + + def add_handler(self, line_handler): + if (not (line_handler is None)) and (not (line_handler in self.line_handlers)): + self.line_handlers.append(line_handler) + + def open(self, comport, baud, line_handler, **serial_kwargs): + if self.comm_device is not None: + self.close() + self.stop_signalled = False + self.comm_device = serial.Serial(comport, baud, **serial_kwargs) + if self.comm_device is not None: + self.add_handler(line_handler) + self.comm_thread = threading.Thread(target=self.serial_worker, args=()) + self.comm_thread.start() + + def close(self): + self.stop_signalled = True + self.comm_thread.join() + + self.comm_device.close() + self.line_handlers = [] + self.comm_device = None + self.stop_signalled = False + + def remove_handler(self, line_handler): + self.line_handlers.remove(line_handler) + + def serial_worker(self): + while (True): + if self.stop_signalled: + return # terminate + line = self.comm_device.readline() + # dispatch the line + if line.startswith(b'$'): + str_line = line.decode("utf-8") + for p in self.line_handlers: + p(str_line) + + @classmethod + def example_line_handler(cls, text): + print('serial: ',text) + +# set up the serial_gps to run +# get location data from the GPS, update the grid +# get the grid out of the status message from the WSJT-X instance + +# if we have a grid, and it's not the same as GPS, then make it the same by sending the message. +# But only do that if triggered by a status message. + +# rinse and repeat -- wait for a status message. Once we have a status message, see if + +wsjtx_id = None +nmea_p = None +gps_grid = "" + +def example_callback(new_grid): + global gps_grid + print("New Grid! {}".format(new_grid)) + # this sets the + gps_grid = new_grid + +sgps = SerialGPS() + +s = pywsjtx.extra.simple_server.SimpleServer() + +print("Starting wsjt-x message server") + +while True: + + (pkt, addr_port) = s.rx_packet() + if (pkt != None): + the_packet = pywsjtx.WSJTXPacketClassFactory.from_udp_packet(addr_port, pkt) + if wsjtx_id is None and (type(the_packet) == pywsjtx.HeartBeatPacket): + # we have an instance of WSJTX + print("wsjtx detected, id is {}".format(the_packet.wsjtx_id)) + print("starting gps monitoring") + wsjtx_id = the_packet.wsjtx_id + # start up the GPS reader + nmea_p = NMEALocation(example_callback) + sgps.open('COM8', 9600, nmea_p.handle_serial, timeout=1.0) + + if type(the_packet) == pywsjtx.StatusPacket: + if gps_grid != "" and the_packet.de_grid != gps_grid: + print("Sending Grid Change to wsjtx-x, old grid:{} new grid: {}".format(the_packet.de_grid, gps_grid)) + grid_change_packet = pywsjtx.LocationChangePacket.Builder(wsjtx_id, "GRID:"+gps_grid) + print(pywsjtx.PacketUtil.hexdump(grid_change_packet)) + s.send_packet(the_packet.addr_port, grid_change_packet) + # for fun, change the TX5 message to our grid square, so we don't have to call CQ again + # this only works if the length of the free text message is less than 13 characters. + # if len(the_packet.de_call <= 5): + # free_text_packet = pywsjtx.FreeTextPacket.Builder(wsjtx_id,"73 {} {}".format(the_packet.de_call, the_packet[0:4]),False) + # s.send_packet(addr_port, free_text_packet) + + print(the_packet) + + + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e69de29