OBP60v/tracker.py

619 lines
23 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.local_lfdno = 0
self.local_dt = cfg['tracker']['interval'] # Eintrag alle <n> 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',
<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_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()