You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

509 lines
22 KiB

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Hoymiles micro-inverters main application
"""
import sys
import struct
import traceback
import re
import argparse
import yaml
from yaml.loader import SafeLoader
import logging
from logging.handlers import RotatingFileHandler
import time
from suntimes import SunTimes
from datetime import datetime, timedelta
import hoymiles # import paket on this place, call once: "hoymiles/__init__.py"
################################################################################
# SIGINT = Interrupt from keyboard (CTRL + C)
# SIGTERM = Signal Handler from terminating processes
# SIGHUP = Hangup detected on controlling terminal or death of controlling process
# SIGKILL = Signal Handler SIGKILL and SIGSTOP cannot be caught, blocked, or ignored!!
################################################################################
from signal import signal, Signals, SIGINT, SIGTERM, SIGHUP
from os import environ
def signal_handler(sig_num, frame):
""" Signal Handler
param: signal number [signal-name]
param: frame
"""
signame = Signals(sig_num).name
logging.info(f'Stop by Signal <{signame}> ({sig_num})')
if environ.get('TERM') is not None:
print (f'\nStop by Signal <{signame}> ({sig_num}) '
f'at: {time.strftime("%d.%m.%Y %H:%M:%S")}\n')
if mqtt_client:
mqtt_client.disco()
if influx_client:
influx_client.disco()
if volkszaehler_client:
volkszaehler_client.disco()
sys.exit(0)
""" activate signal handler """
signal(SIGINT, signal_handler)
signal(SIGTERM, signal_handler)
signal(SIGHUP, signal_handler)
# signal(SIGKILL, signal_handler) # not used
################################################################################
################################################################################
class SunsetHandler:
""" Sunset class
to recognize the times of sunrise, sunset and to sleep at night time
:param str inverter: inverter serial
:param retries: tx retry count if no inverter contact
:type retries: int
"""
def __init__(self, sunset_config):
self.suntimes = None
if sunset_config and sunset_config.get('disabled', True) == False:
latitude = sunset_config.get('latitude')
longitude = sunset_config.get('longitude')
altitude = sunset_config.get('altitude')
self.suntimes = SunTimes(longitude=longitude, latitude=latitude, altitude=altitude)
self.nextSunset = self.suntimes.setutc(datetime.utcnow())
logging.info (f'Sunset today at: {self.nextSunset} UTC')
# send info to mqtt, if broker configured
self.sun_status2mqtt()
else:
logging.info('Sunset disabled!')
def checkWaitForSunrise(self):
if not self.suntimes:
return
# if the sunset already happened for today
now = datetime.utcnow()
if self.nextSunset < now:
# wait until the sun rises again. if it's already after midnight, this will be today
nextSunrise = self.suntimes.riseutc(now)
if nextSunrise < now:
tomorrow = now + timedelta(days=1)
nextSunrise = self.suntimes.riseutc(tomorrow)
self.nextSunset = self.suntimes.setutc(nextSunrise)
time_to_sleep = int((nextSunrise - datetime.utcnow()).total_seconds())
logging.info (f'Next sunrise is at {nextSunrise} UTC, next sunset is at {self.nextSunset} UTC, sleeping for {time_to_sleep} seconds.')
if time_to_sleep > 0:
time.sleep(time_to_sleep)
logging.info (f'Woke up...')
def sun_status2mqtt(self):
""" send sunset information every day to MQTT broker """
if not mqtt_client or not self.suntimes:
return
if self.suntimes:
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
mqtt_client.info2mqtt(f'{dtu_name}/{dtu_serial}',
{'dis_night_comm' : 'True',
'local_sunrise' : local_sunrise,
'local_sunset' : local_sunset,
'local_zone' : local_zone})
else:
mqtt_client.info2mqtt(f'{dtu_name}/{dtu_serial}', {'dis_night_comm': 'False'})
def main_loop(ahoy_config):
""" Main loop """
# check 'interval' parameter in config-file
loop_interval = ahoy_config.get('interval', 15)
logging.info(f"AHOY-MAIN: loop interval : {loop_interval} sec.")
if (loop_interval <= 0):
logging.critical("Parameter 'loop_interval' must grater 0 - please check ahoy.yml.")
# print console message too
print("Parameter 'loop_interval' must be >0 - please check ahoy.yml - STOP(0)")
sys.exit(0)
# check 'transmit_retries' parameter in config-file
transmit_retries = ahoy_config.get('transmit_retries', 5)
if (transmit_retries <= 0):
logging.critical("Parameter 'transmit_retries' must grater 0 - please check ahoy.yml.")
# print console message too
print("Parameter 'transmit_retries' must be >0 - please check ahoy.yml - STOP(0)")
sys.exit(0)
# get parameter from config-file
inverters = [inverter for inverter in ahoy_config.get('inverters', [])
if not inverter.get('disabled', False)]
# check all inverter names and serial numbers in config-file
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)
# init Sunset-Handler object
sunset = SunsetHandler(ahoy_config.get('sunset'))
if not hoymiles.HOYMILES_VERBOSE_LOGGING and not hoymiles.HOYMILES_TRANSACTION_LOGGING:
logging.info(f"MAIN LOOP starts now without any output")
try:
do_init = True
while True: # MAIN endless LOOP
# check sunrise and sunset times and sleep in night time
sunset.checkWaitForSunrise()
t_loop_start = time.time()
for inverter in inverters:
poll_inverter(inverter, do_init, transmit_retries)
do_init = False
# calc time to pause main-loop
time_to_sleep = loop_interval - (time.time() - t_loop_start)
if time_to_sleep > 0:
if hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info(f'MAIN-LOOP: sleep for {time_to_sleep} sec.')
time.sleep(time_to_sleep)
except Exception as e:
logging.fatal('Exception catched: %s' % e)
logging.fatal(traceback.print_exc())
raise
def poll_inverter(inverter, do_init, retries):
"""
Send/Receive command_queue, initiate status poll on inverter
:param str inverter: inverter serial
:param retries: tx retry count if no inverter contact
:type retries: int
"""
inverter_ser = inverter.get('serial')
inverter_name = inverter.get('name')
inverter_strings = inverter.get('strings')
inv_str = str(inverter_ser)
# Queue at least status data request
if do_init:
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.InverterDevInform_Simple)) # 00
command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.InverterDevInform_All)) # 01
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.GridOnProFilePara)) # 02
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.HardWareConfig)) # 03
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.SimpleCalibrationPara)) # 04
##command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.SystemConfigPara)) # 05
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.RealTimeRunData_Reality)) # 0c
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.AlarmData)) # 11
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.AlarmUpdate)) # 12
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.RecordData)) # 13
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.InternalData)) # 14
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.GetLossRate)) # 15
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.GetSelfCheckState)) # 1E
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.InitDataState)) # FF
command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.RealTimeRunData_Debug)) # 0b
# Put all queued commands for current inverter on air
while len(command_queue[inv_str]) > 0:
if hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info(f'Poll inverter name={inverter_name} ser={inverter_ser} command={hoymiles.InfoCommands(command_queue[inv_str][0][0]).name}')
payload = command_queue[inv_str].pop(0) ## get first object from command queue
# Send payload {ttl}-times until we get at least one reponse
payload_ttl = retries
response = None
while payload_ttl > 0:
payload_ttl = payload_ttl - 1
com = hoymiles.InverterTransaction(
radio=hmradio,
txpower=inverter.get('txpower', None),
dtu_ser=dtu_serial,
inverter_ser=inverter_ser,
request=next(hoymiles.compose_esb_packet(
payload,
seq=b'\x80',
src=dtu_serial,
dst=inverter_ser
)))
while com.rxtx():
try:
response = com.get_payload()
payload_ttl = 0
except Exception as e_all:
if hoymiles.HOYMILES_TRANSACTION_LOGGING:
logging.error(f'Error while retrieving data: {e_all}')
pass
# Handle response data, if any
if response:
if hoymiles.HOYMILES_TRANSACTION_LOGGING:
logging.debug(f'Payload: {len(response)} bytes: {hoymiles.hexify_payload(response)}')
# get a ResponseDecoder object to decode response-payload
decoder = hoymiles.ResponseDecoder(response,
request=com.request,
inverter_ser=inverter_ser,
inverter_name=inverter_name,
strings=inverter_strings
)
result = decoder.decode() # call decoder object
data = result.__dict__() # convert result into python-dict
if hoymiles.HOYMILES_TRANSACTION_LOGGING:
logging.debug(f'Decoded: {data}')
# check result object for output
if isinstance(result, hoymiles.decoders.StatusResponse):
if hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info(f"StatusResponse: payload contains {len(data)} elements "
f"(power={data['phases'][0]['power']} W - event_count={data['event_count']})")
# when 'event_count' is changed, add AlarmData-command to queue
if data is not None and 'event_count' in data:
# if event_message_index[inv_str] < data['event_count']:
if event_message_index[inv_str] != data['event_count']:
event_message_index[inv_str] = data['event_count']
if hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info(f"event_count changed to {data['event_count']} --> AlarmData requested")
# add AlarmData-command to queue
command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.AlarmData, alarm_id=event_message_index[inv_str]))
# sent outputs
if mqtt_client:
mqtt_client.store_status(data)
if influx_client:
influx_client.store_status(data)
if volkszaehler_client:
volkszaehler_client.store_status(data)
# check decoder object for different data types
if isinstance(result, hoymiles.decoders.HardwareInfoResponse):
if hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info(f"Firmware version {data['FW_ver_maj']}.{data['FW_ver_min']}.{data['FW_ver_pat']}, "
f"build at {data['FW_build_dd']:>02}/{data['FW_build_mm']:>02}/{data['FW_build_yy']}T"
f"{data['FW_build_HH']:>02}:{data['FW_build_MM']:>02}, "
f"HW revision {data['FW_HW_ID']}")
if mqtt_client:
mqtt_client.store_status(data)
if isinstance(result, hoymiles.decoders.EventsResponse):
if hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info(f"EventsResponse: {data['inv_stat_txt']} ({data['inv_stat_num']})")
if isinstance(result, hoymiles.decoders.DebugDecodeAny):
if hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info(f"DebugDecodeAny: payload ({data['len_payload']} bytes): {data['payload']}")
def mqtt_on_message(mqtt_client, userdata, message):
'''
MQTT(PAHO) callcack method to handle receiving payload
( run in thread: "paho-mqtt-client-" - important for signals and Exceptions !)
a) when receiving topic ends with "SENSOR" for privat electricity meter
b) when receiving topic ends with "command" for runtime faster debugging
Handle commands to topic
hoymiles/{inverter_ser}/command
frame a payload and put onto command_queue
Inverters must have mqtt.send_raw_enabled: true configured
This can be used to inject debug payloads
The message must be in hexlified format
Use of variables:
tttttttt gets expanded to a current int(time)
Example injects exactly the same as we normally use to poll data:
mosquitto -h broker -t inverter_topic/command -m 800b00tttttttt0000000500000000
This allows for even faster hacking during runtime
:param paho.mqtt.client.Client client: mqtt-client instance
:param dict userdata: Userdata
:param dict message: mqtt-client message object
'''
# print(f"msg-topic: {message.topic} - QoS: {message.qos}")
# print(f"payload: ",str(message.payload.decode("utf-8")), "\n")
# handle specific payload topic
if message.topic.endswith("SENSOR"):
if volkszaehler_client:
volkszaehler_client.store_status(yaml.safe_load(str(message.payload.decode("utf-8"))))
if message.topic.endswith("command"):
p_message = message.payload.decode('utf-8').lower()
# Expand tttttttt to current time for use in hexlified payload
expand_time = ''.join(f'{b:02x}' for b in struct.pack('>L', int(time.time())))
p_message = p_message.replace('tttttttt', expand_time)
logging.info (f"MQTT-command: {message.topic} - {p_message}")
if (len(p_message) < 2048 and len(p_message) % 2 == 0 and re.match(r'^[a-f0-9]+$', p_message)):
payload = bytes.fromhex(p_message)
if hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info (f"MQTT-command for {inv_str}: {payload}")
# commands must start with \x80
if payload[0] == 0x80:
# array "command_queue[inv_str]" will be shared to an other thread --> critical section
command_queue[inv_str].append(hoymiles.frame_payload(payload[1:]))
else:
logging.info (f"MQTT-command: must start with \x80: {payload}")
else:
logging.info (f"MQTT-command to long (max length: 2048 bytes) - or contains non hex char")
def init_logging(ahoy_config):
""" init and prepare logging """
log_config = ahoy_config.get('logging')
fn = 'hoymiles.log'
lvl = logging.ERROR
max_log_filesize = 1000000
max_log_files = 1
if log_config:
fn = log_config.get('filename', fn)
level = log_config.get('level', 'ERROR')
if level == 'DEBUG':
lvl = logging.DEBUG
elif level == 'INFO':
lvl = logging.INFO
elif level == 'WARNING':
lvl = logging.WARNING
elif level == 'ERROR':
lvl = logging.ERROR
elif level == 'FATAL':
lvl = logging.FATAL
max_log_filesize = log_config.get('max_log_filesize', max_log_filesize)
max_log_files = log_config.get('max_log_files', max_log_files)
# define log switches
if global_config.log_transactions:
hoymiles.HOYMILES_TRANSACTION_LOGGING = True
lvl = logging.DEBUG
if global_config.verbose:
hoymiles.HOYMILES_VERBOSE_LOGGING = True
# start configured logging
logging.basicConfig(handlers=[RotatingFileHandler(fn, maxBytes=max_log_filesize, backupCount=max_log_files)],
format='%(asctime)s %(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S.%s', level=lvl)
logging.info(f'AHOY-logging started for "{dtu_name}" with level: {logging.getLevelName(logging.root.level)}')
if __name__ == '__main__':
# read commandline parameter
parser = argparse.ArgumentParser(description='Ahoy - Hoymiles solar inverter gateway', prog="hoymiles")
parser.add_argument("-c", "--config-file", nargs="?", required=True,
help="configuration file")
parser.add_argument("--log-transactions", action="store_true", default=False,
help="Enable transaction logging output (loglevel must be DEBUG)")
parser.add_argument("--verbose", action="store_true", default=False,
help="Enable detailed debug output (loglevel must be DEBUG)")
global_config = parser.parse_args()
# Load config file given in commandline parameter
try:
if isinstance(global_config.config_file, str):
with open(global_config.config_file, 'r') as fh_yaml:
cfg = yaml.load(fh_yaml, Loader=SafeLoader)
else:
with open('ahoy.yml', 'r') as fh_yaml:
cfg = yaml.load(fh_yaml, Loader=SafeLoader)
except FileNotFoundError:
logging.error("Could not load config file. Try --help")
sys.exit(2)
except yaml.YAMLError as e_yaml:
logging.error(f'Failed to load config file {global_config.config_file}: {e_yaml}')
sys.exit(1)
# read all parameter from configuration file as 'ahoy_config'
ahoy_config = dict(cfg.get('ahoy', {}))
# extract 'DTU' parameter
dtu_serial = ahoy_config.get('dtu', {}).get('serial', None)
dtu_name = ahoy_config.get('dtu', {}).get('name', 'hoymiles-dtu')
# init and prepare logging
init_logging(ahoy_config)
# Prepare for multiple transceivers (radio modules), makes them configurable
for radio_config in ahoy_config.get('nrf', [{}]):
hmradio = hoymiles.HoymilesNRF(**radio_config)
# create MQTT client object
# if: mqtt-disabled is "true" - only
# if: mqtt-disabled is "true" AND inverter-mqtt-send_raw_enabled is "true"
# if: mqtt topic is defined - only or with other functions
mqtt_c_obj = mqtt_client = None # create client-obj-placeholder
mqtt_config = ahoy_config.get('mqtt', None) # get mqtt-config, if available
if mqtt_config and (not mqtt_config.get('disabled', False) or mqtt_topic):
from .outputs import MqttOutputPlugin
# MQTT_TOPIC array should contain QOS levels as well as topic names.
# MQTT_TOPIC = [("Server1/kpi1",0),("Server2/kpi2",0),("Server3/kpi3",0)]
mqtt_topic = mqtt_config.get('topic', None) # get topic, if available
mqtt_topic_array = [] # create empty array
if mqtt_topic:
mqtt_topic_array.append((mqtt_topic, mqtt_config.get('QoS',0)))
# create MQTT(PAHO) client object with own callback funtion
mqtt_c_obj = MqttOutputPlugin(mqtt_config, mqtt_on_message)
if mqtt_c_obj and not mqtt_config.get('disabled', False):
mqtt_client = mqtt_c_obj
# create INFLUX client object
influx_client = None
influx_config = ahoy_config.get('influxdb', None)
if influx_config and not influx_config.get('disabled', False):
from .outputs import InfluxOutputPlugin
influx_client = InfluxOutputPlugin(
influx_config.get('url'),
influx_config.get('token'),
org=influx_config.get('org', ''),
bucket=influx_config.get('bucket', None),
measurement=influx_config.get('measurement', 'hoymiles'))
# create VOLKSZAEHLER client object
volkszaehler_client = None
volkszaehler_config = ahoy_config.get('volkszaehler', {})
if volkszaehler_config and not volkszaehler_config.get('disabled', False):
from .outputs import VolkszaehlerOutputPlugin
volkszaehler_client = VolkszaehlerOutputPlugin(volkszaehler_config)
# init important runtime variables
event_message_index = {}
command_queue = {}
mqtt_command_topic_subs = []
for g_inverter in ahoy_config.get('inverters', []): # loop inverters in ahoy_config
inv_str = str(g_inverter.get('serial')) # inverter serial number as index
command_queue[inv_str] = [] # create empty command-queue
event_message_index[inv_str] = 0 # init event-queue with value=0
# if send_raw_enabled, add topic to subscribe command-queue
if mqtt_c_obj and g_inverter.get('mqtt', {}).get('send_raw_enabled', False):
mqtt_topic_array.append(
(g_inverter.get('mqtt', {}).get('topic', f'hoymiles/{inv_str}') + '/command',
mqtt_config.get('QoS',0)
))
# start subscribe mqtt broker, if requested 'topic' is available
if mqtt_c_obj and len(mqtt_topic_array) > 0:
if hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info(f'MQTT: subscribe for topic: {mqtt_topic_array}')
mqtt_c_obj.client.subscribe(mqtt_topic_array)
# start main-loop
main_loop(ahoy_config)