mirror of https://github.com/lumapu/ahoy.git
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.
405 lines
16 KiB
405 lines
16 KiB
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
"""
|
|
Hoymiles micro-inverters main application
|
|
"""
|
|
|
|
import sys
|
|
import struct
|
|
from enum import IntEnum
|
|
import re
|
|
import time
|
|
import traceback
|
|
from datetime import datetime
|
|
from datetime import timedelta
|
|
from suntimes import SunTimes
|
|
import argparse
|
|
import yaml
|
|
from yaml.loader import SafeLoader
|
|
import hoymiles
|
|
import logging
|
|
|
|
################################################################################
|
|
""" Signal Handler """
|
|
################################################################################
|
|
# from signal import signal, Signals, SIGINT, SIGTERM, SIGKILL, SIGHUP
|
|
from signal import *
|
|
def signal_handler(sig_num, frame):
|
|
signame = Signals(sig_num).name
|
|
logging.info(f'Stop by Signal {signame} ({sig_num})')
|
|
print (f'Stop by Signal <{signame}> ({sig_num}) at: {time.strftime("%d.%m.%Y %H:%M:%S")}')
|
|
|
|
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)
|
|
signal(SIGTERM, signal_handler) # Signal Handler from terminating processes
|
|
signal(SIGHUP, signal_handler) # Hangup detected on controlling terminal or death of controlling process
|
|
# signal(SIGKILL, signal_handler) # Signal Handler SIGKILL and SIGSTOP cannot be caught, blocked, or ignored!!
|
|
################################################################################
|
|
################################################################################
|
|
|
|
class InfoCommands(IntEnum):
|
|
InverterDevInform_Simple = 0 # 0x00
|
|
InverterDevInform_All = 1 # 0x01
|
|
GridOnProFilePara = 2 # 0x02
|
|
HardWareConfig = 3 # 0x03
|
|
SimpleCalibrationPara = 4 # 0x04
|
|
SystemConfigPara = 5 # 0x05
|
|
RealTimeRunData_Debug = 11 # 0x0b
|
|
RealTimeRunData_Reality = 12 # 0x0c
|
|
RealTimeRunData_A_Phase = 13 # 0x0d
|
|
RealTimeRunData_B_Phase = 14 # 0x0e
|
|
RealTimeRunData_C_Phase = 15 # 0x0f
|
|
AlarmData = 17 # 0x11, Alarm data - all unsent alarms
|
|
AlarmUpdate = 18 # 0x12, Alarm data - all pending alarms
|
|
RecordData = 19 # 0x13
|
|
InternalData = 20 # 0x14
|
|
GetLossRate = 21 # 0x15
|
|
GetSelfCheckState = 30 # 0x1e
|
|
InitDataState = 0xff
|
|
|
|
class SunsetHandler:
|
|
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'Todays sunset is at {self.nextSunset} UTC')
|
|
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, 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 = [
|
|
inverter for inverter in ahoy_config.get('inverters', [])
|
|
if not inverter.get('disabled', False)]
|
|
|
|
sunset = SunsetHandler(ahoy_config.get('sunset'))
|
|
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:
|
|
do_init = True
|
|
while True:
|
|
sunset.checkWaitForSunrise()
|
|
|
|
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)
|
|
do_init = False
|
|
|
|
if loop_interval > 0:
|
|
time_to_sleep = loop_interval - (time.time() - t_loop_start)
|
|
if time_to_sleep > 0:
|
|
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, dtu_ser, 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')
|
|
|
|
# Queue at least status data request
|
|
inv_str = str(inverter_ser)
|
|
if do_init:
|
|
command_queue[inv_str].append(hoymiles.compose_send_time_payload(InfoCommands.InverterDevInform_All))
|
|
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(InfoCommands.SystemConfigPara))
|
|
command_queue[inv_str].append(hoymiles.compose_send_time_payload(InfoCommands.RealTimeRunData_Debug))
|
|
|
|
# Put all queued commands for current inverter on air
|
|
while len(command_queue[inv_str]) > 0:
|
|
payload = command_queue[inv_str].pop(0)
|
|
|
|
# Send payload {ttl}-times until we get at least one reponse
|
|
payload_ttl = retries
|
|
while payload_ttl > 0:
|
|
payload_ttl = payload_ttl - 1
|
|
com = hoymiles.InverterTransaction(
|
|
radio=hmradio,
|
|
txpower=inverter.get('txpower', None),
|
|
dtu_ser=dtu_ser,
|
|
inverter_ser=inverter_ser,
|
|
request=next(hoymiles.compose_esb_packet(
|
|
payload,
|
|
seq=b'\x80',
|
|
src=dtu_ser,
|
|
dst=inverter_ser
|
|
)))
|
|
response = None
|
|
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 the response data if any
|
|
if response:
|
|
if hoymiles.HOYMILES_TRANSACTION_LOGGING:
|
|
c_datetime = datetime.now()
|
|
logging.debug(f'{c_datetime} Payload: ' + hoymiles.hexify_payload(response))
|
|
|
|
# prepare decoder object
|
|
decoder = hoymiles.ResponseDecoder(response,
|
|
request=com.request,
|
|
inverter_ser=inverter_ser,
|
|
inverter_name=inverter_name,
|
|
dtu_ser=dtu_ser,
|
|
strings=inverter_strings
|
|
)
|
|
|
|
# get decoder object
|
|
result = decoder.decode()
|
|
if hoymiles.HOYMILES_DEBUG_LOGGING:
|
|
logging.info(f'Decoded: {result.__dict__()}')
|
|
|
|
# check decoder object for output
|
|
if isinstance(result, hoymiles.decoders.StatusResponse):
|
|
|
|
data = result.__dict__()
|
|
if 'event_count' in data:
|
|
if event_message_index[inv_str] < data['event_count']:
|
|
event_message_index[inv_str] = data['event_count']
|
|
command_queue[inv_str].append(hoymiles.compose_send_time_payload(InfoCommands.AlarmData, alarm_id=event_message_index[inv_str]))
|
|
|
|
if mqtt_client:
|
|
mqtt_client.store_status(result, topic=inverter.get('mqtt', {}).get('topic', None))
|
|
|
|
if influx_client:
|
|
influx_client.store_status(result)
|
|
|
|
if volkszaehler_client:
|
|
volkszaehler_client.store_status(result)
|
|
|
|
# check decoder object for output
|
|
if isinstance(result, hoymiles.decoders.HardwareInfoResponse):
|
|
if mqtt_client:
|
|
mqtt_client.store_status(result, topic=inverter.get('mqtt', {}).get('topic', None))
|
|
|
|
|
|
def mqtt_on_command(client, userdata, message):
|
|
"""
|
|
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
|
|
"""
|
|
try:
|
|
inverter_ser = next(
|
|
item[0] for item in mqtt_command_topic_subs if item[1] == message.topic)
|
|
except StopIteration:
|
|
logging.warning('Unexpedtedly received mqtt message for {message.topic}')
|
|
|
|
if inverter_ser:
|
|
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)
|
|
|
|
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)
|
|
# commands must start with \x80
|
|
if payload[0] == 0x80:
|
|
command_queue[str(inverter_ser)].append(
|
|
hoymiles.frame_payload(payload[1:]))
|
|
|
|
def init_logging(ahoy_config):
|
|
log_config = ahoy_config.get('logging')
|
|
fn = 'hoymiles.log'
|
|
lvl = logging.ERROR
|
|
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
|
|
if hoymiles.HOYMILES_TRANSACTION_LOGGING:
|
|
lvl = logging.DEBUG
|
|
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")
|
|
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 ahoy.yml config file
|
|
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)
|
|
|
|
if global_config.log_transactions:
|
|
hoymiles.HOYMILES_TRANSACTION_LOGGING=True
|
|
if global_config.verbose:
|
|
hoymiles.HOYMILES_DEBUG_LOGGING=True
|
|
|
|
# read AHOY configuration file and prepare logging
|
|
ahoy_config = dict(cfg.get('ahoy', {}))
|
|
init_logging(ahoy_config)
|
|
|
|
# Prepare for multiple transceivers, makes them configurable
|
|
for radio_config in ahoy_config.get('nrf', [{}]):
|
|
hmradio = hoymiles.HoymilesNRF(**radio_config)
|
|
|
|
# create MQTT - client object
|
|
mqtt_client = None
|
|
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', 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)
|
|
|
|
event_message_index = {}
|
|
command_queue = {}
|
|
mqtt_command_topic_subs = []
|
|
|
|
for g_inverter in ahoy_config.get('inverters', []):
|
|
g_inverter_ser = g_inverter.get('serial')
|
|
inv_str = str(g_inverter_ser)
|
|
command_queue[inv_str] = []
|
|
event_message_index[inv_str] = 0
|
|
|
|
# Enables and subscribe inverter to mqtt /command-Topic
|
|
if mqtt_client and g_inverter.get('mqtt', {}).get('send_raw_enabled', False):
|
|
topic_item = (
|
|
str(g_inverter_ser),
|
|
g_inverter.get('mqtt', {}).get('topic', f'hoymiles/{g_inverter_ser}') + '/command'
|
|
)
|
|
mqtt_client.subscribe(topic_item[1])
|
|
mqtt_command_topic_subs.append(topic_item)
|
|
|
|
# start main-loop
|
|
main_loop(ahoy_config)
|
|
|
|
|