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. 93
      tools/rpi/hoymiles/__init__.py
  5. 369
      tools/rpi/hoymiles/__main__.py
  6. 20
      tools/rpi/hoymiles/decoders/__init__.py
  7. 219
      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.
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:
- `uname -a` search for aarch64
- `lsb_release -d`
- `cat /etc/debian_version`
There are 2 possible solutions to install the RF24 wrapper:
To install RF24 wrapper follow the instrauction:
**__1. Solution:__**
```code
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
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
cd pyRF24
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,
please try to reduce the speed of your radio module!
Add the following parameter to your ahoy.yml configuration file in "nrf" section:
`spispeed: 600000` (0.6 MHz)
Install pyRF24 library on Debian 12 (bookworm) 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`
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
-----------------------
Some modules are not installed by default on a RaspberryPi, therefore add them manually:
```code
pip install crcmod pyyaml paho-mqtt SunTimes
python3 -m pip install crcmod pyyaml paho-mqtt SunTimes
```
Configuration
@ -170,6 +200,12 @@ Configuration
Local settings are read from ahoy.yml
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
-----------
@ -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.
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
- `-u` enables python's unbuffered mode
- `-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
```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
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.
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.
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.
If you want to run directly at start, you have to install ahoy as a service.
Depending oni, 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.
- `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
------------------
@ -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.
Todo
----
- Ability to talk to multiple inverters
- Ability to talk to multiple inverters - implemented - please test
- MQTT gateway
- understand channel hopping
- ~~configurable polling interval~~ done: interval ist configurable in ahoy.yml
@ -267,7 +315,6 @@ Todo
- ...
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

93
tools/rpi/hoymiles/__init__.py

@ -13,33 +13,36 @@ import logging
import crcmod
from .decoders import *
from os import environ
from enum import IntEnum
try:
# OSI Layer 2 driver for nRF24L01 on Arduino & Raspberry Pi/Linux Devices
# 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
if environ.get('TERM') is not None:
print('Using python Module: RF24')
print('Using python Module: "RF24"')
except ModuleNotFoundError as e:
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:
# Repo for pyRF24 package
# 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
if environ.get('TERM') is not None:
print(f'{e} - Using python Module: pyrf24')
print(f"'pyrf24' found and used")
except ModuleNotFoundError as e:
if environ.get('TERM') is not None:
print(f'{e} - 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_crc8 = crcmod.mkCrcFun(0x101, initCrc=0, xorOut=0)
HOYMILES_TRANSACTION_LOGGING=False
HOYMILES_DEBUG_LOGGING=False
def ser_to_hm_addr(inverter_ser):
"""
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'
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:
"""
Prepare payload decoder
@ -177,48 +201,52 @@ class ResponseDecoder(ResponseDecoderFactory):
"""
model = self.inverter_model
command = self.request_command
model_desc = str(InfoCommands(int(command, 16)).name)
if HOYMILES_DEBUG_LOGGING:
if command.upper() == '00':
if HOYMILES_VERBOSE_LOGGING:
if command.upper() == "00": ## 00 - 0x00
model_desc = "Inverter Dev Inform Simple"
elif command.upper() == '01':
elif command.upper() == "01": ## 01 - 0x01
model_desc = "Firmware version / date"
elif command.upper() == '02':
elif command.upper() == "02": ## 02 - 0x02
model_desc = "Inverter generic events log"
elif command.upper() == '03': ## HardWareConfig
elif command.upper() == "03": ## 03 - 0x03
model_desc = "Hardware configuration"
elif command.upper() == '04': ## SimpleCalibrationPara
elif command.upper() == "04": ## 04 - 0x04
model_desc = "Simple Calibration Parameter"
elif command.upper() == '05': ## SystemConfigPara
model_desc = "Inverter generic SystemConfigPara"
elif command.upper() == '0B': ## 11 - RealTimeRunData_Debug
elif command.upper() == "05": ## 05 - 0x05
# model_desc = "Inverter generic SystemConfigPara"
model_desc = "SystemConfigPara create DebugDecodeAny"
elif command.upper() == "0B": ## 11 - 0x0b
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"
elif command.upper() == '0D': ## 13 - RealTimeRunData_A_Phase
elif command.upper() == "0D": ## 13 - 0x0d
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 "
elif command.upper() == '0F': ## 15 - RealTimeRunData_C_Phase
elif command.upper() == "0F": ## 15 - 0x0f
model_desc = "Real-Time Run Data C Phase "
elif command.upper() == '11': ## 17 - AlarmData
model_desc = "Inverter generic events log"
elif command.upper() == '12': ## 18 - AlarmUpdate
elif command.upper() == "11": ## 17 - 0x11
# model_desc = "Inverter generic events log"
model_desc = "AlarmData create EventsResponse"
elif command.upper() == "12": ## 18 - 0x12
model_desc = "Inverter major events log"
elif command.upper() == '13': ## 19 - RecordData
elif command.upper() == "13": ## 19 - 0x13
model_desc = "Record Data"
elif command.upper() == '14': ## 20 - InternalData
elif command.upper() == "14": ## 20 - 0x14
model_desc = "Internal Data"
elif command.upper() == '15': ## 21 - GetLossRate
elif command.upper() == "15": ## 21 - 0x15
model_desc = "Get Loss Rate"
elif command.upper() == '1E': ## 30 - GetSelfCheckState
elif command.upper() == "1E": ## 30
model_desc = "Get Self Check State"
elif command.upper() == 'FF': ## 255 - InitDataState
elif command.upper() == "FF": ##255 - 0xff
model_desc = "Initi Data State"
else:
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')
if hasattr(model_decoders, f'{model}Decode{command.upper()}'):
@ -318,8 +346,8 @@ class InverterPacketFragment:
:rtype: str
"""
size = len(self.frame)
channel = f' channel {self.ch_rx}' if self.ch_rx else ''
return f"Received {size} bytes{channel}: {hexify_payload(self.frame)}"
channel = f' channel {self.ch_rx:>02}' if self.ch_rx else ''
return f"Received {size:>02} bytes{channel}: {hexify_payload(self.frame)}"
class HoymilesNRF:
"""Hoymiles NRF24 Interface"""
@ -364,8 +392,7 @@ class HoymilesNRF:
self.next_tx_channel()
if HOYMILES_TRANSACTION_LOGGING:
c_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
logging.debug(f'{c_datetime} Transmit {len(packet)} bytes channel {self.tx_channel}: {hexify_payload(packet)}')
logging.debug(f'Transmit {len(packet):>02} bytes channel {self.tx_channel:>02}: {hexify_payload(packet)}')
if not txpower:
txpower = self.txpower

369
tools/rpi/hoymiles/__main__.py

@ -7,29 +7,40 @@ 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 re
import argparse
import yaml
from yaml.loader import SafeLoader
import hoymiles
import logging
from logging.handlers import RotatingFileHandler
import time
from suntimes import SunTimes
from datetime import datetime, timedelta
import hoymiles # import paket on this place, call once: "hoymiles/__init__.py"
################################################################################
""" 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 *
from signal import signal, Signals, SIGINT, SIGTERM, SIGHUP
from os import environ
def signal_handler(sig_num, frame):
""" Signal Handler
param: signal number [signal-name]
param: frame
"""
signame = Signals(sig_num).name
logging.info(f'Stop by Signal {signame} ({sig_num})')
print (f'Stop by Signal <{signame}> ({sig_num}) at: {time.strftime("%d.%m.%Y %H:%M:%S")}')
logging.info(f'Stop by Signal <{signame}> ({sig_num})')
if environ.get('TERM') is not None:
print (f'\nStop by Signal <{signame}> ({sig_num}) '
f'at: {time.strftime("%d.%m.%Y %H:%M:%S")}\n')
if mqtt_client:
mqtt_client.disco()
@ -42,34 +53,22 @@ def signal_handler(sig_num, frame):
sys.exit(0)
signal(SIGINT, signal_handler) # Interrupt from keyboard (CTRL + C)
signal(SIGTERM, signal_handler) # Signal Handler from terminating processes
signal(SIGHUP, signal_handler) # Hangup detected on controlling terminal or death of controlling process
# signal(SIGKILL, signal_handler) # Signal Handler SIGKILL and SIGSTOP cannot be caught, blocked, or ignored!!
""" activate signal handler """
signal(SIGINT, signal_handler)
signal(SIGTERM, signal_handler)
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:
""" Sunset class
to recognize the times of sunrise, sunset and to sleep at night time
:param str inverter: inverter serial
:param retries: tx retry count if no inverter contact
:type retries: int
"""
def __init__(self, sunset_config):
self.suntimes = None
if sunset_config and sunset_config.get('disabled', True) == False:
@ -78,9 +77,11 @@ class SunsetHandler:
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')
logging.info (f'Sunset today at: {self.nextSunset} UTC')
# send info to mqtt, if broker configured
self.sun_status2mqtt()
else:
logging.info('Sunset disabled.')
logging.info('Sunset disabled!')
def checkWaitForSunrise(self):
if not self.suntimes:
@ -100,7 +101,8 @@ class SunsetHandler:
time.sleep(time_to_sleep)
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:
return
@ -108,64 +110,76 @@ class SunsetHandler:
local_sunrise = self.suntimes.riselocal(datetime.now()).strftime("%d.%m.%YT%H:%M")
local_sunset = self.suntimes.setlocal(datetime.now()).strftime("%d.%m.%YT%H:%M")
local_zone = self.suntimes.setlocal(datetime.now()).tzinfo.key
mqtt_client.info2mqtt({'topic' : f'{dtu_name}/{dtu_ser}'}, \
{'dis_night_comm' : 'True', \
'local_sunrise' : local_sunrise, \
mqtt_client.info2mqtt(f'{dtu_name}/{dtu_serial}',
{'dis_night_comm' : 'True',
'local_sunrise' : local_sunrise,
'local_sunset' : local_sunset,
'local_zone' : local_zone})
else:
mqtt_client.sun_info2mqtt({'sun_topic': f'{dtu_name}/{dtu_ser}'}, \
{'dis_night_comm': 'False'})
mqtt_client.info2mqtt(f'{dtu_name}/{dtu_serial}', {'dis_night_comm': 'False'})
def main_loop(ahoy_config):
"""Main loop"""
inverters = [
inverter for inverter in ahoy_config.get('inverters', [])
if not inverter.get('disabled', False)]
""" Main loop """
# check 'interval' parameter in config-file
loop_interval = ahoy_config.get('interval', 15)
logging.info(f"AHOY-MAIN: loop interval : {loop_interval} sec.")
if (loop_interval <= 0):
logging.critical("Parameter 'loop_interval' must grater 0 - please check ahoy.yml.")
# print console message too
print("Parameter 'loop_interval' must be >0 - please check ahoy.yml - STOP(0)")
sys.exit(0)
sunset = SunsetHandler(ahoy_config.get('sunset'))
dtu_ser = ahoy_config.get('dtu', {}).get('serial', None)
dtu_name = ahoy_config.get('dtu', {}).get('name', 'hoymiles-dtu')
sunset.sun_status2mqtt(dtu_ser, dtu_name)
loop_interval = ahoy_config.get('interval', 1)
# check 'transmit_retries' parameter in config-file
transmit_retries = ahoy_config.get('transmit_retries', 5)
if (transmit_retries <= 0):
logging.critical('Parameter "transmit_retries" must be >0 - please check ahoy.yml.')
# print message to console too
print('Parameter "transmit_retries" must be >0 - please check ahoy.yml - STOP(0)x')
logging.critical("Parameter 'transmit_retries' must grater 0 - please check ahoy.yml.")
# print console message too
print("Parameter 'transmit_retries' must be >0 - please check ahoy.yml - STOP(0)")
sys.exit(0)
try:
do_init = True
while True:
sunset.checkWaitForSunrise()
t_loop_start = time.time()
# 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)
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)
# init Sunset-Handler object
sunset = SunsetHandler(ahoy_config.get('sunset'))
if not hoymiles.HOYMILES_VERBOSE_LOGGING and not hoymiles.HOYMILES_TRANSACTION_LOGGING:
logging.info(f"MAIN LOOP starts now without any output")
try:
do_init = True
while True: # MAIN endless LOOP
# check sunrise and sunset times and sleep in night time
sunset.checkWaitForSunrise()
t_loop_start = time.time()
for inverter in inverters:
poll_inverter(inverter, do_init, transmit_retries)
do_init = False
if loop_interval > 0:
# calc time to pause main-loop
time_to_sleep = loop_interval - (time.time() - t_loop_start)
if time_to_sleep > 0:
if hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info(f'MAIN-LOOP: sleep for {time_to_sleep} sec.')
time.sleep(time_to_sleep)
except Exception as e:
logging.fatal('Exception catched: %s' % e)
logging.fatal(traceback.print_exc())
raise
def poll_inverter(inverter, dtu_ser, do_init, retries):
def poll_inverter(inverter, do_init, retries):
"""
Send/Receive command_queue, initiate status poll on inverter
@ -176,17 +190,31 @@ def poll_inverter(inverter, dtu_ser, do_init, retries):
inverter_ser = inverter.get('serial')
inverter_name = inverter.get('name')
inverter_strings = inverter.get('strings')
inv_str = str(inverter_ser)
# Queue at least status data request
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))
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.InverterDevInform_Simple)) # 00
command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.InverterDevInform_All)) # 01
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.GridOnProFilePara)) # 02
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.HardWareConfig)) # 03
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.SimpleCalibrationPara)) # 04
##command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.SystemConfigPara)) # 05
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.RealTimeRunData_Reality)) # 0c
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.AlarmData)) # 11
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.AlarmUpdate)) # 12
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.RecordData)) # 13
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.InternalData)) # 14
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.GetLossRate)) # 15
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.GetSelfCheckState)) # 1E
# command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.InitDataState)) # FF
command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.RealTimeRunData_Debug)) # 0b
# Put all queued commands for current inverter on air
while len(command_queue[inv_str]) > 0:
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
payload_ttl = retries
@ -196,12 +224,12 @@ def poll_inverter(inverter, dtu_ser, do_init, retries):
com = hoymiles.InverterTransaction(
radio=hmradio,
txpower=inverter.get('txpower', None),
dtu_ser=dtu_ser,
dtu_ser=dtu_serial,
inverter_ser=inverter_ser,
request=next(hoymiles.compose_esb_packet(
payload,
seq=b'\x80',
src=dtu_ser,
src=dtu_serial,
dst=inverter_ser
)))
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}')
pass
# Handle the response data if any
# Handle response data, if any
if response:
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,
request=com.request,
inverter_ser=inverter_ser,
inverter_name=inverter_name,
dtu_ser=dtu_ser,
strings=inverter_strings
)
# get decoder object
result = decoder.decode()
if hoymiles.HOYMILES_DEBUG_LOGGING:
logging.info(f'Decoded: {result.__dict__()}')
result = decoder.decode() # call decoder object
data = result.__dict__() # convert result into python-dict
if hoymiles.HOYMILES_TRANSACTION_LOGGING:
logging.debug(f'Decoded: {data}')
# check decoder object for output
# check result object for output
if isinstance(result, hoymiles.decoders.StatusResponse):
if hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info(f"StatusResponse: payload contains {len(data)} elements "
f"(power={data['phases'][0]['power']} W - event_count={data['event_count']})")
data = result.__dict__()
# when 'event_count' is changed, add AlarmData-command to queue
if data is not None and 'event_count' in data:
if event_message_index[inv_str] < data['event_count']:
# if event_message_index[inv_str] < data['event_count']:
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 hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info(f"event_count changed to {data['event_count']} --> AlarmData requested")
# add AlarmData-command to queue
command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.AlarmData, alarm_id=event_message_index[inv_str]))
# sent outputs
if mqtt_client:
mqtt_client.store_status(result, topic=inverter.get('mqtt', {}).get('topic', None))
mqtt_client.store_status(data)
if influx_client:
influx_client.store_status(result)
influx_client.store_status(data)
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 hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info(f"Firmware version {data['FW_ver_maj']}.{data['FW_ver_min']}.{data['FW_ver_pat']}, "
f"build at {data['FW_build_dd']:>02}/{data['FW_build_mm']:>02}/{data['FW_build_yy']}T"
f"{data['FW_build_HH']:>02}:{data['FW_build_MM']:>02}, "
f"HW revision {data['FW_HW_ID']}")
if mqtt_client:
mqtt_client.store_status(result, topic=inverter.get('mqtt', {}).get('topic', None))
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
hoymiles/{inverter_ser}/command
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 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}')
'''
# print(f"msg-topic: {message.topic} - QoS: {message.qos}")
# print(f"payload: ",str(message.payload.decode("utf-8")), "\n")
# handle specific payload topic
if message.topic.endswith("SENSOR"):
if volkszaehler_client:
volkszaehler_client.store_status(yaml.safe_load(str(message.payload.decode("utf-8"))))
if inverter_ser:
if message.topic.endswith("command"):
p_message = message.payload.decode('utf-8').lower()
# Expand tttttttt to current time for use in hexlified payload
expand_time = ''.join(f'{b:02x}' for b in struct.pack('>L', int(time.time())))
p_message = p_message.replace('tttttttt', expand_time)
logging.info (f"MQTT-command: {message.topic} - {p_message}")
if (len(p_message) < 2048 \
and len(p_message) % 2 == 0 \
and re.match(r'^[a-f0-9]+$', p_message)):
if (len(p_message) < 2048 and len(p_message) % 2 == 0 and re.match(r'^[a-f0-9]+$', p_message)):
payload = bytes.fromhex(p_message)
if hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info (f"MQTT-command for {inv_str}: {payload}")
# commands must start with \x80
if payload[0] == 0x80:
command_queue[str(inverter_ser)].append(
hoymiles.frame_payload(payload[1:]))
# array "command_queue[inv_str]" will be shared to an other thread --> critical section
command_queue[inv_str].append(hoymiles.frame_payload(payload[1:]))
else:
logging.info (f"MQTT-command: must start with \x80: {payload}")
else:
logging.info (f"MQTT-command to long (max length: 2048 bytes) - or contains non hex char")
def init_logging(ahoy_config):
""" init and prepare logging """
log_config = ahoy_config.get('logging')
fn = 'hoymiles.log'
lvl = logging.ERROR
max_log_filesize = 1000000
max_log_files = 1
if log_config:
fn = log_config.get('filename', fn)
level = log_config.get('level', 'ERROR')
@ -322,15 +384,23 @@ def init_logging(ahoy_config):
lvl = logging.FATAL
max_log_filesize = log_config.get('max_log_filesize', max_log_filesize)
max_log_files = log_config.get('max_log_files', max_log_files)
if hoymiles.HOYMILES_TRANSACTION_LOGGING:
# define log switches
if global_config.log_transactions:
hoymiles.HOYMILES_TRANSACTION_LOGGING = True
lvl = logging.DEBUG
if global_config.verbose:
hoymiles.HOYMILES_VERBOSE_LOGGING = True
# start configured logging
logging.basicConfig(handlers=[RotatingFileHandler(fn, maxBytes=max_log_filesize, backupCount=max_log_files)],
format='%(asctime)s %(levelname)s: %(message)s',
datefmt='%Y-%m-%d %H:%M:%S.%s', level=lvl)
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__':
# read commandline parameter
parser = argparse.ArgumentParser(description='Ahoy - Hoymiles solar inverter gateway', prog="hoymiles")
parser.add_argument("-c", "--config-file", nargs="?", required=True,
help="configuration file")
@ -340,7 +410,7 @@ if __name__ == '__main__':
help="Enable detailed debug output (loglevel must be DEBUG)")
global_config = parser.parse_args()
# Load ahoy.yml config file
# Load config file given in commandline parameter
try:
if isinstance(global_config.config_file, str):
with open(global_config.config_file, 'r') as fh_yaml:
@ -355,27 +425,44 @@ if __name__ == '__main__':
logging.error(f'Failed to load config file {global_config.config_file}: {e_yaml}')
sys.exit(1)
if global_config.log_transactions:
hoymiles.HOYMILES_TRANSACTION_LOGGING=True
if global_config.verbose:
hoymiles.HOYMILES_DEBUG_LOGGING=True
# read AHOY configuration file and prepare logging
# read all parameter from configuration file as 'ahoy_config'
ahoy_config = dict(cfg.get('ahoy', {}))
# extract 'DTU' parameter
dtu_serial = ahoy_config.get('dtu', {}).get('serial', None)
dtu_name = ahoy_config.get('dtu', {}).get('name', 'hoymiles-dtu')
# init and prepare logging
init_logging(ahoy_config)
# Prepare for multiple transceivers, makes them configurable
# Prepare for multiple transceivers (radio modules), makes them configurable
for radio_config in ahoy_config.get('nrf', [{}]):
hmradio = hoymiles.HoymilesNRF(**radio_config)
# create MQTT - client object
mqtt_client = None
mqtt_config = ahoy_config.get('mqtt', None)
if mqtt_config and not mqtt_config.get('disabled', False):
# create MQTT client object
# if: mqtt-disabled is "true" - only
# if: mqtt-disabled is "true" AND inverter-mqtt-send_raw_enabled is "true"
# if: mqtt topic is defined - only or with other functions
mqtt_c_obj = mqtt_client = None # create client-obj-placeholder
mqtt_config = ahoy_config.get('mqtt', None) # get mqtt-config, if available
if mqtt_config and (not mqtt_config.get('disabled', False) or mqtt_topic):
from .outputs import MqttOutputPlugin
mqtt_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_config = ahoy_config.get('influxdb', None)
if influx_config and not influx_config.get('disabled', False):
@ -387,31 +474,35 @@ if __name__ == '__main__':
bucket=influx_config.get('bucket', None),
measurement=influx_config.get('measurement', 'hoymiles'))
# create VOLKSZAEHLER - client object
# create VOLKSZAEHLER client object
volkszaehler_client = None
volkszaehler_config = ahoy_config.get('volkszaehler', {})
if volkszaehler_config and not volkszaehler_config.get('disabled', False):
from .outputs import VolkszaehlerOutputPlugin
volkszaehler_client = VolkszaehlerOutputPlugin(volkszaehler_config)
# init important runtime variables
event_message_index = {}
command_queue = {}
mqtt_command_topic_subs = []
for g_inverter in ahoy_config.get('inverters', []):
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.client.subscribe(topic_item[1])
mqtt_command_topic_subs.append(topic_item)
for g_inverter in ahoy_config.get('inverters', []): # loop inverters in ahoy_config
inv_str = str(g_inverter.get('serial')) # inverter serial number as index
command_queue[inv_str] = [] # create empty command-queue
event_message_index[inv_str] = 0 # init event-queue with value=0
# if send_raw_enabled, add topic to subscribe command-queue
if mqtt_c_obj and g_inverter.get('mqtt', {}).get('send_raw_enabled', False):
mqtt_topic_array.append(
(g_inverter.get('mqtt', {}).get('topic', f'hoymiles/{inv_str}') + '/command',
mqtt_config.get('QoS',0)
))
# start subscribe mqtt broker, if requested 'topic' is available
if mqtt_c_obj and len(mqtt_topic_array) > 0:
if hoymiles.HOYMILES_VERBOSE_LOGGING:
logging.info(f'MQTT: subscribe for topic: {mqtt_topic_array}')
mqtt_c_obj.client.subscribe(mqtt_topic_array)
# start main-loop
main_loop(ahoy_config)

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

@ -64,7 +64,6 @@ class Response:
""" All Response Shared methods """
inverter_ser = None
inverter_name = None
dtu_ser = None
response = None
def __init__(self, *args, **params):
@ -73,7 +72,6 @@ class Response:
"""
self.inverter_ser = params.get('inverter_ser', None)
self.inverter_name = params.get('inverter_name', None)
self.dtu_ser = params.get('dtu_ser', None)
self.response = args[0]
strings = params.get('strings', None)
@ -89,7 +87,7 @@ class Response:
return {
'inverter_ser': self.inverter_ser,
'inverter_name': self.inverter_name,
'dtu_ser': self.dtu_ser}
}
class StatusResponse(Response):
"""Inverter StatusResponse object"""
@ -331,7 +329,6 @@ class EventsResponse(UnknownResponse):
self.status = struct.unpack('>H', self.response[:2])[0]
self.a_text = self.alarm_codes.get(self.status, 'N/A')
logging.info (f'Inverter status: {self.a_text} ({self.status})')
chunk_size = 12
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}')
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_maj = int((fw_version / 10000))
@ -397,9 +394,6 @@ class HardwareInfoResponse(UnknownResponse):
fw_build_dd = int(fw_build_mmdd % 100)
fw_build_HH = 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_min'] = fw_version_min
@ -428,9 +422,6 @@ class DebugDecodeAny(UnknownResponse):
logging.debug(' payload has valid modbus crc')
self.response = self.response[:-2]
l_payload = len(self.response)
logging.debug(f' payload has {l_payload} bytes')
logging.debug('')
logging.debug('Field view: int')
print_table_unpack('>B', self.response)
@ -455,6 +446,13 @@ class DebugDecodeAny(UnknownResponse):
except UnicodeDecodeError:
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
class Hm300Decode01(HardwareInfoResponse):

219
tools/rpi/hoymiles/outputs.py

@ -9,7 +9,7 @@ import socket
import logging
from datetime import datetime, timezone
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:
def __init__(self, **params):
@ -64,7 +64,7 @@ class InfluxOutputPlugin(OutputPluginFactory):
print(ErrorText1, ErrorText2)
logging.error(ErrorText1)
logging.error(ErrorText2)
exit()
exit(1)
self._bucket = params.get('bucket', 'hoymiles/autogen')
self._org = params.get('org', '')
@ -72,12 +72,15 @@ class InfluxOutputPlugin(OutputPluginFactory):
with InfluxDBClient(url, token, bucket=self._bucket) as self.client:
self.api = self.client.write_api()
if HOYMILES_VERBOSE_LOGGING:
logging.info(f"Influx: connect to DB {url} initialized")
def disco(self, **params):
self.client.close() # Shutdown the client
return
def store_status(self, response, **params):
# def store_status(self, response, **params):
def store_status(self, data, **params):
"""
Publish StatusResponse object
@ -89,10 +92,12 @@ class InfluxOutputPlugin(OutputPluginFactory):
:raises ValueError: when response is not instance of StatusResponse
"""
if not isinstance(response, StatusResponse):
raise ValueError('Data needs to be instance of StatusResponse')
# if not isinstance(response, 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"]}'
@ -108,7 +113,7 @@ class InfluxOutputPlugin(OutputPluginFactory):
# InfluxDB requires nanoseconds
ctime = int(utctime.timestamp() * 1e9)
if HOYMILES_DEBUG_LOGGING:
if HOYMILES_VERBOSE_LOGGING:
logging.info(f'InfluxDB: utctime: {utctime}')
# 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=Efficiency value={data["efficiency"]:.2f} {ctime}')
if HOYMILES_DEBUG_LOGGING:
#logging.debug(f'INFLUX data to DB: {data_stack}')
if HOYMILES_VERBOSE_LOGGING:
logging.debug(f'INFLUX data to DB: {data_stack}')
pass
self.api.write(self._bucket, self._org, data_stack)
@ -153,7 +158,7 @@ class MqttOutputPlugin(OutputPluginFactory):
""" Mqtt output plugin """
client = None
def __init__(self, config, **params):
def __init__(self, config, cb_message, **params):
"""
Initialize MqttOutputPlugin
@ -177,34 +182,51 @@ class MqttOutputPlugin(OutputPluginFactory):
super().__init__(**params)
try:
import paho.mqtt.client
import paho.mqtt.client as mqtt
except ModuleNotFoundError:
ErrorText1 = f'Module "paho.mqtt.client" for MQTT-output necessary.'
ErrorText2 = f'Install module with command: python3 -m pip install paho-mqtt'
print(ErrorText1, ErrorText2)
logging.error(ErrorText1)
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):
mqtt_client.tls_set()
mqtt_client.tls_insecure_set(config.get('insecureTLS',False))
mqtt_client.username_pw_set(config.get('user', None), config.get('password', None))
self.client.tls_set()
self.client.tls_insecure_set(config.get('insecureTLS',False))
self.client.username_pw_set(config.get('user', None), config.get('password', None))
last_will = config.get('last_will', None)
if last_will:
lw_topic = last_will.get('topic', 'last will hoymiles')
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))
mqtt_client.loop_start()
self.client.connect(config.get('host', '127.0.0.1'), config.get('port', 1883))
self.client.loop_start()
self.client = mqtt_client
self.qos = config.get('QoS', 0) # Quality of Service
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):
self.client.loop_stop() # Stop loop
self.client.disconnect() # disconnect
@ -212,10 +234,11 @@ class MqttOutputPlugin(OutputPluginFactory):
def info2mqtt(self, mqtt_topic, mqtt_data):
for mqtt_key in mqtt_data:
self.client.publish(f'{mqtt_topic["topic"]}/{mqtt_key}', mqtt_data[mqtt_key], self.qos, self.ret)
self.client.publish(f'{mqtt_topic}/{mqtt_key}', mqtt_data[mqtt_key], self.qos, self.ret)
return
def store_status(self, response, **params):
# def store_status(self, response, **params):
def store_status(self, data, **params):
"""
Publish StatusResponse object
@ -226,20 +249,20 @@ class MqttOutputPlugin(OutputPluginFactory):
: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:
logging.warn("received data object is empty")
logging.warn("OUTPUT-MQTT: received data object is empty")
return
topic = params.get('topic', None)
if not topic:
topic = f'{data.get("inverter_name", "hoymiles")}/{data.get("inverter_ser", None)}'
if HOYMILES_DEBUG_LOGGING:
logging.info(f'MQTT-topic: {topic} data-type: {type(response)}')
if HOYMILES_TRANSACTION_LOGGING:
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
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)
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:
self.client.publish(f'{topic}/Firmware/Version',\
f'{data["FW_ver_maj"]}.{data["FW_ver_min"]}.{data["FW_ver_pat"]}', self.qos, self.ret)
# elif isinstance(response, HardwareInfoResponse):
elif 'FW_ver_maj' in data and 'FW_ver_min' in data and 'FW_ver_pat' in data:
payload = f'{data["FW_ver_maj"]}.{data["FW_ver_min"]}.{data["FW_ver_pat"]}'
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:
self.client.publish(f'{topic}/Firmware/Build_at',\
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)
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', payload, self.qos, self.ret)
if data["FW_HW_ID"] is not None:
self.client.publish(f'{topic}/Firmware/HWPartId',\
f'{data["FW_HW_ID"]}', self.qos, self.ret)
payload = f'{data["FW_HW_ID"]}'
self.client.publish(f'{topic}/Firmware/Build_at', payload, self.qos, self.ret)
else:
raise ValueError('Data needs to be instance of StatusResponse or a instance of HardwareInfoResponse')
class VzInverterOutput:
def __init__(self, config, session):
def __init__(self, vz_inverter_config, session):
self.session = session
self.serial = config.get('serial')
self.baseurl = config.get('url', 'http://localhost/middleware/')
self.serial = vz_inverter_config.get('serial')
self.baseurl = vz_inverter_config.get('url', 'http://localhost/middleware/')
self.channels = dict()
for channel in config.get('channels', []):
uid = channel.get('uid', None)
for channel in vz_inverter_config.get('channels', []):
ctype = channel.get('type')
uid = channel.get('uid', None)
# if uid and ctype:
if ctype:
self.channels[ctype] = uid
def store_status(self, data, session):
def store_status(self, data):
"""
Publish StatusResponse object
@ -329,16 +349,16 @@ class VzInverterOutput:
:raises ValueError: when response is not instance of StatusResponse
"""
if len(self.channels) == 0:
logging.debug('no channels configured - no data to send')
return
ts = int(round(data['time'].timestamp() * 1000))
if HOYMILES_DEBUG_LOGGING:
logging.info(f'Volkszaehler-Timestamp: {ts}')
# AC Data
phase_id = 0
if 'phases' in data:
for phase in data['phases']:
self.try_publish(ts, f'ac_voltage{phase_id}', phase['voltage'])
self.try_publish(ts, f'ac_current{phase_id}', phase['current'])
@ -349,6 +369,7 @@ class VzInverterOutput:
# DC Data
string_id = 0
if 'strings' in data:
for string in data['strings']:
self.try_publish(ts, f'dc_voltage{string_id}', string['voltage'])
self.try_publish(ts, f'dc_current{string_id}', string['current'])
@ -359,33 +380,43 @@ class VzInverterOutput:
string_id = string_id + 1
# Global
if data['event_count'] is not None:
if 'event_count' in data:
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'])
if 'temperature' in data:
self.try_publish(ts, f'temperature', data['temperature'])
if data['yield_total'] is not None:
if 'yield_total' in data:
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'])
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
def try_publish(self, ts, ctype, value):
if not ctype in self.channels:
if HOYMILES_DEBUG_LOGGING:
logging.warning(f'ctype \"{ctype}\" not found in ahoy.yml')
logging.debug(f'ctype \"{ctype}\" not found in ahoy.yml')
return
uid = self.channels[ctype]
url = f'{self.baseurl}/data/{uid}.json?operation=add&ts={ts}&value={value}'
if uid == None:
if HOYMILES_DEBUG_LOGGING:
logging.debug(f'ctype \"{ctype}\" has no configured uid-value in ahoy.yml')
return
if HOYMILES_DEBUG_LOGGING:
logging.debug(f'VZ-url: {url}')
# if HOYMILES_VERBOSE_LOGGING:
if HOYMILES_TRANSACTION_LOGGING:
logging.info(f'VZ-url: {url}')
try:
r = self.session.get(url)
@ -400,36 +431,44 @@ class VzInverterOutput:
return
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)
try:
import requests
import time
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'
print(ErrorText1, ErrorText2)
logging.error(ErrorText1)
logging.error(ErrorText2)
exit(1)
# The Session object allows you to persist certain parameters across requests.
self.session = requests.Session()
self.inverters = dict()
for inverterconfig in config.get('inverters', []):
serial = inverterconfig.get('serial')
output = VzInverterOutput(inverterconfig, self.session)
self.inverters[serial] = output
self.vz_inverters = dict()
for inverter_in_vz_config in vz_config.get('inverters', []):
url = inverter_in_vz_config.get('url')
serial = inverter_in_vz_config.get('serial')
# 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):
self.session.close() # closing the connection
return
def store_status(self, response, **params):
def store_status(self, data, **params):
"""
Publish StatusResponse object
@ -438,19 +477,43 @@ class VolkszaehlerOutputPlugin(OutputPluginFactory):
:raises ValueError: when response is not instance of StatusResponse
"""
# check decoder object for output
if not isinstance(response, StatusResponse):
raise ValueError('Data needs to be instance of StatusResponse')
if len(self.vz_inverters) == 0: # check list of inverters
logging.error('VolkszaehlerOutputPlugin:store_status: No inverters configured')
return
# 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
if len(self.inverters) == 0:
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
data = response.__dict__()
serial = data["inverter_ser"]
if serial in self.inverters:
output = self.inverters[serial]
if serial in self.vz_inverters: # check, if inverter-serial-number in list of vz_inverters
try:
output.store_status(data, self.session)
# call method VzInverterOutput.store_status with parameter "data"
self.vz_inverters[serial].store_status(data)
except ValueError as e:
logging.warning('Could not send data to volkszaehler instance: %s' % e)
return

Loading…
Cancel
Save