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>
674 lines
20 KiB
Python
674 lines
20 KiB
Python
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
|