Browse Source

Merge 916a63e3d4 into 5feb293c9f

pull/1392/merge
Knuti_in_Päse 1 month ago
committed by GitHub
parent
commit
1ccc6ee642
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 91
      tools/rpi/README.md
  2. 37
      tools/rpi/ahoy@bookworm.service
  3. 46
      tools/rpi/ahoy@bookworm_system.service
  4. 97
      tools/rpi/hoymiles/__init__.py
  5. 419
      tools/rpi/hoymiles/__main__.py
  6. 144
      tools/rpi/hoymiles/decoders/__init__.py
  7. 263
      tools/rpi/hoymiles/outputs.py

91
tools/rpi/README.md

@ -89,17 +89,16 @@ python3 getting_started.py # to test and see whether RF24 class can be loaded as
If there are no error messages on the last step, then the NRF24 Wrapper has been installed successfully. If there are no error messages on the last step, then the NRF24 Wrapper has been installed successfully.
Building RF24 Wrapper for Debian 11 (bullseye) 64 bit operating system Building RF24 Wrapper on Debian 11 (bullseye) 64 bit operating system
---------------------------------------------------------------------- ----------------------------------------------------------------------
The description above does not work on Debian 11 (bullseye) 64 bit operating system. The description above does not work on Debian 11 (bullseye) 32 bit operating system.
Please check first, if you have Debian 11 (bullseye) 64 bit operating system installed: Please check first, if you have Debian 11 (bullseye) 64 bit operating system installed:
- `uname -a` search for aarch64 - `uname -a` search for aarch64
- `lsb_release -d` - `lsb_release -d`
- `cat /etc/debian_version` - `cat /etc/debian_version`
There are 2 possible solutions to install the RF24 wrapper: To install RF24 wrapper follow the instrauction:
**__1. Solution:__**
```code ```code
sudo apt install cmake git python3-dev libboost-python-dev python3-pip python3-rpi.gpio sudo apt install cmake git python3-dev libboost-python-dev python3-pip python3-rpi.gpio
@ -139,29 +138,60 @@ python3 -m pip list #watch for RF24 module - if its there its installed
``` ```
**__2. Solution:__** Alternative: Install pyRF24 library on Debian 11 (bullseye) 64 bit operating system
-----------------------------------------------------------------------------------
The description above does not work on Debian 11 (bullseye) 32 bit operating system.
Please check first, if you have Debian 11 (bullseye) 64 bit operating system installed:
- `uname -a` search for aarch64
- `lsb_release -d`
- `cat /etc/debian_version`
```code ```code
sudo apt install git python3-dev libboost-python-dev python3-pip python3-rpi.gpio sudo apt install cmake git python3-dev libboost-python-dev python3-pip python3-rpi.gpio
git clone --recurse-submodules https://github.com/nRF24/pyRF24.git git clone --recurse-submodules https://github.com/nRF24/pyRF24.git
cd pyRF24 cd pyRF24
python3 -m pip install . -v # this step takes about 5 minutes on my RPI-4 ! python3 -m pip install . -v # this step takes about 5 minutes on my RPI-4 !
cd
``` ```
If you have problems with your radio module from ahoi, e.g.: cannot interpret received data, Install pyRF24 library on Debian 12 (bookworm) 64 bit operating system
please try to reduce the speed of your radio module! -----------------------------------------------------------------------------------
Add the following parameter to your ahoy.yml configuration file in "nrf" section: The description above does not work on Debian 11 (bullseye) 32 bit operating system.
`spispeed: 600000` (0.6 MHz) Please check first, if you have Debian 11 (bullseye) 64 bit operating system installed:
- `uname -a` search for aarch64
- `lsb_release -d`
- `cat /etc/debian_version`
Important: Debian 12 follows the recommendation of [`PEP 668`]
(https://peps.python.org/pep-0668/) - now, PYTHON is configured as
"externally-managed-environment" !
- You cann't install python libs via `pip`!
- You have to use a python virtual environment `https://docs.python.org/3/library/venv.html`
```code
sudo apt install cmake git python3-dev libboost-python-dev python3-pip python3-rpi.gpio
cd ~
python3 -m venv ahoyenv ## create python virtual environment
source ahoyenv/bin/activate ## activate the virtual environment
git clone --recurse-submodules https://github.com/nRF24/pyRF24.git
cd pyRF24
python3 -m pip install . -v
python3 -m pip list ## check: search for pyRF24
cd ~
```
Required python modules Required python modules
----------------------- -----------------------
Some modules are not installed by default on a RaspberryPi, therefore add them manually: Some modules are not installed by default on a RaspberryPi, therefore add them manually:
```code ```code
pip install crcmod pyyaml paho-mqtt SunTimes python3 -m pip install crcmod pyyaml paho-mqtt SunTimes
``` ```
Configuration Configuration
@ -170,6 +200,12 @@ Configuration
Local settings are read from ahoy.yml Local settings are read from ahoy.yml
An example is provided as ahoy.yml.example An example is provided as ahoy.yml.example
If you have any problems with your radio module,
e.g.: cannot interpret received data,
please try to reduce the speed of your radio module!
Add the following parameter to your `ahoy.yml` configuration file in section `nrf`:
`spispeed: 600000` (0.6 MHz)
Example Run Example Run
----------- -----------
@ -178,13 +214,19 @@ The following command will run the communication tool, which will try to
contact the inverter every second on channel 40, and listen for replies. contact the inverter every second on channel 40, and listen for replies.
Whenever it sees a reply, it will decoded and logged to the given log file. Whenever it sees a reply, it will decoded and logged to the given log file.
```code
~~$ sudo python3 -um hoymiles --log-transactions --verbose --config /home/dtu/ahoy.yml | tee -a log2.log~~
## when using PYTHON virtual environment only - see hint `PEP 668`
$ source /home/pi/ahoyenv/bin/activate
$ sudo python3 -um hoymiles --log-transactions --verbose --config /home/dtu/ahoy.yml | tee -a log2.log $ tail -f RPI-AHOY-DTU.log &
$ python3 -um hoymiles --log-transactions --verbose --config /home/dtu/ahoy.yml
```
Python parameters Python parameters
- `-u` enables python's unbuffered mode - `-u` enables python's unbuffered mode
- `-m hoymiles` tells python to load module 'hoymiles' as main app - `-m hoymiles` tells python to load module 'hoymiles' as main app
Do not forget to stop `tail -f ...` with `fg`(forground) and than `ctrl-c`
The application describes itself The application describes itself
```code ```code
@ -228,12 +270,20 @@ Example injects exactly the same as we normally use to poll data
This allows for even faster hacking during runtime This allows for even faster hacking during runtime
Running it as a service
Run as a service
----------------------- -----------------------
If you want to run directly from the start, you might want to install it as a service. If you want to run directly at start, you have to install ahoy as a service.
Depending on if you want to run it once a user is logged in or as soon as the system is booted, two service examples are included. Depending oni, if you want to run it once a user is logged in or as soon as the system is booted,
ahoy.service allows you to start it as a user service upon login. two service examples are included.
ahoy_system.service allows you to start it as a system service already before login without user interaction. - `ahoy.service` allows you to start it as a user service upon login.
- `ahoy_system.service` allows you to start it as a system service already before login without user interaction.
Run as a service on Debian 12 (bookworm)
----------------------------------------
- `ahoy@bookworm.service` allows you to start it as a user service upon login.
- `ahoy@bookworm_system.service` allows you to start it as a system service already before login without user interaction.
Analysing the Logs Analysing the Logs
------------------ ------------------
@ -252,12 +302,10 @@ Use basic command line tools to get an idea what you recorded. For example:
A brief example log is supplied in the `example-logs` folder. A brief example log is supplied in the `example-logs` folder.
Todo Todo
---- ----
- Ability to talk to multiple inverters - Ability to talk to multiple inverters - implemented - please test
- MQTT gateway - MQTT gateway
- understand channel hopping - understand channel hopping
- ~~configurable polling interval~~ done: interval ist configurable in ahoy.yml - ~~configurable polling interval~~ done: interval ist configurable in ahoy.yml
@ -267,7 +315,6 @@ Todo
- ... - ...
References References
---------- ----------

37
tools/rpi/ahoy@bookworm.service

@ -0,0 +1,37 @@
######################################################################
# systemd.service configuration for ahoy (lumapu)
# users can modify the lines:
# Description
# ExecStart (example: name of config file)
# WorkingDirectory (absolute path to your private ahoy dir)
# To change other config parameter, please consult systemd documentation
#
# To activate this service, enable and start ahoy.service
# $ systemctl --user enable /home/pi/ahoy/tools/rpi/ahoy@bookworm.service
# $ systemctl --user status ahoy@bookworm.service
# $ systemctl --user start ahoy@bookworm.service
# $ systemctl --user stop ahoy@bookworm.service
# $ systemctl --user disable ahoy@bookworm.service
#
# 2023.01 <PaeserBastelstube>
# 2024.01 <PaeserBastelstube>
######################################################################
[Unit]
Description=ahoy (lumapu) as Service
[Service]
ExecStart=/bin/bash -c '\
source /home/pi/ahoyenv/bin/activate; \
python3 -um hoymiles --log-transactions --verbose --config ahoy.yml'
RestartSec=30
Restart=on-failure
Type=simple
# WorkingDirectory must be an absolute path - not relative path
WorkingDirectory=/home/pi/ahoy/tools/rpi
EnvironmentFile=/etc/environment
[Install]
WantedBy=default.target

46
tools/rpi/ahoy@bookworm_system.service

@ -0,0 +1,46 @@
######################################################################
# systemd.service configuration for ahoy (lumapu)
# users can modify the lines:
# Description
# ExecStart (example: name of config file)
# WorkingDirectory (absolute path to your private ahoy dir)
# To change other config parameter, please consult systemd documentation
#
# To activate this service, enable and start ahoy.service:
# - Create folder ahoy in /home/ and set owner to the user that the
# service should be executed for (e.g. pi)
# - Copy folder contents to new folder
# - Adjust the user that this service should be executed as, avoid root
# - Execute commands to setup, check and start/stop as wanted
# $ sudo systemctl enable /home/ahoy/tools/rpi/ahoy@bookworm_system.service
# $ sudo systemctl status ahoy@bookworm_system
# $ sudo systemctl start ahoy@bookworm_system
# $ sudo systemctl stop ahoy@bookworm_system
# $ sudo systemctl disable ahoy@bookworm_system
#
# 2023.01 <PaeserBastelstube>
# 2023.03 <DM6JM>
# 2024.01 <PaeserBastelstube>
######################################################################
[Unit]
Description=ahoy (lumapu) as Service
After=network.target local-fs.target time-sync.target
[Service]
ExecStart=/bin/bash -c '\
source /home/pi/ahoyenv/bin/activate; \
python3 -um hoymiles --log-transactions --verbose --config ahoy.yml'
RestartSec=30
Restart=on-failure
Type=simple
User=pi
# WorkingDirectory must be an absolute path - not relative path
WorkingDirectory=/home/ahoy/tools/rpi
EnvironmentFile=/etc/environment
[Install]
WantedBy=default.target

97
tools/rpi/hoymiles/__init__.py

@ -13,33 +13,36 @@ import logging
import crcmod import crcmod
from .decoders import * from .decoders import *
from os import environ from os import environ
from enum import IntEnum
try: try:
# OSI Layer 2 driver for nRF24L01 on Arduino & Raspberry Pi/Linux Devices # OSI Layer 2 driver for nRF24L01 on Arduino & Raspberry Pi/Linux Devices
# https://github.com/nRF24/RF24.git # https://github.com/nRF24/RF24.git
from RF24 import RF24, RF24_PA_MIN, RF24_PA_LOW, RF24_PA_HIGH, RF24_PA_MAX, RF24_250KBPS, RF24_CRC_DISABLED, RF24_CRC_8, RF24_CRC_16 from RF24 import RF24, RF24_PA_MIN, RF24_PA_LOW, RF24_PA_HIGH, RF24_PA_MAX, RF24_250KBPS, RF24_CRC_DISABLED, RF24_CRC_8, RF24_CRC_16
if environ.get('TERM') is not None: if environ.get('TERM') is not None:
print('Using python Module: RF24') print('Using python Module: "RF24"')
except ModuleNotFoundError as e: except ModuleNotFoundError as e:
if environ.get('TERM') is not None: if environ.get('TERM') is not None:
print(f'{e} - try to use module: RF24') print(f"{e} - module not found, try to use 'pyRF24'")
try: try:
# Repo for pyRF24 package # Repo for pyRF24 package
# https://github.com/nRF24/pyRF24.git # https://github.com/nRF24/pyRF24.git
from pyrf24 import RF24, RF24_PA_MIN, RF24_PA_LOW, RF24_PA_HIGH, RF24_PA_MAX, RF24_250KBPS, RF24_CRC_DISABLED, RF24_CRC_8, RF24_CRC_16 from pyrf24 import RF24, RF24_PA_MIN, RF24_PA_LOW, RF24_PA_HIGH, RF24_PA_MAX, RF24_250KBPS, RF24_CRC_DISABLED, RF24_CRC_8, RF24_CRC_16
if environ.get('TERM') is not None: if environ.get('TERM') is not None:
print(f'{e} - Using python Module: pyrf24') print(f"'pyrf24' found and used")
except ModuleNotFoundError as e: except ModuleNotFoundError as e:
if environ.get('TERM') is not None: if environ.get('TERM') is not None:
print(f'{e} - exit') print(f'{e} - exit')
exit() exit()
if environ.get('TERM') is not None:
print("run before starting AHOY: tail -f RPI-AHOY-DTU.log &")
HOYMILES_TRANSACTION_LOGGING = False
HOYMILES_VERBOSE_LOGGING = False
f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus') f_crc_m = crcmod.predefined.mkPredefinedCrcFun('modbus')
f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0) f_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0)
HOYMILES_TRANSACTION_LOGGING=False
HOYMILES_DEBUG_LOGGING=False
def ser_to_hm_addr(inverter_ser): def ser_to_hm_addr(inverter_ser):
""" """
Calculate the 4 bytes that the HM devices use in their internal messages to Calculate the 4 bytes that the HM devices use in their internal messages to
@ -71,6 +74,27 @@ def ser_to_esb_addr(inverter_ser):
air_order = ser_to_hm_addr(inverter_ser)[::-1] + b'\x01' air_order = ser_to_hm_addr(inverter_ser)[::-1] + b'\x01'
return air_order[::-1] return air_order[::-1]
class InfoCommands(IntEnum):
''' compare to .../ahoy/src/hm/hmDefines.h '''
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 ResponseDecoderFactory: class ResponseDecoderFactory:
""" """
Prepare payload decoder Prepare payload decoder
@ -175,50 +199,54 @@ class ResponseDecoder(ResponseDecoderFactory):
:return: payload decoder instance :return: payload decoder instance
:rtype: object :rtype: object
""" """
model = self.inverter_model model = self.inverter_model
command = self.request_command command = self.request_command
model_desc = str(InfoCommands(int(command, 16)).name)
if HOYMILES_DEBUG_LOGGING: if HOYMILES_VERBOSE_LOGGING:
if command.upper() == '00': if command.upper() == "00": ## 00 - 0x00
model_desc = "Inverter Dev Inform Simple" model_desc = "Inverter Dev Inform Simple"
elif command.upper() == '01': elif command.upper() == "01": ## 01 - 0x01
model_desc = "Firmware version / date" model_desc = "Firmware version / date"
elif command.upper() == '02': elif command.upper() == "02": ## 02 - 0x02
model_desc = "Inverter generic events log" model_desc = "Inverter generic events log"
elif command.upper() == '03': ## HardWareConfig elif command.upper() == "03": ## 03 - 0x03
model_desc = "Hardware configuration" model_desc = "Hardware configuration"
elif command.upper() == '04': ## SimpleCalibrationPara elif command.upper() == "04": ## 04 - 0x04
model_desc = "Simple Calibration Parameter" model_desc = "Simple Calibration Parameter"
elif command.upper() == '05': ## SystemConfigPara elif command.upper() == "05": ## 05 - 0x05
model_desc = "Inverter generic SystemConfigPara" # model_desc = "Inverter generic SystemConfigPara"
elif command.upper() == '0B': ## 11 - RealTimeRunData_Debug model_desc = "SystemConfigPara create DebugDecodeAny"
elif command.upper() == "0B": ## 11 - 0x0b
model_desc = "mirco-inverters status data" model_desc = "mirco-inverters status data"
elif command.upper() == '0C': ## 12 - RealTimeRunData_Reality elif command.upper() == "0C": ## 12 - 0x0c
model_desc = "mirco-inverters status data" model_desc = "mirco-inverters status data"
elif command.upper() == '0D': ## 13 - RealTimeRunData_A_Phase elif command.upper() == "0D": ## 13 - 0x0d
model_desc = "Real-Time Run Data A Phase " model_desc = "Real-Time Run Data A Phase "
elif command.upper() == '0E': ## 14 - RealTimeRunData_B_Phase elif command.upper() == "0E": ## 14 - 0x0e
model_desc = "Real-Time Run Data B Phase " model_desc = "Real-Time Run Data B Phase "
elif command.upper() == '0F': ## 15 - RealTimeRunData_C_Phase elif command.upper() == "0F": ## 15 - 0x0f
model_desc = "Real-Time Run Data C Phase " model_desc = "Real-Time Run Data C Phase "
elif command.upper() == '11': ## 17 - AlarmData elif command.upper() == "11": ## 17 - 0x11
model_desc = "Inverter generic events log" # model_desc = "Inverter generic events log"
elif command.upper() == '12': ## 18 - AlarmUpdate model_desc = "AlarmData create EventsResponse"
elif command.upper() == "12": ## 18 - 0x12
model_desc = "Inverter major events log" model_desc = "Inverter major events log"
elif command.upper() == '13': ## 19 - RecordData elif command.upper() == "13": ## 19 - 0x13
model_desc = "Record Data" model_desc = "Record Data"
elif command.upper() == '14': ## 20 - InternalData elif command.upper() == "14": ## 20 - 0x14
model_desc = "Internal Data" model_desc = "Internal Data"
elif command.upper() == '15': ## 21 - GetLossRate elif command.upper() == "15": ## 21 - 0x15
model_desc = "Get Loss Rate" model_desc = "Get Loss Rate"
elif command.upper() == '1E': ## 30 - GetSelfCheckState elif command.upper() == "1E": ## 30
model_desc = "Get Self Check State" model_desc = "Get Self Check State"
elif command.upper() == 'FF': ## 255 - InitDataState elif command.upper() == "FF": ##255 - 0xff
model_desc = "Initi Data State" model_desc = "Initi Data State"
else: else:
model_desc = "event not configured - check ahoy script" model_desc = "event not configured - check ahoy script"
logging.info(f'model_decoder: {model}Decode{command.upper()} - {model_desc}')
logging.info(f'--> using model_decoder: {model}Decode{command.upper()}'
f' - {InfoCommands(int(command, 16)).name} [{command}] ({model_desc})')
model_decoders = __import__('hoymiles.decoders') model_decoders = __import__('hoymiles.decoders')
if hasattr(model_decoders, f'{model}Decode{command.upper()}'): if hasattr(model_decoders, f'{model}Decode{command.upper()}'):
@ -318,8 +346,8 @@ class InverterPacketFragment:
:rtype: str :rtype: str
""" """
size = len(self.frame) size = len(self.frame)
channel = f' channel {self.ch_rx}' if self.ch_rx else '' channel = f' channel {self.ch_rx:>02}' if self.ch_rx else ''
return f"Received {size} bytes{channel}: {hexify_payload(self.frame)}" return f"Received {size:>02} bytes{channel}: {hexify_payload(self.frame)}"
class HoymilesNRF: class HoymilesNRF:
"""Hoymiles NRF24 Interface""" """Hoymiles NRF24 Interface"""
@ -364,8 +392,7 @@ class HoymilesNRF:
self.next_tx_channel() self.next_tx_channel()
if HOYMILES_TRANSACTION_LOGGING: if HOYMILES_TRANSACTION_LOGGING:
c_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f") logging.debug(f'Transmit {len(packet):>02} bytes channel {self.tx_channel:>02}: {hexify_payload(packet)}')
logging.debug(f'{c_datetime} Transmit {len(packet)} bytes channel {self.tx_channel}: {hexify_payload(packet)}')
if not txpower: if not txpower:
txpower = self.txpower txpower = self.txpower

419
tools/rpi/hoymiles/__main__.py

@ -7,69 +7,68 @@ Hoymiles micro-inverters main application
import sys import sys
import struct import struct
from enum import IntEnum
import re
import time
import traceback import traceback
from datetime import datetime import re
from datetime import timedelta
from suntimes import SunTimes
import argparse import argparse
import yaml import yaml
from yaml.loader import SafeLoader from yaml.loader import SafeLoader
import hoymiles
import logging import logging
from logging.handlers import RotatingFileHandler 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"
################################################################################ ################################################################################
""" Signal Handler """ # 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, SIGKILL, SIGHUP from signal import signal, Signals, SIGINT, SIGTERM, SIGHUP
from signal import * from os import environ
def signal_handler(sig_num, frame): def signal_handler(sig_num, frame):
signame = Signals(sig_num).name """ Signal Handler
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")}') 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: if mqtt_client:
mqtt_client.disco() mqtt_client.disco()
if influx_client: if influx_client:
influx_client.disco() influx_client.disco()
if volkszaehler_client: if volkszaehler_client:
volkszaehler_client.disco() volkszaehler_client.disco()
sys.exit(0) sys.exit(0)
signal(SIGINT, signal_handler) # Interrupt from keyboard (CTRL + C) """ activate signal handler """
signal(SIGTERM, signal_handler) # Signal Handler from terminating processes signal(SIGINT, signal_handler)
signal(SIGHUP, signal_handler) # Hangup detected on controlling terminal or death of controlling process signal(SIGTERM, signal_handler)
# signal(SIGKILL, signal_handler) # Signal Handler SIGKILL and SIGSTOP cannot be caught, blocked, or ignored!! signal(SIGHUP, signal_handler)
# signal(SIGKILL, signal_handler) # not used
################################################################################ ################################################################################
################################################################################ ################################################################################
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: 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): def __init__(self, sunset_config):
self.suntimes = None self.suntimes = None
if sunset_config and sunset_config.get('disabled', True) == False: if sunset_config and sunset_config.get('disabled', True) == False:
@ -78,9 +77,11 @@ class SunsetHandler:
altitude = sunset_config.get('altitude') altitude = sunset_config.get('altitude')
self.suntimes = SunTimes(longitude=longitude, latitude=latitude, altitude=altitude) self.suntimes = SunTimes(longitude=longitude, latitude=latitude, altitude=altitude)
self.nextSunset = self.suntimes.setutc(datetime.utcnow()) self.nextSunset = self.suntimes.setutc(datetime.utcnow())
logging.info (f'Todays sunset is at {self.nextSunset} UTC') logging.info (f'Sunset today at: {self.nextSunset} UTC')
# send info to mqtt, if broker configured
self.sun_status2mqtt()
else: else:
logging.info('Sunset disabled.') logging.info('Sunset disabled!')
def checkWaitForSunrise(self): def checkWaitForSunrise(self):
if not self.suntimes: if not self.suntimes:
@ -97,75 +98,88 @@ class SunsetHandler:
time_to_sleep = int((nextSunrise - datetime.utcnow()).total_seconds()) 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.') 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: if time_to_sleep > 0:
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): def sun_status2mqtt(self):
""" send sunset information every day to MQTT broker """
if not mqtt_client or not self.suntimes: if not mqtt_client or not self.suntimes:
return return
if self.suntimes: if self.suntimes:
local_sunrise = self.suntimes.riselocal(datetime.now()).strftime("%d.%m.%YT%H:%M") 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_sunset = self.suntimes.setlocal(datetime.now()).strftime("%d.%m.%YT%H:%M")
local_zone = self.suntimes.setlocal(datetime.now()).tzinfo.key local_zone = self.suntimes.setlocal(datetime.now()).tzinfo.key
mqtt_client.info2mqtt({'topic' : f'{dtu_name}/{dtu_ser}'}, \
{'dis_night_comm' : 'True', \ mqtt_client.info2mqtt(f'{dtu_name}/{dtu_serial}',
'local_sunrise' : local_sunrise, \ {'dis_night_comm' : 'True',
'local_sunset' : local_sunset, 'local_sunrise' : local_sunrise,
'local_zone' : local_zone}) 'local_sunset' : local_sunset,
'local_zone' : local_zone})
else: else:
mqtt_client.sun_info2mqtt({'sun_topic': f'{dtu_name}/{dtu_ser}'}, \ mqtt_client.info2mqtt(f'{dtu_name}/{dtu_serial}', {'dis_night_comm': 'False'})
{'dis_night_comm': 'False'})
def main_loop(ahoy_config): def main_loop(ahoy_config):
"""Main loop""" """ Main loop """
inverters = [ # check 'interval' parameter in config-file
inverter for inverter in ahoy_config.get('inverters', []) loop_interval = ahoy_config.get('interval', 15)
if not inverter.get('disabled', False)] 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)
sunset = SunsetHandler(ahoy_config.get('sunset')) # check 'transmit_retries' parameter in config-file
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)
transmit_retries = ahoy_config.get('transmit_retries', 5) transmit_retries = ahoy_config.get('transmit_retries', 5)
if (transmit_retries <= 0): if (transmit_retries <= 0):
logging.critical('Parameter "transmit_retries" must be >0 - please check ahoy.yml.') logging.critical("Parameter 'transmit_retries' must grater 0 - please check ahoy.yml.")
# print message to console too # print console message too
print('Parameter "transmit_retries" must be >0 - please check ahoy.yml - STOP(0)x') print("Parameter 'transmit_retries' must be >0 - please check ahoy.yml - STOP(0)")
sys.exit(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: try:
do_init = True do_init = True
while True: while True: # MAIN endless LOOP
# check sunrise and sunset times and sleep in night time
sunset.checkWaitForSunrise() sunset.checkWaitForSunrise()
t_loop_start = time.time() t_loop_start = time.time()
for inverter in inverters: for inverter in inverters:
if not 'name' in inverter: poll_inverter(inverter, do_init, transmit_retries)
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, transmit_retries)
do_init = False do_init = False
if loop_interval > 0: # calc time to pause main-loop
time_to_sleep = loop_interval - (time.time() - t_loop_start) time_to_sleep = loop_interval - (time.time() - t_loop_start)
if time_to_sleep > 0: if time_to_sleep > 0:
time.sleep(time_to_sleep) 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: 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())
raise raise
def poll_inverter(inverter, do_init, retries):
def poll_inverter(inverter, dtu_ser, do_init, retries):
""" """
Send/Receive command_queue, initiate status poll on inverter Send/Receive command_queue, initiate status poll on inverter
@ -173,20 +187,34 @@ def poll_inverter(inverter, dtu_ser, do_init, retries):
:param retries: tx retry count if no inverter contact :param retries: tx retry count if no inverter contact
:type retries: int :type retries: int
""" """
inverter_ser = inverter.get('serial') inverter_ser = inverter.get('serial')
inverter_name = inverter.get('name') inverter_name = inverter.get('name')
inverter_strings = inverter.get('strings') inverter_strings = inverter.get('strings')
inv_str = str(inverter_ser)
# Queue at least status data request # Queue at least status data request
inv_str = str(inverter_ser)
if do_init: 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(hoymiles.InfoCommands.InverterDevInform_Simple)) # 00
#command_queue[inv_str].append(hoymiles.compose_send_time_payload(InfoCommands.SystemConfigPara)) 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(InfoCommands.RealTimeRunData_Debug)) # 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 # Put all queued commands for current inverter on air
while len(command_queue[inv_str]) > 0: while len(command_queue[inv_str]) > 0:
payload = command_queue[inv_str].pop(0) ## Sub.Cmd 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 # Send payload {ttl}-times until we get at least one reponse
payload_ttl = retries payload_ttl = retries
@ -196,12 +224,12 @@ def poll_inverter(inverter, dtu_ser, do_init, retries):
com = hoymiles.InverterTransaction( com = hoymiles.InverterTransaction(
radio=hmradio, radio=hmradio,
txpower=inverter.get('txpower', None), txpower=inverter.get('txpower', None),
dtu_ser=dtu_ser, dtu_ser=dtu_serial,
inverter_ser=inverter_ser, inverter_ser=inverter_ser,
request=next(hoymiles.compose_esb_packet( request=next(hoymiles.compose_esb_packet(
payload, payload,
seq=b'\x80', seq=b'\x80',
src=dtu_ser, src=dtu_serial,
dst=inverter_ser dst=inverter_ser
))) )))
while com.rxtx(): while com.rxtx():
@ -213,51 +241,75 @@ def poll_inverter(inverter, dtu_ser, do_init, retries):
logging.error(f'Error while retrieving data: {e_all}') logging.error(f'Error while retrieving data: {e_all}')
pass pass
# Handle the response data if any # Handle response data, if any
if response: if response:
if hoymiles.HOYMILES_TRANSACTION_LOGGING: if hoymiles.HOYMILES_TRANSACTION_LOGGING:
logging.debug(f'Payload: ' + hoymiles.hexify_payload(response)) logging.debug(f'Payload: {len(response)} bytes: {hoymiles.hexify_payload(response)}')
# prepare decoder object # get a ResponseDecoder object to decode response-payload
decoder = hoymiles.ResponseDecoder(response, decoder = hoymiles.ResponseDecoder(response,
request=com.request, request=com.request,
inverter_ser=inverter_ser, inverter_ser=inverter_ser,
inverter_name=inverter_name, inverter_name=inverter_name,
dtu_ser=dtu_ser,
strings=inverter_strings strings=inverter_strings
) )
# get decoder object result = decoder.decode() # call decoder object
result = decoder.decode() data = result.__dict__() # convert result into python-dict
if hoymiles.HOYMILES_DEBUG_LOGGING: if hoymiles.HOYMILES_TRANSACTION_LOGGING:
logging.info(f'Decoded: {result.__dict__()}') logging.debug(f'Decoded: {data}')
# check decoder object for output # check result object for output
if isinstance(result, hoymiles.decoders.StatusResponse): 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']})")
data = result.__dict__() # when 'event_count' is changed, add AlarmData-command to queue
if data is not None and 'event_count' in data: 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 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])) 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: if mqtt_client:
mqtt_client.store_status(result, topic=inverter.get('mqtt', {}).get('topic', None)) mqtt_client.store_status(data)
if influx_client: if influx_client:
influx_client.store_status(result) influx_client.store_status(data)
if volkszaehler_client: if volkszaehler_client:
volkszaehler_client.store_status(result) volkszaehler_client.store_status(data)
# check decoder object for output # check decoder object for different data types
if isinstance(result, hoymiles.decoders.HardwareInfoResponse): if isinstance(result, hoymiles.decoders.HardwareInfoResponse):
if mqtt_client: if hoymiles.HOYMILES_VERBOSE_LOGGING:
mqtt_client.store_status(result, topic=inverter.get('mqtt', {}).get('topic', None)) 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
def mqtt_on_command(client, userdata, message):
"""
Handle commands to topic Handle commands to topic
hoymiles/{inverter_ser}/command hoymiles/{inverter_ser}/command
frame a payload and put onto command_queue frame a payload and put onto command_queue
@ -278,35 +330,45 @@ def mqtt_on_command(client, userdata, message):
:param paho.mqtt.client.Client client: mqtt-client instance :param paho.mqtt.client.Client client: mqtt-client instance
:param dict userdata: Userdata :param dict userdata: Userdata
:param dict message: mqtt-client message object :param dict message: mqtt-client message object
""" '''
try: # print(f"msg-topic: {message.topic} - QoS: {message.qos}")
inverter_ser = next( # print(f"payload: ",str(message.payload.decode("utf-8")), "\n")
item[0] for item in mqtt_command_topic_subs if item[1] == message.topic)
except StopIteration: # handle specific payload topic
logging.warning('Unexpedtedly received mqtt message for {message.topic}') if message.topic.endswith("SENSOR"):
if volkszaehler_client:
volkszaehler_client.store_status(yaml.safe_load(str(message.payload.decode("utf-8"))))
if inverter_ser: if message.topic.endswith("command"):
p_message = message.payload.decode('utf-8').lower() p_message = message.payload.decode('utf-8').lower()
# Expand tttttttt to current time for use in hexlified payload # 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()))) expand_time = ''.join(f'{b:02x}' for b in struct.pack('>L', int(time.time())))
p_message = p_message.replace('tttttttt', expand_time) p_message = p_message.replace('tttttttt', expand_time)
logging.info (f"MQTT-command: {message.topic} - {p_message}")
if (len(p_message) < 2048 \ if (len(p_message) < 2048 and len(p_message) % 2 == 0 and re.match(r'^[a-f0-9]+$', p_message)):
and len(p_message) % 2 == 0 \
and re.match(r'^[a-f0-9]+$', p_message)):
payload = bytes.fromhex(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 # commands must start with \x80
if payload[0] == 0x80: if payload[0] == 0x80:
command_queue[str(inverter_ser)].append( # array "command_queue[inv_str]" will be shared to an other thread --> critical section
hoymiles.frame_payload(payload[1:])) 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): def init_logging(ahoy_config):
""" init and prepare logging """
log_config = ahoy_config.get('logging') log_config = ahoy_config.get('logging')
fn = 'hoymiles.log' fn = 'hoymiles.log'
lvl = logging.ERROR lvl = logging.ERROR
max_log_filesize = 1000000 max_log_filesize = 1000000
max_log_files = 1 max_log_files = 1
if log_config: if log_config:
fn = log_config.get('filename', fn) fn = log_config.get('filename', fn)
level = log_config.get('level', 'ERROR') level = log_config.get('level', 'ERROR')
@ -322,15 +384,23 @@ def init_logging(ahoy_config):
lvl = logging.FATAL lvl = logging.FATAL
max_log_filesize = log_config.get('max_log_filesize', max_log_filesize) max_log_filesize = log_config.get('max_log_filesize', max_log_filesize)
max_log_files = log_config.get('max_log_files', max_log_files) max_log_files = log_config.get('max_log_files', max_log_files)
if hoymiles.HOYMILES_TRANSACTION_LOGGING:
# define log switches
if global_config.log_transactions:
hoymiles.HOYMILES_TRANSACTION_LOGGING = True
lvl = logging.DEBUG 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)], logging.basicConfig(handlers=[RotatingFileHandler(fn, maxBytes=max_log_filesize, backupCount=max_log_files)],
format='%(asctime)s %(levelname)s: %(message)s', format='%(asctime)s %(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S.%s', level=lvl) datefmt='%Y-%m-%d %H:%M:%S.%s', level=lvl)
dtu_name = ahoy_config.get('dtu',{}).get('name','hoymiles-dtu')
logging.info(f'start logging for {dtu_name} with level: {logging.getLevelName(logging.root.level)}') logging.info(f'AHOY-logging started for "{dtu_name}" with level: {logging.getLevelName(logging.root.level)}')
if __name__ == '__main__': if __name__ == '__main__':
# read commandline parameter
parser = argparse.ArgumentParser(description='Ahoy - Hoymiles solar inverter gateway', prog="hoymiles") parser = argparse.ArgumentParser(description='Ahoy - Hoymiles solar inverter gateway', prog="hoymiles")
parser.add_argument("-c", "--config-file", nargs="?", required=True, parser.add_argument("-c", "--config-file", nargs="?", required=True,
help="configuration file") help="configuration file")
@ -340,7 +410,7 @@ if __name__ == '__main__':
help="Enable detailed debug output (loglevel must be DEBUG)") help="Enable detailed debug output (loglevel must be DEBUG)")
global_config = parser.parse_args() global_config = parser.parse_args()
# Load ahoy.yml config file # Load config file given in commandline parameter
try: try:
if isinstance(global_config.config_file, str): if isinstance(global_config.config_file, str):
with open(global_config.config_file, 'r') as fh_yaml: with open(global_config.config_file, 'r') as fh_yaml:
@ -355,27 +425,44 @@ if __name__ == '__main__':
logging.error(f'Failed to load config file {global_config.config_file}: {e_yaml}') logging.error(f'Failed to load config file {global_config.config_file}: {e_yaml}')
sys.exit(1) sys.exit(1)
if global_config.log_transactions: # read all parameter from configuration file as 'ahoy_config'
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', {})) 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) init_logging(ahoy_config)
# Prepare for multiple transceivers, makes them configurable # Prepare for multiple transceivers (radio modules), makes them configurable
for radio_config in ahoy_config.get('nrf', [{}]): for radio_config in ahoy_config.get('nrf', [{}]):
hmradio = hoymiles.HoymilesNRF(**radio_config) hmradio = hoymiles.HoymilesNRF(**radio_config)
# create MQTT - client object # create MQTT client object
mqtt_client = None # if: mqtt-disabled is "true" - only
mqtt_config = ahoy_config.get('mqtt', None) # if: mqtt-disabled is "true" AND inverter-mqtt-send_raw_enabled is "true"
if mqtt_config and not mqtt_config.get('disabled', False): # 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 from .outputs import MqttOutputPlugin
mqtt_client = MqttOutputPlugin(mqtt_config)
# create INFLUX - client object # 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_client = None
influx_config = ahoy_config.get('influxdb', None) 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):
@ -387,31 +474,35 @@ if __name__ == '__main__':
bucket=influx_config.get('bucket', None), bucket=influx_config.get('bucket', None),
measurement=influx_config.get('measurement', 'hoymiles')) measurement=influx_config.get('measurement', 'hoymiles'))
# create VOLKSZAEHLER - client object # create VOLKSZAEHLER client object
volkszaehler_client = None volkszaehler_client = None
volkszaehler_config = ahoy_config.get('volkszaehler', {}) volkszaehler_config = ahoy_config.get('volkszaehler', {})
if volkszaehler_config and not volkszaehler_config.get('disabled', False): if volkszaehler_config and not volkszaehler_config.get('disabled', False):
from .outputs import VolkszaehlerOutputPlugin from .outputs import VolkszaehlerOutputPlugin
volkszaehler_client = VolkszaehlerOutputPlugin(volkszaehler_config) volkszaehler_client = VolkszaehlerOutputPlugin(volkszaehler_config)
# init important runtime variables
event_message_index = {} event_message_index = {}
command_queue = {} command_queue = {}
mqtt_command_topic_subs = [] mqtt_command_topic_subs = []
for g_inverter in ahoy_config.get('inverters', []): for g_inverter in ahoy_config.get('inverters', []): # loop inverters in ahoy_config
g_inverter_ser = g_inverter.get('serial') inv_str = str(g_inverter.get('serial')) # inverter serial number as index
inv_str = str(g_inverter_ser) command_queue[inv_str] = [] # create empty command-queue
command_queue[inv_str] = [] event_message_index[inv_str] = 0 # init event-queue with value=0
event_message_index[inv_str] = 0
# if send_raw_enabled, add topic to subscribe command-queue
# Enables and subscribe inverter to mqtt /command-Topic if mqtt_c_obj and g_inverter.get('mqtt', {}).get('send_raw_enabled', False):
if mqtt_client and g_inverter.get('mqtt', {}).get('send_raw_enabled', False): mqtt_topic_array.append(
topic_item = ( (g_inverter.get('mqtt', {}).get('topic', f'hoymiles/{inv_str}') + '/command',
str(g_inverter_ser), mqtt_config.get('QoS',0)
g_inverter.get('mqtt', {}).get('topic', f'hoymiles/{g_inverter_ser}') + '/command' ))
)
mqtt_client.client.subscribe(topic_item[1]) # start subscribe mqtt broker, if requested 'topic' is available
mqtt_command_topic_subs.append(topic_item) 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 # start main-loop
main_loop(ahoy_config) main_loop(ahoy_config)

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

@ -64,7 +64,6 @@ class Response:
""" All Response Shared methods """ """ All Response Shared methods """
inverter_ser = None inverter_ser = None
inverter_name = None inverter_name = None
dtu_ser = None
response = None response = None
def __init__(self, *args, **params): def __init__(self, *args, **params):
@ -73,7 +72,6 @@ class Response:
""" """
self.inverter_ser = params.get('inverter_ser', None) self.inverter_ser = params.get('inverter_ser', None)
self.inverter_name = params.get('inverter_name', None) self.inverter_name = params.get('inverter_name', None)
self.dtu_ser = params.get('dtu_ser', None)
self.response = args[0] self.response = args[0]
strings = params.get('strings', None) strings = params.get('strings', None)
@ -89,7 +87,7 @@ class Response:
return { return {
'inverter_ser': self.inverter_ser, 'inverter_ser': self.inverter_ser,
'inverter_name': self.inverter_name, 'inverter_name': self.inverter_name,
'dtu_ser': self.dtu_ser} }
class StatusResponse(Response): class StatusResponse(Response):
"""Inverter StatusResponse object""" """Inverter StatusResponse object"""
@ -250,74 +248,74 @@ class EventsResponse(UnknownResponse):
alarm_codes = { alarm_codes = {
# HM Error Codes # HM Error Codes
1: 'Inverter start', # 0x01 1: 'Inverter start', # 0x01
2: 'DTU command failed', # 0x02 2: 'DTU command failed', # 0x02
121: 'Over temperature protection', # 0x79 121: 'Over temperature protection', # 0x79
125: 'Grid configuration parameter error', # 0x7D 125: 'Grid configuration parameter error', # 0x7D
126: 'Software error code 126', # 0x7E 126: 'Software error code 126', # 0x7E
127: 'Firmware error', # 0x7F 127: 'Firmware error', # 0x7F
128: 'Software error code 128', # 0x80 128: 'Software error code 128', # 0x80
129: 'Software error code 129', # 0x81 129: 'Software error code 129', # 0x81
130: 'Offline', # 0x82 130: 'Offline', # 0x82
141: 'Grid overvoltage', # 0x8D 141: 'Grid overvoltage', # 0x8D
142: 'Average grid overvoltage', # 0x8E 142: 'Average grid overvoltage', # 0x8E
143: 'Grid undervoltage', # 0x8F 143: 'Grid undervoltage', # 0x8F
144: 'Grid overfrequency', # 0x90 144: 'Grid overfrequency', # 0x90
145: 'Grid underfrequency', # 0x91 145: 'Grid underfrequency', # 0x91
146: 'Rapid grid frequency change', # 0x92 146: 'Rapid grid frequency change', # 0x92
147: 'Power grid outage', # 0x93 147: 'Power grid outage', # 0x93
148: 'Grid disconnection', # 0x94 148: 'Grid disconnection', # 0x94
149: 'Island detected', # 0x95 149: 'Island detected', # 0x95
205: 'Input port 1 & 2 overvoltage', # 0xCD 205: 'Input port 1 & 2 overvoltage', # 0xCD
206: 'Input port 3 & 4 overvoltage', # 0xCE 206: 'Input port 3 & 4 overvoltage', # 0xCE
207: 'Input port 1 & 2 undervoltage', # 0xCF 207: 'Input port 1 & 2 undervoltage', # 0xCF
208: 'Input port 3 & 4 undervoltage', # 0xD0 208: 'Input port 3 & 4 undervoltage', # 0xD0
209: 'Port 1 no input', # 0xD1 209: 'Port 1 no input', # 0xD1
210: 'Port 2 no input', # 0xD2 210: 'Port 2 no input', # 0xD2
211: 'Port 3 no input', # 0xD3 211: 'Port 3 no input', # 0xD3
212: 'Port 4 no input', # 0xD4 212: 'Port 4 no input', # 0xD4
213: 'PV-1 & PV-2 abnormal wiring', # 0xD5 213: 'PV-1 & PV-2 abnormal wiring', # 0xD5
214: 'PV-3 & PV-4 abnormal wiring', # 0xD6 214: 'PV-3 & PV-4 abnormal wiring', # 0xD6
215: 'PV-1 Input overvoltage', # 0xD7 215: 'PV-1 Input overvoltage', # 0xD7
216: 'PV-1 Input undervoltage', # 0xD8 216: 'PV-1 Input undervoltage', # 0xD8
217: 'PV-2 Input overvoltage', # 0xD9 217: 'PV-2 Input overvoltage', # 0xD9
218: 'PV-2 Input undervoltage', # 0xDA 218: 'PV-2 Input undervoltage', # 0xDA
219: 'PV-3 Input overvoltage', # 0xDB 219: 'PV-3 Input overvoltage', # 0xDB
220: 'PV-3 Input undervoltage', # 0xDC 220: 'PV-3 Input undervoltage', # 0xDC
221: 'PV-4 Input overvoltage', # 0xDD 221: 'PV-4 Input overvoltage', # 0xDD
222: 'PV-4 Input undervoltage', # 0xDE 222: 'PV-4 Input undervoltage', # 0xDE
301: 'Hardware error code 301', # 0x012D 301: 'Hardware error code 301', # 0x012D
302: 'Hardware error code 302', # 0x012E 302: 'Hardware error code 302', # 0x012E
303: 'Hardware error code 303', # 0x012F 303: 'Hardware error code 303', # 0x012F
304: 'Hardware error code 304', # 0x0130 304: 'Hardware error code 304', # 0x0130
305: 'Hardware error code 305', # 0x0131 305: 'Hardware error code 305', # 0x0131
306: 'Hardware error code 306', # 0x0132 306: 'Hardware error code 306', # 0x0132
307: 'Hardware error code 307', # 0x0133 307: 'Hardware error code 307', # 0x0133
308: 'Hardware error code 308', # 0x0134 308: 'Hardware error code 308', # 0x0134
309: 'Hardware error code 309', # 0x0135 309: 'Hardware error code 309', # 0x0135
310: 'Hardware error code 310', # 0x0136 310: 'Hardware error code 310', # 0x0136
311: 'Hardware error code 311', # 0x0137 311: 'Hardware error code 311', # 0x0137
312: 'Hardware error code 312', # 0x0138 312: 'Hardware error code 312', # 0x0138
313: 'Hardware error code 313', # 0x0139 313: 'Hardware error code 313', # 0x0139
314: 'Hardware error code 314', # 0x013A 314: 'Hardware error code 314', # 0x013A
# MI Error Codes # MI Error Codes
5041: 'Error code-04 Port 1', # 0x13B1 5041: 'Error code-04 Port 1', # 0x13B1
5042: 'Error code-04 Port 2', # 0x13B2 5042: 'Error code-04 Port 2', # 0x13B2
5043: 'Error code-04 Port 3', # 0x13B3 5043: 'Error code-04 Port 3', # 0x13B3
5044: 'Error code-04 Port 4', # 0x13B4 5044: 'Error code-04 Port 4', # 0x13B4
5051: 'PV Input 1 Overvoltage/Undervoltage', # 0x13BB 5051: 'PV Input 1 Overvoltage/Undervoltage', # 0x13BB
5052: 'PV Input 2 Overvoltage/Undervoltage', # 0x13BC 5052: 'PV Input 2 Overvoltage/Undervoltage', # 0x13BC
5053: 'PV Input 3 Overvoltage/Undervoltage', # 0x13BD 5053: 'PV Input 3 Overvoltage/Undervoltage', # 0x13BD
5054: 'PV Input 4 Overvoltage/Undervoltage', # 0x13BE 5054: 'PV Input 4 Overvoltage/Undervoltage', # 0x13BE
5060: 'Abnormal bias', # 0x13C4 5060: 'Abnormal bias', # 0x13C4
5070: 'Over temperature protection', # 0x13CE 5070: 'Over temperature protection', # 0x13CE
5080: 'Grid Overvoltage/Undervoltage', # 0x13D8 5080: 'Grid Overvoltage/Undervoltage', # 0x13D8
5090: 'Grid Overfrequency/Underfrequency', # 0x13E2 5090: 'Grid Overfrequency/Underfrequency', # 0x13E2
5100: 'Island detected', # 0x13EC 5100: 'Island detected', # 0x13EC
5120: 'EEPROM reading and writing error', # 0x1400 5120: 'EEPROM reading and writing error', # 0x1400
5150: '10 min value grid overvoltage', # 0x141E 5150: '10 min value grid overvoltage', # 0x141E
5200: 'Firmware error', # 0x1450 5200: 'Firmware error', # 0x1450
8310: 'Shut down', # 0x2076 8310: 'Shut down', # 0x2076
9000: 'Microinverter is suspected of being stolen' # 0x2328 9000: 'Microinverter is suspected of being stolen' # 0x2328
} }
@ -331,7 +329,6 @@ 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})')
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):
@ -387,7 +384,7 @@ class HardwareInfoResponse(UnknownResponse):
logging.error(f'HardwareInfoResponse: data: {self.response}') logging.error(f'HardwareInfoResponse: data: {self.response}')
return data return data
logging.info(f'HardwareInfoResponse: {struct.unpack(">HHHHHHHH", self.response[0:16])}') logging.debug(f'HardwareInfoResponse: {struct.unpack(">HHHHHHHH", self.response[0:16])}')
fw_version, fw_build_yyyy, fw_build_mmdd, fw_build_hhmm, hw_id = struct.unpack('>HHHHH', self.response[0:10]) fw_version, fw_build_yyyy, fw_build_mmdd, fw_build_hhmm, hw_id = struct.unpack('>HHHHH', self.response[0:10])
fw_version_maj = int((fw_version / 10000)) fw_version_maj = int((fw_version / 10000))
@ -397,9 +394,6 @@ class HardwareInfoResponse(UnknownResponse):
fw_build_dd = int(fw_build_mmdd % 100) fw_build_dd = int(fw_build_mmdd % 100)
fw_build_HH = int(fw_build_hhmm / 100) fw_build_HH = int(fw_build_hhmm / 100)
fw_build_MM = int(fw_build_hhmm % 100) fw_build_MM = int(fw_build_hhmm % 100)
logging.info(f'Firmware: {fw_version_maj}.{fw_version_min}.{fw_version_pat} '\
f'build at {fw_build_dd:>02}/{fw_build_mm:>02}/{fw_build_yyyy}T{fw_build_HH:>02}:{fw_build_MM:>02}, '\
f'HW revision {hw_id}')
data['FW_ver_maj'] = fw_version_maj data['FW_ver_maj'] = fw_version_maj
data['FW_ver_min'] = fw_version_min data['FW_ver_min'] = fw_version_min
@ -428,9 +422,6 @@ class DebugDecodeAny(UnknownResponse):
logging.debug(' payload has valid modbus crc') logging.debug(' payload has valid modbus crc')
self.response = self.response[:-2] self.response = self.response[:-2]
l_payload = len(self.response)
logging.debug(f' payload has {l_payload} bytes')
logging.debug('') logging.debug('')
logging.debug('Field view: int') logging.debug('Field view: int')
print_table_unpack('>B', self.response) print_table_unpack('>B', self.response)
@ -455,6 +446,13 @@ class DebugDecodeAny(UnknownResponse):
except UnicodeDecodeError: except UnicodeDecodeError:
logging.debug(' type ascii : ascii decode error') logging.debug(' type ascii : ascii decode error')
def __dict__(self):
""" Base values, availabe in each __dict__ call """
data = super().__dict__()
data['len_payload'] = len(self.response)
data['payload'] = self.response
return data
# 1121-Series Intervers, 1 MPPT, 1 Phase # 1121-Series Intervers, 1 MPPT, 1 Phase
class Hm300Decode01(HardwareInfoResponse): class Hm300Decode01(HardwareInfoResponse):

263
tools/rpi/hoymiles/outputs.py

@ -9,7 +9,7 @@ import socket
import logging import logging
from datetime import datetime, timezone from datetime import datetime, timezone
from hoymiles.decoders import StatusResponse, HardwareInfoResponse from hoymiles.decoders import StatusResponse, HardwareInfoResponse
from hoymiles import HOYMILES_TRANSACTION_LOGGING, HOYMILES_DEBUG_LOGGING from hoymiles import HOYMILES_TRANSACTION_LOGGING, HOYMILES_VERBOSE_LOGGING
class OutputPluginFactory: class OutputPluginFactory:
def __init__(self, **params): def __init__(self, **params):
@ -22,7 +22,7 @@ class OutputPluginFactory:
:type inverter_name: str :type inverter_name: str
""" """
self.inverter_ser = params.get('inverter_ser', '') self.inverter_ser = params.get('inverter_ser', '')
self.inverter_name = params.get('inverter_name', None) self.inverter_name = params.get('inverter_name', None)
def store_status(self, response, **params): def store_status(self, response, **params):
@ -64,7 +64,7 @@ class InfluxOutputPlugin(OutputPluginFactory):
print(ErrorText1, ErrorText2) print(ErrorText1, ErrorText2)
logging.error(ErrorText1) logging.error(ErrorText1)
logging.error(ErrorText2) logging.error(ErrorText2)
exit() exit(1)
self._bucket = params.get('bucket', 'hoymiles/autogen') self._bucket = params.get('bucket', 'hoymiles/autogen')
self._org = params.get('org', '') self._org = params.get('org', '')
@ -72,12 +72,15 @@ class InfluxOutputPlugin(OutputPluginFactory):
with InfluxDBClient(url, token, bucket=self._bucket) as self.client: with InfluxDBClient(url, token, bucket=self._bucket) as self.client:
self.api = self.client.write_api() self.api = self.client.write_api()
if HOYMILES_VERBOSE_LOGGING:
logging.info(f"Influx: connect to DB {url} initialized")
def disco(self, **params): def disco(self, **params):
self.client.close() # Shutdown the client self.client.close() # Shutdown the client
return return
def store_status(self, response, **params): # def store_status(self, response, **params):
def store_status(self, data, **params):
""" """
Publish StatusResponse object Publish StatusResponse object
@ -89,10 +92,12 @@ class InfluxOutputPlugin(OutputPluginFactory):
:raises ValueError: when response is not instance of StatusResponse :raises ValueError: when response is not instance of StatusResponse
""" """
if not isinstance(response, StatusResponse): # if not isinstance(response, StatusResponse):
raise ValueError('Data needs to be instance of StatusResponse') # raise ValueError('Data needs to be instance of StatusResponse')
if not 'phases' in data or not 'strings' in data:
raise ValueError('DICT need key "inverter_ser" and "inverter_name"')
data = response.__dict__() # data = response.__dict__() # convert response-parameter into python-dict
measurement = self._measurement + f',location={data["inverter_ser"]}' measurement = self._measurement + f',location={data["inverter_ser"]}'
@ -108,7 +113,7 @@ class InfluxOutputPlugin(OutputPluginFactory):
# InfluxDB requires nanoseconds # InfluxDB requires nanoseconds
ctime = int(utctime.timestamp() * 1e9) ctime = int(utctime.timestamp() * 1e9)
if HOYMILES_DEBUG_LOGGING: if HOYMILES_VERBOSE_LOGGING:
logging.info(f'InfluxDB: utctime: {utctime}') logging.info(f'InfluxDB: utctime: {utctime}')
# AC Data # AC Data
@ -144,8 +149,8 @@ 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: if HOYMILES_VERBOSE_LOGGING:
#logging.debug(f'INFLUX data to DB: {data_stack}') logging.debug(f'INFLUX data to DB: {data_stack}')
pass pass
self.api.write(self._bucket, self._org, data_stack) self.api.write(self._bucket, self._org, data_stack)
@ -153,7 +158,7 @@ class MqttOutputPlugin(OutputPluginFactory):
""" Mqtt output plugin """ """ Mqtt output plugin """
client = None client = None
def __init__(self, config, **params): def __init__(self, config, cb_message, **params):
""" """
Initialize MqttOutputPlugin Initialize MqttOutputPlugin
@ -177,34 +182,51 @@ class MqttOutputPlugin(OutputPluginFactory):
super().__init__(**params) super().__init__(**params)
try: try:
import paho.mqtt.client import paho.mqtt.client as mqtt
except ModuleNotFoundError: except ModuleNotFoundError:
ErrorText1 = f'Module "paho.mqtt.client" for MQTT-output necessary.' ErrorText1 = f'Module "paho.mqtt.client" for MQTT-output necessary.'
ErrorText2 = f'Install module with command: python3 -m pip install paho-mqtt' ErrorText2 = f'Install module with command: python3 -m pip install paho-mqtt'
print(ErrorText1, ErrorText2) print(ErrorText1, ErrorText2)
logging.error(ErrorText1) logging.error(ErrorText1)
logging.error(ErrorText2) logging.error(ErrorText2)
exit() exit(1)
# For paho-mqtt 2.0.0, you need to set callback_api_version.
# self.client = mqtt.Client(callback_api_version=mqtt.CallbackAPIVersion.VERSION1)
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
mqtt_client = paho.mqtt.client.Client()
if config.get('useTLS',False): if config.get('useTLS',False):
mqtt_client.tls_set() self.client.tls_set()
mqtt_client.tls_insecure_set(config.get('insecureTLS',False)) self.client.tls_insecure_set(config.get('insecureTLS',False))
mqtt_client.username_pw_set(config.get('user', None), config.get('password', None)) self.client.username_pw_set(config.get('user', None), config.get('password', None))
last_will = config.get('last_will', None) last_will = config.get('last_will', None)
if last_will: if last_will:
lw_topic = last_will.get('topic', 'last will hoymiles') lw_topic = last_will.get('topic', 'last will hoymiles')
lw_payload = last_will.get('payload', 'last will') lw_payload = last_will.get('payload', 'last will')
mqtt_client.will_set(str(lw_topic), str(lw_payload)) self.client.will_set(str(lw_topic), str(lw_payload))
mqtt_client.connect(config.get('host', '127.0.0.1'), config.get('port', 1883)) self.client.connect(config.get('host', '127.0.0.1'), config.get('port', 1883))
mqtt_client.loop_start() self.client.loop_start()
self.client = mqtt_client
self.qos = config.get('QoS', 0) # Quality of Service self.qos = config.get('QoS', 0) # Quality of Service
self.ret = config.get('Retain', True) # Retain Message self.ret = config.get('Retain', True) # Retain Message
# connect own (PAHO) callback functions
self.client.on_connect = self.mqtt_on_connect
self.client.on_message = cb_message
# MQTT(PAHO) callcack method to inform about connection to mqtt broker
def mqtt_on_connect(self, client, userdata, flags, reason_code, properties):
if flags.session_present:
logging.info("flags.session_present")
if reason_code == 0: # success connect
if HOYMILES_VERBOSE_LOGGING:
logging.info(f"MQTT: Connected to Broker: {self.client.host}:{self.client.port} as user {self.client.username}")
if reason_code > 0: # error processing
logging.error(f'Connect failed: {reason_code}') # error message
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
@ -212,10 +234,11 @@ class MqttOutputPlugin(OutputPluginFactory):
def info2mqtt(self, mqtt_topic, mqtt_data): def info2mqtt(self, mqtt_topic, mqtt_data):
for mqtt_key in 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) self.client.publish(f'{mqtt_topic}/{mqtt_key}', mqtt_data[mqtt_key], self.qos, self.ret)
return return
def store_status(self, response, **params): # def store_status(self, response, **params):
def store_status(self, data, **params):
""" """
Publish StatusResponse object Publish StatusResponse object
@ -226,20 +249,20 @@ class MqttOutputPlugin(OutputPluginFactory):
:raises ValueError: when response is not instance of StatusResponse :raises ValueError: when response is not instance of StatusResponse
""" """
data = response.__dict__() # data = response.__dict__() # convert response-parameter into python-dict
if data is None: if data is None:
logging.warn("received data object is empty") logging.warn("OUTPUT-MQTT: received data object is empty")
return return
topic = params.get('topic', None) topic = f'{data.get("inverter_name", "hoymiles")}/{data.get("inverter_ser", None)}'
if not topic:
topic = f'{data.get("inverter_name", "hoymiles")}/{data.get("inverter_ser", None)}'
if HOYMILES_DEBUG_LOGGING: if HOYMILES_TRANSACTION_LOGGING:
logging.info(f'MQTT-topic: {topic} data-type: {type(response)}') logging.info(f'MQTT topic : {topic}')
logging.info(f'MQTT payload: {data}')
if isinstance(response, StatusResponse): # if isinstance(response, StatusResponse):
if 'phases' in data and 'strings' in data:
# Global Head # Global Head
if data['time'] is not None: if data['time'] is not None:
@ -290,38 +313,35 @@ class MqttOutputPlugin(OutputPluginFactory):
self.client.publish(f'{topic}/Efficiency', data['efficiency'], self.qos, self.ret) self.client.publish(f'{topic}/Efficiency', data['efficiency'], self.qos, self.ret)
elif isinstance(response, HardwareInfoResponse): # elif isinstance(response, HardwareInfoResponse):
if data["FW_ver_maj"] is not None and data["FW_ver_min"] is not None and data["FW_ver_pat"] is not None: elif 'FW_ver_maj' in data and 'FW_ver_min' in data and 'FW_ver_pat' in data:
self.client.publish(f'{topic}/Firmware/Version',\ payload = f'{data["FW_ver_maj"]}.{data["FW_ver_min"]}.{data["FW_ver_pat"]}'
f'{data["FW_ver_maj"]}.{data["FW_ver_min"]}.{data["FW_ver_pat"]}', self.qos, self.ret) self.client.publish(f'{topic}/Firmware/Version', payload , self.qos, self.ret)
if data["FW_build_dd"] is not None and data["FW_build_mm"] is not None and data["FW_build_yy"] is not None and data["FW_build_HH"] is not None and data["FW_build_MM"] is not None: payload = f'{data["FW_build_dd"]}/{data["FW_build_mm"]}/{data["FW_build_yy"]}T{data["FW_build_HH"]}:{data["FW_build_MM"]}'
self.client.publish(f'{topic}/Firmware/Build_at',\ self.client.publish(f'{topic}/Firmware/Build_at', payload, self.qos, self.ret)
f'{data["FW_build_dd"]}/{data["FW_build_mm"]}/{data["FW_build_yy"]}T{data["FW_build_HH"]}:{data["FW_build_MM"]}',\
self.qos, self.ret)
if data["FW_HW_ID"] is not None: payload = f'{data["FW_HW_ID"]}'
self.client.publish(f'{topic}/Firmware/HWPartId',\ self.client.publish(f'{topic}/Firmware/Build_at', payload, self.qos, self.ret)
f'{data["FW_HW_ID"]}', self.qos, self.ret)
else: else:
raise ValueError('Data needs to be instance of StatusResponse or a instance of HardwareInfoResponse') raise ValueError('Data needs to be instance of StatusResponse or a instance of HardwareInfoResponse')
class VzInverterOutput: class VzInverterOutput:
def __init__(self, config, session): def __init__(self, vz_inverter_config, session):
self.session = session self.session = session
self.serial = config.get('serial') self.serial = vz_inverter_config.get('serial')
self.baseurl = config.get('url', 'http://localhost/middleware/') self.baseurl = vz_inverter_config.get('url', 'http://localhost/middleware/')
self.channels = dict() self.channels = dict()
for channel in config.get('channels', []): for channel in vz_inverter_config.get('channels', []):
uid = channel.get('uid', None)
ctype = channel.get('type') ctype = channel.get('type')
uid = channel.get('uid', None)
# if uid and ctype: # if uid and ctype:
if ctype: if ctype:
self.channels[ctype] = uid self.channels[ctype] = uid
def store_status(self, data, session): def store_status(self, data):
""" """
Publish StatusResponse object Publish StatusResponse object
@ -329,63 +349,74 @@ class VzInverterOutput:
:raises ValueError: when response is not instance of StatusResponse :raises ValueError: when response is not instance of StatusResponse
""" """
if len(self.channels) == 0: if len(self.channels) == 0:
return logging.debug('no channels configured - no data to send')
return
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']: if 'phases' in data:
self.try_publish(ts, f'ac_voltage{phase_id}', phase['voltage']) for phase in data['phases']:
self.try_publish(ts, f'ac_current{phase_id}', phase['current']) self.try_publish(ts, f'ac_voltage{phase_id}', phase['voltage'])
self.try_publish(ts, f'ac_power{phase_id}', phase['power']) self.try_publish(ts, f'ac_current{phase_id}', phase['current'])
self.try_publish(ts, f'ac_power{phase_id}', phase['power'])
self.try_publish(ts, f'ac_reactive_power{phase_id}', phase['reactive_power']) self.try_publish(ts, f'ac_reactive_power{phase_id}', phase['reactive_power'])
self.try_publish(ts, f'ac_frequency{phase_id}', phase['frequency']) self.try_publish(ts, f'ac_frequency{phase_id}', phase['frequency'])
phase_id = phase_id + 1 phase_id = phase_id + 1
# DC Data # DC Data
string_id = 0 string_id = 0
for string in data['strings']: if 'strings' in data:
self.try_publish(ts, f'dc_voltage{string_id}', string['voltage']) for string in data['strings']:
self.try_publish(ts, f'dc_current{string_id}', string['current']) self.try_publish(ts, f'dc_voltage{string_id}', string['voltage'])
self.try_publish(ts, f'dc_power{string_id}', string['power']) self.try_publish(ts, f'dc_current{string_id}', string['current'])
self.try_publish(ts, f'dc_power{string_id}', string['power'])
self.try_publish(ts, f'dc_energy_daily{string_id}', string['energy_daily']) self.try_publish(ts, f'dc_energy_daily{string_id}', string['energy_daily'])
self.try_publish(ts, f'dc_energy_total{string_id}', string['energy_total']) self.try_publish(ts, f'dc_energy_total{string_id}', string['energy_total'])
self.try_publish(ts, f'dc_irradiation{string_id}', string['irradiation']) self.try_publish(ts, f'dc_irradiation{string_id}', string['irradiation'])
string_id = string_id + 1 string_id = string_id + 1
# Global # Global
if data['event_count'] is not None: if 'event_count' in data:
self.try_publish(ts, f'event_count', data['event_count']) self.try_publish(ts, f'event_count', data['event_count'])
if data['powerfactor'] is not None: if 'powerfactor' in data:
self.try_publish(ts, f'powerfactor', data['powerfactor']) self.try_publish(ts, f'powerfactor', data['powerfactor'])
self.try_publish(ts, f'temperature', data['temperature']) if 'temperature' in data:
if data['yield_total'] is not None: self.try_publish(ts, f'temperature', data['temperature'])
if 'yield_total' in data:
self.try_publish(ts, f'yield_total', data['yield_total']) self.try_publish(ts, f'yield_total', data['yield_total'])
if data['yield_today'] is not None: if 'yield_today' in data:
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']) if 'efficiency' in data:
self.try_publish(ts, f'efficiency', data['efficiency'])
# eBZ = elektronischer Basiszähler (Stromzähler)
if '1_8_0' in data:
self.try_publish(ts, f'eBZ-import', data['1_8_0'])
if '2_8_0' in data:
self.try_publish(ts, f'eBZ-export', data['2_8_0'])
if '16_7_0' in data:
self.try_publish(ts, f'eBZ-power', data['16_7_0'])
return 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:
if HOYMILES_DEBUG_LOGGING: logging.debug(f'ctype \"{ctype}\" not found in ahoy.yml')
logging.warning(f'ctype \"{ctype}\" not found in ahoy.yml')
return return
uid = self.channels[ctype] uid = self.channels[ctype]
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: logging.debug(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: # if HOYMILES_VERBOSE_LOGGING:
logging.debug(f'VZ-url: {url}') if HOYMILES_TRANSACTION_LOGGING:
logging.info(f'VZ-url: {url}')
try: try:
r = self.session.get(url) r = self.session.get(url)
@ -400,36 +431,44 @@ class VzInverterOutput:
return return
class VolkszaehlerOutputPlugin(OutputPluginFactory): class VolkszaehlerOutputPlugin(OutputPluginFactory):
def __init__(self, config, **params): def __init__(self, vz_config, **params):
""" """
Initialize VolkszaehlerOutputPlugin Initialize VolkszaehlerOutputPlugin with VZ-config
Python Requests Module:
Make a request to a web page, and print the response text
https://requests.readthedocs.io/en/latest/user/advanced/
""" """
super().__init__(**params) super().__init__(**params)
try: try:
import requests import requests
import time
except ModuleNotFoundError: except ModuleNotFoundError:
ErrorText1 = f'Module "requests" and "time" for VolkszaehlerOutputPlugin necessary.' # ErrorText1 = f'Module "requests" and "time" for VolkszaehlerOutputPlugin necessary.'
ErrorText1 = f'Module "requests" for VolkszaehlerOutputPlugin necessary.'
ErrorText2 = f'Install module with command: python3 -m pip install requests' ErrorText2 = f'Install module with command: python3 -m pip install requests'
print(ErrorText1, ErrorText2) print(ErrorText1, ErrorText2)
logging.error(ErrorText1) logging.error(ErrorText1)
logging.error(ErrorText2) logging.error(ErrorText2)
exit(1) exit(1)
# The Session object allows you to persist certain parameters across requests.
self.session = requests.Session() self.session = requests.Session()
self.inverters = dict() self.vz_inverters = dict()
for inverterconfig in config.get('inverters', []): for inverter_in_vz_config in vz_config.get('inverters', []):
serial = inverterconfig.get('serial') url = inverter_in_vz_config.get('url')
output = VzInverterOutput(inverterconfig, self.session) serial = inverter_in_vz_config.get('serial')
self.inverters[serial] = output # create class object with parameter "inverter_in_vz_config" and "requests.Session" object
self.vz_inverters[serial] = VzInverterOutput(inverter_in_vz_config, self.session)
if HOYMILES_VERBOSE_LOGGING:
logging.info(f"Volkszaehler: init connection object to host: {url}/{serial}")
def disco(self, **params): def disco(self, **params):
self.session.close() # closing the connection self.session.close() # closing the connection
return return
def store_status(self, response, **params): def store_status(self, data, **params):
""" """
Publish StatusResponse object Publish StatusResponse object
@ -437,20 +476,44 @@ class VolkszaehlerOutputPlugin(OutputPluginFactory):
:raises ValueError: when response is not instance of StatusResponse :raises ValueError: when response is not instance of StatusResponse
""" """
# check decoder object for output if len(self.vz_inverters) == 0: # check list of inverters
if not isinstance(response, StatusResponse): logging.error('VolkszaehlerOutputPlugin:store_status: No inverters configured')
raise ValueError('Data needs to be instance of StatusResponse') return
if len(self.inverters) == 0: # prep variables for output
if 'phases' in data and 'strings' in data:
serial = data["inverter_ser"] # extract "inverter-serial-number" from "response-data"
elif 'Time' in data:
__data = dict() # create empty dict
for key in data:
if key == "Time":
__data['time'] = datetime.strptime(data[key], '%Y-%m-%dT%H:%M:%S')
elif isinstance(data[key], dict):
__data |= {'key' : key}
__data |= data[key]
if not 'key' in __data:
raise ValueError(f"no 'key' in data - no output is sent: {__data}")
return
data = __data
if HOYMILES_VERBOSE_LOGGING:
# eBZ = elektronischer Basiszähler (Stromzähler)
serial = data['96_1_0']
logging.info(f"{data['key']}: {serial}"
f" - import:{data['1_8_0']:>8} kWh"
f" - export:{data['2_8_0']:>5} kWh"
f" - power:{data['16_7_0']:>8} W")
else:
raise ValueError(f"Unknown instance type - no output is sent: {data}")
return return
data = response.__dict__() if serial in self.vz_inverters: # check, if inverter-serial-number in list of vz_inverters
serial = data["inverter_ser"] try:
if serial in self.inverters: # call method VzInverterOutput.store_status with parameter "data"
output = self.inverters[serial] self.vz_inverters[serial].store_status(data)
try: except ValueError as e:
output.store_status(data, self.session) logging.warning('Could not send data to volkszaehler instance: %s' % e)
except ValueError as e:
logging.warning('Could not send data to volkszaehler instance: %s' % e)
return

Loading…
Cancel
Save