Show the present state of each device first in the Grafana dashboard
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.
This commit is contained in:
+303
-114
@@ -10,27 +10,68 @@ from pathlib import Path
|
||||
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):
|
||||
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"] == "{measurement}")',
|
||||
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(" |> aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)")
|
||||
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"])')
|
||||
@@ -41,25 +82,64 @@ def flux(device, measurement, *, history=False, label=None, boolean=False, aggre
|
||||
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.
|
||||
value = "r._value" if history and aggregate else "float(v: r._value)"
|
||||
lines.append(f" |> map(fn: (r) => ({{r with _value: {value} / {float(divide)}}}))")
|
||||
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."""
|
||||
properties = [{"id": "displayName", "value": name}, {"id": "unit", "value": unit}]
|
||||
properties += [{"id": key, "value": value} for key, value in extra.items()]
|
||||
properties += [{"id": "custom.axisPlacement", "value": "right"},
|
||||
{"id": "custom.axisLabel", "value": axis_label}]
|
||||
return {"matcher": {"id": "byFrameRefID", "options": ref}, "properties": properties}
|
||||
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):
|
||||
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"}}
|
||||
@@ -72,6 +152,13 @@ def build_dashboard():
|
||||
}
|
||||
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}}
|
||||
@@ -79,6 +166,9 @@ def build_dashboard():
|
||||
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": {
|
||||
@@ -105,127 +195,226 @@ def build_dashboard():
|
||||
"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
|
||||
panel(f"{title} · Charge ({model})", "stat", x, 0, 5, 4,
|
||||
[flux(device, SOC)], "percentunit", defaults=charge,
|
||||
description="Latest reported charge within five minutes, regardless of the history time range. Missing data is not zero charge.")
|
||||
temperature_queries = [flux(device, TEMP, history=True, label="sensor", aggregate=model != "JBD")]
|
||||
temperature_overrides = []
|
||||
temperature_description = "Reported sensors, without combining them. River 3 uses the decoder's preferred battery temperature. Legend values refer to the selected history range."
|
||||
if model == "JBD":
|
||||
temperature_queries.append(flux(device, "bms_capacity_remaining_ah", history=True, aggregate=False))
|
||||
temperature_overrides = [{
|
||||
"matcher": {"id": "byFrameRefID", "options": "B"}, "properties": [
|
||||
{"id": "displayName", "value": "Remaining Capacity"},
|
||||
{"id": "unit", "value": "amph"},
|
||||
{"id": "custom.axisPlacement", "value": "right"},
|
||||
{"id": "custom.axisLabel", "value": "Remaining capacity (Ah)"},
|
||||
],
|
||||
}]
|
||||
temperature_description = "Recorded temperature samples on the left axis and remaining capacity (Ah) on the right. No averaging or empty intervals are added, matching the original Network history."
|
||||
elif model == "River 2":
|
||||
# The battery heartbeat reports mAh.
|
||||
temperature_queries.append(flux(device, "ef_river2_bms_remain_cap", history=True, divide=1000))
|
||||
temperature_overrides = [right_axis("B", "Remaining Capacity", "amph", "Remaining capacity (Ah)", decimals=2)]
|
||||
temperature_description = "Battery temperature on the left axis and reported remaining capacity (Ah) on the right. Each point is the mean of its display interval. Legend values refer to the selected history range."
|
||||
else:
|
||||
# River 3 reports no remaining capacity. Charge is its reported equivalent.
|
||||
temperature_queries.append(flux(device, SOC, history=True))
|
||||
temperature_overrides = [right_axis("B", "Charge", "percentunit", "Charge (%)", decimals=1)]
|
||||
temperature_description = "The decoder's preferred battery temperature on the left axis and reported charge on the right. River 3 reports no remaining capacity in amp-hours. Each point is the mean of its display interval."
|
||||
panel(f"{title} · Temperature history", "timeseries", x, 18, 8, 6,
|
||||
temperature_queries, "celsius",
|
||||
defaults={"displayName": "Sensor ${__field.labels.sensor}", "decimals": 1},
|
||||
overrides=temperature_overrides, description=temperature_description)
|
||||
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)
|
||||
|
||||
for offset, metric, title, unit in ((0, "bms_voltage_total_volts", "Pack voltage", "volt"),
|
||||
(4, "bms_current_amps", "Pack current", "amp")):
|
||||
panel(f"Network · {title}", "stat", offset, 4, 4, 4, [flux("network", metric)], unit,
|
||||
defaults={"decimals": 2}, description="Latest JBD reading within five minutes. Current retains its reported sign.")
|
||||
panel("Network · Cell voltage history", "timeseries", 0, 12, 8, 6,
|
||||
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=[{"matcher": {"id": "byFrameRefID", "options": "B"}, "properties": [
|
||||
{"id": "displayName", "value": "Current"},
|
||||
{"id": "unit", "value": "amp"},
|
||||
{"id": "decimals", "value": 2},
|
||||
{"id": "custom.axisPlacement", "value": "right"},
|
||||
{"id": "custom.axisLabel", "value": "Current (A)"},
|
||||
]}],
|
||||
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.")
|
||||
panel("Network · Capacity", "stat", 5, 0, 3, 4,
|
||||
[flux("network", "bms_capacity_remaining_ah"), flux("network", "bms_capacity_nominal_ah")],
|
||||
"amph", defaults={"decimals": 1}, options={"textMode": "value_and_name"})
|
||||
elements[f"panel-{len(elements)}"]["spec"]["vizConfig"]["spec"]["fieldConfig"]["overrides"] = [
|
||||
{"matcher": {"id": "byFrameRefID", "options": ref}, "properties": [{"id": "displayName", "value": label}]}
|
||||
for ref, label in (("A", "Remaining"), ("B", "Nominal"))
|
||||
]
|
||||
panel("Network · Cell voltages", "bargauge", 0, 8, 8, 4,
|
||||
[flux("network", "bms_voltage_cells_volts", label="cell")], "volt",
|
||||
defaults={"displayName": "Cell ${__field.labels.cell}", "decimals": 3},
|
||||
description="Latest readings within five minutes. No chemistry-specific voltage limits are assumed.")
|
||||
|
||||
battery_color = {"id": "color", "value": {"mode": "fixed", "fixedColor": "blue"}}
|
||||
for col, (device, title, model) in enumerate(DEVICES[1:], start=1):
|
||||
x = col * 8
|
||||
power_queries = []
|
||||
for offset, metric, 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, metric)], "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.")
|
||||
power_queries.append(flux(device, metric, history=True))
|
||||
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)
|
||||
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 = {"matcher": {"id": "byFrameRefID", "options": "C"},
|
||||
"properties": [{"id": "displayName", "value": "Battery"}]}
|
||||
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."
|
||||
battery["properties"].append(battery_color)
|
||||
panel(f"{title} · Input / output history", "timeseries", x, 12, 8, 6,
|
||||
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.
|
||||
last = elements[f"panel-{len(elements)}"]["spec"]["vizConfig"]["spec"]["fieldConfig"]
|
||||
last["overrides"] = [
|
||||
{"matcher": {"id": "byFrameRefID", "options": ref}, "properties": [
|
||||
{"id": "displayName", "value": label}, {"id": "color", "value": {"mode": "fixed", "fixedColor": color}}]}
|
||||
for ref, label, color in (("A", "Input", "green"), ("B", "Output", "orange"))
|
||||
] + [battery]
|
||||
if model == "River 2":
|
||||
# The decoder omits the estimate of the idle direction (5999).
|
||||
estimates = (("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."
|
||||
else:
|
||||
# River 3 always sends both estimates. This field holds the one in use.
|
||||
estimates = (("ef_remaining_time_minutes", "Time remaining"),)
|
||||
estimate_description = "River 3 reports both estimates at all times. This value is the one for the direction that the battery power flow panel shows."
|
||||
panel(f"{title} · Time remaining", "stat", x, 8, 8, 4,
|
||||
[flux(device, metric, divide=60) for metric, _ in estimates], "suffix: h",
|
||||
defaults={"decimals": 1, "noValue": "No estimate"},
|
||||
options={"textMode": "value_and_name" if len(estimates) > 1 else "value"},
|
||||
overrides=[{"matcher": {"id": "byFrameRefID", "options": chr(65 + i)},
|
||||
"properties": [{"id": "displayName", "value": label}]}
|
||||
for i, (_, label) in enumerate(estimates)],
|
||||
description="Device-reported estimate in hours, not a prediction calculated by Grafana. " + estimate_description + " No estimate means that the battery is idle or that the device sent none. Latest export within five minutes.")
|
||||
panel(f"{title} · Battery power flow", "stat", x + 5, 0, 3, 4,
|
||||
[flux(device, "bms_charge_is_charging", boolean=True), flux(device, "bms_charge_is_discharging", boolean=True)],
|
||||
description="Battery-side direction from the EcoFlow decoder, not model-specific state codes. Small idle losses can count as discharge. Each flag uses its latest export within five minutes.",
|
||||
defaults={"mappings": [{"type": "value", "options": {
|
||||
"0": {"text": "No", "color": "text"}, "1": {"text": "Yes", "color": "blue"}}}]},
|
||||
options={"textMode": "value_and_name"})
|
||||
elements[f"panel-{len(elements)}"]["spec"]["vizConfig"]["spec"]["fieldConfig"]["overrides"] = [
|
||||
{"matcher": {"id": "byFrameRefID", "options": ref}, "properties": [{"id": "displayName", "value": label}]}
|
||||
for ref, label in (("A", "Charging"), ("B", "Discharging"))
|
||||
]
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user