From eb41bdafa4bd75fe340a7eb2b6e04ca4ce362f33 Mon Sep 17 00:00:00 2001 From: Thomas Hooge Date: Mon, 15 Sep 2025 19:33:33 +0200 Subject: [PATCH] =?UTF-8?q?Erste=20funktionsf=C3=A4hige=20Tracker-Version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appdata.py | 26 +++++- nmea0183.py | 39 ++++---- obp60v.py | 17 +++- pages/page.py | 8 +- pages/tracker.py | 231 ++++++++++++++++++++++++++++++++++++++--------- tracker.py | 62 +++++++++---- 6 files changed, 291 insertions(+), 92 deletions(-) diff --git a/appdata.py b/appdata.py index 3e87162..6570c5f 100644 --- a/appdata.py +++ b/appdata.py @@ -12,6 +12,8 @@ class AppData(): self.shutdown = False # Globaler Ausschalter self.track = Tracker('NONE') self.frontend = None + self.bv_lat = None + self.bv_lon = None # Für u.a. Header-Indikatoren # TODO @@ -28,22 +30,38 @@ class AppData(): def setFrontend(self, frontend): self.frontend = frontend # Referenz zur GUI + self.bv_lat = frontend.boatdata.getRef("LAT") + self.bv_lon = frontend.boatdata.getRef("LON") def refreshStatus(self): - self.status['AP'] = False + self.status['AP'] = False # nicht implementiert self.status['TCP'] = False self.status['WIFI'] = False for intf in os.listdir('/sys/class/net'): - statefile = os.path.join('/sys/class/net', interface, 'operstate') - wififile = os.path.join('/sys/class/net', interface, 'wireless') + statefile = os.path.join('/sys/class/net', intf, 'operstate') + wififile = os.path.join('/sys/class/net', intf, 'wireless') if os.path.exists(statefile): with open(statefile) as fh: - state = f.read().strip() + state = fh.read().strip() if state == 'up': if os.path.exists(wififile): self.status['WIFI'] = True else: self.status['TCP'] = True + # TODO NMEA2000 + # can-Interface can0 im Netzwerk. Identifikation? + + # TODO NMEA0183 tty auf Konfiguration + # enabled in Konfiguration + # port muß gültige Schnittstelle sein + + # TODO USB /dev/ttyUSB0? + + # GPS + # Kann ein Empfänger am USB sein. Siehe Konfiguration + self.status['GPS'] = self.bv_lat and self.bv_lon and self.bv_lat.valid and self.bv_lon.valid + + # Tracker self.status['TRK'] = self.track.is_active() diff --git a/nmea0183.py b/nmea0183.py index 4914847..17c0000 100644 --- a/nmea0183.py +++ b/nmea0183.py @@ -9,9 +9,10 @@ TODO Multi-Sentence verarbeiten import serial from setproctitle import setthreadtitle +import pynmea2 # Empfangsthread -def rxd_0183(appdata, devname): +def rxd_0183(appdata,boatdata, devname): # Prüfe ob NMEA0183-Port vorhanden ist und sich öffnen läßt try: ser = serial.Serial(devname, 115200, timeout=3) @@ -351,28 +352,28 @@ def VPW(boatdata, msg): def VTG(boatdata, msg): # Track made good and speed over ground """ -(('True Track made good', 'true_track', ), -('True Track made good symbol', 'true_track_sym'), -('Magnetic Track made good', 'mag_track', ), -('Magnetic Track symbol', 'mag_track_sym'), -('Speed over ground knots', 'spd_over_grnd_kts', ), -('Speed over ground symbol', 'spd_over_grnd_kts_sym'), -('Speed over ground kmph', 'spd_over_grnd_kmph', ), -('Speed over ground kmph symbol', 'spd_over_grnd_kmph_sym'), -('FAA mode indicator', 'faa_mode')) -['', 'T', '', 'M', '0.117', 'N', '0.216', 'K', 'A'] + (('True Track made good', 'true_track', ), + ('True Track made good symbol', 'true_track_sym'), + ('Magnetic Track made good', 'mag_track', ), + ('Magnetic Track symbol', 'mag_track_sym'), + ('Speed over ground knots', 'spd_over_grnd_kts', ), + ('Speed over ground symbol', 'spd_over_grnd_kts_sym'), + ('Speed over ground kmph', 'spd_over_grnd_kmph', ), + ('Speed over ground kmph symbol', 'spd_over_grnd_kmph_sym'), + ('FAA mode indicator', 'faa_mode')) + ['', 'T', '', 'M', '0.117', 'N', '0.216', 'K', 'A'] + $IIVTG,312.000000,T,,M,2.000000,N,3.704000,K,A*28 """ - #print("-> VTG") - # msg.true_track true_track_sym - # msg.mag_track mag_track_sym - # msg.faa_mode + if msg.faa_mode != 'A': + return #TODO klären was für Typen hier ankommen können # bytearray, str, decimal.Decimal? - #sog = float(msg.spd_over_grnd_kts) #str von OpenCPN: sog = float(msg.spd_over_grnd_kts[:-1]) - #boatdata.setValue("SOG", sog) - #print("VTG", msg.spd_over_grnd_kts) - print("VTG", msg) + #Ggf. ist OpenCPN buggy! + cog = float(msg.true_track) # in Grad + sog = float(msg.spd_over_grnd_kts) # in Knoten + boatdata.setValue("COG", cog) + boatdata.setValue("SOG", sog) def VWR(boatdata, msg): # Relative Wind Speed and Angle diff --git a/obp60v.py b/obp60v.py index 26cf2c8..445736d 100755 --- a/obp60v.py +++ b/obp60v.py @@ -94,10 +94,10 @@ import cairo import math import threading import socket +import pynmea2 import can import serial import smbus2 -import pynmea2 import bme280 import math import time @@ -301,7 +301,6 @@ class Frontend(Gtk.Window): def __init__(self, cfg, appdata, device, boatdata, profile): super().__init__() self.appdata = appdata - self.appdata.setFrontend(self) self.owndev = device self.boatdata = boatdata self._config = cfg['_config'] @@ -311,6 +310,7 @@ class Frontend(Gtk.Window): self.connect("delete-event", self.on_delete) self.connect("destroy", self.on_destroy) + self.appdata.setFrontend(self) if self._fullscreen: self.fullscreen() @@ -396,11 +396,13 @@ class Frontend(Gtk.Window): self.get_window().set_cursor(Gdk.Cursor(Gdk.CursorType.BLANK_CURSOR)) def run(self): - GLib.timeout_add_seconds(1, self.on_timer) + appdata.refreshStatus() + GLib.timeout_add_seconds(1, self.on_timer_fast) + GLib.timeout_add_seconds(10, self.on_timer_slow) self.show_all() Gtk.main() - def on_timer(self): + def on_timer_fast(self): # Boatdata validator boatdata.updateValid(5) # Tastaturstatus an Seite durchreichen @@ -409,6 +411,10 @@ class Frontend(Gtk.Window): self.da.queue_draw() return True + def on_timer_slow(self): + appdata.refreshStatus() + return True + def on_draw(self, widget, ctx): # Fenstertransparenz ctx.set_source_rgba(0, 0, 0, 0) @@ -765,7 +771,7 @@ if __name__ == "__main__": t_rxd_n2k.start() if cfg['nmea0183']: print("NMEA0183 enabled, library version {}".format(pynmea2.version)) - t_rxd_0183 = threading.Thread(target=nmea0183.rxd_0183, args=(appdata,cfg['0183_port'],)) + t_rxd_0183 = threading.Thread(target=nmea0183.rxd_0183, args=(appdata,boatdata,cfg['0183_port'],)) t_rxd_0183.start() if cfg['gps']: print("GPS enabled (local)") @@ -776,6 +782,7 @@ if __name__ == "__main__": t_rxd_net = threading.Thread(target=rxd_network, args=(cfg['net_port'],cfg['net_addr'])) t_rxd_net.start() if cfg['tracker']['type'] != 'NONE': + appdata.track.set_type( cfg['tracker']['type']) t_tracker = threading.Thread(target=appdata.track.mqtt_tracker, args=(cfg['tracker'],cfg['boat'],appdata,boatdata)) t_tracker.start() if not cfg['simulation']: diff --git a/pages/page.py b/pages/page.py index 3b1e5d4..cf1c0b6 100644 --- a/pages/page.py +++ b/pages/page.py @@ -142,13 +142,9 @@ class Page(): ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) ctx.set_font_size(16) ctx.move_to(0.5, 14.5) - ctx.show_text(f"N2K GPS") + ctx.show_text(' '.join([s for s in self.appdata.status if self.appdata.status[s]])) ctx.stroke() - # AP: Nicht implementiert - # WIFI: - # /proc/net/wireless - # Tastenstatus ctx.save() if self.keylock: @@ -325,6 +321,7 @@ class Page(): ctx.stroke() def draw_text_boxed(self, ctx, x, y, w, h, content, inverted=False, border=False): + ctx.save() ctx.set_line_width(1) # Background fill ctx.set_source_rgb(*self.fgcolor) @@ -343,6 +340,7 @@ class Page(): ctx.move_to(x + 4, y + h - 5 + 0.5) ctx.show_text(content) ctx.stroke() + ctx.restore() def wordwrap(text, wrap): # Wrap long line to multiple lines, monospaced character set diff --git a/pages/tracker.py b/pages/tracker.py index 27cb3f5..4810731 100644 --- a/pages/tracker.py +++ b/pages/tracker.py @@ -22,17 +22,20 @@ Behandlung von Verbindungsabbrüchen: import os import cairo from .page import Page +from cfgmenu import Menu + class Tracker(Page): def __init__(self, pageno, cfg, appdata, boatdata): super().__init__(pageno, cfg, appdata, boatdata) - self._appdata = appdata self.bv_lat = boatdata.getRef("LAT") self.bv_lon = boatdata.getRef("LON") self.bv_sog = boatdata.getRef("SOG") + self.races = None + self.raceid = None # Ausgewählte Regatta + self.menupos = 0 self.buttonlabel[1] = 'MODE' - self.buttonlabel[2] = 'ON' self.mode = 'N' # (N)ormal, (C)onfiguration # Flaggengröße: 96 x 64 Pixel @@ -43,31 +46,88 @@ class Tracker(Page): 'finish', 'hotel', 'india', 'november', 'orange', 'papa', 'repeat_one', 'sierra', 'start', 'uniform', 'xray', 'yankee', 'zulu') + # Mapping + self.flagmap = { + 3: 'blue', # + 8: 'sierra', # Bahnverkürzung + 9: 'november', # Abbruch + 10: 'yankee', # Schwimmwesten + 11: 'repeat_one', # Rückruf + 12: 'answer', # Startverschiebung + 14: 'start', + 15: 'class', # Klassenflagge + 100: 'papa', # Vorbereitung, Frühstart: Zurückfallen über Startlinie + 101: 'india', # Vorbereitung, Frühstart: Starttonne umrunden + 102: 'zulu', # Frühstart: 20%-Strafe + 103: 'uniform', # Frühstart: Disqualifikation, Wiederholung erlaubt + 104: 'black' # Frühstart: Disqualifikation, Wiederholung nicht erlaubt + } self.sym_flag = {} for f in flag: flagfile = os.path.join(cfg['imgpath'], 'flags', f + '.png') self.sym_flag[f] = cairo.ImageSurface.create_from_png(flagfile) - print(self.sym_flag) + + self._menu = Menu("Regattas", 200, 250) + self._menu.setItemDimension(120, 20) def handle_key(self, buttonid): if buttonid == 1: # Modus umschalten if self.mode == 'N': self.mode = 'C' + self.buttonlabel[2] = '#UP' + self.buttonlabel[3] = '#DOWN' + self.buttonlabel[4] = 'SET' + if self.appdata.track.is_active(): + self.buttonlabel[5] = 'OFF' + else: + self.buttonlabel[5] = 'ON' else: self.mode = 'N' + self.buttonlabel[2] = '' + self.buttonlabel[3] = '#PREV' + self.buttonlabel[4] = '#NEXT' + self.buttonlabel[5] = '' + return True elif buttonid == 2: - # Tracking ein/-ausschalten - if self._appdata.track.is_active(): - self._appdata.track.set_active(False) - self.buttonlabel[2] = 'ON' - else: - self._appdata.track.set_active(True) - self.buttonlabel[2] = 'OFF' + if self.mode == 'C': + # Up + if self.menupos > 1: + self.menupos -= 1 + else: + self.menupos = len(self.races) + return True + elif buttonid == 3: + if self.mode == 'C': + # Down + if self.menupos < len(self.races): + self.menupos += 1 + else: + self.menupos = 1 + return True + elif buttonid == 4: + if self.mode == 'C': + # Set / Select regatta + if self.menupos > 0: + self.raceid = self.races[self.menupos - 1] # Nullbasiert + self.appdata.track.hero_raceid = self.raceid + print(f"Selected race '{self.raceid}'") + return True elif buttonid == 5: - self._appdata.frontend.flashled.setColor('yellow') - #self._appdata.frontend.flashled.switchOn(4) - self._appdata.frontend.flashled.doFlash(2) + if self.mode == 'C': + # Tracking ein/-ausschalten + if self.appdata.track.is_active(): + self.appdata.track.set_active(False) + self.buttonlabel[5] = 'ON' + else: + self.appdata.track.set_active(True) + self.buttonlabel[5] = 'OFF' + else: + self.appdata.frontend.flashled.setColor('yellow') + #self.appdata.frontend.flashled.switchOn(4) + self.appdata.frontend.flashled.doFlash(2) + return True + return False def draw_normal(self, ctx): # Name @@ -79,9 +139,19 @@ class Tracker(Page): ctx.select_font_face("DSEG7 Classic") ctx.set_font_size(80) - if self._appdata.track.is_active(): - ctx.move_to(20, 120) - ctx.show_text("-00:00") + if self.appdata.track.is_active(): + if self.appdata.track.hero_racestatus: + counter = self.appdata.track.hero_racestatus['time'] + minutes, seconds = divmod(abs(counter), 60) + if counter < 0: + ctx.move_to(16, 120) + ctx.show_text(f"-{minutes:02d}:{seconds:02d}") + else: + ctx.move_to(28, 120) + ctx.show_text(f"{minutes:03d}:{seconds:02d}") + else: + ctx.move_to(48, 120) + ctx.show_text("--:--") else: ctx.move_to(100, 120) ctx.show_text("off") @@ -94,58 +164,133 @@ class Tracker(Page): ctx.move_to(x0, y0) ctx.show_text("Type: ") ctx.move_to(x1, y0) - ctx.show_text(self._appdata.track.ttype) + ctx.show_text(self.appdata.track.ttype) ctx.move_to(x0, y0 + 16) ctx.show_text("Regatta") ctx.move_to(x1, y0 + 16) - ctx.show_text('') + ctx.show_text(self.appdata.track.hero_raceid or '[not selected]') ctx.move_to(x0, y0 + 32) - ctx.show_text("Lat=") + ctx.show_text("Course") ctx.move_to(x1, y0 + 32) - ctx.show_text(self.bv_lat.format()) + if self.appdata.track.hero_orgstatus and self.appdata.track.hero_raceid: + ctx.show_text(self.appdata.track.hero_orgstatus['races'][self.appdata.track.hero_raceid]['courseid']) + else: + ctx.show_text('[not selected]') ctx.move_to(x0, y0 + 48) - ctx.show_text("Lon=") + ctx.show_text("Latitude") ctx.move_to(x1, y0 + 48) - ctx.show_text(self.bv_lon.format()) + ctx.show_text(self.bv_lat.format()) ctx.move_to(x0, y0 + 64) - ctx.show_text("Sog=") + ctx.show_text("Longitude") ctx.move_to(x1, y0 + 64) + ctx.show_text(self.bv_lon.format()) + + ctx.move_to(x0, y0 + 80) + ctx.show_text("Speed") + ctx.move_to(x1, y0 + 80) ctx.show_text(self.bv_sog.format()) + # Flaggen + if self.appdata.track.hero_racestatus: + pos = 0 + for f in self.appdata.track.hero_racestatus['flags']: + if f in self.flagmap: + # TODO Context save/restore erforderlich? + ctx.save() + ctx.set_source_surface(self.sym_flag[self.flagmap[f]], *self.flagpos[pos]) + ctx.paint() + ctx.restore() + pos += 1 + + def draw_config(self, ctx): ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) - ctx.set_font_size(32) - ctx.move_to(20, 80) + ctx.set_font_size(24) + ctx.move_to(4, 42) ctx.show_text("Tracker configuration") - # Daten aus Konfiguration anzeigen - # - boot - # - tracker + + x0 = 8 + x1 = 88 + y0 = 96 + + ctx.set_font_size(20) + ctx.move_to(x0, 75) + ctx.show_text("Boat data") ctx.set_font_size(16) - # Mögliche Regatten - # -> auf Konfigurationsmodus verschieben - x = 250 - y = 100 - ctx.move_to(x, y - 24) - ctx.show_text("Regattas") - for r in self._appdata.track.hero_get_races(): - ctx.move_to(x, y) - ctx.show_text(r) - y += 20 - if y == 160: - ctx.move_to(x, y) - ctx.show_text("keine") + ctx.move_to(x0, y0) + ctx.show_text("Name") + ctx.move_to(x1, y0) + ctx.show_text(self.cfg['boat']['name']) + ctx.move_to(x0, y0 + 16) + ctx.show_text("Class") + ctx.move_to(x1, y0 + 16) + ctx.show_text(self.cfg['boat']['class']) - ctx.move_to(20, 120) + ctx.move_to(x0, y0 + 32) + ctx.show_text("Handicap") + ctx.move_to(x1, y0 + 32) + ctx.show_text(str(self.cfg['boat']['handicap'])) + + ctx.move_to(x0, y0 + 48) + ctx.show_text("Club") + ctx.move_to(x1, y0 + 48) + ctx.show_text(self.cfg['boat']['club']) + + ctx.move_to(x0, y0 + 64) + ctx.show_text("Sailno.") + ctx.move_to(x1, y0 + 64) + ctx.show_text(self.cfg['boat']['sailno']) + + x0 = 208 + x1 = 272 + + ctx.set_font_size(20) + ctx.move_to(x0, 75) + ctx.show_text("Tracker info") + + ctx.set_font_size(16) + + ctx.move_to(x0, y0) ctx.show_text("Type: ") - ctx.show_text(self._appdata.track.ttype) + ctx.show_text(self.appdata.track.ttype) + ctx.move_to(x0, y0 + 16) + ctx.show_text("Status") + ctx.move_to(x0, y0 + 32) + ctx.show_text("Org.") + ctx.move_to(x0, y0 + 48) + ctx.show_text("Team") + + # Mögliche Regatten + self.races = self.appdata.track.hero_get_races() + x = 208 + y = 180 + ctx.set_font_size(20) + ctx.move_to(x, y) + ctx.show_text("Regattas") + ctx.set_font_size(16) + y += 4 + if self.menupos > len(self.races): + # Nichts auswählen + self.menupos = 0 + self.raceid = None + i = 0 + for r in self.races: + i += 1 + if r == self.raceid: + r += '*' + self.draw_text_boxed(ctx, x, y, 180, 20, r, (self.menupos == i)) + y += 20 + if i == 0: + ctx.move_to(x, y + 20) + ctx.show_text("[ none ]") def draw(self, ctx): if self.mode == 'N': diff --git a/tracker.py b/tracker.py index d594d89..2abff7a 100644 --- a/tracker.py +++ b/tracker.py @@ -12,6 +12,9 @@ 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 + """ import os @@ -23,11 +26,8 @@ import socket class Tracker(): def __init__(self, trackertype='NONE'): - validtypes = ('HERO', 'SDCARD', 'SERVER', 'NONE') - trackertype = trackertype.upper() - if trackertype not in validtypes: - raise TypeError(f"Invalid tracker type: '{valtype}'. Only supported: {validtypes}") - self.ttype = trackertype + self.ttype = 'NONE' + self.set_type(trackertype) self.appdata = None @@ -44,7 +44,8 @@ class Tracker(): self.sog = None self.hero_orgstatus = None - self.hero_racestatus = None # Akluelle Regatta + self.hero_racestatus = None # Aktuelle Regatta + self.hero_raceid = None # TODO Wirklich alles im Tracker oder ist einiges generisch? self.boatid = None @@ -75,6 +76,16 @@ class Tracker(): 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', 'NONE') + newtype = newtype.upper() + if newtype not in validtypes: + raise TypeError(f"Invalid tracker type: '{newtype}'. Only supported: {validtypes}") + self.ttype = newtype + def get_position(self): # Positionsabfrage für die Payload # LAT, LON, TSPOS, SOG @@ -124,25 +135,39 @@ class Tracker(): if self.hero_orgstatus['allLogout']: print("All logout received!") client.disconnect() - sys.exit(0) # TODO nur die MQTT-Task beenden + self.activated = False + return if self.hero_orgstatus['message']: # TODO Alarm-Funktion nutzen? print("Nachricht der Wettfahrtkeitung:") - print(orgstatus['message']) + print(self.hero_orgstatus['message']) + #print(self.hero_orgstatus) elif msg.topic.startswith("regattahero/racestatus/thomas"): # kommt alle 1s # dem Topic angehängt ist noch die raceid payload = json.loads(msg.payload) - racestatus = payload['racestatus'] + self.hero_racestatus = payload['racestatus'] + + print(self.hero_racestatus['flags']) + #print(self.hero_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: print(f"UNKNOWN TOPIC: {msg.topic}") @@ -152,16 +177,19 @@ class Tracker(): """ 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: + 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()) - + print(payload) client.publish(topic, json.dumps(payload)) else: print("No GPS data available. Nothing published!") @@ -189,13 +217,13 @@ class Tracker(): payload = { "passcode": cfg['passcode'], "orgid": cfg['orgname'], - "raceid": "Demo Regatta", # TODO aus Selektion einstellen + "raceid": None, # Nach Auswahl einstellen "gps": { "lat": 0.0, "lon": 0.0, "speed": 0.0, - "age": 1000, - "odo": 1000, + "age": 500, + # "odo": 1000, # deprecated "bat": 1.0, "timestamp": "" # ISO8601 Format mit Millisekunden in UTC }, @@ -206,7 +234,9 @@ class Tracker(): "boatclass": boat['class'], "handicap": boat['handicap'], "club": boat['club'], - "boatname": boat['name'] + "boatname": boat['name'], + "isTracking": True, + "hasGivenUp": False } } @@ -218,7 +248,7 @@ class Tracker(): client.loop_start() while not appdata.shutdown: time.sleep(1) - if appdata.track.is_active(): + if self.activated and self.hero_raceid is not None: self.mqtt_publish(client, topic, payload, bv_lat, bv_lon, bv_sog) client.loop_stop() client.disconnect()