Complete rewrite, uses Fronius Push API now

This commit contains a complete rewrite
- HTTP push API server based on bottle
- no more dependency to pyfronius
This commit is contained in:
Gerrit Beine 2023-07-27 17:27:53 +02:00
parent f486503b1e
commit b4a833601f
26 changed files with 344 additions and 349 deletions

8
.gitignore vendored
View file

@ -1,10 +1,6 @@
__pycache__
bin
include
lib
share
fronius2mqtt.bbprojectd
pip-selfcheck.json
pyvenv.cfg
pyfronius
fronius2mqtt/fronius2mqtt.egg-info
fronius2mqtt.yaml
.DS_Store

8
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

14
.idea/fronius2mqtt.iml Normal file
View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="PYTHON_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/venv" />
</content>
<orderEntry type="jdk" jdkName="Python 3.9 (fronius2mqtt)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">
<option name="format" value="PLAIN" />
<option name="myDocStringFormat" value="Plain" />
</component>
</module>

View file

@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<settings>
<option name="USE_PROJECT_PROFILE" value="false" />
<version value="1.0" />
</settings>
</component>

4
.idea/misc.xml Normal file
View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.9 (fronius2mqtt)" project-jdk-type="Python SDK" />
</project>

8
.idea/modules.xml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/fronius2mqtt.iml" filepath="$PROJECT_DIR$/.idea/fronius2mqtt.iml" />
</modules>
</component>
</project>

6
.idea/vcs.xml Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View file

@ -1,6 +1,13 @@
# fronius2mqtt
A Fronius HTTP API to MQTT bridge
Attention: This is a complete rewrite of the bridge.
While the old fronius2mqtt brigde used polling the inverters to fetch the data, the new version make use of the push feature provided by Fronius Symo.
The daemon offers no HTTP endpoints which can be configured in the configuration interface of each inverter.
It forwards the data directly to MQTT.
## Install
- clone the git repository
@ -9,5 +16,21 @@ A Fronius HTTP API to MQTT bridge
## Configuration
- copy ```fronius2mqtt.yaml.example```
Each configuration option is also available as command line argument.
- copy ```fronius2mqtt.conf.example```
- configure as you like
| option | default | arguments | comment |
|----------------|--------------------------|---------------------|--------------------------------------------------------------------|
| mqtt_host | 'localhost' | -m, --mqtt_host | The hostname of the MQTT server. |
| mqtt_port | 1883 | --mqtt_port | The port of the MQTT server. |
| mqtt_keepalive | 30 | --mqtt_keepalive | The keep alive interval for the MQTT server connection in seconds. |
| mqtt_clientid | 'fronius2mqtt' | --mqtt_clientid | The clientid to send to the MQTT server. |
| mqtt_user | - | -u, --mqtt_user | The username for the MQTT server connection. |
| mqtt_password | - | -p, --mqtt_password | The password for the MQTT server connection. |
| mqtt_topic | 'fronius' | -t, --mqtt_topic | The topic to publish MQTT message. |
| http_host | 'localhost' | --http_host | The address of the HTTP server. |
| http_port | 8080 | --http_port | The port of the HTTP server. |
| verbose | - | -v, --verbose | Be verbose while running. |
| - | '/etc/fronius2mqtt.conf' | -c, --config | The path to the config file. |

View file

@ -1,13 +0,0 @@
#!/usr/bin/env python3
from fronius2mqtt import config
from fronius2mqtt import daemon
def main():
cfg = config.Config()
cfg.read()
d = daemon.Daemon(cfg)
d.run()
main()

260
fronius2mqtt Executable file
View file

@ -0,0 +1,260 @@
#!/usr/bin/env python
import argparse
import json
import os
import paho.mqtt.client as mqtt
from bottle import request, route, post, run
mqtt_client = None
daemon_args = None
def extract_request_body():
body = request.body
string = body.getvalue().decode('utf-8')
return string
def extract_request_data():
json_string = extract_request_body()
data = json.loads(json_string)
return data
@route('/')
def index():
return "Hello World!"
@post('/current_data_inverter/<device>')
def current_data_inverter(device):
data = extract_request_data()
if not 'Body' in data:
return "Empty"
topic_base = "{}/{}/current_data_inverter".format(daemon_args.mqtt_topic, device)
if 'PAC' in data['Body'] and 'Values' in data['Body']['PAC']:
for k, v in data['Body']['PAC']['Values'].items():
topic = "{}/pac/{}".format(topic_base, k)
mqtt_client.publish(topic, v)
if 'DAY_ENERGY' in data['Body'] and 'Values' in data['Body']['DAY_ENERGY']:
for k, v in data['Body']['DAY_ENERGY']['Values'].items():
topic = "{}/day_energy/{}".format(topic_base, k)
mqtt_client.publish(topic, v)
if 'YEAR_ENERGY' in data['Body'] and 'Values' in data['Body']['YEAR_ENERGY']:
for k, v in data['Body']['YEAR_ENERGY']['Values'].items():
topic = "{}/year_energy/{}".format(topic_base, k)
mqtt_client.publish(topic, v)
if 'TOTAL_ENERGY' in data['Body'] and 'Values' in data['Body']['TOTAL_ENERGY']:
for k, v in data['Body']['TOTAL_ENERGY']['Values'].items():
topic = "{}/total_energy/{}".format(topic_base, k)
mqtt_client.publish(topic, v)
return "OK"
@post('/current_data_meter/<device>')
def current_data_meter(device):
data = extract_request_data()
if not 'Body' in data:
return "Empty"
topic_base = "{}/{}/current_data_meter".format(daemon_args.mqtt_topic, device)
for m, d in data['Body'].items():
for k, v in d.items():
if v is not None and type(v) in [int, float, str] :
topic = "{}/{}/{}".format(topic_base, m, k.lower())
mqtt_client.publish(topic, v)
return "OK"
@post('/current_data_powerflow/<device>')
def current_data_powerflow(device):
data = extract_request_data()
if not 'Body' in data:
return "Empty"
topic_base = "{}/{}/current_data_powerflow".format(daemon_args.mqtt_topic, device)
if 'Site' in data['Body']:
for k, v in data['Body']['Site'].items():
if v is not None:
topic = "{}/site/{}".format(topic_base, k.lower())
mqtt_client.publish(topic, v)
if 'Inverters' in data['Body']:
for i, d in data['Body']['Inverters'].items():
for k, v in d.items():
if v is not None:
topic = "{}/{}/{}".format(topic_base, i, k.lower())
mqtt_client.publish(topic, v)
return "OK"
@post('/current_data_storages/<device>')
def current_data_storages(device):
data = extract_request_data()
if not 'Body' in data:
return "Empty"
topic_base = "{}/{}/current_data_storages".format(daemon_args.mqtt_topic, device)
for s, d in data['Body'].items():
if 'Controller' in d:
for k, v in d['Controller'].items():
if v is not None and type(v) in [int, float, str] :
topic = "{}/{}/{}".format(topic_base, s, k.lower())
mqtt_client.publish(topic, v)
if 'Modules' in d:
for m in d['Modules']:
serial = m['Details']['Serial']
for k, v in m.items():
if v is not None and type(v) in [int, float, str] :
topic = "{}/{}/{}/{}".format(topic_base, s, serial.lower(), k.lower())
mqtt_client.publish(topic, v)
return "OK"
@post('/current_data_sensorcard/<device>')
def current_data_sensorcard(device):
data = extract_request_data()
if not 'Body' in data:
return "Empty"
topic_base = "{}/{}/current_data_sensorcard".format(daemon_args.mqtt_topic, device)
# TODO not yet implemented
return "OK"
@post('/current_data_stringcontrol/<device>')
def current_data_stringcontrol(device):
data = extract_request_data()
if not 'Body' in data:
return "Empty"
topic_base = "{}/{}/current_data_stringcontrol".format(daemon_args.mqtt_topic, device)
# TODO not yet implemented
return "OK"
@post('/datamanager_io_states/<device>')
def datamanager_io_states(device):
data = extract_request_data()
topic_base = "{}/{}/datamanager_io_states".format(daemon_args.mqtt_topic, device)
for p, d in data.items():
for k, v in d.items():
if v is not None:
topic = "{}/{}/{}".format(topic_base, p.replace(' ', '_'), k.lower())
mqtt_client.publish(topic, v)
return "OK"
@post('/logdata_errors_and_events/<device>')
def logdata_errors_and_events(device):
data = extract_request_data()
if not 'Body' in data:
return "Empty"
topic_base = "{}/{}/logdata_errors_and_events".format(daemon_args.mqtt_topic, device)
return "OK"
@post('/logdata_data/<device>')
def logdata_data(device):
data = extract_request_data()
if not 'Body' in data:
return "Empty"
topic_base = "{}/{}/logdata_data".format(daemon_args.mqtt_topic, device)
return "OK"
def start_mqtt():
global daemon_args
mqtt_client = mqtt.Client(daemon_args.mqtt_clientid)
if daemon_args.verbose:
mqtt_client.enable_logger()
if daemon_args.mqtt_user is not None and daemon_args.mqtt_password is not None:
mqtt_client.username_pw_set(daemon_args.mqtt_user, daemon_args.mqtt_password)
mqtt_client.connect(daemon_args.mqtt_host, daemon_args.mqtt_port, daemon_args.mqtt_keepalive)
mqtt_client.loop_start()
return mqtt_client
def start_http():
global daemon_args
run(host=daemon_args.http_host, port=daemon_args.http_port, debug=daemon_args.verbose)
def parse_args():
parser = argparse.ArgumentParser(
prog='fronius2mqtt',
description='Send the data from Fronius HTTP push to MQTT',
epilog='Have a lot of fun!')
parser.add_argument('-m', '--mqtt_host', type=str,
default='localhost',
help='The hostname of the MQTT server. Default is localhost')
parser.add_argument('--mqtt_port', type=int,
default=1883,
help='The port of the MQTT server. Default is 1883')
parser.add_argument('--mqtt_keepalive', type=int,
default=30,
help='The keep alive interval for the MQTT server connection in seconds. Default is 30')
parser.add_argument('--mqtt_clientid', type=str,
default='fronius2mqtt',
help='The clientid to send to the MQTT server. Default is fronius2mqtt')
parser.add_argument('-u', '--mqtt_user', type=str,
help='The username for the MQTT server connection.')
parser.add_argument('-p', '--mqtt_password', type=str,
help='The password for the MQTT server connection.')
parser.add_argument('-t', '--mqtt_topic', type=str,
default='home/fronius',
help='The topic to publish MQTT message. Default is home/fronius')
parser.add_argument('--http_host', type=str,
default='localhost',
help='The address of the HTTP server. Default is localhost')
parser.add_argument('--http_port', type=int,
default=8080,
help='The port of the HTTP server. Default is 8080')
parser.add_argument('-c', '--config', type=str,
default='/etc/fronius2mqtt.conf',
help='The path to the config file. Default is /etc/fronius2mqtt.conf')
parser.add_argument('-v', '--verbose',
default=False,
action='store_true',
help='Be verbose while running.')
args = parser.parse_args()
return args
def parse_config():
global daemon_args
if not os.path.isfile(daemon_args.config):
return
with open(daemon_args.config, "r") as config_file:
data = json.load(config_file)
if 'mqtt_host' in data:
daemon_args.mqtt_host = data['mqtt_host']
if 'mqtt_port' in data:
daemon_args.mqtt_port = data['mqtt_port']
if 'mqtt_keepalive' in data:
daemon_args.mqtt_keepalive = data['mqtt_keepalive']
if 'mqtt_clientid' in data:
daemon_args.mqtt_clientid = data['mqtt_clientid']
if 'mqtt_user' in data:
daemon_args.mqtt_user = data['mqtt_user']
if 'mqtt_password' in data:
daemon_args.mqtt_password = data['mqtt_password']
if 'mqtt_topic' in data:
daemon_args.mqtt_topic = data['mqtt_topic']
if 'http_host' in data:
daemon_args.http_host = data['http_host']
if 'http_port' in data:
daemon_args.http_port = data['http_port']
if 'verbose' in data:
daemon_args.verbose = data['verbose']
def main():
global daemon_args, mqtt_client
daemon_args = parse_args()
parse_config()
mqtt_client = start_mqtt()
start_http()
if __name__ == "__main__":
main()

View file

@ -1,13 +1,6 @@
[program:fronius2mqtt]
command=/opt/service/fronius2mqtt/run
process_name=%(program_name)s
directory=/opt/service/fronius2mqtt
umask=022
autostart=true
redirect_stderr=true
stdout_logfile=/var/log/fronius2mqtt/main.log
stdout_logfile_maxbytes=2MB
stdout_logfile_backups=1
stdout_capture_maxbytes=0
stdout_events_enabled=false
environment=LOGDIR=/var/log/fronius2mqtt
{
"mqtt_user": "fronius2mqtt",
"mqtt_password": "t0p_s3cr3t",
"http_host": "0.0.0.0",
"http_port": 80
}

View file

@ -1,28 +0,0 @@
mqtt:
host: localhost
port: 1883
user: user
password: secret
topic: "mqtt/topic/for/fronius"
qos: 1
retain: true
fronius:
- inverter:
host: "inverter.myfroniusfarm"
device: 1
topic: "inverter_1"
- inverter:
host: "inverter.myfroniusfarm"
device: 2
topic: "inverter_2"
- storage:
host: "storage.myfroniusfarm"
device: 0
topic: "battery_1"
- meter:
host: "storage.myfroniusfarm"
device: 0
topic: "meter"
- flow:
host: "storage.myfroniusfarm"
topic: "flow"

View file

@ -1,62 +0,0 @@
import yaml
import logging
import logging.config
class Config:
"""Class for parsing fronius2mqtt.yaml."""
def __init__(self):
"""Initialize Config class."""
logging.config.fileConfig('logging.conf')
self._mqtt = {}
self._fronius = {}
def read(self, file='fronius2mqtt.yaml'):
"""Read config."""
logging.debug("Reading %s", file)
try:
with open(file, 'r') as filehandle:
config = yaml.load(filehandle)
self._parse_mqtt(config)
self._parse_fronius(config)
except FileNotFoundError as ex:
logging.error("Error while reading %s: %s", file, ex)
def _parse_mqtt(self, config):
"""Parse the mqtt section of fronius2mqtt.yaml."""
if "mqtt" in config:
self._mqtt = config["mqtt"]
if not "host" in self._mqtt:
raise ValueError("MQTT host not set")
if not "port" in self._mqtt:
raise ValueError("MQTT port not set")
if not "user" in self._mqtt:
raise ValueError("MQTT user not set")
if not "password" in self._mqtt:
raise ValueError("MQTT password not set")
if not "topic" in self._mqtt:
raise ValueError("MQTT topic not set")
if not "qos" in self._mqtt:
self._mqtt["qos"] = 0
if not "retain" in self._mqtt:
self._mqtt["retain"] = False
def _parse_fronius(self, config):
"""Parse the fronius section of fronius2mqtt.yaml."""
if "fronius" in config:
self._fronius = config["fronius"]
for item in self._fronius:
if len(item) != 1:
raise ValueError("Fronius device configuration contains more than one item.")
for (type, properties) in item.items():
if not "host" in properties:
raise ValueError("Missing host for Fronius device")
if not "topic" in properties:
raise ValueError("Missing topic for Fronius device")
def mqtt(self):
return self._mqtt
def fronius(self):
return self._fronius

View file

@ -1,27 +0,0 @@
import time
from fronius2mqtt import froniusfactory
from fronius2mqtt import mqtt
class Daemon:
def __init__(self, config):
self.config = config
self.devices = []
self._init_mqtt()
self._init_fronius()
def run(self):
while True:
for device in self.devices:
device.update_and_publish(self.mqtt)
time.sleep(5)
def _init_mqtt(self):
self.mqtt = mqtt.Mqtt(self.config.mqtt())
self.mqtt.connect()
def _init_fronius(self):
factory = froniusfactory.FroniusFactory(self.config.fronius())
self.devices = factory.create_devices()

View file

@ -1,16 +0,0 @@
import logging
class Device:
def update(self):
raise NotImplementedError("update not implemented")
def update_and_publish(self, mqtt):
data = self.update()
for (key, value) in data.items():
if 'value' in value:
mqtt.publish("{}/{}".format(self.topic, key), value['value'])
else:
logging.info("Ignore %s: %s", key, value)

View file

@ -1,14 +0,0 @@
from pyfronius import fronius
from fronius2mqtt import device
class Flow(device.Device):
def __init__(self, config):
self.host = config['host']
self.topic = config['topic']
self.fronius = fronius.Fronius(self.host)
def update(self):
data = self.fronius.current_power_flow()
return data

View file

@ -1,43 +0,0 @@
from fronius2mqtt import inverter
from fronius2mqtt import storage
from fronius2mqtt import flow
from fronius2mqtt import meter
class FroniusFactory:
def __init__(self, config):
self.config = config
def create_devices(self):
devices = []
for c in self.config:
(type, properties) = c.popitem()
if not type in factory:
raise ValueError("Not a valid device: {}".format(type))
device = factory[type](properties)
devices.append(device)
return devices
def _create_inverter(properties):
device = inverter.Inverter(properties)
return device
def _create_storage(properties):
device = storage.Storage(properties)
return device
def _create_meter(properties):
device = meter.Meter(properties)
return device
def _create_flow(properties):
device = flow.Flow(properties)
return device
factory = {
"inverter" : _create_inverter,
"storage" : _create_storage,
"meter" : _create_meter,
"flow" : _create_flow
}

View file

@ -1,19 +0,0 @@
from pyfronius import fronius
from fronius2mqtt import device
class Inverter(device.Device):
def __init__(self, config):
self.host = config['host']
if 'device' in config:
self.device = config['device']
self.topic = config['topic']
self.fronius = fronius.Fronius(self.host)
def update(self):
if hasattr(self, 'device'):
data = self.fronius.current_inverter_data(self.device)
else:
data = self.fronius.current_system_inverter_data()
return data

View file

@ -1,19 +0,0 @@
from pyfronius import fronius
from fronius2mqtt import device
class Meter(device.Device):
def __init__(self, config):
self.host = config['host']
if 'device' in config:
self.device = config['device']
self.topic = config['topic']
self.fronius = fronius.Fronius(self.host)
def update(self):
if hasattr(self, 'device'):
data = self.fronius.current_meter_data(self.device)
else:
data = self.fronius.current_system_meter_data()
return data

View file

@ -1,22 +0,0 @@
import logging
import paho.mqtt.client as mqtt
class Mqtt:
def __init__(self, config):
self._config = config
def connect(self):
self._client = mqtt.Client()
self._client.username_pw_set(self._config['user'], self._config['password'])
self._client.connect(self._config['host'], self._config['port'])
self._client.loop_start()
def disconnect(self):
self.client.disconnect()
def publish(self, topic, payload):
topic = "{}/{}".format(self._config['topic'], topic)
logging.info("Publish %s: %s, %s, %s", topic, payload, self._config["qos"], self._config["retain"])
self._client.publish(topic, payload, self._config["qos"], self._config["retain"])

View file

@ -1,18 +0,0 @@
from pyfronius import fronius
from fronius2mqtt import device
class Storage(device.Device):
def __init__(self, config):
self.host = config['host']
if 'device' in config:
self.device = config['device']
else:
self.device = 0
self.topic = config['topic']
self.fronius = fronius.Fronius(self.host)
def update(self):
data = self.fronius.current_storage_data(self.device)
return data

View file

@ -1,17 +0,0 @@
from setuptools import setup
setup(name='fronius2mqtt',
version='0.2',
description='Fronius 2 MQTT bridge',
url='https://github.com/gbeine/fronius2mqtt',
author='Gerrit',
author_email='mail@gerritbeine.de',
license='MIT',
packages=['fronius2mqtt'],
requires=[
'logging',
'paho.mqtt',
'pyfronius',
'pyyaml',
],
zip_safe=False)

10
install
View file

@ -1,10 +1,8 @@
#!/bin/sh
python3 -m venv .
python3 -m venv venv
git clone https://github.com/gbeine/pyfronius.git
. venv/bin/activate
. bin/activate
pip install wheel paho-mqtt pyyaml
pip install -e pyfronius
pip install -e fronius2mqtt
pip install bottle
pip install paho.mqtt

View file

@ -1,21 +0,0 @@
[loggers]
keys=root
[handlers]
keys=consoleHandler
[formatters]
keys=simpleFormatter
[logger_root]
level=DEBUG
handlers=consoleHandler
[handler_consoleHandler]
class=StreamHandler
level=DEBUG
formatter=simpleFormatter
args=(sys.stdout,)
[formatter_simpleFormatter]
format=%(asctime)s - %(name)s - %(levelname)s - %(message)s

6
run
View file

@ -1,7 +1,5 @@
#!/bin/sh
touch ${LOGDIR}/.tmpfs
. venv/bin/activate
. bin/activate
exec bin/fronius2mqtt
exec /usr/bin/env python fronius2mqtt