Project Description

At my home we have solar PV hooked up to Victron inverter/chargers and a Redflow ZCell battery for energy storage. The setup is described in detail on my blog. By their nature, ZCell flow batteries needs to undergo a maintenance cycle at least every three days, where they are discharged completely for a few hours. Having only one battery, this means we can't use the "minimum state of charge" feature of the Victron kit to always keep some charge in the battery in case of outages, because doing so conflicts with the ZCell maintenance cycles. This isn't a problem if you have more than one ZCell, because the maintenance cycles interleave in that case, but so far we only have one of these things. If I want to keep charge in the battery for emergency purposes on non-maintenance days, I can do that by configuring scheduled charge settings manually on the Victron Cerbo GX console, but then I have to remember to turn those things back off (or otherwise adjust the settings) for the next maintenance day. For this hack week, I'm going to see if I can automate that piece somehow.

Goal for this Hackweek

Figure out how to interrogate the ZCell Battery Management System's REST API to find out when the battery will be doing maintenance, and then figure out how to automatically update the scheduled charge settings on the Victron Cerbo GX to keep the battery reasonably full on non-maintenance days, and to let it drain again on maintenance days. Also, the battery still needs to be allowed to discharge during peak (i.e. more expensive) electricity times, which are 07:00-10:00 and 16:00-21:00 weekdays, regardless of whether it's a maintenance cycle day.

Bonus points for implementing this in Go and/or Rust, to force me to learn those languages.


  • Go With The Flow (my blog post describing our current setup)
  • Victron Energy Open Source
  • Venus OS: Root Access (to get root on the Cerbo in case I need that)
  • The ZCell BMS REST API is documented in its online help, i.e. in my case this is available only on my internal home network (apologies to everyone reading this who doesn't have access to my house)

Looking for hackers with the skills:

Nothing? Add some keywords!

This project is part of:

Hack Week 21


  • 6 days ago: kieferchang liked this project.
  • 11 days ago: paulgonin liked this project.
  • 12 days ago: bschmidt liked this project.
  • 12 days ago: dmdiss liked this project.
  • 12 days ago: tserong started this project.
  • 12 days ago: tserong originated this project.

  • Comments

    • tserong
      6 days ago by tserong | Reply

      Progress! The following script actually seems to do the trick, and is at least a reasonable first cut, with some caveats:

      • Almost no error handling
      • Assumes maintenance is due based on maintenance time limit. This will usually be true, unless someone (i.e.: me) has messed with the discharge cycle allowed days settings on the BMS (if all days are allowed, you're good - if some days are not allowed you may get maintenance before the time limit).
      • The charge schedules are probably sane for winter (which is now, for me) but likely won't make sense for summer when there's a lot more sun. That's OK, I can tweak this later (or just disable it altogether in the summer months).

      The advantage of using python is I can run this directly on the GX itself. If I want to do it in rust or go, I'd have to figure out how to build for an armv7l system, which might mean going down a cross-compilation rat hole, and I'm not sure I want to do that.

      Another possibility is to enable MQTT on the GX, and potentially poke at the schedule settings remotely that way. Or automate ssh'ing in and running dbus -y -- com.victronenergy.settings /Settings/CGwacs/BatteryLife/Schedule/Charge/$INDEX/$PARAM SetValue $WHATEVER a few times.

      #!/usr/bin/env python3
      # Set scheduled charges on a single ZCell, to generally keep the
      # battery full except for peak electricity usage times, and maintenance
      # cycle days.
      # This is designed to be run on the GX from cron at 07:00 each day
      # (just as peak electricity starts), because that makes the scheduled
      # charges as simple as I could imagine.
      # Run on the GX with:
      #   TZ=$(dbus -y com.victronenergy.settings /Settings/System/TimeZone GetValue | tr -d "'")
      # to make sure is working off the local timezone,
      # which is what the scheduler uses, vs. UTC, which is what the GX itself
      # runs in.
      import argparse
      import dbus
      import requests
      from datetime import datetime
      HOURS = 60 * 60
      DAYS = 60 * 60 * 24
      # CGX days are:
      #   0 Sunday
      #   1 Monday
      #   2 Tuesday
      #   3 Wednesday
      #   4 Thursday
      #   5 Friday
      #   6 Saturday
      #   7 Every Day
      #   8 Weekdays
      #   9 Weekends
      # python's datetime.weekday() is Monday 0 - Sunday 6
      # So to get from python to CGX, we add 1 modulo 7
      def cgx_day(day):
          return (day + 1) % 7
      # Yeah, I know this is the same code as the above function, but the
      # semantics are different ;-)
      def next_day(day):
          return (day + 1) % 7
      def is_maintenance_day():
          # If it's less than 24 hours until maintenance is due when
          # this script is run, we know today is a maintenance day
          status = requests.get(f'http://{args.bms_ip}:3000/rest/1.0/status').json()
          strip_pump_run_target = status['list'][0]['strip_pump_run_target']
          strip_pump_run_timer = status['list'][0]['strip_pump_run_timer']
          next_strip = strip_pump_run_target - strip_pump_run_timer
          return next_strip / DAYS < 1
      def set_charge_schedule(index, day, start, duration, soc):
          bus = dbus.SystemBus()
          # SetValue returns dbus.Int32(0) on success or dbus.Int32(-1) on failure
          o = bus.get_object('com.victronenergy.settings',
          o = bus.get_object('com.victronenergy.settings',
          o = bus.get_object('com.victronenergy.settings',
          o = bus.get_object('com.victronenergy.settings',
      def set_scheduled_charges():
          today = cgx_day(
          tomorrow = next_day(today)
              if is_maintenance_day():
                  # On maintenance days, set a scheduled charge from 13:00 for 3
                  # hours with a 50% SoC limit...
                  set_charge_schedule(3, today, 13 * HOURS, 3 * HOURS, 50)
                  # ...and set an overnight charge starting at 03:00 for 4 hours
                  # the next day (maintenance should be complete by then)
                  set_charge_schedule(4, tomorrow, 3 * HOURS, 4 * HOURS, 100)
                  # On non-maintenance days, set scheduled charges from 10:00 for
                  # 6 hours and 21:00 for 10 hours, so we're basically keeping the
                  # battery charged all the time, except for peak electricity hours
                  # (07:00-10:00 and 16:00-21:00).  Note that for simplicity this
                  # assumes that all days have peak times.  That's not actually
                  # true on Weekends, but it's just simpler to pretend it's true
                  # rather than trying to set up schedules that account for the
                  # difference between weekdays and weekends.
                  set_charge_schedule(3, today, 10 * HOURS, 6 * HOURS, 100)
                  set_charge_schedule(4, today, 21 * HOURS, 10 * HOURS, 100)
          except Exception as e:
              # If something breaks (e.g. can't talk to the BMS) fall back to
              # disabling our scheduled charges as the least worst course of
              # action
              set_charge_schedule(3, -today, 10 * HOURS, 6 * HOURS, 100)
              set_charge_schedule(4, -today, 21 * HOURS, 10 * HOURS, 100)
      if __name__ == "__main__":
          parser = argparse.ArgumentParser("Set scheduled charges based on ZCell maintenance cycle")
          parser.add_argument("bms_ip", help="ZCell BMS IP address")
          args = parser.parse_args()

    • tserong
      5 days ago by tserong | Reply

      The Cerbo GX doesn't preserve crontabs over firmware updates (see We can work around this with the following /data/rc.local script:

      if ! grep -q /etc/crontab; then
          mount | grep -q 'on / .*ro'
          if [ $is_ro -eq 0 ]; then
              mount -o remount,rw /
          cat >> /etc/crontab <<EOF
      # Set scheduled charges based on zcell maintenance cycle
      0 21 * * * root TZ=\$(dbus -y com.victronenergy.settings /Settings/System/TimeZone GetValue | tr -d "'") /home/root/                        
          if [ $is_ro -eq 0 ]; then
              mount -o remount,ro /

      The script we've added to crontab (/home/root/ is actually on the data partition because /home/root is a symlink to /data/home/root. It's just the crontab itself that needs setting up after a firmware update.

    • tserong
      4 days ago by tserong | Reply

      Alright, I think I'm done :-) Here's a blog post with details:

      I didn't get the bonus points for implementing in go or rust (see earlier comment on cross compiling). I would still like to experiment with external control/access via MQTT, so maybe that's what I should try in those languages...

    Similar Projects

    This project is one of its kind!