From fb7c688a998af1284101ce3b057fba56d31f1aeb Mon Sep 17 00:00:00 2001 From: Thomas Hooge Date: Wed, 1 Oct 2025 06:54:56 +0200 Subject: [PATCH] =?UTF-8?q?NAVTEX-Feature=20hinzugef=C3=BCgt.=20Daten=20nu?= =?UTF-8?q?r=20=C3=BCber=20das=20Netzwerk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- appdata.py | 3 + navtex.py | 197 +++++++++++++++++++++++++++++++++++++++++++ obp60v.conf-sample | 6 ++ obp60v.py | 8 +- pages/__init__.py | 1 + pages/clock.py | 2 +- pages/navtex.py | 112 ++++++++++++++++++++++++ pages/racetracker.py | 6 +- tracker.py | 1 - 9 files changed, 329 insertions(+), 7 deletions(-) create mode 100644 navtex.py create mode 100644 pages/navtex.py diff --git a/appdata.py b/appdata.py index 61a1276..f3747fc 100644 --- a/appdata.py +++ b/appdata.py @@ -5,12 +5,15 @@ Generische Applikationsdaten import os from tracker import Tracker +from navtex import NAVTEX class AppData(): def __init__(self, logger, cfg): self.shutdown = False # Globaler Ausschalter self.log = logger + if cfg['navtex']: + self.navtex = NAVTEX(logger, cfg) self.track = Tracker(logger, cfg) self.frontend = None self.bv_lat = None diff --git a/navtex.py b/navtex.py new file mode 100644 index 0000000..c0f13a7 --- /dev/null +++ b/navtex.py @@ -0,0 +1,197 @@ +""" +DWD NAVTEX + +Funkmodul (radio) noch nicht implementiert mangels Hardware + +B1: Transmitter identity + +B2: Subject indicator character + + A Navigational warnings + B Meteorological warnings + C Ice reports + D Search & rescue information, and pirate warnings + E Meteorological forecasts + F Pilot service messages + G AIS messages (formerly Decca messages[6]) + H LORAN messages + I Not used (formerly OMEGA messages[6]) + J SATNAV messages (i.e. GPS or GLONASS) + K Other electronic navaid messages + L Navigational warnings — additional to letter A (Should not be rejected by the receiver) + T Test transmissions (UK only — not official) + V Notice to fishermen (U.S. only — currently not used) + W Environmental (U.S. only — currently not used) + X Special services — allocation by IMO NAVTEX Panel + Y Special services — allocation by IMO NAVTEX Panel + Z No message on hand + +B3, B4: Serial number 01-99, 00: immediate printout + +Timecode: DDHHmm UTC MMM YY + +Sqlite database schema + .schema message + msgid TEXT PRIMARY KEY + timestamp TEXT + station TEXT + content TEXT + received TEXT + +""" +import os +import http.client +import ssl +import re +import sqlite3 +import datetime +from gi.repository import GLib + +class NAVTEX(): + + def __init__(self, logger, cfg): + self.log = logger + self.source = cfg['ntx_source'].lower() # net | radio + self.maxage = cfg['ntx_housekeeping'] # message hold time in hours + self.running = False + dbpath = os.path.join(cfg['histpath'], "navtex.db") + try: + self.conn = sqlite3.connect(dbpath) + self.cur = self.conn.cursor() + except: + self.log.error(f"Failed to open local database: {dbpath}") + return + # Datenbank erstellen wenn nicht vorhanden + sql = "SELECT name FROM sqlite_master WHERE type='table' AND name='message'" + self.cur.execute(sql) + if self.cur.fetchone() == None: + sql = ("CREATE TABLE IF NOT EXISTS message (" + "msgid TEXT PRIMARY KEY NOT NULL," + "station TEXT," + "timestamp TEXT," + "content TEXT NOT NULL," + "received TEXT NOT NULL DEFAULT current_timestamp)" + ) + self.cur.execute(sql) + self.log.info(f"Created NAVTEX database: {dbpath}") + # Aktualisieren bei Programmstart + # TODO Ausgeschaltet für Programmentwicklung + if self.source == 'net': + self.refresh() + # In der Konfiguration werden Minuten angegeben + GLib.timeout_add_seconds(cfg['ntx_refresh'] * 60, self.on_timer) + self.running = True + + def __del__(self): + self.conn.close() + + def on_timer(self): + """ + NAVTEX data handling + """ + self.refresh() + self.housekeeping() + return True + + def parse_message(self, plainmsg): + """ + Zeile 1: ZCZC + Zeile 2: Stationskennung + Zeile 3: Zeitstempel (meistens) + Zeile 4 bis n-1: Nachrichteninhalt + Zeile n: NNNN + + Je nach Code B1, B2 kann das folgende Format unterschiedlich sein + """ + msg = {} + data = plainmsg.splitlines() + msg['id'] = data[0][5:9] + msg['station'] = data[1].strip() + timestamp = data[2] + if len(timestamp) == 17: + day = int(timestamp[0:2]) + hour = int(timestamp[2:4]) + minute = int(timestamp[4:6]) + monmap = ('JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC') + month = monmap.index(timestamp[11:14]) + 1 + year = 2000 + int(timestamp[15:17]) + try: + msg['timestamp'] = datetime.datetime(year, month, day, hour, minute, 0) + except: + msg['timestamp'] = None + else: + msg['timestamp'] = None + return msg + + def dwd_get_data(self, local=False): + """ + net: Webseite auslesen + https://www.dwd.de/DE/fachnutzer/schifffahrt/funkausstrahlung/navtex + """ + if local: + # Für Tests um nicht permanent die Webseite abzufragen + with open("490_emd.html", "r") as fh: + content = fh.read() + else: + ssl_context = ssl.create_default_context() + conn = http.client.HTTPSConnection("www.dwd.de", 443, context=ssl_context) + url = "https://www.dwd.de/DE/fachnutzer/schifffahrt/funkausstrahlung/navtex/490_emd.html" + + try: + conn.request("GET", url) + response = conn.getresponse() + if response.status == 200: + content = response.read().decode() + else: + print(f"Error: {response.status}") + return [] + except http.client.HTTPException as e: + self.log.warning(f"HTTP error occurred: {e}") + return [] + except ssl.SSLError as ssl_error: + self.log.warning(f"SSL error occurred: {ssl_error}") + return [] + + expr = re.compile("(ZCZC.*?NNNN)", re.DOTALL) + matches = re.findall(expr, content) + + return matches + + def refresh(self): + self.log.info("NAVTEX refresh") + messages = self.dwd_get_data(False) + sql = "INSERT INTO message (msgid, station, content) VALUES (?, ?, ?)" + for m in messages: + msg = self.parse_message(m) + self.cur.execute("SELECT COUNT(*) FROM message WHERE msgid=?", (msg['id'],)) + result = self.cur.fetchone() + if result[0] == 0: + self.log.debug(f"NAVTEX: insert new message '{msg['id']}'") + self.cur.execute(sql, (msg['id'], msg['station'], m)) + self.conn.commit() + + def housekeeping(self): + self.log.info("NAVTEX housekeeping") + sql = "DELETE FROM message WHERE (julianday('now') - julianday(received)) * 24 > ?" + self.cur.execute(sql, (self.maxage, )) + + def get_count(self): + sql = "SELECT COUNT(*) FROM message" + self.cur.execute(sql) + result = self.cur.fetchone() + return result[0] + + def get_ids(self): + sql = "SELECT msgid FROM message" + result = self.cur.execute(sql) + msgids = [] + for row in result.fetchall(): + msgids.append(row[0]) + return msgids + + def get_message(self, msgid): + sql = "SELECT content FROM message WHERE msgid=?" + self.cur.execute(sql, (msgid, )) + result = self.cur.fetchone() + return result[0] + diff --git a/obp60v.conf-sample b/obp60v.conf-sample index bff47bb..9d4df19 100644 --- a/obp60v.conf-sample +++ b/obp60v.conf-sample @@ -33,6 +33,12 @@ address = 0x76 enabled = false port = /dev/ttyACM0 +[navtex] +enabled = false +source = net +housekeeping = 72 +refresh = 30 + [opencpn] navobj = ~/.opencpn/navobj.xml config = ~/.opencpn/opencpn.conf diff --git a/obp60v.py b/obp60v.py index 399c862..032b6b8 100755 --- a/obp60v.py +++ b/obp60v.py @@ -734,6 +734,12 @@ if __name__ == "__main__": if cfg['gps']: cfg['gps_port'] = config.get('gps', 'port') + cfg['navtex'] = config.getboolean('navtex', 'enabled') + if cfg['navtex']: + cfg['ntx_source'] = config.get('navtex', 'source') # Datenquelle: net | radio + cfg['ntx_housekeeping'] = config.getint('navtex', 'housekeeping') # Max. Nachrichtenalter in Stunden + cfg['ntx_refresh'] = config.getint('navtex', 'refresh') # Aktualisierung alle Minuten + cfg['network'] = config.getboolean('network', 'enabled') if cfg['network']: cfg['net_addr'] = config.get('network', 'address') @@ -773,7 +779,6 @@ if __name__ == "__main__": cfg['boat']['club'] = config.get('boat', 'club') cfg['boat']['team'] = config.get('boat', 'team') - # Protokollierung loglevel = set_loglevel(cfg['loglevel']) init_logging(os.path.expanduser(cfg['logdir']), cfg['logfile'], loglevel) @@ -796,7 +801,6 @@ if __name__ == "__main__": # Globale Daten, u.a. auch Shutdown-Indikator appdata = AppData(log, cfg) - # Ggf. Simulationsdaten einschalten if cfg['simulation']: boatdata.enableSimulation() diff --git a/pages/__init__.py b/pages/__init__.py index 3497b88..0654f44 100644 --- a/pages/__init__.py +++ b/pages/__init__.py @@ -32,6 +32,7 @@ from .dst810 import DST810 from .epropulsion import EPropulsion from .keel import Keel from .mob import MOB +from .navtex import Navtex from .racetracker import RaceTracker from .rollpitch import RollPitch from .skyview import SkyView diff --git a/pages/clock.py b/pages/clock.py index 86c6fd7..1a70db0 100644 --- a/pages/clock.py +++ b/pages/clock.py @@ -25,7 +25,7 @@ class Clock(Page): self.buttonlabel[1] = 'MODE' self.buttonlabel[2] = 'TZ' self.mode = ('A', 'D', 'T') # (A)nalog (D)igital (T)imer - self.modeindex = 1 + self.modeindex = 0 self.utc = True self.tzoffset = cfg['tzoffset'] self.bv_lat = boatdata.getRef("LAT") diff --git a/pages/navtex.py b/pages/navtex.py new file mode 100644 index 0000000..e6c9fd3 --- /dev/null +++ b/pages/navtex.py @@ -0,0 +1,112 @@ +""" + +NAVTEX + - Meldungen anzeigen + +""" + +import cairo +from .page import Page + +class Navtex(Page): + + def __init__(self, pageno, cfg, appdata, boatdata): + super().__init__(pageno, cfg, appdata, boatdata) + self.disabled = self.app.navtex is None + self.ids = self.app.navtex.get_ids() + if len(self.ids) > 0: + self.current = 1 + self.msgid = self.ids[self.current - 1] + else: + self.current = 0 + self.msgid = None + self.buttonlabel[1] = 'PREV' + self.buttonlabel[2] = 'NEXT' + self.buttonlabel[5] = 'MORE' + self.skip = 0 + + def handle_key(self, buttonid): + if buttonid == 1: + if self.current > 1: + self.current -= 1 + else: + self.current = len(self.ids) + self.msgid = self.ids[self.current - 1] + self.skip = 0 + return True + if buttonid == 2: + if self.current < len(self.ids): + self.current += 1 + else: + self.current = 1 + self.msgid = self.ids[self.current - 1] + self.skip = 0 + return True + if buttonid == 5: + self.skip += 1 + return True + return False + + def draw(self, ctx): + # Title + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(24) + ctx.move_to(8, 40) + ctx.show_text("NAVTEX") + + ctx.set_line_width(1) + ctx.move_to(4.5, 42.5) + ctx.line_to(395.5, 42.5) + ctx.move_to(4.5, 272.5) + ctx.line_to(395.5, 272.5) + + ctx.move_to(4.5, 32.5) + ctx.line_to(4.5, 272.5) + ctx.move_to(396.5, 32.5) + ctx.line_to(396.5, 272.5) + + #ctx.rectangle(4.5, 20.5, 392, 250) + ctx.stroke() + + ctx.set_font_size(16) + + if self.disabled: + ctx.move_to(8, 75) + ctx.show_text("Feature ist disabled by configuration") + return + + ctx.move_to(150, 40) + self.draw_text_ralign(ctx, 392, 38, "Message {} of {}".format(self.current, len(self.ids))) + + ctx.select_font_face("AtariST8x16SystemFont") + + ctx.move_to(8, 59) + if self.current == 0: + ctx.show_text("NIL") + return + + # 48 Zeichen je Zeile möglich + # Max. 14 Zeilen auf dem Bildschirm + rawmsg = self.app.navtex.get_message(self.msgid).splitlines() + output = [] + for line in rawmsg: + if len(line) <= 48: + output.append(line) + else: + i = 0 + j = 48 + while i < len(line): + output.append(line[i:j]) + i += 48 + j += 48 + x = 8 + y = 59 + n = 0 + for line in output: + if n >= self.skip: + ctx.move_to(x, y) + ctx.show_text(line) + y += 16 + n += 1 + if n >= 14 + self.skip: + break diff --git a/pages/racetracker.py b/pages/racetracker.py index 2aecdb0..1330edf 100644 --- a/pages/racetracker.py +++ b/pages/racetracker.py @@ -190,7 +190,7 @@ class RaceTracker(Page): Absätzen auf dem Bildschirma ausgibt. """ x = 8 - y = 50 + y = 48 ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) ctx.set_font_size(24) ctx.move_to(x, y) @@ -199,7 +199,7 @@ class RaceTracker(Page): y += 25 ctx.set_font_size(16) ctx.move_to(x, y) - ctx.show_text("Disabled by 'NONE in configuration'.") + ctx.show_text("Disabled by 'NONE' in configuration.") y += 30 ctx.move_to(x, y) ctx.show_text("Currently only tracker types 'HERO' and 'LOCAL'") @@ -225,7 +225,7 @@ class RaceTracker(Page): def draw_local(self, ctx): x = 8 x1 = 130 - y = 50 + y = 48 ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) ctx.set_font_size(24) ctx.move_to(x, y) diff --git a/tracker.py b/tracker.py index 5d03e28..7576f9a 100644 --- a/tracker.py +++ b/tracker.py @@ -33,7 +33,6 @@ import http.client import ssl import json import socket -import ssl import math import subprocess # für Audioausgabe / mpg123