From cceb52430ccfa84bb9feac8561fef09017de6618 Mon Sep 17 00:00:00 2001 From: saemmy Date: Wed, 3 Mar 2021 20:38:13 +0100 Subject: [PATCH 1/5] Added support for output Mqtt broker --- Outputs/Mqtt.py | 30 ++++++++++++++++++++++++++++++ config-sample.toml | 9 +++++++++ power_scraper.py | 7 ++++++- 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 Outputs/Mqtt.py diff --git a/Outputs/Mqtt.py b/Outputs/Mqtt.py new file mode 100644 index 0000000..c4354fe --- /dev/null +++ b/Outputs/Mqtt.py @@ -0,0 +1,30 @@ +from paho.mqtt import client as mqtt + +class Mqtt(object): + def __init__(self, config): + self.mqtt_host = config['mqtt_host'] + self.mqtt_port = config['mqtt_port'] + self.mqtt_keepalive = config['mqtt_keepalive'] + self.mqtt_user = config['mqtt_user'] + self.mqtt_pass = config['mqtt_pass'] + self.mqtt_topic = config['mqtt_topic'] + + def on_connect(client, userdata, flags, rc): + if rc != 0: + print("Failed to connect, return code %d\n", rc) + + def send(self, vals): + client = mqtt.Client() + client.username_pw_set(username=self.mqtt_user, password=self.mqtt_pass) + client.connect(self.mqtt_host, self.mqtt_port, self.mqtt_keepalive) + + inverterDetails = vals.copy() + inverterDetails.pop('Serial', None) + inverterDetails.pop('#SolaxClient', None) + + for x, y in inverterDetails.items(): + client.publish(f"{self.mqtt_topic}/{x}", y) + # print(f"Publish topic:{self.mqtt_topic}/{x} - Value:{y}") + + client.disconnect() + diff --git a/config-sample.toml b/config-sample.toml index c3c9dd0..f6d3bfe 100644 --- a/config-sample.toml +++ b/config-sample.toml @@ -37,6 +37,15 @@ inverters = ['solax1'] #influx_pass = "influxpassword" #influx_retention_policy = 'autogen' +# Enable this sction to output to MQTT +#[mqtt] +#mqtt_host = "localhost" +#mqtt_port = 1883 +#mqtt_keepalive = 60 +#mqtt_user = "mqttuser" +#mqtt_pass = "mqttpassword" +#mqtt_topic = "/yourtopic" + # Enable these sections to control battery charge/discharge on Solax SK-SU5000E inverters # This does a number of things: # 1. Allows more than 2 time periods through the day diff --git a/power_scraper.py b/power_scraper.py index b65de98..0394283 100755 --- a/power_scraper.py +++ b/power_scraper.py @@ -3,7 +3,7 @@ # Scrapes Inverter information from solax inverters and presents it to OpenEnergyMonitor # # Setup: -# pip3 install toml twisted pymodbus influxdb_client +# pip3 install toml twisted pymodbus influxdb_client paho-mqtt # cp config-example.toml config.toml # vi config.toml # @@ -40,6 +40,7 @@ from Outputs.SolaxBatteryControl import SolaxBatteryControl from Outputs.EmonCMS import EmonCMS from Outputs.Influx2 import Influx2 +from Outputs.Mqtt import Mqtt from twisted.internet.defer import setDebugging setDebugging(True) @@ -93,6 +94,10 @@ def shutdown(): print("Setting up Influx") outputs.append(Influx2(config['influx'])) +if 'mqtt' in config: + print("Setting up Mqtt") + outputs.append(Mqtt(config['mqtt'])) + if 'Solax-BatteryControl' in config: print("Setting up Solax-BatteryControl") outputs.append(SolaxBatteryControl(config['Solax-BatteryControl'])) From a3d175595d1f903c0fd55318c5c0dce36c539159 Mon Sep 17 00:00:00 2001 From: Alastair D'Silva Date: Wed, 24 Jan 2024 12:15:39 +1100 Subject: [PATCH 2/5] Add support for the DTSU666 energy meter Signed-off-by: Alastair D'Silva --- Inputs/DTSU666Modbus.py | 83 +++++++++++++++++++++++++++++++++++++++++ config-sample.toml | 9 +++++ power_scraper.py | 12 ++++++ 3 files changed, 104 insertions(+) create mode 100644 Inputs/DTSU666Modbus.py diff --git a/Inputs/DTSU666Modbus.py b/Inputs/DTSU666Modbus.py new file mode 100644 index 0000000..339dc4d --- /dev/null +++ b/Inputs/DTSU666Modbus.py @@ -0,0 +1,83 @@ +from pymodbus.client.sync import ModbusSerialClient +from pymodbus.exceptions import ModbusException +from struct import unpack + +def float32(result, base, addr): + low = result.getRegister(addr - base) + high = result.getRegister(addr - base + 1) + data = bytearray(4) + data[0] = high & 0xff + data[1] = high >> 8 + data[2] = low & 0xff + data[3] = low >> 8 + + val = unpack('f', bytes(data)) + + return val[0] + +class DTSU666Modbus(object): + # Class to interface with the DTSU666 energy meter via Modbus RTU + + def __init__(self, port, baudrate=9600, parity='N', stopbits=1, timeout=3): + self.port = port + # Initialize Modbus serial client with default parameters for DTSU666 + self.client = ModbusSerialClient(method="rtu", port=port, baudrate=baudrate, + parity=parity, stopbits=stopbits, timeout=timeout) + + def fetch(self, completionCallback): + try: + # Reading first block of registers + base = 0x2000 + result = self.client.read_input_registers(base, 0x52, unit=1) + if isinstance(result, ModbusException): + print("Exception from DTSU666: {}".format(result)) + return + + # Parsing registers from the first block + self.vals = {} + self.vals['name'] = self.port.replace("/dev/tty", ""); + self.vals['Line 1 to Line 2 volts'] = float32(result, base, 0x2000) / 10; + self.vals['Line 2 to Line 3 volts'] = float32(result, base, 0x2002) / 10; + self.vals['Line 3 to Line 1 volts'] = float32(result, base, 0x2004) / 10; + self.vals['Phase 1 line to neutral volts'] = float32(result, base, 0x2006) / 10; + self.vals['Phase 2 line to neutral volts'] = float32(result, base, 0x2008) / 10; + self.vals['Phase 3 line to neutral volts'] = float32(result, base, 0x200A) / 10; + self.vals['Phase 1 current'] = float32(result, base, 0x200C) / 1000; + self.vals['Phase 2 current'] = float32(result, base, 0x200E) / 1000; + self.vals['Phase 3 current'] = float32(result, base, 0x2010) / 1000; + self.vals['Phase 1 power'] = float32(result, base, 0x2014) / 10; + self.vals['Phase 2 power'] = float32(result, base, 0x2016) / 10; + self.vals['Phase 3 power'] = float32(result, base, 0x2018) / 10; + self.vals['Phase 1 volt amps reactive'] = float32(result, base, 0x201C) / 10; + self.vals['Phase 2 volt amps reactive'] = float32(result, base, 0x201E) / 10; + self.vals['Phase 3 volt amps reactive'] = float32(result, base, 0x2020) / 10; + self.vals['Phase 1 power factor'] = float32(result, base, 0x202C) / 1000; + self.vals['Phase 2 power factor'] = float32(result, base, 0x202E) / 1000; + self.vals['Phase 3 power factor'] = float32(result, base, 0x2030) / 1000; + self.vals['Total system power'] = float32(result, base, 0x2012) / 10; + self.vals['Total system VAr'] = float32(result, base, 0x201A) / 10; + self.vals['Total system power factor'] = float32(result, base, 0x202A) / 1000; + self.vals['Frequency Of supply voltages'] = float32(result, base, 0x2044) / 100; + self.vals['Total system power demand'] = float32(result, base, 0x2044) / 10; + + # Reading second block of registers + base = 0x401E + result = self.client.read_input_registers(base, 52, unit=1) + if isinstance(result, ModbusException): + print("Exception from DTSU666: {}".format(result)) + return + + # Parsing registers from the second block + # Adjusting the base address and register address accordingly + self.vals['Total import kWh'] = float32(result, base, 0x401E) * 1000; + self.vals['Total export kWh'] = float32(result, base, 0x4028) * 1000; + self.vals['Total Q1 kvarh'] = float32(result, base, 0x4032) * 1000; + self.vals['Total Q2 kvarh'] = float32(result, base, 0x403C) * 1000; + self.vals['Total Q3 kvarh'] = float32(result, base, 0x4046) * 1000; + self.vals['Total Q4 kvarh'] = float32(result, base, 0x4050) * 1000; + + completionCallback(self.vals, None) + + except ModbusException as e: + print(f"Modbus Error: {e}") + return None diff --git a/config-sample.toml b/config-sample.toml index 3bc3402..74337df 100644 --- a/config-sample.toml +++ b/config-sample.toml @@ -29,6 +29,15 @@ inverters = ['solax-x1-hybrid.lan'] #stopbits = 1 #ports = ["/dev/ttyMainsMeter"] +# Enable this section to scrape Modbus/RTU (RS485) connected DTSU666 energy meters +#[DTSU666] +#poll_period = 1 # seconds +#timeout = 1 # seconds +#baud = 9600 +#parity = 'N' +#stopbits = 1 +#ports = ["/dev/ttyMainsMeter"] + # Enable this section to scrape Modbus/RTU (RS485, "meter" connection in the manual) for Solax X3 inverters #[SolaxX3RS485] #poll_period = 10 # seconds diff --git a/power_scraper.py b/power_scraper.py index deacb3c..96a4097 100755 --- a/power_scraper.py +++ b/power_scraper.py @@ -38,6 +38,7 @@ from Inputs.SolaxModbus import SolaxModbus from Inputs.SolaxXHybridModbus import SolaxXHybridModbus from Inputs.SDM630ModbusV2 import SDM630ModbusV2 +from Inputs.DTSU666Modbus import DTSU666Modbus from Inputs.SolaxX3RS485 import SolaxX3RS485 from Outputs.SolaxBatteryControl import SolaxBatteryControl from Outputs.EmonCMS import EmonCMS @@ -140,6 +141,17 @@ def shutdown(): looperSDM630 = task.LoopingCall(inputActions, SDM630Meters) looperSDM630.start(config['SDM630ModbusV2']['poll_period']) +if 'DTSU666' in config: + print("Setting up DTSU666") + DTSU666Meters = [] + for meter in config['DTSU666']['ports']: + modbusMeter = DTSU666Modbus(meter, config['DTSU666']['baud'], config['DTSU666']['parity'], + config['DTSU666']['stopbits'], config['DTSU666']['timeout']) + DTSU666Meters.append(modbusMeter) + + looperDTSU666 = task.LoopingCall(inputActions, DTSU666Meters) + looperDTSU666.start(config['DTSU666']['poll_period']) + if 'SolaxX3RS485' in config: print("Setting up SolaxX3RS485") SolaxRS485Meters = [] From 3af8a874e9789ec29e5425fc42ac294544db7c92 Mon Sep 17 00:00:00 2001 From: Alastair D'Silva Date: Wed, 24 Jan 2024 12:17:13 +1100 Subject: [PATCH 3/5] Add things to make commissioning easier - Python requirements.txt - Sample systemd service Signed-off-by: Alastair D'Silva --- PowerScraper.service | 13 +++++++++++++ requirements.txt | 5 +++++ 2 files changed, 18 insertions(+) create mode 100644 PowerScraper.service create mode 100644 requirements.txt diff --git a/PowerScraper.service b/PowerScraper.service new file mode 100644 index 0000000..8b60e6c --- /dev/null +++ b/PowerScraper.service @@ -0,0 +1,13 @@ +[Unit] +Description=Power Scraper + +[Service] +Type=simple +User=root +WorkingDirectory=/usr/local/PowerScraper +ExecStart=/usr/local/PowerScraper/power_scraper.py +Restart=on-abort + +[Install] +WantedBy=multi-user.target + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..aee3c7f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +pymodbus==2.5.3 +influxdb_client +toml +twisted + From 0217fee4d7e7b7ba994e15135e6a090de7cf1888 Mon Sep 17 00:00:00 2001 From: Alastair D'Silva Date: Thu, 25 Jan 2024 09:51:40 +1100 Subject: [PATCH 4/5] Ahh tickle-remote-control option for newer inverters that need remote-control periodcially enabled Signed-off-by: Alastair D'Silva --- Inputs/SolaxXHybridModbus.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Inputs/SolaxXHybridModbus.py b/Inputs/SolaxXHybridModbus.py index d1085b3..35ba5be 100644 --- a/Inputs/SolaxXHybridModbus.py +++ b/Inputs/SolaxXHybridModbus.py @@ -323,6 +323,9 @@ def solaxRegisterCCallback(self, result): def wakeupInverter(self, result): result = self.factory.getClient().write_register(0x56, 1) + def tickleRemoteControl(self, result): + result = self.factory.getClient().write_register(0x51, 1) + def chargeBattery(self, power): self.requestedBatteryPower = power @@ -337,6 +340,9 @@ def chargeBattery(self, power): # else: # result = self.factory.getClient().write_registers(0x07C, [1, power, 0, 0, 0]) result = self.factory.getClient().write_register(0x52, power) + inverter = self.config['Solax-BatteryControl']['Inverter'][self.vals['name']] + if 'tickle-remote-control' in inverter and inverter['tickle-remote-control']: + result.addCallback(self.tickleRemoteControl) # if power != 0: # result.addCallback(self.wakeupInverter) From e681b6fc77162b5fb218e67baeac2afbd8634e06 Mon Sep 17 00:00:00 2001 From: Alastair D'Silva Date: Thu, 25 Jan 2024 09:52:42 +1100 Subject: [PATCH 5/5] Implement use-total-power option This option allows the battery control to offset the total system power, rather than the phase power, when using a single inverter in a 3 phase installation. Signed-off-by: Alastair D'Silva --- Outputs/SolaxBatteryControl.py | 12 +++++++++--- config-sample.toml | 1 + 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Outputs/SolaxBatteryControl.py b/Outputs/SolaxBatteryControl.py index d2ce0a7..3c95a56 100644 --- a/Outputs/SolaxBatteryControl.py +++ b/Outputs/SolaxBatteryControl.py @@ -119,6 +119,9 @@ def send(self, vals, batteryAPI): if 'name' not in inverter: inverter['name'] = inverterName + if 'use-total-power' not in inverter: + inverter['use-total-power'] = False + inverter['Battery Capacity'] = vals['Battery Capacity'] phase = inverter['phase'] @@ -152,9 +155,12 @@ def send(self, vals, batteryAPI): self.assistNeeded[inverterName] = True return - # Try and zero our phase power - #print("Initial discharge power is {}, additional from phase is {}\n".format(inverter['DischargePower'], self.phasePower[phase])) - inverter['DischargePower'] += self.phasePower[phase] * 0.25 + if inverter['use-total-power']: + inverter['DischargePower'] += self.totalPower * 0.25 + else: + # Try and zero our phase power + #print("Initial discharge power is {}, additional from phase is {}\n".format(inverter['DischargePower'], self.phasePower[phase])) + inverter['DischargePower'] += self.phasePower[phase] * 0.25 if self.assistNeeded[inverterName]: if inverter['DischargePower'] >= 0 and inverter['DischargePower'] < inverter['single-phase-discharge-limit'] / len(self.config['Inverter']): diff --git a/config-sample.toml b/config-sample.toml index 74337df..778d9f8 100644 --- a/config-sample.toml +++ b/config-sample.toml @@ -80,6 +80,7 @@ inverters = ['solax-x1-hybrid.lan'] # Defines the inverters that will participate #[Solax-BatteryControl.Inverter.solax1] # First inverter #phase = 1 # Which phase the inverter is connected to, as seen by the power consumption meter +#use-total-power = false # If true, the inverter will try to zero the total power, rather than the phase power #single-phase-charge-limit = 1000 # If the charge rate is below this, aim to zero our own phase, if it's above, aim to zero total power #single-phase-discharge-limit = 1000 # If the discharge rate is below this, aim to zero our own phase, if it's above, aim to zero total power #max-charge = 2000 # Maximum battery charge rate in Watts