commit 71b5f84a6f189743954752018464320a5fffa072 Author: Thomas Hooge Date: Tue Dec 24 09:36:04 2024 +0100 Erstveröffentlichung Weihnachten 2024 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b5e2e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*~ +__pycache__ diff --git a/INSTALL b/INSTALL new file mode 100644 index 0000000..d33d73f --- /dev/null +++ b/INSTALL @@ -0,0 +1,13 @@ +Das Programm kann direkt gestartet werden. Eine Installation ist nicht +erforderlich. Die unten angegebenen Abhängigkeiten müssen erfüllt sein. + +apt-get install python3-cairo python3-gi python3-gi-cairo gir1.2-rsvg-2.0 \ + python-serial python3-nmea2 python3-smbus2 python3-bme280 + +Das Programm wird über eine Konfigurationsdatei obp60.conf im gleichen +Verzeichnis wie das Hauptprogramm gesteuert. Die Konfiguration wird +einmalig beim Programmstart eingelesen. + +Meßdaten werden im Homeverzeichnis unter ~/.local/lib/obp60 gespeichert. +Dies betrifft momentan Luftdruckmessungen mit dem BME280. +Das Verzeichnis wird automatisch angelegt. diff --git a/README b/README new file mode 100644 index 0000000..51c94b3 --- /dev/null +++ b/README @@ -0,0 +1,61 @@ +Multifunktionsdisplay (MFD) virtuell: OBP60v + +Hinweis: Dieses Programm dient in erster Linie dazu die GUI der "echten" +OBP60-Hardware zu designen. Eine eigenständige Nutzung ist selbstverständlich +"auf eigene Gefahr" hin möglich. + +Für Informationen zum OBP60 in Hardware siehe: + - https://open-boat-projects.org/de/diy-multifunktionsdisplay-obp-60/ + - https://obp60-v2-docu.readthedocs.io/de/latest/ + +Fehlermeldungen und Patches gerne an thomas@hoogi.de senden. + +Basishardware +- Raspberry Pi 4 + +Zusatzhardware: +- NMEA2000 Interface + - PiCAN-M (hiermit wird entwickelt) + - Waveshare RS485 CAN HAT (ungetestet) +- BME280-Sensor +- GPS über USB/seriell angeschlossen + +Zusatzsoftware: +- OpenCPN + +Abhängigkeiten +- python-can +- heapdict + +Für GPS +- python-serial +- python3-nmea2 + +Für BME280 +- smbus2 +- bme280 + +Zur Steuerung des Geräts sind 6 Tasten vorhanen. Numeriert von 1 bis 6 von +links nach rechts. Die Tasten können angeklickt werden und führen dann direkt +eine von der jeweiligen Seite abhängige Funktion aus. +Die jeweilige Funktion wird durch ein Symbol oberhalb der Taste dargestellt. +Die Tasten 3 und 4 sind für die Seitennavigation vorgesehen: zurück und vor. +Sie können jedoch von einer Seite bei Bedarf übersteuert werden. + +Wischgesten werden simuliert, indem die Maustaste auf einer Tastenfläche +gedrückt und auf einer anderen Taste losgelassen wird. + +Folgende Wischfunktionen sind implementiert: + 1. Programmende durch die Wischfunktion "2" -> "1" + 2. Tastensperre an: "6" -> "1" + 3. Tastensperre aus: "1" -> "6" + 4. Systemseite: "5" -> "6" + +Routen und Wegepunkte können von OpenCPN empfangen werden. Dazu muß eine +passende serielle Schnittstelle für den NMEA0183-Ausgang definiert werden. +Im System kann diese in der Datei rc.local aktiviert werden: + # Create virtual serial connection + socat pty,rawer,echo=0,group-late=dialout,mode=0660,link=/dev/ttyV0 \ + pty,rawer,echo=0,group-late=dialout,mode=0660,link=/dev/ttyV1 & +OpenCPN sendet dann Datensätze über ttyV0 und dieses Programm +empfängt sie über ttyV1. diff --git a/fonts/AtariST8x16.ttf b/fonts/AtariST8x16.ttf new file mode 100644 index 0000000..f8f4b3e Binary files /dev/null and b/fonts/AtariST8x16.ttf differ diff --git a/fonts/DSEG7Classic-BoldItalic.ttf b/fonts/DSEG7Classic-BoldItalic.ttf new file mode 100644 index 0000000..de0b43a Binary files /dev/null and b/fonts/DSEG7Classic-BoldItalic.ttf differ diff --git a/fonts/Ubuntu-B.ttf b/fonts/Ubuntu-B.ttf new file mode 100644 index 0000000..c0142fe Binary files /dev/null and b/fonts/Ubuntu-B.ttf differ diff --git a/images/Falling.png b/images/Falling.png new file mode 100644 index 0000000..e0eca0a Binary files /dev/null and b/images/Falling.png differ diff --git a/images/Falling_Fast.png b/images/Falling_Fast.png new file mode 100644 index 0000000..80a28ff Binary files /dev/null and b/images/Falling_Fast.png differ diff --git a/images/Falling_Very_Fast.png b/images/Falling_Very_Fast.png new file mode 100644 index 0000000..8438e85 Binary files /dev/null and b/images/Falling_Very_Fast.png differ diff --git a/images/Rising.png b/images/Rising.png new file mode 100644 index 0000000..6850dc6 Binary files /dev/null and b/images/Rising.png differ diff --git a/images/Rising_Fast.png b/images/Rising_Fast.png new file mode 100644 index 0000000..c7b80f8 Binary files /dev/null and b/images/Rising_Fast.png differ diff --git a/images/Rising_Very_Fast.png b/images/Rising_Very_Fast.png new file mode 100644 index 0000000..a08d85c Binary files /dev/null and b/images/Rising_Very_Fast.png differ diff --git a/images/Stationary.png b/images/Stationary.png new file mode 100644 index 0000000..3b748af Binary files /dev/null and b/images/Stationary.png differ diff --git a/images/alarm.png b/images/alarm.png new file mode 100644 index 0000000..c4fd003 Binary files /dev/null and b/images/alarm.png differ diff --git a/images/anchor.png b/images/anchor.png new file mode 100644 index 0000000..77f8c1a Binary files /dev/null and b/images/anchor.png differ diff --git a/images/arrow_l1.png b/images/arrow_l1.png new file mode 100644 index 0000000..b917606 Binary files /dev/null and b/images/arrow_l1.png differ diff --git a/images/arrow_r1.png b/images/arrow_r1.png new file mode 100644 index 0000000..fa30684 Binary files /dev/null and b/images/arrow_r1.png differ diff --git a/images/bee.png b/images/bee.png new file mode 100644 index 0000000..9ba2a01 Binary files /dev/null and b/images/bee.png differ diff --git a/images/buzzer.png b/images/buzzer.png new file mode 100644 index 0000000..d9a8864 Binary files /dev/null and b/images/buzzer.png differ diff --git a/images/fuelcan.png b/images/fuelcan.png new file mode 100644 index 0000000..c0044f9 Binary files /dev/null and b/images/fuelcan.png differ diff --git a/images/fuelpump.png b/images/fuelpump.png new file mode 100644 index 0000000..6ed5ece Binary files /dev/null and b/images/fuelpump.png differ diff --git a/images/lightoff.png b/images/lightoff.png new file mode 100644 index 0000000..624dfee Binary files /dev/null and b/images/lightoff.png differ diff --git a/images/lighton.png b/images/lighton.png new file mode 100644 index 0000000..7a3f5fe Binary files /dev/null and b/images/lighton.png differ diff --git a/images/lock.png b/images/lock.png new file mode 100644 index 0000000..f04f8be Binary files /dev/null and b/images/lock.png differ diff --git a/images/ship.png b/images/ship.png new file mode 100644 index 0000000..5e1e24d Binary files /dev/null and b/images/ship.png differ diff --git a/images/swipe.png b/images/swipe.png new file mode 100644 index 0000000..449a80a Binary files /dev/null and b/images/swipe.png differ diff --git a/images/water.png b/images/water.png new file mode 100644 index 0000000..fe3fbee Binary files /dev/null and b/images/water.png differ diff --git a/images/wmo0.png b/images/wmo0.png new file mode 100644 index 0000000..556220e Binary files /dev/null and b/images/wmo0.png differ diff --git a/images/wmo1.png b/images/wmo1.png new file mode 100644 index 0000000..b87eb3f Binary files /dev/null and b/images/wmo1.png differ diff --git a/images/wmo2.png b/images/wmo2.png new file mode 100644 index 0000000..80cbae0 Binary files /dev/null and b/images/wmo2.png differ diff --git a/images/wmo3.png b/images/wmo3.png new file mode 100644 index 0000000..abcff9d Binary files /dev/null and b/images/wmo3.png differ diff --git a/images/wmo4.png b/images/wmo4.png new file mode 100644 index 0000000..54782a1 Binary files /dev/null and b/images/wmo4.png differ diff --git a/images/wmo5.png b/images/wmo5.png new file mode 100644 index 0000000..4663949 Binary files /dev/null and b/images/wmo5.png differ diff --git a/images/wmo6.png b/images/wmo6.png new file mode 100644 index 0000000..45a0520 Binary files /dev/null and b/images/wmo6.png differ diff --git a/images/wmo7.png b/images/wmo7.png new file mode 100644 index 0000000..0ae1e45 Binary files /dev/null and b/images/wmo7.png differ diff --git a/images/wmo8.png b/images/wmo8.png new file mode 100644 index 0000000..2a81b05 Binary files /dev/null and b/images/wmo8.png differ diff --git a/nmea2000/__init__.py b/nmea2000/__init__.py new file mode 100644 index 0000000..16ba3d3 --- /dev/null +++ b/nmea2000/__init__.py @@ -0,0 +1,3 @@ +from .device import Device +from .boatdata import BoatData +from .hbuffer import History, HistoryBuffer diff --git a/nmea2000/boatdata.py b/nmea2000/boatdata.py new file mode 100644 index 0000000..48e753a --- /dev/null +++ b/nmea2000/boatdata.py @@ -0,0 +1,567 @@ +''' +!!! Dies ist noch im Ideen-Stadium +WIP TBD + +Die Werte der Daten werden wie in NMEA2000 gespeichert: +Längen: m +Geschwindigkeiten: m/s +Winkel, Kurse: radiant (Bogenmaß) +Temperaturen: K +Druck: Pa +Geokoordinaten sind eine vorzeichenbehaftete Fileßkommazahl + +Liste der Daten mit Langbezeichnung siehe: valdesc.py + +Die format()-Funktion liefert immer einen String zurück. +Unterscheidung zwischen "kein Wert" und "ungültiger Wert"? + +Normale Daten +------------- +ALT - Altitude, Höhe über Grund +AWA - Apparant Wind Angle, scheinbare Windrichtung +AWS - Apparant Wind Speed, scheinbare Windgeschwindigkeit +BTW - Bearing To Waipoynt, Winkel zum aktuellen Wegpunkt +COG - Course over Ground, Kurs über Grund +DBS - Depth Below Surface, Tiefe unter Wasseroberfläche +DBT - Depth Below Transducer, Tiefe unter Sensor +DEV - Deviation, Kursabweichung +DTW - Distance To Waypoint, Entfernung zum aktuellen Wegpunkt +GPSD - GPS Date, GPS-Datum +GPDT - GPS Time, GPS-Zeit als UTC (Weltzeit) +HDM - Magnetic Heading, magnetischer rechtweisender Kurs +HDT - Heading, wahrer rechtweisender Kurs +HDOP - GPS-Genauigkeit in der Horizontalen +LAT - Latitude, geografische Breite +LON - Longitude, geografische Höhe +Log - Log, Entfernung +MaxAws - Maximum Apperant Wind Speed, Maximum der relativen Windgeschwindigkeit seit Gerätestart +MaxTws - Maximum True Wind Speed, Maximum der wahren Windgeschwindigkeit seit Gerätestart +PDOP - GPS-Genauigkeit über alle 3 Raumachsen +PRPOS - Auslenkung Sekundärruder +ROT - Rotation, Drehrate +RPOS - Rudder Position, Auslenkung Hauptruder +SOG - Speed Over Ground, Geschwindigkeit über Grund +STW - Speed Through Water, Geschwindigkeit durch das Wasser +SatInfo - Satellit Info, Anzahl der sichtbaren Satelliten +TWD - True Wind Direction, wahre Windrichtung +TWS - True Wind Speed, wahre Windgeschwindigkeit +TZ - Time Zone, Zeitzone +TripLog - Trip Log, Tages-Entfernungszähler +VAR - Variation, Abweichung vom Sollkurs +VDOP - GPS-Genauigkeit in der Vertikalen +WPLat - Waypoint Latitude, geogr. Breite des Wegpunktes +WPLon - Waypoint Longitude, geogr. Länge des Wegpunktes +WTemp - Water Temperature, Wassertemperatur +XTE - Cross Track Error, Kursfehler + +Normale Daten erweitert +----------------------- + +ROLL - Roll - Krängen / Rotation in Querrichtung +PTCH - Pitch - Rollen / Rotation in Längsrichtung +YAW - Yaw - Gieren / Rotation um die Senkrechte Achse + +XDR-Daten +--------- +xdrVBat - Bordspannung +xdrHum - Luftfeuchte +xdrPress - Luftdruck +xdrTemp - Temperatur + +xdrRotK - Kielrotation +xdrRoll +xdrPitch +xdrYaw + +''' + +import datetime +import time +import math +import random + +class BoatValue(): + ''' + Wert mit Datentyp, Einheit, Validekennzeichen und Skalierungsfaktor + ''' + + placeholder = '---' + + def __init__(self, shortname, unit=''): + self.valname = shortname + self.unit = unit + self.value = None + self.valid = False + self.resolution = 1 + self.decpl = None # decimal places for format + self.desc = "" # long description + self.timestamp = time.time() + self.simulated = False + self.history = False + self.hbuf = None + + def getValue(self): + # Wert unter Beachtung der Unit zurückgeben + if self.value and valid: + return self.value + else: + return self.placeholder + + def getValueRaw(self, ignorevalid=False): + if ignorevalid or self.valid: + return self.value + return None + + def setValue(self, newvalue): + self.value = newvalue + self.timestamp = time.time() + self.valid = True + # if self.history: + # TODO es kann mehrere verschiedene Zeitreihen geben! + # Implementierung nich unklar. + + def enableHistory(self, size, delta_t): + if self.history: + # Wiederholter Aufruf löscht die bisherige Historie + self.hbuf.clear() + return + self.history = True + self.hbuf = HistoryBuffer(size, delta_t) + + def format(self): + if self.simulated: + if self.valname == "xdrVBat": + return "{:.1f}".format(random.uniform(11.8, 14.2)) + else: + return "{:.0f}".format(random.uniform(95, 130)) + if not self.value or not self.valid: + return self.placeholder + if not self.decpl: + if self.value < 10: + self.decpl = 1 + else: + self.decpl = 0 + return "{0:3.{1}f}".format(self.value, self.decpl) + + def __str__(self): + out = self.valname + #out += self.format() + if not self.valid: + out += (" (invalid)") + return out + +class BoatValueGeo(BoatValue): + # geofmt = lat | lon + # unit = rad | deg + def __init__(self, shortname, geofmt, unit='deg'): + super().__init__(shortname, unit) + self.geofmt = geofmt + self.decpl = 3 + #print(self.valname) + def format(self): + # latitude, longitude + if not self.value or not self.valid: + return self.placeholder + degrees = int(self.value) + minutes = (self.value - degrees) * 60 + if self.geofmt == 'lat': + direction = ('E' if self.value > 0 else 'W') + formatted = "{0}° {1:.{3}f}' {2}".format(degrees, minutes, direction, decpl) + elif self.geofmt == 'lon': + direction = 'N' if self.value > 0 else 'S' + formatted = "{0}° {1:.{3}f}' {2}".format(degrees, minutes, direction, decpl) + else: + formatted = str(self.placeholder) + return formatted + +class BoatValueDate(BoatValue): + # datefmt = GB | US | DE | ISO + def __init__(self, shortname, datefmt='ISO'): + super().__init__(shortname) + self.datefmt = datefmt + def format(self): + if self.datefmt == 'DE': + formatted = self.value.strftime("%d.%m.%Y") + elif self.datefmt == 'GB': + formatted = self.value.strftime("%d/%m/%Y") + elif self.datefmt == 'US': + formatted = self.value.strftime("%m/%d/%Y") + elif self.datefmt == 'ISO': + formatted = self.value.strftime("%Y-%m-%d") + return formatted + +class BoatValueTime(BoatValue): + def __init__(self, shortname, timezone='UTC'): + super().__init__(shortname) + self.tz = timezone + self.timefmt = 'hh:mm:' # TODO hh:mm:ss | ...? + def format(self): + formatted = self.value + return formatted + +class BoatValueSpeed(BoatValue): + # unsigned? Was ist mit Rückwärts? + def format(self): + if self.simulated: + return "5.3" + if not self.value or not self.valid: + return self.placeholder + if self.value < 20: + formatted = f"{self.value:3.1f}" + else: + formatted = f"{self.value:3.0f}" + return formatted + +class BoatValueAngle(BoatValue): + # course, wind, heading, bearing + # roll, pitch, yaw, rudder, keel + def format(self): + if self.simulated: + if self.valname == "BTW": + return "253" + else: + return "120" + if self.value: + return f"{self.value:03.0f}" + else: + return self.placeholder + +class BoatValueRotation(BoatValue): + # signed + def format(self): + if self.value < 10 and self.value > -10: + formatted = f"{self.value:.1f}" + else: + formatted = f"{self.value:.1f}" + return formatted + +class BoatValueDepth(BoatValue): + # unsigned + def format(self): + if self.simulated: + if self.valname == "DBT": + return "6.2" + else: + return "6.5" + if not self.value or not self.valid: + return self.placeholder + if self.value < 100: + formatted = f"{self.value:3.1f}" + else: + formatted = f"{self.value:3.0f}" + return formatted + +class BoatValueDistance(BoatValue): + # unsigned integer? + def format(self, signed=False): + if self.value: + return f"{self.value:d}" + else: + return self.placeholder + +class BoatValueTemperature(BoatValue): + # signed + def __init__(self, shortname, unit='K'): + super().__init__(shortname, unit) + self.instance = None + self.sensortype = None + self.decpl = 0 + def format(self): + if self.value < 100: + formatted = f"{self.value:3.1f}" + else: + formatted = f"{self.value:3.0f}" + return formatted + +class BoatValueHumidity(BoatValue): + # unsigned integer + # range 0 .. 100 + def __init__(self, shortname, unit='%'): + super().__init__(shortname, unit) + self.instance = None + self.sensortype = None + self.decpl = 0 + def format(self): + return f"{self.value:d}" + +class BoatValuePressure(BoatValue): + # unsigned integer + # range ca. 800 .. 1100 for athmospheric pressure + def __init__(self, shortname, unit='Pa'): + super().__init__(shortname, unit) + self.instance = None + self.sensortype = None + self.decpl = 1 + def format(self): + if self.value and self.valid: + return f"{self.value:4.{self.decpl}f}" + else: + return self.placeholder + +class Tank(): + + def __init__(self, instance=0): + self.instance = instance + self.fluidtype = 1 # water -> lookup + self.volume = None + self.capacity = None + self.desc = "" # long description + + def __str__(self): + out = f" Tank #{self.instance}" + out += f" Capacity: {self.capacity} l\n" + out += f" Fluid level: {self.volume} l\n" + return out + +class Engine(): + + def __init__(self, instance=0): + self.instance = instance + self.speed_rpm = None + self.exhaust_temp = None + self.desc = "" # long description + + def __str__(self): + out = f" Engine #{self.instance}\n" + if self.exhaust_temp: + out += f" Exhaust temp: {self.exhaust_temp:.1f} °C\n" + else: + out += " Exhaust temp: no data\n" + return out + +class Satellite(): + + def __init__(self, prn_num): + self.prn_num = prn_num + self.elevation = None + self.azimuth = None + self.snr = None # signal noise ratio + self.rres = None # range residuals + self.status = None # lookup -> prnusage + self.lastseen = None + + def __str__(self): + out = f"SAT {self.prn_num:02d}: " + if self.snr: + out += f"snr={self.snr}dB elevation={self.elevation:.4f} azimuth={self.azimuth:.4f} status={self.status}\n" + else: + out += "no signal\n" + return out + +class SatelliteList(): + + def __init__(self): + sat = {} + rrmode = None + maxage = 300 # sec + def getCount(self): + return len(sat) + def addSat(self, pnr_num): + pass + def delSat(self, pnr_num): + pass + + +class BoatData(): + + def __init__(self): + + self.simulation = False + + # nach Überschreiten dieser Schwelle in Sekunden wird + # ein Meßwert als ungültig angesehen + self.maxage = 5 + + # Systemspannung; temporär. später auf bessere Weise speichern + self.voltage = BoatValue("xdrVBat", "V") + + # Navigationsdaten + self.awa = BoatValueAngle("AWA", "kn") + self.aws = BoatValueSpeed("AWS", "kn") + self.twd = BoatValueAngle("TWD", "kn") + self.tws = BoatValueSpeed("TWS", "kn") + self.lat = BoatValueGeo("LAT", "lat", "deg") + self.lon = BoatValueGeo("LON", "lon", "deg") + self.gpsd = BoatValueDate("GPSD", "ISO") + self.gpst = BoatValueTime("GPST") + self.sog = BoatValueSpeed("SOG", "kn") + self.cog = BoatValueAngle("COG", "deg") + self.xte = BoatValueDistance("XTE", "m") + self.stw = BoatValueSpeed("STW", "kn") + self.dbt = BoatValueDepth("DBT", "m") + self.roll = BoatValueAngle("ROLL", "deg") + self.pitch = BoatValueAngle("PTCH", "deg") + self.yaw = BoatValueAngle("YAW", "deg") + self.rpos = BoatValueAngle("RPOS", "deg") + self.prpos = BoatValueAngle("PRPOS", "deg") + + # Nächster Wegepunkt + self.wpno = BoatValue("WP") + self.wpname = BoatValue("WPname") + self.wplat = BoatValueGeo("WPLat", "lat", "deg") + self.wplon = BoatValueGeo("WPLon", "lon", "deg") + self.wpdist = BoatValueDistance("DTW", "m") + self.bearing = BoatValueAngle("BTW", "kn") + + # Umgebung + self.temp_water = BoatValueTemperature("WTemp", "°C") + self.temp_air = BoatValueTemperature("xdrTemp", "°C") + self.pressure = BoatValuePressure("xdrPress", "hPa") + self.humidity = BoatValueHumidity("xdrHum", "%") + self.temp = {} # Erweiterte Temperaturdaten + self.press = {} # Erweiterte Druckdaten + + # Sonderdaten + self.rotk = BoatValueAngle("xdrRotK", "deg") # Kielrotation + + # Maschinen + self.engine = {} + + # Tanks + self.tank = {} + + # Mehrere getrennte Batteriekreise + # - Starter + # - Verbrauchen + # - Ankerwinsch / Bugstrahlruder + + # Stromerzeugung + # Solarleistung + # Generatorleistung + # Benzingenerator + # Windgenerator + # Wasser-/Schleppgenerator + # Maschine Rekuperation + + # Satelliten + self.sat = {} + + # Zeitreihen für diverse Daten + self.history = {} + + self.valref = { + 'AWA': self.awa, + 'AWS': self.aws, + 'BTW': self.bearing, + 'COG': self.cog, + 'DBT': self.dbt, + 'DTW': self.wpdist, + 'GPSD': self.gpsd, + 'GPST': self.gpst, + 'LAT': self.lat, + 'LON': self.lon, + 'PRPOS': self.prpos, + 'PTCH': self.pitch, + 'RPOS': self.rpos, + 'ROLL': self.roll, + 'SOG': self.sog, + 'STW': self.stw, + 'TWD': self.twd, + 'TWS': self.tws, + 'WTemp': self.temp_water, + 'WPLat': self.wplat, + 'WPLon': self.wplon, + 'XTE': self.xte, + 'xdrRotK': self.rotk, + 'xdrVBat': self.voltage, + 'xdrTemp': self.temp_air, + 'xdrPress': self.pressure, + 'xdrHum': self.humidity, + 'YAW': self.yaw + } + + def addTank(self, instance): + self.tank[instance] = Tank(instance) + + def addEngine(self, instance): + self.engine[instance] = Engine(instance) + + def addSensor(self, sensortype, instance): + if sensortype == 'temp': + if not instance in self.temp: + self.temp[instance] = BoatValueTemperature() + else: + raise ValueError(f"duplicate key '{instance}'") + elif sensortype == 'press': + if not instance in self.press: + self.press[instance] = BoatValuePressure() + else: + raise ValueError(f"duplicate key '{instance}'") + + def updateSatellite(self, prn_num, elevation, azimuth, snr, rres, status): + if not prn_num in self.sat: + self.sat[prn_num] = Satellite(prn_num) + self.sat[prn_num].elevation = elevation + self.sat[prn_num].azimuth = azimuth + self.sat[prn_num].snr = snr + self.sat[prn_num].rres = rres + self.sat[prn_num].status = status + self.sat[prn_num].lastseen = time.time() + + def __str__(self): + out = "Boat Data\n" + out += f" Voltage: {self.voltage}\n" + out += f" Latitude: {self.lat.value}\n" + out += f" Longitude: {self.lon.value}\n" + out += f" SOG: {self.sog}\n" + for e in self.engine.values(): + print(e) + for t in self.tank.values(): + print(t) + out += " Satellite info\n" + for s in self.sat.values(): + out += str(s) + return out + + def updateValid(self, age=None): + # age: Alter eines Meßwerts in Sekunden + if not age: + age = self.maxage + t = time.time() + for v in vars(self).values(): + if isinstance(v,BoatValue): + if t - v.timestamp > age: + v.valid = False + + def getRef(self, shortname): + ''' + Referenz auf ein BoatValue-Objekt + ''' + try: + bv = self.valref[shortname] + except KeyError: + bv = None + return bv + + def getValue(self, shortname): + ''' + Wert aufgrund textuellem Kurznamen zurückliefern + ''' + try: + value = self.valref[shortname].value + except KeyError: + value = None + return value + + def setValue(self, shortname, newvalue): + ''' + Rückgabewert True bei erfolgreichem Speichern des Werts + ''' + if not shortname in self.valref: + return False + field = self.valref[shortname] + field.value = newvalue + field.timestamp = time.time() + field.valid = True + return True + + def enableSimulation(self): + self.simulation = True + for v in self.valref.values(): + v.simulated = True + + def addHistory(self, history, htype): + """ + htype: press, temp, hum + """ + self.history[htype] = history diff --git a/nmea2000/device.py b/nmea2000/device.py new file mode 100644 index 0000000..06a950f --- /dev/null +++ b/nmea2000/device.py @@ -0,0 +1,63 @@ + +''' +Platzhalter WIP +- ausprogrammieren nach Bedarf +Geräteliste + - wird regelmäßig aktualisiert + +''' + +import time +import struct + +class Device(): + + def __init__(self, address): + #WIP + #Felder können sich noch ändern! + self.address = address + self.instance = 0 # default 0 + self.sysinstance = 0 # used with bridged networks, default 0 + self.lastseen = time.time() + self.uniqueid = None + self.manufacturercode = None + self.industrygroup = None + self.name = None # User defined device name + self.product = None # Product name + self.productcode = None # Product code + self.devicefunction = None + self.deviceclass = None + self.serial = None + self.modelvers = None # Hardware Version + self.softvers = None # Current Software Version + self.n2kvers = None # NMEA2000 Network Message Database Version + self.certlevel = None # NMEA2000 Certification Level + self.loadequiv = None # NMEA2000 LEN + + # Configuration Info + self.instdesc1 = None + self.instdesc2 = None + self.manufinfo = None + + def getName(self): + # NAME errechnen aus den Claim-Daten + # TODO Das hier ist noch fehlerhaft! + data = bytearray() + data.append((self.deviceclass << 4) | (self.devicefunction & 0x0f)) + data.extend(struct.pack(' self.size: + # too old start new + self.clearfile + self.head = 0 + else: + # usable data found, fix missing + self.fp.seek(32) + data = self.fp.read(self.bytesize * self.size) + i = 0 + for d in range(0, self.size, self.bytesize): + if self.bytesize == 1: + self.buf[i] = data[d] + elif self.bytesize == 2: + self.buf[i] = data[d] + data[d+1] * 256 + elif self.bytesize == 3: + self.buf[i] = data[d] + (data[d+1] << 8) + (data[d+2] << 16) + elif self.bytesize == 4: + self.buf[i] = data[d] + (data[d+1] << 8) + (data[d+2] << 16) + (data[d+3] << 24) + i += 1 + # add empty data for missing steps + for s in range(missing): + self.add(0) + return True + + def finish(self): + if not self.fp.closed: + self.fp.close() + + def add(self, value): + # check if add request perhaps too early + timestamp = int(time.time()) + if timestamp - self.headdate < self.dt * 0.98: # a little bit of tolerance + print("add new value too early, ignored") + return False + self.headdate = timestamp + self.buf[self.head] = value + self.updatefile(value) + self.head += 1 + if self.head == self.size: + self.head = 0 + return True + + def get(self): + """ + Return buffer in linear sequence, newest values first + """ + for i in range(self.head -1, -1, -1): + yield self.buf[i] + for i in range(self.size - 1, self.head -1, -1): + yield self.buf[i] + + def getvalue(self, delta): + """ + Return a single value dt seconds ago + delta has to be smaller than self.dt * self.size + TODO check if value is missing, perhaps allow tolerance (+/- ) + """ + index = self.head - abs(delta) // self.dt + if index < 0: + index = self.size - index - 1 + return self.buf[index] + + def getvalue3(self, delta): + """ + same as getvalue but calculate mean with two neighbor values + TODO check for missing values (=0) + """ + index = self.head - abs(delta) // self.dt + if index < 0: + index = self.size - index - 1 + ixprev = index - 1 + if ixprev < 0: + ixprev = self.size - 1 + ixnext = index + 1 + if ixnext > self.size - 1: + ixnext = 0 + return round((self.buf[ix] + self.buf[ixprev] + self.buf[ixnext]) / 3) + + def setname(self, newname): + """ + set new name in buffer and storage backend + """ + self.name = newname[:16] or '' + self.fp.seek(4) + fp.write(self.name.ljust(16, ' ').encode()) + + def createfile(self): + """" + Creates new file from current buffer + """ + with open(self.filename, 'wb') as fp: + fp.write(self.magic) + fp.write(self.name.ljust(16, ' ').encode()) + fp.write(struct.pack('B', self.bytesize)) + fp.write(struct.pack('H', self.size)) + fp.write(struct.pack('H', self.dt)) + fp.write(struct.pack('I', self.headdate)) + fp.write(struct.pack('H', self.head)) + fp.write(b"\xff") # header end + if self.bytesize == 1: + fp.write(bytes(self.buf)) + elif self.bytesize == 2: + for val in self.buf: + fp.write(struct.pack('H', val)) + elif self.bytesize == 3: + for val in self.buf: + fp.write((val >> 16) & 0xff) + fp.write((val >> 8) & 0xff) + fp.write(val & 0xff) + elif self.bytesize == 4: + for val in self.buf: + fp.write(struct.pack('I', val)) + return True + + def checkfile(self): + """ + Check if file header matches buffer metadata + Name is not taken into account because it is optional + """ + with open(self.filename, 'rb') as fp: + header = fp.read(32) + magic = header[:4] + if not (header[:4] == self.magic): + print(f"Invalid magic: {magic}") + return False + bs = header[20] + if not (bs == self.bytesize): + print(f"Invalid bytesize: {bs}") + return False + vc = struct.unpack('H', header[21:23])[0] + if not (vc == self.size): + eprint(f"Invalid value count: {vc}") + return False + ts = struct.unpack('H', header[23:25])[0] + if not (ts == self.dt): + eprint(f"Invalid time step: {ts}") + return False + return True + + def updatefile(self, value): + """ + Write value to file and update header accordingly + """ + pos = 32 + self.head * self.bytesize + self.fp.seek(25) + self.fp.write(struct.pack('IH', self.headdate, self.head + 1)) + self.fp.seek(pos) + if self.bytesize == 1: + self.fp.write(struct.pack('B', value)) + elif self.bytesize == 2: + self.fp.write(struct.pack('H', value)) + elif self.bytesize == 3: + self.fp.write((value >> 16) & 0xff) + self.fp.write((value >> 8) & 0xff) + self.fp.write(value & 0xff) + elif self.bytesize == 4: + self.fp.write(struct.pack('I', value)) + + def clearfile(self): + """ + Clear data part of history file + """ + self.fp.seek(25) + self.fp.write(struct.pack('IH', int(time.time()), 0)) + fp.seek(32) + for p in range(32, self.size * self.bytesize): + fp.write(0) + +class History(): + """ + A history can consist of different time series with different + temporal resolutions + + TODO implement type (e.g. pressure humidity temp etc.) to identify data + """ + def __init__(self, basename, delta_min): + self.delta_t = delta_min # smallest unit of time in the series + self.series = dict() + self.basepath = "/tmp" + self.basename = basename + + def __str__(self): + out = f"History {self.basename} (min. {self.delta_t}s) in {self.basepath}\n" + n = 0 + for ser in self.series.values(): + out += f" Series: {ser.name} {ser.dt}s {ser.filename}\n" + n += 1 + if n == 0: + out += " No series found\n" + return out + + def addSeries(self, name, size, delta_t): + """ + Check whether a series already exists and throw an error if so. + The new delta t must also be divisible by delta_min + """ + if delta_t in self.series: + raise KeyError(f"Error: delta t {delta_t} already exists") + if delta_t < self.delta_t: + raise ValueError(f"Error: delta t {delta_t} too small, minimum is {self.delta_t}") + if delta_t % self.delta_t != 0: + raise ValueError(f"Error: delta t have to be a multiple of {self.delta_t}") + hb = HistoryBuffer(name, size, delta_t) + histfilename = f"hb{self.basename}_{size}-{delta_t}.dat" + hb.filename = os.path.join(self.basepath, histfilename) + self.series[delta_t] = hb + return hb + + def clear(self): + # Clear all time series buffer + self.series.clear() diff --git a/nmea2000/lookup.py b/nmea2000/lookup.py new file mode 100644 index 0000000..a234c98 --- /dev/null +++ b/nmea2000/lookup.py @@ -0,0 +1,563 @@ +# Lookup tables + +accesslevel = { + 0: "Locked", + 1: "unlocked level 1", + 2: "unlocked level 2" +} + +alarmgroup = { + 0: "Instrument", + 1: "Autopilot", + 2: "Radar", + 3: "Chart Plotter", + 4: "AIS" +} + +alarmid = { + 0: "No Alarm", + 1: "Shallow Depth", + 2: "Deep Depth", + 3: "Shallow Anchor", + 4: "Deep Anchor", + 5: "Off Course", + 6: "AWA High", + 7: "AWA Low", + 8: "AWS High", + 9: "AWS Low", + 10: "TWA High", + 11: "TWA Low", + 12: "TWS High", + 13: "TWS Low", + 14: "WP Arrival", + 15: "Boat Speed High", + 16: "Boat Speed Low", + 17: "Sea Temperature High", + 18: "Sea Temperature Low", + 19: "Pilot Watch", + 20: "Pilot Off Course", + 21: "Pilot Wind Shift", + 22: "Pilot Low Battery", + 23: "Pilot Last Minute Of Watch", + 24: "Pilot No NMEA Data", + 25: "Pilot Large XTE", + 26: "Pilot NMEA DataError", + 27: "Pilot CU Disconnected", + 28: "Pilot Auto Release", + 29: "Pilot Way Point Advance", + 30: "Pilot Drive Stopped", + 31: "Pilot Type Unspecified", + 32: "Pilot Calibration Required", + 33: "Pilot Last Heading", + 34: "Pilot No Pilot", + 35: "Pilot Route Complete", + 36: "Pilot Variable Text", + 37: "GPS Failure", + 38: "MOB", + 39: "Seatalk1 Anchor", + 40: "Pilot Swapped Motor Power", + 41: "Pilot Standby Too Fast To Fish", + 42: "Pilot No GPS Fix", + 43: "Pilot No GPS COG", + 44: "Pilot Start Up", + 45: "Pilot Too Slow", + 46: "Pilot No Compass", + 47: "Pilot Rate Gyro Fault", + 48: "Pilot Current Limit", + 49: "Pilot Way Point Advance Port", + 50: "Pilot Way Point Advance Stbd", + 51: "Pilot No Wind Data", + 52: "Pilot No Speed Data", + 53: "Pilot Seatalk Fail1", + 54: "Pilot Seatalk Fail2", + 55: "Pilot Warning Too Fast To Fish", + 56: "Pilot Auto Dockside Fail", + 57: "Pilot Turn Too Fast", + 58: "Pilot No Nav Data", + 59: "Pilot Lost Waypoint Data", + 60: "Pilot EEPROM Corrupt", + 61: "Pilot Rudder Feedback Fail", + 62: "Pilot Autolearn Fail1", + 63: "Pilot Autolearn Fail2", + 64: "Pilot Autolearn Fail3", + 65: "Pilot Autolearn Fail4", + 66: "Pilot Autolearn Fail5", + 67: "Pilot Autolearn Fail6", + 68: "Pilot Warning Cal Required", + 69: "Pilot Warning OffCourse", + 70: "Pilot Warning XTE", + 71: "Pilot Warning Wind Shift", + 72: "Pilot Warning Drive Short", + 73: "Pilot Warning Clutch Short", + 74: "Pilot Warning Solenoid Short", + 75: "Pilot Joystick Fault", + 76: "Pilot No Joystick Data", + 80: "Pilot Invalid Command", + 81: "AIS TX Malfunction", + 82: "AIS Antenna VSWR fault", + 83: "AIS Rx channel 1 malfunction", + 84: "AIS Rx channel 2 malfunction", + 85: "AIS No sensor position in use", + 86: "AIS No valid SOG information", + 87: "AIS No valid COG information", + 88: "AIS 12V alarm", + 89: "AIS 6V alarm", + 90: "AIS Noise threshold exceeded channel A", + 91: "AIS Noise threshold exceeded channel B", + 92: "AIS Transmitter PA fault", + 93: "AIS 3V3 alarm", + 94: "AIS Rx channel 70 malfunction", + 95: "AIS Heading lost/invalid", + 96: "AIS internal GPS lost", + 97: "AIS No sensor position", + 98: "AIS Lock failure", + 99: "AIS Internal GGA timeout", + 100: "AIS Protocol stack restart", + 101: "Pilot No IPS communications", + 102: "Pilot Power-On or Sleep-Switch Reset While Engaged", + 103: "Pilot Unexpected Reset While Engaged", + 104: "AIS Dangerous Target", + 105: "AIS Lost Target", + 106: "AIS Safety Related Message (used to silence)", + 107: "AIS Connection Lost", + 108: "No Fix" +} + +alarmstatus = { + 0: "Alarm condition not met", + 1: "Alarm condition met and not silenced", + 2: "Alarm condition met and silenced" +} + +# Class 1, 2, Level A, B? +certlevel = { # Not yet verified! + 0: "None", + 1: "Certified", + 2: "Not applicable" +} + +control = { + 0: "ACK", + 1: "NAK", + 2: "Access Denied", + 3: "Address Busy" +} + +deviceclass = { + 0: "Reserved for 2000 Use", + 10: "System tools", + 20: "Safety systems", + 25: "Internetwork device", + 30: "Electrical Distribution", + 35: "Electrical Generation", + 40: "Steering and Control surfaces", + 50: "Propulsion", + 60: "Navigation", + 70: "Communication", + 75: "Sensor Communication Interface", + 80: "Instrumentation/general systems", # deprecated + 85: "External Environment", + 90: "Internal Environment", + 100: "Deck + cargo + fishing equipment systems", + 110: "Human Interface", + 120: "Display", + 125: "Entertainment" +} + +devicefunction = { # dependent of deviceclass above + 0: {}, + 10: {130: "Diagnostic", + 140: "Bus Traffic Logger" + }, + 20: {110: "Alarm Enunciator", + 130: "Emergency Positon Indicating Radia Beacon (EPIRB)", + 135: "Man Overboard", + 140: "Voyage Date Recorder", + 150: "Camera" + }, + 25: {130: "PC Gateway", + 131: "NMEA 2000 to Analog Gateway", + 132: "Analog to NMEA 2000 Gateway", + 135: "NMEA 0183 Gateway", + 140: "Router", + 150: "Bridge", + 160: "Repeater" + }, + 30: {130: "Binary Event Monitor", + 140: "Load Controller", + 141: "AC/DC Input", + 150: "Function Controller" + }, + 35: {140: "Engine", + 141: "DC Generator/Alternator", + 142: "Solar Panel (Solar Array)", + 143: "Wind Generator (DC)", + 144: "Fuel Cell", + 145: "Network Power Supply", + 151: "AC Generator", + 152: "AC Bus", + 153: "AC Mains (Utility/Shore)", + 154: "AC Output", + 160: "Power Converter - Battery Charger", + 161: "Power Converter - Battery Charger+Inverter", + 162: "Power Converter - Inverter", + 163: "Power Converter DC", + 170: "Battery", + 180: "Engine Gateway" + }, + 40: {130: "Follow-up Controller", + 140: "Mode Controller", + 150: "Autopilot", + 155: "Rudder", + 160: "Heading Sensors", # deprecated + 170: "Trim (Tabs)/Interceptors", + 180: "Attitude (Pitch, Roll, Yaw) Control" + }, + 50: {130: "Engineroom Monitoring", # deprecated + 140: "Engine", + 141: "DC Generator/Alternator", + 150: "Engine Controller", # deprecated + 151: "AC Generator", + 155: "Motor", + 160: "Engine Gateway", + 165: "Transmission", + 170: "Throttle/Shift Control", + 180: "Actuator", # deprecated + 190: "Gauge Interface", #deprecated + 200: "Gauge Large", # deprecated + 210: "Gauge Small" # deprecated + }, + 60: {130: "Bottom Depth", + 135: "Bottom Depth/Speed", + 140: "Ownship Attitude", + 145: "Ownship Position (GNSS)", + 150: "Ownship Position (Loran C)", + 155: "Speed", + 160: "Turn Rate Indicator", # deprecated + 170: "Integrated Navigaton", # deprecated + 175: "Integrated Navigation System", + 190: "Navigation Management", + 195: "Automatic Identification System (AIS)", + 200: "Radar", + 201: "Infrared Imaging", + 205: "ECDIS", # deprecated + 210: "ECS", # deprecated + 220: "Direction Finder", # deprecated + 230: "Voyage Status" + }, + 70: {130: "EPIRB", # deprecated + 140: "AIS", # deprecated + 150: "DSC", # deprecated + 160: "Data Receiver/Transceiver", + 170: "Satellite", + 180: "Radio-telephone (MF/HF)", # deprecated + 190: "Radiotelephone" + }, + 75: {130: "Temperature", + 140: "Pressure", + 150: "Fluid Level", + 160: "Flow", + 170: "Humidity" + }, + 80: {130: "Time/Date Systems", # deprecated + 140: "VDR", # deprecated + 150: "Integrated Instrumentation", # deprecated + 160: "General Purpose Displays", # deprecated + 170: "General Sensor Box", # deprecated + 180: "Wheather Instruments", # deprecated + 190: "Transducer/General", # deprecated + 200: "NMEA 0183 Converter" # deprecated + }, + 85: {130: "Athmospheric", + 140: "Aquatic" + }, + 90: {130: "HVAC" + }, + 100: {130: "Scale (Catch)" + }, + 110: { # NEW? WIP + }, + 120: {130: "Display", + 140: "Alarm Enunciator" + }, + 125: {130: "Multimedia Player", + 140: "Multimedia Controller" + } +} + +fluidtype = { + 0: "Fuel", + 1: "Water", + 2: "Gray Water", + 3: "Live Well", + 4: "Oil", + 5: "Black Water", + 6: "Fuel Gasoline", + 14: "Error", + 15: "Unavailable" +} + +industrygroup = { + 0: "Global", + 1: "Highway", + 2: "Agriculture", + 3: "Construction", + 4: "Marine", + 5: "Industrial" +} + +manufacturer = { + 69: "ARKS Enterprises, Inc.", + 78: "FW Murphy/Enovation Controls", + 80: "Twin Disc", + 85: "Kohler Power Systems", + 88: "Hemisphere GPS Inc", + 116: "BEP Marine", + 135: "Airmar", + 137: "Maretron", + 140: "Lowrance", + 144: "Mercury Marine", + 147: "Nautibus Electronic GmbH", + 148: "Blue Water Data", + 154: "Westerbeke", + 161: "Offshore Systems (UK) Ltd.", + 163: "Evinrude/BRP", + 165: "CPAC Systems AB", + 168: "Xantrex Technology Inc.", + 172: "Yanmar Marine", + 174: "Volvo Penta", + 175: "Honda Marine", + 176: "Carling Technologies Inc. (Moritz Aerospace)", + 185: "Beede Instruments", + 192: "Floscan Instrument Co. Inc.", + 193: "Nobletec", + 198: "Mystic Valley Communications", + 199: "Actia", + 200: "Honda Marine", + 201: "Disenos Y Technologia", + 211: "Digital Switching Systems", + 215: "Xintex/Atena", + 224: "EMMI NETWORK S.L.", + 225: "Honda Marine", + 228: "ZF", + 229: "Garmin", + 233: "Yacht Monitoring Solutions", + 235: "Sailormade Marine Telemetry/Tetra Technology LTD", + 243: "Eride", + 250: "Honda Marine", + 257: "Honda Motor Company LTD", + 272: "Groco", + 273: "Actisense", + 274: "Amphenol LTW Technology", + 275: "Navico", + 283: "Hamilton Jet", + 285: "Sea Recovery", + 286: "Coelmo SRL Italy", + 295: "BEP Marine", + 304: "Empir Bus", + 305: "NovAtel", + 306: "Sleipner Motor AS", + 307: "MBW Technologies", + 311: "Fischer Panda", + 315: "ICOM", + 328: "Qwerty", + 329: "Dief", + 341: "Böning Automationstechnologie GmbH & Co. KG", + 345: "Korean Maritime University", + 351: "Thrane and Thrane", + 355: "Mastervolt", + 356: "Fischer Panda Generators", + 358: "Victron Energy", + 370: "Rolls Royce Marine", + 373: "Electronic Design", + 374: "Northern Lights", + 378: "Glendinning", + 381: "B & G", + 384: "Rose Point Navigation Systems", + 385: "Johnson Outdoors Marine Electronics Inc Geonav", + 394: "Capi 2", + 396: "Beyond Measure", + 400: "Livorsi Marine", + 404: "ComNav", + 409: "Chetco", + 419: "Fusion Electronics", + 421: "Standard Horizon", + 422: "True Heading AB", + 426: "Egersund Marine Electronics AS", + 427: "em-trak Marine Electronics", + 431: "Tohatsu Co, JP", + 437: "Digital Yacht", + 438: "Comar Systems Limited", + 440: "Cummins", + 443: "VDO (aka Continental-Corporation)", + 451: "Parker Hannifin aka Village Marine Tech", + 459: "Alltek Marine Electronics Corp", + 460: "SAN GIORGIO S.E.I.N", + 466: "Veethree Electronics & Marine", + 467: "Humminbird Marine Electronics", + 470: "SI-TEX Marine Electronics", + 471: "Sea Cross Marine AB", + 475: "GME aka Standard Communications Pty LTD", + 476: "Humminbird Marine Electronics", + 478: "Ocean Sat BV", + 481: "Chetco Digitial Instruments", + 493: "Watcheye", + 499: "Lcj Capteurs", + 502: "Attwood Marine", + 503: "Naviop S.R.L.", + 504: "Vesper Marine Ltd", + 510: "Marinesoft Co. LTD", + 517: "NoLand Engineering", + 518: "Transas USA", + 529: "National Instruments Korea", + 532: "Onwa Marine", + 571: "Marinecraft (South Korea)", + 573: "McMurdo Group aka Orolia LTD", + 578: "Advansea", + 579: "KVH", + 580: "San Jose Technology", + 583: "Yacht Control", + 586: "Suzuki Motor Corporation", + 591: "US Coast Guard", + 595: "Ship Module aka Customware", + 600: "Aquatic AV", + 605: "Aventics GmbH", + 606: "Intellian", + 612: "SamwonIT", + 614: "Arlt Tecnologies", + 637: "Bavaria Yacts", + 641: "Diverse Yacht Services", + 644: "Wema U.S.A dba KUS", + 645: "Garmin", + 658: "Shenzhen Jiuzhou Himunication", + 688: "Rockford Corp", + 704: "JL Audio", + 715: "Autonnic", + 717: "Yacht Devices", + 734: "REAP Systems", + 735: "Au Electronics Group", + 739: "LxNav", + 743: "DaeMyung", + 744: "Woosung", + 773: "Clarion US", + 776: "HMI Systems", + 777: "Ocean Signal", + 778: "Seekeeper", + 781: "Poly Planar", + 785: "Fischer Panda DE", + 795: "Broyda Industries", + 796: "Canadian Automotive", + 797: "Tides Marine", + 798: "Lumishore", + 799: "Still Water Designs and Audio", + 802: "BJ Technologies (Beneteau)", + 803: "Gill Sensors", + 811: "Blue Water Desalination", + 815: "FLIR", + 824: "Undheim Systems", + 838: "TeamSurv", + 844: "Fell Marine", + 847: "Oceanvolt", + 862: "Prospec", + 868: "Data Panel Corp", + 890: "L3 Technologies", + 894: "Rhodan Marine Systems", + 896: "Nexfour Solutions", + 905: "ASA Electronics", + 909: "Marines Co (South Korea)", + 911: "Nautic-on", + 930: "Ecotronix", + 962: "Timbolier Industries", + 963: "TJC Micro", + 968: "Cox Powertrain", + 969: "Blue Seas", + 1850: "Teleflex Marine (SeaStar Solutions)", + 1851: "Raymarine", + 1852: "Navionics", + 1853: "Japan Radio Co", + 1854: "Northstar Technologies", + 1855: "Furuno", + 1856: "Trimble", + 1857: "Simrad", + 1858: "Litton", + 1859: "Kvasar AB", + 1860: "MMP", + 1861: "Vector Cantech", + 1862: "Yamaha Marine", + 1863: "Faria Instruments", + 2001: "Open Boat Projects" +} + +pilotmode = { + 64: "Standby", + 66: "Auto", + 70: "Wind", + 74: "Track" +} + +pressure = { + 0: "Athmospheric", + 1: "Water", + 2: "Steam", + 3: "Compressed Air", + 4: "Hydraulic", + 5: "Filter", + 6: "AltimeterSetting", + 7: "Oil", + 8: "Fuel" +} + +prnusage = { + 0: "Not Tracked", + 1: "Tracked", + 2: "Used", + 3: "Not tracked+Diff", + 4: "Tracked+Diff", + 5: "Used+Diff", + 14: "Error", + 15: "No Selection" +} + +speedwater = { + 0: "Paddle wheel", + 1: "Pitot tube", + 2: "Doppler", + 3: "Correlation (ultra sound)", + 4: "Electro Magnetic" +} + +timesource = { + 0: "GPS", + 1: "GLONASS", + 2: "Radio Station", + 3: "Local Cesium clock", + 4: "Local Rubidium clock", + 5: "Local Crystal clock" +} + +temperature = { + 0: "Sea Temperature", + 1: "Outside Temperature", + 2: "Inside Temperature", + 3: "Engine Room Temperature", + 4: "Main Cabin Temperature", + 5: "Live Well Temperature", + 6: "Bait Well Temperature", + 7: "Refrigeration Temperature", + 8: "Heating System Temperature", + 9: "Dew Point Temperature", + 10: "Apparent Wind Chill Temperature", + 11: "Theoretical Wind Chill Temperature", + 12: "Heat Index Temperature", + 13: "Freezer Temperature", + 14: "Exhaust Gas Temperature", + 15: "Shaft Seal Temperature" +} + +xtemode = { + 0: "auto", + 1: "differential", + 2: "estimated", + 3: "simulation", + 4: "manual" +} diff --git a/nmea2000/mavg.py b/nmea2000/mavg.py new file mode 100644 index 0000000..604be4e --- /dev/null +++ b/nmea2000/mavg.py @@ -0,0 +1,25 @@ +''' +Moving Average + +Sortierung einer Liste nach Alter? FIFO? + +''' + +import time + +class mAvg(): + + def __init__(self, interval): + self.interval = interval + self.avg = None + self.data = [] + + def addVal(self, value): + self.data.append((time.time(), value)) + + def setInterval(self, interval): + pass + + def getAvg(self): + return self.avg + \ No newline at end of file diff --git a/nmea2000/parser.py b/nmea2000/parser.py new file mode 100644 index 0000000..c816918 --- /dev/null +++ b/nmea2000/parser.py @@ -0,0 +1,224 @@ +''' + +PGNs verarbeiten + +''' + +import struct + +def parse_126996(buf, source, device): + # Product information + n2kvers = (buf[0] + buf[1] * 256) / 1000 + prodcode = buf[2] + buf[3] * 256 + modelid = buf[4:36] # 256bit modelid ascii text + softvers = buf[36:68] # 256bit software version code ascii text + modelvers = buf[68:100] # 256bit model version ascii text + serial = buf[100:132] # 256bit model serial code ascii text + # Füllzeichen entfernen. 0x20, 0xff, '@' für AIS + # Die N2K-Bibliothek von ttlappalainen liefert 0xff + modelid = modelid.rstrip(b'\xff') + softvers = softvers.rstrip(b'\xff') + modelvers = modelvers.rstrip(b'\xff') + serial = serial.rstrip(b'\xff') + # Übertragen in die Geräteliste + devices[source].n2kvers = n2kvers + devices[source].productcode = prodcode + devices[source].modelvers = modelvers.decode('ascii').rstrip() + devices[source].softvers = softvers.decode('ascii').rstrip() + devices[source].product = modelid.decode('ascii').rstrip() + devices[source].serial = serial.decode('ascii').rstrip() + devices[source].certlevel = buf[132] + devices[source].loadequiv = buf[133] + +def parse_126998(buf, source, device): + # Configuration information + # Installation Description 1 + txtlen = buf[0] + if txtlen > 2: + device.instdesc1 = buf[2:txtlen].decode('ascii') + p = txtlen + else: + device.instdesc1 = "" + p = 2 + # Installation Description 2 + txtlen = buf[p] + if txtlen > 2: + device.instdesc2 = buf[p+2:p+txtlen].decode('ascii') + p += txtlen + else: + device.instdesc2 = "" + p += 2 + # Manufacturer Info + txtlen = buf[p] + if txtlen > 2: + device.manufinfo = buf[p+2:p+txtlen].decode('ascii') + else: + device.manufinfo = "" + +def parse_127257(buf, boatdata): + # Attitude + sid = buf[0] + yaw = struct.unpack_from('> 4 + boatdata.tank[instance].capacity = struct.unpack_from('> 6 # 0: true, 1: magnetic, 2: error + cog = struct.unpack_from('> 4 + navterm = buf[1] & 0x03 + xte = struct.unpack_from(' self.age_interval: + self.heap[key][0] -= 1 + + def is_empty(self): + return len(self.heap) == 0 diff --git a/nmea2000/receiver.py b/nmea2000/receiver.py new file mode 100644 index 0000000..4108533 --- /dev/null +++ b/nmea2000/receiver.py @@ -0,0 +1,61 @@ +''' + +Momentan nur Idee + +Verarbeiten von Frames + +Nur Empfang von Paketen / Frames +- single +- fast +- transport + +fast packets können parallel von mehreren Quellen verarbeitet werden + +''' + +import math + +class FpReceiver(): + + def __init__(self): + self.sc0 = {} + self.nf = {} + self.frame = {} + self.wip = [] + + def receive(self, data, source) + ''' + Liefert True wenn Paket komplett empfangen wurde + ''' + sc = (data[0] & 0xf0) >> 5 + fc = data[0] & 0x1f + if not source in self.wip: + # Erster Frame + if fc != 0: + # unbekannte Herkunft und kein Startframe + continue + self.sc0[source] = sc + datalen = data[1] + self.nf[source] = math.ceil((datalen - 6) / 7) + 1 # Anzahl Frames + self.frame[source] = {fc : data[2:]} # Ersten Frame merken + else: + # Folgeframes + if (sc == self.sc0[source]): + # TODO prüfe ob der Framecounter fc schon vorgekommen ist + if not fc in self.frame[source]: + self.frame[source][fc] = data[1:8] + else: + # TODO Fehler im Fast-Packet: doppelter fc! + raise('Frame error: duplicate fc') + if len(self.frame[source]) == self.nf[source]: + self.wip.remove(source) + return True + return False + + def getPacket(self, source) + # TODO Frames in der richtigen reihenfolge zusammensetzen + packet = bytearray() + for frame in sorted(self.frame.items()): + print(frame) + #packet.extend() + return packet diff --git a/nmea2000/routing.py b/nmea2000/routing.py new file mode 100644 index 0000000..773fb25 --- /dev/null +++ b/nmea2000/routing.py @@ -0,0 +1,17 @@ +''' +Routen und Wegepunkte +''' +class Waypoint(): + def __init__(self, number, name): + self.number = number + self.name = name + self.lat = None + self.lon = None + +class Route(): + def __init__(self, number, name) + self.number = number + self.name = name + self.wps = dict() + def getActiveWP(self): + return None diff --git a/nmea2000/valdesc.py b/nmea2000/valdesc.py new file mode 100644 index 0000000..fced0e4 --- /dev/null +++ b/nmea2000/valdesc.py @@ -0,0 +1,179 @@ +''' +Lange Beschreibung der Daten, mehrsprachig + +''' +desc = { + "ALT": { + "en": "Altitude", + "de": "Höhe über Grund" + }, + "AWA": { + "en": "Apparant Wind Angle", + "de": "Scheinbare Windrichtung" + }, + "AWS": { + "en": "Apparant Wind Speed", + "de": "Scheinbare Windgeschwindigkeit" + }, + "BTW": { + "en": "Bearing To Waypoint", + "de": "Kurs zum nächsten Wegepunkt" + }, + "COG": { + "en": "Course Over Ground", + "de": "Kurs über Grund" + }, + "DBS": { + "en": "Depth Below Surface", + "de": "Tiefe unter Wasseroberfläche" + }, + "DBT": { + "en": "Depth Below Transducer", + "de": "Tiefe unter Sensor" + }, + "DEV": { + "en": "Deviation", + "de": "Kursabweichung" + }, + "DTW": { + "en": "Distance To Waypoint", + "de": "Entfernung zum nächsten Wegepunkt" + }, + "GPSD": { + "en": "GPS Date", + "de": "GPS-Datum" + }, + "GPST": { + "en": "GPS Time", + "de": "GPS-Zeit" + }, + "HDM": { + "en": "Magnetic Heading", + "de": "Magnetischer Kurs" + }, + "HDT": { + "en": "Heading", + "de": "Wahrer rechtweisender Kurs" + }, + "HDOP": { + "en": "Horizontal Dilation Of Position", + "de": "Positionsgenauigkeit in der Horizontalen" + }, + "LAT": { + "en": "Latitude", + "de": "Geographische Breite" + }, + "LON": { + "en": "Longitude", + "de": "Geographische Länge" + }, + "Log": { + "en": "Logged distance", + "de": "Entfernung" + }, + "MaxAws": { + "en": "Maximum Apperant Wind Speed", + "de": "Maximum der relativen Windgeschwindigkeit" + }, + "MaxTws": { + "en": "Maximum True Wind Speed", + "de": "Maximum der wahren Windgeschwindigkeit" + }, + "PDOP": { + "en": "Position dilation", + "de": "Positionsgenauigkeit im Raum" + }, + "PRPOS": { + "en": "Secondary Rudder Position", + "de": "Auslenkung Sekundärruder" + }, + "ROT": { + "en": "Rotation", + "de": "Drehrate" + }, + "RPOS": { + "en": "Rudder Position", + "de": "Auslenkung Ruder" + }, + "SOG": { + "en": "Speed Over Ground", + "de": "Geschwindigkeit über Grund" + }, + "STW": { + "en": "Speed Through Water", + "de": "Geschwindigkeit durch das Wasser" + }, + "SatInfo": { + "en": "Satellit Info", + "de": "Anzahl der sichtbaren Satelliten" + }, + "TWD": { + "en": "True Wind Direction", + "de": "Wahre Windrichtung" + }, + "TWS": { + "en": "True Wind Speed", + "de": "Wahre Windgeschwindigkeit" + }, + "TZ": { + "en": "Timezone", + "de": "Zeitzone" + }, + "TripLog": { + "en": "Trip Log", + "de": "Tages-Entfernungszähler" + }, + "VAR": { + "en": "Course Variation", + "de": "Abweichung vom Sollkurs" + }, + "VDOP": { + "en": "Vertical Dilation Of Position", + "de": "Positionsgenauigkeit in der Vertikalen" + }, + "WPLat": { + "en": "Waypoint Latitude", + "de": "Geo. Breite des Wegpunktes" + }, + "WPLon": { + "en": "Waypoint Longitude", + "de": "Geo. Länge des Wegpunktes" + }, + "WTemp": { + "en": "Water Temperature", + "de": "Wassertemperatur" + }, + "XTE": { + "en": "Cross Track Error", + "de": "Kursfehler" + }, + + # Sonderwerte + "xdrHum": { + "en": "Humidity", + "de": "Luftfeuchte" + }, + "xdrPress": { + "en": "Pressure", + "de": "Luftdruck" + }, + "xdrRotK": { + "en": "Keel Rotation", + "de": "Kielrotation" + }, + "tdrTemp": { + "en": "Temperature", + "de": "Temperatur" + }, + "xdrVBat": { + "en": "Battery Voltage", + "de": "Batteriespannung" + }, + + # WIP + "xdrCurr": { + "en": "Current", + "de": "Stromstärke" + } + +} diff --git a/obp60.conf b/obp60.conf new file mode 100644 index 0000000..a83c562 --- /dev/null +++ b/obp60.conf @@ -0,0 +1,96 @@ +[system] +systemname = OBP60v +loglevel = 3 +deviceid = 100 +simulation = on +histpath = ~/.local/lib/obp60 + +[bme280] +enabled = true +port = 1 +address = 0x76 + +[gps] +enabled = false +port = /dev/ttyACM0 + +[opencpn] +port = /dev/ttyV1 +navobj = ~/.opencpn/navobj.xml +config = ~/.opencpn/opencpn.conf + +[settings] +timezone = 1 +boat_draft = 1.3 +fuel_tank = 17 +fuel_consumption = 1.5 +water_tank_1 = 47 +water_tank_2 = 50 +battery_voltage = 12 +battery_type = AGM +battery_capacity = 200 +solar_power = 0 +generator_power = 0 + +[units] +length_format = m +distance_format = nm +speed_format = kn +wind_speed_format = ln +temperature_format = C +date_format = ISO + +[display] +hold_values = off +backlight_color = red +flash_led_mode = limit + +[buzzer] +buzzer_gps_error = off +buzzer_gps_fix = off +buzzer_by_limits = off +buzzer_mode = off +buzzer_power = 50 + +[pages] +number_of_pages = 10 +start_page = 1 + +[page1] +type=Voltage + +[page2] +type=Barograph + +[page3] +type=Anchor + +[page4] +type=Autobahn + +[page5] +type=Clock + +[page6] +type=TwoValues +value1=LAT +value2=LON + +[page7] +type=ThreeValues +value1=COG +value2=STW +value3=DBT + +[page8] +type=FourValues +value1=AWA +value2=AWS +value3=COG +value4=STW + +[page9] +type=Rudder + +[page10] +type=SkyView diff --git a/obp60.py b/obp60.py new file mode 100755 index 0000000..2385255 --- /dev/null +++ b/obp60.py @@ -0,0 +1,543 @@ +#!/usr/bin/env python + +''' +Virtuelles Multifunktionsgerät + +Benötigte Pakete: + python3-cairo python3-gi python3-gi-cairo gir1.2-rsvg-2.0 + +Um transparente Darstellung unter Openbox zu erhalten muß xcompmgr +installiert und konfiguriert werden. + +Wenn ein lokaler GPS-Empfänger angeschlossen ist, so kann dieser genutzt +werden. Zusätzlich benötigte Pakete: + python-serial python3-nmea2 + +Für Unterstützung des BME280-Sensors sind die Bibliotheken smbus2 und +bme280 erforderlich. + +Routen und Wegepunkte können von OpenCPN empfangen werden. Dazu muß eine +passende serielle Schnittstelle für NMEA0183-Ausgang definiert werden. +Im System kann diese in der Datei rc.local aktiviert werden: + # Create virtual serial connection + socat pty,rawer,echo=0,group-late=dialout,mode=0660,link=/dev/ttyV0 \ + pty,rawer,echo=0,group-late=dialout,mode=0660,link=/dev/ttyV1 & +OpenCPN sendet dann Datensätze über ttyV0 und dieses Programm +empfängt sie über ttyV1. +Die Wegepunkte werden in OpenCPN im Standard auf 6 Zeichen gekürzt. +Über die Konfigurationsdatei Settings / MaxWaypointNameLength= +ist es möglich einen anderen Wert einzustellen im Bereich zwischen +3 und 32. Lt. NMEA0183 ist die maximale Länge 10 Zeichen. + + +Zeichensätze müssen, sofern sie noch nicht vorhanden sind in +/usr/local/share/fonts abgelegt werden + +Buttonlayout: +[ 1 ] [ 2 ] [ 3 ] [ 4 ] [ 5 ] [ 6 ] + +Button 6 ist reserviert für Beleuchtung ILUM + +Neu +Button 2: Abbruch +Button 3: zurück +Button 4: hoch +Button 5: Ok/Menu + +Verlagerung der Seitenanzeige in den Titel als Ziffer in Klammern + +Vergleich zum Garmin GMI20: +[ Zurück ] [ Hoch ] [ Menü ] [ Runter ] [ on/off ] + + +Button 1 wird für AVG verwendet auf mehreren Seiten +Button 5 wird für Trend TRND verwendet + +Aber sowas von WIP + + +Änderungsprotokoll +================== + +Version Datum Änderung(en) von +-------- ----------- ------------------------------------------------------ ---- +0.1 2024-10-31 Entwicklung begonnen tho +0.2 2024-12-24 Veräffentlichung als Git-Repository tho + + +''' + +import os +import sys +import configparser +from setproctitle import setproctitle, setthreadtitle +import gi +gi.require_version('Gtk', '3.0') +gi.require_version('Rsvg', '2.0') +from gi.repository import GLib, Gtk, Gdk, Rsvg +import cairo +import math +import threading +import can +import serial +import smbus2 +import pynmea2 +import bme280 +import math +import time +from datetime import datetime +from nmea2000 import Device, BoatData, History, HistoryBuffer +from nmea2000 import parser +import pages +import struct + +__author__ = "Thomas Hooge" +__copyright__ = "Copyleft 2024, all rights reversed" +__version__ = "0.2" +__email__ = "thomas@hoogi.de" +__status__ = "Development" + +cfg = { + 'cfgfile': 'obp60.conf', + 'imgpath': os.path.join(sys.path[0], 'images'), + 'deviceid': 100, + 'gps': False, + 'bme280': False +} + +def rxd_n2k(): + setthreadtitle("N2Klistener") + bus = can.Bus(interface='socketcan', channel='can0', bitrate=250000); + wip = False + sc = 0 + nf = 0 + while not shutdown: + msg = bus.recv(2) + if not msg: + continue + priority = (msg.arbitration_id & 0x1c000000) >> 26 + source = msg.arbitration_id & 0x000000ff + pgn = (msg.arbitration_id & 0x3ffff00) >> 8 + match pgn: + case 129025: + # Position + #lat = struct.unpack_from('> 4 + #instance = msg.data[0] & 0x0f + #level = struct.unpack_from('> 5 + fc = msg.data[0] & 0x1f + if not wip: + if fc != 0: + continue + source0 = source # muß über das Fast-packet konstant bleiben + sc0 = sc # -"- + fc0 = fc # dieser Zähler wird inkrementiert + datalen = msg.data[1] + nf = math.ceil((datalen - 6) / 7) + 1 + buf129540 = msg.data[2:] + wip = True + else: + if (source == source0) and (sc == sc0) and (fc == fc0 + 1): + buf129540.extend(msg.data[1:8]) + fc0 = fc + else: + # Dieser Frame paßt nicht + #print("PGN 129540: sc/fc mismatch") + pass + if fc == nf: + wip = False + parser.parse_129540(buf129540, boatdata) + case _: + pass + bus.shutdown() + +def rxd_0183(devname): + # Prüfe ob Port vorhanden ist und sich öffnen läßt + try: + ser = serial.Serial(devname, 115200, timeout=3) + except serial.SerialException as e: + print("OpenCPN serial port not available") + return + setthreadtitle("0183listener") + while not shutdown: + try: + msg = pynmea2.parse(ser.readline().decode('ascii')) + except pynmea2.nmea.ParseError: + continue + if msg.sentence_type == 'GLL': + boatdata.setValue("LAT", msg.latitude) + boatdata.setValue("LON", msg.longitude) + elif msg.sentence_type == 'VTG': + boatdata.setValue("COG", int(msg.true_track)) + boatdata.setValue("SOG", float(msg.spd_over_grnd_kts[:-1])) + elif msg.sentence_type == 'VHW': + boatdata.setValue("STW", float(msg.water_speed_knots)) + elif msg.sentence_type == 'WPL': + # Wegepunkt + print(msg.fields) + elif msg.sentence_type == 'RTE': + # Route + print(msg.fields) + ser.close() + +def datareader(histpath, history): + """ + Daten zu fest definierten Zeitpunkten lesen + + Die Schleife läuft einmal alle Sekunden immer zum gleichen Zeitpunkt. + Die Nutzlast sollte demnach weniger als eine Sekunde Laufzeit haben. + Falls durch eine außergewöhnliche Situation doch einmal mehr als eine + Sekunde benötigt werden sollte, gleicht sich das in den darauffolgenden + Durchläufen wieder aus. + + """ + setthreadtitle("datareader") + + # Speicherpfad für Meßwertdaten + if not os.path.exists(histpath): + os.makedirs(histpath) + history.basepath = histpath + # Serien initialisieren + history.addSeries("BMP280-75", 336 ,75) + history.addSeries("BMP280-150", 336 , 150) + history.addSeries("BMP280-300", 336 , 300) + history.addSeries("BMP280-600", 336 , 600) + history.addSeries("BMP280-900", 336 , 900) + for s in history.series.values(): + s.begin() + + def g_tick(n=1): + t = time.time() + count = 0 + while True: + count += n + yield max(t + count - time.time(), 0) + g = g_tick(1) + + n = 0 + while not shutdown: + time.sleep(next(g)) + # BME280 abfragen + if cfg['bme280']: + sensordata = bme280.sample(smbus, cfg['bme280_address'], cfg['bme280_cp']) + # Aktuellen Wert merken + boatdata.setValue("xdrTemp", sensordata.temperature) + boatdata.setValue("xdrPress", sensordata.pressure) + boatdata.setValue("xdrHum", sensordata.humidity) + # Historie schreiben + pval = int(sensordata.pressure *10) + for k, v in history.series.items(): + if n % k == 0: + v.add(pval) + # Lokales GPS abfragen + # TODO + if cfg['gps']: + pass + n += 1 + + for s in history.series.values(): + s.finish() + +class Frontend(Gtk.Window): + + button = { + 1: (75, 485), + 2: (150, 492), + 3: (227, 496), + 4: (306, 496), + 5: (382, 492), + 6: (459, 485) + } + radius = 30 + + def __init__(self, device, boatdata, profile): + super().__init__() + self.owndev = device + self.boatdata = boatdata + + self.connect("destroy", self.on_destroy) + + self.set_position(Gtk.WindowPosition.CENTER) + self.set_size_request(530, 555) + self.set_title("OBP60 virt") + self.set_app_paintable(True) + self.set_decorated(False) + self.set_keep_above(True) + + self.screen = self.get_screen() + self.visual = self.screen.get_rgba_visual() + if (self.visual is not None and self.screen.is_composited()): + self.set_visual(self.visual) + + handle = Rsvg.Handle() + self._svg = handle.new_from_file(os.path.join(sys.path[0], "obp60.svg")) + + self.connect("draw", self.on_draw) + + self.da = Gtk.DrawingArea() + self.da.add_events(Gdk.EventMask.BUTTON_PRESS_MASK|Gdk.EventMask.BUTTON_RELEASE_MASK) + self.add(self.da) + self.da.connect("draw", self.da_draw) + self.da.connect('button-press-event', self.da_button_press) + self.da.connect('button-release-event', self.da_button_release) + + self.button_clicked = 0 # Geklickter Button vor Loslassen + self.keylock = False + self.pages = profile + self.pageno = 1 + self.curpage = self.pages[self.pageno] + + print("Wische von 2 nach 1 für Programmende") + + def run(self): + GLib.timeout_add_seconds(2, self.on_timer) + self.show_all() + Gtk.main() + + def on_timer(self): + # Boatdata validator + boatdata.updateValid(5) + # Tastaturstatus an Seite durchreichen + self.curpage.keylock = self.keylock + # Neuzeichnen + self.queue_draw() + return True + + def on_draw(self, widget, ctx): + # Fenstertransparenz + ctx.set_source_rgba(0, 0, 0, 0) + ctx.paint() + + def da_draw(self, widget, ctx): + viewport = Rsvg.Rectangle() + viewport.x = 0 + viewport.y = 0 + viewport.width = 530 + viewport.height = 555 + self._svg.render_document(ctx, viewport) + ctx.set_source_rgb(1.0, 0, 0) + ctx.translate(64, 95) # Koordinatenursprung auf virtuellen Displaybereich setzen + ctx.rectangle(0, 0, 400, 300) + ctx.clip() + + ctx.set_source_rgb(0, 0, 0) # Schwarz auf Weiß + + # Heartbeat umschalten + if self.curpage.header: + self.curpage.draw_header(ctx) + self.curpage.draw(ctx) + if self.curpage.footer: + self.curpage.draw_footer(ctx) + + def da_button_press(self, widget, event): + # Es gibt eine Liste mit Objekten und hier wird + # geprüft ob und in welches Objekt geklickt wurde + # Die eigentliche Funktion wird beim Loslassen ausgelöst. + # Damit sind Wischgesten simulierbar + self.button_clicked = 0 + if (event.x < self.button[1][0] - self.radius or event.x > self.button[6][0] + self.radius): + return True + if (event.y < self.button[1][1] - self.radius or event.y > self.button[3][1] + self.radius): + return True + for b, v in self.button.items(): + diff = math.sqrt((event.x - v[0])**2 + (event.y - v[1])**2) + if diff < self.radius: + self.button_clicked = b + break + return True + + def da_button_release(self, widget, event): + # Hier sind die eigentlichen Tastenfunktionen + # + # Die Auswertung ist abhängig von der jew. angezeigten Seite + # Jede Seite kann eine Methode "handle_key" implementieren + # Falls der Rückgabewert "True" ist, hat die Seite die Taste + # verarbeitet, die Funktion hier wird damit unterdrückt. + # TODO + if (event.x < self.button[1][0] - self.radius or event.x > self.button[6][0] + self.radius): + return True + if (event.y < self.button[1][1] - self.radius or event.y > self.button[3][1] + self.radius): + return True + selected = 0 + for b, v in self.button.items(): + diff = math.sqrt((event.x - v[0])**2 + (event.y - v[1])**2) + if diff < self.radius: + selected = b + break + if self.keylock: + # Bei Tastensperre einzige Möglichkeit: Tastensperre ausschalten + if selected == 6 and self.button_clicked == 1: + self.keylock = False + return True + if selected == 1: + if self.button_clicked == 2: + # Programmende bei Klicken auf 2 und loslassen auf 1 + self.get_window().set_cursor(Gdk.Cursor(Gdk.CursorType.WATCH)) + Gtk.main_quit() + elif self.button_clicked == 6: + # Klick auf 6 und loslassen auf 1 ist Tastatursperre + self.keylock = True + else: + self.curpage.handle_key(1) + elif selected == 2: + # Abbruch/Zurück + self.curpage.handle_key(2) + elif selected == 3: + # runter / eine Seite vor + if not self.curpage.handle_key(3): + if self.pageno > 1: + self.pageno -= 1 + else: + self.pageno = len(self.pages) - 1 + self.curpage = self.pages[self.pageno] + elif selected == 4: + if not self.curpage.handle_key(4): + if self.pageno < len(self.pages) - 1: + self.pageno += 1 + else: + self.pageno = 1 + # hoch / eine Seite zurück + self.curpage = self.pages[self.pageno] + elif selected == 5: + # Ok/Menü + self.curpage.handle_key(5) + elif selected == 6: + if self.button_clicked == 6: + # Backlight on/off + self.curpage.backlight = not self.curpage.backlight + elif self.button_clicked == 5: + # Umschalten zur Systemseite + self.curpage = self.pages[0] + return True + + def on_destroy(self, widget): + Gtk.main_quit() + +def init_profile(config, cfg, boatdata): + ''' + config: Configparser-Objekt + cfg: Laufende Programmkonfiguration + Die Liste und Anordnung der Seiten nennen wir "Profil" + Seiten-Profil aus Konfiguration erstellen + Seite Nummer 0 ist immer die Systemseite. Diese ist nicht + über die normale Seitenreihenfolge erreichbar, sondern + durch eine spezielle Tastenkombination/Geste. + TODO Prüfungen einbauen: + Fortlaufende Seitennummern ab 1 + Fortlaufende Wertenummern ab 1 + Maximalwerte nicht überschreiten + ''' + pages_max = config.getint('pages', 'number_of_pages') + + # Suche alle Abschnitte, die mit "page" beginnen + sects = config.sections() + sects.remove('pages') + pagedef = {} + n = 0 + for s in sects: + if s.startswith('page'): + # Nummer und Art ermitteln + pageno = int(s[4:]) + pagedef[pageno] = {'type': config.get(s, "type")} + # Hole ein bin maximal 4 Werte je Seite + values = {} + valno = 1 + for i in (1, 2, 3, 4): + try: + values[i] = config.get(s, f"value{i}") + except configparser.NoOptionError: + break + pagedef[pageno]['values'] = values + n += 1 + if n >= pages_max: + break + clist = { + 0: pages.System(0, cfg, boatdata) + } + for i, p in pagedef.items(): + try: + cls = getattr(pages, p['type']) + except AttributeError: + # Klasse nicht vorhanden, Seite wird nicht benutzt + print(f"Klasse '{type}' nicht gefunden") + continue + c = cls(i, cfg, boatdata, *[v for v in p['values'].values()]) + clist[i] = c + return clist + +if __name__ == "__main__": + + #setproctitle("obp60v") + + shutdown = False + owndevice = Device(100) + boatdata = BoatData() + boatdata.addTank(0) + boatdata.addEngine(0) + + # Basiskonfiguration aus Datei lesen + config = configparser.ConfigParser() + config.read(os.path.join(sys.path[0], cfg['cfgfile'])) + cfg['deviceid'] = config.getint('system', 'deviceid') + cfg['simulation'] = config.getboolean('system', 'simulation') + cfg['histpath'] = os.path.expanduser(config.get('system', 'histpath')) + + cfg['gps'] = config.getboolean('gps', 'enabled') + if cfg['gps']: + cfg['gps_port'] = config.get('gps', 'port') + + cfg['bme280'] = config.getboolean('bme280', 'enabled') + if cfg['bme280']: + cfg['bme280_port'] = config.getint('bme280', 'port') + cfg['bme280_address'] = int(config.get('bme280', 'address'), 16) # convert 0x76 + smbus = smbus2.SMBus(cfg['bme280_port']) + cfg['bme280_cp'] = bme280.load_calibration_params(smbus, cfg['bme280_address']) + history = History("press", 75) + boatdata.addHistory(history, "press") + + cfg['ocpn_port'] = config.get('opencpn', 'port') + + if cfg['simulation']: + boatdata.enableSimulation() + + profile = init_profile(config, cfg, boatdata) + + t_rxd_n2k = threading.Thread(target=rxd_n2k) + t_rxd_n2k.start() + t_rxd_0183 = threading.Thread(target=rxd_0183, args=(cfg['ocpn_port'],)) + t_rxd_0183.start() + + t_data = threading.Thread(target=datareader, args=(cfg['histpath'], history)) + t_data.start() + + app = Frontend(owndevice, boatdata, profile) + app.run() + shutdown = True + t_rxd_n2k.join() + t_rxd_0183.join() + print("Another fine product of the Sirius Cybernetics Corporation.") diff --git a/obp60.svg b/obp60.svg new file mode 100644 index 0000000..7777244 --- /dev/null +++ b/obp60.svg @@ -0,0 +1,106 @@ + + + + diff --git a/pages/__init__.py b/pages/__init__.py new file mode 100644 index 0000000..441eb26 --- /dev/null +++ b/pages/__init__.py @@ -0,0 +1,36 @@ +# Displayseiten + +from .system import System + +# Generische Seiten +from .onevalue import OneValue +from .twovalues import TwoValues +from .threevalues import ThreeValues +from .fourvalues import FourValues +from .fourvalues2 import FourValues2 + +# Graphen +from .onegraph import OneGraph +from .twographs import TwoGraphs +from .exhaust import Exhaust + +# Analoginstrumente +from .clock import Clock +from .fluid import Fluid + +# Spezialseiten +from .anchor import Anchor +from .apparentwind import ApparentWind +from .autobahn import Autobahn +from .barograph import Barograph +from .battery import Battery +from .battery2 import Battery2 +from .bme280 import BME280 +from .dst810 import DST810 +from .keel import Keel +from .rollpitch import RollPitch +from .skyview import SkyView +from .solar import Solar +from .rudder import Rudder +from .voltage import Voltage +from .windrose import WindRose diff --git a/pages/anchor.py b/pages/anchor.py new file mode 100644 index 0000000..68ecea7 --- /dev/null +++ b/pages/anchor.py @@ -0,0 +1,52 @@ +""" + +Ankerinfo / -alarm + +""" + +import os +import cairo +import math +from .page import Page + +class Anchor(Page): + + def __init__(self, pageno, cfg, boatdata): + super().__init__(pageno, cfg, boatdata) + self.sym_anchor = cairo.ImageSurface.create_from_png(os.path.join(cfg['imgpath'], "anchor.png")) + self.buttonlabel[1] = 'DEC' + self.buttonlabel[2] = 'INC' + self.buttonlabel[5] = 'SET' + + def draw(self, ctx): + + # Name + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(20) + + ctx.move_to(2, 50) + ctx.show_text("Anchor") + ctx.move_to(320, 50) + ctx.show_text("Chain") + + ctx.set_font_size(16) + ctx.move_to(2, 70) + ctx.show_text("Alarm: off") + ctx.move_to(320, 70) + ctx.show_text("45 m") + ctx.stroke() + + + # Spezialseite + cx = 200 + cy = 150 + r = 125 + + ctx.set_line_width(1.5) + ctx.arc(cx, cy, r, 0, 2*math.pi) + ctx.stroke() + + ctx.save() + ctx.set_source_surface(self.sym_anchor, cx-8, cy-8) + ctx.paint() + ctx.restore() diff --git a/pages/apparentwind.py b/pages/apparentwind.py new file mode 100644 index 0000000..881ea55 --- /dev/null +++ b/pages/apparentwind.py @@ -0,0 +1,16 @@ +import cairo +import math +from .page import Page + +class ApparentWind(Page): + + def __init__(self, pageno, cfg, boatdata): + super().__init__(pageno, cfg, boatdata) + + def draw(self, ctx): + # Name + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(60) + ctx.move_to(20, 100) + ctx.show_text("Apparent Wind") + diff --git a/pages/autobahn.py b/pages/autobahn.py new file mode 100644 index 0000000..a144946 --- /dev/null +++ b/pages/autobahn.py @@ -0,0 +1,102 @@ +""" + +3D-View angelehnt an die NASA Clipper GPS-Darstellung + +""" + +import os +import cairo +import math +from .page import Page + +class Autobahn(Page): + + def __init__(self, pageno, cfg, boatdata): + super().__init__(pageno, cfg, boatdata) + self.xte = self.bd.getRef("XTE") + self.cog = self.bd.getRef("COG") + self.btw = self.bd.getRef("BTW") + self.dtw = self.bd.getRef("DTW") + self.wpname = "no data" + self.symbol = cairo.ImageSurface.create_from_png(os.path.join(cfg['imgpath'], "ship.png")) + + def draw(self, ctx): + # Beschriftung unter den Werten + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(16) + ctx.move_to(50, 188); + ctx.show_text("Cross-track error") + ctx.move_to(270, 188) + ctx.show_text("Track") + ctx.move_to(45, 275); + ctx.show_text("Distance to waypoint") + ctx.move_to(260, 275); + ctx.show_text("Bearing") + ctx.stroke() + + # Meßwerte + ctx.select_font_face("DSEG7 Classic") + ctx.set_font_size(60) + + ctx.move_to(40, 170) + #ctx.show_text(self.xte.format()) + ctx.show_text("2.3") + ctx.move_to(220, 170) + #ctx.show_text(self.cog.format()) + ctx.show_text("253") + ctx.move_to(40, 257) + #ctx.show_text(self.dtw.format()) + ctx.show_text("5.8") + ctx.move_to(220, 257) + #ctx.show_text(self.btw.format()) + ctx.show_text("248") + + # 3D-Ansicht oben + # TODO Schiffssymbol + ctx.save() + ctx.set_source_surface(self.symbol, 186, 68) + ctx.paint() + ctx.restore() + + # Segmente: 2 1 0 3 4 5 + seg = [True] * 6 + points = { + 2: ((0, 54), (46, 24), (75, 24), (0, 90)), + 1: ((0, 100), (82, 24), (112, 24), (50, 100)), + 0: ((60, 100), (117, 24), (147, 24), (110, 100)), + 3: ((340, 100), (283, 24), (253, 24), (290, 100)), + 4: ((399, 100), (318, 24), (289, 24), (350, 100)), + 5: ((399, 54), (354, 24), (325, 24), (399, 90)) + } + + # Winkeldifferenz + diff = (self.cog.value or 0) - (self.btw.value or 0) + if diff < -180: + diff += 360 + elif diff > 180: + diff -= 360 + + if diff > 0: + order = (3, 4, 5, 0, 1, 2) + else: + order = (0, 1, 2, 3, 4, 5) + + # Anzahl aktiver Segmente + seg_step = math.radians(3) + nseg = min(abs(diff) / seg_step, 5) + i = 0 + while nseg > 0: + seg[order[i]] = False + i += 1 + nseg -= 1 + + # Segmente zeichnen + for p in range(6): + ctx.move_to(*points[p][0]) + ctx.line_to(*points[p][1]) + ctx.line_to(*points[p][2]) + ctx.line_to(*points[p][3]) + if seg[p]: + ctx.fill() + else: + ctx.stroke() diff --git a/pages/barograph.py b/pages/barograph.py new file mode 100644 index 0000000..6b50434 --- /dev/null +++ b/pages/barograph.py @@ -0,0 +1,366 @@ +""" + +Siehe auch: Steamrock Digital Barometer + +Meßwert alls 15 Minuten: + Es wird in hPa gemessen mit einer Nachkommastelle + 84 Stunden * 4 Werte je Stunde = 336 Meßwerte +Tendenzwert über 3 Stunden + +Je Zoomstufe wird ein eigener Buffer vorgehalten um ein sauberes +Diagramm zu erhalten. Überall gilt: Je Pixel ein Meßwert. + +Drucktendenz: + - 1 hour tendency + - 3 hour tendency + +Verschiedene Datenquellen auswählbar: +- intern (BME280, BMP280) +- N2K generisch +- Historie von + - Yacht devices + - Capteurs? + +Das Diagramm wird mit Ursprung rechts unten (x0, y0) gezeichnet, +da die Werte in der Vergangenhait liegen, also links vom Ursprung. + +Damit eine saubere Skala auf der Y-Achse erreicht wird, gibt einige +feste Skalierungen. +Standard: 20hPa von unten nach oben, z.B. 1015, 1020, 1025, 1030, 1035 + +""" + +import time +import cairo +from .page import Page + +class Barograph(Page): + + def __init__(self, pageno, cfg, boatdata): + super().__init__(pageno, cfg, boatdata) + # Meßwert alle 15 Minuten: + # 84 Stunden * 4 Werte je Stunde = 336 Meßwerte + self.bd = boatdata + self.source = 'I' # (I)ntern, e(X)tern + self.zoom = (1, 2, 3, 6, 12) + self.zoomindex = 4 + self.series = (75, 150, 300, 600, 900) + + # Y-Axis + self.vmin = 0 + self.vmax = 0 + self.scalemin = 1000 + self.scalemax = 1020 + self.scalestep = 5 + + # Tendenzwert über 3 Stunden + self.hist3 = None + self.hist1 = None + + self.buttonlabel[1] = '+' + self.buttonlabel[2] = '-' + self.buttonlabel[5] = 'SRC' + + self.refresh = time.time() - 30 + + def handle_key(self, buttonid): + # TODO Serie auswählen aufgrund Zoomlevel + if buttonid == 1: + # Zoom in + if self.zoomindex > 0: + self.zoomindex -= 1 + self.refresh = time.time() - 30 + elif buttonid == 2: + # Zoom out + if self.zoomindex < len(self.zoom) - 1: + self.zoomindex += 1 + self.refresh = time.time() - 30 + if buttonid == 5: + # Source + if self.source == 'I': + self.source = 'X' + else: + self.source = 'I' + + # Testausgabe der Datenerfassung + data = [] + vmin = data[0] + vmax = data[0] + i = self.series[self.zoomindex] + for value in self.bd.history['press'].series[i].get(): + v = value / 10 + data.append(v) + if v < vmin and v != 0: + vmin = v + elif v > vmax and v != 0: + vmax = v + print(f"Werte: vmin={vmin}, vmax={vmax}") + ymin, ymax, step = self.getYScale(vmin, vmax) + print(f"Skala: ymin={ymin}, ymax={ymax}, step={step}") + print(f"zoomindex={self.zoomindex}, series={self.series[self.zoomindex]}") + + hist1a = self.bd.history['press'].series[i].getvalue(3600) + hist1b = self.bd.history['press'].series[i].getvalue3(3600) + trend1 = data[0] - hist1b + print(f"{hist1a} / {hist1b} -> Trend1: {trend1:.1f}") + + def loadData(self): + """ + Transfer data from history to page buffer + set y-axis according to data + """ + self.data = [] + self.vmin = 9999 + self.vmax = 0 + i = self.series[self.zoomindex] + for value in self.bd.history['press'].series[i].get(): + v = value / 10 + self.data.append(v) + if v < self.vmin and v != 0: + self.vmin = v + elif v > self.vmax and v != 0: + self.vmax = v + self.scalemin, self.scalemax, self.scalestep = self.getYScale(self.vmin, self.vmax) + return True + + def drawTrend(self, ctx, code, x, y, w): + """ + One hour Trend + 0: Stationary <= 1 hPa + 1: Rising >1 and <= 2 hPa + 2: Rising fast >2 and <= 3 hPa + 3: Rising very fast >3 hPa + -1: Falling + -2: Falling fast + -3: Falling very fast + """ + trend1map = { + -3: "Falling_Very_Fast.png", # > 3 hPa + -2: "Falling_Fast.png", # > 2 and <= 3 hPa + -1: "Falling.png", # > 1 and <= 2 hPa + 0: "Stationary.png", # <= +/- 1 hPa + 1: "Rising.png", # < -1 and >= -2 hPa + 2: "Rising_Fast.png", # < -2 and >= -3 hPa + 3: "Rising_Very_Fast.png" # < -3 hPa + } + + if code == 0: + # Pfeil horizontal rechts + ctx.move_to(x, y - w / 2) + ctx.line_to(x + w, y - w / 2) + ctx.draw() + # Position merken + ctx.line_to(x - w / 4, y - w) + ctx.line_to(x - w / 4, y) + ctx.line_to(x + w, y - w / 2) + ctx.fill() + elif code == 1: + # Pfeil schräg nach oben + pass + elif code == 2: + # Pfeil gerade nach oben + pass + elif code == 3: + # Doppelpfeil nach oben + pass + elif code == -1: + # Pfeil schräg nach unten + pass + elif code == -2: + # Pfeil gerade nach unten + pass + elif code == -3: + # Doppelpfeil nach unten + pass + + def drawWMOCode(self, ctx, code, x, y, w): + """ + Three hour code + Code 0 to 8: + 0: Increasing, then decreasing; athmospheric pressure the same + as or higher than three hours ago + 1: Increasing then steady; or increasing, then increasing more + slowly; athmospheric pressure now higher than three hours ago + 2: Increasing (steadily or unsteadily); athmospheric pressure + now higher than three hours ago + 3: Decreasing or steady, then increasing; or increasing then + increasing more rapidly; athmospheric pressure now higher + than three hours ago + 4: Steady; athmospheric pressure is the same as three hours ago + 5: Decreasing, then increasing; athmospheric pressure now is the + same as or lower than three hours ago + 6: + 7: + 8: + """ + pass + + def getYScale(self, vmin, vmax): + # Y-Achse aufgrund Meßwerten einstellen + diff = vmax - vmin + if diff < 20: + step = 5 + elif diff <= 40: + step = 10 + else: + step = 15 + vmin = int(vmin - (vmin % step)) # Nächstes Vielfaches nach oben + vmax = int(vmax + step - (vmax % step)) # Nächstes Vielfaches nach unten + return (vmin, vmax, step) + + def draw(self, ctx): + """ + Darstellung angelehnt an klassisches Gerät + Daten werden im nichtflüchtigen Speicher gehalten + Da sich die Daten langsam verändern, reicht es, diese z.B. nur alle + 30 Sekunden oder langsamer zu laden. + Der aktuelle Wert oben ist natürlich nicht alt. + + Datenreihen + - 1 Woche, stündlich: 7 * 24 = 168 Meßwerte + - 1 Tag, alle 10 min: 24 * 6 = 144 Meßwerte + Der Druck wird in zwei Bytes abgespeichert. Es wird eine Nachkommastelle + verwendet. Um ohne Fließkommazahlen auszukommen wird der Maßwert einfach + mit 10 multipliziert. + + Darstellung wie Steamrock: + 1 Pixel entspricht einem Meßwert alle 15min. + 1 Tag hat dementsprechend eine Breite von 48px + """ + + timestamp = time.time() + if timestamp - self.refresh >= 30: + self.refresh = timestamp + self.loadData() + + ctx.set_source_rgb(0, 0, 0) + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + + # Datenquelle rechts oben + ctx.set_font_size(16) + ctx.move_to(330, 50) + if self.source == 'I': + ctx.show_text("BMP280") + else: + ctx.show_text("N2K Bus") + ctx.stroke() + # Zoomstufe + datastep = self.series[self.zoomindex] + if datastep > 120: + if datastep % 60 == 0: + fmt = "{:.0f} min" + else: + fmt = "{:.1f} min" + datastep /= 60 + else: + fmt = '{} s' + self.draw_text_center(ctx, 360, 62, fmt.format(datastep)) + + # Aktueller Luftdruck hPa + ctx.set_font_size(32) + self.draw_text_center(ctx, 200, 40, self.bd.pressure.format()) + #self.draw_text_center(ctx, 200, 40, "1019.2") + ctx.set_font_size(16) + self.draw_text_center(ctx, 200, 62, "hPa") + + # Trend + ctx.set_font_size(16) + # TODO Trend linie + #trend = + self.draw_text_center(ctx, 295, 62, "0.0") + + # min/max + ctx.move_to(10, 38) + ctx.show_text(f"min: {self.vmin}") + ctx.move_to(10, 50) + ctx.show_text(f"max: {self.vmax}") + + # Alarm + self.draw_text_center(ctx, 70, 62, "Alarm Off") + + # Hintergrundrahmen + ctx.set_line_width(2) + + ctx.move_to(0, 75) + ctx.line_to(400, 75) + + ctx.move_to(130, 20) + ctx.line_to(130, 75) + + ctx.move_to(270, 20) + ctx.line_to(270, 75) + + ctx.move_to(325, 20) + ctx.line_to(325, 75) + + ctx.stroke() + + + # Diagramm + # -------- + ymin = self.scalemin + ymax = self.scalemax + yn = self.scalestep + ystep = (ymax - ymin) / yn + + xstep = 48 + + # Ursprung ist rechts unten + x0 = 350 + y0 = 270 + w = 7 * 48 + h = 180 + + ctx.set_line_width(1) + ctx.rectangle(x0 - w + 0.5, y0 - h + 0.5, w, h) + ctx.stroke() + + # X-Achse sind Stunden + xn = 0 + for xt in [x * -1 * self.zoom[self.zoomindex] for x in range(1,7)]: + xn += 1 + ctx.move_to(x0 - xn * xstep + 0.5, y0) + ctx.line_to(x0 - xn * xstep + 0.5, y0 - h) + ctx.stroke() + self.draw_text_center(ctx, x0 - xn * xstep + 0.5, y0 - 8, str(xt), fill=True) + ctx.stroke() + #for x in (1, 2, 3, 4, 5, 6): + # ctx.move_to(x0 - x * 48 + 0.5, y0 + 0.5) + # ctx.line_to(x0 - x * 48 + 0.5, y0 - h + 0.5) + #ctx.stroke() + + # Y-Achse + ctx.move_to(x0 + 5.5, y0 + 0.5) + ctx.line_to(x0 + 5.5, y0 - h) + ctx.move_to(x0 - w - 5.5, y0 + 0.5) + ctx.line_to(x0 - w - 5.5, y0 -h ) + ctx.stroke() + + dy = 9 # Pixel je hPa + ysmin = self.scalemin + ysmax = self.scalemax + + y = y0 + 0.5 + ystep = self.scalestep + ys = ysmin + while y >= y0 - h: + if ys % ystep == 0: + ctx.move_to(x0 + 10, y + 5.5) + ctx.show_text(str(ys)) + ctx.move_to(x0 - w - 5, y) + ctx.line_to(x0 + 5, y) + else: + ctx.move_to(x0, y) + ctx.line_to(x0 + 5, y) + ctx.move_to(x0 - w - 5, y) + ctx.line_to(x0 - w, y) + y -= dy + ys += 1 + ctx.stroke() + + # Meßdaten + for v in self.data: + x0 -= 1 + if v > 0: + ctx.rectangle(x0, y0 - (v - ysmin) * dy, 1.5, 1.5) + ctx.fill() diff --git a/pages/battery.py b/pages/battery.py new file mode 100644 index 0000000..75df190 --- /dev/null +++ b/pages/battery.py @@ -0,0 +1,66 @@ +""" + +Batteriewerte eines INA219 oder INA226 Sensors +Ähnlich ThreeValue + +""" + +import cairo +from .page import Page + +class Battery(Page): + + avg = (1, 10, 60, 300); + + def __init__(self, pageno, cfg, boatdata): + super().__init__(pageno, cfg, boatdata) + self.avgindex = 0 + self.buttonlabel[1] = 'AVG' + + def handle_key(self, buttonid): + if buttonid == 1: + if self.avgindex < len(self.avg) -1: + self.avgindex += 1 + else: + self.avgindex = 0 + return True + return False + + def draw(self, ctx): + + # Aufteilung in 3 Bereiche durch 2 Linien + ctx.rectangle(0, 105, 400, 3); + ctx.rectangle(0, 195, 400, 3); + ctx.fill() + + # Name + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(40) + ctx.move_to(20, 55) + ctx.show_text("VBat") + ctx.move_to(20, 145) + ctx.show_text("IBat") + ctx.move_to(20, 235) + ctx.show_text("PBat") + ctx.stroke() + + # Einheit + ctx.set_font_size(24) + ctx.move_to(20, 90) + ctx.show_text("V") + ctx.move_to(20, 180) + ctx.show_text("A") + ctx.move_to(20, 270) + ctx.show_text("W") + + + # Werte + ctx.select_font_face("DSEG7 Classic") + ctx.set_font_size(60) + ctx.move_to(180, 90) + ctx.show_text("12.3") + ctx.move_to(180, 180) + ctx.show_text("3.2") + ctx.move_to(180, 270) + ctx.show_text("39.4") + diff --git a/pages/battery2.py b/pages/battery2.py new file mode 100644 index 0000000..56f507e --- /dev/null +++ b/pages/battery2.py @@ -0,0 +1,90 @@ +""" + +Komplexe Batterieübersichtsseite + +""" + +import cairo +from .page import Page + +class Battery2(Page): + + def draw_battery(self, ctx, x, y, w, h, level): + ''' + Das Rechteck ist das komplett umschließende + Level ist der prozentuale Füllstand + ''' + pass + + ''' + // Battery graphic with fill level + void batteryGraphic(uint x, uint y, float percent, int pcolor, int bcolor){ + // Show battery + int xb = x; // X position + int yb = y; // Y position + int t = 4; // Line thickness + // Percent limits + if(percent < 0){ + percent = 0; + } + if(percent > 99){ + percent = 99; + } + // Battery corpus 100x80 with fill level + int level = int((100.0 - percent) * (80-(2*t)) / 100.0); + getdisplay().fillRect(xb, yb, 100, 80, pcolor); + if(percent < 99){ + getdisplay().fillRect(xb+t, yb+t, 100-(2*t), level, bcolor); + } + // Plus pol 20x15 + int xp = xb + 20; + int yp = yb - 15 + t; + getdisplay().fillRect(xp, yp, 20, 15, pcolor); + getdisplay().fillRect(xp+t, yp+t, 20-(2*t), 15-(2*t), bcolor); + // Minus pol 20x15 + int xm = xb + 60; + int ym = yb -15 + t; + getdisplay().fillRect(xm, ym, 20, 15, pcolor); + getdisplay().fillRect(xm+t, ym+t, 20-(2*t), 15-(2*t), bcolor); + ''' + + + def draw(self, ctx): + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(40) + ctx.move_to(10, 65) + ctx.show_text("Bat.") + + # Batterietyp + ctx.move_to(90, 65) + ctx.show_text("AGM") + + # Kapazität + ctx.move_to(10, 200) + ctx.select_font_face("DSEG7 Classic") + ctx.set_font_size(40) + ctx.show_text("12") + + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(16) + ctx.show_text("Ah") + + ctx.move_to(10, 235) + ctx.show_text("Installed") + + ctx.move_to(10, 255) + ctx.show_text("Battery Type") + + # Batteriegraphik + # Rechteck mit Füllstand 100x80, oben zwei Pole + ctx.rectangle(150, 100, 100, 80) + ctx.stroke() + # Füllstand + # Pole + + + ''' + ctx.line_to(2.5, 1.5) + + ctx.set_line_width(0.06) + ''' diff --git a/pages/bme280.py b/pages/bme280.py new file mode 100644 index 0000000..79a3a94 --- /dev/null +++ b/pages/bme280.py @@ -0,0 +1,55 @@ +""" + +Werte eines lokal angeschlossenen BME280/BMP280 + +""" + +import cairo +from .page import Page + +class BME280(Page): + + def __init__(self, pageno, cfg, boatdata): + super().__init__(pageno, cfg, boatdata) + #self.ref1 = self.bd.getRef(boatvalue1) + #self.ref2 = self.bd.getRef(boatvalue2) + #self.ref3 = self.bd.getRef(boatvalue3) + + def draw(self, ctx): + + # Bildschirmunterteilung mit Linien + ctx.rectangle(0, 105, 399, 3) + ctx.rectangle(0, 195, 399, 3) + ctx.fill() + + # Beschriftung + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(40) + # Titel + ctx.move_to(20,55) + ctx.show_text("Temp") + ctx.move_to(20,145) + ctx.show_text("Humid") + ctx.move_to(20, 235) + ctx.show_text("Press") + # Einheit + ctx.set_font_size(24) + ctx.move_to(20, 90) + ctx.show_text("Deg C") + ctx.move_to(20, 180) + ctx.show_text("%") + ctx.move_to(20, 270) + ctx.show_text("hPa") + + # Meßwerte + ctx.select_font_face("DSEG7 Classic") + ctx.set_font_size(60) + # Temperatur °C + ctx.move_to(180, 90) + ctx.show_text("{:.1f}".format(self.bd.temp_air)) + # Feuchte % + ctx.move_to(180, 180) + ctx.show_text("{}".format(int(self.bd.humidity))) + # Luftdruck hPa + ctx.move_to(180, 270) + ctx.show_text("{}".format(int(self.bd.pressure))) diff --git a/pages/clock.py b/pages/clock.py new file mode 100644 index 0000000..e25144b --- /dev/null +++ b/pages/clock.py @@ -0,0 +1,188 @@ +""" + +Uhr + +TODO: Zeitzone anzeigen. Abhängig von Lat, Lon + + Es sollen verschiedene Modi unterstützt werden + - Analoguhr + - Digitaluhr + - Regattauhr / -timer + +""" + +import cairo +import math +from .page import Page +from datetime import datetime +import astral + +class Clock(Page): + + def __init__(self, pageno, cfg, boatdata): + super().__init__(pageno, cfg, boatdata) + self.buttonlabel[1] = 'MODE' + self.buttonlabel[2] = 'TZ' + self.mode = ('A', 'D', 'T') # (A)nalog (D)igital (T)imer + self.modeindex = 1 + self.utc = True + self.location = astral.Location(('Norderstedt', 'Germany', 53.710105, 10.0574378, 'UTC')) + self.location.astral = astral.Astral() + + def handle_key(self, buttonid): + if buttonid == 1: + if self.modeindex < len(self.mode): + self.modeindex += 1 + else: + self.modeindex = 0 + return True + if buttonid == 2: + self.utc = not self.utc + return True + return False + + def draw(self, ctx): + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + + # akuellen Modus anzeigen + if mode[modeindex] == 'A': + self.draw_analog(ctx) + if mode[modeindex] == 'D': + self.draw_digital(ctx) + else: + self.draw_timer(ctx) + + def draw_digital(self, ctx): + ctx.set_font_size(24) + ctx.move_to(10, 220) + ctx.show_text("Digital clock") + + def draw_timer(self, ctx): + ctx.set_font_size(24) + ctx.move_to(10, 220) + ctx.show_text("Timer") + + def draw_analog(self, ctx): + + ts = datetime.now() + sunrise = self.location.sunrise(ts) + sunset = self.location.sunset(ts) + #print(sunrise) + #print(sunset) + + # Datum und Uhrzeit + # Sonnenaufgang + # Sonnenuntergang + + # Wochentag + # ts.strftime('%a') + + # Werte in den Ecken der Uhr + ctx.set_font_size(24) + ctx.move_to(10, 220) + ctx.show_text("Time") + ctx.move_to(10, 95) + ctx.show_text("Date") + ctx.move_to(335, 95) + ctx.show_text("SunR") + ctx.move_to(335, 220) + ctx.show_text("SunS") + ctx.stroke() + + ctx.set_font_size(16) + + ctx.move_to(10, 65) + ctx.show_text(ts.strftime("%d.%m.%Y")) + ctx.move_to(10, 250) + ctx.show_text(ts.strftime("%H:%M")) + ctx.move_to(335, 65) + ctx.show_text(sunrise.strftime("%H:%M")) + ctx.move_to(335, 250) + ctx.show_text(sunset.strftime("%H:%M")) + ctx.stroke() + + # Horizontal separators + ctx.rectangle(0, 149, 60, 3) + ctx.rectangle(340, 149, 60, 3) + ctx.fill() + + # Uhr + cx = 200 + cy = 150 + r = 110 + ctx.arc(cx, cy, r + 10, 0, 2*math.pi) + ctx.arc(cx, cy, r + 7, 0, 2*math.pi) + ctx.stroke() + + ctx.set_font_size(20) + self.draw_text_center(ctx, cx, cy-40, 'UTC' if self.utc else 'LOT') + + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(24) + + for i in range(360): + x = cx + (r - 30) * math.sin(i/180*math.pi) + y = cy - (r - 30) * math.cos(i/180*math.pi) + + char = "" + if i == 0: + char = "12" + elif i == 90: + char = "3" + elif i == 180: + char = "6" + elif i == 270: + char = "9" + + if i % 90 == 0: + #ctx.move_to(x, y) + self.draw_text_center(ctx, x, y, char) + #ctx.stroke() + + ctx.set_line_width(3.0) + if i % 6 == 0: + if i % 30 == 0: + x0 = cx + (r - 10) * math.sin(i/180*math.pi) + y0 = cy - (r - 10) * math.cos(i/180*math.pi) + x1 = cx + (r + 10) * math.sin(i/180*math.pi) + y1 = cy - (r + 10) * math.cos(i/180*math.pi) + ctx.move_to(x0, y0) + ctx.line_to(x1, y1) + ctx.stroke() + else: + x = cx + r * math.sin(i/180*math.pi) + y = cy - r * math.cos(i/180*math.pi) + ctx.arc(x, y, 2, 0, 2*math.pi) + ctx.fill() + + # Stundenzeiger + p = ((cx - 2, cy - (r - 50)), (cx + 2, cy - (r - 50)), (cx + 6, cy + 16), (cx - 6, cy + 16)) + angle_h = (ts.hour % 12 + ts.minute / 60) * 30 + zeiger = self.rotate((cx, cy), p, angle_h) + ctx.move_to(*zeiger[0]) + for point in zeiger[1:]: + ctx.line_to(*point) + ctx.fill() + + # Minutenzeiger + p = ((cx - 1, cy - (r - 15)), (cx + 1, cy - (r - 15)), (cx + 6, cy + 20), (cx - 6, cy + 20)) + angle_m = ts.minute * 6 + zeiger = self.rotate((cx, cy), p, angle_m) + ctx.move_to(*zeiger[0]) + for point in zeiger[1:]: + ctx.line_to(*point) + ctx.fill() + + # Zentraler Kreis + ctx.set_source_rgb(0, 0, 0) + ctx.arc(cx, cy, 12, 0, 2*math.pi) + ctx.fill() + + # Wozu dieses? + ctx.set_source_rgb(0.86, 0.86, 0.86) + ctx.arc(cx, cy, 10, 0, 2*math.pi) + ctx.fill() + + ctx.set_source_rgb(0, 0, 0) + ctx.arc(cx, cy, 2, 0, 2*math.pi) + ctx.fill() diff --git a/pages/dst810.py b/pages/dst810.py new file mode 100644 index 0000000..42803a0 --- /dev/null +++ b/pages/dst810.py @@ -0,0 +1,40 @@ +import cairo +from .page import Page + +class DST810(Page): + + # DBT, STW, Log, WTemp + + def draw(self, ctx): + + # Layout + ctx.rectangle(0, 105, 400, 3) + ctx.rectangle(0, 195, 400, 3) + ctx.rectangle(200, 195, 3, 75) + ctx.fill() + + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(40) + + # titel + ctx.move_to(20, 55) + ctx.show_text("Depth") + ctx.move_to(20, 145) + ctx.show_text("Speed") + ctx.move_to(20, 220) + ctx.show_text("Log") + ctx.move_to(220, 220) + ctx.show_text("Temp") + + # Einheiten + ctx.set_font_size(24) + ctx.move_to(20, 90) + ctx.show_text("m") + + # Meßwerte + ctx.select_font_face("DSEG7 Classic") + ctx.set_font_size(60) + + ctx.move_to(180, 90) + ctx.show_text("m") + ctx.set_font_size(40) diff --git a/pages/exhaust.py b/pages/exhaust.py new file mode 100644 index 0000000..70be7b8 --- /dev/null +++ b/pages/exhaust.py @@ -0,0 +1,58 @@ +""" + +XY-Graphik der Abgastemperatur + +""" + +import cairo +import math +from .page import Page + +class Exhaust(Page): + + def draw(self, ctx): + # Title + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(24) + ctx.move_to(10, 45) + ctx.show_text("Exhaust Temperature") + + # Graph anzeigen X/Y + + x0 = 55 + y0 = 255 + w = 300 + h = 200 + + # X-Achse + ctx.move_to(x0 - 20, y0) + ctx.line_to(x0 + w, y0) + # Y-Achse + ctx.move_to(x0, y0 + 20) + ctx.line_to(x0, y0 - h) + ctx.stroke() + # Pfeispitze X + ctx.move_to(x0-4, y0 - h + 12) + ctx.line_to(x0, y0 - h) + ctx.line_to(x0 + 4, y0 - h + 12) + ctx.fill() + # Pfeilspitze Y + ctx.move_to(x0 + w -12, y0 - 4) + ctx.line_to(x0 + w, y0) + ctx.line_to(x0 + w - 12, y0 + 4) + ctx.fill() + + # Achsenbeschriftung + ctx.set_font_size(16) + ctx.move_to(x0 - 30, y0 - h + 20) + ctx.show_text("°C") + ctx.move_to(x0 + w - 10, y0 + 15) + ctx.show_text("min") + + # Hier wird eine Reihe von Meßwerten erwartet + # Aufgrund min und max kann die Y-Achse skaliert werden + # Die X-Achse ist die Zeit + self.draw_text_center(ctx, x0 - 30, y0 - h / 2, "Temperature", True, False) + + # Einzeichnen von zwei Warnschwellen als horizontale + # Linie (gestrichelt) diff --git a/pages/fluid.py b/pages/fluid.py new file mode 100644 index 0000000..5cdcd6e --- /dev/null +++ b/pages/fluid.py @@ -0,0 +1,137 @@ +""" + +Füllstandsanzeige Tank + + 0: "Fuel", + 1: "Water", + 2: "Gray Water", + 3: "Live Well", + 4: "Oil", + 5: "Black Water", + 6: "Fuel Gasoline", + 14: "Error", + 15: "Unavailable" + +""" + +import os +import cairo +import math +from .page import Page +import nmea2000.lookup + +class Fluid(Page): + + def __init__(self, pageno, cfg, boatdata, fluidtype): + super().__init__(pageno, cfg, boatdata) + self.fluidtype = int(fluidtype) + if self.fluidtype == 0: + self.symbol = cairo.ImageSurface.create_from_png(os.path.join(cfg['imgpath'], "fuelpump.png")) + else: + self.symbol = None + + def draw(self, ctx): + + # Zentrum Instrument + cx = 200 + cy = 150 + # Radius + r = 110 + + # Füllstand von 0 - 100% + # 0 = -120°, 100 = +120° + level = self.bd.tank[0].volume or 0 + angle = -120 + level * 2.4 + + # Rahmen + ctx.set_source_rgb(*self.fgcolor) + ctx.set_line_width(3) + ctx.arc(cx, cy, r, 0, 2*math.pi) + ctx.stroke() + + # Symbol, sofern vorhanden + if self.symbol: + ctx.save() + ctx.set_source_surface(self.symbol, cx - 8, cy - 50) + ctx.paint() + ctx.restore() + + # Fluidtype + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(24) + ctx.move_to(20, 60) + ctx.show_text(nmea2000.lookup.fluidtype[self.fluidtype]) + ctx.stroke() + + # Zeigerrahmen im Zentrum + ctx.arc(cx, cy, 8, 0, 2*math.pi) + ctx.stroke() + + # Zeiger in Nullposition + # Variante 1, einseitig + #p = ((cx - 1, cy - (r - 20)), (cx + 1, cy - (r - 20)), (cx + 4, cy), (cx - 4, cy)) + # Variante 2, überstehend + p = ((cx - 1, cy - (r - 20)), (cx + 1, cy - (r - 20)), (cx + 6, cy + 15), (cx - 6, cy + 15)) + # Zeiger für aktuellen Meßwert + zeiger = self.rotate((cx, cy), p, angle) + + # Zeiger zeichnen + ctx.move_to(*zeiger[0]) + for point in zeiger[1:]: + ctx.line_to(*point) + ctx.fill() + + # Lösche das Zentrum heraus + ctx.set_source_rgb(*self.bgcolor) + ctx.arc(cx, cy, 6, 0, 2*math.pi) + ctx.fill() + + ctx.set_source_rgb(*self.fgcolor) + + # Simple Skala direkt zeichnen + # 50%-Wert oben in der Mitte + + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(16) + + ctx.move_to(cx, cy -r) + ctx.line_to(cx, cy -r + 16) + # linker Anschlag 0% + self.draw_text_center(ctx, cx, cy - r + 30, "1/2") + l = self.rotate((cx, cy), ((cx, cy - r + 16), (cx, cy - r)), -120) + ctx.move_to(*l[0]) + ctx.line_to(*l[1]) + # rechter Anschlag 100% + l = self.rotate((cx, cy), ((cx, cy - r + 16), (cx, cy - r)), 120) + ctx.move_to(*l[0]) + ctx.line_to(*l[1]) + + # 25% + l = self.rotate((cx, cy), ((cx, cy - r + 16), (cx, cy - r)), -60) + ctx.move_to(*l[0]) + ctx.line_to(*l[1]) + tx, ty = self.rotate((cx, cy), ((cx, cy - r + 30),), -60)[0] + self.draw_text_center(ctx, tx, ty, "1/4") + # 75% + l = self.rotate((cx, cy), ((cx, cy - r + 16), (cx, cy - r)), 60) + ctx.move_to(*l[0]) + ctx.line_to(*l[1]) + tx, ty = self.rotate((cx, cy), ((cx, cy - r + 30),), 60)[0] + self.draw_text_center(ctx, tx, ty, "3/4") + + ctx.set_font_size(24) + tx, ty = self.rotate((cx, cy), ((cx, cy - r + 30),), -130)[0] + self.draw_text_center(ctx, tx, ty, "E") + tx, ty = self.rotate((cx, cy), ((cx, cy - r + 30),), 130)[0] + self.draw_text_center(ctx, tx, ty, "F") + ctx.stroke() + + self.draw_text_center(ctx, cx, cy + r - 20, f"{level:.0f}%") + ctx.stroke() + + # Skalenpunkte + # Alle 5% ein Punkt aber nur da wo noch kein Strich ist + for angle in [x for x in range(-120, 120, 12) if x not in (-120, -60, 0, 60, 120)]: + x, y = self.rotate((cx, cy), ((cx, cy - r + 10),), angle)[0] + ctx.arc(x, y, 2, 0, 2*math.pi) + ctx.fill() diff --git a/pages/fourvalues.py b/pages/fourvalues.py new file mode 100644 index 0000000..2d85a9a --- /dev/null +++ b/pages/fourvalues.py @@ -0,0 +1,75 @@ +""" + +Vier frei wählbare Meßwerte + +Layout ++--------------------+ +| 1 | ++--------------------+ +| 2 | ++--------------------+ +| 3 | ++--------------------+ +| 4 | ++--------------------+ + +""" + +import cairo +from .page import Page + +class FourValues(Page): + + def __init__(self, pageno, cfg, boatdata, boatvalue1, boatvalue2, boatvalue3, boatvalue4): + super().__init__(pageno, cfg, boatdata) + self.value1 = boatvalue1 + self.value2 = boatvalue2 + self.value3 = boatvalue3 + self.value4 = boatvalue4 + + def draw(self, ctx): + # Seitenunterteilung + ctx.rectangle(0, 80, 400, 3) + ctx.rectangle(0, 146, 400, 3) + ctx.rectangle(0, 214, 400, 3) + ctx.fill() + + # + + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(32) + ctx.move_to(20, 45) + ctx.show_text("AWA") + ctx.move_to(20, 113) + ctx.show_text("AWS") + ctx.move_to(20, 181) + ctx.show_text("COG") + ctx.move_to(20, 249) + ctx.show_text("STW") + ctx.stroke() + + # Units + ctx.set_font_size(16) + ctx.move_to(20, 65) + ctx.show_text("Deg") + ctx.move_to(20, 133) + ctx.show_text("kn") + ctx.move_to(20, 201) + ctx.show_text("Deg") + ctx.move_to(20, 269) + ctx.show_text("kn") + ctx.stroke() + + # Meßwerte + ctx.select_font_face("DSEG7 Classic") + ctx.set_font_size(40) + + ctx.move_to(180, 65) + ctx.show_text("150") + ctx.move_to(180, 133) + ctx.show_text("25.3") + ctx.move_to(180, 201) + ctx.show_text("146") + ctx.move_to(180, 269) + ctx.show_text("56.4") + diff --git a/pages/fourvalues2.py b/pages/fourvalues2.py new file mode 100644 index 0000000..14ff400 --- /dev/null +++ b/pages/fourvalues2.py @@ -0,0 +1,77 @@ +""" + +Vier frei auswählbare Meßwerte + +Layout ++--------------------+ +| 1 | ++--------------------+ +| 2 | ++--------------------+ +| 3 | 4 | ++--------------------+ + +""" + +import cairo +from .page import Page + +class FourValues2(Page): + + def __init__(self, pageno, cfg, boatdata, boatvalue1, boatvalue2, boatvalue3, boatvalue4): + super().__init__(pageno, cfg, boatdata) + self.value1 = boatvalue1 + self.value2 = boatvalue2 + self.value3 = boatvalue3 + self.value4 = boatvalue4 + + def draw(self, ctx): + + # Seitenunterteilung + ctx.rectangle(0, 105, 400, 3) + ctx.rectangle(0, 195, 400, 3) + ctx.rectangle(200, 195, 3, 75) + ctx.fill() + + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + + # Titel + ctx.set_font_size(40) + ctx.move_to(20, 55) + ctx.show_text("AWA") + ctx.move_to(20, 145) + ctx.show_text("AWS") + + ctx.set_font_size(24) + ctx.move_to(20, 220) + ctx.show_text("COG") + ctx.move_to(220, 220) + ctx.show_text("STW") + + # Einheiten + ctx.set_font_size(16) + ctx.move_to(20, 90) + ctx.show_text("Deg") + ctx.move_to(20, 180) + ctx.show_text("kn") + + ctx.set_font_size(16) + ctx.move_to(20, 240) + ctx.show_text("Deg") + ctx.move_to(220, 240) + ctx.show_text("kn") + + # Meßwerte + ctx.select_font_face("DSEG7 Classic") + ctx.set_font_size(60) + ctx.move_to(180, 90) + ctx.show_text("150") + ctx.move_to(180, 180) + ctx.show_text("33.0") + + ctx.set_font_size(40) + ctx.move_to(80, 270) + ctx.show_text("146") + ctx.move_to(280, 270) + ctx.show_text("50.5") + ctx.stroke() diff --git a/pages/generator.py b/pages/generator.py new file mode 100644 index 0000000..0a92593 --- /dev/null +++ b/pages/generator.py @@ -0,0 +1,44 @@ +""" +WIP Mangels Generator keine Überprüfung möglich +Dies ist im Prinzip ein Platzhalter + +""" + +import cairo +from .page import Page +import math + +class Generator(Page): + + def draw_generator(self, ctx, x, y, r): + ctx.set_line_width(4.0) + ctx.arc(x, y, r) + ctx.set_font_size(60) + self.draw_text_center(ctx, x, y, "G") + + def draw(self, ctx): + # Name + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(40) + ctx.move_to(10, 65) + ctx.show_text("Power") + ctx.move_to(12, 82) + ctx.show_text("Generator") + + # Voltage type + ctx.select_font_face("DSEG7 Classic") + ctx.move_to(10, 140) + # 12 or 24 + ctx.show_text("12V") + + # Generator power + # kW or W + + # Show load level in percent + + # Show sensor type info + # INA219, INA226 + + # Current, A + + # Consumption, W diff --git a/pages/keel.py b/pages/keel.py new file mode 100644 index 0000000..e2aa630 --- /dev/null +++ b/pages/keel.py @@ -0,0 +1,114 @@ +""" + +Rotationssensor AS5600 mit Funktion "Kiel" +WIP + +Idee: + - Zusätzlich Anzeigemöglichkeit für die Tiefe eines variablen Kiels + - Mode-Taste + +""" + +import cairo +import math +from .page import Page + +class Keel(Page): + + def __init__(self, pageno, cfg, boatdata): + super().__init__(pageno, cfg, boatdata) + # Wert für Kielrotation + self.valref = self.bd.getRef("xdrRotK") + + def draw(self, ctx): + + # Mitte oben Instrument (Halbkreis) + cx = 200 + cy = 150 + + # Radius Kielposition + r = 110 + + # Titel + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(32) + ctx.move_to(100, 70) + ctx.show_text("Keel Position") + ctx.set_font_size(24) + ctx.move_to(175, 110) + ctx.show_text(self.valref.unit) + ctx.stroke() + + + # Halbkreis für Skala + ctx.set_source_rgb(*self.fgcolor) + ctx.set_line_width(3) + ctx.arc(cx, cy, r + 10, 0, math.pi) + ctx.stroke() + + # Skala mit Strichen, Punkten und Beschriftung + char = { + 90: "45", + 120: "30", + 150: "15", + 180: "0", + 210: "15", + 240: "30", + 270: "45" + } + # Zeichnen in 10°-Schritten + ctx.set_font_size(16) + for i in range(90, 271, 10): + fx = math.sin(i / 180 * math.pi) + fy = math.cos(i / 180 * math.pi) + if i in char: + x = cx + (r - 30) * fx + y = cy - (r - 30) * fy + self.draw_text_center(ctx, x, y, char[i]) + ctx.stroke() + if i % 30 == 0: + ctx.move_to(cx + (r - 10) * fx, cy - (r - 10) * fy) + ctx.line_to(cx + (r + 10) * fx, cy - (r + 10) * fy) + ctx.stroke() + else: + x = cx + r * fx + y = cy - r * fy + ctx.arc(x, y, 2, 0, 2*math.pi) + ctx.fill() + + # Boot und Wasserlinie + ctx.arc(cx, cy - 10, 28, 0, math.pi) + ctx.fill() + ctx.set_line_width(4) + ctx.move_to(150, cy) + ctx.line_to(250, cy) + ctx.stroke() + + #ctx.arc(200, 150, r + 10, 0, 2*math.pi) + #ctx.fill() + #ctx.set_source_rgb(*self.bgcolor) + #ctx.arc(200, 150, r + 7, 0, 2* math.pi) + #ctx.rectangle(0, 30, 299, 122) + #ctx.fill() + + angle = -15 + #angle = self.valref.value + #TODO Angle limits to +/-45° + if angle < -45: + angle = -45 + elif angle > 45: + angle = 45 + angle *= 2 # stretched scale + + # Kiel + p = ((cx - 6, cy), (cx + 6, cy), (cx + 2, cy + r - 50), (cx - 2, cy + r - 50)) + keel = self.rotate((cx, cy), p, angle) + ctx.move_to(*keel[0]) + for point in keel[1:]: + ctx.line_to(*point) + ctx.fill() + + # Kiel-Bombe + x, y = self.rotate((cx, cy), ((cx, cy + r -50),), angle)[0] + ctx.arc(x, y, 5, 0, 2*math.pi) + ctx.fill() diff --git a/pages/onegraph.py b/pages/onegraph.py new file mode 100644 index 0000000..2be2e84 --- /dev/null +++ b/pages/onegraph.py @@ -0,0 +1,26 @@ +""" + +Frei auswählbaren Meßwert als Graphen anzeigen + + +YDGS01 + History Request PGN 61184 + History Data PGN 130816 + + + +""" + +import cairo +from .page import Page + +class OneGraph(Page): + + def draw(self, ctx): + # Name + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(60) + ctx.move_to(20, 100) + ctx.show_text("One Graph") + + # Graph anzeigen X/Y diff --git a/pages/onevalue.py b/pages/onevalue.py new file mode 100644 index 0000000..3846060 --- /dev/null +++ b/pages/onevalue.py @@ -0,0 +1,31 @@ +import cairo +from .page import Page + +class OneValue(Page): + + def __init__(self, pageno, cfg, boatdata, boatvalue): + super().__init__(pageno, cfg, boatdata) + self.ref1 = self.bd.getRef(boatvalue) + + def draw(self, ctx): + + # Bezeichnung + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(60) + ctx.move_to(20, 100) + ctx.show_text(self.ref1.valname) + + # Einheit + ctx.set_font_size(40) + ctx.move_to(270, 100) + ctx.show_text(self.ref1.unit) + + # Meßwert + ctx.select_font_face("DSEG7 Classic") + ctx.set_font_size(100) + ctx.move_to(40, 240) + if self.ref1.value: + ctx.show_text(self.ref1.format()) + else: + ctx.show_text(self.placeholder) + ctx.stroke() diff --git a/pages/page.py b/pages/page.py new file mode 100644 index 0000000..d7b8dfe --- /dev/null +++ b/pages/page.py @@ -0,0 +1,202 @@ +""" + +Basisklasse für alle Darstellungsseiten + +Hinweise zu Cairo: +Das Koordinatensystem geht von (0, 0) links oben bis (400, 300) rechts unten. +Um exakte Pixel zu treffen müssen Koordinaten mit Offset 0.5 verwendet werden. + +""" +import os +import cairo +import math +from datetime import datetime + +class Page(): + + pageno = 1 # Nummer der aktuell sichtbaren Seite + backlight = False + color_normal = "dcdcdc" # Standardhintergrund + color_lighted = "d89090" # Hintergrund im Nachtmodus + + bgcolor = (0.86, 0.86, 0.86) + fgcolor = (0, 0, 0) + + @staticmethod + def hexcolor(hexstr): + if (len(hexstr) != 6) or (not all(c.lower in '0123456789abcdef' for c in hexstr)): + raise ValueError('Not a valid RGB Hexstring') + else: + return(int(hexstr[0:2], 16) / 255.0, + int(hexstr[2:4], 16) / 255.0, + int(hexstr[4:6], 16) / 255.0) + + @staticmethod + def rotate (origin, points, angle): + # operates on tuples, angle in degrees + ox, oy = origin + phi = math.radians(angle) + fs = math.sin(phi) + fc = math.cos(phi) + rotated = [] + for x, y in points: + dx = x - ox + dy = y - oy + rotated.append((ox + fc * dx - fs * dy, oy + fs * dx + fc * dy)) + return rotated + + def __init__(self, pageno, cfg, boatdata): + self.pageno = pageno + self.cfg = cfg + self.bd = boatdata + self.header = True + self.footer = True + self.hbled = False # Heartbeat LED + self.hbfreq = 1000 # Heartbeat Frequenz in ms + self.keylock = False + self.icon = {} + self.icon['PREV'] = cairo.ImageSurface.create_from_png(os.path.join(cfg['imgpath'], "arrow_l1.png")) + self.icon['NEXT'] = cairo.ImageSurface.create_from_png(os.path.join(cfg['imgpath'], "arrow_r1.png")) + self.icon['ILUM'] = cairo.ImageSurface.create_from_png(os.path.join(cfg['imgpath'], "lighton.png")) + self.sym_lock = cairo.ImageSurface.create_from_png(os.path.join(cfg['imgpath'], "lock.png")) + self.sym_swipe = cairo.ImageSurface.create_from_png(os.path.join(cfg['imgpath'], "swipe.png")) + self.buttonlabel = { + 1: '', + 2: '', + 3: '#PREV', + 4: '#NEXT', + 5: '', + 6: '#ILUM' + } + + def handle_key(self, buttonid): + """ + Diese Methode sollte in der Detailseite überladen werden + """ + print(f"Button no. {buttonid} ignored") + return False + + def heartbeat(self, ctx): + """ + Wie ausschalten bei Seitenwechsel? + """ + ctx.save() + if self.hbled: + ctx.set_source_rgb(0, 0, 0) + else: + ctx.set_source_rgb(0.86, 0.86, 0.86) # 0xdcdcdc + ctx.arc(210, 9, 6, 0, math.pi*2) + ctx.fill() + ctx.restore() + self.hbled = not self.hbled + + def draw_header(self, ctx): + """ + Mögliche Zeichen für aktivierte Funktionen + AP - Accesspoint ist aktiv + WIFI - WIFI-Client + TCP + N2K - NMEA2000 + 183 + USB + GPS - GPS Fix vorhanden + # TODO Umstellung auf Symbole je 16 Pixel zum Platz sparen + Neu: Nummer der aktiven Seite (1 - 10) + """ + 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.stroke() + + # Seitennummer neue Darstellung + ctx.set_line_width(1) + ctx.move_to(170.5, 1.5) + ctx.line_to(190.5, 1.5) + ctx.line_to(190.5, 16.5) + ctx.line_to(170.5, 16.5) + ctx.line_to(170.5, 1.5) + ctx.stroke() + self.draw_text_center(ctx, 180, 9.5, str(self.pageno)) + + # Tastenstatus + ctx.save() + if self.keylock: + ctx.set_source_surface(self.sym_lock, 150, 1) + else: + ctx.set_source_surface(self.sym_swipe, 150, 1) + ctx.paint() + ctx.restore() + + # Heartbeat + self.heartbeat(ctx) + + # Datum und Uhrzeit + ctx.move_to(230, 14.5) + ctx.show_text(datetime.today().strftime('%H:%M %Y-%m-%d LOT')) + ctx.stroke() + + def draw_footer(self, ctx): + """ + Nur Belegung der Buttons (label[1] bis label[6]) + """ + ctx.select_font_face("AtariST8x16SystemFont") + #ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(16) + x = (35, 101, 167, 233, 299, 365) + y = 294 + for i in range(6): + if len(self.buttonlabel[i+1]) > 0 : + if self.buttonlabel[i+1][0] == "#": + # Symbol verwenden 16x16 Pixel + ctx.save() + key = self.buttonlabel[i+1][1:] + ctx.set_source_surface(self.icon[key], x[i]-8, y-13) + ctx.paint() + ctx.restore() + else: + text = "[ {} ]".format(self.buttonlabel[i+1]) + w = ctx.text_extents(text).width + ctx.move_to(x[i] - w/2, y) + ctx.show_text(text) + ctx.stroke() + + def clear(self): + ctx.set_source_rgb(1, 1, 1) + ctx.rectangle(0, 0, 399, 299) + ctx.fill() + ctx.set_source_rgb(0, 0, 0) + + def draw_text_center(self, ctx, x, y, content, rotate=False, baseline=False, fill=False): + ext = ctx.text_extents(content) + if fill: + ctx.set_source_rgb(*self.bgcolor) + xf = x + ext.x_bearing - 2 + yf = y + ext.height / 2 + ext.y_bearing - 2 + wf = ext.width + 4 + hf = ext.height + 4 + ctx.rectangle(xf, yf, wf, hf) + ctx.fill() + ctx.set_source_rgb(*self.fgcolor) + if rotate: + if baseline: + ctx.move_to(x - ext[3] / 2.0, y) + else: + ctx.move_to(x - ext[3] / 2.0, y + ext[2] / 2.0) + ctx.save() + ctx.rotate(1.5 * math.pi) + ctx.show_text(content) + ctx.restore() + else: + if baseline: + ctx.move_to(x - ext[2] / 2.0, y) + else: + ctx.move_to(x - ext[2] / 2.0, y + ext[3] / 2.0) + ctx.show_text(content) + ctx.stroke() + + def draw_text_ralign(self, ctx, x, y, content): + ext = ctx.text_extents(content) + ctx.move_to(x - ext[2], y) + ctx.show_text(content) + ctx.stroke() diff --git a/pages/rollpitch.py b/pages/rollpitch.py new file mode 100644 index 0000000..d9fac18 --- /dev/null +++ b/pages/rollpitch.py @@ -0,0 +1,13 @@ +import cairo +from .page import Page + +class RollPitch(Page): + + def draw(self, ctx): + # Name + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(60) + ctx.move_to(20, 100) + ctx.show_text("RollPitch") + + # Graph anzeigen X/Y diff --git a/pages/rudder.py b/pages/rudder.py new file mode 100644 index 0000000..409b7e5 --- /dev/null +++ b/pages/rudder.py @@ -0,0 +1,103 @@ +import cairo +import math +from .page import Page + +class Rudder(Page): + + def __init__(self, pageno, cfg, boatdata): + super().__init__(pageno, cfg, boatdata) + self.buttonlabel[1] = 'MODE' + self.mode = 'P' + # Werte für Ruderausschlag + self.valpri = self.bd.getRef("RPOS") # Primäres Ruder + self.valsec = self.bd.getRef("PRPOS") # Sekundäres Ruder + + def handle_key(self, buttonid): + if buttonid == 1: + if self.mode == 'P': + self.mode = 'S' + else: + self.mode = 'P' + return True + return False + + def draw(self, ctx): + + # Ruder auswählen + if self.mode == 'P': + valref = self.valpri + else: + valref = self.valsec + + # Rotationszentrum + cx = 200 + cy = 150 + + # Radius des Instruments + r = 110 + + # Titel + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(32) + ctx.move_to(80, 70) + ctx.show_text("Rudder Position") + + ctx.set_font_size(24) + if valref.valid: + ctx.move_to(175, 110) + ctx.show_text(valref.unit) + else: + ctx.move_to(110, 110) + ctx.show_text("no data available") + ctx.stroke() + + # Debug + angle = 5 + + # Rahmen + ctx.set_source_rgb(*self.fgcolor) + ctx.set_line_width(3) + ctx.arc(cx, cy, r + 10, 0, math.pi) + ctx.stroke() + + # Zentrum + ctx.arc(cx, cy, 8, 0, 2*math.pi) + ctx.fill() + + # Skala mit Strichen, Punkten und Beschriftung + char = { + 90: "45", + 120: "30", + 150: "15", + 180: "0", + 210: "15", + 240: "30", + 270: "45" + } + # Zeichnen in 10°-Schritten + ctx.set_font_size(16) + for i in range(90, 271, 10): + fx = math.sin(i / 180 * math.pi) + fy = math.cos(i / 180 * math.pi) + if i in char: + x = cx + (r - 30) * fx + y = cy - (r - 30) * fy + self.draw_text_center(ctx, x, y, char[i]) + ctx.stroke() + if i % 30 == 0: + ctx.move_to(cx + (r - 10) * fx, cy - (r - 10) * fy) + ctx.line_to(cx + (r + 10) * fx, cy - (r + 10) * fy) + ctx.stroke() + else: + x = cx + r * fx + y = cy - r * fy + ctx.arc(x, y, 2, 0, 2*math.pi) + ctx.fill() + + if valref.valid: + p = ((cx - 6, cy), (cx + 6, cy), (cx + 2, cy + r - 50), (cx - 2, cy + r - 50)) + rudder = self.rotate((cx, cy), p, angle) + ctx.move_to(*rudder[0]) + for point in rudder[1:]: + ctx.line_to(*point) + ctx.fill() diff --git a/pages/skyview.py b/pages/skyview.py new file mode 100644 index 0000000..610ada6 --- /dev/null +++ b/pages/skyview.py @@ -0,0 +1,85 @@ +""" + +Satelliteninfos + + - Sat mit Fix: ausgefüllter Kreis + - Sat ohne fix: leerer Kreis + - Slots für 12 SNR-Balken + +""" + +import cairo +import math +from .page import Page + +class SkyView(Page): + + def __init__(self, pageno, cfg, boatdata): + super().__init__(pageno, cfg, boatdata) + + def pol2cart(azimuth, elevation): + ''' + Polar to Cartesian coordinates within the horizon circle. + azimuth in radians + x = math.sin(azimuth) * elevation * self.radius + y = math.cos(azimuth) * elevation * self.radius + ''' + pass + # (x, y) = self.pol2cart(sat.az, sat.el) + + def draw(self, ctx): + + # Name + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + #ctx.set_font_size(32) + #self.draw_text_center(ctx, 200, 40, "Satellite Info") + + # Spezialseite + cx = 130 + cy = 150 + r = 125 + r1 = r / 2 + + ctx.set_line_width(1.5) + ctx.arc(cx, cy, r, 0, 2*math.pi) + ctx.stroke() + ctx.arc(cx, cy, r1, 0, 2*math.pi) + ctx.stroke() + ctx.set_dash([4, 4], 0) + ctx.move_to(cx, cy - r) + ctx.line_to(cx, cy + r) + ctx.move_to(cx - r, cy) + ctx.line_to(cx + r, cy) + ctx.stroke() + ctx.set_dash([], 0) + + # Signal/Noise Balken + ctx.set_font_size(16) + ctx.move_to(325, 34) + ctx.show_text("SNR") + ctx.stroke() + + ctx.set_line_width(1.5) + ctx.rectangle(270, 20, 125, 257) + + # Beispieldaten + ctx.set_line_width(0.5) + for s in range(12): + y = 30 + (s + 1) * 20 + ctx.move_to(275, y) + ctx.show_text(f"{s:02d}") + ctx.rectangle(305, y-12, 85, 14) + ctx.stroke() + + ctx.set_line_width(1.0) + for s in self.bd.sat.values(): + x = cx + math.sin(s.azimuth) * s.elevation * r + y = cy + math.cos(s.azimuth) * s.elevation * r + ctx.arc(x, y, 4, 0, 2*math.pi) + ctx.move_to(x+4, y+4) + ctx.show_text(f"{s.prn_num}") + ctx.stroke() + + # Satellitenliste mit SNR-Balken sortiert nach nummer + for prn_num in sorted(self.bd.sat): + print(prn_num) \ No newline at end of file diff --git a/pages/solar.py b/pages/solar.py new file mode 100644 index 0000000..a384654 --- /dev/null +++ b/pages/solar.py @@ -0,0 +1,40 @@ +import cairo +from .page import Page + +class Solar(Page): + + + def draw_solar(self, ctx, x, y, w, h): + pass + """ + // Solar graphic with fill level + void solarGraphic(uint x, uint y, int pcolor, int bcolor){ + // Show solar modul + int xb = x; // X position + int yb = y; // Y position + int t = 4; // Line thickness + int percent = 0; + // Solar corpus 100x80 + int level = int((100.0 - percent) * (80-(2*t)) / 100.0); + getdisplay().fillRect(xb, yb, 100, 80, pcolor); + if(percent < 99){ + getdisplay().fillRect(xb+t, yb+t, 100-(2*t), level, bcolor); + } + // Draw horizontel lines + getdisplay().fillRect(xb, yb+28-t, 100, t, pcolor); + getdisplay().fillRect(xb, yb+54-t, 100, t, pcolor); + // Draw vertical lines + getdisplay().fillRect(xb+19+t, yb, t, 80, pcolor); + getdisplay().fillRect(xb+39+2*t, yb, t, 80, pcolor); + getdisplay().fillRect(xb+59+3*t, yb, t, 80, pcolor); + + } + """ + + def draw(self, ctx): + # Name + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(60) + ctx.move_to(20, 100) + ctx.show_text("Solar") + diff --git a/pages/system.py b/pages/system.py new file mode 100644 index 0000000..89398d5 --- /dev/null +++ b/pages/system.py @@ -0,0 +1,107 @@ +import cairo +from .page import Page +import datetime + +class System(Page): + + def __init__(self, pageno, cfg, boatdata): + super().__init__(pageno, cfg, boatdata) + self.buttonlabel[1] = 'MODE' + self.buttonlabel[2] = 'STBY' + self.mode = ('I', 'N') # (I)nformation (N)MEA2000 Device List + self.modeindex = 1 + self.standby = False + + def handle_key(self, buttonid): + if self.standby and buttonid != 1: + return True + if buttonid == 1: + if self.standby: + self.standby = False + self.buttonlabel[1] = 'MODE' + self.buttonlabel[2] = 'STBY' + self.header = True + self.footer = True + else: + if self.modeindex < len(self.mode): + self.modeindex += 1 + else: + self.modeindex = 0 + return True + if buttonid == 2: + self.buttonlabel[1] = None + self.buttonlabel[2] = None + self.header = False + self.footer = False + self.standby = True + return False + + def draw_stby(self, ctx): + # Standby + # TODO Kopf und Fußzeile ausschalten + # Ein Klick auf die Mode-Taste weckt das System wieder auf + pass + + def draw_info(self, ctx): + + # Bezeichnung + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(32) + self.draw_text_center(ctx, 200, 40 , "System Info") + + ctx.set_font_size(16) + + # System name + # Software version + + # Linke Seite + ctx.move_to(2, 80) + ctx.show_text("Simulation: ") + ctx.move_to(140, 80) + ctx.show_text('On' if self.cfg['simulation'] else 'Off') + + ctx.move_to(2, 100) + ctx.show_text("BME280/BMP280: ") + ctx.move_to(140, 100) + ctx.show_text('On' if self.cfg['bme280'] else 'Off') + + ctx.move_to(2, 120) + ctx.show_text("GPS: ") + ctx.move_to(140, 120) + ctx.show_text('On' if self.cfg['gps'] else 'Off') + + # Rechte Seite + ctx.move_to(202, 80) + ctx.show_text("Wifi: ") + ctx.move_to(340, 80) + ctx.show_text('On') + + ctx.move_to(202, 100) + ctx.show_text("Buzzer: ") + ctx.move_to(340, 100) + ctx.show_text('60%') + + ctx.move_to(202, 120) + ctx.show_text("Timezone: ") + ctx.move_to(340, 120) + ctx.show_text(datetime.datetime.now().astimezone().tzname()) + + ctx.stroke() + + # Geräteliste + ctx.move_to(2, 150) + ctx.show_text("NMEA2000 Device List") + + ctx.set_line_width(1.5) + ctx.rectangle(2, 155, 394, 100) + ctx.stroke() + + def draw_devlist(self, ctx): + # NMEA2000 Geräteliste, Vollbild + # scrollen mit Up/Down + pass + + def draw(self, ctx): + if self.standby: + return + self.draw_info(ctx) diff --git a/pages/threevalues.py b/pages/threevalues.py new file mode 100644 index 0000000..0c1093e --- /dev/null +++ b/pages/threevalues.py @@ -0,0 +1,47 @@ +import cairo +from .page import Page + +class ThreeValues(Page): + + def __init__(self, pageno, cfg, boatdata, boatvalue1, boatvalue2, boatvalue3): + super().__init__(pageno, cfg, boatdata) + self.ref1 = self.bd.getRef(boatvalue1) + self.ref2 = self.bd.getRef(boatvalue2) + self.ref3 = self.bd.getRef(boatvalue3) + + def draw(self, ctx): + + # Seitenlayout + ctx.rectangle(0, 105, 400, 3) + ctx.rectangle(0, 195, 400, 3) + ctx.fill() + + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + + # Titel + ctx.set_font_size(40) + ctx.move_to(20, 55) + ctx.show_text(self.ref1.valname) + ctx.move_to(20, 145) + ctx.show_text(self.ref2.valname) + ctx.move_to(20, 235) + ctx.show_text(self.ref3.valname) + + # Einheiten + ctx.set_font_size(24) + ctx.move_to(20, 90) + ctx.show_text(self.ref1.unit) + ctx.move_to(20, 180) + ctx.show_text(self.ref2.unit) + ctx.move_to(20, 270) + ctx.show_text(self.ref3.unit) + + ctx.select_font_face("DSEG7 Classic") + ctx.set_font_size(60) + ctx.move_to(180, 90) + ctx.show_text(self.ref1.format()) + ctx.move_to(180, 180) + ctx.show_text(self.ref2.format()) + ctx.move_to(180, 270) + ctx.show_text(self.ref3.format()) + ctx.stroke() diff --git a/pages/twographs.py b/pages/twographs.py new file mode 100644 index 0000000..0c26ccb --- /dev/null +++ b/pages/twographs.py @@ -0,0 +1,14 @@ +import cairo +from .page import Page + +class TwoGraphs(Page): + + def draw(self, ctx): + # Name + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(60) + ctx.move_to(20, 100) + ctx.show_text("Two Graphs") + + # Zwei Graphen anzeigen X/Y nebeneinander + diff --git a/pages/twovalues.py b/pages/twovalues.py new file mode 100644 index 0000000..5f055e6 --- /dev/null +++ b/pages/twovalues.py @@ -0,0 +1,67 @@ +""" + +Anzeige von zwei frei definierbaren Werten + +Layout ++--------------------+ +| 1 | ++--------------------+ +| 2 | ++--------------------+ + +""" + +import cairo +from .page import Page + +class TwoValues(Page): + + def __init__(self, pageno, cfg, boatdata, boatvalue1, boatvalue2): + super().__init__(pageno, cfg, boatdata) + self.ref1 = self.bd.getRef(boatvalue1) + self.ref2 = self.bd.getRef(boatvalue2) + #print(self.ref1.valname) + + def draw(self, ctx): + + # Seitenunterteilung + ctx.rectangle(0, 145, 400, 3) + ctx.fill() + + # Name + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(40) + ctx.move_to(20, 80) + ctx.show_text(self.ref1.valname) + ctx.move_to(20, 190) + ctx.show_text(self.ref2.valname) + + # Einheit + ctx.set_font_size(24) + ctx.move_to(20, 130) + ctx.show_text(self.ref1.unit) + ctx.move_to(20, 240) + ctx.show_text(self.ref2.unit) + + # Meßwerte + if type(self.ref1 == 'BoatValueGeo'): + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + ctx.set_font_size(40) + ctx.move_to(140, 100) + else: + ctx.select_font_face("DSEG7 Classic") + ctx.set_font_size(84) + ctx.move_to(180, 130) + ctx.show_text(self.ref1.format()) + + if type(self.ref2 == 'BoatValueGeo'): + ctx.select_font_face("Ubuntu") + ctx.set_font_size(40) + ctx.move_to(140, 210) + else: + ctx.select_font_face("DSEG7 Classic") + ctx.set_font_size(84) + ctx.move_to(180, 240) + ctx.show_text(self.ref2.format()) + + ctx.stroke() diff --git a/pages/voltage.py b/pages/voltage.py new file mode 100644 index 0000000..057d06b --- /dev/null +++ b/pages/voltage.py @@ -0,0 +1,216 @@ +""" + +Integrierte Spannungsmessung + +Ideen: + - Umschaltung Datenquelle: intern, N2K + - Umschaltung analog / digital / Graphik + - Historische Werte vom YD-Batteriemonitor + +""" + +import cairo +import math +from .page import Page + +class Voltage(Page): + + avg = (1, 10, 60, 300); + + def __init__(self, pageno, cfg, boatdata): + super().__init__(pageno, cfg, boatdata) + self.trend = True + self.mode = 'A' + self.avgindex = 0 + self.buttonlabel[1] = 'AVG' + self.buttonlabel[2] = 'MODE' + self.buttonlabel[5] = 'TRD' + self.lastvalue = self.bd.voltage.value + + def handle_key(self, buttonid): + if buttonid == 1: + if self.avgindex < len(self.avg) -1: + self.avgindex += 1 + else: + self.avgindex = 0 + elif buttonid == 2: + if self.mode == 'A': + self.mode = 'D' + else: + self.mode = 'A' + elif buttonid == 5: + self.trend = not self.trend + + def setBoatValue(self, boatvalue): + # Einstellen welcher Wert dargestellt werden soll + self.value1 = boatvalue + + def draw(self, ctx): + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + if self.mode == 'A': + self.draw_analog(ctx) + else: + self.draw_digital(ctx) + + def draw_analog(self, ctx): + # TODO schönes Viertelkreis-Instrument + # Skala von 9V bis 15V + # d.h. 90° entsprechend unterteilen in 6 Stücke je 15° + # Beschriftung 10, 11, 12, 13, 14 + + # Datenquelle + ctx.set_font_size(16) + ctx.move_to(300, 40) + ctx.show_text("Source:") + ctx.move_to(300, 60) + ctx.show_text("VBat") + + # Batterietyp + ctx.move_to(300, 90) + ctx.show_text("Type:") + ctx.move_to(300, 110) + ctx.show_text("AGM") + + # Glättung + ctx.move_to(300, 140) + ctx.show_text("Avg:") + ctx.move_to(300, 160) + ctx.show_text("1s") + ctx.stroke() + + # Gleichstromsymbol + ctx.set_font_size(32) + ctx.move_to(20, 80) + ctx.show_text("V") + ctx.set_line_width(3) + ctx.move_to(20, 86.5) # obere Linie + ctx.line_to(40, 86.5) + ctx.move_to(20, 91.5) # untere drei kurzen Linien + ctx.line_to(25, 91.5) + ctx.move_to(27, 91.5) + ctx.line_to(33, 91.5) + ctx.move_to(35, 91.5) + ctx.line_to(40, 91.5) + ctx.stroke() + + # Kreis-segment 90° + + # Rotationszentrum + cx = 260 + cy = 270 + + # Radius des Instruments + r = 240 + + ctx.set_source_rgb(*self.fgcolor) + ctx.set_line_width(2) + + ctx.arc(cx, cy, r, math.pi, 1.5*math.pi) + ctx.stroke() + + + # Beschriftung + ctx.set_font_size(20) + label = {285: "10", 300: "11", 315: "12", 330: "13", 345: "14"} + for angle in label: + x, y = self.rotate((cx, cy), ((cx, cy - r + 30),), angle)[0] + self.draw_text_center(ctx, x, y, label[angle]) + + # grobe Skala + p = ((cx, cy-r), (cx, cy - r + 12)) + ctx.set_line_width(2) + for angle in label: + line = self.rotate((cx, cy), p, angle) + ctx.move_to(*line[0]) + ctx.line_to(*line[1]) + ctx.stroke() + + # feine Skala + p = ((cx, cy-r), (cx, cy - r + 5)) + ctx.set_line_width(1) + for angle in [x for x in range(273, 360, 3)]: + if angle in label: + continue + line = self.rotate((cx, cy), p, angle) + ctx.move_to(*line[0]) + ctx.line_to(*line[1]) + ctx.stroke() + + # Zeiger auf 0-Position + val = float(self.bd.voltage.format()) + if not val: + angle = -0.5 + elif val > 15: + angle = 91 + elif val <= 9: + angle = -0.5 + else: + angle = (val - 9) * 15 + p = ((cx - 2, cy + 4), + (cx - r + 35, cy + 2), + (cx - r + 35, cy + 1), + (cx - r + 5, cy + 1), + (cx - r + 5, cy - 1), + (cx - r + 35, cy - 1), + (cx - r + 35, cy - 2), + (cx - 2, cy - 4)) + zeiger = self.rotate((cx, cy), p, angle) + + # Zeiger zeichnen + ctx.set_line_width(1) + ctx.move_to(*zeiger[0]) + for point in zeiger[1:]: + ctx.line_to(*point) + ctx.fill() + + # Zeigerbasis + ctx.arc(cx, cy, 6, 0, 2*math.pi) + ctx.set_source_rgb(*self.bgcolor) + ctx.fill_preserve() + ctx.set_line_width(2) + ctx.set_source_rgb(*self.fgcolor) + ctx.stroke() + + def draw_digital(self, ctx): + # Name + ctx.set_font_size(60) + ctx.move_to(20, 100) + ctx.show_text("VBat") + # Unit + ctx.set_font_size(40) + ctx.move_to(270, 100) + ctx.show_text("V") + # Battery type + ctx.set_font_size(16) + ctx.move_to(294, 100) + ctx.show_text("AGM") + # Mittelwert + ctx.move_to(320, 84) + ctx.show_text("Avg: {}s".format(self.avg[self.avgindex])) + ctx.stroke() + + ctx.select_font_face("DSEG7 Classic") + ctx.set_font_size(100) + ctx.move_to(40, 240) + ctx.show_text(self.bd.voltage.format()) + ctx.stroke() + + # Trendanzeige + if self.trend and self.bd.voltage.value and self.lastvalue: + ctx.rectangle(315, 183, 35, 4) + ctx.fill() + size = 11 + if self.lastvalue < self.bd.voltage.value: + ctx.move_to(320, 174) + ctx.line_to(320+size*2, 174) + ctx.line_to(320+size, 174-size*2) + ctx.line_to(320, 174) + ctx.fill() + elif self.lastvalue > self.bd.voltage.value: + ctx.move_to(320, 195) + ctx.line_to(320+size*2, 195) + ctx.line_to(320+size, 195+size*2) + ctx.line_to(320, 195) + ctx.fill() + + self.lastvalue = self.bd.voltage.value diff --git a/pages/windrose.py b/pages/windrose.py new file mode 100644 index 0000000..4951a3a --- /dev/null +++ b/pages/windrose.py @@ -0,0 +1,119 @@ +""" + +Windrose und Windroseflex +Benötigt 6 Werte +Hauptwerte: AWA, AWS, TWD, TWS +Nebenwerte: DBT, STW, oder COG, SOG + +""" + +import cairo +import math +from .page import Page + +class WindRose(Page): + + def draw(self, ctx): + + # Rahmen + cx = 200 + cy = 150 + r = 110 + + ctx.set_line_width(3) + ctx.arc(cx, cy, r + 9, 0, 2*math.pi) + ctx.stroke() + ctx.arc(cx, cy, r - 11, 0, 2*math.pi) + ctx.stroke() + + for angle in range(0, 360, 10): + if angle % 30 != 0: + x, y = self.rotate((cx, cy), ((cx, cy - r),), angle)[0] + ctx.arc(x, y, 2, 0, 2*math.pi) + ctx.fill() + else: + p = ((cx, cy - r + 10), (cx, cy - r - 10), (cx, cy - r + 30)) + pr = self.rotate((cx, cy), p, angle) + ctx.move_to(*pr[0]) + ctx.line_to(*pr[1]) + ctx.stroke() + self.draw_text_center(ctx, *pr[2], str(angle)) + + + ctx.select_font_face("Ubuntu", cairo.FontSlant.NORMAL, cairo.FontWeight.BOLD) + + # Namen + ctx.set_font_size(24) + ctx.move_to(10, 95) # links oben + ctx.show_text("AWA") + ctx.move_to(335, 95) # rechts oben + ctx.show_text("TWD") + ctx.move_to(10, 220) # links unten + ctx.show_text("AWS") + ctx.move_to(335, 220) # rechts unten + ctx.show_text("TWS") + + # Units + ctx.set_font_size(16) + ctx.move_to(10, 115) # links oben + ctx.show_text("Deg") + ctx.move_to(335, 115) # rechts oben + ctx.show_text("Deg") + ctx.move_to(10, 190) # links unten + ctx.show_text("kn") + ctx.move_to(335, 190) # rechts unten + ctx.show_text("kn") + + # Horiz. Trennlinien + #ctx.rectangle(0, 149, 60, 3) + #ctx.fill() + ctx.set_line_width(3) + # links + ctx.move_to(0, 149) + ctx.line_to(60, 149) + # rechts + ctx.move_to(340, 149) + ctx.line_to(400, 149) + ctx.stroke() + + # Meßwerte + ctx.select_font_face("DSEG7 Classic") + ctx.set_font_size(40) + # links oben + ctx.move_to(10, 65) + ctx.show_text("148") + # rechts oben + ctx.move_to(295, 65) + ctx.show_text("---") + # links unten + ctx.move_to(10, 270) + ctx.show_text("46.7") + # rechts unten + ctx.move_to(295, 270) + ctx.show_text("77.8") + + ctx.set_font_size(32) + # innen oben + ctx.move_to(160, 130) + ctx.show_text("38.9") + # innen unten + ctx.move_to(160, 200) + ctx.show_text("19.9") + ctx.stroke() + + + # Zeiger + angle = 148 + p = ((cx - 1, cy - (r - 15)), (cx + 1, cy - (r - 15)), (cx + 4, cy), (cx - 4, cy)) + zeiger = self.rotate((cx, cy), p, angle) + ctx.move_to(*zeiger[0]) + for point in zeiger[1:]: + ctx.line_to(*point) + ctx.fill() + + ctx.set_source_rgb(*self.bgcolor) + ctx.arc(cx, cy, 8, 0, 2*math.pi) + ctx.fill() + ctx.set_source_rgb(*self.fgcolor) + ctx.arc(cx, cy, 7, 0, 2*math.pi) + ctx.fill()