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).
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.