OBP60v/tracker.py

519 lines
19 KiB
Python

"""
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.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']
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
# 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
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, boat, appdata, boatdata):
# TODO / WIP
self.log.info("Local tracker enabled")
# 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")
self.local_dt = 15
self.local_lfdno = 0
trackerfile = "/tmp/test.log" # TODO Konfiguration lesen
fh = open(trackerfile, 'a+')
while not appdata.shutdown:
time.sleep(self.local_dt)
self.local_lfdno += 1
data = f"{},{},{},{}\n".format(
self.local_lfdno,
bv_tspos.getValueRaw(),
bv_lat.getValueRaw(),
bv_lon.getValueRaw(),
bv_hdop.getValueRaw())
fh.write(data)
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',
<n> Minuten bis zum Start
'eineMin', 'zweiMin', 'dreiMin', 'vierMin', 'fuenfMin'
<n> 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_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(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']
racephase = payload['racestatus']['racephase']
if self.hero_racephase != racephase:
# Phasenänderung! Event!
print("Event: Phasenübergang {} -> {}".format(self.hero_racephase, racephase))
self.hero_racephase = racephase
# payload['racestatus']['racestarted']
# payload['racesettings']
"""
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, 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.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()