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:
@@ -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
@@ -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
@@ -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
@@ -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