The dashboard started with gauges and histories of equal weight, so a power failure and its effect were hard to find. The first rows now show the charge, the power source, the time remaining, and the errors of each device. Below them, one band shows each period on battery power, and one chart shows the charge of the three devices. The charge history adds the pack current of the JBD and the River 2 on a right axis, as dashed lines in the color of each device. The charge limits apply to the charge lines only. A time-remaining panel with no estimate now names the state. The JBD panel shows "On mains" while the pack does not discharge. The River 2 panel shows "Idle" while the device reports 5999 minutes for the two directions. The River 2 errors panel names the module that reports a code and shows the code in hexadecimal. The solar controller reports 4096 with no solar input, and the panel counts that value as zero.
466 lines
31 KiB
Python
466 lines
31 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate the three-device Grafana v2 dashboard using only the standard library."""
|
|
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
|
|
|
|
# InfluxDB ups tag, display label, model. Display labels do not rename stored tags.
|
|
DEVICES = (("network", "Network", "JBD"), ("desktop", "Desktop", "River 2"), ("nas", "NAS", "River 3"))
|
|
SOC = "bms_capacity_charge_ratio"
|
|
TEMP = "bms_temperature_celsius"
|
|
# Measurements that are above zero on mains power, and zero on battery power.
|
|
# The River models report total input power. An idle River 2 draws no input
|
|
# power on mains, so its AC input voltage counts too.
|
|
MAINS_INPUT = {
|
|
"River 2": "/^(ef_input_power_watts|ef_river2_inv_inv_in_vol)$/",
|
|
"River 3": "ef_input_power_watts",
|
|
}
|
|
SOURCE_MAPPINGS = [{"type": "value", "options": {
|
|
"0": {"text": "On battery", "color": "red"}, "1": {"text": "Mains", "color": "green"}}}]
|
|
# Numeric error fields of each model. Zero means no error in the reviewed reports.
|
|
# The River 2 device code is four bytes, which bmspy exports as text.
|
|
ERROR_CODES = {
|
|
"River 2": "/^ef_river2_((bms|inv)_err_code|mppt_fault_code|bms_bms_fault)$/",
|
|
"River 3": "/^ef_river3_display_[a-z0-9_]*err_?code$/",
|
|
}
|
|
# Signed pack current of each model, in amps. River 3 reports none.
|
|
PACK_CURRENT = {"JBD": "bms_current_amps", "River 2": "ef_river2_bms_amp"}
|
|
# One color for each device, for a panel that shows two values of a device.
|
|
DEVICE_COLORS = {"network": "green", "desktop": "yellow", "nas": "blue"}
|
|
DASHED = {"custom.lineStyle": {"fill": "dash", "dash": [10, 10]}, "custom.fillOpacity": 0}
|
|
RIVER2_DEVICE_ERROR = ("ef_river2_pd_err_code", 'if r._value == "0x00000000" then 0.0 else 1.0')
|
|
# The numeric River 2 codes, each with the name of the module that reports it.
|
|
RIVER2_MODULE_CODES = (("ef_river2_bms_err_code", "Battery"), ("ef_river2_bms_bms_fault", "Battery fault"),
|
|
("ef_river2_inv_err_code", "Inverter"), ("ef_river2_mppt_fault_code", "Solar"))
|
|
# A code that a module reports in normal operation. The solar controller
|
|
# reports 4096 with no solar input, on this River 2 and in the report at
|
|
# https://github.com/tolwi/hassio-ecoflow-cloud/issues/280.
|
|
RIVER2_NORMAL_CODES = {"ef_river2_mppt_fault_code": 4096}
|
|
# The values that River 2 reports for an estimate that it does not have.
|
|
RIVER2_NO_ESTIMATE = "r._value == 5999.0 or r._value == 4294967295.0"
|
|
# One value for a device, from the latest reading of several measurements.
|
|
# The names of those measurements do not belong to the result.
|
|
HIGHEST = [' |> group(columns: ["ups"])', " |> max()", ' |> keep(columns: ["_time", "_value", "ups"])']
|
|
|
|
|
|
def flux(device, measurement, *, history=False, label=None, boolean=False, aggregate=True,
|
|
divide=None, fn="mean", empty=True, value=None, steps=()):
|
|
"""Keep independent series; raw history matches the original Network charts.
|
|
|
|
*measurement* is one name, or a Flux regular expression between slashes.
|
|
*divide* converts a reported unit, such as mAh to Ah or minutes to hours.
|
|
*fn* and *empty* set how a history combines the readings of one display
|
|
interval, and whether an interval with no reading stays in the result.
|
|
*value* is a Flux expression that replaces each reading. It needs
|
|
``empty=False`` in a history, because it cannot test a null reading.
|
|
*steps* are further pipeline lines, for a value from several series.
|
|
"""
|
|
start = "v.timeRangeStart" if history else "-5m"
|
|
stop = "v.timeRangeStop" if history else "now()"
|
|
keys = ["ups", "_measurement", "_field"] + ([label] if label else [])
|
|
match = f"=~ {measurement}" if measurement.startswith("/") else f'== "{measurement}"'
|
|
# Own the quotes here; the Flux datasource's default interpolation can add them.
|
|
lines = [
|
|
'from(bucket: "${bucket:raw}")',
|
|
f" |> range(start: {start}, stop: {stop})",
|
|
f' |> filter(fn: (r) => r["ups"] == "{device}")',
|
|
f' |> filter(fn: (r) => r["_measurement"] {match})',
|
|
' |> filter(fn: (r) => r["_field"] == "value")',
|
|
f" |> group(columns: {json.dumps(keys)})",
|
|
]
|
|
if history and aggregate:
|
|
lines.append(f" |> aggregateWindow(every: v.windowPeriod, fn: {fn}, createEmpty: {json.dumps(empty)})")
|
|
else:
|
|
# group() does not preserve time order. Raw histories need it too.
|
|
lines.append(' |> sort(columns: ["_time"])')
|
|
if not history:
|
|
lines.append(" |> last()")
|
|
if boolean:
|
|
lines.append(" |> map(fn: (r) => ({r with _value: if r._value then 1.0 else 0.0}))")
|
|
if divide:
|
|
# A mean is a float already, and an empty interval stays null. A
|
|
# latest reading can be a whole number, so convert it first.
|
|
reading = "r._value" if history and aggregate else "float(v: r._value)"
|
|
lines.append(f" |> map(fn: (r) => ({{r with _value: {reading} / {float(divide)}}}))")
|
|
if value:
|
|
lines.append(f" |> map(fn: (r) => ({{r with _value: {value}}}))")
|
|
lines.extend(steps)
|
|
lines.append(f' |> keep(columns: {json.dumps(["_time", "_value"] + keys)})')
|
|
return "\n".join(lines)
|
|
|
|
|
|
def source(device, model, history=False):
|
|
"""Give 1.0 on mains power and 0.0 on battery power.
|
|
|
|
A history uses the lowest reading of each display interval, so that a
|
|
short power failure stays visible.
|
|
"""
|
|
window = {"history": True, "fn": "min", "empty": False} if history else {}
|
|
if model == "JBD":
|
|
# A JBD pack reports no input. A negative pack current means battery power.
|
|
return flux(device, "bms_current_amps", value="if r._value < 0.0 then 0.0 else 1.0", **window)
|
|
# Several measurements give one state: the highest value at each time.
|
|
# Their types differ, so convert before they join into one table.
|
|
highest = [" |> toFloat()", f' |> group(columns: {json.dumps(["ups", "_time"] if history else ["ups"])})', " |> max()"]
|
|
if history:
|
|
highest += [' |> group(columns: ["ups"])', ' |> sort(columns: ["_time"])']
|
|
return flux(device, MAINS_INPUT[model], steps=highest + [
|
|
" |> map(fn: (r) => ({r with _value: if r._value > 0.0 then 1.0 else 0.0}))",
|
|
' |> keep(columns: ["_time", "_value", "ups"])',
|
|
], **window)
|
|
|
|
|
|
def override(ref, name, properties=None):
|
|
"""Name the series of one query, and set its other field properties."""
|
|
items = [{"id": "displayName", "value": name}]
|
|
items += [{"id": key, "value": value} for key, value in (properties or {}).items()]
|
|
return {"matcher": {"id": "byFrameRefID", "options": ref}, "properties": items}
|
|
|
|
|
|
def right_axis(ref, name, unit, axis_label, **extra):
|
|
"""Put one query on the right axis, with its own name and unit."""
|
|
return override(ref, name, {"unit": unit, **extra, "custom.axisPlacement": "right",
|
|
"custom.axisLabel": axis_label})
|
|
|
|
|
|
def fixed(color):
|
|
return {"mode": "fixed", "fixedColor": color}
|
|
|
|
|
|
def state(ref, name, value, text):
|
|
"""Show one value of a query as a text, for a state that has no number."""
|
|
return override(ref, name, {"color": fixed("text"), "mappings": [
|
|
{"type": "value", "options": {value: {"text": text}}}]})
|
|
|
|
|
|
def build_dashboard():
|
|
elements, items = {}, []
|
|
|
|
def panel(title, group, x, y, width, height, queries=(), unit="none", description="",
|
|
defaults=None, options=None, overrides=None, custom=None):
|
|
number = len(elements) + 1
|
|
name = f"panel-{number}"
|
|
field = {"unit": unit, "noValue": "No recent data", "color": {"mode": "palette-classic"}}
|
|
field.update(defaults or {})
|
|
if group == "timeseries":
|
|
field["custom"] = {
|
|
"drawStyle": "line", "lineInterpolation": "linear", "lineWidth": 2,
|
|
"fillOpacity": 8, "showPoints": "never", "spanNulls": False, "insertNulls": False,
|
|
"axisPlacement": "auto", "stacking": {"mode": "none", "group": "A"},
|
|
}
|
|
opts = {"legend": {"displayMode": "list", "placement": "bottom", "showLegend": True, "calcs": ["lastNotNull", "min", "max"]},
|
|
"tooltip": {"mode": "multi", "sort": "none", "hideZeros": False}}
|
|
elif group == "state-timeline":
|
|
# A state lasts until the next reading. End it after five minutes
|
|
# with no reading, so that lost data does not look like a state.
|
|
field["custom"] = {"lineWidth": 0, "fillOpacity": 80, "spanNulls": False, "insertNulls": 300000}
|
|
opts = {"mergeValues": True, "showValue": "never", "alignValue": "left", "rowHeight": 0.8,
|
|
"legend": {"displayMode": "list", "placement": "bottom", "showLegend": True},
|
|
"tooltip": {"mode": "single", "sort": "none"}}
|
|
elif group == "bargauge":
|
|
opts = {"orientation": "horizontal", "displayMode": "basic", "showUnfilled": True,
|
|
"reduceOptions": {"calcs": ["last"], "fields": "", "values": False}}
|
|
else:
|
|
opts = {"colorMode": "value", "graphMode": "none", "textMode": "value",
|
|
"orientation": "auto", "justifyMode": "auto", "wideLayout": True,
|
|
"reduceOptions": {"calcs": ["last"], "fields": "", "values": False}}
|
|
field.setdefault("custom", {}).update(custom or {})
|
|
if not field["custom"]:
|
|
del field["custom"]
|
|
opts.update(options or {})
|
|
elements[name] = {
|
|
"kind": "Panel", "spec": {
|
|
"id": number, "title": title, "description": description, "links": [],
|
|
"data": {"kind": "QueryGroup", "spec": {
|
|
"queries": [{"kind": "PanelQuery", "spec": {
|
|
"refId": chr(65 + i), "hidden": False,
|
|
"query": {"kind": "DataQuery", "group": "influxdb", "version": "v0",
|
|
"datasource": {"name": "${datasource}"}, "spec": {"query": query}},
|
|
}} for i, query in enumerate(queries)],
|
|
"transformations": [], "queryOptions": {"interval": "1m"},
|
|
}},
|
|
"vizConfig": {"kind": "VizConfig", "group": group, "version": "13.1.0", "spec": {
|
|
"options": opts, "fieldConfig": {"defaults": field, "overrides": overrides or []},
|
|
}},
|
|
},
|
|
}
|
|
items.append({"kind": "GridLayoutItem", "spec": {
|
|
"x": x, "y": y, "width": width, "height": height,
|
|
"element": {"kind": "ElementReference", "name": name},
|
|
}})
|
|
|
|
charge = {"min": 0, "max": 1, "decimals": 1, "color": {"mode": "thresholds"},
|
|
"thresholds": {"mode": "absolute", "steps": [
|
|
{"value": 0, "color": "red"}, {"value": 0.2, "color": "orange"},
|
|
{"value": 0.5, "color": "green"}]}}
|
|
status = {"decimals": 0, "color": {"mode": "thresholds"},
|
|
"thresholds": {"mode": "absolute", "steps": [
|
|
{"value": 0, "color": "green"}, {"value": 1, "color": "red"}]}}
|
|
hours = {"decimals": 1, "noValue": "No estimate"}
|
|
|
|
# Rows 0 to 11: the present state of each device, one column per device.
|
|
for col, (device, title, model) in enumerate(DEVICES):
|
|
x = col * 8
|
|
charge_queries, charge_overrides, charge_options = [flux(device, SOC)], [], None
|
|
charge_description = "Latest reported charge within five minutes, regardless of the history time range. Missing data is not zero charge."
|
|
if model != "JBD":
|
|
limit = "ef_river2_ems_max_charge_soc" if model == "River 2" else "ef_river3_display_cms_max_chg_soc"
|
|
charge_queries.append(flux(device, limit, divide=100))
|
|
charge_overrides = [override("A", "Charge"), override("B", "Limit", {"color": fixed("text")})]
|
|
charge_options = {"textMode": "value_and_name"}
|
|
charge_description += " Limit is the maximum charge that the device is set to. A charge at the limit is a full battery."
|
|
panel(f"{title} · Charge ({model})", "stat", x, 0, 5, 4, charge_queries, "percentunit",
|
|
defaults=charge, options=charge_options, overrides=charge_overrides,
|
|
description=charge_description)
|
|
|
|
source_description = {
|
|
"JBD": "On battery while the pack current is negative.",
|
|
"River 2": "Mains while the total input power or the AC input voltage is above zero. Solar or car input counts as mains too.",
|
|
"River 3": "Mains while the total input power is above zero. Solar or car input counts as mains too.",
|
|
}[model]
|
|
panel(f"{title} · Source", "stat", x + 5, 0, 3, 4, [source(device, model)],
|
|
defaults={"mappings": SOURCE_MAPPINGS}, options={"colorMode": "background"},
|
|
description=source_description + " Latest export within five minutes.")
|
|
|
|
if model == "JBD":
|
|
for offset, measurement, label, unit in ((0, "bms_voltage_total_volts", "Pack voltage", "volt"),
|
|
(4, "bms_current_amps", "Pack current", "amp")):
|
|
panel(f"{title} · {label}", "stat", x + offset, 4, 4, 4, [flux(device, measurement)], unit,
|
|
defaults={"decimals": 2}, description="Latest JBD reading within five minutes. Current retains its reported sign.")
|
|
# The JBD reports no estimate. Remaining capacity divided by the
|
|
# discharge current gives hours at the present load.
|
|
estimates = [flux(device, "/^bms_(capacity_remaining_ah|current_amps)$/", steps=[
|
|
' |> group(columns: ["ups", "_field"])',
|
|
' |> pivot(rowKey: ["_field"], columnKey: ["_measurement"], valueColumn: "_value")',
|
|
' |> filter(fn: (r) => r["bms_current_amps"] < 0.0)',
|
|
' |> map(fn: (r) => ({r with _value: r["bms_capacity_remaining_ah"] / (0.0 - r["bms_current_amps"])}))',
|
|
])]
|
|
# A pack that does not discharge has no estimate. This query gives
|
|
# a value then, so that the panel names the state.
|
|
estimates.append(flux(device, "bms_current_amps", steps=[
|
|
" |> filter(fn: (r) => r._value >= 0.0)",
|
|
" |> map(fn: (r) => ({r with _value: 0.0}))",
|
|
]))
|
|
estimate_overrides, estimate_mode = [override("A", "To empty"), state("B", "State", "0", "On mains")], "value"
|
|
estimate_defaults = {"decimals": 1}
|
|
estimate_description = "Calculated, not reported: remaining capacity divided by the discharge current. It assumes that the present load continues. On mains means that the pack does not discharge, so there is no load to calculate from."
|
|
# One series for each flag that is set, with the flag's description
|
|
# as its name. The second query gives a value only if no flag is
|
|
# set, so that a missing reading does not look like a clear state.
|
|
flags = "/^bms_protection_[a-z]+_bool$/"
|
|
status_queries = [
|
|
flux(device, flags, label="help", boolean=True, steps=[" |> filter(fn: (r) => r._value == 1.0)"]),
|
|
flux(device, flags, boolean=True, steps=HIGHEST + [" |> filter(fn: (r) => r._value == 0.0)"]),
|
|
]
|
|
status_title, status_width = "Protection", 4
|
|
status_defaults = {**status, "displayName": "${__field.labels.help}",
|
|
"thresholds": {"mode": "absolute", "steps": [
|
|
{"value": 0, "color": "green"}, {"value": 1, "color": "orange"}]}}
|
|
status_options, status_overrides = {"textMode": "name"}, [override("B", "None")]
|
|
status_description = "The JBD protection flags that are set, by name. A set flag is an action of the battery management system, such as an end to the charge at the cell voltage limit. None means that every flag is clear."
|
|
else:
|
|
for offset, measurement, label in ((0, "ef_input_power_watts", "Input"), (4, "ef_output_power_watts", "Output")):
|
|
panel(f"{title} · {label}", "stat", x + offset, 4, 4, 4,
|
|
[flux(device, measurement)], "watt", defaults={"decimals": 0, "displayName": label},
|
|
description="Total device power, not battery-side power. Input and output can include AC bypass. Latest export within five minutes.")
|
|
if model == "River 2":
|
|
# The decoder omits the estimate of the idle direction (5999).
|
|
reported = (("ef_discharge_remaining_time_minutes", "To empty"),
|
|
("ef_charge_remaining_time_minutes", "To full"))
|
|
estimate_description = "River 2 reports an estimate for the direction in use only, so one value shows at most. Idle means that the device reports no estimate for either direction."
|
|
else:
|
|
# River 3 always sends both estimates. This field holds the one in use.
|
|
reported = (("ef_remaining_time_minutes", "Time remaining"),)
|
|
estimate_description = "River 3 reports both estimates at all times. This value is the one for the direction of the battery power."
|
|
estimates = [flux(device, measurement, divide=60) for measurement, _ in reported]
|
|
estimate_overrides = [override(chr(65 + i), label) for i, (_, label) in enumerate(reported)]
|
|
estimate_mode = "value_and_name" if len(reported) > 1 else "value"
|
|
estimate_description = "Device-reported estimate in hours, not a prediction calculated by Grafana. " + estimate_description
|
|
if model == "River 2":
|
|
# The raw reports keep the values that the decoder omits. This
|
|
# query gives a value only if neither direction has an estimate.
|
|
estimates.append(flux(device, "/^ef_river2_ems_(chg|dsg)_remain_time$/", steps=[
|
|
" |> toFloat()",
|
|
f" |> map(fn: (r) => ({{r with _value: if {RIVER2_NO_ESTIMATE} then 1.0 else 0.0}}))",
|
|
' |> group(columns: ["ups"])', " |> min()", ' |> keep(columns: ["_time", "_value", "ups"])',
|
|
" |> filter(fn: (r) => r._value == 1.0)",
|
|
]))
|
|
estimate_overrides.append(state("C", "Battery", "1", "Idle"))
|
|
estimate_defaults = {"decimals": 1}
|
|
else:
|
|
estimate_defaults = hours
|
|
estimate_description += " No estimate means that the battery is idle or that the device sent none."
|
|
status_queries = [flux(device, ERROR_CODES[model], steps=[" |> toFloat()"] + HIGHEST)]
|
|
status_title, status_width = "Errors", 3
|
|
status_defaults = {**status, "mappings": [{"type": "value", "options": {"0": {"text": "OK", "color": "green"}}}]}
|
|
status_options, status_overrides = None, []
|
|
status_description = "The highest of the error codes that the device reports. A value other than OK is a device code, and its meaning is not known to bmspy."
|
|
if model == "River 2":
|
|
measurement, rule = RIVER2_DEVICE_ERROR
|
|
# One value for each module code that is not zero. The last
|
|
# query gives a value only if every code is zero, so that a
|
|
# missing reading does not look like a clear state.
|
|
# A normal code counts as zero in the two kinds of query.
|
|
normal = " or ".join(f'r._measurement == "{code}" and r._value == {float(value)}'
|
|
for code, value in RIVER2_NORMAL_CODES.items())
|
|
codes = [" |> toFloat()", f" |> map(fn: (r) => ({{r with _value: if {normal} then 0.0 else r._value}}))"]
|
|
nonzero = codes + [" |> filter(fn: (r) => r._value != 0.0)"]
|
|
clear = codes + HIGHEST + [" |> filter(fn: (r) => r._value == 0.0)"]
|
|
status_queries = ([flux(device, measurement, value=rule)]
|
|
+ [flux(device, code, steps=nonzero) for code, _ in RIVER2_MODULE_CODES]
|
|
+ [flux(device, ERROR_CODES[model], steps=clear)])
|
|
status_options = {"textMode": "value_and_name"}
|
|
status_overrides = (
|
|
[override("A", "Device", {"mappings": [{"type": "value", "options": {
|
|
"0": {"text": "OK", "color": "green"}, "1": {"text": "Error", "color": "red"}}}]})]
|
|
# Hexadecimal, because a code can be a set of flags.
|
|
+ [override(chr(66 + i), name, {"unit": "hex0x", "color": fixed("orange")})
|
|
for i, (_, name) in enumerate(RIVER2_MODULE_CODES)]
|
|
+ [override(chr(66 + len(RIVER2_MODULE_CODES)), "Modules")])
|
|
status_description = "Device is the error code of the main controller, which is four bytes. Modules shows OK while the battery, inverter, and solar codes are zero. The solar controller reports 4096 with no solar input, and that value counts as zero. A different code shows in hexadecimal with the name of its module. Its meaning is not known to bmspy, so it is not always a fault."
|
|
panel(f"{title} · Time remaining", "stat", x, 8, 8 - status_width, 4, estimates, "suffix: h", defaults=estimate_defaults,
|
|
options={"textMode": estimate_mode}, overrides=estimate_overrides,
|
|
description=estimate_description + " Latest export within five minutes.")
|
|
panel(f"{title} · {status_title}", "stat", x + 8 - status_width, 8, status_width, 4, status_queries,
|
|
defaults=status_defaults, options=status_options, overrides=status_overrides,
|
|
description=status_description + " Latest export within five minutes.")
|
|
|
|
# Rows 12 to 22: power failures and their effect, for the three devices together.
|
|
panel("Power source", "state-timeline", 0, 12, 24, 4,
|
|
[source(device, model, history=True) for device, _, model in DEVICES],
|
|
defaults={"mappings": SOURCE_MAPPINGS},
|
|
overrides=[override(chr(65 + i), title) for i, (_, title, _) in enumerate(DEVICES)],
|
|
description="Mains or battery power over the selected time range. Each interval uses its lowest reading, so a short power failure stays visible. A gap means no data for more than five minutes.")
|
|
# The charge limits belong to the charge series only. As panel defaults,
|
|
# they also apply to the current axis.
|
|
currents = [(device, title, PACK_CURRENT[model]) for device, title, model in DEVICES if model in PACK_CURRENT]
|
|
panel("Charge history", "timeseries", 0, 16, 24, 7,
|
|
[flux(device, SOC, history=True) for device, _, _ in DEVICES]
|
|
+ [flux(device, measurement, history=True) for device, _, measurement in currents], "percentunit",
|
|
defaults={"decimals": 1},
|
|
overrides=[override(chr(65 + i), title, {"color": fixed(DEVICE_COLORS[device]), "max": 1, "custom.axisSoftMin": 0.5})
|
|
for i, (device, title, _) in enumerate(DEVICES)]
|
|
+ [right_axis(chr(65 + len(DEVICES) + i), f"{title} current", "amp", "Current (A)",
|
|
color=fixed(DEVICE_COLORS[device]), **DASHED)
|
|
for i, (device, title, _) in enumerate(currents)],
|
|
description="Reported charge of each device on the left axis. The dashed lines show the signed pack current (A) of Network and Desktop on the right axis, negative during discharge. River 3 reports no battery current, so NAS has no dashed line. Each point is the mean of its display interval, and an interval with no data is a gap.")
|
|
|
|
# Rows 23 to 28: the detail that only one model reports.
|
|
panel("Network · Cell voltage history", "timeseries", 0, 23, 8, 6,
|
|
[flux("network", "bms_voltage_cells_volts", history=True, label="cell", aggregate=False),
|
|
flux("network", "bms_current_amps", history=True, aggregate=False)], "volt",
|
|
defaults={"displayName": "Cell ${__field.labels.cell}", "decimals": 3},
|
|
overrides=[right_axis("B", "Current", "amp", "Current (A)", decimals=2)],
|
|
description="Recorded voltage samples for each JBD cell on the left axis and signed pack current (A) on the right. No averaging or empty intervals are added, matching the original Network history.")
|
|
for col, (device, title, model) in enumerate(DEVICES[1:], start=1):
|
|
power_queries = [flux(device, measurement, history=True)
|
|
for measurement in ("ef_input_power_watts", "ef_output_power_watts")]
|
|
if model == "River 2":
|
|
# Signed pack current, in the whole amps that the battery heartbeat reports.
|
|
power_queries.append(flux(device, "ef_river2_bms_amp", history=True))
|
|
battery = right_axis("C", "Current", "amp", "Current (A)", decimals=1, color=fixed("blue"))
|
|
battery_description = "Reported pack current (A) on the right axis, in whole amps, negative during discharge."
|
|
else:
|
|
# River 3 reports no battery current. Signed battery power shares the watt axis.
|
|
power_queries.append(flux(device, "ef_river3_display_pow_get_bms", history=True))
|
|
battery = override("C", "Battery", {"color": fixed("blue")})
|
|
battery_description = "Reported battery power (W) on the same axis, positive during charge and negative during discharge. River 3 reports no battery current."
|
|
panel(f"{title} · Input / output history", "timeseries", col * 8, 23, 8, 6,
|
|
power_queries, "watt",
|
|
description="External input and output totals. Do not subtract them to infer battery charge or discharge. " + battery_description,
|
|
defaults={"decimals": 0, "displayName": "${__field.labels._measurement}"},
|
|
# Display aliases are per-query overrides, not inferred from numeric values.
|
|
overrides=[override("A", "Input", {"color": fixed("green")}),
|
|
override("B", "Output", {"color": fixed("orange")}), battery])
|
|
|
|
# Rows 29 to 38: values that change slowly.
|
|
panel("Output power", "timeseries", 0, 29, 12, 6,
|
|
[flux(device, "ef_output_power_watts", history=True) for device, _, _ in DEVICES[1:]], "watt",
|
|
defaults={"decimals": 0},
|
|
overrides=[override(chr(65 + i), title) for i, (_, title, _) in enumerate(DEVICES[1:])],
|
|
description="Total output power of each EcoFlow device, for a comparison of the loads. The JBD reports battery current only, so it has no line.")
|
|
panel("Temperature", "timeseries", 12, 29, 12, 6,
|
|
[flux(device, TEMP, history=True, label="sensor") for device, _, _ in DEVICES], "celsius",
|
|
defaults={"decimals": 1},
|
|
overrides=[override("A", "Network sensor ${__field.labels.sensor}"), override("B", "Desktop"), override("C", "NAS")],
|
|
description="Reported battery temperatures. The JBD sensors stay separate. River 3 uses the decoder's preferred battery temperature. Each point is the mean of its display interval.")
|
|
capacity = {"unit": "amph", "decimals": 1}
|
|
battery_panels = {
|
|
"JBD": (
|
|
("Cycles", flux("network", "bms_charge_cycles"), {}),
|
|
# Highest minus lowest cell voltage. A growing value shows cells that drift apart.
|
|
("Cell spread", flux("network", "bms_voltage_cells_volts", label="cell",
|
|
steps=[' |> group(columns: ["ups"])', " |> spread()"]), {"unit": "volt", "decimals": 3}),
|
|
("Remaining", flux("network", "bms_capacity_remaining_ah"), capacity),
|
|
("Nominal", flux("network", "bms_capacity_nominal_ah"), capacity),
|
|
),
|
|
"River 2": (
|
|
("Cycles", flux("desktop", "ef_river2_bms_cycles"), {}),
|
|
("Health", flux("desktop", "ef_river2_bms_soh"), {"unit": "percent"}),
|
|
("Full", flux("desktop", "ef_river2_bms_full_cap", divide=1000), capacity),
|
|
("Design", flux("desktop", "ef_river2_bms_design_cap", divide=1000), capacity),
|
|
),
|
|
"River 3": (
|
|
("Health", flux("nas", "ef_river3_display_cms_batt_soh"), {"unit": "percent"}),
|
|
("Design", flux("nas", "ef_river3_display_bms_design_cap", divide=1000), capacity),
|
|
),
|
|
}
|
|
for col, (_, title, model) in enumerate(DEVICES):
|
|
values = battery_panels[model]
|
|
panel(f"{title} · Battery", "stat", col * 8, 35, 8, 4, [query for _, query, _ in values],
|
|
defaults={"decimals": 0, "color": fixed("text")}, options={"textMode": "value_and_name"},
|
|
overrides=[override(chr(65 + i), name, properties) for i, (name, _, properties) in enumerate(values)],
|
|
description="Slow battery values, as the device reports them. "
|
|
+ ("Cell spread is calculated: the highest cell voltage minus the lowest. " if model == "JBD" else "")
|
|
+ "Latest export within five minutes.")
|
|
|
|
# Grafana's JSON import/editor expects the model, not an API resource envelope.
|
|
return {
|
|
"title": "UPS overview · Network / Desktop / NAS",
|
|
"description": "JBD network UPS, River 2 desktop UPS, and River 3 NAS UPS. Flux datasource; bmspy ups tags and value fields.",
|
|
"annotations": [], "cursorSync": "Crosshair", "editable": True, "links": [],
|
|
"liveNow": False, "preload": False, "tags": ["bmspy", "UPS", "EcoFlow", "JBD"],
|
|
"timeSettings": {"timezone": "browser", "from": "now-6h", "to": "now", "autoRefresh": "30s",
|
|
"autoRefreshIntervals": ["30s", "1m", "5m", "15m", "1h"], "hideTimepicker": False, "fiscalYearStartMonth": 0},
|
|
"variables": [
|
|
{"kind": "DatasourceVariable", "spec": {
|
|
"name": "datasource", "label": "InfluxDB (Flux)", "pluginId": "influxdb",
|
|
"refresh": "onDashboardLoad", "regex": "", "current": {"text": "", "value": ""},
|
|
"options": [], "multi": False, "includeAll": False, "hide": "dontHide",
|
|
"skipUrlSync": False, "allowCustomValue": False,
|
|
}},
|
|
{"kind": "TextVariable", "spec": {
|
|
"name": "bucket", "label": "Bucket", "query": "ups", "current": {"text": "ups", "value": "ups"},
|
|
"hide": "dontHide", "skipUrlSync": False,
|
|
}},
|
|
],
|
|
"elements": elements, "layout": {"kind": "GridLayout", "spec": {"items": items}},
|
|
}
|
|
|
|
|
|
def render(*, api_resource=False):
|
|
dashboard = build_dashboard()
|
|
if api_resource:
|
|
dashboard = {
|
|
"apiVersion": "dashboard.grafana.app/v2",
|
|
"kind": "Dashboard",
|
|
"metadata": {"name": "bmspy-three-upses"},
|
|
"spec": dashboard,
|
|
}
|
|
return json.dumps(dashboard, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--output", type=Path, default=Path(__file__).resolve().parents[1] / "grafana-ups.json")
|
|
parser.add_argument("--api-resource", action="store_true",
|
|
help="Include the Grafana v2 API resource wrapper (default: bare dashboard model)")
|
|
args = parser.parse_args()
|
|
args.output.write_text(render(api_resource=args.api_resource), encoding="utf-8")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|