From 9b4f424de1e9549773ddd5d43b615d12ca5c86b1 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Odd=20Str=C3=A5b=C3=B8?= <oddstr13@openshell.no>
Date: Sun, 14 Nov 2021 01:54:00 +0100
Subject: [PATCH] Render RRD graphs

---
 .gitignore       |   8 ++
 render_rrd.py    | 214 +++++++++++++++++++++++++++++++++++++++++++++++
 requirements.txt |   4 +
 3 files changed, 226 insertions(+)
 create mode 100644 render_rrd.py
 create mode 100644 requirements.txt

diff --git a/.gitignore b/.gitignore
index f0b7d96..19eddcf 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,9 @@
 *.py[ocd]
+
+*.log
+*.zip
+
+*.rrd
+graphs/
+
+config.yaml
diff --git a/render_rrd.py b/render_rrd.py
new file mode 100644
index 0000000..73d42c6
--- /dev/null
+++ b/render_rrd.py
@@ -0,0 +1,214 @@
+# -*- coding: utf-8 -*-
+import datetime
+import os
+from ast import literal_eval
+from collections import namedtuple
+from typing import Any, Dict
+
+import rrdtool
+
+DT_FORMAT = "%Y-%m-%d %H:%M:%S.%f"
+
+START = (
+    int(datetime.datetime.strptime("2021-10-31 19:28:17.502365", DT_FORMAT).timestamp())
+    - 1
+)
+# 2021-11-12 16:58:32.262030
+HISTORICAL_KEYS = {
+    "battery_voltage_min",
+    "battery_voltage_max",
+    "charge_max_current",
+    "_discharge_max_current?",
+    "charge_max_power",
+    "discharge_max_power",
+    "charge_amp_hour",
+    "discharge_amp_hour",
+    "production_power",
+    "consumption_power",
+    "run_days",
+    "discharge_count",
+    "full_charge_count",
+    "total_charge_amp_hours",
+    "total_discharge_amp_hours",
+    "total_production_power",
+    "total_consumption_power",
+}
+
+# 2021-11-12 16:58:47.521142
+INSTANT_KEYS = {
+    "battery_charge",
+    "battery_voltage",
+    "battery_current",
+    "internal_temp",
+    "battery_temp",
+    "load_voltage",
+    "load_current",
+    "load_power",
+    "panel_voltage",
+    "panel_current",
+    "panel_power",
+    "load_enabled",
+}
+
+KNOWN_KEYS = HISTORICAL_KEYS.union(INSTANT_KEYS)
+
+
+MAP = {
+    "_internal_temperature?": "internal_temp",
+    "unknown1": "charge_max_current",
+    "unknown2": "_discharge_max_current?",
+    "internal_temperature": "internal_temp",
+    "battery_temperature": "battery_temp",
+}
+
+
+def map_keys(d: dict) -> dict:
+    res = {}
+    for k, v in d.items():
+        if k in MAP:
+            k = MAP[k]
+        res[k] = v
+    return res
+
+
+DS = namedtuple("DS", ("name", "type", "heartbeat", "min", "max"))
+
+
+def _DS2str(self: DS) -> str:
+    return f"DS:{self.name}:{self.type}:{self.heartbeat}:{self.min}:{self.max}"
+
+
+# Mypy expects object, not DS as first argument
+DS.__str__ = _DS2str  # type: ignore
+
+datapoints = [
+    DS("internal_temp", "GAUGE", "60s", "-70", "126"),
+    DS("battery_temp", "GAUGE", "60s", "-70", "126"),
+    DS("battery_charge", "GAUGE", "60s", "0", "100"),
+    DS("battery_voltage", "GAUGE", "60s", "0", "40"),
+    DS("battery_current", "GAUGE", "60s", "-30", "30"),
+    DS("load_voltage", "GAUGE", "60s", "0", "40"),
+    DS("load_current", "GAUGE", "60s", "-30", "30"),
+    DS("load_power", "GAUGE", "60s", "-800", "800"),
+    DS("load_enabled", "GAUGE", "60s", "0", "1"),
+    DS("panel_voltage", "GAUGE", "60s", "0", "120"),
+    DS("panel_current", "GAUGE", "60s", "-30", "30"),
+    DS("panel_power", "GAUGE", "60s", "-800", "800"),
+]
+
+
+def parse_log(fh):
+    # address = None
+    for line in fh.readlines():
+        if " " not in line:
+            continue
+
+        date, time, text = line.strip().split(" ", 2)
+        # print(date, time, text)
+
+        try:
+            dt = datetime.datetime.strptime(" ".join([date, time]), DT_FORMAT)
+        except ValueError as e:
+            print(e)
+
+        if text.startswith("{") and text.endswith("}"):
+            try:
+                data = map_keys(literal_eval(text))
+            except SyntaxError as e:
+                print(e)
+
+            for key in data.keys():
+                if key not in KNOWN_KEYS:
+                    if type(key) is int:
+                        continue
+            yield (dt, data)
+
+        # elif text.startswith("Reading"):
+        #    _, addr_txt = text.split(" ")
+        #    address = int(addr_txt.strip("."), 16)
+
+
+RRDFILE = "test.rrd"
+
+
+# feed updates to the database
+# rrdtool.update("test.rrd", "N:32")
+
+
+def rrdupdate(file: str, timestamp: int, data: dict):
+    res = [timestamp]
+    for ds in datapoints:
+        res.append(data.get(ds.name, "U"))
+    update = ":".join([str(int(x)) if type(x) is bool else str(x) for x in res])
+    # print(update)
+    rrdtool.update(file, update)
+
+
+def re_read():
+
+    rrdtool.create(
+        RRDFILE,
+        # "--no-overwrite",
+        "--start",
+        str(START),
+        "--step=60s",
+        # Full resolution (1 minute) for 7 days
+        "RRA:MIN:0.20:1:7d",
+        "RRA:MAX:0.20:1:7d",
+        "RRA:LAST:0.20:1:7d",
+        "RRA:AVERAGE:0.20:1:7d",
+        *[str(ds) for ds in datapoints],
+    )
+
+    with open("z_solar.log", "r") as fh:
+        dt_ep_last = 0
+        data: Dict[str, Any] = {}
+
+        for dt, d in parse_log(fh):
+            # print(dt, d)
+            if "panel_voltage" in d or "battery_voltage" in d:
+                dt_ep = int(dt.timestamp())
+                if not dt_ep_last:
+                    dt_ep_last = dt_ep
+
+                if dt_ep_last != dt_ep:
+                    if not data:
+                        continue
+
+                    rrdupdate(RRDFILE, dt_ep_last, data)
+
+                    data.clear()
+                    dt_ep_last = dt_ep
+                # print(d)
+                # exit()
+                data.update(d)
+        if data:
+            rrdupdate(RRDFILE, dt_ep, data)
+
+
+# re_read()
+
+# DS("internal_temp", "GAUGE", "60s", "-70", "126")
+# DS("battery_temp", "GAUGE", "60s", "-70", "126")
+# DS("battery_charge", "GAUGE", "60s", "0", "100")
+# DS("battery_voltage", "GAUGE", "60s", "0", "40")
+# DS("battery_current", "GAUGE", "60s", "-30", "30")
+# DS("load_voltage", "GAUGE", "60s", "0", "40")
+# DS("load_current", "GAUGE", "60s", "-30", "30")
+# DS("load_power", "GAUGE", "60s", "-800", "800")
+# DS("load_enabled", "GAUGE", "60s", "0", "1")
+# DS("panel_voltage", "GAUGE", "60s", "0", "120")
+# DS("panel_current", "GAUGE", "60s", "-30", "30")
+# DS("panel_power", "GAUGE", "60s", "-800", "800")
+
+os.makedirs("graphs", exist_ok=True)
+for ds in datapoints:
+    rrdtool.graph(
+        f"graphs/{ds.name}.png",
+        "--start=-1w",
+        f"--title={ds.name}",
+        f"DEF:{ds.name}={RRDFILE}:{ds.name}:AVERAGE",
+        f"LINE:{ds.name}#000000:{ds.name}",
+        # "LINE:panel_voltage#ff0000:Panel voltage",
+        # "LINE:panel_power#00ff00:Panel power",
+    )
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..d247648
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,4 @@
+libyaml
+rrdtool
+bluepy
+libscrc