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:
tim
2026-10-13 12:48:03 +02:00
parent 99bb321e3b
commit 99f3d41970
4 changed files with 761 additions and 223 deletions
+3 -2
View File
@@ -196,8 +196,9 @@ InfluxDB connection details can also be supplied via environment variables (`INF
Run `python tools/generate_grafana_ups.py` to write `grafana-ups.json`. Import
that file for a three-column view of Network
(JBD), Desktop (River 2), and NAS (River 3). Queries use the `network`, `desktop`,
and `nas` tags, respectively. It uses the same 24-row grid height as the
original dashboard. Select an InfluxDB data source configured for Flux.
and `nas` tags, respectively. The top rows show the present state of each
device, the power source history, and the charge history. Select an InfluxDB
data source configured for Flux.
See the [dashboard guide](docs/grafana-ups.md) for setup and measurement limits.
## Development
+90 -30
View File
@@ -16,8 +16,9 @@ Select that source in the `InfluxDB (Flux)` control. The `Bucket` control
starts at `ups`. Enter the bucket name without quotes. Queries use
`from(bucket: "${bucket:raw}")` to prevent Grafana from adding a second pair of quotes.
The dashboard uses three columns within a 24-column, 24-row grid. This matches
the original dashboard's grid height. Grafana can rearrange panels on small screens.
The dashboard uses a 24-column grid. The first 23 rows show the present state
of each device, the power source history, and the charge history. The rows below
them show detail. Grafana can rearrange panels on small screens.
The original `grafana-network-ups.json` is unchanged.
## Device names
@@ -52,57 +53,116 @@ Do not add `--ups` to this exporter command. That option restricts export to one
## What the panels show
All three columns show battery charge and temperature history.
The Network column also shows pack voltage, signed current, capacity in amp-hours,
and individual cell voltages. Its cell voltage history includes current on the
right axis, in amps. Its temperature history includes remaining capacity on the
right axis, in amp-hours. The EcoFlow columns show total input/output power,
the time remaining, and battery power-flow flags.
The top of the dashboard has one column for each device. Each column shows
these values, from the latest export within five minutes:
The EcoFlow histories also show battery values. Each model reports different
values:
| Panel | Desktop (River 2) | NAS (River 3) |
| Panel | Network (JBD) | Desktop (River 2) and NAS (River 3) |
|---|---|---|
| Input / output history | Signed pack current on the right axis, in whole amps. | Signed battery power on the watt axis. Positive means charge. |
| Temperature history | Remaining capacity on the right axis, in amp-hours. | Charge on the right axis, in percent. |
| Charge | Reported charge. | Reported charge, and the charge limit of the device. |
| Source | `On battery` while the pack current is negative. | `Mains` while the total input power is above zero. |
| Second row | Pack voltage and signed pack current. | Total input power and total output power. |
| Time remaining | Calculated from remaining capacity and discharge current. | The estimate that the device reports. |
| Protection or Errors | The name of each protection flag that is set, or `None`. | `OK`, or each reported error code that is not zero. |
Two panels below the columns cover all three devices. `Power source` is a state
timeline, which is a band that shows a state over time. It shows each period on
battery power. `Charge history` shows the charge of the three devices on the
left axis. Its dashed lines show the signed pack current of Network and Desktop
on the right axis, in amps. A dashed line has the color of its device, and a
negative current means discharge.
The next row shows the values that only one model reports:
| Panel | Content |
|---|---|
| Network · Cell voltage history | Each cell voltage, and signed pack current on the right axis, in amps. |
| Desktop · Input / output history | Total input and output power, and signed pack current on the right axis, in whole amps. |
| NAS · Input / output history | Total input and output power, and signed battery power on the watt axis. Positive means charge. |
River 3 reports no battery current and no remaining capacity in amp-hours.
The last rows show values that change slowly. `Output power` compares the loads
of the two EcoFlow devices. `Temperature` shows the battery temperature of all
three devices. Each `Battery` panel shows these values:
| Device | Values |
|---|---|
| Network | Charge cycles, cell spread, remaining capacity, and nominal capacity. |
| Desktop | Charge cycles, health, full capacity, and design capacity. |
| NAS | Health and design capacity. |
Cell spread is the highest cell voltage minus the lowest. A value that grows
over time shows cells that drift apart.
## Calculated values and limits
The queries calculate two values that no device reports: the Network time
remaining and the Network cell spread. The Network time remaining is the
remaining capacity divided by the discharge current. It assumes that the present
load continues. While the pack does not discharge, there is no load to
calculate from, and the panel shows `On mains`.
The source rule reads power, not the mains connection. A River with solar or car
input shows `Mains`. An idle River 2 draws no input power on mains, so its rule
also reads the AC input voltage from the inverter report. A River 3 that draws
no input power shows `On battery`.
A JBD protection flag is an action of the battery management system. For
example, it ends the charge at the cell voltage limit. A set flag is therefore
not always a fault.
The meanings of the EcoFlow error codes are not known to bmspy, and the panel
shows `OK` for zero. The River 3 panel shows the highest of its codes.
The River 2 panel shows `Device` for the code of the main controller. It shows
`Modules` and `OK` while the battery, inverter, and solar codes are all zero.
A module code that is not zero shows in hexadecimal, with the name of its module:
`Battery`, `Battery fault`, `Inverter`, or `Solar`. The value is orange, because
a code that bmspy cannot read is not always a fault.
The solar controller of a River 2 reports the code 4096 while it has no solar
input. The panel counts that value as zero. A solar code with a different value
shows as `Solar`.
Current-value panels query the last five minutes, regardless of the history time
picker. An absent series remains absent, not zero.
A panel with no current series shows a no-data indication.
The server can return cached Bluetooth readings, which receive new export timestamps.
History panels follow the time picker. Network histories plot recorded samples
without averaging or adding empty intervals, as in `grafana-network-ups.json`.
History panels follow the time picker. The Network cell voltage history plots
recorded samples without averaging or adding empty intervals, as in
`grafana-network-ups.json`.
Lines connect recorded samples, including samples on either side of a period
without exports. A continuous line does not prove that exports continued throughout
that period. Explicit null values still break the line. The queries do not replace
missing readings with zero.
EcoFlow histories average readings within each display interval. Missing intervals
remain gaps. Temperature sensors and battery cells remain separate series.
The other histories average readings within each display interval. Missing
intervals remain gaps. Temperature sensors and battery cells remain separate series.
The power source panel uses the lowest reading of each interval, so a short power
failure stays visible. It ends a state after five minutes with no reading.
The dashboard refreshes every 30 seconds.
Charge values use ratios from 0 to 1. The dashboard displays them as percentages.
The colors indicate below 20%, 20% to below 50%, and 50% or higher. These are
visual thresholds, not device alarms or battery-specific safety limits.
An EcoFlow charge at the charge limit is a full battery.
EcoFlow input/output totals can include AC bypass. They do not establish battery
charge direction. The flow flags come from battery-side measurements. Small idle
losses can count as discharge. JBD charge-control bits describe switches rather
than power flow, so the dashboard does not compare them with the EcoFlow flags.
charge direction.
The time-remaining panels show the device estimate in hours, for the direction
in use. River 2 reports an estimate for that direction only, so its panel shows
`To empty` or `To full`. River 3 reports both estimates at all times, so its
panel uses `ef_remaining_time_minutes`, which holds the estimate for the
direction in the flow flags. If the battery is idle, or the device sends no
estimate, the panel shows `No estimate`. A River 3 that supplies its own small
losses from the battery counts as discharging, and shows a long time to empty.
The dashboard does not invent a runtime estimate for the JBD or apply River 2
state-code meanings to River 3.
The EcoFlow time-remaining panels show the device estimate in hours, for the
direction in use. River 2 reports an estimate for that direction only, so its
panel shows `To empty` or `To full`. River 3 reports both estimates at all
times, so its panel uses `ef_remaining_time_minutes`, which holds the estimate
for the direction of the battery power. If the battery is idle, or the device
sends no estimate, the River 3 panel shows `No estimate`. A River 3 that supplies
its own small losses from the battery counts as discharging, and shows a long time
to empty.
River 2 reports 5999 minutes for a direction that has no estimate. If the two
directions both have that value, the River 2 panel shows `Battery` and `Idle`.
The dashboard does not apply River 2 state-code meanings to River 3.
## Regeneration
+365 -77
View File
@@ -45,39 +45,40 @@ def properties_for(panel, ref):
}
@pytest.mark.parametrize("title,primary,label,unit,secondary,secondary_unit,display_name", [
("Network · Cell voltage history", "bms_voltage_cells_volts", "cell", "volt",
"bms_current_amps", "amp", "Current"),
("Network · Temperature history", "bms_temperature_celsius", "sensor", "celsius",
"bms_capacity_remaining_ah", "amph", "Remaining Capacity"),
])
def test_network_histories_keep_original_series_and_separate_axes(
panels, title, primary, label, unit, secondary, secondary_unit, display_name,
):
panel = panels[title]
def defaults_for(panel):
return panel["vizConfig"]["spec"]["fieldConfig"]["defaults"]
def positions(generator):
dashboard = generator.build_dashboard()
return {
dashboard["elements"][item["spec"]["element"]["name"]]["spec"]["title"]:
tuple(item["spec"][key] for key in ("x", "y", "width", "height"))
for item in dashboard["layout"]["spec"]["items"]
}
def test_network_cell_history_keeps_original_series_and_separate_axes(panels):
panel = panels["Network · Cell voltage history"]
query = queries(panel)
assert set(query) == {"A", "B"}
assert f'r["_measurement"] == "{primary}"' in query["A"]
assert f'r["_measurement"] == "{secondary}"' in query["B"]
# Cell and sensor identities survive grouping and column selection.
keys = ["ups", "_measurement", "_field", label]
assert 'r["_measurement"] == "bms_voltage_cells_volts"' in query["A"]
assert 'r["_measurement"] == "bms_current_amps"' in query["B"]
# Cell identities survive grouping and column selection.
keys = ["ups", "_measurement", "_field", "cell"]
assert f'group(columns: {json.dumps(keys)})' in query["A"]
assert f'keep(columns: {json.dumps(["_time", "_value"] + keys)})' in query["A"]
defaults = panel["vizConfig"]["spec"]["fieldConfig"]["defaults"]
assert defaults["unit"] == unit
assert "${__field.labels." + label + "}" in defaults["displayName"]
secondary_properties = properties_for(panel, "B")
assert secondary_properties["unit"] == secondary_unit
assert secondary_properties["displayName"] == display_name
assert secondary_properties["custom.axisPlacement"] == "right"
assert secondary_properties["custom.axisLabel"]
assert defaults_for(panel)["unit"] == "volt"
assert "${__field.labels.cell}" in defaults_for(panel)["displayName"]
current = properties_for(panel, "B")
assert current["unit"] == "amp"
assert current["displayName"] == "Current"
assert current["custom.axisPlacement"] == "right"
assert current["custom.axisLabel"]
@pytest.mark.parametrize("title", [
"Network · Cell voltage history", "Network · Temperature history",
])
def test_network_histories_do_not_invent_empty_intervals(panels, title):
panel = panels[title]
def test_network_cell_history_does_not_invent_empty_intervals(panels):
panel = panels["Network · Cell voltage history"]
for query in queries(panel).values():
assert 'from(bucket: "${bucket:raw}")' in query
assert 'r["ups"] == "network"' in query
@@ -88,7 +89,7 @@ def test_network_histories_do_not_invent_empty_intervals(panels, title):
assert 'last()' not in query
assert 'fill(' not in query
assert 'map(' not in query # In particular, do not change the current's sign.
custom = panel["vizConfig"]["spec"]["fieldConfig"]["defaults"]["custom"]
custom = defaults_for(panel)["custom"]
assert custom["insertNulls"] is False
assert custom["spanNulls"] is False # Keep actual nulls, rather than hiding them.
assert custom["drawStyle"] == "line"
@@ -96,44 +97,282 @@ def test_network_histories_do_not_invent_empty_intervals(panels, title):
assert panel["data"]["spec"]["transformations"] == []
@pytest.mark.parametrize("device,title", [("desktop", "Desktop"), ("nas", "NAS")])
def test_ecoflow_histories_average_each_display_interval(panels, device, title):
for suffix, refs in (("Temperature history", {"A", "B"}), ("Input / output history", {"A", "B", "C"})):
panel = panels[f"{title} · {suffix}"]
assert set(queries(panel)) == refs
for query in queries(panel).values():
assert f'r["ups"] == "{device}"' in query
assert 'aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)' in query
assert 'float(v:' not in query # An empty interval stays null.
@pytest.mark.parametrize("title,ref,measurement,divisor,expected", [
# River 2 reports pack current and remaining capacity. River 3 reports
# neither, so its panels use battery power and charge.
("Desktop · Temperature history", "B", "ef_river2_bms_remain_cap", "1000.0", {
"displayName": "Remaining Capacity", "unit": "amph",
"custom.axisPlacement": "right", "custom.axisLabel": "Remaining capacity (Ah)"}),
("NAS · Temperature history", "B", "bms_capacity_charge_ratio", None, {
"displayName": "Charge", "unit": "percentunit",
"custom.axisPlacement": "right", "custom.axisLabel": "Charge (%)"}),
("Desktop · Input / output history", "C", "ef_river2_bms_amp", None, {
@pytest.mark.parametrize("device,title,measurement,expected", [
# River 2 reports pack current. River 3 reports none, so its panel uses
# battery power, which stays on the watt axis.
("desktop", "Desktop", "ef_river2_bms_amp", {
"displayName": "Current", "unit": "amp",
"custom.axisPlacement": "right", "custom.axisLabel": "Current (A)"}),
("NAS · Input / output history", "C", "ef_river3_display_pow_get_bms", None, {
"displayName": "Battery"}),
("nas", "NAS", "ef_river3_display_pow_get_bms", {"displayName": "Battery"}),
])
def test_ecoflow_histories_add_a_battery_series(panels, title, ref, measurement, divisor, expected):
panel = panels[title]
query = queries(panel)[ref]
assert f'r["_measurement"] == "{measurement}"' in query
assert ("r._value / " + divisor in query) if divisor else ("map(" not in query)
properties = properties_for(panel, ref)
def test_ecoflow_power_histories_add_a_battery_series(panels, device, title, measurement, expected):
panel = panels[f"{title} · Input / output history"]
assert set(queries(panel)) == {"A", "B", "C"}
for query in queries(panel).values():
assert f'r["ups"] == "{device}"' in query
assert 'aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)' in query
assert 'map(' not in query # An empty interval stays null.
assert f'r["_measurement"] == "{measurement}"' in queries(panel)["C"]
properties = properties_for(panel, "C")
assert expected.items() <= properties.items()
# Battery power stays on the watt axis of the panel.
if "unit" not in expected:
assert "unit" not in properties and "custom.axisPlacement" not in properties
def test_charge_history_adds_pack_current_on_its_own_axis(panels):
panel = panels["Charge history"]
expected = {
"A": ("network", "bms_capacity_charge_ratio", "Network", "green"),
"B": ("desktop", "bms_capacity_charge_ratio", "Desktop", "yellow"),
"C": ("nas", "bms_capacity_charge_ratio", "NAS", "blue"),
# River 3 reports no battery current, so NAS has no current series.
"D": ("network", "bms_current_amps", "Network current", "green"),
"E": ("desktop", "ef_river2_bms_amp", "Desktop current", "yellow"),
}
assert set(queries(panel)) == set(expected)
for ref, (device, measurement, name, color) in expected.items():
query = queries(panel)[ref]
assert f'r["ups"] == "{device}"' in query
assert f'r["_measurement"] == "{measurement}"' in query
assert 'aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)' in query
assert 'map(' not in query # Do not change the current's sign.
properties = properties_for(panel, ref)
assert properties["displayName"] == name
# A device has one color for its charge and its current.
assert properties["color"] == {"mode": "fixed", "fixedColor": color}
if ref in "ABC":
assert properties["max"] == 1 and properties["custom.axisSoftMin"] == 0.5
assert "unit" not in properties and "custom.axisPlacement" not in properties
else:
assert properties["unit"] == "amp"
assert properties["custom.axisPlacement"] == "right"
assert properties["custom.axisLabel"] == "Current (A)"
assert properties["custom.lineStyle"]["fill"] == "dash"
# The charge limits must not apply to the current axis.
assert "max" not in properties and "custom.axisSoftMin" not in properties
defaults = defaults_for(panel)
assert defaults["unit"] == "percentunit"
assert "max" not in defaults and "axisSoftMin" not in defaults["custom"]
@pytest.mark.parametrize("title,queries_by_name", [
# The JBD reports battery current only, so it has no output power.
("Output power", {"Desktop": ("desktop", "ef_output_power_watts"),
"NAS": ("nas", "ef_output_power_watts")}),
("Temperature", {"Network sensor ${__field.labels.sensor}": ("network", "bms_temperature_celsius"),
"Desktop": ("desktop", "bms_temperature_celsius"),
"NAS": ("nas", "bms_temperature_celsius")}),
])
def test_shared_histories_have_one_query_for_each_device(panels, title, queries_by_name):
panel = panels[title]
assert len(queries(panel)) == len(queries_by_name)
for (ref, query), (name, (device, measurement)) in zip(queries(panel).items(), queries_by_name.items()):
assert f'r["ups"] == "{device}"' in query
assert f'r["_measurement"] == "{measurement}"' in query
assert 'aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)' in query
assert properties_for(panel, ref) == {"displayName": name}
# Temperature sensors stay separate series.
if title == "Temperature":
assert all('"sensor"])' in query for query in queries(panel).values())
@pytest.mark.parametrize("title,measurement,rule", [
# A JBD pack discharges on battery power. A River has no input power then.
("Network", '== "bms_current_amps"', "if r._value < 0.0 then 0.0 else 1.0"),
# An idle River 2 draws no input power on mains, so the AC input voltage counts too.
("Desktop", "=~ /^(ef_input_power_watts|ef_river2_inv_inv_in_vol)$/", "if r._value > 0.0 then 1.0 else 0.0"),
("NAS", '== "ef_input_power_watts"', "if r._value > 0.0 then 1.0 else 0.0"),
])
def test_power_source_uses_the_same_rule_now_and_over_time(generator, panels, title, measurement, rule):
mappings = [{"type": "value", "options": {
"0": {"text": "On battery", "color": "red"}, "1": {"text": "Mains", "color": "green"}}}]
now = panels[f"{title} · Source"]
(query,) = queries(now).values()
assert f'r["_measurement"] {measurement}' in query
assert query.index("last()") < query.index(f"_value: {rule}")
assert defaults_for(now)["mappings"] == mappings
timeline = panels["Power source"]
assert timeline["vizConfig"]["group"] == "state-timeline"
ref = "ABC"[[name for _, name, _ in generator.DEVICES].index(title)]
query = queries(timeline)[ref]
assert f'r["_measurement"] {measurement}' in query
# The lowest reading keeps a short failure visible. The rule cannot test
# a null reading, so empty intervals are left out, and the panel ends a
# state after five minutes with no reading.
assert 'aggregateWindow(every: v.windowPeriod, fn: min, createEmpty: false)' in query
assert query.index("aggregateWindow(") < query.index(f"_value: {rule}")
assert properties_for(timeline, ref) == {"displayName": title}
assert defaults_for(timeline)["mappings"] == mappings
assert defaults_for(timeline)["custom"]["insertNulls"] == 300000
def test_river_source_takes_the_highest_of_its_measurements(panels):
"""Mains if one of the measurements is above zero, at each time."""
(now,) = queries(panels["Desktop · Source"]).values()
steps = [now.index(step) for step in (
"last()", "toFloat()", 'group(columns: ["ups"])', "max()", "_value: if r._value > 0.0")]
assert steps == sorted(steps)
history = queries(panels["Power source"])["B"]
steps = [history.index(step) for step in (
"aggregateWindow(", "toFloat()", 'group(columns: ["ups", "_time"])', "max()",
'group(columns: ["ups"])', 'sort(columns: ["_time"])', "_value: if r._value > 0.0")]
assert steps == sorted(steps)
# A second text column would show as a second band in the timeline.
assert 'keep(columns: ["_time", "_value", "ups"])' in history
@pytest.mark.parametrize("title,measurement", [
("Desktop · Charge (River 2)", "ef_river2_ems_max_charge_soc"),
("NAS · Charge (River 3)", "ef_river3_display_cms_max_chg_soc"),
])
def test_ecoflow_charge_shows_the_charge_limit(panels, title, measurement):
panel = panels[title]
assert set(queries(panel)) == {"A", "B"}
assert f'r["_measurement"] == "{measurement}"' in queries(panel)["B"]
# The limit is a percentage. The charge is a ratio.
assert "float(v: r._value) / 100.0" in queries(panel)["B"]
assert properties_for(panel, "A") == {"displayName": "Charge"}
assert properties_for(panel, "B")["displayName"] == "Limit"
assert set(queries(panels["Network · Charge (JBD)"])) == {"A"}
@pytest.mark.parametrize("title,ref,pattern", [
("Desktop · Errors", "F", "/^ef_river2_((bms|inv)_err_code|mppt_fault_code|bms_bms_fault)$/"),
("NAS · Errors", "A", "/^ef_river3_display_[a-z0-9_]*err_?code$/"),
])
def test_ecoflow_errors_show_the_highest_numeric_code(panels, title, ref, pattern):
panel = panels[title]
query = queries(panel)[ref]
assert f'r["_measurement"] =~ {pattern}' in query
# One type before the series join into one table.
steps = [query.index(step) for step in ("last()", "toFloat()", 'group(columns: ["ups"])', "max()")]
assert steps == sorted(steps)
(mapping,) = defaults_for(panel)["mappings"]
assert {value: entry["text"] for value, entry in mapping["options"].items()} == {"0": "OK"}
# No data must not read as OK.
assert defaults_for(panel)["noValue"] == "No recent data"
def test_river2_device_error_code_is_text(panels):
"""bmspy exports the four bytes as "0x00000000", which is not a number."""
panel = panels["Desktop · Errors"]
device = queries(panel)["A"]
assert 'r["_measurement"] == "ef_river2_pd_err_code"' in device
assert '_value: if r._value == "0x00000000" then 0.0 else 1.0' in device
assert "toFloat()" not in device
assert all("pd_err_code" not in queries(panel)[ref] for ref in "BCDEF")
assert properties_for(panel, "A")["displayName"] == "Device"
(mapping,) = properties_for(panel, "A")["mappings"]
assert {value: entry["text"] for value, entry in mapping["options"].items()} == {"0": "OK", "1": "Error"}
# The solar controller reports 4096 with no solar input. That is not an error.
NORMAL_SOLAR_CODE = '_value: if r._measurement == "ef_river2_mppt_fault_code" and r._value == 4096.0 then 0.0 else r._value'
def test_river2_errors_name_the_module_that_reports_a_code(panels):
panel = panels["Desktop · Errors"]
modules = {
"B": ("ef_river2_bms_err_code", "Battery"), "C": ("ef_river2_bms_bms_fault", "Battery fault"),
"D": ("ef_river2_inv_err_code", "Inverter"), "E": ("ef_river2_mppt_fault_code", "Solar"),
}
assert set(queries(panel)) == {"A", "F"} | set(modules)
for ref, (measurement, name) in modules.items():
query = queries(panel)[ref]
assert f'r["_measurement"] == "{measurement}"' in query
# A code of zero gives no series, so only a module with a code shows.
steps = [query.index(step) for step in ("last()", "toFloat()", NORMAL_SOLAR_CODE, "filter(fn: (r) => r._value != 0.0)")]
assert steps == sorted(steps)
properties = properties_for(panel, ref)
assert properties["displayName"] == name
assert properties["unit"] == "hex0x"
# The meaning of a code is not known, so its color is not the fault color.
assert properties["color"] == {"mode": "fixed", "fixedColor": "orange"}
# "Modules OK" needs a reading of every code, and every code at zero.
clear = queries(panel)["F"]
steps = [clear.index(step) for step in ("toFloat()", NORMAL_SOLAR_CODE, "max()", "filter(fn: (r) => r._value == 0.0)")]
assert steps == sorted(steps)
assert properties_for(panel, "F") == {"displayName": "Modules"}
assert panel["vizConfig"]["spec"]["options"]["textMode"] == "value_and_name"
def test_network_protection_names_the_flags_that_are_set(panels):
panel = panels["Network · Protection"]
active, clear = queries(panel)["A"], queries(panel)["B"]
for query in (active, clear):
assert 'r["_measurement"] =~ /^bms_protection_[a-z]+_bool$/' in query
assert "if r._value then 1.0 else 0.0" in query
# One series for each set flag, named by the description of the flag.
assert '"help"])' in active
assert active.index("last()") < active.index("filter(fn: (r) => r._value == 1.0)")
assert defaults_for(panel)["displayName"] == "${__field.labels.help}"
assert panel["vizConfig"]["spec"]["options"]["textMode"] == "name"
# "None" needs a reading of every flag. No data must not read as None.
steps = [clear.index(step) for step in (
"last()", 'group(columns: ["ups"])', "max()", "filter(fn: (r) => r._value == 0.0)")]
assert steps == sorted(steps)
assert properties_for(panel, "B") == {"displayName": "None"}
assert defaults_for(panel)["noValue"] == "No recent data"
@pytest.mark.parametrize("pattern,matches,rejects", [
("/^bms_protection_[a-z]+_bool$/",
["bms_protection_sop_bool", "bms_protection_slmos_bool"], ["bms_charge_is_charging"]),
("/^ef_river2_((bms|inv)_err_code|mppt_fault_code|bms_bms_fault)$/",
["ef_river2_bms_err_code", "ef_river2_inv_err_code", "ef_river2_mppt_fault_code", "ef_river2_bms_bms_fault"],
# The device code is text ("0x00000000"), and has a query of its own.
["ef_river2_pd_err_code", "ef_river2_pd_decode_error", "ef_river2_ems_bms_warning_state"]),
("/^ef_river3_display_[a-z0-9_]*err_?code$/",
["ef_river3_display_errcode", "ef_river3_display_bms_err_code", "ef_river3_display_plug_in_info_4p8_1_err_code"],
# A nested list and a text field. Neither is a number.
["ef_river3_display_err_code_record_list", "ef_river3_display_decode_error"]),
])
def test_fault_patterns_select_numeric_fields_only(pattern, matches, rejects):
import re
expression = re.compile(pattern.strip("/"))
assert all(expression.search(name) for name in matches)
assert not any(expression.search(name) for name in rejects)
@pytest.mark.parametrize("title,expected", [
("Network", {"Cycles": ("bms_charge_cycles", None), "Cell spread": ("bms_voltage_cells_volts", "volt"),
"Remaining": ("bms_capacity_remaining_ah", "amph"), "Nominal": ("bms_capacity_nominal_ah", "amph")}),
("Desktop", {"Cycles": ("ef_river2_bms_cycles", None), "Health": ("ef_river2_bms_soh", "percent"),
"Full": ("ef_river2_bms_full_cap", "amph"), "Design": ("ef_river2_bms_design_cap", "amph")}),
("NAS", {"Health": ("ef_river3_display_cms_batt_soh", "percent"),
"Design": ("ef_river3_display_bms_design_cap", "amph")}),
])
def test_battery_panels_show_the_slow_values_of_each_model(panels, title, expected):
panel = panels[f"{title} · Battery"]
assert len(queries(panel)) == len(expected)
for (ref, query), (name, (measurement, unit)) in zip(queries(panel).items(), expected.items()):
assert f'r["_measurement"] == "{measurement}"' in query
properties = properties_for(panel, ref)
assert properties["displayName"] == name
assert properties.get("unit") == unit
# The River models report mAh.
assert ("float(v: r._value) / 1000.0" in query) is (unit == "amph" and title != "Network")
if title == "Network":
spread = queries(panel)["B"]
assert spread.index("last()") < spread.index('group(columns: ["ups"])') < spread.index("spread()")
def test_network_time_remaining_is_calculated_during_discharge_only(panels):
panel = panels["Network · Time remaining"]
query = queries(panel)["A"]
assert 'r["_measurement"] =~ /^bms_(capacity_remaining_ah|current_amps)$/' in query
steps = [query.index(step) for step in (
"last()", 'pivot(rowKey: ["_field"], columnKey: ["_measurement"], valueColumn: "_value")',
'filter(fn: (r) => r["bms_current_amps"] < 0.0)',
'_value: r["bms_capacity_remaining_ah"] / (0.0 - r["bms_current_amps"])',
)]
assert steps == sorted(steps)
assert defaults_for(panel)["unit"] == "suffix: h"
assert panel["description"].startswith("Calculated, not reported")
@pytest.mark.parametrize("title,series", [
# The River 2 decoder omits the estimate of the idle direction.
("Desktop", {"A": ("ef_discharge_remaining_time_minutes", "To empty"),
@@ -143,7 +382,8 @@ def test_ecoflow_histories_add_a_battery_series(panels, title, ref, measurement,
])
def test_time_remaining_shows_the_direction_in_use_in_hours(panels, title, series):
panel = panels[f"{title} · Time remaining"]
assert set(queries(panel)) == set(series)
# River 2 has one more query, which names the state with no estimate.
assert set(queries(panel)) == set(series) | ({"C"} if title == "Desktop" else set())
for ref, (measurement, name) in series.items():
query = queries(panel)[ref]
assert f'r["_measurement"] == "{measurement}"' in query
@@ -154,13 +394,45 @@ def test_time_remaining_shows_the_direction_in_use_in_hours(panels, title, serie
# A fixed unit: Grafana's duration units round 76 hours to "3 days".
assert defaults["unit"] == "suffix: h"
assert defaults["decimals"] == 1
assert defaults["noValue"] == "No estimate"
assert not any("estimate" in name for name in panels if name != f"{title} · Time remaining")
assert not any("estimate" in name for name in panels)
def test_only_the_time_remaining_panels_change_the_no_value_text(panels):
def state_text(panel, ref):
"""Give the name of a state query, and the text for each of its values."""
properties = properties_for(panel, ref)
(mapping,) = properties["mappings"]
assert properties["color"] == {"mode": "fixed", "fixedColor": "text"}
return properties["displayName"], {value: entry["text"] for value, entry in mapping["options"].items()}
def test_network_time_remaining_names_the_state_on_mains(panels):
panel = panels["Network · Time remaining"]
assert set(queries(panel)) == {"A", "B"}
query = queries(panel)["B"]
assert 'r["_measurement"] == "bms_current_amps"' in query
# The opposite of the discharge rule, so one of the two queries has a value.
steps = [query.index(step) for step in ("last()", "filter(fn: (r) => r._value >= 0.0)", "_value: 0.0")]
assert steps == sorted(steps)
assert state_text(panel, "B") == ("State", {"0": "On mains"})
assert panel["vizConfig"]["spec"]["options"]["textMode"] == "value"
def test_river2_time_remaining_names_the_state_with_no_estimate(panels):
panel = panels["Desktop · Time remaining"]
query = queries(panel)["C"]
# The raw reports keep the values that the decoder omits from A and B.
assert 'r["_measurement"] =~ /^ef_river2_ems_(chg|dsg)_remain_time$/' in query
steps = [query.index(step) for step in (
"last()", "toFloat()", "if r._value == 5999.0 or r._value == 4294967295.0 then 1.0 else 0.0",
'group(columns: ["ups"])', "min()", "filter(fn: (r) => r._value == 1.0)")]
assert steps == sorted(steps)
assert state_text(panel, "C") == ("Battery", {"1": "Idle"})
def test_only_the_river3_time_remaining_changes_the_no_value_text(panels):
"""The other two panels name the state with a query, so no data stays no data."""
for title, panel in panels.items():
expected = "No estimate" if title.endswith("Time remaining") else "No recent data"
expected = "No estimate" if title == "NAS · Time remaining" else "No recent data"
assert panel["vizConfig"]["spec"]["fieldConfig"]["defaults"]["noValue"] == expected
@@ -195,35 +467,51 @@ def test_api_wrapper_is_optional(generator):
assert resource["apiVersion"] == "dashboard.grafana.app/v2"
def test_top_row_has_charge_and_capacity_or_flow_without_reading_age(generator):
dashboard = generator.build_dashboard()
top = {}
for item in dashboard["layout"]["spec"]["items"]:
spec = item["spec"]
if spec["y"] == 0:
title = dashboard["elements"][spec["element"]["name"]]["spec"]["title"]
top[title] = (spec["x"], spec["width"])
def test_each_device_column_starts_with_its_present_state(generator):
position = positions(generator)
expected = {}
for col, (_, title, model) in enumerate(generator.DEVICES):
expected[f"{title} · Charge ({model})"] = (col * 8, 5)
secondary = "Capacity" if model == "JBD" else "Battery power flow"
expected[f"{title} · {secondary}"] = (col * 8 + 5, 3)
assert top == expected
x = col * 8
first, second = ("Pack voltage", "Pack current") if model == "JBD" else ("Input", "Output")
expected.update({
f"{title} · Charge ({model})": (x, 0, 5, 4), f"{title} · Source": (x + 5, 0, 3, 4),
f"{title} · {first}": (x, 4, 4, 4), f"{title} · {second}": (x + 4, 4, 4, 4),
# The names of the protection flags need more width than an error code.
f"{title} · Time remaining": (x, 8, 4 if model == "JBD" else 5, 4),
f"{title} · {'Protection' if model == 'JBD' else 'Errors'}":
(x + 4, 8, 4, 4) if model == "JBD" else (x + 5, 8, 3, 4),
})
assert {title: place for title, place in position.items() if place[1] < 12} == expected
def test_failures_and_charge_follow_the_present_state_at_full_width(generator):
position = positions(generator)
assert position["Power source"] == (0, 12, 24, 4)
assert position["Charge history"] == (0, 16, 24, 7)
# Everything else is detail, below these two.
assert all(y >= 23 for title, (_, y, _, _) in position.items()
if y >= 12 and title not in ("Power source", "Charge history"))
def test_no_panel_shows_the_age_of_a_reading(generator):
dashboard = generator.build_dashboard()
assert all("Last export" not in element["spec"]["title"]
for element in dashboard["elements"].values())
for element in dashboard["elements"].values():
assert all("int(v: now())" not in query for query in queries(element["spec"]).values())
def test_three_column_layout_stays_within_original_height(generator):
def test_layout_has_no_overlap_and_no_hole(generator):
dashboard = generator.build_dashboard()
occupied = set()
for item in dashboard["layout"]["spec"]["items"]:
spec = item["spec"]
assert spec["element"]["name"] in dashboard["elements"]
assert 0 <= spec["x"] < spec["x"] + spec["width"] <= 24
assert 0 <= spec["y"] < spec["y"] + spec["height"] <= 24
assert 0 <= spec["y"] < spec["y"] + spec["height"]
cells = {(x, y) for x in range(spec["x"], spec["x"] + spec["width"])
for y in range(spec["y"], spec["y"] + spec["height"])}
assert not occupied.intersection(cells)
occupied.update(cells)
height = max(y for _, y in occupied) + 1
assert occupied == {(x, y) for x in range(24) for y in range(height)}
+303 -114
View File
@@ -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 {