#!/usr/bin/env python3
# Zeigt X728-Batterie-Ladezustand im System-Tray des Desktops an.
# Nur für diese USV-Platine (von geekworm.com) gemacht und geeignet!
#
# Da dieses Programm permanent laufen sollte,
# bietet sich ein Autostart an.
#
# Bei mir (2023, LXDE) ist das wie folgt einzutragen:
# sudo nano /etc/xdg/lxsession/LXDE-pi/autostart
# Neue Zeile (anfügen):
# @[pfad/]X728BatteryStatus.py -a
import smbus # I²C = Batteriemonitor
from RPi import GPIO # Netz/Batteriebetrieb, Piezo
from struct import unpack
from time import sleep
from os import system
from sys import argv
import PyQt5
from PyQt5.QtGui import (
QIcon,QPixmap,QColor,QPainter,QPainterPath,QPen,QPolygonF
)
from PyQt5.QtWidgets import (
QApplication,QSystemTrayIcon,
QMenu,QAction,QDialog,QMessageBox,
QLabel,QSpinBox,QPushButton,QGridLayout,
)
from PyQt5.QtCore import (
Qt,QTimer,
QSettings,QLocale,QDateTime,QPointF,QRectF,
)
locale = QLocale("de") # für Dezimalkommas
# Ein Wrapper für den künftig ohne GUI laufenden Systemdienst
# Die Kommunikation wird wohl künftig über mqtt laufen …
class SystemService():
def __init__(self):
self.bus = smbus.SMBus(1) # X728 Batteriemonitor an I²C-Adresse 0x26
self.voltage = 0 # Spannung in V
self.current = 0 # Ladestrom in A (hier: fiktiv)
self.percent = 0 # Füllstand in %, NaN bei Fehler
# h#s = haftmann#software (für Windows: HKCU/Software/h#s/X728BatteryStatus)
self.settings= QSettings("h#s","X728BatteryStatus")
self.buzzlev = int(self.settings.value("buzzlev",10)) # Prozent, darunter piepsen
self.downlev = int(self.settings.value("downlev",2)) # Prozent, darunter PowerDown
self.lastoff = int(self.settings.value("lastoff",0)) # Millisekunden seit 1970-01-01T00:00:00.000UTC
GPIO.setmode(GPIO.BCM) # Was für ein hanebüchener Unsinn: Was denn sonst??
GPIO.setwarnings(False) # (was RPi.GPIO so will)
GPIO.setup(6,GPIO.IN) # X728 H = Batteriebetrieb L = Netzbetrieb
GPIO.setup(20,GPIO.OUT) # X728 H = Piezokapsel ein
def queryData(self):
try:
block = self.bus.read_i2c_block_data(0x36,2,4);
except:
# print("I²C-Lesefehler")
self.percent = float("nan")
self.current = 0 # Spannung unverändert belassen
return
v,p = unpack(">HH",bytes(block));
self.voltage = v/12800 # scheint so zu stimmen
self.percent = p/256
self.current = 0 if GPIO.input(6) else 2.0 # Annahme: 2 A Ladestrom
def cyclic(self):
self.queryData()
if self.current<=0 and self.percent>=0:
if self.percent<=self.buzzlev: self.buzz()
if self.percent<=self.downlev: self.goDown()
if self.current>0: self.buzz(0) # ausschalten beim Laden
def setbuzzlev(self,val:int):
if val<0 or val>50: return False
self.buzzlev = val;
self.settings.setValue("buzzlev",val)
return True
def setdownlev(self,val:int):
if val<0 or val>=self.buzzlev: return False
self.downlev = val
self.settings.setValue("downlev",val)
return True
def buzz(self,sta=2): # sta=0: aus, sta=1: ein, sta=2: toggle
if sta!=0 and sta!=1: sta=not(GPIO.input(20))
GPIO.output(20,sta)
def goDown(self):
# Aktion vermerken
self.lastoff = QDateTime.currentMSecsSinceEpoch()
self.settings.setValue("lastoff",self.lastoff)
self.settings.sync()
# TODO: Fenstern ein WM_ENDSESSION schicken, wenn ich's herausfinde.
# Gesittete Programme speichern daraufhin ihre Daten,
# bspw. der Browser seine geöffneten Tabs oder der Texteditor ein Backup.
# root scheint nicht erforderlich zu sein! Geht auch so!
system("systemctl poweroff") # als root
def clearLastoff(self):
self.lastoff = 0
self.settings.remove("lastoff")
svc = SystemService()
class ConfigDialog(QDialog):
def __init__(self):
super().__init__()
self.setWindowTitle("X728-Batteriestatus")
g = QGridLayout()
def GridRow(row,label,maxvalid,value,onchange,testlabel,ontest):
l = QLabel(label)
e = QSpinBox(self) # Windows: Editfenster mit UpDownControl
e.setRange(0,maxvalid)
e.setValue(value)
e.setSuffix(" %")
e.setPrefix("≤ ")
l.setBuddy(e) # bewirkt dass '&' wie unter Windows funktioniert
g.addWidget(l,row,0,Qt.AlignmentFlag.AlignRight)
e.setAlignment(Qt.AlignmentFlag.AlignRight)
e.valueChanged.connect(onchange)
g.addWidget(e,row,1)
b = QPushButton(testlabel)
b.clicked.connect(ontest)
g.addWidget(b,row,2)
def reallyDown(): # Sicherheitsabfrage
if QMessageBox.warning(self,
"X728-Batteriestatus",
"Wirklich sofort herunterfahren und ausschalten?",
QMessageBox.Yes|QMessageBox.Cancel,
QMessageBox.Cancel)==QMessageBox.Yes: svc.goDown()
def gelesen(): # Mehrere Aktionen lassen sich nicht in ein Lambda packen
svc.clearLastoff()
# Dritte Grid-Zeile löschen beim Klick auf "Gelesen"
g.itemAtPosition(2,0).widget().hide()
g.itemAtPosition(2,2).widget().hide()
# Der Dialog bleibt so groß (hoch) wie er ist.
# Nach "stackoverflow.com" artet das anschließende Verkleinern
# in viel Arbeit aus und das Weglassen ist hier verzeihlich.
GridRow(0,"&Piep wenn",
30,svc.buzzlev,
lambda i:svc.setbuzzlev(i),
"&Test",lambda:svc.buzz())
GridRow(1,"&Ausschalten wenn",
svc.buzzlev,svc.downlev,
lambda i:svc.setdownlev(i),
"T&est",reallyDown)
# Maximum für Ausschaltpegel nachführen
g.itemAtPosition(0,1).widget().valueChanged.connect(
lambda i:g.itemAtPosition(1,1).widget().setMaximum(i))
if svc.lastoff: # In einer weiteren Zeile letztes PowerDown anzeigen
s = "Letztes Ausschalten: " # TODO: locale-spezifische Anzeige
t = QDateTime.fromMSecsSinceEpoch(svc.lastoff)
s += t.toString("yyyy-MM-dd hh:mm:ss")
g.addWidget(QLabel(s),2,0,1,2) # mit "colspan"
b = QPushButton("&Gelesen")
b.clicked.connect(gelesen)
g.addWidget(b,2,2)
self.setLayout(g)
def done(self,r): # beim Beenden Piepser ausmachen
svc.buzz(0)
return super().done(r) # "done" ist eine überladene virtuelle Memberfunktion
def getIconSize():
# Das ist hochgradig plattformspezifisch!!
# Mir ist kein Königsweg für die Symbolgröße bekannt,
# und es scheint auch keinen zu geben.
# Ach wie schön ist doch GetSystemMetrics() unter Windows …
st = QSettings("lxsession/LXDE-pi","desktop")
st.beginGroup("GTK") # geht in [GTK]
ret = st.value("sGtk/IconSizes") # liefert leider List-Of-Strings
st.endGroup()
if type(ret) is list: ret = ','.join(ret)
ret = ret.split('=')[1] # "gtk-large-toolbar=" weg
return list(map(int,ret.split(','))) # x und y getrennt (sollte gleich sein für quadratische Symbole)
# Wahrscheinlich ist es sicherer, alle Dateien unter /home/pi/.config/*/LXDE*/
# nach dem Muster /IconSize[^=]*=.*?([0-9]{2}),([0-9]{2})/ zu durchsuchen.
iconsize = getIconSize()
if len(iconsize)!=2 or iconsize[0]<16 or iconsize[1]<16:
print("Falsche Symbolgröße:",iconsize) # Komma generiert Leerzeichen!
iconsize = [32,32] # Defaultwert
anistate = 0
# Das „handgemalte“ Symbol beachtet p und i sowie iconsize
def makeIcon(p:float,u:float,i:float):
global anistate
color = Qt.darkGray # (#808080)
alarm = 30 # Unterhalb dieser Prozentangabe Alarmfarben
# TODO: Die 30 % könnten/sollten einstellbar sein
if p==p: # nicht NaN
# beim Laden grün, sonst blau, unter 30% gelb-orange-rot
hue = 1/3 if i>0 else 200/360 if p>alarm else p/alarm/6
v = 1 if i<=0 and p<=alarm else 0.75 # 0.75 = nicht ganz stechend (dunkler)
color = QColor.fromHsvF(hue,1,v)
# TODO: Icon-Animation bei Alarm (Welle beim „Flüssigkeitsstand“)
bm = QPixmap(*iconsize) # beide Zahlen der Liste als Argumente
bm.fill(Qt.transparent)
dc = QPainter(bm)
dc.setRenderHint(QPainter.Antialiasing)
dc.scale(*iconsize) # auf [0..1> skalieren
dc.scale(0.01,0.01) # X/Y fortan in handlichen Prozentangaben
pa = QPainterPath()
pa.addRoundedRect(25,20,50,75,6,6) # Korpus: aufrechte Rundzelle
pa.addRect(40,5,20,15) # Pluspol
dc.fillPath(pa,color)
del pa
if p>=0 and p<=100:
bottom = int((100-p)*0.7) # Füllgrad: Mit 100-p der Leere-Grad
if p<=alarm: bottom-=3 # Platz für „Welle“ (TODO: Schönere Animation)
dc.fillRect(30,25,40,bottom,Qt.white)
if p<=alarm: # Hier: Absichtlich Animation während des Ladens (stört kaum)
tal = -10 if anistate==0 else 10 if anistate==2 else 0
dc.fillRect(50+tal-10,25+bottom,20,6,Qt.white)
anistate = 0 if anistate==3 else anistate+1
if i>0: # Beim Laden
pa = QPainterPath()
p = QPolygonF()
p.append(QPointF(50,30)) # Ein Blitz
p.append(QPointF(30,65))
p.append(QPointF(50,65))
p.append(QPointF(50,90))
p.append(QPointF(70,55))
p.append(QPointF(50,55))
p.append(QPointF(50,30)) # Pfad schließen (für Linie)
pa.addPolygon(p)
dc.fillPath(pa,Qt.yellow) # gelb
dc.setPen(QPen(Qt.darkYellow,2))
dc.drawPath(pa) # mit dunkelgelber Umrandung
del p,pa
del dc
return QIcon(bm)
def changeBatteryStatus(p:float,u:float,i:float):
tray.setIcon(makeIcon(p,u,i))
def pui():
s = locale.toString(p,'f',1)+" %"
if u or i:
s+= " ("
if u: s+= locale.toString(u,'f',2)+" V"
if u and i: s+= ", "
if i: s+= locale.toString(i,'f',2)+" A"
s+= ")"
return s
s = "Batteriestatus unbekannt!"
if p==p: # nicht NaN
s = "lade bei" if i>0 else "noch"
s+= " "+pui()
tray.setToolTip(s)
def onTimer():
svc.cyclic()
changeBatteryStatus(svc.percent,svc.voltage,svc.current)
app = QApplication(argv)
app.setObjectName("X728") # wozu auch immer, wohl für interne Logs
app.setApplicationDisplayName("Batteriestatus") # für app.aboutQt()
app.setQuitOnLastWindowClosed(False) # es gibt kein permanentes Fenster
tray = QSystemTrayIcon()
tray.setIcon(makeIcon(float("nan"),0,0)) # Graue Batterie beim Start
tray.setVisible(True)
timer = QTimer(app)
timer.timeout.connect(onTimer)
timer.start(500)
# Popup-Menü
menu = QMenu()
menu.addAction("&Konfigurieren …").triggered.connect(lambda:ConfigDialog().exec())
# Die Option -a oder --autostart unterdrückt die Möglichkeit des Beendens
if not("-a" in argv or "--autostart" in argv):
menu.addAction("&Beenden").triggered.connect(app.quit)
if "-f" in argv or "--fullmenu" in argv:
menu.addSeparator()
menu.addAction("&Über Qt …").triggered.connect(lambda:app.aboutQt())
tray.setContextMenu(menu)
app.exec()
svc.buzz(0) # beim Beenden Piepser aus
Vorgefundene Kodierung: UTF-8 | 0
|