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
|
Run `python tools/generate_grafana_ups.py` to write `grafana-ups.json`. Import
|
||||||
that file for a three-column view of Network
|
that file for a three-column view of Network
|
||||||
(JBD), Desktop (River 2), and NAS (River 3). Queries use the `network`, `desktop`,
|
(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
|
and `nas` tags, respectively. The top rows show the present state of each
|
||||||
original dashboard. Select an InfluxDB data source configured for Flux.
|
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.
|
See the [dashboard guide](docs/grafana-ups.md) for setup and measurement limits.
|
||||||
|
|
||||||
## Development
|
## 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
|
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.
|
`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 dashboard uses a 24-column grid. The first 23 rows show the present state
|
||||||
the original dashboard's grid height. Grafana can rearrange panels on small screens.
|
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.
|
The original `grafana-network-ups.json` is unchanged.
|
||||||
|
|
||||||
## Device names
|
## Device names
|
||||||
@@ -52,57 +53,116 @@ Do not add `--ups` to this exporter command. That option restricts export to one
|
|||||||
|
|
||||||
## What the panels show
|
## What the panels show
|
||||||
|
|
||||||
All three columns show battery charge and temperature history.
|
The top of the dashboard has one column for each device. Each column shows
|
||||||
The Network column also shows pack voltage, signed current, capacity in amp-hours,
|
these values, from the latest export within five minutes:
|
||||||
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 EcoFlow histories also show battery values. Each model reports different
|
| Panel | Network (JBD) | Desktop (River 2) and NAS (River 3) |
|
||||||
values:
|
|
||||||
|
|
||||||
| Panel | Desktop (River 2) | 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. |
|
| Charge | Reported charge. | Reported charge, and the charge limit of the device. |
|
||||||
| Temperature history | Remaining capacity on the right axis, in amp-hours. | Charge on the right axis, in percent. |
|
| 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.
|
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
|
Current-value panels query the last five minutes, regardless of the history time
|
||||||
picker. An absent series remains absent, not zero.
|
picker. An absent series remains absent, not zero.
|
||||||
A panel with no current series shows a no-data indication.
|
A panel with no current series shows a no-data indication.
|
||||||
The server can return cached Bluetooth readings, which receive new export timestamps.
|
The server can return cached Bluetooth readings, which receive new export timestamps.
|
||||||
|
|
||||||
History panels follow the time picker. Network histories plot recorded samples
|
History panels follow the time picker. The Network cell voltage history plots
|
||||||
without averaging or adding empty intervals, as in `grafana-network-ups.json`.
|
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
|
Lines connect recorded samples, including samples on either side of a period
|
||||||
without exports. A continuous line does not prove that exports continued throughout
|
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
|
that period. Explicit null values still break the line. The queries do not replace
|
||||||
missing readings with zero.
|
missing readings with zero.
|
||||||
|
|
||||||
EcoFlow histories average readings within each display interval. Missing intervals
|
The other histories average readings within each display interval. Missing
|
||||||
remain gaps. Temperature sensors and battery cells remain separate series.
|
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.
|
The dashboard refreshes every 30 seconds.
|
||||||
|
|
||||||
Charge values use ratios from 0 to 1. The dashboard displays them as percentages.
|
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
|
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.
|
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
|
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
|
charge direction.
|
||||||
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.
|
|
||||||
|
|
||||||
The time-remaining panels show the device estimate in hours, for the direction
|
The EcoFlow time-remaining panels show the device estimate in hours, for the
|
||||||
in use. River 2 reports an estimate for that direction only, so its panel shows
|
direction in use. River 2 reports an estimate for that direction only, so its
|
||||||
`To empty` or `To full`. River 3 reports both estimates at all times, so its
|
panel shows `To empty` or `To full`. River 3 reports both estimates at all
|
||||||
panel uses `ef_remaining_time_minutes`, which holds the estimate for the
|
times, so its panel uses `ef_remaining_time_minutes`, which holds the estimate
|
||||||
direction in the flow flags. If the battery is idle, or the device sends no
|
for the direction of the battery power. If the battery is idle, or the device
|
||||||
estimate, the panel shows `No estimate`. A River 3 that supplies its own small
|
sends no estimate, the River 3 panel shows `No estimate`. A River 3 that supplies
|
||||||
losses from the battery counts as discharging, and shows a long time to empty.
|
its own small losses from the battery counts as discharging, and shows a long time
|
||||||
The dashboard does not invent a runtime estimate for the JBD or apply River 2
|
to empty.
|
||||||
state-code meanings to River 3.
|
|
||||||
|
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
|
## Regeneration
|
||||||
|
|
||||||
|
|||||||
+365
-77
@@ -45,39 +45,40 @@ def properties_for(panel, ref):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("title,primary,label,unit,secondary,secondary_unit,display_name", [
|
def defaults_for(panel):
|
||||||
("Network · Cell voltage history", "bms_voltage_cells_volts", "cell", "volt",
|
return panel["vizConfig"]["spec"]["fieldConfig"]["defaults"]
|
||||||
"bms_current_amps", "amp", "Current"),
|
|
||||||
("Network · Temperature history", "bms_temperature_celsius", "sensor", "celsius",
|
|
||||||
"bms_capacity_remaining_ah", "amph", "Remaining Capacity"),
|
def positions(generator):
|
||||||
])
|
dashboard = generator.build_dashboard()
|
||||||
def test_network_histories_keep_original_series_and_separate_axes(
|
return {
|
||||||
panels, title, primary, label, unit, secondary, secondary_unit, display_name,
|
dashboard["elements"][item["spec"]["element"]["name"]]["spec"]["title"]:
|
||||||
):
|
tuple(item["spec"][key] for key in ("x", "y", "width", "height"))
|
||||||
panel = panels[title]
|
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)
|
query = queries(panel)
|
||||||
assert set(query) == {"A", "B"}
|
assert set(query) == {"A", "B"}
|
||||||
assert f'r["_measurement"] == "{primary}"' in query["A"]
|
assert 'r["_measurement"] == "bms_voltage_cells_volts"' in query["A"]
|
||||||
assert f'r["_measurement"] == "{secondary}"' in query["B"]
|
assert 'r["_measurement"] == "bms_current_amps"' in query["B"]
|
||||||
# Cell and sensor identities survive grouping and column selection.
|
# Cell identities survive grouping and column selection.
|
||||||
keys = ["ups", "_measurement", "_field", label]
|
keys = ["ups", "_measurement", "_field", "cell"]
|
||||||
assert f'group(columns: {json.dumps(keys)})' in query["A"]
|
assert f'group(columns: {json.dumps(keys)})' in query["A"]
|
||||||
assert f'keep(columns: {json.dumps(["_time", "_value"] + keys)})' in query["A"]
|
assert f'keep(columns: {json.dumps(["_time", "_value"] + keys)})' in query["A"]
|
||||||
defaults = panel["vizConfig"]["spec"]["fieldConfig"]["defaults"]
|
assert defaults_for(panel)["unit"] == "volt"
|
||||||
assert defaults["unit"] == unit
|
assert "${__field.labels.cell}" in defaults_for(panel)["displayName"]
|
||||||
assert "${__field.labels." + label + "}" in defaults["displayName"]
|
current = properties_for(panel, "B")
|
||||||
secondary_properties = properties_for(panel, "B")
|
assert current["unit"] == "amp"
|
||||||
assert secondary_properties["unit"] == secondary_unit
|
assert current["displayName"] == "Current"
|
||||||
assert secondary_properties["displayName"] == display_name
|
assert current["custom.axisPlacement"] == "right"
|
||||||
assert secondary_properties["custom.axisPlacement"] == "right"
|
assert current["custom.axisLabel"]
|
||||||
assert secondary_properties["custom.axisLabel"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("title", [
|
def test_network_cell_history_does_not_invent_empty_intervals(panels):
|
||||||
"Network · Cell voltage history", "Network · Temperature history",
|
panel = panels["Network · Cell voltage history"]
|
||||||
])
|
|
||||||
def test_network_histories_do_not_invent_empty_intervals(panels, title):
|
|
||||||
panel = panels[title]
|
|
||||||
for query in queries(panel).values():
|
for query in queries(panel).values():
|
||||||
assert 'from(bucket: "${bucket:raw}")' in query
|
assert 'from(bucket: "${bucket:raw}")' in query
|
||||||
assert 'r["ups"] == "network"' 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 'last()' not in query
|
||||||
assert 'fill(' not in query
|
assert 'fill(' not in query
|
||||||
assert 'map(' not in query # In particular, do not change the current's sign.
|
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["insertNulls"] is False
|
||||||
assert custom["spanNulls"] is False # Keep actual nulls, rather than hiding them.
|
assert custom["spanNulls"] is False # Keep actual nulls, rather than hiding them.
|
||||||
assert custom["drawStyle"] == "line"
|
assert custom["drawStyle"] == "line"
|
||||||
@@ -96,44 +97,282 @@ def test_network_histories_do_not_invent_empty_intervals(panels, title):
|
|||||||
assert panel["data"]["spec"]["transformations"] == []
|
assert panel["data"]["spec"]["transformations"] == []
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("device,title", [("desktop", "Desktop"), ("nas", "NAS")])
|
@pytest.mark.parametrize("device,title,measurement,expected", [
|
||||||
def test_ecoflow_histories_average_each_display_interval(panels, device, title):
|
# River 2 reports pack current. River 3 reports none, so its panel uses
|
||||||
for suffix, refs in (("Temperature history", {"A", "B"}), ("Input / output history", {"A", "B", "C"})):
|
# battery power, which stays on the watt axis.
|
||||||
panel = panels[f"{title} · {suffix}"]
|
("desktop", "Desktop", "ef_river2_bms_amp", {
|
||||||
assert set(queries(panel)) == refs
|
"displayName": "Current", "unit": "amp",
|
||||||
|
"custom.axisPlacement": "right", "custom.axisLabel": "Current (A)"}),
|
||||||
|
("nas", "NAS", "ef_river3_display_pow_get_bms", {"displayName": "Battery"}),
|
||||||
|
])
|
||||||
|
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():
|
for query in queries(panel).values():
|
||||||
assert f'r["ups"] == "{device}"' in query
|
assert f'r["ups"] == "{device}"' in query
|
||||||
assert 'aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)' in query
|
assert 'aggregateWindow(every: v.windowPeriod, fn: mean, createEmpty: true)' in query
|
||||||
assert 'float(v:' not in query # An empty interval stays null.
|
assert 'map(' not in query # An empty interval stays null.
|
||||||
|
assert f'r["_measurement"] == "{measurement}"' in queries(panel)["C"]
|
||||||
|
properties = properties_for(panel, "C")
|
||||||
@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, {
|
|
||||||
"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"}),
|
|
||||||
])
|
|
||||||
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)
|
|
||||||
assert expected.items() <= properties.items()
|
assert expected.items() <= properties.items()
|
||||||
# Battery power stays on the watt axis of the panel.
|
|
||||||
if "unit" not in expected:
|
if "unit" not in expected:
|
||||||
assert "unit" not in properties and "custom.axisPlacement" not in properties
|
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", [
|
@pytest.mark.parametrize("title,series", [
|
||||||
# The River 2 decoder omits the estimate of the idle direction.
|
# The River 2 decoder omits the estimate of the idle direction.
|
||||||
("Desktop", {"A": ("ef_discharge_remaining_time_minutes", "To empty"),
|
("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):
|
def test_time_remaining_shows_the_direction_in_use_in_hours(panels, title, series):
|
||||||
panel = panels[f"{title} · Time remaining"]
|
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():
|
for ref, (measurement, name) in series.items():
|
||||||
query = queries(panel)[ref]
|
query = queries(panel)[ref]
|
||||||
assert f'r["_measurement"] == "{measurement}"' in query
|
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".
|
# A fixed unit: Grafana's duration units round 76 hours to "3 days".
|
||||||
assert defaults["unit"] == "suffix: h"
|
assert defaults["unit"] == "suffix: h"
|
||||||
assert defaults["decimals"] == 1
|
assert defaults["decimals"] == 1
|
||||||
assert defaults["noValue"] == "No estimate"
|
assert not any("estimate" in name for name in panels)
|
||||||
assert not any("estimate" in name for name in panels if name != f"{title} · Time remaining")
|
|
||||||
|
|
||||||
|
|
||||||
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():
|
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
|
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"
|
assert resource["apiVersion"] == "dashboard.grafana.app/v2"
|
||||||
|
|
||||||
|
|
||||||
def test_top_row_has_charge_and_capacity_or_flow_without_reading_age(generator):
|
def test_each_device_column_starts_with_its_present_state(generator):
|
||||||
dashboard = generator.build_dashboard()
|
position = positions(generator)
|
||||||
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"])
|
|
||||||
expected = {}
|
expected = {}
|
||||||
for col, (_, title, model) in enumerate(generator.DEVICES):
|
for col, (_, title, model) in enumerate(generator.DEVICES):
|
||||||
expected[f"{title} · Charge ({model})"] = (col * 8, 5)
|
x = col * 8
|
||||||
secondary = "Capacity" if model == "JBD" else "Battery power flow"
|
first, second = ("Pack voltage", "Pack current") if model == "JBD" else ("Input", "Output")
|
||||||
expected[f"{title} · {secondary}"] = (col * 8 + 5, 3)
|
expected.update({
|
||||||
assert top == expected
|
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"]
|
assert all("Last export" not in element["spec"]["title"]
|
||||||
for element in dashboard["elements"].values())
|
for element in dashboard["elements"].values())
|
||||||
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())
|
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()
|
dashboard = generator.build_dashboard()
|
||||||
occupied = set()
|
occupied = set()
|
||||||
for item in dashboard["layout"]["spec"]["items"]:
|
for item in dashboard["layout"]["spec"]["items"]:
|
||||||
spec = item["spec"]
|
spec = item["spec"]
|
||||||
assert spec["element"]["name"] in dashboard["elements"]
|
assert spec["element"]["name"] in dashboard["elements"]
|
||||||
assert 0 <= spec["x"] < spec["x"] + spec["width"] <= 24
|
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"])
|
cells = {(x, y) for x in range(spec["x"], spec["x"] + spec["width"])
|
||||||
for y in range(spec["y"], spec["y"] + spec["height"])}
|
for y in range(spec["y"], spec["y"] + spec["height"])}
|
||||||
assert not occupied.intersection(cells)
|
assert not occupied.intersection(cells)
|
||||||
occupied.update(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)}
|
||||||
|
|||||||
+300
-111
@@ -10,27 +10,68 @@ from pathlib import Path
|
|||||||
DEVICES = (("network", "Network", "JBD"), ("desktop", "Desktop", "River 2"), ("nas", "NAS", "River 3"))
|
DEVICES = (("network", "Network", "JBD"), ("desktop", "Desktop", "River 2"), ("nas", "NAS", "River 3"))
|
||||||
SOC = "bms_capacity_charge_ratio"
|
SOC = "bms_capacity_charge_ratio"
|
||||||
TEMP = "bms_temperature_celsius"
|
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.
|
"""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.
|
*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"
|
start = "v.timeRangeStart" if history else "-5m"
|
||||||
stop = "v.timeRangeStop" if history else "now()"
|
stop = "v.timeRangeStop" if history else "now()"
|
||||||
keys = ["ups", "_measurement", "_field"] + ([label] if label else [])
|
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.
|
# Own the quotes here; the Flux datasource's default interpolation can add them.
|
||||||
lines = [
|
lines = [
|
||||||
'from(bucket: "${bucket:raw}")',
|
'from(bucket: "${bucket:raw}")',
|
||||||
f" |> range(start: {start}, stop: {stop})",
|
f" |> range(start: {start}, stop: {stop})",
|
||||||
f' |> filter(fn: (r) => r["ups"] == "{device}")',
|
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")',
|
' |> filter(fn: (r) => r["_field"] == "value")',
|
||||||
f" |> group(columns: {json.dumps(keys)})",
|
f" |> group(columns: {json.dumps(keys)})",
|
||||||
]
|
]
|
||||||
if history and aggregate:
|
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:
|
else:
|
||||||
# group() does not preserve time order. Raw histories need it too.
|
# group() does not preserve time order. Raw histories need it too.
|
||||||
lines.append(' |> sort(columns: ["_time"])')
|
lines.append(' |> sort(columns: ["_time"])')
|
||||||
@@ -41,25 +82,64 @@ def flux(device, measurement, *, history=False, label=None, boolean=False, aggre
|
|||||||
if divide:
|
if divide:
|
||||||
# A mean is a float already, and an empty interval stays null. A
|
# A mean is a float already, and an empty interval stays null. A
|
||||||
# latest reading can be a whole number, so convert it first.
|
# latest reading can be a whole number, so convert it first.
|
||||||
value = "r._value" if history and aggregate else "float(v: r._value)"
|
reading = "r._value" if history and aggregate else "float(v: r._value)"
|
||||||
lines.append(f" |> map(fn: (r) => ({{r with _value: {value} / {float(divide)}}}))")
|
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)})')
|
lines.append(f' |> keep(columns: {json.dumps(["_time", "_value"] + keys)})')
|
||||||
return "\n".join(lines)
|
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):
|
def right_axis(ref, name, unit, axis_label, **extra):
|
||||||
"""Put one query on the right axis, with its own name and unit."""
|
"""Put one query on the right axis, with its own name and unit."""
|
||||||
properties = [{"id": "displayName", "value": name}, {"id": "unit", "value": unit}]
|
return override(ref, name, {"unit": unit, **extra, "custom.axisPlacement": "right",
|
||||||
properties += [{"id": key, "value": value} for key, value in extra.items()]
|
"custom.axisLabel": axis_label})
|
||||||
properties += [{"id": "custom.axisPlacement", "value": "right"},
|
|
||||||
{"id": "custom.axisLabel", "value": axis_label}]
|
|
||||||
return {"matcher": {"id": "byFrameRefID", "options": ref}, "properties": properties}
|
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():
|
def build_dashboard():
|
||||||
elements, items = {}, []
|
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
|
number = len(elements) + 1
|
||||||
name = f"panel-{number}"
|
name = f"panel-{number}"
|
||||||
field = {"unit": unit, "noValue": "No recent data", "color": {"mode": "palette-classic"}}
|
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"]},
|
opts = {"legend": {"displayMode": "list", "placement": "bottom", "showLegend": True, "calcs": ["lastNotNull", "min", "max"]},
|
||||||
"tooltip": {"mode": "multi", "sort": "none", "hideZeros": False}}
|
"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":
|
elif group == "bargauge":
|
||||||
opts = {"orientation": "horizontal", "displayMode": "basic", "showUnfilled": True,
|
opts = {"orientation": "horizontal", "displayMode": "basic", "showUnfilled": True,
|
||||||
"reduceOptions": {"calcs": ["last"], "fields": "", "values": False}}
|
"reduceOptions": {"calcs": ["last"], "fields": "", "values": False}}
|
||||||
@@ -79,6 +166,9 @@ def build_dashboard():
|
|||||||
opts = {"colorMode": "value", "graphMode": "none", "textMode": "value",
|
opts = {"colorMode": "value", "graphMode": "none", "textMode": "value",
|
||||||
"orientation": "auto", "justifyMode": "auto", "wideLayout": True,
|
"orientation": "auto", "justifyMode": "auto", "wideLayout": True,
|
||||||
"reduceOptions": {"calcs": ["last"], "fields": "", "values": False}}
|
"reduceOptions": {"calcs": ["last"], "fields": "", "values": False}}
|
||||||
|
field.setdefault("custom", {}).update(custom or {})
|
||||||
|
if not field["custom"]:
|
||||||
|
del field["custom"]
|
||||||
opts.update(options or {})
|
opts.update(options or {})
|
||||||
elements[name] = {
|
elements[name] = {
|
||||||
"kind": "Panel", "spec": {
|
"kind": "Panel", "spec": {
|
||||||
@@ -105,127 +195,226 @@ def build_dashboard():
|
|||||||
"thresholds": {"mode": "absolute", "steps": [
|
"thresholds": {"mode": "absolute", "steps": [
|
||||||
{"value": 0, "color": "red"}, {"value": 0.2, "color": "orange"},
|
{"value": 0, "color": "red"}, {"value": 0.2, "color": "orange"},
|
||||||
{"value": 0.5, "color": "green"}]}}
|
{"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):
|
for col, (device, title, model) in enumerate(DEVICES):
|
||||||
x = col * 8
|
x = col * 8
|
||||||
panel(f"{title} · Charge ({model})", "stat", x, 0, 5, 4,
|
charge_queries, charge_overrides, charge_options = [flux(device, SOC)], [], None
|
||||||
[flux(device, SOC)], "percentunit", defaults=charge,
|
charge_description = "Latest reported charge within five minutes, regardless of the history time range. Missing data is not zero charge."
|
||||||
description="Latest reported charge within five minutes, regardless of the history time range. Missing data is not zero charge.")
|
if model != "JBD":
|
||||||
temperature_queries = [flux(device, TEMP, history=True, label="sensor", aggregate=model != "JBD")]
|
limit = "ef_river2_ems_max_charge_soc" if model == "River 2" else "ef_river3_display_cms_max_chg_soc"
|
||||||
temperature_overrides = []
|
charge_queries.append(flux(device, limit, divide=100))
|
||||||
temperature_description = "Reported sensors, without combining them. River 3 uses the decoder's preferred battery temperature. Legend values refer to the selected history range."
|
charge_overrides = [override("A", "Charge"), override("B", "Limit", {"color": fixed("text")})]
|
||||||
if model == "JBD":
|
charge_options = {"textMode": "value_and_name"}
|
||||||
temperature_queries.append(flux(device, "bms_capacity_remaining_ah", history=True, aggregate=False))
|
charge_description += " Limit is the maximum charge that the device is set to. A charge at the limit is a full battery."
|
||||||
temperature_overrides = [{
|
panel(f"{title} · Charge ({model})", "stat", x, 0, 5, 4, charge_queries, "percentunit",
|
||||||
"matcher": {"id": "byFrameRefID", "options": "B"}, "properties": [
|
defaults=charge, options=charge_options, overrides=charge_overrides,
|
||||||
{"id": "displayName", "value": "Remaining Capacity"},
|
description=charge_description)
|
||||||
{"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)
|
|
||||||
|
|
||||||
for offset, metric, title, unit in ((0, "bms_voltage_total_volts", "Pack voltage", "volt"),
|
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")):
|
(4, "bms_current_amps", "Pack current", "amp")):
|
||||||
panel(f"Network · {title}", "stat", offset, 4, 4, 4, [flux("network", metric)], unit,
|
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.")
|
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,
|
# 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_voltage_cells_volts", history=True, label="cell", aggregate=False),
|
||||||
flux("network", "bms_current_amps", history=True, aggregate=False)], "volt",
|
flux("network", "bms_current_amps", history=True, aggregate=False)], "volt",
|
||||||
defaults={"displayName": "Cell ${__field.labels.cell}", "decimals": 3},
|
defaults={"displayName": "Cell ${__field.labels.cell}", "decimals": 3},
|
||||||
overrides=[{"matcher": {"id": "byFrameRefID", "options": "B"}, "properties": [
|
overrides=[right_axis("B", "Current", "amp", "Current (A)", decimals=2)],
|
||||||
{"id": "displayName", "value": "Current"},
|
|
||||||
{"id": "unit", "value": "amp"},
|
|
||||||
{"id": "decimals", "value": 2},
|
|
||||||
{"id": "custom.axisPlacement", "value": "right"},
|
|
||||||
{"id": "custom.axisLabel", "value": "Current (A)"},
|
|
||||||
]}],
|
|
||||||
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.")
|
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):
|
for col, (device, title, model) in enumerate(DEVICES[1:], start=1):
|
||||||
x = col * 8
|
power_queries = [flux(device, measurement, history=True)
|
||||||
power_queries = []
|
for measurement in ("ef_input_power_watts", "ef_output_power_watts")]
|
||||||
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))
|
|
||||||
if model == "River 2":
|
if model == "River 2":
|
||||||
# Signed pack current, in the whole amps that the battery heartbeat reports.
|
# Signed pack current, in the whole amps that the battery heartbeat reports.
|
||||||
power_queries.append(flux(device, "ef_river2_bms_amp", history=True))
|
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."
|
battery_description = "Reported pack current (A) on the right axis, in whole amps, negative during discharge."
|
||||||
else:
|
else:
|
||||||
# River 3 reports no battery current. Signed battery power shares the watt axis.
|
# 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))
|
power_queries.append(flux(device, "ef_river3_display_pow_get_bms", history=True))
|
||||||
battery = {"matcher": {"id": "byFrameRefID", "options": "C"},
|
battery = override("C", "Battery", {"color": fixed("blue")})
|
||||||
"properties": [{"id": "displayName", "value": "Battery"}]}
|
|
||||||
battery_description = "Reported battery power (W) on the same axis, positive during charge and negative during discharge. River 3 reports no battery current."
|
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", col * 8, 23, 8, 6,
|
||||||
panel(f"{title} · Input / output history", "timeseries", x, 12, 8, 6,
|
|
||||||
power_queries, "watt",
|
power_queries, "watt",
|
||||||
description="External input and output totals. Do not subtract them to infer battery charge or discharge. " + battery_description,
|
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}"})
|
defaults={"decimals": 0, "displayName": "${__field.labels._measurement}"},
|
||||||
# Display aliases are per-query overrides, not inferred from numeric values.
|
# Display aliases are per-query overrides, not inferred from numeric values.
|
||||||
last = elements[f"panel-{len(elements)}"]["spec"]["vizConfig"]["spec"]["fieldConfig"]
|
overrides=[override("A", "Input", {"color": fixed("green")}),
|
||||||
last["overrides"] = [
|
override("B", "Output", {"color": fixed("orange")}), battery])
|
||||||
{"matcher": {"id": "byFrameRefID", "options": ref}, "properties": [
|
|
||||||
{"id": "displayName", "value": label}, {"id": "color", "value": {"mode": "fixed", "fixedColor": color}}]}
|
# Rows 29 to 38: values that change slowly.
|
||||||
for ref, label, color in (("A", "Input", "green"), ("B", "Output", "orange"))
|
panel("Output power", "timeseries", 0, 29, 12, 6,
|
||||||
] + [battery]
|
[flux(device, "ef_output_power_watts", history=True) for device, _, _ in DEVICES[1:]], "watt",
|
||||||
if model == "River 2":
|
defaults={"decimals": 0},
|
||||||
# The decoder omits the estimate of the idle direction (5999).
|
overrides=[override(chr(65 + i), title) for i, (_, title, _) in enumerate(DEVICES[1:])],
|
||||||
estimates = (("ef_discharge_remaining_time_minutes", "To empty"),
|
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.")
|
||||||
("ef_charge_remaining_time_minutes", "To full"))
|
panel("Temperature", "timeseries", 12, 29, 12, 6,
|
||||||
estimate_description = "River 2 reports an estimate for the direction in use only, so one value shows at most."
|
[flux(device, TEMP, history=True, label="sensor") for device, _, _ in DEVICES], "celsius",
|
||||||
else:
|
defaults={"decimals": 1},
|
||||||
# River 3 always sends both estimates. This field holds the one in use.
|
overrides=[override("A", "Network sensor ${__field.labels.sensor}"), override("B", "Desktop"), override("C", "NAS")],
|
||||||
estimates = (("ef_remaining_time_minutes", "Time remaining"),)
|
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.")
|
||||||
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."
|
capacity = {"unit": "amph", "decimals": 1}
|
||||||
panel(f"{title} · Time remaining", "stat", x, 8, 8, 4,
|
battery_panels = {
|
||||||
[flux(device, metric, divide=60) for metric, _ in estimates], "suffix: h",
|
"JBD": (
|
||||||
defaults={"decimals": 1, "noValue": "No estimate"},
|
("Cycles", flux("network", "bms_charge_cycles"), {}),
|
||||||
options={"textMode": "value_and_name" if len(estimates) > 1 else "value"},
|
# Highest minus lowest cell voltage. A growing value shows cells that drift apart.
|
||||||
overrides=[{"matcher": {"id": "byFrameRefID", "options": chr(65 + i)},
|
("Cell spread", flux("network", "bms_voltage_cells_volts", label="cell",
|
||||||
"properties": [{"id": "displayName", "value": label}]}
|
steps=[' |> group(columns: ["ups"])', " |> spread()"]), {"unit": "volt", "decimals": 3}),
|
||||||
for i, (_, label) in enumerate(estimates)],
|
("Remaining", flux("network", "bms_capacity_remaining_ah"), capacity),
|
||||||
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.")
|
("Nominal", flux("network", "bms_capacity_nominal_ah"), capacity),
|
||||||
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)],
|
"River 2": (
|
||||||
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.",
|
("Cycles", flux("desktop", "ef_river2_bms_cycles"), {}),
|
||||||
defaults={"mappings": [{"type": "value", "options": {
|
("Health", flux("desktop", "ef_river2_bms_soh"), {"unit": "percent"}),
|
||||||
"0": {"text": "No", "color": "text"}, "1": {"text": "Yes", "color": "blue"}}}]},
|
("Full", flux("desktop", "ef_river2_bms_full_cap", divide=1000), capacity),
|
||||||
options={"textMode": "value_and_name"})
|
("Design", flux("desktop", "ef_river2_bms_design_cap", divide=1000), capacity),
|
||||||
elements[f"panel-{len(elements)}"]["spec"]["vizConfig"]["spec"]["fieldConfig"]["overrides"] = [
|
),
|
||||||
{"matcher": {"id": "byFrameRefID", "options": ref}, "properties": [{"id": "displayName", "value": label}]}
|
"River 3": (
|
||||||
for ref, label in (("A", "Charging"), ("B", "Discharging"))
|
("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.
|
# Grafana's JSON import/editor expects the model, not an API resource envelope.
|
||||||
return {
|
return {
|
||||||
|
|||||||
Reference in New Issue
Block a user