Logging solar system performance from a Renogy Wanderer_

🇺🇦 Resources to help support the people of Ukraine. 🇺🇦
October 07, 2022 @12:50

Grafana Dashboard I've been trying to size and design a portable solar power system for camping and so I needed to figure out a way to get the data from the charge controller. Renogy sells some silly Bluetooth module that can connect your charge controller to their app but that doesn't appear to provide any sort of long-term logging and analysis functions so it's not what I want. It turns out that as is the case with so many things the answer was a quick Python script. The frequent reader of this blog will likely know what is coming next as the combination of InfluxDB and Grafana is a popular one here. I got into it to replace MRTG then expanded it to monitor my ADSB feeder, a Mikrotik Wireless Wire, an Arris DOCSIS cable modem, my Internet speeds, my bespoke sensor network, the performance of all my systems including my Windows gaming PC, and of course the performance of this website.

The trickiest part of the whole operation was figuring out the various register addresses and formats for the data stored in the controller. The Renogy uses Modbus over an RS-232 serial link. Making the cable was straightforward since I have piles of telecomm parts from ages gone by. The cable I went with was a DE-9F to RJ-12 since I have several USB to DE-9M RS-232 converters that operate at the correct RS-232 levels (this is different from the popular, so-called TTL serial or FTDI adapters that you find all over the place for Arduino projects).

Renogy Modbus RS-232 cable pinout

Armed with the proper cable, and some documentation for the Rover I was able to start testing commands and successfully extract the information I wanted. It's worth noting that given a register that the controller doesn't understand it just sits there and refuses to respond which will cause the connection to timeout. The result is below.

#!/usr/bin/env python3
''' solarmonitor.py (c) 2022 Matthew J. Ernisse <matt@going-flying.com>
All Rights Reserved.

Redistribution and use in source and binary forms,
with or without modification, are permitted provided
that the following conditions are met:

    * Redistributions of source code must retain the
      above copyright notice, this list of conditions
      and the following disclaimer.
    * Redistributions in binary form must reproduce
     the above copyright notice, this list of conditions
      and the following disclaimer in the documentation
      and/or other materials provided with the distribution.

THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS
OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR
TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE
USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
'''
import datetime
import minimalmodbus
import optparse
import time
import struct
import sys

from requests.exceptions import ConnectionError
from influxdb import InfluxDBClient
from influxdb.exceptions import InfluxDBServerError


class ModbusClient(object):
    def __init__(self, system_name, port):
        self.client = None
        self.port = port
        self.systemName = system_name

    @staticmethod
    def initalizeMeasurement(system_name):
        return {
            'measurement': 'solar_power_system',
            'tags': { 'name': system_name },
            'time': datetime.datetime.utcnow().isoformat() + 'Z',
            'fields': {}
        }

    def addPoint(self, p_name, val):
        self.points['fields'].update({p_name: val})

    def open(self):
        self.client = minimalmodbus.Instrument(self.port, 1)
        self.client.serial.baudrate = 9600
        self.client.serial.timeout = 5

    def sample(self):
        self.open()

        self.points = self.initalizeMeasurement(self.systemName)

        register = self.client.read_register(0x103)
        register = (register >> 8).to_bytes(1, 'big')

        # 'b' = signed char
        self.addPoint('sccTemp', struct.unpack('b', register)[0])

        register = self.client.read_register(0x106)
        self.addPoint('loadWatts', register)

        register = self.client.read_register(0x107)
        self.addPoint('pvVolts', float(register/10))

        register = self.client.read_register(0x108)
        self.addPoint('pvAmps', float(register/100))

        register = self.client.read_register(0x109)
        self.addPoint('pvWatts', register)

        register = self.client.read_register(0x101)
        self.addPoint('batVolts', float(register/10))
        self.stop()

    def stop(self):
        self.client.serial.close()
        self.client = None
        self.shouldStop = True


if __name__ == '__main__':
    parser = optparse.OptionParser()
    solar_group = optparse.OptionGroup(
        parser,
        'Solar System Options',
        'The MODBUS connection to a solar charge controller'
        ' needs a port and a name.'
    )
    solar_group.add_option(
        '-p',
        '--port',
        help='Serial port name to connect to.'
    )
    solar_group.add_option(
        '-n',
        '--name',
        help='Name of the system attached to the port.'
    )
    parser.add_option_group(solar_group)

    influxdb_group = optparse.OptionGroup(
        parser,
        'InfluxDB Options',
        'Configure the global connection to InfluxDB'
    )
    influxdb_group.add_option(
        '-H',
        '--hostname',
        help='Hostname of the InfluxDB server'
    )
    influxdb_group.add_option(
        '-P',
        '--dbport',
        default=8086,
        help='Port to connect to InfluxDB on',
        type='int'
    )
    influxdb_group.add_option(
        '-d',
        '--dbname',
        help='InfluxDB database name to query.'
    )
    influxdb_group.add_option(
        '-w',
        '--password',
        help='InfluxDB password.'
    )
    influxdb_group.add_option(
        '-u',
        '--username',
        help='InfluxDB username.'
    )
    parser.add_option_group(influxdb_group)

    options, _ = parser.parse_args()

    if not options.port or \
        not options.name or \
        not options.hostname or \
        not options.dbname:
        parser.error('Insufficient arguments.')


    client = ModbusClient(options.name, options.port)
    db = InfluxDBClient(
        options.hostname,
        options.dbport,
        options.username,
        options.password,
        options.dbname
    )

    while True:
        # modbus errors are fatal, InfluxDB errors are ignored.
        try:
            client.sample()

        except Exception as e:
            print(f'MODBUS ERROR: {e!s}')
            sys.exit(1)

        try:
            db.write_points([client.points])
        except (ConnectionError, InfluxDBServerError) as e:
            print(f'InfluxDB ERROR: {e!s}')
            pass

        time.sleep(60)

I've had this running for a while now and it's been pretty useful. The solar system is a 100W Renogy monocrystalline panel, and a home built 3S4P LiPo battery made from scavenged 18650 cells all attached to the Renogy Wanderer charge controller. I load the system with some random LEDs, battery chargers, and a Wouxun KG-905G GMRS radio in my home office.

Comment via e-mail. Subscribe via RSS.