Source file: /~heha/ewa/Logger/Batterieanzeige.zip/X728BatteryStatus

#!/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
Detected encoding: UTF-80