mirror of
https://github.com/lumapu/ahoy.git
synced 2025-06-03 19:21:38 +02:00
to send all meassured and calculated values to Volkszaehler and to mqtt change some logging levels for better differentiation change frequency as part of AC phase
350 lines
13 KiB
Python
350 lines
13 KiB
Python
#!/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 paho.mqtt.client
|
|
import hoymiles
|
|
import logging
|
|
|
|
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 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')
|
|
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 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 KeyboardInterrupt:
|
|
sys.exit()
|
|
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:
|
|
logging.error(f'Error while retrieving data: {e_all}')
|
|
pass
|
|
|
|
# Handle the response data if any
|
|
if response:
|
|
c_datetime = datetime.now()
|
|
if hoymiles.HOYMILES_DEBUG_LOGGING:
|
|
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'{c_datetime} 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
|
|
logging.basicConfig(filename=fn, format='%(asctime)s %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S', level=lvl)
|
|
|
|
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)
|
|
|
|
# read AHOY configuration file and prepare logging
|
|
ahoy_config = dict(cfg.get('ahoy', {}))
|
|
init_logging(ahoy_config)
|
|
|
|
if global_config.log_transactions:
|
|
hoymiles.HOYMILES_TRANSACTION_LOGGING=True
|
|
if global_config.verbose:
|
|
hoymiles.HOYMILES_DEBUG_LOGGING=True
|
|
|
|
# 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', {})
|
|
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', {})
|
|
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)
|
|
|