SolarManager unter Versionsverwaltung

Erster Stand der Hintergrundprozesse, die auf der Synology unter
/volume1/homes/wagner/SolarManager laufen: der Manager selbst, die Sammler
je Geraet, die MQTT-Bruecke, der Wecker und - neu hinzugezogen - der
AutoAction-Runner, der als Hintergrundprozess hierher gehoert und nicht ins
Web-Verzeichnis.

Zugangsdaten stehen nicht mehr im Quelltext, sondern in config.ini, die
nicht mit eingecheckt wird. Vorlage ist config.ini.example, gelesen wird sie
von konfig.py. Betroffen waren solarManager.py (Datenbank und Wattpilot),
zeit.py, gatherWaterData.py, wecker.py und skoda_testdaten.py, das sich das
Passwort bisher aus dem Quelltext eines anderen Moduls herausgesucht hat.

Die Kia-Anbindung ist mit dem Fahrzeug entfallen: kiaTest.py,
gatherCarData.py und hyundai_kia_connect_api sind nicht mehr dabei, ebenso
gatherInverterData.py, auf das nur noch eine auskommentierte Zeile zeigte.

Die mitgelieferten Bibliotheken bleiben im Repository - die NAS hat kein
pip, sie muessen neben den Skripten liegen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 20:46:59 +02:00
co-authored by Claude Opus 5
commit 79843aa2ae
968 changed files with 261182 additions and 0 deletions
+673
View File
@@ -0,0 +1,673 @@
import sys
sys.path.append("./")
import websocket
import json
import hashlib
import random
import threading
import hmac
import logging
import base64
import subprocess
from time import sleep
from types import SimpleNamespace
_LOGGER = logging.getLogger(__name__)
class LoadMode():
"""Wrapper Class to represent the Load Mode of the Wattpilot"""
DEFAULT=3
ECO=4
NEXTTRIP=5
class Wattpilot(object):
supported_events = [
# Wattpilot events:
"wp_auth",
"wp_authError",
"wp_authSuccess",
"wp_clearInverters",
"wp_connect",
"wp_deltaStatus",
"wp_disconnect",
"wp_fullStatus",
"wp_fullStatus_finished",
"wp_hello",
"wp_init",
"wp_property",
"wp_response",
"wp_updateInverter",
# WebSocketApp events:
"ws_close",
"ws_error",
"ws_message",
"ws_open",
]
carValues = {}
alwValues = {}
astValues = {}
lmoValues = {}
ustValues = {}
errValues = {}
acsValues = {}
lmoValues[3] = "Default"
lmoValues[4] = "Eco"
lmoValues[5] = "Next Trip"
astValues[0] = "open"
astValues[1] = "locked"
astValues[2] = "auto"
carValues[1] = "no car"
carValues[2] = "charging"
carValues[3] = "ready"
carValues[4] = "complete"
alwValues[0] = False
alwValues[1] = True
ustValues[0] = "Normal"
ustValues[1] = "AutoUnlock"
ustValues[2] = "AlwaysLock"
errValues[0] = "Unknown Error"
errValues[1] = "Idle"
errValues[2] = "Charging"
errValues[3] = "Wait Car"
errValues[4] = "Complete"
errValues[5] = "Error"
acsValues[0] = "Open"
acsValues[1] = "Wait"
@property
def allProps(self):
"""Returns a dictionary with all properties"""
return self._allProps
@property
def allPropsInitialized(self):
"""Returns true, if all properties have been initialized"""
return self._allPropsInitialized
@property
def cableType(self):
"""Returns the Cable Type (Ampere) of the connected cable"""
return self._cableType
@property
def frequency(self):
"""Returns the power frequency"""
return self._frequency
@property
def phases(self):
"""returns the phases"""
return self._phases
@property
def energyCounterSinceStart(self):
"""Returns used kwh since start of charging"""
return self._energyCounterSinceStart
@property
def errorState(self):
"""Returns error State"""
return self._errorState
@property
def cableLock(self):
return self._cableLock
@property
def energyCounterTotal(self):
return self._energyCounterTotal
@property
def serial(self):
"""Returns the serial number of Wattpilot Device (read only)"""
return self._serial
@serial.setter
def serial(self,value):
self._serial = value
if (self._password is not None) & (self._serial is not None):
self._hashedpassword = base64.b64encode(hashlib.pbkdf2_hmac('sha512',self._password.encode(),self._serial.encode(),100000,256))[:32]
@property
def name(self):
"""Returns the name of Wattpilot Device (read only)"""
return self._name
@property
def hostname(self):
"""Returns the DNS Hostname of Wattpilot Device (read only)"""
return self._hostname
@property
def friendlyName(self):
"""Returns the friendly name of Wattpilot Device (read only)"""
return self._friendlyName
@property
def manufacturer(self):
"""Returns the Manufacturer of Wattpilot Device (read only)"""
return self._manufacturer
@property
def devicetype(self):
return self._devicetype
@property
def protocol(self):
return self._protocol
@property
def secured(self):
return self._secured
@property
def password(self):
return self._password
@password.setter
def password(self,value):
self._password = value
if (self._password is not None) & (self._serial is not None):
self._hashedpassword = base64.b64encode(hashlib.pbkdf2_hmac('sha512',self._password.encode(),self._serial.encode(),100000,256))[:32]
@property
def url(self):
return self._url
@url.setter
def url(self,value):
self._url = value
@property
def connected(self):
return self._connected
@property
def voltage1(self):
return self._voltage1
@property
def voltage2(self):
return self._voltage2
@property
def voltage3(self):
return self._voltage3
@property
def voltageN(self):
return self._voltageN
@property
def amps1(self):
return self._amps1
@property
def amps2(self):
return self._amps2
@property
def amps3(self):
return self._amps3
@property
def power1(self):
return self._power1
@property
def power2(self):
return self._power2
@property
def power3(self):
return self._power3
@property
def powerN(self):
return self._powerN
@property
def power(self):
return self._power
@property
def version(self):
return self._version
@property
def amp(self):
return self._amp
@property
def AccessState(self):
return self._AccessState
@property
def firmware(self):
"""Returns the Firmwareversion of Wattpilot Device (read only)"""
return self._firmware
@property
def ftt(self):
"""Returns the The desired charged time for NextTrip"""
return self._ftt
@property
def WifiSSID(self):
"""Returns the SSID of the Wifi network currently connected (read only)"""
return self._WifiSSID
@property
def AllowCharging(self):
return self._AllowCharging
@property
def mode(self):
return self._mode
@property
def carConnected(self):
return self._carConnected
@property
def cae(self):
"""Returns true if Cloud API Access is enabled (read only)"""
return self._cae
@property
def cak(self):
"""Returns the API Key for Cloud API Access (read only)"""
return self._cak
def __str__(self):
"""Returns a String representation of the core Wattpilot attributes"""
if self.connected:
ret = "Wattpilot: " + str(self.name) + "\n"
ret = ret + "Serial: " + str(self.serial) + "\n"
ret = ret + "Connected: " + str(self.connected) + "\n"
ret = ret + "Car Connected: " + str(self.carConnected) + "\n"
ret = ret + "Charge Status " + str(self.AllowCharging) + "\n"
ret = ret + "Mode: " + str(self.mode) + "\n"
ret = ret + "Power: " + str(self.amp) + "\n"
ret = ret + "Charge: " + "%.2f" % self.power + "kW" + " ---- " + str(self.voltage1) + "V/" + str(self.voltage2) + "V/" + str(self.voltage3) + "V" + " -- "
ret = ret + "%.2f" % self.amps1 + "A/" + "%.2f" % self.amps2 + "A/" + "%.2f" % self.amps3 + "A" + " -- "
ret = ret + "%.2f" % self.power1 + "kW/" + "%.2f" % self.power2 + "kW/" + "%.2f" % self.power3 + "kW" + "\n"
else:
ret = "Not connected"
return ret
def connect(self):
self._wst = threading.Thread(target=self._wsapp.run_forever)
self._wst.daemon = True
self._wst.start()
self.__call_event_handler("wp_connect")
_LOGGER.info("Wattpilot connected")
def disconnect(self):
self._wsapp.close()
self._connected=False
self._auto_reconnect=False # Do not reconnect on explicit disconnect
self.__call_event_handler("wp_disconnect")
_LOGGER.info("Wattpilot disconnected")
# Wattpilot Event Handling
def add_event_handler(self,event_type,callback_fn):
if event_type not in self._event_handler:
self._event_handler[event_type] = []
self._event_handler[event_type].append(callback_fn)
def remove_event_handler(self,event_type,callback_fn):
if event_type in self._event_handler and callback_fn in self._event_handler[event_type]:
self._event_handler[event_type].remove(callback_fn)
def __call_event_handler(self, event_type, *args):
_LOGGER.debug(f"Calling event handler for event type '{event_type} ...")
for callback_fn in self._event_handler[event_type]:
event = {
"type": event_type,
"wp": self,
}
callback_fn(event,*args)
def set_power(self,power):
self.send_update("amp",power)
def set_mode(self,mode):
self.send_update("lmo",mode)
def send_update(self,name,value):
message = {}
message["type"]="setValue"
self.__requestid = self.__requestid+1
message["requestId"]=self.__requestid
message["key"]=name
message["value"]=value
if (self._secured is not None):
if (self._secured > 0):
self.__send(message,True)
else:
self.__send(message)
else:
self.__send(message)
def unpairInverter(self,InverterID):
message = {}
message["type"]="unpairInverter"
self.__requestid = self.__requestid+1
message["requestId"]=self.__requestid
message["inverterId"]=InverterID
if (self._secured is not None):
if (self._secured > 0):
self.__send(message,True)
else:
self.__send(message)
else:
self.__send(message)
def pairInverter(self,InverterID):
message = {}
message["type"]="pairInverter"
self.__requestid = self.__requestid+1
message["requestId"]=self.__requestid
message["inverterId"]=InverterID
if (self._secured is not None):
if (self._secured > 0):
self.__send(message,True)
else:
self.__send(message)
else:
self.__send(message)
def __update_property(self,name,value):
self._allProps[name] = value
if name=="acs":
self._AccessState = Wattpilot.acsValues[value]
if name=="cbl":
self._cableType = value
if name=="fhz":
self._frequency = value
if name=="pha":
self._phases = value
if name=="wh":
self._energyCounterSinceStart = value
if name=="err":
self._errorState = Wattpilot.errValues[value]
if name=="ust":
self._cableLock = Wattpilot.ustValues[value]
if name=="eto":
self._energyCounterTotal = value
if name=="cae":
self._cae = value
if name=="cak":
self._cak = value
if name=="lmo":
self._mode = Wattpilot.lmoValues[value]
if name=="car":
self._carConnected = Wattpilot.carValues[value]
if name=="alw":
self._AllowCharging = Wattpilot.alwValues[value]
if name=="nrg":
self._voltage1=value[0]
self._voltage2=value[1]
self._voltage3=value[2]
self._voltageN=value[3]
self._amps1=value[4]
self._amps2=value[5]
self._amps3=value[6]
self._power1=value[7]*0.001
self._power2=value[8]*0.001
self._power3=value[9]*0.001
self._powerN=value[10]*0.001
self._power=value[11]*0.001
if name=="amp":
self._amp = value
if name=="version":
self._version = value
if name=="ast":
self._AllowCharging = self._astValues[value]
if name=="fwv":
self._firmware = value
if name=="wss":
self._WifiSSID=value
if name=="ftt":
self._ftt=value
if name=="upd":
if value=="0":
self._updateAvailable = False
else:
self._updateAvailable = True
self.__call_event_handler("wp_property",name,value)
def __on_hello(self,message):
_LOGGER.info("Connected to WattPilot Serial %s",message.serial)
if hasattr(message,"hostname"):
self._name=message.hostname
self.serial = message.serial
if hasattr(message,"hostname"):
self._hostname=message.hostname
if hasattr(message,"version"):
self._version=message.version
self._manufacturer=message.manufacturer
self._devicetype=message.devicetype
self._protocol=message.protocol
if hasattr(message,"secured"):
self._secured=message.secured
self.__call_event_handler("wp_hello",message)
def __on_auth(self,wsapp,message):
ran = random.randrange(10**80)
self._token3 = "%064x" % ran
self._token3 = self._token3[:32]
hash1 = hashlib.sha256((message.token1.encode()+self._hashedpassword)).hexdigest()
hash = hashlib.sha256((self._token3 + message.token2+hash1).encode()).hexdigest()
response = {}
response["type"] = "auth"
response["token3"] = self._token3
response["hash"] = hash
self.__send(response)
self.__call_event_handler("wp_auth",message)
def __send(self,message,secure=False):
# If the connection to wattpilot is over a unsecure channel (http) all send messages are wrapped in
# a "securedMsg" Message which contains the original messageobject and a sha256 HMAC Hashed created
# using the password
if secure:
messageid=message["requestId"]
payload=json.dumps(message)
h = hmac.new(bytearray(self._hashedpassword), bytearray(payload.encode()), hashlib.sha256 )
message={}
message["type"]="securedMsg"
message["data"]=payload
message["requestId"]=str(messageid)+"sm"
message["hmac"]=h.hexdigest()
_LOGGER.debug("Message send: %s",json.dumps(message) )
self._wsapp.send(json.dumps(message))
def __on_AuthSuccess(self,message):
self._connected = True
self.__call_event_handler("wp_authSuccess",message)
_LOGGER.info("Authentication successful")
def __on_FullStatus(self,message):
props = message.status.__dict__
for key in props:
self.__update_property(key,props[key])
self.__call_event_handler("wp_fullStatus",message)
self._allPropsInitialized = not message.partial
_LOGGER.info("Status part")
if message.partial == False:
self.__call_event_handler("wp_fullStatus_finished",message)
_LOGGER.info("Status complete")
def __on_AuthError(self,message):
if message.message=="Wrong password":
self._wsapp.close()
_LOGGER.error("Authentication failed: %s" , message.message)
self.__call_event_handler("wp_authError",message)
def __on_DeltaStatus(self,message):
props = message.status.__dict__
for key in props:
self.__update_property(key,props[key])
self.__call_event_handler("wp_deltaStatus",message)
def __on_clearInverters(self,message):
self.__call_event_handler("wp_clearInverters",message)
def __on_updateInverter(self,message):
self.__call_event_handler("wp_updateInverter",message)
def __on_response(self,message):
if message.success:
if hasattr(message,"status"):
props = message.status.__dict__
for key in props:
self.__update_property(key,props[key])
else:
_LOGGER.error("Error Sending Request %s. Message: %s" ,message.requestId,message.message)
self.__call_event_handler("wp_response",message)
def __on_open(self,wsapp):
self.__call_event_handler("ws_open",wsapp)
def __on_error(self,wsapp,err):
self.__call_event_handler("ws_error",wsapp,err)
_LOGGER.error(f"Error received from WebSocketApp: {err}")
def __on_close(self,wsapp,code,msg):
self._connected=False
self.__call_event_handler("ws_close",wsapp,code,msg)
if (self._auto_reconnect):
sleep(self._reconnect_interval)
self._wsapp.run_forever()
def __on_message(self, wsapp, message):
## called whenever a message through websocket is received
_LOGGER.debug("Message received: %s", message)
msg=json.loads(message, object_hook=lambda d: SimpleNamespace(**d))
self.__call_event_handler("ws_message",message)
if (msg.type == 'hello'): # Hello Message -> Received upon connection before auth
self.__on_hello(msg)
if (msg.type == 'authRequired'): # Auth Required -> Received after hello
self.__on_auth(wsapp,msg)
if (msg.type == 'response'): # Response Message -> Received after sending a update and contains result of update
self.__on_response(msg)
if (msg.type == 'authSuccess'): # Auth Success -> Received after sending correct authentication message
self.__on_AuthSuccess(msg)
if (msg.type == 'authError'): # Auth Error -> Received after sending incorrect authentication message (e.g. wrong password)
self.__on_AuthError(msg)
if (msg.type == 'fullStatus'): # Full Status -> Received after successful connection. Contains all properties of Wattpilot
self.__on_FullStatus(msg)
if (msg.type == 'deltaStatus'): # Delta Status -> Whenever a property changes a Delta Status is send
self.__on_DeltaStatus(msg)
if (msg.type == 'clearInverters'): # Unknown
self.__on_clearInverters(msg)
if (msg.type == 'updateInverter'): # Contains information of connected Photovoltaik inverter / powermeter
self.__on_updateInverter(msg)
def __init__(self, ip ,password,serial=None,cloud=False):
self._auto_reconnect = True
self._reconnect_interval = 30
self._websocket_default_timeout = 10
self.__requestid = 0
self._name = None
self._hostname = None
self._friendlyName = None
self._manufacturer = None
self._devicetype = None
self._protocol = None
self._secured = None
self._serial = None
self._password = None
self.password = password
if(cloud):
self._url= "wss://app.wattpilot.io/app/" + serial + "?version=1.2.9"
else:
self._url = "ws://"+ip+"/ws"
_LOGGER.info("URL: %s",self._url);
self.serial = None
self._connected = False
self._allProps={}
self._allPropsInitialized=False
self._voltage1=None
self._voltage2=None
self._voltage3=None
self._voltageN=None
self._amps1=None
self._amps2=None
self._amps3=None
self._power1=None
self._power2=None
self._power3=None
self._powerN=None
self._power=None
self._version = None
self._amp = None
self._AccessState = None
self._firmware = None
self._ftt = None
self._WifiSSID = None
self._AllowCharging = None
self._mode=None
self._carConnected=None
self._cae=None
self._cak=None
# Initialize callback lists:
self._event_handler = {}
for event_type in self.supported_events:
self._event_handler[event_type] = []
self._wst=threading.Thread()
websocket.setdefaulttimeout(self._websocket_default_timeout)
self._wsapp = websocket.WebSocketApp(
self.url,
on_close=self.__on_close,
on_error=self.__on_error,
on_message=self.__on_message,
on_open=self.__on_open,
)
self.__call_event_handler("wp_init")
_LOGGER.info ("Wattpilot %s initialized",self.serial)
def wp_handle_events(event, *args):
_LOGGER.debug(f"wp_handle_events(event={event},{args})")
_LOGGER.debug(f"wp_handle_events(): MQTT client not yet initialized - status publishing skipped.")
if event['type'] == 'fullStatus':
_LOGGER.debug(f"wp_handle_events(Hz={wp.frequency})")
return