Wednesday, 13 May 2026

Building a Home Energy Dashboard with Node-RED, MQTT, InfluxDB and Grafana

I recently started building a local energy monitoring dashboard for my home solar setup. The goal was to take live data from my Solis inverter, process it in Node-RED, publish it through MQTT, store it in InfluxDB, and visualise it in Grafana.

This post outlines the first working version of that setup, and is a continuation of the initial retrieval of data from Solis into NodeRed


The Overall Architecture

The final data path looks like this:

Solis inverter
  ↓
Modbus TCP
  ↓
Node-RED
  ↓
MQTT / Mosquitto
  ↓
InfluxDB
  ↓
Grafana

The idea is to keep MQTT as the central message bus. Node-RED reads and decodes the inverter data, publishes useful values to MQTT, and also writes those values into InfluxDB for historical storage. Grafana then reads from InfluxDB to provide dashboards and charts.


Existing Infrastructure

I already had Node-RED and Mosquitto running on my always-on UnRAID NUC. That made it the obvious place to add InfluxDB and Grafana as well.

The Mosquitto broker is running locally on the NUC and is available on the LAN. I used MQTTX to inspect topics and confirm that messages were being published correctly.

The working MQTT broker settings were:

Host: 192.168.1.197
Port: 1883
TLS: Off
Username: blank
Password: blank


Publishing Solis Data to MQTT

The first step was confirming that Node-RED could publish to MQTT.

I created a simple test flow:

Inject → MQTT out

The MQTT topic was:

home/solar/solis/test

Once I saw the test message appear in MQTTX, I started publishing real Solis telemetry.

The first set of topics used this structure:

home/solar/solis/raw/pv_power_w
home/solar/solis/raw/house_load_w
home/solar/solis/raw/grid_import_w
home/solar/solis/raw/grid_export_w
home/solar/solis/raw/battery_soc_pct
home/solar/solis/raw/battery_charge_w
home/solar/solis/raw/battery_discharge_w

Each topic carries a simple numeric payload. For example:

home/solar/solis/raw/pv_power_w    6120


Decoding the Solis Battery Values

One important lesson was that the Solis battery power register did not contain signed charge and discharge data.

The battery power registers showed a positive value both when charging and discharging. For example:

Discharge: [0, 75]
Charge:    [0, 1107]

This showed that the battery power register was a magnitude value only.

The correct direction came from another register:

33135 Battery current direction
0 = charge
1 = discharge

The Node-RED logic therefore became:

const batteryPower = u32(33149, 33150);
const batteryDirection = reg(33135);

battery_charge_w =
    batteryDirection === 0
        ? batteryPower
        : 0;

battery_discharge_w =
    batteryDirection === 1
        ? batteryPower
        : 0;

This fixed the battery flow chart so that charge and discharge were correctly separated.


Installing InfluxDB

Next, I installed InfluxDB 2.x on the NUC.

The initial setup used:

Bucket: solar_raw

The bucket stores the raw Solis telemetry.

In Node-RED, I installed the InfluxDB nodes and created a simple test write:

Inject → Function → InfluxDB out

The test Function node was:

msg.measurement = "test";

msg.payload = {
    value: 123
};

return msg;

Once I confirmed that the value appeared in the InfluxDB Data Explorer, I moved on to writing real Solis values.


Writing Solis Metrics to InfluxDB

The main InfluxDB write Function node uses a measurement called:

solar

The payload contains fields such as:

pv_power_w
house_load_w
grid_import_w
grid_export_w
battery_soc_pct
battery_charge_w
battery_discharge_w

A simplified version looks like this:

msg.measurement = "solar";

msg.payload = {
    pv_power_w: msg.payload.pv_power_w,
    house_load_w: msg.payload.house_load_w,
    grid_import_w: msg.payload.grid_import_w,
    grid_export_w: msg.payload.grid_export_w,
    battery_soc_pct: msg.payload.battery_soc_pct,
    battery_charge_w: msg.payload.battery_charge_w,
    battery_discharge_w: msg.payload.battery_discharge_w
};

return msg;

After deploying the flow, I confirmed the fields in InfluxDB Data Explorer under the solar_raw bucket.


Installing Grafana

I then installed Grafana on the same NUC.

The Grafana web interface was available at:

http://192.168.1.197:3000

I added InfluxDB as a data source using Flux as the query language.

The datasource settings were:

URL: http://192.168.1.197:8086
Organisation: meehab
Bucket: solar_raw
Query language: Flux


Creating the First Dashboard

The first dashboard focused on operational visibility rather than deep analysis.

The initial panels were:

  • PV Power
  • PV Capacity
  • House Load
  • Grid Flow
  • Battery State of Charge
  • Battery Flow
  • Battery Direction


PV Power

The PV Power panel uses this Flux query:

from(bucket: "solar_raw")
  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
  |> filter(fn: (r) => r._measurement == "solar")
  |> filter(fn: (r) => r._field == "pv_power_w")

The important point is the use of:

range(start: v.timeRangeStart, stop: v.timeRangeStop)

This allows the panel to follow the Grafana dashboard time picker. Using a fixed range such as -15m causes the panel to ignore the selected dashboard range.


PV Capacity

The PV Capacity panel shows current PV output as a percentage of the array size.

The array size is 8.1 kW, so the calculation is:

pv_power_w / 8100 * 100

The Flux query is:

from(bucket: "solar_raw")
  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
  |> filter(fn: (r) => r._measurement == "solar")
  |> filter(fn: (r) => r._field == "pv_power_w")
  |> map(fn: (r) => ({
      r with
      _field: "pv_capacity_pct",
      _value: float(v: r._value) / 8100.0 * 100.0
  }))

For this panel, it is important to set the visualisation minimum and maximum explicitly:

Min: 0
Max: 100

Otherwise Grafana may auto-scale the bar and make the percentage visually misleading.


Grid Flow

The Grid Flow panel shows import and export together:

from(bucket: "solar_raw")
  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
  |> filter(fn: (r) => r._measurement == "solar")
  |> filter(fn: (r) => r._field == "grid_export_w" or r._field == "grid_import_w")


Battery Flow

The Battery Flow panel shows charge and discharge:

from(bucket: "solar_raw")
  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
  |> filter(fn: (r) => r._measurement == "solar")
  |> filter(fn: (r) => r._field == "battery_charge_w" or r._field == "battery_discharge_w")


Battery Direction

I also added a small direction panel. It creates a signed value from the latest charge and discharge readings:

from(bucket: "solar_raw")
  |> range(start: v.timeRangeStart, stop: v.timeRangeStop)
  |> filter(fn: (r) => r._measurement == "solar")
  |> filter(fn: (r) => r._field == "battery_charge_w" or r._field == "battery_discharge_w")
  |> last()
  |> map(fn: (r) => ({
      r with
      _field: "battery_direction",
      _value: if r._field == "battery_charge_w" then float(v: r._value) else float(v: r._value) * -1.0
  }))
  |> group(columns: ["_field"])
  |> sum()

In Grafana value mappings, this can then be displayed as:

-99999 to -81 = Discharging
-80 to 80     = Idle
81 to 99999   = Charging

The idle band avoids showing the small inverter housekeeping draw as meaningful battery discharge.


Grafana Lessons Learned

A few useful Grafana lessons came out of the first dashboard build.

  • Use dashboard time variables in Flux queries.
  • Set explicit min and max values for percentage gauges.
  • Use thresholds for operational meaning, not decoration.
  • Set dashboard auto-refresh to match the telemetry polling interval.
  • Duplicate panels rather than building every panel from scratch.
  • Keep raw telemetry and derived metrics conceptually separate.


Current Result

The first version of the dashboard now gives a useful live view of the home energy system:

  • how much solar is being generated
  • how hard the solar array is working
  • how much power the house is using
  • whether the house is importing or exporting
  • the battery state of charge
  • whether the battery is charging, discharging, or idle

This is now a solid baseline for future work.


Next Steps

The next stage will be to add derived energy metrics and automation-friendly states.

Examples include:

  • solar surplus state
  • cheap-rate grid charging state
  • peak tariff avoidance
  • estimated import cost
  • estimated feed-in tariff earnings
  • self-consumption percentage
  • household guidance such as good or bad times to run discretionary loads

For now, the core telemetry pipeline is working: Node-RED reads the inverter, MQTT exposes the live values, InfluxDB stores the history, and Grafana provides the dashboard.

No comments: