""" Tracker-Daten Mögliche Typen: HERO - Regatta Hero SDCARD LOCAL SERVER - spezielle Software benötigt, kann auch ein Raspi an Bord sein NONE - kein Tracking Wenn die Verbindung zum Server im Internet nicht funktioniert, werden die Positionen in eine Warteschlange gesichert und nach Wiederherstellung der Verbindung übertragen. TODO - Nach einem Disconnect manuelle Neuverbindung ermöglichen - Audioausgabe für Regatta Hero """ import os import time import paho.mqtt.client as mqtt import http.client import ssl import json import socket import ssl import math import subprocess # für Audioausgabe / mpg123 class Tracker(): def __init__(self, logger, cfg): self.log = logger self.appdata = None validtypes = ('HERO', 'SDCARD', 'SERVER', 'LOCAL', 'NONE') if cfg['tracker']['type'] not in validtypes: raise TypeError("Invalid tracker type: '{}'. Only supported: {}".format(cfg['tracker']['type'], ','.join(validtypes))) self.ttype = cfg['tracker']['type'] self.trace = cfg['tracker']['trace'] # Debugging self.trace_fh = None # File Handle der Tracedatei self.buoys = {} # Tonnen (Hero=20) self.courses = [] # Bahnen self.races = [] # Regatten self.mqtt_connected = False self.activated = False self.lat = None # last latitude self.lon = None # last longitude self.tspos = None # timestamp (hh:ss:mm) as datetime.time self.sog = None # Statische Daten # TODO Wirklich alles hier im Tracker oder ist einiges generisch? # appdata? self.boatid = cfg['boat']['uuid'] self.sailno = cfg['boat']['sailno'] self.boatname = cfg['boat']['name'] self.boatclass = cfg['boat']['class'] self.handicap = cfg['boat']['handicap'] self.club = cfg['boat']['club'] self.team = cfg['boat']['team'] # Regatta Hero self.hero_orgid = cfg['tracker']['username'] # Eingestellt in Gerätekonfiguration self.hero_passcode = cfg['tracker']['password'] self.hero_host = cfg['tracker']['host'] self.hero_port = cfg['tracker']['port'] self.hero_viewerpass = None # Wird vom Server in "org" gesendet # Vorlage für Anfragen self.http_payload_template = { "orgid": self.hero_orgid, "passcode": self.hero_passcode, "raceid": "", "replay": "live", "replaytime": 0, "updateType": "timerUpdate" } self.hero_raceid = None # Aktuell ausgewählte Regatta # MQTT self.client = mqtt.Client() self.client.on_connect = self.mqtt_on_connect self.client.on_message = self.mqtt_on_message self.hero_orgstatus = None self.hero_racestatus = None self.hero_timedelta = 0 # Zeitdifferenz zum Server in sec # Hole erste Daten vom Server self.hero_query_org() def is_server_active(self, hostname, port): """ ohne Netzwerkverbindung wirft socket.gethostbyname eine Exception, ggf. "Erro -3 temporary failure in name resolution" """ try: host = socket.gethostbyname(hostname) # DNS Lookup s = socket.create_connection((host, port), 2) s.close() return True except Exception: pass return False def is_active(self): return self.activated def set_active(self, newval): self.activated = newval def set_hero_raceid(self, newraceid): self.hero_raceid = newraceid #def set_type(self, newtype): # validtypes = ('HERO', 'SDCARD', 'SERVER', 'LOCAL', 'NONE') # newtype = newtype.upper() # if newtype not in validtypes: # raise TypeError(f"Invalid tracker type: '{newtype}'. Only supported: {validtypes}") # self.ttype = newtype """ Audioevents: Einfach nur die Zahl 'eins', 'zwei', 'drei', 'vier', 'fuenf', 'sechs', 'sieben', 'acht', 'neun', 'zehn', Minuten bis zum Start 'eineMin', 'zweiMin', 'dreiMin', 'vierMin', 'fuenfMin' Sekunden bis zum Start 'fuenfzehn', 'zwanzig', 'dreissig', 'vierzig', 'fuenfzig' '30sek_alerter' 30 sec kein GPS. Smartphonetext 'abbruch' Abbruch der Wettfahrt 'alive' sehr leises Geräusch 'allgmRueck' 'bahnmarke' 'bahnVerk' Bahnverkürzung 'batteryLevel' Ladezustand unter 20%. Smartphonetext 'cellback' Mobilfunkverbindung wiederhergestellt, Daten übertragen 'einzelRueck' 'endeEineMinute' Zeitrennen in einer Minute zuende 'endeWettfahrt' Ende der Wettfahrt 'endstartVerschiebung' 'jumping' Springende Koordinaten. Smartphonetext. 'neueAnsage' Achtung neue Bekanntmachung 'noconnection' Kein Mobilfunk. GPS-Daten werden zwischengespeichert. 'runde' 'startErfolgt' BEEEP 'startlinie' Startlinie überquert 'startnotready' Startlinie nicht bereit 'startready' 'startVerschiebung' 'wartenAnkuend' Warten auf Ankündigungssignal 'ziellinie' Ziellinie überquert """ def hero_play_audio(self, event, lang='de'): """ Ein Event ist mit einer Audiodatei gekoppelt. Das Event trägt den Basisnamen der Audiodatei. Es werden nur MP3-Dateien unterstützt """ lang = lang.lower() if lang == 'de': filename = f"{event}.mp3" else: filename = f"{event}_{lang}.mp3" mp3file = os.path.join(cfg['audiopath'], filename) subprocess.run(["mpg123", "-q", mp3_file]) def hero_get_degrees(azimut): """ Winkel von einer Bahnmarke zur nächsten basierend auf dem Datenfeld azimutNext """ if (azimut <= math.pi * 3/2): return math.degrees(2 * math.pi - azimut - math.pi / 2) else: return math.degrees(2 * math.pi - azimut + math.pi * 3 / 2) def hero_query_org(self): """ Abfrage des Datenservers / Basisdaten - Namen der Regatten und Kurse """ ssl_context = ssl.create_default_context() conn = http.client.HTTPSConnection(self.hero_host, self.hero_port, context=ssl_context) endpoint = '/mapupdate' payload = self.http_payload_template payload['raceid'] = '[No race]' json_data = json.dumps(payload) headers = { 'Content-Type': 'application/json', 'Content-Length': str(len(json_data)) } try: conn.request("POST", endpoint, body=json_data, headers=headers) response = conn.getresponse() if response.status == 200: self.log.info("HTTP: Response received successfully!") data = json.loads(response.read().decode()) else: self.log.warning(f"HTTP: Failed to retrieve data. Status code: {response.status}") return except http.client.HTTPException as e: self.log.warning(f"HTTP error occurred: {e}") except ssl.SSLError as ssl_error: self.log.warning(f"SSL error occurred: {ssl_error}") finally: conn.close() self.viewerpass = data['org']['viewerPasscode'] self.courses = [] for c in data['org']['courses']: self.courses.append(c) self.races = [] for r in data['org']['races'].values(): if not r['hiderace']: self.races.append(r['raceid']) # Regatta automatisch auswählen wenn nur genau eine aktivierbar ist if len(self.races) == 1: self.hero_raceid = self.races[0] def hero_query_course(self, raceid): # Bojen und Kurs für ein gegebenes Rennen ssl_context = ssl.create_default_context() endpoint = '/mapupdate' conn = http.client.HTTPSConnection(self.hero_host, self.hero_port, context=ssl_context) payload = self.http_payload_template payload['raceid'] = raceid json_data = json.dumps(payload) headers = { 'Content-Type': 'application/json', 'Content-Length': str(len(json_data)) } try: conn.request("POST", endpoint, body=json_data, headers=headers) response = conn.getresponse() if response.status == 200: self.log.info("HTTP: Response received successfully!") data = json.loads(response.read().decode()) else: self.log.warning(f"HTTP: Failed to retrieve data. Status code: {response.status}") return except http.client.HTTPException as e: self.log.warning(f"HTTP error occurred: {e}") except ssl.SSLError as ssl_error: self.log.warning(f"SSL error occurred: {ssl_error}") finally: conn.close() print(self.hero_orgdata) # Bojen self.buoys.clear() for key, val in self.hero_orgdata['org']['buoysets']['default']['buoyset']['marks'].items(): self.buoys[key] = { 'label': val['label'], 'lat': val['label'], 'lon': val['label'] } print(self.buoys) # Kurse def get_position(self): # Positionsabfrage für die Payload # LAT, LON, TSPOS, SOG return (self.lat, self.lon, self.tspos, self.sog) """ def hero_add_race(self, raceid): self.races.add(raceid) def hero_set_races(self, newraces): self.races = set(newraces) """ def hero_get_races(self): if not self.hero_orgstatus: return [] races = [] for r in self.hero_orgstatus['races'].values(): if not r['hiderace']: races.append(r['raceid']) return races def mqtt_on_connect(self, client, userdata, flags, rc): self.mqtt_connected = False if rc == 0: self.log.info(f"MQTT connected, subscribing to topics") client.subscribe("regattahero/orgstatus/thomas") client.subscribe("regattahero/racestatus/thomas/#") self.mqtt_connected = True elif rc == 1: self.log.error("MQTT connection refused, unacceptable protocol version") elif rc == 2: self.log.error("MQTT connection refused, identifier rejected") elif rc == 3: self.log.error("MQTT connection refused, server unavailable") elif rc == 4: self.log.error("MQTT connection refused, bad user name or password") elif rc == 5: self.log.error("MQTT connection refused, not authorized") else: self.log.info(f"MQTT connection refused, error #{rc}") def mqtt_on_message(self, client, userdata, msg): """ TODO raceid über userdata? dann topic prüfen? """ if self.trace: self.trace_fh.write(msg.topic) self.trace_fh.write("\n") self.trace_fh.write(msg.payload.decode()) self.trace_fh.write("\n\n") self.trace_fh.flush() if msg.topic == "regattahero/orgstatus/thomas": # kommt alle 10s self.hero_orgstatus = json.loads(msg.payload) # Zeitdifferenz Client/Server ermitteln für ggf. spätere Warnung server_ts = self.appdata.track.hero_orgstatus['timestamp'].split(':') sec1 = int(server_ts[0]) * 3600 + int(server_ts[1]) * 60 + int(server_ts[2]) ts = time.localtime() sec2 = ts.tm_hour * 3600 + ts.tm_min * 60 + ts.tm_sec self.hero_timedelta = abs(sec1 - sec2) if self.hero_orgstatus['allLogout']: self.log.info("All logout received!") client.disconnect() self.activated = False return if self.hero_orgstatus['message']: # TODO Alarm-Funktion nutzen? pass elif msg.topic.startswith("regattahero/racestatus/thomas"): # kommt alle 1s # dem Topic angehängt ist noch die raceid payload = json.loads(msg.payload) self.hero_racestatus = payload['racestatus'] """ time: negativ: Zeit vor dem Start, positiv: Zeit nach dem Start in Sekunden flags: [0, 0, 14, 10] raceactive: true bedeutet orange Flagge ist oben racestarted: true Signale der Wettfahrtleitung hier anzeigen Regattaabbruch Bahnverkürzung Rückrufe phase: 0 vor dem Start racephase: 1 racephase: 4 5 Vorbereitungssignal racephase: 6 nach vorbereitnug wieder runter 7: Rennen gestartet """ else: self.log.warning(f"UNKNOWN TOPIC: {msg.topic}") self.log.debug(msg.payload) def mqtt_publish(self, client, topic, payload, bv_lat, bv_lon, bv_sog): """ Payload vorbelegt als Template, so daß nur noch die veränderlichen GPS-Daten eingefügt werden müssen: LAT LON SOG TIMESTAMP isTracking kann ausgeschaltet werden, """ lat = bv_lat.getValueRaw() lon = bv_lon.getValueRaw() sog = bv_sog.getValueRaw() if lat and lon and (sog is not None): payload['raceid'] = self.hero_raceid payload['gps']['lat'] = round(lat, 5) payload['gps']['lon'] = round(lon, 5) payload['gps']['speed'] = sog payload['gps']['timestamp'] = time.strftime("%Y-%m-%dT%H:%M:%S.000Z", time.gmtime()) client.publish(topic, json.dumps(payload)) else: self.log.debug("No GPS data available. Nothing published!") def mqtt_tracker(self, cfg, boat, appdata, boatdata): self.log.info("MQTT tracker enabled") self.appdata = appdata """ self.boatid = boat['uuid'] self.sailno = boat['sailno'] self.boatname = boat['name'] self.boatclass = boat['class'] self.handicap = boat['handicap'] self.club = boat['club'] self.team = boat['team'] self.trace = cfg['trace'] self.hero_orgid = cfg['username'] """ self.client.username_pw_set(username=cfg['mqtt_user'], password=cfg['mqtt_pass']) try: self.client.connect(cfg['mqtt_host'], cfg['mqtt_port'], 60) except ConnectionRefusedError: self.log.error("MQTT connection refused. Check username and password.") return # Initial die Organisationsdaten abfragen um Tonnen und Kurse zu erhalten # self.hero_query_org(cfg['host'], cfg['port'], cfg['username'], cfg['password']) if self.trace: tracefile = os.path.join(os.path.expanduser(cfg['logdir']), 'tracker.log') self.trace_fh = open(tracefile, 'w+') self.log.info(f"MQTT trace file '{tracefile}' enabled") topic = "regattahero/tracker/" + self.hero_orgid payload = { "passcode": cfg['password'], "orgid": self.hero_orgid, "raceid": None, # Nach Auswahl einstellen "gps": { "lat": 0.0, "lon": 0.0, "speed": 0.0, "age": 500, # letzter Kontakt zum GPS empfänger in ms # "odo": 1000, # deprecated "bat": 1.0, "timestamp": "" # ISO8601 Format mit Millisekunden in UTC }, "boat": { "boatid": self.boatid, "sailno": self.sailno, "team": self.team, "boatclass": self.boatclass, "handicap": self.handicap, "club": self.club, "boatname": self.boatname, "isTracking": True, "hasGivenUp": False }, "device": { # optional "os" : "linux", "osVer" : "OBP60v 0.1", "isIOS" : False, "isAndroid" : False, "isTracker" : True } } # Zugriff auf Boatdata: Referenzen für leichten schnellen Zugriff bv_lat = boatdata.getRef("LAT") bv_lon = boatdata.getRef("LON") bv_sog = boatdata.getRef("SOG") self.client.loop_start() while not appdata.shutdown: time.sleep(1) if self.activated and self.hero_raceid is not None: self.mqtt_publish(topic, payload, bv_lat, bv_lon, bv_sog) self.client.loop_stop() self.client.disconnect() if cfg['trace']: self.trace_fh.close()