Browse Source

RPi:(new)DTU-name,Disco-handler,ext.Error-handling,sun2mqtt

Add disconnect handler for influx and volkszaehler.
Change spec. Informations on ahoy.service and ahoy.yml.example.
Extented Error handling.
Send sun-rise and sun-set information to MQTT.
pull/679/head
Knuti_in_Paese 2 years ago
parent
commit
6b3af717fb
  1. 3
      tools/rpi/ahoy.service
  2. 13
      tools/rpi/ahoy.yml.example
  3. 45
      tools/rpi/hoymiles/__main__.py
  4. 17
      tools/rpi/hoymiles/decoders/__init__.py
  5. 61
      tools/rpi/hoymiles/outputs.py

3
tools/rpi/ahoy.service

@ -6,8 +6,7 @@
# WorkingDirectory (absolute path to your private ahoy dir) # WorkingDirectory (absolute path to your private ahoy dir)
# To change other config parameter, please consult systemd documentation # To change other config parameter, please consult systemd documentation
# #
# To activate this service, create a link with enable and start the ahoy.service # To activate this service, enable and start ahoy.service
# $ mkdir -p $HOME/.config/systemd/user
# $ systemctl --user enable $(pwd)/ahoy/tools/rpi/ahoy.service # $ systemctl --user enable $(pwd)/ahoy/tools/rpi/ahoy.service
# $ systemctl --user status ahoy # $ systemctl --user status ahoy
# $ systemctl --user start ahoy # $ systemctl --user start ahoy

13
tools/rpi/ahoy.yml.example

@ -31,7 +31,7 @@ ahoy:
QoS: 0 QoS: 0
Retain: True Retain: True
last_will: last_will:
topic: hoymiles/114172220003 # defaults to 'hoymiles/{serial}' topic: my_DTU_name # Name of DTU - default: hoymiles/{DTU-serial}
payload: "LAST-WILL-MESSAGE: Please check my HOST and Process!" payload: "LAST-WILL-MESSAGE: Please check my HOST and Process!"
# Influx2 output # Influx2 output
@ -96,6 +96,7 @@ ahoy:
dtu: dtu:
serial: 99978563001 serial: 99978563001
name: my_DTU_name
inverters: inverters:
- name: 'balkon' - name: 'balkon'
@ -103,14 +104,14 @@ ahoy:
txpower: 'low' # txpower per inverter (min,low,high,max) txpower: 'low' # txpower per inverter (min,low,high,max)
mqtt: mqtt:
send_raw_enabled: false # allow inject debug data via mqtt send_raw_enabled: false # allow inject debug data via mqtt
topic: 'hoymiles/114172221234' # defaults to '{inverter-name}/{serial}' topic: 'hoymiles/114172220003' # defaults to '{inverter-name}/{serial}'
strings: # list all available strings strings: # list all available strings
- s_name: 'String 1 left' # String 1 name - s_name: 'String 1 left' # String 1 name
s_maxpower: 395 # String 1 max power in Wp s_maxpower: 395 # String 1 max power in inverter
- s_name: 'String 2 right' # String 2 name - s_name: 'String 2 right' # String 2 name
s_maxpower: 400 # String 2 max power in Wp s_maxpower: 400 # String 2 max power in inverter
- s_name: 'String 3 up' # String 3 name - s_name: 'String 3 up' # String 3 name
s_maxpower: 405 # String 3 max power in Wp s_maxpower: 405 # String 3 max power in inverter
- s_name: 'String 4 down' # String 4 name - s_name: 'String 4 down' # String 4 name
s_maxpower: 410 # String 4 max power in Wp s_maxpower: 410 # String 4 max power in inverter

45
tools/rpi/hoymiles/__main__.py

@ -33,6 +33,12 @@ def signal_handler(sig_num, frame):
if mqtt_client: if mqtt_client:
mqtt_client.disco() mqtt_client.disco()
if influx_client:
influx_client.disco()
if volkszaehler_client:
volkszaehler_client.disco()
sys.exit(0) sys.exit(0)
signal(SIGINT, signal_handler) # Interrupt from keyboard (CTRL + C) signal(SIGINT, signal_handler) # Interrupt from keyboard (CTRL + C)
@ -75,7 +81,6 @@ class SunsetHandler:
else: else:
logging.info('Sunset disabled.') logging.info('Sunset disabled.')
def checkWaitForSunrise(self): def checkWaitForSunrise(self):
if not self.suntimes: if not self.suntimes:
return return
@ -94,6 +99,23 @@ class SunsetHandler:
time.sleep(time_to_sleep) time.sleep(time_to_sleep)
logging.info (f'Woke up...') logging.info (f'Woke up...')
def sun_status2mqtt(self, dtu_ser, dtu_name):
if not mqtt_client:
return
local_sunrise = self.suntimes.riselocal(datetime.now()).strftime("%d.%m.%YT%H:%M")
local_sunset = self.suntimes.setlocal(datetime.now()).strftime("%d.%m.%YT%H:%M")
local_zone = self.suntimes.setlocal(datetime.now()).tzinfo._key
if self.suntimes:
mqtt_client.info2mqtt({'topic' : f'{dtu_name}/{dtu_ser}'}, \
{'dis_night_comm' : 'True', \
'local_sunrise' : local_sunrise, \
'local_sunset' : local_sunset,
'local_zone' : local_zone})
else:
mqtt_client.sun_info2mqtt({'sun_topic': f'{dtu_name}/{dtu_ser}'}, \
{'dis_night_comm': 'False'})
def main_loop(ahoy_config): def main_loop(ahoy_config):
"""Main loop""" """Main loop"""
inverters = [ inverters = [
@ -101,7 +123,9 @@ def main_loop(ahoy_config):
if not inverter.get('disabled', False)] if not inverter.get('disabled', False)]
sunset = SunsetHandler(ahoy_config.get('sunset')) sunset = SunsetHandler(ahoy_config.get('sunset'))
dtu_ser = ahoy_config.get('dtu', {}).get('serial') dtu_ser = ahoy_config.get('dtu', {}).get('serial', None)
dtu_name = ahoy_config.get('dtu', {}).get('name', 'hoymiles-dtu')
sunset.sun_status2mqtt(dtu_ser, dtu_name)
loop_interval = ahoy_config.get('interval', 1) loop_interval = ahoy_config.get('interval', 1)
try: try:
@ -112,6 +136,11 @@ def main_loop(ahoy_config):
t_loop_start = time.time() t_loop_start = time.time()
for inverter in inverters: for inverter in inverters:
if not 'name' in inverter:
inverter['name'] = 'hoymiles'
if not 'serial' in inverter:
logging.error("No inverter serial number found in ahoy.yml - exit")
sys.exit(999)
if hoymiles.HOYMILES_DEBUG_LOGGING: if hoymiles.HOYMILES_DEBUG_LOGGING:
logging.info(f'Poll inverter name={inverter["name"]} ser={inverter["serial"]}') logging.info(f'Poll inverter name={inverter["name"]} ser={inverter["serial"]}')
poll_inverter(inverter, dtu_ser, do_init, 3) poll_inverter(inverter, dtu_ser, do_init, 3)
@ -122,8 +151,6 @@ def main_loop(ahoy_config):
if time_to_sleep > 0: if time_to_sleep > 0:
time.sleep(time_to_sleep) time.sleep(time_to_sleep)
except KeyboardInterrupt:
sys.exit()
except Exception as e: except Exception as e:
logging.fatal('Exception catched: %s' % e) logging.fatal('Exception catched: %s' % e)
logging.fatal(traceback.print_exc()) logging.fatal(traceback.print_exc())
@ -284,11 +311,11 @@ def init_logging(ahoy_config):
lvl = logging.ERROR lvl = logging.ERROR
elif level == 'FATAL': elif level == 'FATAL':
lvl = logging.FATAL lvl = logging.FATAL
if hoymiles.HOYMILES_TRANSACTION_LOGGING and hoymiles.HOYMILES_DEBUG_LOGGING: if hoymiles.HOYMILES_TRANSACTION_LOGGING:
lvl = logging.DEBUG lvl = logging.DEBUG
if not hoymiles.HOYMILES_TRANSACTION_LOGGING and not hoymiles.HOYMILES_DEBUG_LOGGING:
lvl = logging.INFO
logging.basicConfig(filename=fn, format='%(asctime)s %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S', level=lvl) logging.basicConfig(filename=fn, format='%(asctime)s %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S', level=lvl)
dtu_name = ahoy_config.get('dtu',{}).get('name','hoymiles-dtu')
logging.info(f'start logging for {dtu_name} with level: {logging.root.level}')
if __name__ == '__main__': if __name__ == '__main__':
parser = argparse.ArgumentParser(description='Ahoy - Hoymiles solar inverter gateway', prog="hoymiles") parser = argparse.ArgumentParser(description='Ahoy - Hoymiles solar inverter gateway', prog="hoymiles")
@ -330,14 +357,14 @@ if __name__ == '__main__':
# create MQTT - client object # create MQTT - client object
mqtt_client = None mqtt_client = None
mqtt_config = ahoy_config.get('mqtt', {}) mqtt_config = ahoy_config.get('mqtt', None)
if mqtt_config and not mqtt_config.get('disabled', False): if mqtt_config and not mqtt_config.get('disabled', False):
from .outputs import MqttOutputPlugin from .outputs import MqttOutputPlugin
mqtt_client = MqttOutputPlugin(mqtt_config) mqtt_client = MqttOutputPlugin(mqtt_config)
# create INFLUX - client object # create INFLUX - client object
influx_client = None influx_client = None
influx_config = ahoy_config.get('influxdb', {}) influx_config = ahoy_config.get('influxdb', None)
if influx_config and not influx_config.get('disabled', False): if influx_config and not influx_config.get('disabled', False):
from .outputs import InfluxOutputPlugin from .outputs import InfluxOutputPlugin
influx_client = InfluxOutputPlugin( influx_client = InfluxOutputPlugin(

17
tools/rpi/hoymiles/decoders/__init__.py

@ -155,6 +155,7 @@ class StatusResponse(Response):
s_exists = False s_exists = False
string_id = len(strings) string_id = len(strings)
string = {} string = {}
string['name'] = self.inv_strings[string_id]['s_name']
for key in self.string_keys: for key in self.string_keys:
prop = f'dc_{key}_{string_id}' prop = f'dc_{key}_{string_id}'
if hasattr(self, prop): if hasattr(self, prop):
@ -329,7 +330,7 @@ class EventsResponse(UnknownResponse):
self.status = struct.unpack('>H', self.response[:2])[0] self.status = struct.unpack('>H', self.response[:2])[0]
self.a_text = self.alarm_codes.get(self.status, 'N/A') self.a_text = self.alarm_codes.get(self.status, 'N/A')
logging.info (f' Inverter status: {self.a_text} ({self.status})') logging.info (f'Inverter status: {self.a_text} ({self.status})')
chunk_size = 12 chunk_size = 12
for i_chunk in range(2, len(self.response), chunk_size): for i_chunk in range(2, len(self.response), chunk_size):
@ -489,6 +490,8 @@ class Hm300Decode0B(StatusResponse):
""" String 1 irratiation in percent """ """ String 1 irratiation in percent """
if self.inv_strings is None: if self.inv_strings is None:
return None return None
if self.inv_strings[0]['s_maxpower'] == 0:
return 0.00
return round(self.unpack('>H', 6)[0]/10/self.inv_strings[0]['s_maxpower']*100, 3) return round(self.unpack('>H', 6)[0]/10/self.inv_strings[0]['s_maxpower']*100, 3)
@property @property
@ -561,6 +564,8 @@ class Hm600Decode0B(StatusResponse):
""" String 1 irratiation in percent """ """ String 1 irratiation in percent """
if self.inv_strings is None: if self.inv_strings is None:
return None return None
if self.inv_strings[0]['s_maxpower'] == 0:
return 0.00
return round(self.unpack('>H', 6)[0]/10/self.inv_strings[0]['s_maxpower']*100, 3) return round(self.unpack('>H', 6)[0]/10/self.inv_strings[0]['s_maxpower']*100, 3)
@property @property
@ -588,6 +593,8 @@ class Hm600Decode0B(StatusResponse):
""" String 2 irratiation in percent """ """ String 2 irratiation in percent """
if self.inv_strings is None: if self.inv_strings is None:
return None return None
if self.inv_strings[1]['s_maxpower'] == 0:
return 0.00
return round(self.unpack('>H', 12)[0]/10/self.inv_strings[1]['s_maxpower']*100, 3) return round(self.unpack('>H', 12)[0]/10/self.inv_strings[1]['s_maxpower']*100, 3)
@property @property
@ -668,6 +675,8 @@ class Hm1200Decode0B(StatusResponse):
""" String 1 irratiation in percent """ """ String 1 irratiation in percent """
if self.inv_strings is None: if self.inv_strings is None:
return None return None
if self.inv_strings[0]['s_maxpower'] == 0:
return 0.00
return round(self.unpack('>H', 8)[0]/10/self.inv_strings[0]['s_maxpower']*100, 3) return round(self.unpack('>H', 8)[0]/10/self.inv_strings[0]['s_maxpower']*100, 3)
@property @property
@ -695,6 +704,8 @@ class Hm1200Decode0B(StatusResponse):
""" String 2 irratiation in percent """ """ String 2 irratiation in percent """
if self.inv_strings is None: if self.inv_strings is None:
return None return None
if self.inv_strings[1]['s_maxpower'] == 0:
return 0.00
return round(self.unpack('>H', 10)[0]/10/self.inv_strings[1]['s_maxpower']*100, 3) return round(self.unpack('>H', 10)[0]/10/self.inv_strings[1]['s_maxpower']*100, 3)
@property @property
@ -722,6 +733,8 @@ class Hm1200Decode0B(StatusResponse):
""" String 3 irratiation in percent """ """ String 3 irratiation in percent """
if self.inv_strings is None: if self.inv_strings is None:
return None return None
if self.inv_strings[2]['s_maxpower'] == 0:
return 0.00
return round(self.unpack('>H', 30)[0]/10/self.inv_strings[2]['s_maxpower']*100, 3) return round(self.unpack('>H', 30)[0]/10/self.inv_strings[2]['s_maxpower']*100, 3)
@property @property
@ -749,6 +762,8 @@ class Hm1200Decode0B(StatusResponse):
""" String 4 irratiation in percent """ """ String 4 irratiation in percent """
if self.inv_strings is None: if self.inv_strings is None:
return None return None
if self.inv_strings[3]['s_maxpower'] == 0:
return 0.00
return round(self.unpack('>H', 32)[0]/10/self.inv_strings[3]['s_maxpower']*100, 3) return round(self.unpack('>H', 32)[0]/10/self.inv_strings[3]['s_maxpower']*100, 3)
@property @property

61
tools/rpi/hoymiles/outputs.py

@ -40,6 +40,7 @@ class InfluxOutputPlugin(OutputPluginFactory):
def __init__(self, url, token, **params): def __init__(self, url, token, **params):
""" """
Initialize InfluxOutputPlugin Initialize InfluxOutputPlugin
https://influxdb-client.readthedocs.io/en/stable/api.html#influxdbclient
The following targets must be present in your InfluxDB. This does not The following targets must be present in your InfluxDB. This does not
automatically create anything for You. automatically create anything for You.
@ -69,8 +70,12 @@ class InfluxOutputPlugin(OutputPluginFactory):
self._org = params.get('org', '') self._org = params.get('org', '')
self._measurement = params.get('measurement', f'inverter,host={socket.gethostname()}') self._measurement = params.get('measurement', f'inverter,host={socket.gethostname()}')
client = InfluxDBClient(url, token, bucket=self._bucket) with InfluxDBClient(url, token, bucket=self._bucket) as self.client:
self.api = client.write_api() self.api = self.client.write_api()
def disco(self, **params):
self.client.close() # Shutdown the client
return
def store_status(self, response, **params): def store_status(self, response, **params):
""" """
@ -103,6 +108,9 @@ class InfluxOutputPlugin(OutputPluginFactory):
# InfluxDB requires nanoseconds # InfluxDB requires nanoseconds
ctime = int(utctime.timestamp() * 1e9) ctime = int(utctime.timestamp() * 1e9)
if HOYMILES_DEBUG_LOGGING:
logging.info(f'InfluxDB: utctime: {utctime}')
# AC Data # AC Data
phase_id = 0 phase_id = 0
for phase in data['phases']: for phase in data['phases']:
@ -136,6 +144,9 @@ class InfluxOutputPlugin(OutputPluginFactory):
data_stack.append(f'{measurement},type=YieldToday value={data["yield_today"]/1000:.3f} {ctime}') data_stack.append(f'{measurement},type=YieldToday value={data["yield_today"]/1000:.3f} {ctime}')
data_stack.append(f'{measurement},type=Efficiency value={data["efficiency"]:.2f} {ctime}') data_stack.append(f'{measurement},type=Efficiency value={data["efficiency"]:.2f} {ctime}')
if HOYMILES_DEBUG_LOGGING:
#logging.debug(f'INFLUX data to DB: {data_stack}')
pass
self.api.write(self._bucket, self._org, data_stack) self.api.write(self._bucket, self._org, data_stack)
class MqttOutputPlugin(OutputPluginFactory): class MqttOutputPlugin(OutputPluginFactory):
@ -197,6 +208,12 @@ class MqttOutputPlugin(OutputPluginFactory):
def disco(self, **params): def disco(self, **params):
self.client.loop_stop() # Stop loop self.client.loop_stop() # Stop loop
self.client.disconnect() # disconnect self.client.disconnect() # disconnect
return
def info2mqtt(self, mqtt_topic, mqtt_data):
for mqtt_key in mqtt_data:
self.client.publish(f'{mqtt_topic["topic"]}/{mqtt_key}', mqtt_data[mqtt_key], self.qos, self.ret)
return
def store_status(self, response, **params): def store_status(self, response, **params):
""" """
@ -210,13 +227,18 @@ class MqttOutputPlugin(OutputPluginFactory):
""" """
data = response.__dict__() data = response.__dict__()
topic = params.get('topic', None)
if not topic:
topic = f'{data.get("inverter_name", "hoymiles")}/{data.get("inverter_ser", None)}' topic = f'{data.get("inverter_name", "hoymiles")}/{data.get("inverter_ser", None)}'
if HOYMILES_DEBUG_LOGGING:
logging.info(f'MQTT-topic: {topic} data-type: {type(response)}')
if isinstance(response, StatusResponse): if isinstance(response, StatusResponse):
# Global Head # Global Head
if data['time'] is not None: if data['time'] is not None:
self.client.publish(f'{topic}/time', data['time'].strftime("%d.%m.%y - %H:%M:%S"), self.qos, self.ret) self.client.publish(f'{topic}/time', data['time'].strftime("%d.%m.%YT%H:%M:%S"), self.qos, self.ret)
# AC Data # AC Data
phase_id = 0 phase_id = 0
@ -234,12 +256,16 @@ class MqttOutputPlugin(OutputPluginFactory):
string_id = 0 string_id = 0
string_sum_power = 0 string_sum_power = 0
for string in data['strings']: for string in data['strings']:
self.client.publish(f'{topic}/emeter-dc/{string_id}/voltage', string['voltage'], self.qos, self.ret) if 'name' in string:
self.client.publish(f'{topic}/emeter-dc/{string_id}/current', string['current'], self.qos, self.ret) string_name = string['name'].replace(" ","_")
self.client.publish(f'{topic}/emeter-dc/{string_id}/power', string['power'], self.qos, self.ret) else:
self.client.publish(f'{topic}/emeter-dc/{string_id}/YieldDay', string['energy_daily'], self.qos, self.ret) string_name = string_id
self.client.publish(f'{topic}/emeter-dc/{string_id}/YieldTotal', string['energy_total']/1000, self.qos, self.ret) self.client.publish(f'{topic}/emeter-dc/{string_name}/voltage', string['voltage'], self.qos, self.ret)
self.client.publish(f'{topic}/emeter-dc/{string_id}/Irradiation', string['irradiation'], self.qos, self.ret) self.client.publish(f'{topic}/emeter-dc/{string_name}/current', string['current'], self.qos, self.ret)
self.client.publish(f'{topic}/emeter-dc/{string_name}/power', string['power'], self.qos, self.ret)
self.client.publish(f'{topic}/emeter-dc/{string_name}/YieldDay', string['energy_daily'], self.qos, self.ret)
self.client.publish(f'{topic}/emeter-dc/{string_name}/YieldTotal', string['energy_total']/1000, self.qos, self.ret)
self.client.publish(f'{topic}/emeter-dc/{string_name}/Irradiation', string['irradiation'], self.qos, self.ret)
string_id = string_id + 1 string_id = string_id + 1
string_sum_power += string['power'] string_sum_power += string['power']
@ -297,6 +323,9 @@ class VzInverterOutput:
ts = int(round(data['time'].timestamp() * 1000)) ts = int(round(data['time'].timestamp() * 1000))
if HOYMILES_DEBUG_LOGGING:
logging.info(f'Volkszaehler-Timestamp: {ts}')
# AC Data # AC Data
phase_id = 0 phase_id = 0
for phase in data['phases']: for phase in data['phases']:
@ -329,6 +358,7 @@ class VzInverterOutput:
if data['yield_today'] is not None: if data['yield_today'] is not None:
self.try_publish(ts, f'yield_today', data['yield_today']) self.try_publish(ts, f'yield_today', data['yield_today'])
self.try_publish(ts, f'efficiency', data['efficiency']) self.try_publish(ts, f'efficiency', data['efficiency'])
return
def try_publish(self, ts, ctype, value): def try_publish(self, ts, ctype, value):
if not ctype in self.channels: if not ctype in self.channels:
@ -340,9 +370,12 @@ class VzInverterOutput:
url = f'{self.baseurl}/data/{uid}.json?operation=add&ts={ts}&value={value}' url = f'{self.baseurl}/data/{uid}.json?operation=add&ts={ts}&value={value}'
if uid == None: if uid == None:
if HOYMILES_DEBUG_LOGGING: if HOYMILES_DEBUG_LOGGING:
logging.warning(f'ctype \"{ctype}\" has no configured uid-value in ahoy.yml') logging.debug(f'ctype \"{ctype}\" has no configured uid-value in ahoy.yml')
return return
if HOYMILES_DEBUG_LOGGING:
logging.debug(f'VZ-url: {url}')
try: try:
r = self.session.get(url) r = self.session.get(url)
if r.status_code == 404: if r.status_code == 404:
@ -353,6 +386,7 @@ class VzInverterOutput:
raise ValueError(f'Transmit result {url}') raise ValueError(f'Transmit result {url}')
except ConnectionError as e: except ConnectionError as e:
raise ValueError(f'Could not connect VZ-DB {type(e)} {e.keys()}') raise ValueError(f'Could not connect VZ-DB {type(e)} {e.keys()}')
return
class VolkszaehlerOutputPlugin(OutputPluginFactory): class VolkszaehlerOutputPlugin(OutputPluginFactory):
def __init__(self, config, **params): def __init__(self, config, **params):
@ -373,13 +407,17 @@ class VolkszaehlerOutputPlugin(OutputPluginFactory):
exit(1) exit(1)
self.session = requests.Session() self.session = requests.Session()
self.inverters = dict()
self.inverters = dict()
for inverterconfig in config.get('inverters', []): for inverterconfig in config.get('inverters', []):
serial = inverterconfig.get('serial') serial = inverterconfig.get('serial')
output = VzInverterOutput(inverterconfig, self.session) output = VzInverterOutput(inverterconfig, self.session)
self.inverters[serial] = output self.inverters[serial] = output
def disco(self, **params):
self.session.close() # closing the connection
return
def store_status(self, response, **params): def store_status(self, response, **params):
""" """
Publish StatusResponse object Publish StatusResponse object
@ -404,3 +442,4 @@ class VolkszaehlerOutputPlugin(OutputPluginFactory):
output.store_status(data, self.session) output.store_status(data, self.session)
except ValueError as e: except ValueError as e:
logging.warning('Could not send data to volkszaehler instance: %s' % e) logging.warning('Could not send data to volkszaehler instance: %s' % e)
return

Loading…
Cancel
Save