Erstveröffentlichung Weihnachten 2024

This commit is contained in:
Thomas Hooge 2024-12-24 09:36:04 +01:00
commit 71b5f84a6f
81 changed files with 5728 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*~
__pycache__

13
INSTALL Normal file
View File

@ -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.

61
README Normal file
View File

@ -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.

BIN
fonts/AtariST8x16.ttf Normal file

Binary file not shown.

Binary file not shown.

BIN
fonts/Ubuntu-B.ttf Normal file

Binary file not shown.

BIN
images/Falling.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
images/Falling_Fast.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 841 B

BIN
images/Rising.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
images/Rising_Fast.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

BIN
images/Rising_Very_Fast.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 847 B

BIN
images/Stationary.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 559 B

BIN
images/alarm.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 B

BIN
images/anchor.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 B

BIN
images/arrow_l1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 B

BIN
images/arrow_r1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 B

BIN
images/bee.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 B

BIN
images/buzzer.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 B

BIN
images/fuelcan.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

BIN
images/fuelpump.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

BIN
images/lightoff.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 B

BIN
images/lighton.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 B

BIN
images/lock.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

BIN
images/ship.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 B

BIN
images/swipe.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 B

BIN
images/water.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
images/wmo0.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 543 B

BIN
images/wmo1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 B

BIN
images/wmo2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 510 B

BIN
images/wmo3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 B

BIN
images/wmo4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 B

BIN
images/wmo5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

BIN
images/wmo6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

BIN
images/wmo7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 B

BIN
images/wmo8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 B

3
nmea2000/__init__.py Normal file
View File

@ -0,0 +1,3 @@
from .device import Device
from .boatdata import BoatData
from .hbuffer import History, HistoryBuffer

567
nmea2000/boatdata.py Normal file
View File

@ -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

63
nmea2000/device.py Normal file
View File

@ -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('<L', self.uniqueid))
data.extend(struct.pack('<L', self.manufacturercode))
data.extend(struct.pack('<L', self.industrygroup))
return data
def __str__(self):
out = f"Device: {self.address} : '{self.product}'\n"
out += f" Instance: {self.instance}\n"
out += f" Product Code: {self.productcode}\n"
out += f" Product: {self.product}\n"
out += f" Serial: {self.serial}\n"
out += f" Model Version: {self.modelvers}\n"
out += f" Software Version: {self.softvers}\n"
out += f" NMEA2000 Version: {self.n2kvers}\n"
out += f" Cert-Level: {self.certlevel}\n"
out += f" LEN: {self.loadequiv}"
return out

17
nmea2000/devicelist.py Normal file
View File

@ -0,0 +1,17 @@
'''
Platzhalter WIP
- ausprogrammieren nach Bedarf
Geräteliste
- wird regelmäßig aktualisiert
'''
class DeviceList():
def __init__(self):
self.devices = list()
def print(self):
for d in self.devicelist:
print(d)

300
nmea2000/hbuffer.py Normal file
View File

@ -0,0 +1,300 @@
"""
History Buffer
Permanent storage backed buffer for sensordata
Only supported at the moment: file system storage
Values can be 1 to 4 bytes in length
Header: 32 bytes of size
0 0x00 HB00 4 magic number
4 0x04 xxxxxxxxxxxxxxxx 16 name, space padded
20 0x14 n 1 byte size of values in buffer
21 0x15 mm 2 buffer size in count of values
23 0x17 dd 2 time step in seconds between values
25 0x19 tttt 4 unix timestamp of head
29 0x1d hh 2 head pointer
31 0x1f 0xff 1 header end sign
32 0x20 ... start of buffer data
Usage example: 7 hours of data collected every 75 seconds
def g_tick(n=1):
t = time.time()
count = 0
while True:
count += n
yield max(t + count - time.time(), 0)
hb = HistoryBuffer("test", 336, 75)
g = g_tick(hb.dt)
hb.filename = "/tmp/test.dat"
hb.begin()
while True:
time.sleep(next(g))
hb.add(measured_new_value)
hb.finish()
TODO
- Logging
- Additional backend: I2C FRAM module
- Sync to next tick after loading from storage
"""
import os
import time
import struct
class HistoryBuffer():
def __init__(self, name, size, delta_t):
"""
Buffer can have an optional name of max. 16 characters
"""
self.magic = b'HB00'
self.name = name[:16] or ''
self.bytesize = 2
self.size = size
self.dt = delta_t
self.headdate = int(time.time())
self.head = 0
self.buf = [0 for _ in range(size)]
self.filename = f"/tmp/hb{name}_{size}-{delta_t}.dat"
self.fp = None
def begin(self):
# Check if data exists and format is correct
if not os.path.exists(self.filename):
self.createfile()
else:
if not self.checkfile():
print(f"Incompatible data file: {self.filename}")
return False
# Read old data to continue processing
self.fp = open(self.filename, 'r+b')
self.headdate = int(time.time())
self.fp.seek(25)
timestamp = struct.unpack('I', self.fp.read(4))[0]
self.head = struct.unpack('H', self.fp.read(2))[0]
self.fp.seek(32)
data = self.fp.read(self.bytesize * self.size)
# Fix difference between current time and data time
missing = (self.headdate - timestamp) // self.dt
if missing > 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 (+/- <n>)
"""
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()

563
nmea2000/lookup.py Normal file
View File

@ -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"
}

25
nmea2000/mavg.py Normal file
View File

@ -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

224
nmea2000/parser.py Normal file
View File

@ -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('<h', buf, 1)[0] * 0.0001
pitch = struct.unpack_from('<h', buf, 3)[0] * 0.0001
roll = struct.unpack_from('<h', buf, 5)[0] * 0.0001
boatdata.setValue("YAW", yaw)
boatdata.setValue("PTCH", pitch)
boatdata.setValue("ROLL", roll)
def parse_127505(buf, boatdata):
# Fluid Level
instance = buf[0] & 0x0f
boatdata.tank[instance].fluidtype = buf[0] >> 4
boatdata.tank[instance].capacity = struct.unpack_from('<L', buf, 2)[0] * 0.1
boatdata.tank[instance].volume = struct.unpack_from('<H', buf, 1)[0] * 0.004
def parse_127508(buf, boatdata):
# Battery status
instance = buf[0]
voltage = (buf[2] * 256 + buf[1]) * 0.01
current = (buf[4] * 256 + buf[3]) * 0.1
temp = (buf[6] * 256 + buf[5]) * 0.01 - 273.15
sid = buf[7]
def parse_129025(buf, boatdata):
# Position, Rapid Update
lat = struct.unpack_from('<l', buf, 0)[0] * 1e-07
lon = struct.unpack_from('<l', buf, 4)[0] * 1e-07
boatdata.setValue("LAT", lat)
boatdata.setValue("LON", lon)
def parse_129026(buf, boatdata):
# COG & SOG, Rapid Update
sid = buf[0]
cogref = buf[1] >> 6 # 0: true, 1: magnetic, 2: error
cog = struct.unpack_from('<H', buf, 2)[0] * 0.0001 # rad
sog = struct.unpack_from('<H', buf, 4)[0] * 0.01 # m/s
# 2 Byte reserved
boatdata.setValue("COG", cog)
boatdata.setValue("SOG", sog)
def parse_129027(buf, boatdata):
# TODO
# Position Delta Rapid Update
sid = buf[0]
dt = struct.unpack_from('<H', buf, 1)[0],
dlat = struct.unpack_from('<h', buf, 3)[0]
dlon = struct.unpack_from('<h', buf, 5)[0]
# 1 Byte reserved
def parse_129029(buf, boatdata):
# GNSS Position Data
'''
1 sid
2 date
4 time seconds since midn
8 lat
8 lon
8 alt
4 bit gnss type
{"name": "GPS", "value": 0},
{"name": "GLONASS", "value": 1},
{"name": "GPS+GLONASS", "value": 2},
{"name": "GPS+SBAS/WAAS", "value": 3},
{"name": "GPS+SBAS/WAAS+GLONASS", "value": 4},
{"name": "Chayka", "value": 5},
{"name": "integrated", "value": 6},
{"name": "surveyed", "value": 7},
{"name": "Galileo", "value": 8}]},
4bit method
{"name": "no GNSS", "value": 0},
{"name": "GNSS fix", "value": 1},
{"name": "DGNSS fix", "value": 2},
{"name": "Precise GNSS", "value": 3},
{"name": "RTK Fixed Integer", "value": 4},
{"name": "RTK float", "value": 5},
{"name": "Estimated (DR) mode", "value": 6},
{"name": "Manual Input", "value": 7},
{"name": "Simulate mode", "value": 8}]},
2bit integrity
{"name": "No integrity checking", "value": 0},
{"name": "Safe", "value": 1},
{"name": "Caution", "value": 2}]},
bit reserved
1 byte uint numberOfSvs Number of satellites used in solution
2byte hdop
2 byte tdop
4 byte geoidalSeparation
1 byte Number of reference stations
4bit referenceStationType
12bit referenceStationId
2byte sageOfDgnssCorrections
'''
def parse_129033(buf, boatdata):
# Time & Date
gpsdate = struct.unpack_from('<H', buf, 0)[0] # days
gpstime = struct.unpack_from('<L', buf, 2)[0] # seconds since midnight
offset = struct.unpack_from('<h', buf, 6)[0] # local offset
# TODO
def parse_129283(buf, boatdata):
# TODO Cross Track Error XTE
sid = buf[0]
mode = buf[1] >> 4
navterm = buf[1] & 0x03
xte = struct.unpack_from('<l', buf, 2)[0] * 0.01 # m
# 2 Byte reserved
boatdata.setValue("XTE", xte)
def parse_129540(buf, boatdata):
#sid = buf[0]
#rrmode = buf[1]
nsats = buf[2]
# Datensatz je Sat Länge: 12 Byte
smax = nsats * 12
for s in range(0, smax, 12):
prn = buf[3 + s]
elevation = struct.unpack_from('<h', buf, s+4)[0] * 0.0001
azimuth = struct.unpack_from('<H', buf, s+6)[0] * 0.0001
snr = struct.unpack_from('<H', buf, s+8)[0] * 0.01
rres = struct.unpack_from('<l', buf, s+10)[0]
status = buf[s+14] & 0x0f
boatdata.updateSatellite(prn, elevation, azimuth, snr, rres, status)
def parse_130312(buf, boatdata):
# Temperature
src = buf[2] # lookup "temperature" (0 .. 15)
val = (buf[4] * 256 + buf[3]) * 0.01 # Kelvin
if instance == 0 and src == 1:
boatdata.setValue("xdrTemp", val)
elif instance in boatdata.temp:
boatdata.temp[instance].value = val
def parse_130314(buf, boatdata):
# Pressure
sid = buf[0]
instance = buf[1]
src = buf[2] # lookup "pressure"
pressure = struct.unpack_from('<L', buf, s+3)[0] * 0.1 # Pa
if instance == 0 and src == 0:
# Generischer Luftdruckwert
boatdata.setValue("xdrPress", pressure)
if instance in boatdata.press:
# Verschiedene weitere Drücke
# TODO sensortype "src"
boatdata.press[instance].value = pressure
def parse_130316(buf, boatdata):
# Temperature, extended range
sid = buf[0]
instance = buf[1]
src = buf[2] # lookup "temperature" (0 .. 15)
val = ((buf[5] << 16) | (buf[4] << 8) | buf[3]) * 0.001
# TODO save in global temp data
# Konflikt mit 130312?
#if instance == 0 and src == 2:
# boatdata.setValue("xdrTemp", val)
# save in engine data
if src == 14 and instance in boatdata.engine:
boatdata.engine[instance].exhaust_temp = val

275
nmea2000/pgntype.py Normal file
View File

@ -0,0 +1,275 @@
pgntype = {
59392: "S",
59904: "S",
60160: "S",
60416: "S",
60928: "S",
61184: "S",
65001: "S",
65002: "S",
65003: "S",
65004: "S",
65005: "S",
65006: "S",
65007: "S",
65008: "S",
65009: "S",
65010: "S",
65011: "S",
65012: "S",
65013: "S",
65014: "S",
65015: "S",
65016: "S",
65017: "S",
65018: "S",
65019: "S",
65020: "S",
65021: "S",
65022: "S",
65023: "S",
65024: "S",
65025: "S",
65026: "S",
65027: "S",
65028: "S",
65029: "S",
65030: "S",
65240: "I",
65280: "S",
65284: "S",
65285: "S",
65286: "S",
65287: "S",
65288: "S",
65289: "S",
65290: "S",
65292: "S",
65293: "S",
65302: "S",
65305: "S",
65309: "S",
65312: "S",
65340: "S",
65341: "S",
65345: "S",
65350: "S",
65359: "S",
65360: "S",
65361: "S",
65371: "S",
65374: "S",
65379: "S",
65408: "S",
65409: "S",
65410: "S",
65420: "S",
65480: "S",
126208: "F",
126464: "F",
126720: "F",
126983: "F",
126984: "F",
126985: "F",
126986: "F",
126987: "F",
126988: "F",
126992: "S",
126993: "S",
126996: "F",
126998: "F",
127233: "F",
127237: "F",
127245: "S",
127250: "S",
127251: "S",
127252: "S",
127257: "S",
127258: "S",
127488: "S",
127489: "F",
127490: "F",
127491: "F",
127493: "S",
127494: "F",
127495: "F",
127496: "F",
127497: "F",
127498: "F",
127500: "S",
127501: "S",
127502: "S",
127503: "F",
127504: "F",
127505: "S",
127506: "F",
127507: "F",
127508: "S",
127509: "F",
127510: "F",
127511: "S",
127512: "S",
127513: "F",
127514: "S",
127744: "S",
127745: "S",
127746: "S",
127750: "S",
127751: "S",
128000: "S",
128001: "S",
128002: "S",
128003: "S",
128006: "S",
128007: "S",
128008: "S",
128259: "S",
128267: "S",
128275: "F",
128520: "F",
128538: "F",
128768: "S",
128769: "S",
128776: "S",
128777: "S",
128778: "S",
128780: "S",
129025: "S",
129026: "S",
129027: "S",
129028: "S",
129029: "F",
129033: "S",
129038: "F",
129039: "F",
129040: "F",
129041: "F",
129044: "F",
129045: "F",
129283: "S",
129284: "F",
129285: "F",
129291: "S",
129301: "F",
129302: "F",
129538: "F",
129539: "S",
129540: "F",
129541: "F",
129542: "F",
129545: "F",
129546: "S",
129547: "F",
129549: "F",
129550: "F",
129551: "F",
129556: "F",
129792: "F",
129793: "F",
129794: "F",
129795: "F",
129796: "F",
129797: "F",
129798: "F",
129799: "F",
129800: "F",
129801: "F",
129802: "F",
129803: "F",
129804: "F",
129805: "F",
129806: "F",
129807: "F",
129808: "F",
129809: "F",
129810: "F",
130052: "F",
130053: "F",
130054: "F",
130060: "F",
130061: "F",
130064: "F",
130065: "F",
130066: "F",
130067: "F",
130068: "F",
130069: "F",
130070: "F",
130071: "F",
130072: "F",
130073: "F",
130074: "F",
130306: "S",
130310: "S",
130311: "S",
130312: "S",
130313: "S",
130314: "S",
130315: "S",
130316: "S",
130320: "F",
130321: "F",
130322: "F",
130323: "F",
130324: "F",
130330: "F",
130560: "S",
130561: "F",
130562: "F",
130563: "F",
130564: "F",
130565: "F",
130566: "F",
130567: "F",
130569: "F",
130570: "F",
130571: "F",
130572: "F",
130573: "F",
130574: "F",
130576: "S",
130577: "F",
130578: "F",
130579: "S",
130580: "F",
130581: "F",
130582: "S",
130583: "F",
130584: "F",
130585: "S",
130586: "F",
130816: "F",
130817: "F",
130818: "F",
130819: "F",
130820: "F",
130821: "F",
130822: "F",
130823: "F",
130824: "F",
130825: "F",
130827: "F",
130828: "F",
130831: "F",
130832: "F",
130833: "F",
130834: "F",
130835: "F",
130836: "F",
130837: "F",
130838: "F",
130839: "F",
130840: "F",
130842: "F",
130843: "F",
130845: "F",
130846: "F",
130847: "F",
130850: "F",
130851: "F",
130856: "F",
130860: "F",
130880: "F",
130881: "F",
130944: "F"
}

27
nmea2000/queue.py Normal file
View File

@ -0,0 +1,27 @@
from heapdict import heapdict
import time
import threading
class FrameQueue():
def __init__(self):
self.heap = heapdict()
self.age_interval = 5 # seconds
def push(self, frame, priority):
timestamp = time.time() # microsecond resolution
self.heap[timestamp] = (priority, timestamp, frame)
def pop(self):
p, f = self.heap.popitem()
self.age()
return f[2]
def age(self):
current_time = time.time()
for key in list(self.heap.keys()):
if current_time - key > self.age_interval:
self.heap[key][0] -= 1
def is_empty(self):
return len(self.heap) == 0

61
nmea2000/receiver.py Normal file
View File

@ -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

17
nmea2000/routing.py Normal file
View File

@ -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

179
nmea2000/valdesc.py Normal file
View File

@ -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"
}
}

96
obp60.conf Normal file
View File

@ -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

543
obp60.py Executable file
View File

@ -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=<nn>
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('<l', msg.data, 0)[0] * 1e-07
#lon = struct.unpack_from('<l', msg.data, 4)[0] * 1e-07
#boatdata.setValue("LAT", lat)
#boatdata.setValue("LON", lon)
parser.parse_129025(msg.data, boatdata)
case 127505:
# Fluid level
#fluidtype = msg.data[0] >> 4
#instance = msg.data[0] & 0x0f
#level = struct.unpack_from('<H', msg.data, 1)[0] * 0.004
#capacity = struct.unpack_from('<L', msg.data, 2)[0] * 0.1
#boatdata.tank[instance].capacity = capacity
#boatdata.tank[instance].volume = level
#boatdata.tank[instance].fluidtype = fluidtype
parser.parse_127505(msg.data, boatdata)
case 127508:
# Battery status
#instance = msg.data[0]
#boatdata.voltage = (msg.data[2] * 256 + msg.data[1]) * 0.01 # Spannung
#ampere = (msg.data[4] * 256 + msg.data[3]) * 0.1 # Stromstärke
#temp = (msg.data[6] * 256 + msg.data[5]) * 0.01 - 273.15 # Temperatur
#sid = msg.data[7]
parser.parse_127508(msg.data, boatdata)
case 129540:
# GNS sats in view
# TODO es kann mehrere Geräte geben die senden
# - source beachten (muß gleich bleiben)
# - gemischte Reihenfolge der Pakete ermöglichen
sc = (msg.data[0] & 0xf0) >> 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 <n> 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.")

106
obp60.svg Normal file
View File

@ -0,0 +1,106 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="529.258"
height="554.04303"
viewBox="0 0 140.03284 146.59055"
version="1.1"
id="svg5"
xml:space="preserve"
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
sodipodi:docname="obp60.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><sodipodi:namedview
id="namedview7"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:document-units="px"
showgrid="false"
inkscape:zoom="0.84096521"
inkscape:cx="394.78446"
inkscape:cy="291.92646"
inkscape:current-layer="layer2" /><defs
id="defs2" /><g
inkscape:groupmode="layer"
id="layer2"
inkscape:label="Rahmen"
style="display:inline"><rect
style="fill:#383838;fill-opacity:0.995886;stroke:#000000;stroke-width:0.264583;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="rect5580"
width="111.65156"
height="101.83302"
x="13.757235"
y="13.865232" /><circle
style="fill:#5f6d9b;fill-opacity:1;stroke:#000000;stroke-width:0.264583;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path5526"
cx="24.958796"
cy="18.318338"
r="1.1906251" /><rect
style="fill:#dcdcdc;fill-opacity:1;stroke:#000000;stroke-width:0.264583;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="rect6475"
width="105.56875"
height="79.11042"
x="16.723654"
y="24.941565" /><path
id="rect376"
style="fill:#a2a2a2;fill-opacity:1;stroke:#000000;stroke-width:0.529167;stroke-linecap:round;stroke-linejoin:round"
d="m 4.9387167,0.2645835 c -2.5895378,0 -4.6741332,2.0296907 -4.6741332,4.5511433 V 135.78447 c 0,2.52146 2.527424,4.27506 4.6741332,4.55166 0,0 36.5284973,6.49111 65.5148633,5.95881 28.986364,-0.5323 64.64049,-5.95881 64.64049,-5.95881 2.76316,-0.44318 4.67414,-2.0302 4.67414,-4.55166 V 4.8157268 c 0,-2.5214526 -2.0846,-4.5511433 -4.67414,-4.5511433 z M 13.857552,13.984676 H 125.45952 v 97.921664 c 0,0 -37.032483,3.56413 -55.632775,3.56413 -18.600293,0 -55.969193,-3.56413 -55.969193,-3.56413 z" /><g
id="g11099"
transform="translate(-5.4497963,-2.865458)"><g
id="g11079"><circle
style="fill:#2a2a2a;fill-opacity:1;stroke:#000000;stroke-width:0.264583;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path8373"
cx="24.618317"
cy="130.36757"
r="7.9375" /><path
sodipodi:type="star"
style="fill:#525252;fill-opacity:1;stroke:#000000;stroke-width:0.616746;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
id="path9879"
inkscape:flatsided="true"
sodipodi:sides="6"
sodipodi:cx="16.355543"
sodipodi:cy="150.80168"
sodipodi:r1="7.745985"
sodipodi:r2="4.8291588"
sodipodi:arg1="1.0332539"
sodipodi:arg2="1.5568527"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 20.321693,157.45525 -7.745232,0.108 -3.9661499,-6.65356 3.7790819,-6.76157 7.745232,-0.10801 3.96615,6.65357 z"
inkscape:transform-center-x="-0.093238378"
inkscape:transform-center-y="-0.22711284"
transform="matrix(0.4296957,0,0,0.42830241,17.59041,65.778845)" /></g><use
x="0"
y="0"
xlink:href="#g11079"
id="use11081"
transform="rotate(-15.936104,42.162291,58.466326)" /><use
x="0"
y="0"
xlink:href="#g11079"
id="use11083"
transform="rotate(-5.080918,80.937544,-325.73636)" /><use
x="0"
y="0"
xlink:href="#g11079"
id="use11085"
transform="rotate(17.398182,44.604565,331.50397)" /><use
x="0"
y="0"
xlink:href="#g11079"
id="use11087"
transform="translate(81.407112,2.126153)" /><use
x="0"
y="0"
xlink:href="#g11079"
id="use11089"
transform="rotate(-15.814908,75.67043,-235.37704)" /></g></g></svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

36
pages/__init__.py Normal file
View File

@ -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

52
pages/anchor.py Normal file
View File

@ -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()

16
pages/apparentwind.py Normal file
View File

@ -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")

102
pages/autobahn.py Normal file
View File

@ -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()

366
pages/barograph.py Normal file
View File

@ -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()

66
pages/battery.py Normal file
View File

@ -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")

90
pages/battery2.py Normal file
View File

@ -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)
'''

55
pages/bme280.py Normal file
View File

@ -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)))

188
pages/clock.py Normal file
View File

@ -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()

40
pages/dst810.py Normal file
View File

@ -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)

58
pages/exhaust.py Normal file
View File

@ -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)

137
pages/fluid.py Normal file
View File

@ -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()

75
pages/fourvalues.py Normal file
View File

@ -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")

77
pages/fourvalues2.py Normal file
View File

@ -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()

44
pages/generator.py Normal file
View File

@ -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

114
pages/keel.py Normal file
View File

@ -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()

26
pages/onegraph.py Normal file
View File

@ -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

31
pages/onevalue.py Normal file
View File

@ -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()

202
pages/page.py Normal file
View File

@ -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()

13
pages/rollpitch.py Normal file
View File

@ -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

103
pages/rudder.py Normal file
View File

@ -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()

85
pages/skyview.py Normal file
View File

@ -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)

40
pages/solar.py Normal file
View File

@ -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")

107
pages/system.py Normal file
View File

@ -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)

47
pages/threevalues.py Normal file
View File

@ -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()

14
pages/twographs.py Normal file
View File

@ -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

67
pages/twovalues.py Normal file
View File

@ -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()

216
pages/voltage.py Normal file
View File

@ -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

119
pages/windrose.py Normal file
View File

@ -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()