#!/usr/bin/env python # # Project Horus - Browser-Based Chase Mapper # Listeners # # Copyright (C) 2018 Mark Jessop # Released under GNU GPL v3 or later # # These classes have been pulled in from the horuslib library, to avoid # requiring horuslib (hopefully soon-to-be retired) as a dependency. import socket, json, sys, traceback from threading import Thread from dateutil.parser import parse from datetime import datetime, timedelta MAX_JSON_LEN = 32768 def fix_datetime(datetime_str, local_dt_str=None): """ Given a HH:MM:SS string from an telemetry sentence, produce a complete timestamp, using the current system time as a guide for the date. """ if local_dt_str is None: _now = datetime.utcnow() else: _now = parse(local_dt_str) # Are we in the rollover window? if _now.hour == 23 or _now.hour == 0: _outside_window = False else: _outside_window = True # Append on a timezone indicator if the time doesn't have one. if datetime_str.endswith("Z") or datetime_str.endswith("+00:00"): pass else: datetime_str += "Z" # Parsing just a HH:MM:SS will return a datetime object with the year, month and day replaced by values in the 'default' # argument. _telem_dt = parse(datetime_str, default=_now) if _outside_window: # We are outside the day-rollover window, and can safely use the current zulu date. return _telem_dt else: # We are within the window, and need to adjust the day backwards or forwards based on the sonde time. if _telem_dt.hour == 23 and _now.hour == 0: # Assume system clock running slightly fast, and subtract a day from the telemetry date. _telem_dt = _telem_dt - timedelta(days=1) elif _telem_dt.hour == 00 and _now.hour == 23: # System clock running slow. Add a day. _telem_dt = _telem_dt + timedelta(days=1) return _telem_dt class UDPListener(object): """ UDP Broadcast Packet Listener Listens for Horuslib UDP broadcast packets, and passes them onto a callback function """ def __init__( self, callback=None, summary_callback=None, gps_callback=None, bearing_callback=None, port=55672, ): self.udp_port = port self.callback = callback self.summary_callback = summary_callback self.gps_callback = gps_callback self.bearing_callback = bearing_callback self.listener_thread = None self.s = None self.udp_listener_running = False def handle_udp_packet(self, packet): """ Process a received UDP packet """ try: packet_dict = json.loads(packet.decode()) if self.callback is not None: self.callback(packet_dict) if packet_dict["type"] == "PAYLOAD_SUMMARY": if self.summary_callback is not None: self.summary_callback(packet_dict) if packet_dict["type"] == "PAYLOAD_TELEMETRY": if "time_string" in packet_dict.keys(): packet_dict["time"] = packet_dict["time_string"] if self.summary_callback is not None: self.summary_callback(packet_dict) if packet_dict["type"] == "GPS": if self.gps_callback is not None: self.gps_callback(packet_dict) if packet_dict["type"] == "BEARING": if self.bearing_callback is not None: self.bearing_callback(packet_dict) if packet_dict["type"] == "MODEM_STATS": if self.summary_callback is not None: self.summary_callback(packet_dict) except Exception as e: print("Could not parse packet: %s" % str(e)) traceback.print_exc() def udp_rx_thread(self): """ Listen for Broadcast UDP packets """ self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.s.settimeout(1) self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) except: pass self.s.bind(("", self.udp_port)) print("Started UDP Listener Thread.") self.udp_listener_running = True while self.udp_listener_running: try: m = self.s.recvfrom(MAX_JSON_LEN) except socket.timeout: m = None except: traceback.print_exc() if m != None: self.handle_udp_packet(m[0]) print("Closing UDP Listener") self.s.close() def start(self): if self.listener_thread is None: self.listener_thread = Thread(target=self.udp_rx_thread) self.listener_thread.start() def close(self): self.udp_listener_running = False self.listener_thread.join() class OziListener(object): """ Listen on a supplied UDP port for OziPlotter-compatible telemetry data. Incoming sentences are of the form: TELEMETRY.HH:MM:SS,latitude,longitude,altitude\n WAYPOINT,waypoint_name,latitude,longitude,comment\n """ allowed_sentences = ["TELEMETRY", "WAYPOINT"] def __init__( self, hostname="", port=8942, telemetry_callback=None, waypoint_callback=None ): self.input_host = hostname self.input_port = port self.telemetry_callback = telemetry_callback self.waypoint_callback = waypoint_callback self.start() def start(self): """ Start the UDP Listener Thread. """ self.udp_listener_running = True self.t = Thread(target=self.udp_rx_thread) self.t.start() def udp_rx_thread(self): """ Listen for incoming UDP packets, and pass them off to another function to be processed. """ self.s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.s.settimeout(1) self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) except: pass self.s.bind((self.input_host, self.input_port)) while self.udp_listener_running: try: m = self.s.recvfrom(1024) except socket.timeout: m = None except: traceback.print_exc() if m != None: try: self.handle_packet(m[0]) except: traceback.print_exc() print("ERROR: Couldn't handle packet correctly.") pass print("INFO: Closing UDP Listener Thread") self.s.close() def close(self): """ Close the UDP listener thread. """ self.udp_listener_running = False try: self.t.join() except: pass def handle_telemetry_packet(self, packet): """ Split a telemetry packet into time/lat/lon/alt, and pass it onto a callback """ _fields = packet.split(",") _short_time = _fields[1] _lat = float(_fields[2]) _lon = float(_fields[3]) _alt = float(_fields[4]) # Timestamp Handling # The 'short' timestamp (HH:MM:SS) is always assumed to be in UTC time. # To build up a complete datetime object, we use the system's current UTC time, and replace the HH:MM:SS part. _full_time = datetime.utcnow().strftime("%Y-%m-%dT") + _short_time + "Z" _time_dt = parse(_full_time) _time_dt = fix_datetime(_short_time) _output = { "time": _time_dt, "lat": _lat, "lon": _lon, "alt": _alt, "comment": "Telemetry Data", } self.telemetry_callback(_output) def handle_waypoint_packet(self, packet): """ Split a 'Waypoint' packet into fields, and pass onto a callback """ _fields = packet.split(",") _waypoint_name = _fields[1] _lat = float(_fields[2]) _lon = float(_fields[3]) _comment = _fields[4] _time_dt = datetime.utcnow() _output = { "time": _time_dt, "name": _waypoint_name, "lat": _lat, "lon": _lon, "comment": _comment, } self.waypoint_callback(_output) def handle_packet(self, packet): """ Check an incoming packet matches a valid type, and then forward it on. """ # Extract header (first field) packet_type = packet.split(",")[0] if packet_type not in self.allowed_sentences: print("ERROR: Got unknown packet: %s" % packet) return try: # Now send on the packet if we are allowed to. if packet_type == "TELEMETRY" and (self.telemetry_callback != None): self.handle_telemetry_packet(packet) # Generally we always want to pass on waypoint data. if packet_type == "WAYPOINT" and (self.waypoint_callback != None): self.handle_waypoint_packet(packet) except: print("ERROR: Error when handling packet.") traceback.print_exc()