""" Tracker-Daten Mögliche Typen: HERO - Regatta Hero SDCARD - nur für ESP32 relevant LOCAL - Logdatei. GPX ist ungeeignet, weil das Gerät zu jedem Zeitpunkt ausgeschaltet werden kann und somit die Datei in einem ungültigen Zustand sein kann. Es wird eine Datei track.log.geschrieben. Um fehlende Daten festzustellen wird ein fortlaufender Zähler geschrieben. SERVER - spezielle Software benötigt, kann auch ein Raspi an Bord sein Der Tracker sendet einen HTTP-POST. Der Server speichert die Daten für mehrere Geräte. Vorläufiger Servername: trackserver 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.local_lfdno = 0 self.local_dt = cfg['tracker']['interval'] # Eintrag alle Sekunden schreiben self.trace = cfg['tracker']['trace'] # Debugging self.trace_fh = None # File Handle der Tracedatei self.audiopath = cfg['audiopath'] 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'] if self.ttype == 'HERO': # 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 self.hero_racephase = 0 # Bei Änderung Event auslösen self.hero_shortened = False # Bahnverkürzung aktiv # MQTT self.client = mqtt.Client() self.client.on_connect = self.mqtt_on_connect self.client.on_message = self.mqtt_on_message self.client_race_age = 0 # Age of last mqtt race data in seconds self.hero_orgstatus = None self.hero_racestatus = None self.hero_timedelta = 0 # Zeitdifferenz zum Server in sec self.hero_givenup = False # 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 local_tracker(self, cfg, appdata, boatdata): self.log.info("Local tracker enabled") if not os.path.exists(cfg['histpath']): os.makedirs(cfg['histpath']) self.log.info(f"History path created: '{cfg['histpath']}'") # Zugriff auf Boatdata: Referenzen für leichten schnellen Zugriff bv_lat = boatdata.getRef("LAT") bv_lon = boatdata.getRef("LON") bv_hdop = boatdata.getRef("HDOP") bv_tspos = boatdata.getRef("TSPOS") trackerfile = os.path.join(cfg['histpath'], "localtrack.log") fh = open(trackerfile, 'a+') n = 0 while not appdata.shutdown: time.sleep(1) if not self.activated: continue n += 1 if n % self.local_dt != 0: continue self.local_lfdno += 1 data = "{},{}Z,{},{},{}\n".format( self.local_lfdno, bv_tspos.getValue(), bv_lat.getValue(), bv_lon.getValue(), bv_hdop.getValue()) fh.write(data) fh.flush() fh.close() 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_mqtt_subscribe(self, raceid, lastid=None): topicbase = f"regattahero/racestatus/{self.hero_orgid}" if lastid is not None: topic = '/'.join((topicbase, lastid)) self.client.unsubscribe(topic) self.log.info(f"Sent unsubscribe for '{topic}'") topic = '/'.join((topicbase, raceid)) self.client.subscribe(topic) self.log.info(f"Sent subscribe for '{topic}'") def hero_giveup(self): # TODO nach Aufgabe noch ein paar Pakete senden bis dier # Tracker dann abgestellt wird. self.hero_givenup = True 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(self.audiopath, filename) subprocess.Popen(["mpg123", "-q", mp3file]) 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(self, raceid): """ Query Regatta Hero HTTP-Server """ 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'] = raceid json_data = json.dumps(payload) headers = { 'Content-Type': 'application/json', 'Content-Length': str(len(json_data)) } data = {} # empty 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 data 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() return data def hero_query_org(self): """ Abfrage des Datenservers / Basisdaten - Namen der Regatten und Kurse """ data = self.hero_query('[No race]') 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 """ data = self.hero_query(raceid) 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 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']) def hero_query_race(self, raceid): """ Aktuelle Regattadaten vom HTTP-Server holen, ggf. weil MQTT nichts mehr sendet """ data = self.hero_query(raceid) self.hero_racestatus = data['race']['racestatus'] print(self.hero_racestatus) # 'raceactive': False # 'racefinished': True 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("MQTT connected, subscribing to organisation topic") client.subscribe(f"regattahero/orgstatus/{self.hero_orgid}") 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): """ Meldungen vom Server empfangen 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 == f"regattahero/orgstatus/{self.hero_orgid}": # 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(f"regattahero/racestatus/{self.hero_orgid}"): # kommt alle 1s # dem Topic angehängt ist noch die raceid self.client_race_age = 0 payload = json.loads(msg.payload) self.hero_racestatus = payload['racestatus'] racephase = payload['racestatus']['racephase'] if self.hero_racephase != racephase: # Phasenänderung! Event! print("Event: Phasenübergang {} -> {}".format(self.hero_racephase, racephase)) if self.hero_racephase == 0: if racephase == 1: # Startlinie bereit self.hero_play_audio('startready') print(payload['racestatus']) #subprocess.popen("mpg123 -q " + os.path.join(self.audiopath, "startready.mp3")) elif self.hero_racephase == 1: if racephase == 1: # Startlinie bereit self.hero_play_audio('startVerschiebung') elif racephase == 3: # Startverschiebung aufgehoben pass elif racephase == 4: self.hero_play_audio('dreiMin') elif self.hero_racephase == 2: if racephase == 3: self.hero_play_audio('vierMin') elif racephase == 4: self.hero_play_audio('dreiMin') elif self.hero_racephase == 3: if racephase == 4: self.hero_play_audio('dreiMin') if racephase == 1: self.hero_play_audio('startVerschiebung') elif self.hero_racephase == 4: if racephase == 1: self.hero_play_audio('startVerschiebung') if racephase == 5: # Blauer Peter oben self.hero_play_audio('zweiMin') elif self.hero_racephase == 5: if racephase == 1: self.hero_play_audio('startVerschiebung') elif racephase == 6: # Blauer Peter runter pass elif self.hero_racephase == 6: if racephase == 1: self.hero_play_audio('startVerschiebung') elif self.hero_racephase == 7: if racephase == 8: # Gestartet pass elif self.hero_racephase == 8: if racephase == 2: if payload['racestatus']['recallgeneral']: self.hero_play_audio('allgmRueck') elif payload['racestatus']['racecancelled']: self.hero_play_audio('abbruch') elif payload['racestatus']['racefinished']: self.hero_play_audio('endeWettfahrt') self.hero_racephase = racephase # TODO Einzelrückruf, keine Änderung in der Phase! #if payload['racestatus']['recallindiv']: # self.hero_play_audio('einzelRueck') # Timing timer = payload['racestatus']['time'] countdown = { -60: 'eineMin', -50: 'fuenfzig', -40: 'vierzig', -30: 'dreissig', -20: 'zwanzig', -15: 'fuenfzehn', -10: 'zehn', -9: 'neun', -8: 'acht', -7: 'sieben', -6: 'sechs', -5: 'fuenf', -4: 'vier', -3: 'drei', -2: 'zwei', -1: 'eins', 0 : 'startErfolgt' } # Komplexere Bedingung die nach Start fast keinen Aufwand # mehr bedeutet if (timer <= 0) and (timer >= -60) and timer in countdown: self.hero_play_audio(countdown[timer]) # payload['racestatus']['racestarted'] # payload['racesettings'] # Bahnverkürzung if payload['racestatus']['shortend'] and not self.hero_shortened: self.hero_shortened = True self.hero_play_audio('bahnVerk') self.log.info("Bahnverkürzung: Zu Bahnmarke {}".format(payload['racestatus']['shortendsel'])) """ 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 """ else: self.log.warning(f"UNKNOWN TOPIC: {msg.topic}") self.log.debug(msg.payload) def mqtt_publish(self, 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()) self.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.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) self.client_race_age += 1 if self.activated and self.hero_raceid is not None: self.mqtt_publish(topic, payload, bv_lat, bv_lon, bv_sog) if self.client_race_age > 15: # Server sendet keine Daten mehr oder Verbindung verloren # TODO Frage den http-Server nach den letzten Racestatus self.log.warning(f"Keine Daten vom MQTT-Sever seit ca. {self.client_race_age} Sekunden") self.log.warning("Hole Daten vom HTTP-Server") self.hero_query_race(self.hero_raceid) if self.hero_racestatus['racefinished'] == True: self.hero_racephase = 0 self.hero_play_audio('endeWettfahrt') self.activated = False self.client.loop_stop() self.client.disconnect() if cfg['trace']: self.trace_fh.close()