Add a Grafana dashboard generator for the three UPS devices

tools/generate_grafana_ups.py writes grafana-ups.json, which shows the JBD network UPS, the River 2 desktop UPS, and the River 3 NAS UPS in three columns. Git ignores the generated file.

Each EcoFlow column shows one time-remaining panel in hours, for the direction in use. The River 2 histories add pack current and remaining capacity. The River 3 reports neither, so its histories add battery power and charge.
This commit is contained in:
tim
2026-10-10 11:56:29 +02:00
parent 0c179d29e5
commit e32d3a8e22
6 changed files with 640 additions and 0 deletions
+3
View File
@@ -5,5 +5,8 @@ __pycache__/
.coverage
/dist/
# Output of tools/generate_grafana_ups.py.
/grafana-ups.json
# Optional third-party Type 7 lookup table. Obtain it separately (see README).
/bmspy/ecoflow_keydata.py
+9
View File
@@ -186,6 +186,15 @@ To push data for a single device only:
InfluxDB connection details can also be supplied via environment variables (`INFLUXDB_V2_URL`, `INFLUXDB_V2_ORG`, `INFLUXDB_V2_TOKEN`) instead of command-line flags.
## Grafana
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.
See the [dashboard guide](docs/grafana-ups.md) for setup and measurement limits.
## Development
Install all extras and run the tests:
+122
View File
@@ -0,0 +1,122 @@
# Three-UPS Grafana dashboard
The repository contains the generator for the dashboard, and Git ignores the
generated file. From the repository root, write `grafana-ups.json`:
```bash
python tools/generate_grafana_ups.py
```
Import `grafana-ups.json` into Grafana. It contains the
Grafana v2 dashboard model directly. Properties such as `title`, `elements`, and
`layout` are at the JSON root, not inside the supplied export's `spec` wrapper.
It uses Flux queries and requires an InfluxDB data source configured for Flux,
not SQL or InfluxQL.
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 original `grafana-network-ups.json` is unchanged.
## Device names
The exporter must write these `ups` tag values:
| Tag | Display label | Device | Column |
|---|---|---|---|
| `network` | Network | JBD serial BMS | Left |
| `desktop` | Desktop | EcoFlow River 2 | Middle |
| `nas` | NAS | EcoFlow River 3 | Right |
The tags are server-assigned names, not device model selectors. Display labels do
not change the stored tags. If your server uses different names, update `DEVICES`
in the generator and regenerate the dashboard. For example:
```bash
uv run --extra ecoflow-bt bmspy-server --socket /tmp/bmspy-ups.sock \
-d network:/dev/ttyUSB0 \
-d 'desktop@RIVER2_MAC/RIVER2_SERIAL/YOUR_USER_ID' \
-d 'nas@RIVER3_MAC/RIVER3_SERIAL/YOUR_USER_ID'
```
Set `INFLUXDB_V2_URL`, `INFLUXDB_V2_ORG`, and `INFLUXDB_V2_TOKEN`.
In another terminal, export all three devices:
```bash
uv run --extra influxdb bmspy-influxdb --socket /tmp/bmspy-ups.sock --bucket ups
```
Do not add `--ups` to this exporter command. That option restricts export to one device.
## 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 EcoFlow histories also show battery values. Each model reports different
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. |
| Temperature history | Remaining capacity on the right axis, in amp-hours. | Charge on the right axis, in percent. |
River 3 reports no battery current and no remaining capacity in amp-hours.
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`.
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 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.
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.
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.
## Regeneration
The generator uses the Python standard library and does not need the original
Grafana export. It writes `grafana-ups.json` in the repository root, or the
file that `--output` names.
The default output is the bare dashboard model for Grafana's JSON import/editor.
To include the API resource wrapper, use `--api-resource`:
```bash
python tools/generate_grafana_ups.py --api-resource --output grafana-ups-resource.json
```
This adds `apiVersion`, `kind`, and `metadata`, and places the dashboard under `spec`.
To change the layout or device names, edit the generator and run it again.
+1
View File
@@ -46,6 +46,7 @@ module-root = ""
source-include = [
"tests/**", "docs/ecoflow-river2-fields.md", "docs/ecoflow-river3-fields.md",
"tools/generate_ecoflow_river3_schema.py", "tools/ecoflow-bt.py", "*.service", "uv.lock",
"docs/grafana-ups.md", "tools/generate_grafana_ups.py",
]
# Do not distribute the external Type 7 table or the unfinished local USB stub.
source-exclude = ["/bmspy/ecoflow_keydata.py", "/bmspy/usbhid.py"]
+229
View File
@@ -0,0 +1,229 @@
"""Regression tests for the generated Grafana v2 dashboard, without Grafana I/O."""
import importlib.util
import json
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
@pytest.fixture(scope="module")
def generator():
spec = importlib.util.spec_from_file_location(
"generate_grafana_ups", ROOT / "tools/generate_grafana_ups.py",
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
@pytest.fixture
def panels(generator):
return {
panel["spec"]["title"]: panel["spec"]
for panel in generator.build_dashboard()["elements"].values()
}
def queries(panel):
return {
query["spec"]["refId"]: query["spec"]["query"]["spec"]["query"]
for query in panel["data"]["spec"]["queries"]
}
def properties_for(panel, ref):
overrides = panel["vizConfig"]["spec"]["fieldConfig"]["overrides"]
return {
prop["id"]: prop["value"]
for override in overrides
if override["matcher"] == {"id": "byFrameRefID", "options": ref}
for prop in override["properties"]
}
@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]
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 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"]
@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]
for query in queries(panel).values():
assert 'from(bucket: "${bucket:raw}")' in query
assert 'r["ups"] == "network"' in query
assert 'r["_field"] == "value"' in query
assert 'range(start: v.timeRangeStart, stop: v.timeRangeStop)' in query
assert 'sort(columns: ["_time"])' in query
assert 'aggregateWindow(' not in query
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"]
assert custom["insertNulls"] is False
assert custom["spanNulls"] is False # Keep actual nulls, rather than hiding them.
assert custom["drawStyle"] == "line"
assert custom["lineInterpolation"] == "linear"
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, {
"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()
# 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
@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"),
"B": ("ef_charge_remaining_time_minutes", "To full")}),
# River 3 sends both estimates at all times. Use the one in use only.
("NAS", {"A": ("ef_remaining_time_minutes", "Time remaining")}),
])
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)
for ref, (measurement, name) in series.items():
query = queries(panel)[ref]
assert f'r["_measurement"] == "{measurement}"' in query
assert 'range(start: -5m, stop: now())' in query
assert query.index("last()") < query.index("float(v: r._value) / 60.0")
assert properties_for(panel, ref) == {"displayName": name}
defaults = panel["vizConfig"]["spec"]["fieldConfig"]["defaults"]
# 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")
def test_only_the_time_remaining_panels_change_the_no_value_text(panels):
for title, panel in panels.items():
expected = "No estimate" if title.endswith("Time remaining") else "No recent data"
assert panel["vizConfig"]["spec"]["fieldConfig"]["defaults"]["noValue"] == expected
def test_latest_readings_still_use_recent_samples(generator):
query = generator.flux("network", "bms_voltage_cells_volts", label="cell")
assert 'range(start: -5m, stop: now())' in query
assert 'sort(columns: ["_time"])' in query
assert 'last()' in query
assert 'aggregateWindow(' not in query
def test_generator_writes_the_dashboard_file(generator, tmp_path, monkeypatch):
output = tmp_path / "dashboard.json"
monkeypatch.setattr("sys.argv", ["generate_grafana_ups.py", "--output", str(output)])
generator.main()
assert output.read_text(encoding="utf-8") == generator.render()
def test_local_dashboard_file_is_current(generator):
"""Git ignores the generated file, so a clone has none until the generator runs."""
dashboard = ROOT / "grafana-ups.json"
if not dashboard.exists():
pytest.skip("grafana-ups.json is not generated")
assert generator.render() == dashboard.read_text(encoding="utf-8")
def test_api_wrapper_is_optional(generator):
dashboard = json.loads(generator.render())
assert "apiVersion" not in dashboard
resource = json.loads(generator.render(api_resource=True))
assert resource["spec"] == dashboard
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"])
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
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):
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
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)
+276
View File
@@ -0,0 +1,276 @@
#!/usr/bin/env python3
"""Generate the three-device Grafana v2 dashboard using only the standard library."""
import argparse
import json
from pathlib import Path
# InfluxDB ups tag, display label, model. Display labels do not rename stored tags.
DEVICES = (("network", "Network", "JBD"), ("desktop", "Desktop", "River 2"), ("nas", "NAS", "River 3"))
SOC = "bms_capacity_charge_ratio"
TEMP = "bms_temperature_celsius"
def flux(device, measurement, *, history=False, label=None, boolean=False, aggregate=True, divide=None):
"""Keep independent series; raw history matches the original Network charts.
*divide* converts a reported unit, such as mAh to Ah or minutes to hours.
"""
start = "v.timeRangeStart" if history else "-5m"
stop = "v.timeRangeStop" if history else "now()"
keys = ["ups", "_measurement", "_field"] + ([label] if label else [])
# 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}")',
' |> 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)")
else:
# group() does not preserve time order. Raw histories need it too.
lines.append(' |> sort(columns: ["_time"])')
if not history:
lines.append(" |> last()")
if boolean:
lines.append(" |> map(fn: (r) => ({r with _value: if r._value then 1.0 else 0.0}))")
if divide:
# A mean is a float already, and an empty interval stays null. A
# latest reading can be a whole number, so convert it first.
value = "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' |> keep(columns: {json.dumps(["_time", "_value"] + keys)})')
return "\n".join(lines)
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}
def build_dashboard():
elements, items = {}, []
def panel(title, group, x, y, width, height, queries=(), unit="none", description="", defaults=None, options=None, overrides=None):
number = len(elements) + 1
name = f"panel-{number}"
field = {"unit": unit, "noValue": "No recent data", "color": {"mode": "palette-classic"}}
field.update(defaults or {})
if group == "timeseries":
field["custom"] = {
"drawStyle": "line", "lineInterpolation": "linear", "lineWidth": 2,
"fillOpacity": 8, "showPoints": "never", "spanNulls": False, "insertNulls": False,
"axisPlacement": "auto", "stacking": {"mode": "none", "group": "A"},
}
opts = {"legend": {"displayMode": "list", "placement": "bottom", "showLegend": True, "calcs": ["lastNotNull", "min", "max"]},
"tooltip": {"mode": "multi", "sort": "none", "hideZeros": False}}
elif group == "bargauge":
opts = {"orientation": "horizontal", "displayMode": "basic", "showUnfilled": True,
"reduceOptions": {"calcs": ["last"], "fields": "", "values": False}}
else:
opts = {"colorMode": "value", "graphMode": "none", "textMode": "value",
"orientation": "auto", "justifyMode": "auto", "wideLayout": True,
"reduceOptions": {"calcs": ["last"], "fields": "", "values": False}}
opts.update(options or {})
elements[name] = {
"kind": "Panel", "spec": {
"id": number, "title": title, "description": description, "links": [],
"data": {"kind": "QueryGroup", "spec": {
"queries": [{"kind": "PanelQuery", "spec": {
"refId": chr(65 + i), "hidden": False,
"query": {"kind": "DataQuery", "group": "influxdb", "version": "v0",
"datasource": {"name": "${datasource}"}, "spec": {"query": query}},
}} for i, query in enumerate(queries)],
"transformations": [], "queryOptions": {"interval": "1m"},
}},
"vizConfig": {"kind": "VizConfig", "group": group, "version": "13.1.0", "spec": {
"options": opts, "fieldConfig": {"defaults": field, "overrides": overrides or []},
}},
},
}
items.append({"kind": "GridLayoutItem", "spec": {
"x": x, "y": y, "width": width, "height": height,
"element": {"kind": "ElementReference", "name": name},
}})
charge = {"min": 0, "max": 1, "decimals": 1, "color": {"mode": "thresholds"},
"thresholds": {"mode": "absolute", "steps": [
{"value": 0, "color": "red"}, {"value": 0.2, "color": "orange"},
{"value": 0.5, "color": "green"}]}}
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)
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,
[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)"},
]}],
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))
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_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_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,
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"))
]
# Grafana's JSON import/editor expects the model, not an API resource envelope.
return {
"title": "UPS overview · Network / Desktop / NAS",
"description": "JBD network UPS, River 2 desktop UPS, and River 3 NAS UPS. Flux datasource; bmspy ups tags and value fields.",
"annotations": [], "cursorSync": "Crosshair", "editable": True, "links": [],
"liveNow": False, "preload": False, "tags": ["bmspy", "UPS", "EcoFlow", "JBD"],
"timeSettings": {"timezone": "browser", "from": "now-6h", "to": "now", "autoRefresh": "30s",
"autoRefreshIntervals": ["30s", "1m", "5m", "15m", "1h"], "hideTimepicker": False, "fiscalYearStartMonth": 0},
"variables": [
{"kind": "DatasourceVariable", "spec": {
"name": "datasource", "label": "InfluxDB (Flux)", "pluginId": "influxdb",
"refresh": "onDashboardLoad", "regex": "", "current": {"text": "", "value": ""},
"options": [], "multi": False, "includeAll": False, "hide": "dontHide",
"skipUrlSync": False, "allowCustomValue": False,
}},
{"kind": "TextVariable", "spec": {
"name": "bucket", "label": "Bucket", "query": "ups", "current": {"text": "ups", "value": "ups"},
"hide": "dontHide", "skipUrlSync": False,
}},
],
"elements": elements, "layout": {"kind": "GridLayout", "spec": {"items": items}},
}
def render(*, api_resource=False):
dashboard = build_dashboard()
if api_resource:
dashboard = {
"apiVersion": "dashboard.grafana.app/v2",
"kind": "Dashboard",
"metadata": {"name": "bmspy-three-upses"},
"spec": dashboard,
}
return json.dumps(dashboard, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=Path(__file__).resolve().parents[1] / "grafana-ups.json")
parser.add_argument("--api-resource", action="store_true",
help="Include the Grafana v2 API resource wrapper (default: bare dashboard model)")
args = parser.parse_args()
args.output.write_text(render(api_resource=args.api_resource), encoding="utf-8")
if __name__ == "__main__":
main()