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)
# To change other config parameter, please consult systemd documentation
#
# To activate this service, create a link with enable and start the ahoy.service
# $ mkdir -p $HOME/.config/systemd/user
# To activate this service, enable and start ahoy.service
# $ systemctl --user enable $(pwd)/ahoy/tools/rpi/ahoy.service
# $ systemctl --user status ahoy
# $ systemctl --user start ahoy

13
tools/rpi/ahoy.yml.example

@ -31,7 +31,7 @@ ahoy:
QoS: 0
Retain: True
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!"
# Influx2 output
@ -96,6 +96,7 @@ ahoy:
dtu:
serial: 99978563001
name: my_DTU_name
inverters:
- name: 'balkon'
@ -103,14 +104,14 @@ ahoy:
txpower: 'low' # txpower per inverter (min,low,high,max)
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
- 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_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_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_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:
mqtt_client.disco()
if influx_client:
influx_client.disco()
if volkszaehler_client:
volkszaehler_client.disco()
sys.exit(0)
signal(SIGINT, signal_handler) # Interrupt from keyboard (CTRL + C)
@ -75,7 +81,6 @@ class SunsetHandler:
else:
logging.info('Sunset disabled.')
def checkWaitForSunrise(self):
if not self.suntimes:
return
@ -94,6 +99,23 @@ class SunsetHandler:
time.sleep(time_to_sleep)
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):
"""Main loop"""
inverters = [
@ -101,7 +123,9 @@ def main_loop(ahoy_config):
if not inverter.get('disabled', False)]
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)
try:
@ -112,6 +136,11 @@ def main_loop(ahoy_config):
t_loop_start = time.time()
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:
logging.info(f'Poll inverter name={inverter["name"]} ser={inverter["serial"]}')
poll_inverter(inverter, dtu_ser, do_init, 3)
@ -122,8 +151,6 @@ def main_loop(ahoy_config):
if time_to_sleep > 0:
time.sleep(time_to_sleep)
except KeyboardInterrupt:
sys.exit()
except Exception as e:
logging.fatal('Exception catched: %s' % e)
logging.fatal(traceback.print_exc())
@ -284,11 +311,11 @@ def init_logging(ahoy_config):
lvl = logging.ERROR
elif level == 'FATAL':
lvl = logging.FATAL
if hoymiles.HOYMILES_TRANSACTION_LOGGING and hoymiles.HOYMILES_DEBUG_LOGGING:
if hoymiles.HOYMILES_TRANSACTION_LOGGING:
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)
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__':
parser = argparse.ArgumentParser(description='Ahoy - Hoymiles solar inverter gateway', prog="hoymiles")
@ -330,14 +357,14 @@ if __name__ == '__main__':
# create MQTT - client object
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):
from .outputs import MqttOutputPlugin
mqtt_client = MqttOutputPlugin(mqtt_config)
# create INFLUX - client object
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):
from .outputs import InfluxOutputPlugin
influx_client = InfluxOutputPlugin(

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

@ -155,6 +155,7 @@ class StatusResponse(Response):
s_exists = False
string_id = len(strings)
string = {}
string['name'] = self.inv_strings[string_id]['s_name']
for key in self.string_keys:
prop = f'dc_{key}_{string_id}'
if hasattr(self, prop):
@ -329,7 +330,7 @@ class EventsResponse(UnknownResponse):
self.status = struct.unpack('>H', self.response[:2])[0]
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
for i_chunk in range(2, len(self.response), chunk_size):
@ -489,6 +490,8 @@ class Hm300Decode0B(StatusResponse):
""" String 1 irratiation in percent """
if self.inv_strings is 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)
@property
@ -561,6 +564,8 @@ class Hm600Decode0B(StatusResponse):
""" String 1 irratiation in percent """
if self.inv_strings is 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)
@property
@ -588,6 +593,8 @@ class Hm600Decode0B(StatusResponse):
""" String 2 irratiation in percent """
if self.inv_strings is 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)
@property
@ -668,6 +675,8 @@ class Hm1200Decode0B(StatusResponse):
""" String 1 irratiation in percent """
if self.inv_strings is 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)
@property
@ -695,6 +704,8 @@ class Hm1200Decode0B(StatusResponse):
""" String 2 irratiation in percent """
if self.inv_strings is 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)
@property
@ -722,6 +733,8 @@ class Hm1200Decode0B(StatusResponse):
""" String 3 irratiation in percent """
if self.inv_strings is 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)
@property
@ -749,6 +762,8 @@ class Hm1200Decode0B(StatusResponse):
""" String 4 irratiation in percent """
if self.inv_strings is 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)
@property

61
tools/rpi/hoymiles/outputs.py

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

Loading…
Cancel
Save