first commit

This commit is contained in:
denis
2024-09-23 15:10:59 +02:00
commit df147630e5
21 changed files with 2270 additions and 0 deletions
+39
View File
@@ -0,0 +1,39 @@
Ce document est en langue allemande. N'hésitez pas à traduire.
L'entreprise suisse vend sous la marque "SolarMax".
Onduleur photovoltaïque Sputnik Engineering AG.
Les onduleurs SolarMax peuvent être utilisés avec une connexion Ethernet normale
requête utilisant un (sorte de) protocole basé sur du texte
une connexion TCP.
Le protocole utilisé a été rétro-conçu par moi (en utilisant
capture réseau lors de l'utilisation du logiciel MaxTalk).
Ce n'est qu'après avoir implémenté la fonctionnalité de base que je l'ai reçu
ni la documentation du protocole utilisé par le fabricant.
Cependant, la licence de cette description d'interface ne permet pas
pour les rendre publics :
| L'utilisateur s'abstient de fournir le journal de données à des tiers
| divulguer à. Il s'abstient en outre de le faire
| Protocole de données MaxComm pour la production en série
| de dispositifs de visualisation et de surveillance des données, et
| de dispositifs d'alarme pour les systèmes d'onduleurs
| utiliser les solutions étalons correspondantes
| du fournisseur en concurrence. En revanche, il est permis
| Utilisation pour la fabrication de tels dispositifs
| pour un usage personnel ou dans le cadre de
| système de communication de données sur mesure solutions individuelles
| pour des clients spécifiques. En cas de doute, Spoutnik
| demander.
Coordonnées du fabricant :
Sputnik Engineering AG
CH-Bienne
www.solarmax.com
Cependant, la bibliothèque Python présentée ici n'est pas basée sur celle-ci
Description de l'interface et n'est donc pas soumis à cette restriction.
+39
View File
@@ -0,0 +1,39 @@
This document is in german language. Feel free to translate.
Unter dem Markennamen "SolarMax" vertreibt die schweizer Firma
Sputnik Engineering AG Fotovoltaik-Wechselrichter.
Die SolarMax-Wechselrichter lassen sich mit normaler Ethernet-Verbindung
abfragen und nutzen dabei ein (gewissermaßen) Text-basiertes Protokoll über
eine TCP-Verbindung.
Das verwendete Protokoll wurde von mir reverse engineered (mittels
Netzwerk-capturing bei Verwendung der MaxTalk-Software).
Erst nach Implementierung der Basisfunktionalität erhielt ich doch
noch eine Dokumentation des verwendeten Protokolls vom Hersteller.
Die Lizenz dieser Schnittstellenbeschreibung erlaubt es jedoch nicht,
diese öffentlich zu machen:
| Der Nutzer sieht davon ab, das Datenprotokoll Dritten
| gegenüber offenzulegen. Weiter sieht er davon ab, das
| MaxComm Datenprotokoll zur serienmässigen Herstellung
| von Datenvisualisierungs und –überwachungsgeräten sowie
| von Alarmierungsgeräten für Wechselrichteranlagen zu
| verwenden, welche die entsprechenden Standardlösungen
| des Lieferers konkurrenzieren. Erlaubt ist dagegen die
| Verwendung für die Herstellung von derartigen Geräten
| für den eigenen Gebrauch oder im Rahmen von massge-
| schneiderten Datenkommunikations-System-Einzellösungen
| für bestimmte Kunden. Im Zweifelsfall ist Sputnik
| anzufragen.
Kontaktdaten des Herstellers:
Sputnik Engineering AG
CH-Biel
www.solarmax.com
Die hier vorliegende Python-Bibliothek basiert jedoch nicht auf dieser
Schnittstellenbeschreibung und unterliegt daher nicht dieser Einschränkung.
View File
+366
View File
@@ -0,0 +1,366 @@
#!/usr/bin/python
# -* coding: utf-8 *-
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Developed 2009-2010 by Bernd Wurst <bernd@schokokeks.org>
# for own use.
# Released to the public in 2012.
import socket, datetime
# Konstanten
inverter_types = {
20010: { 'desc': 'SolarMax 2000S', 'max': 2000, }, # Nur geraten
20020: { 'desc': 'SolarMax 3000S', 'max': 3000, },
20030: { 'desc': 'SolarMax 4200S', 'max': 4200, },
20040: { 'desc': 'SolarMax 6000S', 'max': 6000, },
}
query_types = ['KDY', 'KYR', 'KMT', 'KT0', 'IL1', 'IDC', 'PAC', 'PRL',
'SYS', 'SAL', 'TNF', 'PAC', 'PRL', 'TKK', 'UL1', 'UDC',
'ADR', 'TYP', 'PIN', 'MAC', 'CAC', 'KHR', 'EC00', 'EC01',
'EC02', 'EC03', 'EC04', 'EC05', 'EC06', 'EC07', 'EC08',
'BDN', 'SWV', 'DIN', 'LAN', 'SDAT', 'FDAT']
status_codes = {
20000: 'Keine Kommunikation',
20001: 'In Betrieb',
20002: 'Zu wenig Einstrahlung',
20003: 'Anfahren',
20004: 'Betrieb auf MPP',
20005: 'Ventilator läuft',
20006: 'Betrieb auf Maximalleistung',
20007: 'Temperaturbegrenzung',
20008: 'Netzbetrieb',
}
alarm_codes = {
0: 'kein Fehler',
1: 'Externer Fehler 1',
2: 'Isolationsfehler DC-Seite',
4: 'Fehlerstrom Erde zu Groß',
8: 'Sicherungsbruch Mittelpunkterde',
16: 'Externer Alarm 2',
32: 'Langzeit-Temperaturbegrenzung',
64: 'Fehler AC-Einspeisung',
128: 'Externer Alarm 4',
256: 'Ventilator defekt',
512: 'Sicherungsbruch',
1024: 'Ausfall Temperatursensor',
2048: 'Alarm 12',
4096: 'Alarm 13',
8192: 'Alarm 14',
16384: 'Alarm 15',
32768: 'Alarm 16',
65536: 'Alarm 17',
}
# Hilfs-Routine (DEBUG)
def DEBUG(*s):
out = [datetime.datetime.now().isoformat()+':',] + [str(x) for x in s]
print(' '.join(out))
####################################
## Haupt-Klasse
####################################
class SolarMax ( object ):
def __init__(self, host, port):
self.__host = host
self.__port = port
self.__inverters = {}
self.__socket = None
self.__connected = False
self.__allinverters = False
self.__inverter_list = []
self.__connect()
def __repr__(self):
return 'SolarMax[%s:%s / socket=%s]' % (self.__host, self.__port, self.__socket)
def __str__(self):
return 'SolarMax[%s:%s / socket=%s / inverters=%s]' % (self.__host, self.__port, self.__socket, self.inverters())
def __disconnect(self):
try:
DEBUG('Closing open connection to %s:%s' % (self.__host, self.__port))
self.__socket.shutdown(socket.SHUT_RDWR)
self.__socket.close()
del self.__socket
except:
pass
finally:
self.__connected = False
self.__allinverters = False
self.__socket = None
def __del__(self):
DEBUG('destructor called')
self.__disconnect()
def __connect(self):
self.__disconnect()
DEBUG('establishing connection to %s:%i...' % (self.__host, self.__port))
try:
# Python 2.5
self.__socket = socket.socket()
s = self.__socket
s.settimeout(2)
s.connect((self.__host, self.__port))
s.settimeout(10)
self.__connected = True
DEBUG('connected.')
except:
DEBUG('connection to %s:%i failed' % (self.__host, self.__port))
self.__connected = False
self.__allinverters = False
# Python 2.6
## Socket-timeout: 5 secs
#self.__socket = socket.create_connection((self.__host, self.__port), 5)
# Utility-functions
def hexval(self, i):
return (hex(i)[2:]).upper()
def checksum(self, s):
total = 0
for c in s:
total += ord(c)
h = self.hexval(total)
while len(h) < 4:
h = '0'+h
return h
def __receive(self):
try:
data = ''
tmp = ''
while True:
tmp = self.__socket.recv(1)
data += tmp
if len(tmp) < 1 or tmp == '}':
break
tmp = ''
return data
except:
self.__allinverters = False
return ""
def __parse(self, answer):
# convenience checks
if answer[0] != '{' or answer[-1] != '}':
raise ValueError('malformed answer: %s' % answer)
raw_answer = answer
answer = answer[1:-1]
checksum = answer[-4:]
content = answer[:-4]
# checksum
if checksum != self.checksum(content):
raise ValueError('checksum error')
(header, content) = content[:-1].split('|', 2)
(inverter, fb, length) = header.split(';', 3)
if fb != 'FB':
raise ValueError('answer not understood')
# length
length = int(length, 16)
if length != len(raw_answer):
raise ValueError('length mismatch')
inverter = int(inverter)
# Bei schreibzugriff antwortet der WR mit 'C8'
#if not content.startswith('64:'):
# raise ValueError('Inverter did not understand our query')
content = content[3:]
data = {}
for item in content.split(';'):
(key, value) = item.split('=')
if key not in query_types:
raise NotImplementedError("Don't know %s" % item)
data[key] = value
return (inverter, data)
def __build_query(self, id, values, qtype=100):
qtype = self.hexval(qtype)
if type(values) == list:
for v in values:
if v not in query_types:
raise ValueError('Unknown data type »'+v+'«')
values = ';'.join(values)
elif type(values) in [str, unicode]:
pass
else:
raise ValueError('value has unsupported type')
querystring = '|' + qtype + ':' + values + '|'
# Länge vergrößern um: 2 x { (2), WR-Nummer (2), "FB" (2), zwei Semikolon (2), Länge selbst (2), checksumme (4)
l = len(querystring) + 2 + 2 + 2 + 2 +2 + 4
querystring = 'FB;%02i;%s%s' % (int(id), self.hexval(l), querystring)
querystring += self.checksum(querystring)
return '{%s}' % querystring
def __send_query(self, querystring):
try:
DEBUG(self.__host, '=>', querystring)
self.__socket.send(querystring)
except socket.timeout:
self.__allinverters = False
except socket.error:
self.__connected = False
def query(self, id, values, qtype=100):
q = self.__build_query(id, values, qtype)
#DEBUG("WR %i: %s" % (id, q))
self.__send_query(q)
answer = self.__receive()
if answer:
(inverter, data) = self.__parse(answer)
for d in data.keys():
data[d] = self.normalize_value(d, data[d])
return (inverter, data)
else:
self.__allinverters = False
if not self.__allinverters and not self.__detection_running:
self.detect_inverters()
elif not self.__connected:
self.__connect()
else:
raise socket.timeout
return None
def normalize_value(self, key, value):
if key in [ 'KDY', 'UL1', 'UDC']:
return float(int(value, 16)) / 10
elif key in [ 'IL1', 'IDC', 'TNF', ]:
return float(int(value, 16)) / 100
elif key in [ 'PAC', 'PIN', ]:
return float(int(value, 16)) / 2
elif key in [ 'SAL', ]:
return int(value, 16)
elif key in [ 'SYS', ]:
(x,y) = value.split(',',2)
x = int(x, 16)
y = int(y, 16)
return (x,y)
elif key in [ 'SDAT', 'FDAT' ]:
(date, time) = value.split(',',2)
time = int(time, 16)
return datetime.datetime(int(date[:3], 16), int(date[3:5], 16), int(date[5:], 16), time/3600, (time % 3600) / 60, time % (3600*60))
else:
return int(value, 16)
def write_setting(self, inverter, data):
rawdata = []
for key,value in data.iteritems():
key = key.upper()
if key not in query_types:
raise ValueError('unknown type')
value = self.hexval(value)
rawdata.append('%s=%s' % (key, value))
DEBUG(self.query(inverter, ';'.join(rawdata), 200))
def status(self, inverter):
result = self.query(inverter, ['SYS', 'SAL'])
if not result:
return ('Offline', 'Offline')
result = result[1]
errors = []
if result['SAL'] > 0:
for (code, descr) in alarm_codes.iteritems():
if code & result['SAL']:
errors.append(descr)
status = status_codes[result['SYS'][0]]
return (status, ', '.join(errors))
def use_inverters(self, list_of):
self.__inverter_list = list_of
self.detect_inverters()
def detect_inverters(self):
self.__inverters = {}
if not self.__connected:
self.__connect()
self.__detection_running = True
for inverter in self.__inverter_list:
try:
DEBUG('searching for #%i (socket: %s)' % (inverter, self.__socket))
(inverter, data) = self.query(inverter, [ 'ADR', 'TYP', 'PIN' ])
if data['TYP'] in inverter_types.keys():
self.__inverters[inverter] = inverter_types[data['TYP']].copy()
self.__inverters[inverter]['installed'] = data['PIN']
else:
DEBUG('Unknown inverter type: %s (ID #%i)' % (data['TYP'], data['ADR']))
except:
DEBUG('Inverter #%i not found' % inverter)
self.__allinverters = False
self.__detection_running = False
if len(self.__inverters) == len(self.__inverter_list):
self.__allinverters = True
DEBUG('found all inverters:')
DEBUG(self.__inverters)
else:
DEBUG('not all invertes found, reconnection!')
self.__connect()
def inverters(self):
if not self.__allinverters:
self.detect_inverters()
return self.__inverters
+381
View File
@@ -0,0 +1,381 @@
#!/usr/bin/python
# -* coding: utf-8 *-
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# Developed 2009-2010 by Bernd Wurst <bernd@schokokeks.org>
# for own use.
# Released to the public in 2012.
import socket, datetime, logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# Constantes
inverter_types = {
20010: { 'desc': 'SolarMax 2000S', 'max': 2000, }, # Juste deviner
20020: { 'desc': 'SolarMax 3000S', 'max': 3000, },
20030: { 'desc': 'SolarMax 4200S', 'max': 4200, },
20040: { 'desc': 'SolarMax 6000S', 'max': 6000, },
}
query_types = [
'KDY', 'KYR', 'KMT', 'KT0', 'IL1', 'IDC', 'PAC', 'PRL',
'SYS', 'SAL', 'TNF', 'PAC', 'PRL', 'TKK', 'UL1', 'UDC',
'ADR', 'TYP', 'PIN', 'MAC', 'CAC', 'KHR', 'EC00', 'EC01',
'EC02', 'EC03', 'EC04', 'EC05', 'EC06', 'EC07', 'EC08',
'BDN', 'SWV', 'DIN', 'LAN', 'SDAT', 'FDAT'
]
status_codes = {
20000: 'Pas de communication',
20001: 'En cours d\'utilisation',
20002: 'Trop peu de rayonnement',
20003: 'Démarrage',
20004: 'Opération sur MPP',
20005: 'Le ventilateur tourne',
20006: 'Fonctionnement à puissance maximale',
20007: 'Limite de température',
20008: 'Fonctionnement sur secteur',
}
alarm_codes = {
0: "pas d'erreur",
1: "Erreur externe 1",
2: "Erreur d'isolement côté DC",
4: "Courant de défaut à la terre trop important",
8: "Rupture du fusible de la protection terre",
16: "Alarme externe 2",
32: "Limitation de température à long terme",
64: "Erreur alimentation CA",
128: "Alarme externe 4",
256: "Ventilateur défectueux",
512: "Casser le fusible",
1024: "Défaillance du capteur de température",
2048: "Alarme 12",
4096: "Alarme 13",
8192: "Alarme 14",
16384: "Alarme 15",
32768: "Alarme 16",
65536: "Alarme 17",
}
# Hilfs-Routine (DEBUG)
def DEBUG(*s):
out = [datetime.datetime.now().isoformat()+':',] + [str(x) for x in s]
#print(' '.join(out))
logger.debug(' '.join(out))
def get_status_code(v):
for i in status_codes:
if status_codes[i]==v:
return i
return None
####################################
## main class
####################################
class SolarMax ( object ):
def __init__(self, host, port):
self.__host = host
self.__port = port
self.__inverters = {}
self.__socket = None
self.__connected = False
self.__allinverters = False
self.__inverter_list = []
self.__connect()
def __repr__(self):
return 'SolarMax[%s:%s / socket=%s]' % (self.__host, self.__port, self.__socket)
def __str__(self):
return 'SolarMax[%s:%s / socket=%s / inverters=%s]' % (self.__host, self.__port, self.__socket, self.inverters())
def __disconnect(self):
try:
DEBUG('Closing open connection to %s:%s' % (self.__host, self.__port))
self.__socket.shutdown(socket.SHUT_RDWR)
self.__socket.close()
del self.__socket
except:
pass
finally:
self.__connected = False
self.__allinverters = False
self.__socket = None
def __del__(self):
DEBUG('destructor called')
self.__disconnect()
def __connect(self):
self.__disconnect()
DEBUG('establishing connection to %s:%i...' % (self.__host, self.__port))
try:
self.__socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.__socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s = self.__socket
s.settimeout(2)
s.connect((self.__host, self.__port))
s.settimeout(10)
self.__connected = True
DEBUG('connected.')
except:
DEBUG('connection to %s:%i failed' % (self.__host, self.__port))
self.__connected = False
self.__allinverters = False
# Utility-functions
def hexval(self, i):
return (hex(i)[2:]).upper()
def checksum(self, s):
total = 0
for c in s:
total += ord(c)
h = self.hexval(total)
while len(h) < 4:
h = '0'+h
return h
def __read_char(self):
byte = self.__socket.recv(1)
if len(byte) > 0:
return ord(byte) & 0x7F
return 0
def __receive(self):
try:
data = []
while True:
c = self.__read_char()
if c:
data.append(chr(c))
if not c or chr(c) == '}':
break
return ''.join(data)
except:
self.__allinverters = False
return ''
def __parse(self, answer):
# convenience checks
if answer[0] != '{' or answer[-1] != '}':
raise ValueError('malformed answer: %s' % answer)
raw_answer = answer
answer = answer[1:-1]
checksum = answer[-4:]
content = answer[:-4]
# checksum
if checksum != self.checksum(content):
raise ValueError('checksum error')
(header, content) = content[:-1].split('|', 2)
(inverter, fb, length) = header.split(';', 3)
if fb != 'FB':
raise ValueError('answer not understood')
# length
length = int(length, 16)
if length != len(raw_answer):
raise ValueError('length mismatch')
inverter = int(inverter)
# Bei schreibzugriff antwortet der WR mit 'C8'
# Avec un accès en écriture, le WR répond par 'C8'
#if not content.startswith('64:'):
# raise ValueError('Inverter did not understand our query')
content = content[3:]
data = {}
for item in content.split(';'):
(key, value) = item.split('=')
if key not in query_types:
raise NotImplementedError("Don't know %s" % item)
data[key] = value
return (inverter, data)
def __build_query(self, idn, values, qtype=100):
qtype = self.hexval(qtype)
if type(values) == list:
for v in values:
if v not in query_types:
raise ValueError('Unknown data type »'+v+'«')
values = ';'.join(values)
#elif type(values) in [str, unicode]:
elif type(values) in [str,]:
pass
else:
raise ValueError('value has unsupported type')
querystring = '|' + qtype + ':' + values + '|'
# Länge vergrößern um: 2 x { (2), WR-Nummer (2), "FB" (2), zwei Semikolon (2), Länge selbst (2), checksumme (4)
# Augmentez la longueur de : 2 x { (2), le numéro WR (2), "FB" (2), deux points-virgules (2), la longueur elle-même (2), la somme de contrôle (4)
l = len(querystring) + 2 + 2 + 2 + 2 +2 + 4
querystring = 'FB;%02i;%s%s' % (int(idn), self.hexval(l), querystring)
querystring += self.checksum(querystring)
return '{%s}' % querystring
def __send_query(self, querystring):
try:
DEBUG(self.__host, '=>', querystring)
#self.__socket.send(querystring)
self.__socket.send(querystring.encode())
except socket.timeout:
self.__allinverters = False
self.__connected = False
def query(self, idn, values, qtype=100):
q = self.__build_query(idn, values, qtype)
DEBUG("WR %i: %s" % (idn, q))
self.__send_query(q)
answer = self.__receive()
if answer:
(inverter, data) = self.__parse(answer)
for d in data.keys():
data[d] = self.normalize_value(d, data[d])
return (inverter, data)
else:
self.__allinverters = False
if not self.__allinverters and not self.__detection_running:
self.detect_inverters()
elif not self.__connected:
self.__connect()
else:
raise socket.timeout
return None
def normalize_value(self, key, value):
if key in [ 'KDY', 'UL1', 'UDC']:
return float(int(value, 16)/10)
elif key in [ 'IL1', 'IDC', 'TNF', ]:
return float(int(value, 16)/100)
elif key in [ 'PAC', 'PIN', ]:
return float(int(value, 16)/2)
elif key in [ 'SAL', ]:
return int(value, 16)
elif key in [ 'SYS', ]:
(x,y) = value.split(',',2)
x = int(x, 16)
y = int(y, 16)
return (x,y)
elif key in [ 'SDAT', 'FDAT' ]:
(date, time) = value.split(',',2)
time = int(time, 16)
return datetime.datetime(int(date[:3], 16), int(date[3:5], 16), int(date[5:], 16), time//3600, (time % 3600) // 60, time % (3600*60))
else:
return int(value, 16)
def write_setting(self, inverter, data):
rawdata = []
for key,value in data.iteritems():
key = key.upper()
if key not in query_types:
raise ValueError('unknown type')
value = self.hexval(value)
rawdata.append('%s=%s' % (key, value))
DEBUG(self.query(inverter, ';'.join(rawdata), 200))
def status(self, inverter):
result = self.query(inverter, ['SYS', 'SAL'])
if not result:
return ('Offline', 'Offline')
result = result[1]
errors = []
if result['SAL'] > 0:
for (code, descr) in alarm_codes.iteritems():
if code & result['SAL']:
errors.append(descr)
status = status_codes[result['SYS'][0]]
return (status, ', '.join(errors))
def use_inverters(self, list_of):
self.__inverter_list = list_of
self.detect_inverters()
def detect_inverters(self):
self.__inverters = {}
if not self.__connected:
self.__connect()
self.__detection_running = True
for inverter in self.__inverter_list:
try:
DEBUG('searching for #%i (socket: %s)' % (inverter, self.__socket))
(inverter, data) = self.query(inverter, [ 'ADR', 'TYP', 'PIN' ])
if data['TYP'] in inverter_types.keys():
self.__inverters[inverter] = inverter_types[data['TYP']].copy()
self.__inverters[inverter]['installed'] = data['PIN']
else:
DEBUG('Unknown inverter type: %s (ID #%i)' % (data['TYP'], data['ADR']))
except Exception as e:
DEBUG('Inverter #%i not found: %s' % (inverter, e))
self.__allinverters = False
self.__detection_running = False
if len(self.__inverters) == len(self.__inverter_list):
self.__allinverters = True
DEBUG('found all inverters:')
DEBUG(self.__inverters)
else:
DEBUG('not all invertes found, reconnection!')
self.__connect()
def inverters(self):
if not self.__allinverters:
self.detect_inverters()
return self.__inverters
+19
View File
@@ -0,0 +1,19 @@
mqtt:
ca_cert: null
clean_session: true
host: 192.168.1.x
keepalive: 60
password: xxxx
port: 1883
reconnect_timeout: 5
use_ssl: null
username: xxxx
solarmax:
inverters:
192.168.1.123:
- 1
ip: 192.168.1.4
loop_timeout: 5
origine: automation
topic_base:
uuid:
View File
+149
View File
@@ -0,0 +1,149 @@
#
# mqtt service
#
import json, time, logging, ssl
import paho.mqtt.client as mqtt
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class MqttBase(object):
def __init__(self, **p):
super().__init__()
self.host = p.get('host')
self.port = p.get('port')
self.username = p.get('username')
self.password = p.get('password')
self.keepalive = p.get('keepalive')
self.use_ssl = p.get('use_ssl', False)
self.ca_cert = p.get('ca_cert')
self.tls_version = p.get('tls_version', ssl.PROTOCOL_TLSv1_2)
self.subscriptions = [(topic, qos) for topic, qos in p.get('topic_subs', [])]
self.unsubs = self.client_get_unsubs()
self.topic_base = p.get('topic_base', '')
self.on_message_callback = p.get('on_messages', self._on_message_callback)
self.on_bytes_callback = p.get('on_bytes', self._on_bytes_callback)
# mqtt Client
self.client = mqtt.Client()
if self.username:
self.client.username_pw_set(username=self.username, password=self.password)
if self.use_ssl and self.ca_cert:
self.client.tls_set(ca_certs=self.ca_cert, tls_version=self.tls_version)
self.client.on_connect = self._on_connect
self.client.on_disconnect = self._on_disconnect
self.client.on_message = self._on_message
self.client.on_log = self._on_log
def client_get_unsubs(self):
return [topic for topic, _ in self.subscriptions ]
def client_set_subscriptions(self):
self.client.unsubscribe(self.client_get_unsubs())
self.client.subscribe(self.subscriptions)
logger.info(f"\n client_set_subscription {self.subscriptions}")
def client_add_subscriptions(self, subs=[]):
self.subscriptions += subs
self.subscriptions = list(set(self.subscriptions))
def _publish_message(self, topic, **payload):
try:
qos = payload.pop('qos', 0)
retain = payload.pop('retain', False)
message = json.dumps(payload)
self.client.publish(topic, payload=message.encode('utf-8'), qos=qos, retain=retain)
except Exception as e:
logger.error(e)
def _publish_bytes(self, topic, payload, **conf):
try:
qos = conf.pop('qos', 0)
retain = conf.pop('retain', False)
self.client.publish(topic, payload=payload, qos=qos, retain=retain)
except Exception as e:
logger.error(f"\n _publish_bytes error: {e}")
def _on_log(self, mqttc, obj, level, string): # @UnusedVariable
pass
def _on_connect_info(self, info):
logger.info(info)
def _on_connect(self, client, userdata, flags, rc):
msg = f"\n {client._client_id} connected\n status {rc}\n host={self.host}:{self.port}\n username={self.username}"
try:
if rc:
raise
self.client_set_subscriptions()
self._on_connect_info(msg)
except Exception as e:
logger.error(f"\n _on_connect error {e}")
def _on_disconnect(self, client, userdata, rc):
logger.info(f"\n Disconnected: {client._client_id} with status {rc}")
try:
j = 3
for i in range(j):
#client.unsubscribe(self.unsubs)
try:
client.reconnect()
break
except Exception as e:
if i < j:
logger.warn(e)
time.sleep(1)
continue
else:
raise
except Exception as e:
logger.error(f"\n _on_disconnect error {e}")
def _on_stop_mqtt(self):
pass
def _on_message_callback(self, topic, payload):
pass
def _on_bytes_callback(self, topic, payload):
pass
def _on_message(self, client, userdata, message):
try:
try:
payload = json.loads(message.payload.decode("utf-8"))
self.on_message_callback(message.topic, payload)
except:
self.on_bytes_callback(message.topic, message.payload)
except Exception as e:
logger.error(f"\n _on_message error {e}\n {message.topic} {message.payload[:80]}")
def connectMQTT(self):
self.client.connect_async(self.host, self.port, self.keepalive)
def startMQTT(self):
self.connectMQTT()
self.client.loop_forever()
def stopMQTT(self):
self._on_stop_mqtt()
self.client.disconnect()
+138
View File
@@ -0,0 +1,138 @@
'''
Created on 20 avr. 2022
@author: denis
'''
import json, yaml
import importlib
from datetime import datetime
from decimal import Decimal
import string, secrets
import uuid, socket
class TopicBase:
topickeys = {}
def __init__(self, topics):
self.topics = topics.split('/') or []
def get(self, k):
try:
return self.topics[self.topickeys.get(k)]
except:
pass
return None
@classmethod
def arg(cls, topic, k):
topics = topic.split('/') or []
return topics[cls.get(k)]
def yaml_load(f):
with open(f, 'r') as stream:
return yaml.safe_load(stream)
return {}
def yaml_save(f, context):
with open(f, 'w') as stream:
yaml.dump(context, stream, default_flow_style = False)
def bitread(b, bitpos):
return (b>>bitpos) & 0x1
def get_fqdn():
return socket.getfqdn()
def get_uuid():
return str(hex(uuid.getnode()))[2:]
def ts_now(m=1):
now = datetime.now().timestamp()*m
return int(now)
def random_num(n=16):
alphabet = string.digits
return ''.join(secrets.choice(alphabet) for i in range(n)) # @UnusedVariable
def random_chars(n=6):
alphabet = string.ascii_letters + string.digits
return ''.join(secrets.choice(alphabet) for i in range(n)) # @UnusedVariable
def get_apikey(n=32):
chars = 'abcdefgh01234ijklABCD4567EFGHIJKLmnopqrstuvwxyz0123456789MNOPQRS789TUVWXYZ'
return ''.join(secrets.choice(chars) for i in range(n)) # @UnusedVariable
def str_to_float(n, default='NaN'):
try:
return float(str(n).strip().replace(',', '.'))
except:
return default
def str_to_int(n, default='NaN'):
try:
return int(str(n).strip())
except:
return default
def gps_conv(s, n=1000000, default='NaN'):
try:
return str( int(Decimal(s)*n) )
except:
return default
def conv_gps(v, default=None):
try:
if v == 'NaN':
raise
return float(Decimal(v)/1000000)
except:
return default
def js_serialize_array_to_dict(jsarray):
d = {}
for f in json.loads(jsarray):
name = f.get('name')
value = f.get('value')
if name:
d[name] = value
return d
def get_instance_class(module):
modulename, classname = module.rsplit(".", 1)
return getattr(importlib.import_module(modulename), classname)
def gen_keywords(s):
c = s.replace(',', ' ').replace('+', ' ')
return [ w.strip() for w in c.split(' ') if w]
def gen_device_uuid(n=19):
return hex(int(random_num(n)))[2:]
def get_device_uuid(n=19):
return f'0x{gen_device_uuid(n)}'
def dimensions(s):
w, h = s.split('x')
return int(w), int(h)
def dim_to_size(w, h):
return f'{w}x{h}'
+233
View File
@@ -0,0 +1,233 @@
#!../.venv/bin/python
##!/opt/lib/python/venv_app/bin/python
'''
Created on 7 nov. 2023
@author: denis
'''
import threading, logging, argparse
from SolarMax.solarmax_fr import SolarMax, get_status_code
from contrib.mqttc import MqttBase
from contrib import utils
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
INVERTERS = {
'192.168.1.123': [1,],
#'192.168.0.202': [2,],
#'192.168.0.203': [3,],
#'192.168.0.204': [4,],
}
ACCESS = [
(1, "Pub only"),
(2, "/set only"),
(5, "/get, pub"),
(7, "/get, /set, pub"),
]
class SolarmaxMqttWorker(MqttBase):
def __init__(self, parent=None, **p):
super().__init__(**p)
self.parent = parent
def makeReport(self):
data = dict(
name = 'Onduleur Solarmax',
uuid = self.parent.uuid,
sensor = 'solarmax',
vendor = 'Solarmax',
model_id = 'SM2000S',
description = "Onduleur Solarmax SM2000S, 1980 W",
ip = self.parent.ip,
org = self.parent.org,
datas = [
{"access":1,"description":"Linux timestamp en secondes","label":"Timestamp","name":"time","property":"time","type":"numeric","unit":"s"},
{"access":1,"description":"Numéro d'onduleur","label":"Inverter","name":"inv","property":"range","type":"numeric","unit":""},
{"access":1,"description":"Intensité max","label":"Ivmax","name":"ivmax","property":"intensity","type":"numeric","unit":"A"},
{"access":1,"description":"Production AC","label":"PAC","name":"pac","property":"power","type":"numeric","unit":"W"},
{"access":1,"description":"Rendement AC","label":"Eac","name":"eac","property":"efficiency","type":"numeric","unit":""},
{"access":1,"description":"Production DC.","label":"PDC","name":"pdc","property":"power","type":"numeric","unit":"W"},
{"access":1,"description":"Efficacity DC","label":"Edc","name":"edc","property":"efficiency","type":"numeric","unit":""},
{"access":1,"description":"Production du jour","label":"Qday","name":"qdy","property":"days","type":"numeric","unit":"kWh"},
{"access":1,"description":"Production totale","label":"Qtotal","name":"qt0","property":"days","type":"numeric","unit":"kWh"},
{"access":1,"description":"Status onduleur","label":"Status","name":"stat","property":"state","type":"text","unit":""},
{"access":1,"description":"Température des panneaux","label":"Temperature","name":"tmpr","property":"temperature","type":"numeric","unit":"°C"},
],
)
return data
def publish_to_client(self, evt, **payload):
#logger.info(f"{self.topic_base}/{utils.ts_now()}/{evt}\n{payload}")
self._publish_message(f'{self.topic_base}/{evt}', **payload)
def _on_stop_mqtt(self):
self.publish_to_client('stop', alive=False)
logger.info(f'WAITING 1s for last message')
threading.Event().wait(1)
def _on_connect_info(self, info):
logger.info(f"{info}\n subs: {self.subscriptions}")
self.publish_to_client('report', retain=True, **self.makeReport())
def _on_message_callback(self, topic, payload):
try:
pass
#if topic.endswith('registry'):
# self.publish_to_client('report', **self.makeReport())
except Exception as e:
logger.error(e)
class SolarmaxDaemon():
def __init__(self, conf_file, **settings):
super().__init__()
self.conf_file = conf_file
self.settings = settings
topic_subs = self.settings['solarmax']['topic_subs']
topic_base = self.settings['solarmax']['topic_base']
inverters = self.settings['solarmax']['inverters']
self.timeout = self.settings['solarmax']['loop_timeout']
self.uuid = hex(self.settings['solarmax']['uuid'])
self.ip = self.settings['solarmax']['ip']
self.org = self.settings['solarmax']['origine']
self.solar_stop = threading.Event()
self.smlist = []
for host in inverters.keys():
sm = SolarMax(host, 12345)
sm.use_inverters(inverters[host])
self.smlist.append(sm)
self.allinverters = []
for host in inverters.keys():
self.allinverters.extend(inverters[host])
self.inverters_size = len(self.allinverters)
self.mqtt = SolarmaxMqttWorker(parent=self, topic_base=topic_base ,topic_subs=topic_subs, **settings['mqtt'])
self.mqtt.connectMQTT()
def start(self):
self.mqtt.client.loop_start()
self.run_forever()
def stop(self):
self.mqtt.client.loop_stop()
self.solar_stop.set()
def run_forever(self):
logger.info(f'Module SolarmaxDaemon::run_forever is started')
while not self.solar_stop.is_set():
try:
count = 0
for sm in self.smlist:
for (no, ivdata) in sm.inverters().items():
try:
(inverter, current) = sm.query(no, ['PAC', 'TKK', 'KDY', 'KT0', 'IDC', 'UDC', 'IL1', 'UL1', 'FDAT', 'SYS'])
count += 1
except:
logger.info(f'Erreur de communication, éventuellement onduleur éteint, WR {no}')
threading.Event().wait(self.timeout)
continue
ivmax = ivdata['installed']
ivname = ivdata['desc']
UAC = current['UL1']
IAC = current['IL1']
PAC = UAC * IAC
IDC = current['IDC']
UDC = current['UDC']
tmpr = current['TKK']
eac = int((PAC/ivmax) * 100) # rendement AC
PDC = UDC * IDC
edc = int((float(PAC)/PDC) * 100) # efficacity DC
(status, errors) = sm.status(no)
if errors:
logger.error(f'WR {no}: {status} ({errors})')
continue
logger.debug(
f'''
Onduleur..............: n° {inverter} ({ivname})
Status................: {status}
Température panneaux..: {tmpr} °C
Tension CC............: {UDC} V
Intensité CC..........: {IDC} A
Tension AC............: {UAC} V
Intensité AC..........: {IAC} A
Production AC.........: {current['PAC']:9.1f} Watt / calculée: {PAC:9.1f} W rendement: ({eac}% de {ivmax} Watt)
Production DC.........: {PDC:9.1f} Watt (Efficacité: {edc}%)
Total aujourd'hui.....: {current['KDY']:9.1f} kWh
Total jusqu'à présent.: {current['KT0']:9.1f} kWh (depuis le {current['FDAT'].date()})
'''
)
if count < self.inverters_size:
raise Exception(f"({count} < {self.inverters_size} => Erreur de communication, éventuellement onduleur éteint")
payload = dict(
time=utils.ts_now(),
inv=inverter,
ivmax=ivmax,
tmpr=tmpr,
pac=round(PAC, 1),
eac=eac,
pdc=round(PDC, 1),
edc=edc,
qdy=current['KDY'],
qt0=current['KT0'],
stat=get_status_code(status),
)
self.mqtt.publish_to_client('production', **payload)
except Exception as e:
logger.error(e)
threading.Event().wait(self.timeout)
def load_configuration(conf_file):
settings = utils.yaml_load(conf_file)
if not settings['solarmax']['uuid']:
uuid = f'0x{utils.gen_device_uuid(19)}'
origine = settings['solarmax']['origine']
settings['solarmax']['uuid'] = uuid
settings['solarmax']['inverters'] = INVERTERS
settings['solarmax']['topic_base'] = f"{origine}/{uuid}"
settings['solarmax']['topic_subs']= [[f"{origine}/{uuid}/#", 0], ]
utils.yaml_save(conf_file, settings)
return settings
def main(conf_file):
daemon = None
try:
config = load_configuration(conf_file)
daemon = SolarmaxDaemon(conf_file, **config)
daemon.start()
except Exception as e:
print(f'\n SolarmaxDaemon error {e}')
finally:
if daemon:
daemon.stop()
if __name__ == '__main__':
parser = argparse.ArgumentParser(description="Solarmax device")
parser.add_argument("--config", default='config.yaml', help="Config yaml file path", required=False)
args = parser.parse_args()
if args.config:
main(args.config)