#!/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()