OBP60v/tracker.py

477 lines
17 KiB
Python

"""
Tracker-Daten
Mögliche Typen:
HERO - Regatta Hero
SDCARD
LOCAL - gpx Datei
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
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
# 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',
<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_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()