mirror of
https://github.com/lumapu/ahoy.git
synced 2025-04-28 17:56:21 +02:00
Merge 916a63e3d4
into 5feb293c9f
This commit is contained in:
commit
1ccc6ee642
7 changed files with 699 additions and 390 deletions
|
@ -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
Normal file
37
tools/rpi/ahoy@bookworm.service
Normal file
|
@ -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
Normal file
46
tools/rpi/ahoy@bookworm_system.service
Normal file
|
@ -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
|
||||
|
|
@ -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
|
||||
|
@ -175,50 +199,54 @@ class ResponseDecoder(ResponseDecoderFactory):
|
|||
:return: payload decoder instance
|
||||
:rtype: object
|
||||
"""
|
||||
model = self.inverter_model
|
||||
command = self.request_command
|
||||
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
|
||||
|
|
|
@ -7,69 +7,68 @@ 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):
|
||||
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")}')
|
||||
""" Signal Handler
|
||||
|
||||
if mqtt_client:
|
||||
mqtt_client.disco()
|
||||
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 influx_client:
|
||||
influx_client.disco()
|
||||
if mqtt_client:
|
||||
mqtt_client.disco()
|
||||
|
||||
if volkszaehler_client:
|
||||
volkszaehler_client.disco()
|
||||
if influx_client:
|
||||
influx_client.disco()
|
||||
|
||||
sys.exit(0)
|
||||
if volkszaehler_client:
|
||||
volkszaehler_client.disco()
|
||||
|
||||
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!!
|
||||
sys.exit(0)
|
||||
|
||||
""" activate signal handler """
|
||||
signal(SIGINT, signal_handler)
|
||||
signal(SIGTERM, signal_handler)
|
||||
signal(SIGHUP, signal_handler)
|
||||
# signal(SIGKILL, signal_handler) # not used
|
||||
################################################################################
|
||||
################################################################################
|
||||
|
||||
class 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:
|
||||
|
@ -97,75 +98,88 @@ class SunsetHandler:
|
|||
time_to_sleep = int((nextSunrise - datetime.utcnow()).total_seconds())
|
||||
logging.info (f'Next sunrise is at {nextSunrise} UTC, next sunset is at {self.nextSunset} UTC, sleeping for {time_to_sleep} seconds.')
|
||||
if time_to_sleep > 0:
|
||||
time.sleep(time_to_sleep)
|
||||
logging.info (f'Woke up...')
|
||||
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
|
||||
|
||||
if self.suntimes:
|
||||
local_sunrise = self.suntimes.riselocal(datetime.now()).strftime("%d.%m.%YT%H:%M")
|
||||
local_sunset = self.suntimes.setlocal(datetime.now()).strftime("%d.%m.%YT%H:%M")
|
||||
local_zone = self.suntimes.setlocal(datetime.now()).tzinfo.key
|
||||
mqtt_client.info2mqtt({'topic' : f'{dtu_name}/{dtu_ser}'}, \
|
||||
{'dis_night_comm' : 'True', \
|
||||
'local_sunrise' : local_sunrise, \
|
||||
'local_sunset' : local_sunset,
|
||||
'local_zone' : local_zone})
|
||||
local_sunset = self.suntimes.setlocal(datetime.now()).strftime("%d.%m.%YT%H:%M")
|
||||
local_zone = self.suntimes.setlocal(datetime.now()).tzinfo.key
|
||||
|
||||
mqtt_client.info2mqtt(f'{dtu_name}/{dtu_serial}',
|
||||
{'dis_night_comm' : 'True',
|
||||
'local_sunrise' : local_sunrise,
|
||||
'local_sunset' : local_sunset,
|
||||
'local_zone' : local_zone})
|
||||
else:
|
||||
mqtt_client.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)
|
||||
|
||||
# get parameter from config-file
|
||||
inverters = [inverter for inverter in ahoy_config.get('inverters', [])
|
||||
if not inverter.get('disabled', False)]
|
||||
|
||||
# check all inverter names and serial numbers in config-file
|
||||
for inverter in inverters:
|
||||
if not 'name' in inverter:
|
||||
inverter['name'] = 'hoymiles'
|
||||
if not 'serial' in inverter:
|
||||
logging.error("No inverter serial number found in ahoy.yml - exit")
|
||||
sys.exit(999)
|
||||
|
||||
# init Sunset-Handler object
|
||||
sunset = SunsetHandler(ahoy_config.get('sunset'))
|
||||
|
||||
if not hoymiles.HOYMILES_VERBOSE_LOGGING and not hoymiles.HOYMILES_TRANSACTION_LOGGING:
|
||||
logging.info(f"MAIN LOOP starts now without any output")
|
||||
|
||||
try:
|
||||
do_init = True
|
||||
while True:
|
||||
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:
|
||||
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)
|
||||
poll_inverter(inverter, do_init, transmit_retries)
|
||||
do_init = False
|
||||
|
||||
if loop_interval > 0:
|
||||
time_to_sleep = loop_interval - (time.time() - t_loop_start)
|
||||
if time_to_sleep > 0:
|
||||
time.sleep(time_to_sleep)
|
||||
|
||||
# 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
|
||||
|
||||
|
@ -173,20 +187,34 @@ def poll_inverter(inverter, dtu_ser, do_init, retries):
|
|||
:param retries: tx retry count if no inverter contact
|
||||
:type retries: int
|
||||
"""
|
||||
inverter_ser = inverter.get('serial')
|
||||
inverter_name = inverter.get('name')
|
||||
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
|
||||
decoder = hoymiles.ResponseDecoder(response,
|
||||
# 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']:
|
||||
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 event_message_index[inv_str] < data['event_count']:
|
||||
if event_message_index[inv_str] != data['event_count']:
|
||||
event_message_index[inv_str] = data['event_count']
|
||||
if hoymiles.HOYMILES_VERBOSE_LOGGING:
|
||||
logging.info(f"event_count changed to {data['event_count']} --> AlarmData requested")
|
||||
# add AlarmData-command to queue
|
||||
command_queue[inv_str].append(hoymiles.compose_send_time_payload(hoymiles.InfoCommands.AlarmData, alarm_id=event_message_index[inv_str]))
|
||||
|
||||
# sent outputs
|
||||
if mqtt_client:
|
||||
mqtt_client.store_status(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 mqtt_client:
|
||||
mqtt_client.store_status(result, topic=inverter.get('mqtt', {}).get('topic', None))
|
||||
if hoymiles.HOYMILES_VERBOSE_LOGGING:
|
||||
logging.info(f"Firmware version {data['FW_ver_maj']}.{data['FW_ver_min']}.{data['FW_ver_pat']}, "
|
||||
f"build at {data['FW_build_dd']:>02}/{data['FW_build_mm']:>02}/{data['FW_build_yy']}T"
|
||||
f"{data['FW_build_HH']:>02}:{data['FW_build_MM']:>02}, "
|
||||
f"HW revision {data['FW_HW_ID']}")
|
||||
if mqtt_client:
|
||||
mqtt_client.store_status(data)
|
||||
|
||||
if isinstance(result, hoymiles.decoders.EventsResponse):
|
||||
if hoymiles.HOYMILES_VERBOSE_LOGGING:
|
||||
logging.info(f"EventsResponse: {data['inv_stat_txt']} ({data['inv_stat_num']})")
|
||||
|
||||
if isinstance(result, hoymiles.decoders.DebugDecodeAny):
|
||||
if hoymiles.HOYMILES_VERBOSE_LOGGING:
|
||||
logging.info(f"DebugDecodeAny: payload ({data['len_payload']} bytes): {data['payload']}")
|
||||
|
||||
def mqtt_on_message(mqtt_client, userdata, message):
|
||||
'''
|
||||
MQTT(PAHO) callcack method to handle receiving payload
|
||||
( run in thread: "paho-mqtt-client-" - important for signals and Exceptions !)
|
||||
a) when receiving topic ends with "SENSOR" for privat electricity meter
|
||||
b) when receiving topic ends with "command" for runtime faster debugging
|
||||
|
||||
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")
|
||||
|
||||
if inverter_ser:
|
||||
# handle specific payload topic
|
||||
if message.topic.endswith("SENSOR"):
|
||||
if volkszaehler_client:
|
||||
volkszaehler_client.store_status(yaml.safe_load(str(message.payload.decode("utf-8"))))
|
||||
|
||||
if message.topic.endswith("command"):
|
||||
p_message = message.payload.decode('utf-8').lower()
|
||||
|
||||
# Expand tttttttt to current time for use in hexlified payload
|
||||
expand_time = ''.join(f'{b:02x}' for b in struct.pack('>L', int(time.time())))
|
||||
p_message = p_message.replace('tttttttt', expand_time)
|
||||
logging.info (f"MQTT-command: {message.topic} - {p_message}")
|
||||
|
||||
if (len(p_message) < 2048 \
|
||||
and len(p_message) % 2 == 0 \
|
||||
and re.match(r'^[a-f0-9]+$', p_message)):
|
||||
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):
|
||||
from .outputs import MqttOutputPlugin
|
||||
mqtt_client = MqttOutputPlugin(mqtt_config)
|
||||
# create MQTT client object
|
||||
# if: mqtt-disabled is "true" - only
|
||||
# if: mqtt-disabled is "true" AND inverter-mqtt-send_raw_enabled is "true"
|
||||
# if: mqtt topic is defined - only or with other functions
|
||||
mqtt_c_obj = mqtt_client = None # create client-obj-placeholder
|
||||
mqtt_config = ahoy_config.get('mqtt', None) # get mqtt-config, if available
|
||||
|
||||
# create INFLUX - client object
|
||||
if mqtt_config and (not mqtt_config.get('disabled', False) or mqtt_topic):
|
||||
from .outputs import MqttOutputPlugin
|
||||
|
||||
# MQTT_TOPIC array should contain QOS levels as well as topic names.
|
||||
# MQTT_TOPIC = [("Server1/kpi1",0),("Server2/kpi2",0),("Server3/kpi3",0)]
|
||||
mqtt_topic = mqtt_config.get('topic', None) # get topic, if available
|
||||
mqtt_topic_array = [] # create empty array
|
||||
if mqtt_topic:
|
||||
mqtt_topic_array.append((mqtt_topic, mqtt_config.get('QoS',0)))
|
||||
|
||||
# create MQTT(PAHO) client object with own callback funtion
|
||||
mqtt_c_obj = MqttOutputPlugin(mqtt_config, mqtt_on_message)
|
||||
|
||||
if mqtt_c_obj and not mqtt_config.get('disabled', False):
|
||||
mqtt_client = mqtt_c_obj
|
||||
|
||||
# create INFLUX client object
|
||||
influx_client = None
|
||||
influx_config = ahoy_config.get('influxdb', None)
|
||||
if influx_config and not influx_config.get('disabled', False):
|
||||
|
@ -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
|
||||
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
|
||||
|
||||
# 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)
|
||||
# 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)
|
||||
|
|
|
@ -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"""
|
||||
|
@ -250,74 +248,74 @@ class EventsResponse(UnknownResponse):
|
|||
|
||||
alarm_codes = {
|
||||
# HM Error Codes
|
||||
1: 'Inverter start', # 0x01
|
||||
2: 'DTU command failed', # 0x02
|
||||
121: 'Over temperature protection', # 0x79
|
||||
1: 'Inverter start', # 0x01
|
||||
2: 'DTU command failed', # 0x02
|
||||
121: 'Over temperature protection', # 0x79
|
||||
125: 'Grid configuration parameter error', # 0x7D
|
||||
126: 'Software error code 126', # 0x7E
|
||||
127: 'Firmware error', # 0x7F
|
||||
128: 'Software error code 128', # 0x80
|
||||
129: 'Software error code 129', # 0x81
|
||||
130: 'Offline', # 0x82
|
||||
141: 'Grid overvoltage', # 0x8D
|
||||
142: 'Average grid overvoltage', # 0x8E
|
||||
143: 'Grid undervoltage', # 0x8F
|
||||
144: 'Grid overfrequency', # 0x90
|
||||
145: 'Grid underfrequency', # 0x91
|
||||
146: 'Rapid grid frequency change', # 0x92
|
||||
147: 'Power grid outage', # 0x93
|
||||
148: 'Grid disconnection', # 0x94
|
||||
149: 'Island detected', # 0x95
|
||||
205: 'Input port 1 & 2 overvoltage', # 0xCD
|
||||
206: 'Input port 3 & 4 overvoltage', # 0xCE
|
||||
207: 'Input port 1 & 2 undervoltage', # 0xCF
|
||||
208: 'Input port 3 & 4 undervoltage', # 0xD0
|
||||
209: 'Port 1 no input', # 0xD1
|
||||
210: 'Port 2 no input', # 0xD2
|
||||
211: 'Port 3 no input', # 0xD3
|
||||
212: 'Port 4 no input', # 0xD4
|
||||
213: 'PV-1 & PV-2 abnormal wiring', # 0xD5
|
||||
214: 'PV-3 & PV-4 abnormal wiring', # 0xD6
|
||||
215: 'PV-1 Input overvoltage', # 0xD7
|
||||
216: 'PV-1 Input undervoltage', # 0xD8
|
||||
217: 'PV-2 Input overvoltage', # 0xD9
|
||||
218: 'PV-2 Input undervoltage', # 0xDA
|
||||
219: 'PV-3 Input overvoltage', # 0xDB
|
||||
220: 'PV-3 Input undervoltage', # 0xDC
|
||||
221: 'PV-4 Input overvoltage', # 0xDD
|
||||
222: 'PV-4 Input undervoltage', # 0xDE
|
||||
301: 'Hardware error code 301', # 0x012D
|
||||
302: 'Hardware error code 302', # 0x012E
|
||||
303: 'Hardware error code 303', # 0x012F
|
||||
304: 'Hardware error code 304', # 0x0130
|
||||
305: 'Hardware error code 305', # 0x0131
|
||||
306: 'Hardware error code 306', # 0x0132
|
||||
307: 'Hardware error code 307', # 0x0133
|
||||
308: 'Hardware error code 308', # 0x0134
|
||||
309: 'Hardware error code 309', # 0x0135
|
||||
310: 'Hardware error code 310', # 0x0136
|
||||
311: 'Hardware error code 311', # 0x0137
|
||||
312: 'Hardware error code 312', # 0x0138
|
||||
313: 'Hardware error code 313', # 0x0139
|
||||
314: 'Hardware error code 314', # 0x013A
|
||||
126: 'Software error code 126', # 0x7E
|
||||
127: 'Firmware error', # 0x7F
|
||||
128: 'Software error code 128', # 0x80
|
||||
129: 'Software error code 129', # 0x81
|
||||
130: 'Offline', # 0x82
|
||||
141: 'Grid overvoltage', # 0x8D
|
||||
142: 'Average grid overvoltage', # 0x8E
|
||||
143: 'Grid undervoltage', # 0x8F
|
||||
144: 'Grid overfrequency', # 0x90
|
||||
145: 'Grid underfrequency', # 0x91
|
||||
146: 'Rapid grid frequency change', # 0x92
|
||||
147: 'Power grid outage', # 0x93
|
||||
148: 'Grid disconnection', # 0x94
|
||||
149: 'Island detected', # 0x95
|
||||
205: 'Input port 1 & 2 overvoltage', # 0xCD
|
||||
206: 'Input port 3 & 4 overvoltage', # 0xCE
|
||||
207: 'Input port 1 & 2 undervoltage', # 0xCF
|
||||
208: 'Input port 3 & 4 undervoltage', # 0xD0
|
||||
209: 'Port 1 no input', # 0xD1
|
||||
210: 'Port 2 no input', # 0xD2
|
||||
211: 'Port 3 no input', # 0xD3
|
||||
212: 'Port 4 no input', # 0xD4
|
||||
213: 'PV-1 & PV-2 abnormal wiring', # 0xD5
|
||||
214: 'PV-3 & PV-4 abnormal wiring', # 0xD6
|
||||
215: 'PV-1 Input overvoltage', # 0xD7
|
||||
216: 'PV-1 Input undervoltage', # 0xD8
|
||||
217: 'PV-2 Input overvoltage', # 0xD9
|
||||
218: 'PV-2 Input undervoltage', # 0xDA
|
||||
219: 'PV-3 Input overvoltage', # 0xDB
|
||||
220: 'PV-3 Input undervoltage', # 0xDC
|
||||
221: 'PV-4 Input overvoltage', # 0xDD
|
||||
222: 'PV-4 Input undervoltage', # 0xDE
|
||||
301: 'Hardware error code 301', # 0x012D
|
||||
302: 'Hardware error code 302', # 0x012E
|
||||
303: 'Hardware error code 303', # 0x012F
|
||||
304: 'Hardware error code 304', # 0x0130
|
||||
305: 'Hardware error code 305', # 0x0131
|
||||
306: 'Hardware error code 306', # 0x0132
|
||||
307: 'Hardware error code 307', # 0x0133
|
||||
308: 'Hardware error code 308', # 0x0134
|
||||
309: 'Hardware error code 309', # 0x0135
|
||||
310: 'Hardware error code 310', # 0x0136
|
||||
311: 'Hardware error code 311', # 0x0137
|
||||
312: 'Hardware error code 312', # 0x0138
|
||||
313: 'Hardware error code 313', # 0x0139
|
||||
314: 'Hardware error code 314', # 0x013A
|
||||
# MI Error Codes
|
||||
5041: 'Error code-04 Port 1', # 0x13B1
|
||||
5042: 'Error code-04 Port 2', # 0x13B2
|
||||
5043: 'Error code-04 Port 3', # 0x13B3
|
||||
5044: 'Error code-04 Port 4', # 0x13B4
|
||||
5041: 'Error code-04 Port 1', # 0x13B1
|
||||
5042: 'Error code-04 Port 2', # 0x13B2
|
||||
5043: 'Error code-04 Port 3', # 0x13B3
|
||||
5044: 'Error code-04 Port 4', # 0x13B4
|
||||
5051: 'PV Input 1 Overvoltage/Undervoltage', # 0x13BB
|
||||
5052: 'PV Input 2 Overvoltage/Undervoltage', # 0x13BC
|
||||
5053: 'PV Input 3 Overvoltage/Undervoltage', # 0x13BD
|
||||
5054: 'PV Input 4 Overvoltage/Undervoltage', # 0x13BE
|
||||
5060: 'Abnormal bias', # 0x13C4
|
||||
5070: 'Over temperature protection', # 0x13CE
|
||||
5080: 'Grid Overvoltage/Undervoltage', # 0x13D8
|
||||
5090: 'Grid Overfrequency/Underfrequency', # 0x13E2
|
||||
5100: 'Island detected', # 0x13EC
|
||||
5120: 'EEPROM reading and writing error', # 0x1400
|
||||
5150: '10 min value grid overvoltage', # 0x141E
|
||||
5200: 'Firmware error', # 0x1450
|
||||
8310: 'Shut down', # 0x2076
|
||||
5060: 'Abnormal bias', # 0x13C4
|
||||
5070: 'Over temperature protection', # 0x13CE
|
||||
5080: 'Grid Overvoltage/Undervoltage', # 0x13D8
|
||||
5090: 'Grid Overfrequency/Underfrequency', # 0x13E2
|
||||
5100: 'Island detected', # 0x13EC
|
||||
5120: 'EEPROM reading and writing error', # 0x1400
|
||||
5150: '10 min value grid overvoltage', # 0x141E
|
||||
5200: 'Firmware error', # 0x1450
|
||||
8310: 'Shut down', # 0x2076
|
||||
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.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):
|
||||
|
|
|
@ -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):
|
||||
|
@ -22,7 +22,7 @@ class OutputPluginFactory:
|
|||
: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)
|
||||
|
||||
def store_status(self, response, **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)}'
|
||||
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):
|
||||
self.session = session
|
||||
self.serial = config.get('serial')
|
||||
self.baseurl = config.get('url', 'http://localhost/middleware/')
|
||||
def __init__(self, vz_inverter_config, session):
|
||||
self.session = session
|
||||
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,63 +349,74 @@ class VzInverterOutput:
|
|||
|
||||
:raises ValueError: when response is not instance of StatusResponse
|
||||
"""
|
||||
|
||||
if len(self.channels) == 0:
|
||||
return
|
||||
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
|
||||
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'])
|
||||
self.try_publish(ts, f'ac_power{phase_id}', phase['power'])
|
||||
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'])
|
||||
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_frequency{phase_id}', phase['frequency'])
|
||||
self.try_publish(ts, f'ac_frequency{phase_id}', phase['frequency'])
|
||||
phase_id = phase_id + 1
|
||||
|
||||
# DC Data
|
||||
string_id = 0
|
||||
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'])
|
||||
self.try_publish(ts, f'dc_power{string_id}', string['power'])
|
||||
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'])
|
||||
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_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
|
||||
|
||||
# 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'])
|
||||
self.try_publish(ts, f'temperature', data['temperature'])
|
||||
if data['yield_total'] is not None:
|
||||
if 'temperature' in data:
|
||||
self.try_publish(ts, f'temperature', data['temperature'])
|
||||
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'])
|
||||
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
|
||||
|
||||
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')
|
||||
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
|
||||
|
||||
|
@ -437,20 +476,44 @@ class VolkszaehlerOutputPlugin(OutputPluginFactory):
|
|||
|
||||
:raises ValueError: when response is not instance of StatusResponse
|
||||
"""
|
||||
|
||||
if len(self.vz_inverters) == 0: # check list of inverters
|
||||
logging.error('VolkszaehlerOutputPlugin:store_status: No inverters configured')
|
||||
return
|
||||
|
||||
# check decoder object for output
|
||||
if not isinstance(response, StatusResponse):
|
||||
raise ValueError('Data needs to be instance of StatusResponse')
|
||||
# prep variables for output
|
||||
if 'phases' in data and 'strings' in data:
|
||||
serial = data["inverter_ser"] # extract "inverter-serial-number" from "response-data"
|
||||
|
||||
if len(self.inverters) == 0:
|
||||
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
|
||||
|
||||
data = response.__dict__()
|
||||
serial = data["inverter_ser"]
|
||||
if serial in self.inverters:
|
||||
output = self.inverters[serial]
|
||||
try:
|
||||
output.store_status(data, self.session)
|
||||
except ValueError as e:
|
||||
logging.warning('Could not send data to volkszaehler instance: %s' % e)
|
||||
return
|
||||
if serial in self.vz_inverters: # check, if inverter-serial-number in list of vz_inverters
|
||||
try:
|
||||
# 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)
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue