Home Assistant: An Advanced GoodWe Inverter and Battery Dashboard

Home Assistant: An Advanced GoodWe Inverter and Battery Dashboard

A complete build of a GoodWe hybrid inverter and battery dashboard in Home Assistant: every template sensor, every panel, the automations the data makes possible, and the mistakes made on the way. Entity names are GoodWe’s; the structure, the maths and the traps apply to any inverter with a local connection.

  • InverterA GoodWe ET-series hybrid, or any inverter the GoodWe integration or a Modbus integration can read locally. Cloud-only integrations poll too slowly for the live panels.
  • Helpersinput_number entities for battery capacity, array size, performance ratio, tariffs and system cost; utility_meter helpers for daily, monthly and billing-period totals; a statistics helper for the 20-minute average discharge rate.
  • HACS cardsstack-in-card, Mushroom, apexcharts-card, sankey-chart, power-flow-card-plus and card-mod. None changes the data; the sensors are the substance and the cards are presentation.

It is long on purpose. Skim the headings and take the parts you need; nothing later depends on having read everything before it. The GoodWe integration itself, and getting the inverter talking to Home Assistant over Modbus, is covered in the earlier solar and battery monitoring post.

Who it is for. Anyone running a GoodWe ET-series hybrid (or a similar inverter with a local Modbus connection) who has the integration working and now has a hundred-odd entities and no clear idea what to do with them. The entity names here are GoodWe’s. The structure, the traps and the maths apply to any brand.

Most Home Assistant solar dashboards start the same way: install the integration, drop every entity the inverter offers onto a page, and end up with a wall of numbers nobody looks at twice. This is the rebuild of one such page into something denser, faster to read, and right.

The finished view carries 22 panels across 7 columns, 73 entities and 27 live templates. Everything is included below: the template sensors that feed it, the view skeleton, and the YAML for every panel. Only the entity names are specific to this inverter; the structure transfers to any brand.

A note on where the code goes. Template sensors live in configuration.yaml. The dashboard is storage mode, so its YAML is pasted through the dashboard’s three-dot menu ? Edit ? Raw configuration editor, not into ui-lovelace.yaml.

1. What the dashboard had to do

  1. Answer “what is happening right now” at a glance: generation, house load, battery, grid.
  2. Answer “was today normal”, which needs a benchmark rather than a bare number.
  3. Track money against the actual tariff, not a guess.
  4. Be readable on a phone, because that is where it is usually opened.

2. Before the cards: build the sensor layer

Start here, not with cards. A dashboard is only as good as the sensors behind it, and inverter integrations expose data in shapes convenient for the manufacturer rather than for you. Everything in this section goes under template: in configuration.yaml.

2.1 Establish the sign conventions first

Most hybrid inverters report battery and grid power as a single signed value, and which sign means which direction is not documented reliably. It differs between brands, and sometimes between the two values on the same inverter.

On this system the battery is positive when discharging and the grid is positive when exporting. A second system at another property had the battery the same way round and the grid the opposite. Assuming they pair is how you end up with a flow diagram showing the battery feeding the house while it charges.

Verify each independently, by measurement:

  • Find a period with generation at zero and the battery idle. Whatever the house draws must be coming from the grid, so the sign at that moment is unambiguous.
  • For the battery, correlate the sign of its power against the direction state-of-charge is moving, over a few hundred samples. Positive power coinciding with falling SoC ninety-odd times and rising SoC never is conclusive.
  • Cross-check the identity house load = generation + battery + grid. If it closes to within a watt or two on well-aligned samples, the signs are right.

One caveat: if your inverter computes house load from the other three rather than measuring it, that identity closes by construction whatever your signs are. It then confirms conventions but is useless as an independent check. Know which kind you have.

2.2 Split the signed values into one-directional pairs

Sankey diagrams and flow cards cannot take a negative, and neither can an energy dashboard that wants separate import and export legs. Clamp them into four clean sensors:

yaml · 47 lines
- name: GoodWe Battery Charge Power
  unique_id: goodwe_battery_charge_power
  unit_of_measurement: W
  device_class: power
  state_class: measurement
  icon: mdi:battery-plus-variant
  availability: >-
    {{ states('sensor.goodwe_battery_power')
       not in ['unknown', 'unavailable', 'none'] }}
  state: >-
    {{ [ 0 - (states('sensor.goodwe_battery_power') | float(0)), 0 ] | max | round(0) }}

- name: GoodWe Battery Discharge Power
  unique_id: goodwe_battery_discharge_power
  unit_of_measurement: W
  device_class: power
  state_class: measurement
  icon: mdi:battery-minus-variant
  availability: >-
    {{ states('sensor.goodwe_battery_power')
       not in ['unknown', 'unavailable', 'none'] }}
  state: >-
    {{ [ states('sensor.goodwe_battery_power') | float(0), 0 ] | max | round(0) }}

- name: GoodWe Grid Import Power
  unique_id: goodwe_grid_import_power
  unit_of_measurement: W
  device_class: power
  state_class: measurement
  icon: mdi:transmission-tower-import
  availability: >-
    {{ states('sensor.goodwe_active_power')
       not in ['unknown', 'unavailable', 'none'] }}
  state: >-
    {{ [ 0 - (states('sensor.goodwe_active_power') | float(0)), 0 ] | max | round(0) }}

- name: GoodWe Grid Export Power
  unique_id: goodwe_grid_export_power
  unit_of_measurement: W
  device_class: power
  state_class: measurement
  icon: mdi:transmission-tower-export
  availability: >-
    {{ states('sensor.goodwe_active_power')
       not in ['unknown', 'unavailable', 'none'] }}
  state: >-
    {{ [ states('sensor.goodwe_active_power') | float(0), 0 ] | max | round(0) }}

Note the direction each one clamps. max(x, 0) keeps the positive part; max(-x, 0) keeps the negative part as a positive number. Getting these the wrong way round is silent: the card draws the wrong leg.

2.3 Battery state that means something

Inverters rarely expose pack capacity, so stored energy has to be derived from state-of-charge and a capacity you supply as an input_number:

yaml · 12 lines
- name: GoodWe Battery Stored Energy
  unique_id: goodwe_battery_stored_energy
  unit_of_measurement: kWh
  device_class: energy_storage
  state_class: measurement
  icon: mdi:battery-heart-variant
  availability: >-
    {{ states('sensor.goodwe_battery_state_of_charge')
       not in ['unknown', 'unavailable', 'none'] }}
  state: >-
    {{ ( states('sensor.goodwe_battery_state_of_charge') | float(0) / 100
         * states('input_number.goodwe_battery_capacity') | float(0) ) | round(2) }}

The trustworthy charging/discharging readout is derived from the sign of battery power with a deadband, not from the inverter’s own mode text, which lags by a polling interval:

yaml · 35 lines
- name: GoodWe Battery Activity
  unique_id: goodwe_battery_activity
  icon: >-
    {% set p = states('sensor.goodwe_battery_power') | float(0) %}
    {% if p > 20 %}mdi:battery-arrow-down
    {% elif p < -20 %}mdi:battery-arrow-up
    {% else %}mdi:battery-heart-variant{% endif %}
  availability: >-
    {{ states('sensor.goodwe_battery_power')
       not in ['unknown', 'unavailable', 'none'] }}
  state: >-
    {% set p = states('sensor.goodwe_battery_power') | float(0) %}
    {% if p > 20 %}Discharging
    {% elif p < -20 %}Charging
    {% else %}Idle{% endif %}
  attributes:
    rate: >-
      {% set p = states('sensor.goodwe_battery_power') | float(0) | abs %}
      {% if p < 1000 %}{{ p | round(0) }} W
      {% else %}{{ (p / 1000) | round(2) }} kW{% endif %}
    time_left: >-
      {% set p = states('sensor.goodwe_battery_power') | float(0) %}
      {% set usable = states('sensor.goodwe_battery_usable_energy') | float(0) %}
      {% set cap = states('input_number.goodwe_battery_capacity') | float(0) %}
      {% set stored = states('sensor.goodwe_battery_stored_energy') | float(0) %}
      {% set dis = states('sensor.battery_discharge_power_avg') | float(0) %}
      {% set chg = states('sensor.battery_charge_power_avg') | float(0) %}
      {% if p > 20 and dis > 50 and usable > 0 %}
        {% set h = usable / (dis / 1000) %}
        {{ h | int }}h {{ '%02d'|format(((h - (h | int)) * 60) | round(0) | int) }}m to grid
      {% elif p < -20 and chg > 50 and cap > stored %}
        {% set h = (cap - stored) / (chg / 1000) %}
        {{ h | int }}h {{ '%02d'|format(((h - (h | int)) * 60) | round(0) | int) }}m to full
      {% elif states('sensor.goodwe_battery_state_of_charge') | float(0) >= 99 %}full
      {% else %}steady{% endif %}

A naming trap worth knowing: the integration already owned sensor.goodwe_battery_status, so a template named “GoodWe Battery Status” silently landed as ..._status_2. Check for an existing entity before naming a template sensor after an inverter concept.

2.4 Battery runtime forecast

“When does the battery run out” is a useful number, and it must use a rolling average discharge rate rather than the instantaneous figure. Spot draw swings by kilowatts as appliances cycle, which made the estimate jump by hours. A statistics helper holding a 20-minute mean fixes it. The sensor is availability-gated so it emits nothing rather than nonsense before the helper has samples:

yaml · 23 lines
- name: GoodWe Battery Runs Out
  unique_id: goodwe_battery_runs_out
  device_class: timestamp
  icon: mdi:transmission-tower-import
  availability: >-
    {{ states('sensor.goodwe_battery_power') | float(0) > 20
       and states('sensor.battery_discharge_power_avg') | float(0) > 50
       and states('sensor.goodwe_battery_usable_energy') | float(0) > 0 }}
  state: >-
    {% set usable = states('sensor.goodwe_battery_usable_energy') | float(0) %}
    {% set dis = states('sensor.battery_discharge_power_avg') | float(0) / 1000 %}
    {{ (now() + timedelta(hours = [usable / dis, 240] | min)).isoformat() }}
  attributes:
    hours_remaining: >-
      {% set usable = states('sensor.goodwe_battery_usable_energy') | float(0) %}
      {% set dis = states('sensor.battery_discharge_power_avg') | float(0) / 1000 %}
      {{ (usable / dis) | round(2) if dis > 0.05 else 'unknown' }}
    avg_draw: >-
      {% set d = states('sensor.battery_discharge_power_avg') | float(0) %}
      {% if d < 1000 %}{{ d | round(0) }} W{% else %}{{ (d/1000) | round(2) }} kW{% endif %}
    usable_kwh: "{{ states('sensor.goodwe_battery_usable_energy') }}"
    floor_pct: >-
      {{ 100 - states('number.goodwe_depth_of_discharge_on_grid') | float(90) }}

2.5 Sun position: which cannot be charted directly

sun.sun exposes elevation and azimuth as attributes, and its state changes only twice a day. The recorder therefore holds two rows per day and none of them carries an elevation value, so the entity cannot be charted. Two template sensors fix that and are recorded properly:

yaml · 22 lines
- name: "Sun Elevation"
  unique_id: sun_elevation
  unit_of_measurement: "°"
  state_class: measurement
  icon: mdi:weather-sunny
  state: "{{ state_attr('sun.sun','elevation') | float(0) | round(2) }}"
  availability: "{{ state_attr('sun.sun','elevation') is not none }}"

- name: "Sun Azimuth"
  unique_id: sun_azimuth
  unit_of_measurement: "°"
  state_class: measurement
  icon: mdi:compass-outline
  state: "{{ state_attr('sun.sun','azimuth') | float(0) | round(2) }}"
  availability: "{{ state_attr('sun.sun','azimuth') is not none }}"
  attributes:
    direction: >
      {% set a = state_attr('sun.sun','azimuth') | float(0) %}
      {% set pts = ['N','NNE','NE','ENE','E','ESE','SE','SSE',
                    'S','SSW','SW','WSW','W','WNW','NW','NNW'] %}
      {{ pts[((a / 22.5) | round(0, 'common') | int) % 16] }}

2.6 The clear-sky benchmark

“18 kWh today” means nothing alone. It needs a ceiling. The useful question is “how much of what was available did we capture?”

You do not need a weather API. Extraterrestrial radiation on a horizontal surface is pure geometry: latitude and day of year. Multiply that by a clear-sky transmission factor of about 0.75, the array size in kWp and a performance ratio, and you have a daily ceiling that validates well against real output:

yaml · 52 lines
- name: Solar Clear Sky Today
  unique_id: solar_clear_sky_today
  unit_of_measurement: kWh
  device_class: energy
  icon: mdi:white-balance-sunny
  state: >-
    {% set lat_r = state_attr('zone.home','latitude') | float(-27.8254) * pi / 180 %}
    {% set doy = now().timetuple().tm_yday %}
    {% set decl = 23.45 * sin(2*pi*(284+doy)/365) * pi/180 %}
    {% set x = [[ -tan(lat_r)*tan(decl), -1] | max, 1] | min %}
    {% set ws = acos(x) %}
    {% set H0 = (24*3600*1367/pi) * (1+0.033*cos(2*pi*doy/365))
                * (cos(lat_r)*cos(decl)*sin(ws) + ws*sin(lat_r)*sin(decl)) %}
    {% set psh = H0/3600000 * 0.75 %}
    {{ (psh * states('input_number.solar_array_size') | float(10.8)
            * states('input_number.solar_performance_ratio') | float(0.8)) | round(1) }}
  attributes:
    peak_sun_hours: >-
      {% set lat_r = state_attr('zone.home','latitude') | float(-27.8254) * pi / 180 %}
      {% set doy = now().timetuple().tm_yday %}
      {% set decl = 23.45 * sin(2*pi*(284+doy)/365) * pi/180 %}
      {% set x = [[ -tan(lat_r)*tan(decl), -1] | max, 1] | min %}
      {% set ws = acos(x) %}
      {% set H0 = (24*3600*1367/pi) * (1+0.033*cos(2*pi*doy/365))
                  * (cos(lat_r)*cos(decl)*sin(ws) + ws*sin(lat_r)*sin(decl)) %}
      {{ (H0/3600000*0.75) | round(2) }}
    daylight_hours: >-
      {% set lat_r = state_attr('zone.home','latitude') | float(-27.8254) * pi / 180 %}
      {% set doy = now().timetuple().tm_yday %}
      {% set decl = 23.45 * sin(2*pi*(284+doy)/365) * pi/180 %}
      {% set x = [[ -tan(lat_r)*tan(decl), -1] | max, 1] | min %}
      {{ (acos(x)*180/pi*2/15) | round(2) }}
- name: Solar Capture Today
  unique_id: solar_capture_today
  unit_of_measurement: '%'
  state_class: measurement
  icon: mdi:gauge
  availability: >-
    {{ states('sensor.goodwe_today_s_pv_generation') not in ['unknown','unavailable']
       and states('sensor.solar_clear_sky_today') | float(0) > 0 }}
  state: >-
    {{ ( states('sensor.goodwe_today_s_pv_generation') | float(0)
         / states('sensor.solar_clear_sky_today') | float(1) * 100 ) | round(0) }}

- name: Solar Shortfall Today
  unique_id: solar_shortfall_today
  unit_of_measurement: kWh
  device_class: energy
  icon: mdi:weather-cloudy-alert
  state: >-
    {{ [ states('sensor.solar_clear_sky_today') | float(0)
         - states('sensor.goodwe_today_s_pv_generation') | float(0), 0 ] | max | round(1) }}

A performance ratio of 0.80 is a reasonable physical default. Capture reads 75-97% here, and a sagging figure does mean cloud.

Treat an impossible percentage as a calibration fault

This one cost a week on a second site: if capture exceeds 100%, the model is wrong, not the day. A ceiling you routinely beat is not a ceiling. There the array size had been set to half its true value and the view reported 119%, 142%, 122%, 134%, 134%, 135% and 132% on seven consecutive days before anyone questioned it. The same rule applies to any derived figure that goes physically impossible: negative house load, efficiency above unity, a battery delivering more than its capacity.

What deliberately was not built

An “expected power right now” sensor is tempting and was left out. Measured against sun elevation, instantaneous PV correlates well (r = 0.88 over ~11,000 samples), but the ceiling saturates above about 40° elevation while a simple model keeps climbing. Fitting the mean and presenting it as a maximum would produce a capture figure wrong through the middle of every clear day.

The cause turned out to be physical: this array is split across two roof faces whose peaks are about three and three-quarter hours apart, so the combined curve is broad and flat-topped rather than peaked. A single-azimuth model can never fit a split array. The daily figure is unaffected because integrating over the whole day mostly cancels the split out.

2.7 Tomorrow’s forecast needs a trigger template

A weather forecast requires a service call, which a plain template sensor cannot make. That makes this a trigger-based template, refreshed on a time pattern and at startup:

The wrapper looks like this. One fetch feeds any number of sensors beneath it:

yaml · 15 lines
- trigger:
    - platform: homeassistant
      event: start
    - platform: time_pattern
      minutes: "/30"
  action:
    - service: weather.get_forecasts
      target:
        entity_id: weather.home        # your own weather entity
      data:
        type: daily
      response_variable: fc
  sensor:
    - name: Solar Forecast Tomorrow
      ...

And the sensor itself:

yaml · 31 lines
- name: Solar Forecast Tomorrow
  unique_id: solar_forecast_tomorrow
  unit_of_measurement: kWh
  device_class: energy
  icon: mdi:weather-partly-cloudy
  state: >-
    {% set lat_r = state_attr('zone.home','latitude') | float(-27.8254) * pi / 180 %}
    {% set doy = (now() + timedelta(days=1)).timetuple().tm_yday %}
    {% set decl = 23.45 * sin(2*pi*(284+doy)/365) * pi/180 %}
    {% set x = [[ -tan(lat_r)*tan(decl), -1] | max, 1] | min %}
    {% set ws = acos(x) %}
    {% set H0 = (24*3600*1367/pi) * (1+0.033*cos(2*pi*doy/365))
                * (cos(lat_r)*cos(decl)*sin(ws) + ws*sin(lat_r)*sin(decl)) %}
    {% set clear = H0/3600000 * 0.75
                   * states('input_number.solar_array_size') | float(10.8)
                   * states('input_number.solar_performance_ratio') | float(0.8) %}
    {% set f = {'sunny':0.95,'clear-night':0.95,'partlycloudy':0.75,'cloudy':0.45,
                'rainy':0.30,'pouring':0.22,'lightning':0.30,'lightning-rainy':0.28,
                'fog':0.35,'hail':0.30,'snowy':0.30,'snowy-rainy':0.28,
                'windy':0.90,'windy-variant':0.85,'exceptional':0.60} %}
    {% set fl = fc['weather.home'].forecast if fc is defined else [] %}
    {% set tom = (now() + timedelta(days=1)).date() | string %}
    {% set m = fl | selectattr('datetime','search',tom) | list %}
    {% set cond = m[0].condition if m else 'partlycloudy' %}
    {{ (clear * f.get(cond, 0.6)) | round(1) }}
  attributes:
    condition: >-
      {% set fl = fc['weather.home'].forecast if fc is defined else [] %}
      {% set tom = (now() + timedelta(days=1)).date() | string %}
      {% set m = fl | selectattr('datetime','search',tom) | list %}
      {{ m[0].condition if m else 'unknown' }}

It reads unknown until the first trigger fires, so add the homeassistant: start trigger alongside the time pattern or it stays blank until the next half hour after every restart.

The condition-to-factor map is the whole model: a clear-sky ceiling multiplied by how much of it a given sky is likely to let through. Crude, and close enough to plan a wash load around.

2.8 Tariffs and cost

Rates live in input_number helpers rather than being hard-coded, so one edit updates both these sensors and Home Assistant’s native energy dashboard.

Reconcile against a real bill, to the cent

Both the import rate and the feed-in tariff here were first set from memory and both were wrong. Take a bill and check every line multiplies out exactly. It also tells you something you cannot guess: whether the printed rates already include tax. Divide each by 1.1. If every one gives a clean four-decimal figure, they are tax-inclusive and you must not add it again. Two properties in this household are opposite ways round, so never copy a rate between sites.

Tax applies to charges, never to the feed-in credit

formula
(usage + supply) x 1.1  -  credit  =  amount due

Applying tax to the net figure does not reconcile. In the energy dashboard settings that means the import price entity must be tax-inclusive while export stays raw. That is what this sensor is for:

yaml · 10 lines
- name: Electricity Import Price Incl GST
  unique_id: electricity_import_price_incl_gst
  unit_of_measurement: AUD/kWh
  icon: mdi:cash-plus
  availability: >-
    {{ states('input_number.electricity_import_rate') | is_number
       and states('input_number.electricity_gst_rate') | is_number }}
  state: >-
    {{ ( states('input_number.electricity_import_rate') | float
         * (1 + states('input_number.electricity_gst_rate') | float / 100) ) | round(5) }}

This was wired to the raw ex-tax helper for three weeks and every import cost ran 9.1% low. The template sensors on the same page were right, which is what made it hard to spot.

The daily cost chain:

yaml · 71 lines
- name: Electricity Cost Today
  unique_id: electricity_cost_today
  unit_of_measurement: AUD
  device_class: monetary
  state_class: total
  icon: mdi:cash-minus
  availability: >-
    {{ states('input_number.electricity_import_rate')
       not in ['unknown','unavailable','none'] }}
  state: >-
    {% set gst = 1 + states('input_number.electricity_gst_rate') | float(0) / 100 %}
    {{ ( ( states('sensor.grid_import_daily') | float(0)
           * states('input_number.electricity_import_rate') | float(0)
           + states('input_number.electricity_daily_supply_charge') | float(0) )
         * gst ) | round(2) }}
  attributes:
    usage_charge: >-
      {{ ( states('sensor.grid_import_daily') | float(0)
           * states('input_number.electricity_import_rate') | float(0) ) | round(2) }}
    supply_charge: "{{ states('input_number.electricity_daily_supply_charge') | float(0) | round(2) }}"
    gst: >-
      {{ ( ( states('sensor.grid_import_daily') | float(0)
             * states('input_number.electricity_import_rate') | float(0)
             + states('input_number.electricity_daily_supply_charge') | float(0) )
           * states('input_number.electricity_gst_rate') | float(0) / 100 ) | round(2) }}

- name: Electricity Credit Today
  unique_id: electricity_credit_today
  unit_of_measurement: AUD
  device_class: monetary
  state_class: total
  icon: mdi:cash-plus
  availability: >-
    {{ states('input_number.electricity_feed_in_tariff')
       not in ['unknown','unavailable','none'] }}
  state: >-
    {{ ( states('sensor.grid_export_daily') | float(0)
         * states('input_number.electricity_feed_in_tariff') | float(0) ) | round(2) }}
- name: Electricity Net Today
  unique_id: electricity_net_today
  unit_of_measurement: AUD
  device_class: monetary
  state_class: total
  icon: mdi:scale-balance
  availability: >-
    {{ states('sensor.electricity_cost_today')
       not in ['unknown','unavailable','none'] }}
  state: >-
    {{ ( states('sensor.electricity_cost_today') | float(0)
         - states('sensor.electricity_credit_today') | float(0) ) | round(2) }}

- name: Electricity Savings Today
  unique_id: electricity_savings_today
  unit_of_measurement: AUD
  device_class: monetary
  state_class: total
  icon: mdi:piggy-bank
  availability: >-
    {{ states('sensor.goodwe_today_load') not in ['unknown','unavailable','none'] }}
  state: >-
    {% set gst = 1 + states('input_number.electricity_gst_rate') | float(0) / 100 %}
    {% set nosolar = ( states('sensor.goodwe_today_load') | float(0)
                       * states('input_number.electricity_import_rate') | float(0)
                       + states('input_number.electricity_daily_supply_charge') | float(0) ) * gst %}
    {{ ( nosolar - states('sensor.electricity_net_today') | float(0) ) | round(2) }}
  attributes:
    cost_without_solar: >-
      {% set gst = 1 + states('input_number.electricity_gst_rate') | float(0) / 100 %}
      {{ ( ( states('sensor.goodwe_today_load') | float(0)
             * states('input_number.electricity_import_rate') | float(0)
             + states('input_number.electricity_daily_supply_charge') | float(0) ) * gst ) | round(2) }}

The savings baseline must compare like with like. An early version charged a fixed daily supply fee on the actual-cost side but left it off the “without solar” side, so savings were understated by the whole supply charge and went negative every morning. Solar offsets neither a supply charge nor a separate controlled-load circuit, so both belong on both sides, where they cancel.

2.9 Billing periods rarely match calendar months

This bill runs the 11th to the 10th. Three things follow:

  • Daily and monthly totals come from utility_meter helpers on the meter registers.
  • The billing total uses a monthly cycle with offset: 10, so it resets at midnight on the 11th.
  • Quarterly cycles are calendar quarters and offset caps at 28 days, so a quarter starting in September cannot be expressed that way at all. Use cycle: none and zero the meter from an automation.

Also set periodically_resetting: true when the source itself resets at midnight, or the meter treats the drop as an error. And note a utility meter inherits its source’s device name, so the entity ID it lands on is often not the one you expected, so check and rename it afterwards.

yaml · 70 lines
- name: Electricity Billing Period
  unique_id: electricity_billing_period
  icon: mdi:calendar-range
  state: >-
    {% set d = now() %}
    {% set start = d.replace(day=11) if d.day >= 11
                   else (d.replace(day=1) - timedelta(days=1)).replace(day=11) %}
    {{ start.strftime('%-d %b') }} - {{ ((start.replace(day=28) + timedelta(days=8)).replace(day=10)).strftime('%-d %b') }}
  attributes:
    days_elapsed: >-
      {% set d = now() %}
      {% set start = d.replace(day=11) if d.day >= 11
                     else (d.replace(day=1) - timedelta(days=1)).replace(day=11) %}
      {{ ((d - start).days + 1) }}
    days_total: >-
      {% set d = now() %}
      {% set start = d.replace(day=11) if d.day >= 11
                     else (d.replace(day=1) - timedelta(days=1)).replace(day=11) %}
      {% set end = (start.replace(day=28) + timedelta(days=8)).replace(day=10) %}
      {{ (end - start).days + 1 }}
    days_left: >-
      {% set d = now() %}
      {% set start = d.replace(day=11) if d.day >= 11
                     else (d.replace(day=1) - timedelta(days=1)).replace(day=11) %}
      {% set end = (start.replace(day=28) + timedelta(days=8)).replace(day=10) %}
      {{ [(end - d).days, 0] | max }}
- name: Electricity Bill To Date
  unique_id: electricity_bill_to_date
  unit_of_measurement: AUD
  device_class: monetary
  state_class: total
  icon: mdi:receipt-text
  state: >-
    {% set rate = states('input_number.electricity_import_rate') | float(0) %}
    {% set fit  = states('input_number.electricity_feed_in_tariff') | float(0) %}
    {% set sup  = states('input_number.electricity_daily_supply_charge') | float(0) %}
    {% set gst  = 1 + states('input_number.electricity_gst_rate') | float(0) / 100 %}
    {% set days = state_attr('sensor.electricity_billing_period','days_elapsed') | int(1) %}
    {% set imp  = states('sensor.grid_import_billing') | float(0) %}
    {% set exp  = states('sensor.grid_export_billing') | float(0) %}
    {{ ((imp * rate + sup * days) * gst - exp * fit) | round(2) }}

- name: Electricity Bill Projected
  unique_id: electricity_bill_projected
  unit_of_measurement: AUD
  device_class: monetary
  state_class: total
  icon: mdi:crystal-ball
  state: >-
    {% set rate = states('input_number.electricity_import_rate') | float(0) %}
    {% set fit  = states('input_number.electricity_feed_in_tariff') | float(0) %}
    {% set sup  = states('input_number.electricity_daily_supply_charge') | float(0) %}
    {% set gst  = 1 + states('input_number.electricity_gst_rate') | float(0) / 100 %}
    {% set el   = state_attr('sensor.electricity_billing_period','days_elapsed') | int(1) %}
    {% set tot  = state_attr('sensor.electricity_billing_period','days_total') | int(30) %}
    {% set imp  = states('sensor.grid_import_billing') | float(0) / el * tot %}
    {% set exp  = states('sensor.grid_export_billing') | float(0) / el * tot %}
    {{ ((imp * rate + sup * tot) * gst - exp * fit) | round(2) }}
  attributes:
    projected_import_kwh: >-
      {% set el = state_attr('sensor.electricity_billing_period','days_elapsed') | int(1) %}
      {% set tot = state_attr('sensor.electricity_billing_period','days_total') | int(30) %}
      {{ (states('sensor.grid_import_billing') | float(0) / el * tot) | round(1) }}
    projected_export_kwh: >-
      {% set el = state_attr('sensor.electricity_billing_period','days_elapsed') | int(1) %}
      {% set tot = state_attr('sensor.electricity_billing_period','days_total') | int(30) %}
      {{ (states('sensor.grid_export_billing') | float(0) / el * tot) | round(1) }}
    basis: >-
      {{ state_attr('sensor.electricity_billing_period','days_elapsed') }} of
      {{ state_attr('sensor.electricity_billing_period','days_total') }} days

2.10 Lifetime savings and payback

yaml · 73 lines
- name: Solar Lifetime Savings
  unique_id: solar_lifetime_savings
  unit_of_measurement: AUD
  device_class: monetary
  state_class: total
  icon: mdi:cash-check
  availability: >-
    {{ states('sensor.goodwe_total_load') not in ['unknown','unavailable','none'] }}
  state: >-
    {% set rate = states('input_number.electricity_import_rate') | float(0) %}
    {% set fit  = states('input_number.electricity_feed_in_tariff') | float(0) %}
    {% set load = states('sensor.goodwe_total_load') | float(0) %}
    {% set imp  = states('sensor.goodwe_meter_total_energy_import') | float(0) %}
    {% set exp  = states('sensor.goodwe_meter_total_energy_export') | float(0) %}
    {% set gst = 1 + states('input_number.electricity_gst_rate') | float(0) / 100 %}
    {{ ( load * rate * gst - (imp * rate * gst - exp * fit) ) | round(2) }}
- name: Solar Payback Remaining
  unique_id: solar_payback_remaining
  unit_of_measurement: AUD
  device_class: monetary
  state_class: total
  icon: mdi:progress-clock
  availability: >-
    {{ states('sensor.solar_lifetime_savings') not in ['unknown','unavailable','none'] }}
  state: >-
    {{ [ states('input_number.solar_system_cost') | float(0)
         - states('sensor.solar_lifetime_savings') | float(0), 0 ] | max | round(2) }}

- name: Solar Payback Progress
  unique_id: solar_payback_progress
  unit_of_measurement: "%"
  state_class: measurement
  icon: mdi:percent
  availability: >-
    {{ states('sensor.solar_lifetime_savings') not in ['unknown','unavailable','none'] }}
  state: >-
    {% set cost = states('input_number.solar_system_cost') | float(0) %}
    {% if cost > 0 %}
      {{ ( states('sensor.solar_lifetime_savings') | float(0) / cost * 100 ) | round(2) }}
    {% else %}0{% endif %}
- name: Solar Payback ETA
  unique_id: solar_payback_eta
  device_class: timestamp
  icon: mdi:calendar-check
  availability: >-
    {% set d = (now().date() - strptime(states('input_datetime.solar_install_date'),
                '%Y-%m-%d').date()).days %}
    {{ states('sensor.solar_lifetime_savings') not in ['unknown','unavailable','none']
       and d >= 1
       and states('sensor.solar_lifetime_savings') | float(0) > 0 }}
  state: >-
    {% set days = [ (now().date() - strptime(states('input_datetime.solar_install_date'),
                     '%Y-%m-%d').date()).days, 1 ] | max %}
    {% set avg  = states('sensor.solar_lifetime_savings') | float(0) / days %}
    {% set left = states('sensor.solar_payback_remaining') | float(0) %}
    {{ (now() + timedelta(days = [left / avg, 36500] | min )).isoformat() }}
  attributes:
    days_running: >-
      {{ [ (now().date() - strptime(states('input_datetime.solar_install_date'),
           '%Y-%m-%d').date()).days, 1 ] | max }}
    avg_per_day: >-
      {% set days = [ (now().date() - strptime(states('input_datetime.solar_install_date'),
                       '%Y-%m-%d').date()).days, 1 ] | max %}
      {{ ( states('sensor.solar_lifetime_savings') | float(0) / days ) | round(2) }}
    years_remaining: >-
      {% set days = [ (now().date() - strptime(states('input_datetime.solar_install_date'),
                       '%Y-%m-%d').date()).days, 1 ] | max %}
      {% set avg  = states('sensor.solar_lifetime_savings') | float(0) / days %}
      {% if avg > 0 %}
        {{ ( states('sensor.solar_payback_remaining') | float(0) / avg / 365.25 ) | round(1) }}
      {% else %}unknown{% endif %}

3. Layout: columns, not sections

This is where most dense dashboards fall down, and the fix is structural.

A sections view is a CSS grid, not masonry. Every row sizes to its tallest member, so a three-card section beside a thirteen-card section gives you a column of whitespace as tall as the difference. No amount of dense_section_placement fixes it; that option only fills gaps left by spanning sections.

Stop thinking in rows. Instead of one section per panel, use a small number of full-height column sections, each holding a vertical stack of panels. A column is an independent stack that just grows, so there are no rows and therefore no row-height whitespace.

yaml · 10 lines
title: Energy
path: energy
icon: mdi:solar-power-variant
type: sections
max_columns: 7
dense_section_placement: true
sections:
  - type: grid          # each section is one full-height COLUMN
    column_span: 1
    cards: [ ... panels, stacked vertically ... ]
Column Panels
1 Battery headline · live power flow · today’s energy sankey
2 System overview · live PV · sun vs PV · PV strings · clear-sky performance
3 Live power sankey · sources of energy today · power over 48 hours
4 Storage & thermals · battery performance · settings & tariff
5 AC & inverter · grid & energy flow
6 Cost & billing · payback
7 The four native energy cards

Column order is your only lever for mobile

On a phone a sections view collapses to one column and renders sections in order. Nothing else decides what appears first: not card position within a panel, not grid options. The most important number goes in the first panel of the first column.

Keep spans to 1 and 2

A column_span must never exceed max_columns. This breaks latently rather than obviously: a section spanning 5 in a 4-column grid renders its heading in one column and its cards in another, which reads like the layout has come apart rather than like a width error. Since max_columns is a display setting a user can change, a span of 2 is the only one that survives every width.

4. The panel idiom

Every panel is the same three-part construction, welded into one card: header, chart, tile grid.

yaml · 6 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card     # header: ALL-CAPS name + live status line
  - type: custom:apexcharts-card            # the chart, if the panel has one
  - type: grid                              # a 2-or-3-wide grid of mushroom cards
    columns: 2

The templated second line of the header is what makes the idiom worth the effort: a glanced-at panel still tells you something.

Two traps inside stack-in-card

stack-in-card builds its children with createCardElement and does not wrap them in Home Assistant’s own hui-card. Two silent consequences:

  • grid_options on a nested card is ignored. Width falls back to the frontend default. Cards that support their own sizing still work, such as aspect_ratio on a map or height on a chart.
  • The native visibility: option is ignored too, with nothing logged. A control you meant to hide stays on screen. Use a conditional card, which hides itself internally and therefore works at any depth.

card-mod does work inside a stack, because it patches each card’s own setConfig rather than relying on the wrapper.

5. Panel by panel

Every panel on the view, with the YAML that produces it.

Column 1: what you see first, and what a phone shows first

BATTERY (headline)

State of charge as a filled arc, not a needle. This is a level, and severity colours are meaningful on it: low really is bad. The header templates the live charge/discharge rate from the activity sensor built in section 2.3.

yaml · 36 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: '{{ states(''sensor.goodwe_battery_state_of_charge'') }}% BATTERY'
    secondary: '{{ states(''sensor.goodwe_battery_activity'') }} · {{ states(''sensor.goodwe_battery_stored_energy'') }} kWh stored · runs out {{ as_timestamp(states(''sensor.goodwe_battery_runs_out''),0) | timestamp_custom(''%-I:%M %p'', true, ''n/a'') }}'
    icon: '{% set s = states(''sensor.goodwe_battery_state_of_charge'') | int(0) %}{% set chg = ''Charging'' in states(''sensor.goodwe_battery_activity'') %}{% set lvl = (s / 10) | round | int * 10 %}{% if lvl >= 100 %}mdi:battery{{ ''-charging-100'' if chg else '''' }}{% elif lvl <= 0 %}mdi:battery-outline{% else %}mdi:battery{{ ''-charging'' if chg else '''' }}-{{ lvl }}{% endif %}'
    icon_color: '{% set s = states(''sensor.goodwe_battery_state_of_charge'')|int(0) %}{{ ''red'' if s < 20 else ''orange'' if s < 50 else ''green'' }}'
    multiline_secondary: true
  - type: gauge
    entity: sensor.goodwe_battery_state_of_charge
    name: State of charge
    min: 0
    max: 100
    needle: false
    severity:
      green: 50
      yellow: 20
      red: 0
    grid_options:
      columns: 12
  - type: grid
    columns: 2
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_battery_stored_energy
        name: Stored
        icon: mdi:battery-high
        icon_color: green
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_battery_usable_energy
        name: Usable
        icon: mdi:battery-heart-variant
        icon_color: teal
grid_options:
  columns: 12

LIVE POWER FLOW

power-flow-card-plus animates generation, house, battery and grid. This is where the sign conventions bite. The card reads the positive part of a signed sensor for import and the negative part for battery charging, so on this inverter the grid leg needs invert_state: true and the battery leg must not have it. If a direction ever looks wrong, grep the card bundle for its fromGrid: / fromBattery: getters rather than reasoning from the option names.

yaml · 31 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: LIVE POWER FLOW
    secondary: Solar {{ states('sensor.goodwe_pv_power_total') }} W · house {{ states('sensor.goodwe_house_consumption') }} W · grid {{ states('sensor.goodwe_active_power') }} W
    icon: mdi:transit-connection-variant
    icon_color: cyan
  - type: custom:power-flow-card-plus
    entities:
      grid:
        entity: sensor.goodwe_active_power
        invert_state: true
        name: Grid
      solar:
        entity: sensor.goodwe_pv_power
        name: Solar
      battery:
        entity: sensor.goodwe_battery_power
        state_of_charge: sensor.goodwe_battery_state_of_charge
        name: Battery
      home:
        entity: sensor.goodwe_house_consumption
        name: House
    clickable_entities: true
    watt_threshold: 1000
    display_zero_lines:
      mode: show
    grid_options:
      columns: 12
grid_options:
  columns: 12

ENERGY FLOW: TODAY (sankey)

Built entirely from already-one-directional daily totals, so it is immune to sign convention questions. A sankey cannot take a negative, which is why the split sensors from section 2.2 exist.

yaml · 40 lines
type: custom:sankey-chart
title: Energy flow — today
unit: Wh
unit_prefix: k
round: 1
height: 230
show_names: true
show_icons: false
min_box_height: 3
grid_options:
  columns: 12
sections:
  - entities:
      - entity_id: sensor.goodwe_today_s_pv_generation
        name: Solar
        color: '#ff9800'
        children:
          - sensor.goodwe_today_load
          - sensor.battery_charged_daily
          - sensor.grid_export_daily
      - entity_id: sensor.grid_import_daily
        name: Grid in
        color: '#2196f3'
        children:
          - sensor.goodwe_today_load
      - entity_id: sensor.battery_discharged_daily
        name: Battery out
        color: '#4caf50'
        children:
          - sensor.goodwe_today_load
  - entities:
      - entity_id: sensor.goodwe_today_load
        name: House
        color: '#00bcd4'
      - entity_id: sensor.battery_charged_daily
        name: Battery in
        color: '#4caf50'
      - entity_id: sensor.grid_export_daily
        name: Grid out
        color: '#9c27b0'

Column 2: solar performance

SYSTEM OVERVIEW

Plain status tiles: inverter mode, PV total, house load, and the derived battery activity readout.

yaml · 33 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: SYSTEM OVERVIEW
    secondary: 'Mode: {{ states(''select.goodwe_inverter_operation_mode'') }} | Battery {{ states(''sensor.goodwe_battery_state_of_charge'') }}% | {{ states(''sensor.goodwe_battery_activity'') }}'
    icon: mdi:solar-power-variant
    icon_color: amber
  - type: grid
    columns: 2
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_pv_power_total
        name: PV now
        icon: mdi:solar-power
        icon_color: yellow
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_battery_state_of_charge
        name: SoC
        icon: mdi:battery-high
        icon_color: green
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_house_consumption
        name: House now
        icon: mdi:home-lightning-bolt
        icon_color: blue
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_active_power
        name: Grid now
        icon: mdi:transmission-tower
        icon_color: purple
grid_options:
  columns: 12

LIVE PV OUTPUT

Today’s generation curve. group_by with func: avg over five minutes keeps the series light without visibly smoothing the shape.

yaml · 51 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: LIVE PV OUTPUT
    secondary: Today {{ states('sensor.goodwe_today_s_pv_generation') }} kWh of a {{ states('sensor.solar_clear_sky_today') }} kWh clear-sky ceiling ({{ states('sensor.solar_capture_today') }}%)
    icon: mdi:weather-sunny
    icon_color: yellow
  - type: custom:apexcharts-card
    header:
      show: true
      title: Live PV production
    graph_span: 6h
    apex_config:
      chart:
        height: 230
      stroke:
        width: 2
        curve: smooth
      dataLabels:
        enabled: false
      legend:
        show: false
        position: bottom
      xaxis:
        labels:
          datetimeUTC: false
    series:
      - entity: sensor.goodwe_pv_power_total
        name: PV
        color: '#facc15'
        type: area
        group_by:
          func: avg
          duration: 1min
    update_interval: 30s
  - type: grid
    columns: 2
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_today_s_pv_generation
        name: PV today
        icon: mdi:solar-power
        icon_color: yellow
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_total_pv_generation
        name: PV total
        icon: mdi:chart-line
        icon_color: amber
grid_options:
  columns: 12

SUN vs PV

PV power on the left axis, sun elevation on the right. Measured before building it: PV against sin(elevation) scores r = 0.88 over 10,910 samples, so the two belong on one chart. A sagging PV curve under a smooth elevation curve is cloud; both falling together is just the end of the day. That single read is what the panel is for.

Chart mechanics: two y-axes, and once yaxis[] uses ids, every series needs a yaxis_id or apexcharts-card throws.

yaml · 93 lines
type: custom:stack-in-card
grid_options:
  columns: 12
cards:
  - type: custom:mushroom-template-card
    primary: SUN vs PV
    icon: mdi:solar-power-variant
    icon_color: '{{ ''amber'' if states(''sensor.sun_elevation'') | float(-90) > 10 else ''disabled'' }}'
    secondary: '{% set el = states(''sensor.sun_elevation'') | float(-90) %}{% set ss = states(''sensor.sun_next_setting'') | as_datetime %}Sun {{ el | round }}° {{ state_attr(''sensor.sun_azimuth'',''direction'') }} · PV {{ (states(''sensor.goodwe_pv_power'') | float(0) / 1000) | round(2) }} kW{% if el > 0 and ss %} · {{ ((ss - now()).total_seconds() / 60) | round }} min to sunset{% elif el <= 0 %} · below horizon, no generation{% endif %}'
  - type: custom:apexcharts-card
    header:
      show: true
      title: PV against sun elevation - 24 h
      show_states: false
    graph_span: 24h
    update_interval: 2min
    apex_config:
      chart:
        height: 230
      stroke:
        width:
          - 0
          - 2
        curve: smooth
      dataLabels:
        enabled: false
      legend:
        show: true
        position: bottom
      xaxis:
        labels:
          datetimeUTC: false
      tooltip:
        x:
          format: ddd HH:mm
    yaxis:
      - id: pw
        decimals: 0
        min: 0
        apex_config:
          title:
            text: W
      - id: el
        opposite: true
        decimals: 0
        min: -20
        max: 90
        apex_config:
          title:
            text: sun °
    series:
      - entity: sensor.goodwe_pv_power
        name: PV
        yaxis_id: pw
        type: area
        color: '#f0c064'
        float_precision: 0
        group_by:
          func: avg
          duration: 5min
      - entity: sensor.sun_elevation
        name: Sun elevation
        yaxis_id: el
        type: line
        color: '#7fb8ff'
        float_precision: 1
        group_by:
          func: avg
          duration: 5min
  - type: grid
    columns: 4
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.sun_elevation
        name: Elevation
        icon: mdi:angle-acute
        icon_color: amber
      - type: custom:mushroom-entity-card
        entity: sensor.sun_azimuth
        name: Azimuth
        icon: mdi:compass-outline
        icon_color: blue
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_pv_power
        name: PV Now
        icon: mdi:solar-power
        icon_color: amber
      - type: custom:mushroom-entity-card
        entity: sensor.sun_next_setting
        name: Sunset
        icon: mdi:weather-sunset-down
        icon_color: deep-orange

PV STRINGS

The three strings charted together, which shows the hand-over from one roof face to the other directly. On this array the south-east face peaks about 90 minutes before solar noon and the north-west pair about two and a quarter hours after, roughly three and three-quarter hours apart.

yaml · 100 lines
type: custom:stack-in-card
grid_options:
  columns: 12
cards:
  - type: custom:mushroom-template-card
    primary: PV STRINGS
    icon: mdi:solar-panel-large
    icon_color: '{{ ''amber'' if (states(''sensor.goodwe_pv3_power'') | float(0) + states(''sensor.goodwe_pv4_power'') | float(0)) + states(''sensor.goodwe_pv2_power'') | float(0) > 100 else ''disabled'' }}'
    secondary: '{% set nw = (states(''sensor.goodwe_pv3_power'') | float(0) + states(''sensor.goodwe_pv4_power'') | float(0)) %}{% set se = states(''sensor.goodwe_pv2_power'') | float(0) %}NW {{ (nw/1000) | round(2) }} kW · SE {{ (se/1000) | round(2) }} kW{% if nw + se > 100 %} · SE is {{ (se / (nw + se) * 100) | round }}% of output{% endif %} — two faces, peaking ~3¾ h apart'
    multiline_secondary: true
  - type: custom:apexcharts-card
    header:
      show: true
      title: Strings through the day - 24 h
      show_states: false
    graph_span: 24h
    update_interval: 2min
    apex_config:
      chart:
        height: 230
        stacked: false
      stroke:
        width: 2
        curve: smooth
      dataLabels:
        enabled: false
      legend:
        show: true
        position: bottom
      xaxis:
        labels:
          datetimeUTC: false
      tooltip:
        x:
          format: ddd HH:mm
    yaxis:
      - decimals: 0
        min: 0
        apex_config:
          title:
            text: W
    series:
      - entity: sensor.goodwe_pv3_power
        name: PV3 north-west
        color: '#f0c064'
        type: area
        group_by:
          func: avg
          duration: 5min
      - entity: sensor.goodwe_pv4_power
        name: PV4 north-west
        color: '#e08a3c'
        type: area
        group_by:
          func: avg
          duration: 5min
      - entity: sensor.goodwe_pv2_power
        name: PV2 south-east
        color: '#7fb8ff'
        type: area
        group_by:
          func: avg
          duration: 5min
  - type: grid
    columns: 3
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_pv3_power
        name: PV3 · NW
        icon: mdi:solar-panel
        icon_color: amber
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_pv4_power
        name: PV4 · NW
        icon: mdi:solar-panel
        icon_color: amber
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_pv2_power
        name: PV2 · SE
        icon: mdi:solar-panel
        icon_color: blue
  - type: grid
    columns: 3
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_pv3_voltage
        name: PV3 V
        icon: mdi:flash
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_pv4_voltage
        name: PV4 V
        icon: mdi:flash
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_pv2_voltage
        name: PV2 V
        icon: mdi:flash
  - type: markdown
    content: '**PV1 is not connected** — the inverter has four string inputs and three are used. PV3 and PV4 share the north-west face and carry ~85% of output; PV2 is the south-east face and leads only before about 08:00.'

CLEAR-SKY PERFORMANCE

Daily generation as columns against the modelled ceiling as a line, plus capture, shortfall, tomorrow’s forecast and the editable performance ratio. The ceiling comes from section 2.6.

yaml · 69 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: CLEAR-SKY PERFORMANCE
    secondary: '{{ states(''sensor.goodwe_today_s_pv_generation'') }} of {{ states(''sensor.solar_clear_sky_today'') }} kWh possible — {{ states(''sensor.solar_capture_today'') }}% captured, {{ states(''sensor.solar_shortfall_today'') }} kWh lost to cloud'
    icon: mdi:weather-sunny-alert
    icon_color: amber
  - type: custom:apexcharts-card
    graph_span: 14d
    span:
      end: day
    header:
      show: true
      title: Daily generation vs clear-sky ceiling
      show_states: false
    apex_config:
      chart:
        height: 250
      legend:
        show: true
      plotOptions:
        bar:
          columnWidth: 60%
      grid:
        borderColor: '#8883'
    series:
      - entity: sensor.goodwe_total_pv_generation
        name: Generated
        color: '#e8930c'
        type: column
        statistics:
          type: change
          period: day
      - entity: sensor.solar_clear_sky_today
        name: Clear-sky ceiling
        color: '#9aa0a6'
        type: line
        stroke_width: 2
        group_by:
          func: max
          duration: 1d
    grid_options:
      columns: 12
  - type: grid
    columns: 2
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.solar_capture_today
        name: Captured
        icon: mdi:percent
        icon_color: amber
      - type: custom:mushroom-entity-card
        entity: sensor.solar_shortfall_today
        name: Lost to cloud
        icon: mdi:weather-cloudy
        icon_color: grey
      - type: custom:mushroom-entity-card
        entity: sensor.solar_forecast_tomorrow
        name: Forecast tomorrow
        icon: mdi:weather-partly-cloudy
        icon_color: blue
      - type: custom:mushroom-entity-card
        entity: input_number.solar_performance_ratio
        name: Perf. ratio
        icon: mdi:chart-bell-curve
        icon_color: purple
grid_options:
  columns: 12

Column 3: where the energy went

POWER RIGHT NOW (sankey)

The live counterpart to the daily sankey, and the one that does need the one-directional split sensors, since a sankey takes no negatives.

yaml · 40 lines
type: custom:sankey-chart
title: Power right now
unit: W
unit_prefix: ''
round: 0
height: 230
show_names: true
show_icons: false
min_box_height: 3
grid_options:
  columns: 12
sections:
  - entities:
      - entity_id: sensor.goodwe_pv_power_total
        name: Solar
        color: '#ff9800'
        children:
          - sensor.goodwe_house_consumption
          - sensor.goodwe_battery_charge_power
          - sensor.goodwe_grid_export_power
      - entity_id: sensor.goodwe_grid_import_power
        name: Grid in
        color: '#2196f3'
        children:
          - sensor.goodwe_house_consumption
      - entity_id: sensor.goodwe_battery_discharge_power
        name: Battery out
        color: '#4caf50'
        children:
          - sensor.goodwe_house_consumption
  - entities:
      - entity_id: sensor.goodwe_house_consumption
        name: House
        color: '#00bcd4'
      - entity_id: sensor.goodwe_battery_charge_power
        name: Battery in
        color: '#4caf50'
      - entity_id: sensor.goodwe_grid_export_power
        name: Grid out
        color: '#9c27b0'

SOURCES OF ENERGY TODAY

A donut of solar / grid / battery against the house total. Check your inverter’s own “solar to load” figure before charting it beside battery discharge. On a second system that field turned out to include solar that reached the load via the battery, so plotting the two together double-counted the battery throughput every day.

yaml · 53 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: SOURCES OF ENERGY TODAY
    secondary: House {{ states('sensor.goodwe_today_load') }} kWh | net cost ${{ states('sensor.electricity_net_today') }}
    icon: mdi:chart-donut
    icon_color: blue
  - type: custom:apexcharts-card
    chart_type: donut
    header:
      show: true
      title: Where today's energy came from
    apex_config:
      chart:
        height: 260
      legend:
        position: bottom
    series:
      - entity: sensor.goodwe_today_s_pv_generation
        name: Solar
        color: '#facc15'
      - entity: sensor.grid_import_daily
        name: Grid in
        color: '#f87171'
      - entity: sensor.battery_discharged_daily
        name: Battery out
        color: '#fb923c'
  - type: grid
    columns: 2
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.electricity_net_today
        name: Cost today
        icon: mdi:cash-minus
        icon_color: red
      - type: custom:mushroom-entity-card
        entity: sensor.electricity_savings_today
        name: Saved today
        icon: mdi:cash-plus
        icon_color: green
      - type: custom:mushroom-entity-card
        entity: sensor.grid_import_daily
        name: Grid in
        icon: mdi:transmission-tower-import
        icon_color: red
      - type: custom:mushroom-entity-card
        entity: sensor.grid_export_daily
        name: Grid out
        icon: mdi:transmission-tower-export
        icon_color: green
grid_options:
  columns: 12

POWER: 48 HOURS

Generation, load and grid over two days, which is the window where daily rhythm becomes visible without the chart turning to mush.

yaml · 67 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: POWER — 48 HOURS
    secondary: Solar {{ states('sensor.goodwe_pv_power_total') }} W | house {{ states('sensor.goodwe_house_consumption') }} W | battery {{ states('sensor.goodwe_battery_state_of_charge') }}%
    icon: mdi:chart-areaspline
    icon_color: cyan
  - type: custom:apexcharts-card
    graph_span: 48h
    header:
      show: true
      title: Solar · house · battery
      show_states: true
      colorize_states: true
    apex_config:
      chart:
        height: 270
      legend:
        show: true
      stroke:
        width: 2
      grid:
        borderColor: '#8883'
    yaxis:
      - id: w
        decimals: 0
        apex_config:
          title:
            text: W
      - id: pct
        opposite: true
        min: 0
        max: 100
        decimals: 0
        apex_config:
          title:
            text: '%'
    series:
      - entity: sensor.goodwe_pv_power
        name: Solar
        type: area
        opacity: 0.22
        color: '#e8930c'
        yaxis_id: w
        group_by:
          func: avg
          duration: 10min
      - entity: sensor.goodwe_house_consumption
        name: House
        type: line
        color: '#2f7fd1'
        yaxis_id: w
        group_by:
          func: avg
          duration: 10min
      - entity: sensor.goodwe_battery_state_of_charge
        name: Battery %
        type: line
        color: '#2e9e5b'
        yaxis_id: pct
        group_by:
          func: avg
          duration: 10min
    grid_options:
      columns: 12
grid_options:
  columns: 12

Column 4: battery detail and settings

ENERGY STORAGE & THERMALS

Stored and usable energy, plus pack temperatures.

yaml · 68 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: ENERGY STORAGE & THERMALS
    secondary: Battery {{ states('sensor.goodwe_battery_temperature') }}°C | stored {{ states('sensor.goodwe_battery_stored_energy') }} kWh | runs out {{ as_timestamp(states('sensor.goodwe_battery_runs_out'), 0) | timestamp_custom('%-I:%M %p', true, 'unknown') }}
    icon: mdi:battery-high
    icon_color: green
  - type: custom:apexcharts-card
    header:
      show: true
      title: Battery power balance
    graph_span: 12h
    apex_config:
      chart:
        height: 230
      stroke:
        width: 2
        curve: smooth
      dataLabels:
        enabled: false
      legend:
        show: true
        position: bottom
      xaxis:
        labels:
          datetimeUTC: false
    series:
      - entity: sensor.goodwe_battery_charge_power
        name: Charging (+)
        color: '#22c55e'
        type: area
        group_by:
          func: avg
          duration: 5min
      - entity: sensor.goodwe_battery_discharge_power
        name: Discharging (?)
        color: '#ef4444'
        type: area
        group_by:
          func: avg
          duration: 5min
        invert: true
  - type: grid
    columns: 2
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_battery_state_of_charge
        name: SoC
        icon: mdi:battery-high
        icon_color: green
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_battery_temperature
        name: Battery temp
        icon: mdi:thermometer
        icon_color: orange
      - type: custom:mushroom-entity-card
        entity: sensor.battery_charged_daily
        name: Charged today
        icon: mdi:battery-plus
        icon_color: green
      - type: custom:mushroom-entity-card
        entity: sensor.battery_discharged_daily
        name: Discharged today
        icon: mdi:battery-minus
        icon_color: red
grid_options:
  columns: 12

BATTERY PERFORMANCE

Charge and discharge energy over a selectable period. The period selector is a mushroom-select-card driving an input_select, which the chart reads.

yaml · 73 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: BATTERY PERFORMANCE
    secondary: Health {{ states('sensor.goodwe_battery_state_of_health') }}% | usable {{ states('sensor.goodwe_battery_usable_energy') }} kWh of {{ states('input_number.goodwe_battery_capacity') }} kWh
    icon: mdi:battery-charging-high
    icon_color: teal
  - type: custom:apexcharts-card
    header:
      show: true
      title: Battery temperature
    graph_span: 24h
    apex_config:
      chart:
        height: 210
      stroke:
        width: 2
        curve: smooth
      dataLabels:
        enabled: false
      legend:
        show: false
        position: bottom
      xaxis:
        labels:
          datetimeUTC: false
    series:
      - entity: sensor.goodwe_battery_temperature
        name: Temp
        color: '#fb923c'
        type: line
        group_by:
          func: avg
          duration: 10min
  - type: grid
    columns: 3
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_battery_power
        name: Battery power
        icon: mdi:battery-sync
        icon_color: cyan
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_battery_state_of_health
        name: Health
        icon: mdi:heart-pulse
        icon_color: green
      - type: custom:mushroom-entity-card
        entity: number.goodwe_depth_of_discharge_on_grid
        name: DoD floor
        icon: mdi:battery-low
        icon_color: orange
  - type: custom:mushroom-select-card
    entity: select.goodwe_inverter_operation_mode
    name: Operating mode
    icon_color: blue
  - type: grid
    columns: 2
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_battery_voltage
        name: DC volts
        icon: mdi:flash-triangle
        icon_color: cyan
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_battery_current
        name: DC amps
        icon: mdi:current-dc
        icon_color: cyan
grid_options:
  columns: 12

SETTINGS & TARIFF

The editable input_number helpers that drive every cost figure on the page. These use custom:mushroom-number-card, which is the card that solves the usual bind: a native entities row is editable but visually alien among the panels, while a mushroom-entity-card matches the style and is read-only. The number card does both.

Caveat: three tariff fields use step: 0.0001, so the +/? buttons are useless on them. Tap the card and type the value in the more-info dialog instead, which is the right workflow anyway, since these are set once off a bill and then left alone.

yaml · 65 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: SETTINGS & TARIFF
    secondary: Import ${{ states('input_number.electricity_import_rate') }}/kWh · feed-in ${{ states('input_number.electricity_feed_in_tariff') }} · supply ${{ states('input_number.electricity_daily_supply_charge') }}/day
    icon: mdi:tune
    icon_color: grey
  - type: grid
    columns: 2
    square: false
    cards:
      - type: custom:mushroom-number-card
        entity: input_number.electricity_import_rate
        name: Import rate
        icon: mdi:cash-minus
        icon_color: red
        display_mode: buttons
      - type: custom:mushroom-number-card
        entity: input_number.electricity_feed_in_tariff
        name: Feed-in
        icon: mdi:cash-plus
        icon_color: green
        display_mode: buttons
      - type: custom:mushroom-number-card
        entity: input_number.electricity_daily_supply_charge
        name: Daily supply
        icon: mdi:calendar-today
        icon_color: orange
        display_mode: buttons
      - type: custom:mushroom-number-card
        entity: input_number.electricity_gst_rate
        name: GST
        icon: mdi:percent
        icon_color: grey
        display_mode: buttons
  - type: grid
    columns: 2
    square: false
    cards:
      - type: custom:mushroom-number-card
        entity: number.goodwe_grid_export_limit
        name: Export limit
        icon: mdi:transmission-tower-export
        icon_color: purple
        display_mode: buttons
      - type: custom:mushroom-number-card
        entity: input_number.goodwe_battery_capacity
        name: Battery kWh
        icon: mdi:battery-high
        icon_color: teal
        display_mode: buttons
      - type: custom:mushroom-number-card
        entity: input_number.solar_array_size
        name: Array size
        icon: mdi:solar-panel
        icon_color: amber
        display_mode: buttons
      - type: custom:mushroom-number-card
        entity: input_number.solar_performance_ratio
        name: Perf ratio
        icon: mdi:chart-bell-curve
        icon_color: blue
        display_mode: buttons
grid_options:
  columns: 12

Column 5: inverter and grid

AC & INVERTER

Inverter modes, AC voltages and frequency.

yaml · 78 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: AC & INVERTER
    secondary: '{{ states(''sensor.goodwe_on_grid_l1_voltage'') }} V | {{ states(''sensor.goodwe_on_grid_l1_frequency'') }} Hz | radiator {{ states(''sensor.goodwe_inverter_temperature_radiator'') }}°C'
    icon: mdi:flash
    icon_color: yellow
  - type: grid
    columns: 3
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_on_grid_l1_voltage
        name: Voltage
        icon: mdi:sine-wave
        icon_color: red
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_on_grid_l1_frequency
        name: Frequency
        icon: mdi:sine-wave
        icon_color: green
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_inverter_temperature_radiator
        name: Radiator
        icon: mdi:thermometer
        icon_color: orange
  - type: custom:apexcharts-card
    header:
      show: true
      title: Grid voltage
    graph_span: 3h
    apex_config:
      chart:
        height: 200
      stroke:
        width: 2
        curve: smooth
      dataLabels:
        enabled: false
      legend:
        show: false
        position: bottom
      xaxis:
        labels:
          datetimeUTC: false
      yaxis:
        min: 225
        max: 260
    series:
      - entity: sensor.goodwe_on_grid_l1_voltage
        name: Voltage
        color: '#ef4444'
        type: line
        group_by:
          func: avg
          duration: 1min
    update_interval: 30s
  - type: grid
    columns: 3
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_work_mode
        name: Work mode
        icon: mdi:state-machine
        icon_color: blue
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_grid_mode
        name: Grid mode
        icon: mdi:transmission-tower
        icon_color: green
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_battery_mode
        name: Batt mode
        icon: mdi:battery-sync
        icon_color: orange
grid_options:
  columns: 12

GRID & ENERGY FLOW

Import and export, today and lifetime, from the meter registers rather than integrated power. See the note on registers in the traps section.

yaml · 61 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: GRID & ENERGY FLOW
    secondary: Imported {{ states('sensor.goodwe_meter_total_energy_import') }} kWh | exported {{ states('sensor.goodwe_meter_total_energy_export') }} kWh lifetime
    icon: mdi:transmission-tower-export
    icon_color: red
  - type: custom:apexcharts-card
    header:
      show: true
      title: Grid power (+ export / ? import)
    graph_span: 6h
    apex_config:
      chart:
        height: 210
      stroke:
        width: 2
        curve: smooth
      dataLabels:
        enabled: false
      legend:
        show: false
        position: bottom
      xaxis:
        labels:
          datetimeUTC: false
    series:
      - entity: sensor.goodwe_active_power
        name: Grid
        color: '#3b82f6'
        type: area
        group_by:
          func: avg
          duration: 1min
    update_interval: 30s
  - type: grid
    columns: 2
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.grid_import_daily
        name: Grid today
        icon: mdi:download-network
        icon_color: red
      - type: custom:mushroom-entity-card
        entity: sensor.grid_export_daily
        name: Export today
        icon: mdi:upload-network
        icon_color: green
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_meter_total_energy_import
        name: Grid total
        icon: mdi:transmission-tower
        icon_color: red
      - type: custom:mushroom-entity-card
        entity: sensor.goodwe_meter_total_energy_export
        name: Export total
        icon: mdi:transmission-tower-export
        icon_color: green
grid_options:
  columns: 12

Column 6: money

COST & BILLING

Three worked-example markdown tables (today, month to date, and bill to date), each showing usage, supply, tax and credit as separate lines. When a figure looks wrong you can see which term is wrong, instead of staring at a single total.

yaml · 124 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: COST & BILLING
    secondary: Today ${{ states('sensor.electricity_net_today') }} | month ${{ states('sensor.electricity_net_month') }} | projected ${{ states('sensor.electricity_bill_projected') }}
    icon: mdi:cash-multiple
    icon_color: red
  - type: grid
    columns: 2
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.electricity_net_today
        name: Net today
        icon: mdi:cash-minus
        icon_color: red
      - type: custom:mushroom-entity-card
        entity: sensor.electricity_savings_today
        name: Saved today
        icon: mdi:piggy-bank
        icon_color: green
      - type: custom:mushroom-entity-card
        entity: sensor.electricity_net_month
        name: This month
        icon: mdi:calendar-month
        icon_color: orange
      - type: custom:mushroom-entity-card
        entity: sensor.electricity_bill_projected
        name: Projected bill
        icon: mdi:file-document
        icon_color: blue
  - type: grid
    columns: 2
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.electricity_bill_to_date
        name: Bill to date
        icon: mdi:receipt
        icon_color: orange
      - type: custom:mushroom-entity-card
        entity: sensor.electricity_billing_period
        name: Period
        icon: mdi:calendar-range
        icon_color: grey
      - type: custom:mushroom-entity-card
        entity: sensor.grid_import_billing
        name: Imported
        icon: mdi:transmission-tower-import
        icon_color: red
      - type: custom:mushroom-entity-card
        entity: sensor.grid_export_billing
        name: Exported
        icon: mdi:transmission-tower-export
        icon_color: green
  - type: markdown
    content: |
      {%- set rate = states('input_number.electricity_import_rate') | float(0) -%}
      {%- set fit  = states('input_number.electricity_feed_in_tariff') | float(0) -%}
      {%- set sup  = states('input_number.electricity_daily_supply_charge') | float(0) -%}
      {%- set gstp = states('input_number.electricity_gst_rate') | float(0) -%}
      {%- set imp = states('sensor.grid_import_daily') | float(0) -%}
      {%- set exp = states('sensor.grid_export_daily') | float(0) -%}
      {%- set load = states('sensor.goodwe_today_load') | float(0) -%}
      {%- set ch = imp*rate + sup -%}{%- set gst = ch*gstp/100 -%}{%- set cr = exp*fit -%}
      #### Today

      | Item | Working | Amount |
      |:---|:---|---:|
      | Usage | {{ '%.2f'|format(imp) }} kWh × ${{ '%.4f'|format(rate) }} | ${{ '%.2f'|format(imp*rate) }} |
      | Supply charge | 1 day × ${{ '%.4f'|format(sup) }} | ${{ '%.2f'|format(sup) }} |
      | GST | {{ '%.0f'|format(gstp) }}% of ${{ '%.2f'|format(ch) }} | ${{ '%.2f'|format(gst) }} |
      | Feed-in credit | {{ '%.2f'|format(exp) }} kWh × ${{ '%.4f'|format(fit) }} | ?${{ '%.2f'|format(cr) }} |
      | **Net today** | | **${{ '%.2f'|format(ch+gst-cr) }}** |

      Grid-only cost for the same {{ '%.1f'|format(load) }} kWh would be **${{ '%.2f'|format((load*rate+sup)*(1+gstp/100)) }}** — you are **${{ '%.2f'|format((load*rate+sup)*(1+gstp/100) - (ch+gst-cr)) }}** ahead.
    grid_options:
      columns: 12
  - type: markdown
    content: |
      {%- set rate = states('input_number.electricity_import_rate') | float(0) -%}
      {%- set fit  = states('input_number.electricity_feed_in_tariff') | float(0) -%}
      {%- set sup  = states('input_number.electricity_daily_supply_charge') | float(0) -%}
      {%- set gstp = states('input_number.electricity_gst_rate') | float(0) -%}
      {%- set imp = states('sensor.grid_import_monthly') | float(0) -%}
      {%- set exp = states('sensor.grid_export_monthly') | float(0) -%}
      {%- set days = now().day -%}
      {%- set ch = imp*rate + sup*days -%}{%- set gst = ch*gstp/100 -%}{%- set cr = exp*fit -%}
      #### Month to date

      | Item | Working | Amount |
      |:---|:---|---:|
      | Usage | {{ '%.2f'|format(imp) }} kWh × ${{ '%.4f'|format(rate) }} | ${{ '%.2f'|format(imp*rate) }} |
      | Supply charge | {{ days }} days × ${{ '%.4f'|format(sup) }} | ${{ '%.2f'|format(sup*days) }} |
      | GST | {{ '%.0f'|format(gstp) }}% of ${{ '%.2f'|format(ch) }} | ${{ '%.2f'|format(gst) }} |
      | Feed-in credit | {{ '%.2f'|format(exp) }} kWh × ${{ '%.4f'|format(fit) }} | ?${{ '%.2f'|format(cr) }} |
      | **Net this month** | | **${{ '%.2f'|format(ch+gst-cr) }}** |
    grid_options:
      columns: 12
  - type: markdown
    content: |-
      {%- set rate = states('input_number.electricity_import_rate') | float(0) -%}
      {%- set fit  = states('input_number.electricity_feed_in_tariff') | float(0) -%}
      {%- set sup  = states('input_number.electricity_daily_supply_charge') | float(0) -%}
      {%- set gstp = states('input_number.electricity_gst_rate') | float(0) -%}
      {%- set imp = states('sensor.grid_import_billing') | float(0) -%}
      {%- set exp = states('sensor.grid_export_billing') | float(0) -%}
      {%- set days = state_attr('sensor.electricity_billing_period','days_elapsed') | int(1) -%}
      {%- set total = state_attr('sensor.electricity_billing_period','days_total') | int(30) -%}
      {%- set left = state_attr('sensor.electricity_billing_period','days_left') | int(0) -%}
      {%- set ch = imp*rate + sup*days -%}{%- set gst = ch*gstp/100 -%}{%- set cr = exp*fit -%}
      #### Bill to date — {{ states('sensor.electricity_billing_period') }}

      | Item | Working | Amount |
      |:---|:---|---:|
      | Usage | {{ '%.2f'|format(imp) }} kWh × ${{ '%.4f'|format(rate) }} | ${{ '%.2f'|format(imp*rate) }} |
      | Supply charge | {{ days }} day{{ 's' if days != 1 }} × ${{ '%.4f'|format(sup) }} | ${{ '%.2f'|format(sup*days) }} |
      | GST | {{ '%.0f'|format(gstp) }}% of ${{ '%.2f'|format(ch) }} | ${{ '%.2f'|format(gst) }} |
      | Feed-in credit | {{ '%.2f'|format(exp) }} kWh × ${{ '%.4f'|format(fit) }} | ?${{ '%.2f'|format(cr) }} |
      | **Bill to date** | | **${{ '%.2f'|format(ch+gst-cr) }}** |

      *This is the one that matches an invoice: the billing period runs the 11th to the 10th — {{ days }} of {{ total }} days in, {{ left }} to go. **Month to date above is the CALENDAR month** (from the 1st), so the two do not agree: ${{ '%.2f'|format(states('sensor.electricity_net_month') | float(0)) }} there against **${{ '%.2f'|format(ch+gst-cr) }}** here.*
grid_options:
  columns: 12

PAYBACK

Progress as a templated block bar, not a gauge. At 0.6% repaid the gauge’s severity bands painted it bright red, which reads as a fault when being 0.6% through a multi-year payback twelve days after commissioning is exactly right. A dial conveys nothing at that scale either: the needle is pinned to the stop for months.

General rule: severity colours belong on health metrics, not progress metrics. Low battery is a problem; low payback-so-far is just early.

The fill uses round(0, 'ceil') so any non-zero progress shows at least one block rather than an empty bar, and is clamped with | min so it cannot overflow past 100%.

yaml · 101 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: PAYBACK
    secondary: ${{ states('sensor.solar_lifetime_savings') }} returned of ${{ states('input_number.solar_system_cost') | int }} — {{ states('sensor.solar_payback_progress') }}%
    icon: mdi:cash-clock
    icon_color: green
  - type: markdown
    grid_options:
      columns: 12
    content: |-
      {%- set p = states('sensor.solar_payback_progress') | float(0) -%}
      {%- set saved = states('sensor.solar_lifetime_savings') | float(0) -%}
      {%- set cost = states('input_number.solar_system_cost') | float(1) -%}
      {%- set eta = states('sensor.solar_payback_eta') -%}
      {%- set n = 28 -%}{%- set fill = [(p / 100 * n) | round(0, 'ceil') | int, n] | min -%}
      {{ '?' * fill }}{{ '?' * (n - fill) }}

      **{{ '%.2f'|format(p) }}%** — ${{ '{:,.0f}'.format(saved) }} of ${{ '{:,.0f}'.format(cost) }} recovered.

      Break-even {{ as_timestamp(eta) | timestamp_custom('%b %Y') if eta not in ['unknown','unavailable'] else 'unknown' }}.
  - type: markdown
    content: |-
      {%- set cost = states('input_number.solar_system_cost') | float(0) -%}
      {%- set save = states('sensor.solar_lifetime_savings') | float(0) -%}
      {%- set left = states('sensor.solar_payback_remaining') | float(0) -%}
      {%- set days = state_attr('sensor.solar_payback_eta','days_running') | int(1) -%}
      {%- set avg  = state_attr('sensor.solar_payback_eta','avg_per_day') | float(0) -%}
      {%- set yrs  = state_attr('sensor.solar_payback_eta','years_remaining') -%}
      {%- set load = states('sensor.goodwe_total_load') | float(0) -%}
      {%- set imp  = states('sensor.goodwe_meter_total_energy_import') | float(0) -%}
      {%- set exp  = states('sensor.goodwe_meter_total_energy_export') | float(0) -%}
      {%- set rate = states('input_number.electricity_import_rate') | float(0) -%}
      {%- set fit  = states('input_number.electricity_feed_in_tariff') | float(0) -%}
      {%- set g    = 1 + states('input_number.electricity_gst_rate') | float(0) / 100 -%}
      #### How the return is calculated

      | Item | Working | Amount |
      |:---|:---|---:|
      | Grid-only cost | {{ '%.1f'|format(load) }} kWh × ${{ '%.4f'|format(rate) }} +GST | ${{ '%.2f'|format(load*rate*g) }} |
      | Less: imported | {{ '%.1f'|format(imp) }} kWh × ${{ '%.4f'|format(rate) }} +GST | ?${{ '%.2f'|format(imp*rate*g) }} |
      | Plus: exported | {{ '%.1f'|format(exp) }} kWh × ${{ '%.4f'|format(fit) }} | +${{ '%.2f'|format(exp*fit) }} |
      | **Returned so far** | over {{ days }} day{{ 's' if days != 1 }} | **${{ '%.2f'|format(save) }}** |
      | **Still to recover** | of ${{ '{:,.0f}'.format(cost) }} | **${{ '{:,.2f}'.format(left) }}** |

      At the current average of **${{ '%.2f'|format(avg) }}/day** that is **{{ yrs }} years** to break even.

      *Supply charge excluded — payable either way. Straight-line from {{ days }} day{{ 's' if days != 1 }} of data; a placeholder until there is a full year of seasons.*
    grid_options:
      columns: 12
  - type: grid
    columns: 2
    square: false
    cards:
      - type: custom:mushroom-entity-card
        entity: sensor.solar_payback_remaining
        name: Remaining
        icon: mdi:cash-remove
        icon_color: orange
      - type: custom:mushroom-entity-card
        entity: sensor.solar_payback_eta
        name: Break-even
        icon: mdi:calendar-clock
        icon_color: blue
      - type: custom:mushroom-entity-card
        entity: input_number.solar_system_cost
        name: System cost
        icon: mdi:currency-usd
        icon_color: grey
      - type: custom:mushroom-entity-card
        entity: input_datetime.solar_install_date
        name: Installed
        icon: mdi:calendar-check
        icon_color: grey
  - type: markdown
    content: |-
      {%- set rate = states('input_number.electricity_import_rate') | float(0) -%}
      {%- set fit  = states('input_number.electricity_feed_in_tariff') | float(0) -%}
      {%- set sup  = states('input_number.electricity_daily_supply_charge') | float(0) -%}
      {%- set gstp = states('input_number.electricity_gst_rate') | float(0) -%}
      {%- set el   = state_attr('sensor.electricity_billing_period','days_elapsed') | int(1) -%}
      {%- set tot  = state_attr('sensor.electricity_billing_period','days_total') | int(30) -%}
      {%- set imp  = states('sensor.grid_import_billing') | float(0) -%}
      {%- set exp  = states('sensor.grid_export_billing') | float(0) -%}
      {%- set pimp = imp / el * tot -%}{%- set pexp = exp / el * tot -%}
      {%- set ch = pimp*rate + sup*tot -%}{%- set gst = ch*gstp/100 -%}{%- set cr = pexp*fit -%}
      #### Projected bill — {{ states('sensor.electricity_billing_period') }}

      | Item | Working | Amount |
      |:---|:---|---:|
      | Usage | {{ '%.1f'|format(pimp) }} kWh × ${{ '%.4f'|format(rate) }} | ${{ '%.2f'|format(pimp*rate) }} |
      | Supply charge | {{ tot }} days × ${{ '%.4f'|format(sup) }} | ${{ '%.2f'|format(sup*tot) }} |
      | GST | {{ '%.0f'|format(gstp) }}% of ${{ '%.2f'|format(ch) }} | ${{ '%.2f'|format(gst) }} |
      | Feed-in credit | {{ '%.1f'|format(pexp) }} kWh × ${{ '%.4f'|format(fit) }} | ?${{ '%.2f'|format(cr) }} |
      | **Projected total** | | **${{ '%.2f'|format(ch+gst-cr) }}** |

      *Projected from {{ el }} of {{ tot }} days.{% if el < 7 %} **Too early to trust** — {{ el }} day{{ 's' if el != 1 }} is too few for a straight line; one sunny or cloudy day moves it by tens of dollars.{% else %} Straight-line, so a run of cloudy days late in the period will still move it.{% endif %}*
    grid_options:
      columns: 12
grid_options:
  columns: 12

Column 7: the native energy cards

The four built-in energy-* cards cannot be restyled internally, since they take their period from their own date selector. Wrapping each in a stack-in-card under a mushroom header makes them read as part of the dashboard rather than a foreign strip. energy-date-selection is view-wide, so it appears once at the top of the first of them and governs all four.

yaml · 54 lines
type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: ENERGY HISTORY
    secondary: Pick a period — the panels below follow it
    icon: mdi:calendar-clock
    icon_color: blue
  - type: energy-date-selection
    grid_options:
      columns: 12
  - type: energy-distribution
    link_dashboard: true
    grid_options:
      columns: 12
grid_options:
  columns: 12

type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: HISTORY — SOURCES
    secondary: Where the energy came from, and what it cost
    icon: mdi:table
    icon_color: cyan
  - type: energy-sources-table
    grid_options:
      columns: 12
grid_options:
  columns: 12

type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: HISTORY — USAGE
    secondary: Grid, solar and battery by hour
    icon: mdi:chart-bar
    icon_color: purple
  - type: energy-usage-graph
    grid_options:
      columns: 12
grid_options:
  columns: 12

type: custom:stack-in-card
cards:
  - type: custom:mushroom-template-card
    primary: HISTORY — SOLAR
    secondary: Production against the forecast
    icon: mdi:solar-power
    icon_color: yellow
  - type: energy-solar-graph
    grid_options:
      columns: 12
grid_options:
  columns: 12

6. Automations this data makes possible

The sensors in section 2 are worth building for the dashboard alone, but they also unlock automations that the raw inverter entities cannot support. A signed power value cannot be threshold-triggered sensibly, and “battery percent” says nothing about whether the battery will last the night. These are the ones that have earned their place.

6.1 Run a load on real surplus

The obvious version of this triggers on export above some threshold. The obvious version is also wrong, because early in the day that surplus should be going into the battery. Gate it on the battery being nearly full, and the automation only fires on surplus that would otherwise be sold at the feed-in rate:

yaml · 18 lines
- alias: Solar surplus - start the dishwasher
  trigger:
    - platform: numeric_state
      entity_id: sensor.goodwe_grid_export_power
      above: 1500
      for: "00:10:00"          # ride out cloud flicker
  condition:
    - condition: numeric_state
      entity_id: sensor.goodwe_battery_state_of_charge
      above: 90                # do not steal from the battery
    - condition: state
      entity_id: switch.dishwasher
      state: "off"
  action:
    - service: switch.turn_on
      target:
        entity_id: switch.dishwasher
  mode: single

The for: matters. Without it a passing cloud toggles the trigger repeatedly, and an appliance that restarts mid-cycle is worse than one that never started. This is only possible because grid_export_power is one-directional: you cannot put a sensible above: on a signed sensor that swings through zero.

6.2 Warn when the battery will not reach sunrise

State of charge alone cannot answer this. 40% is comfortable in April and nowhere near enough in June. The runtime sensor from section 2.4 compares the projected empty time against the actual next sunrise:

yaml · 26 lines
- alias: Battery will not last until sunrise
  trigger:
    - platform: time_pattern
      minutes: "/15"
  condition:
    - condition: sun
      after: sunset
    - condition: template
      value_template: >-
        {% set out = states('sensor.goodwe_battery_runs_out') %}
        {{ out not in ['unknown', 'unavailable']
           and as_datetime(out) < as_datetime(state_attr('sun.sun', 'next_rising')) }}
    - condition: state
      entity_id: input_boolean.battery_warning_sent
      state: "off"
  action:
    - service: notify.mobile_app_phone
      data:
        message: >-
          Battery is on track to hit its floor at
          {{ as_datetime(states('sensor.goodwe_battery_runs_out')).strftime('%H:%M') }},
          before sunrise. {{ states('sensor.goodwe_battery_usable_energy') }} kWh left.
    - service: input_boolean.turn_on
      target:
        entity_id: input_boolean.battery_warning_sent
  mode: single

The input_boolean guard matters. A 15-minute trigger with no latch sends the same warning nineteen times before dawn. Clear it with a second automation at sunrise.

6.3 Detect a dead string

A failed string, a tripped isolator or a shaded panel on one MPPT is close to invisible on a daily total, because the other strings mask it. Comparing the strings against each other catches it in half an hour:

yaml · 20 lines
- alias: PV string produced nothing while the others did
  trigger:
    - platform: time_pattern
      minutes: "/30"
  condition:
    - condition: sun
      after: sunrise
      before: sunset
    - condition: numeric_state
      entity_id: sensor.goodwe_pv2_power
      above: 500                              # a reference string is clearly working
    - condition: template
      value_template: "{{ states('sensor.goodwe_pv3_power') | float(0) < 50 }}"
  action:
    - service: notify.mobile_app_phone
      data:
        message: >-
          PV3 is at {{ states('sensor.goodwe_pv3_power') }} W while PV2 is at
          {{ states('sensor.goodwe_pv2_power') }} W. Check the string.
  mode: single

Pick the reference string carefully on a split array. Two strings on different roof faces peak hours apart, so a comparison that is fair at noon can be badly unfair at four o’clock. Compare strings that share an orientation, or raise the threshold until the false positives stop.

6.4 Treat the inverter as a power-cut sensor

A grid-tied inverter drops off the network when the grid fails, so its availability doubles as an outage signal. It cannot distinguish a power cut from a network fault, which is why the message should not claim to:

yaml · 11 lines
- alias: Inverter unreachable - possible power cut
  trigger:
    - platform: state
      entity_id: sensor.goodwe_active_power
      to: unavailable
      for: "00:05:00"
  action:
    - service: notify.mobile_app_phone
      data:
        message: "Inverter unreachable for 5 minutes. Power cut, or a network fault."
  mode: single

Five minutes filters routine integration reconnects. If Home Assistant itself is on the affected supply it will be down too, so treat this as a best-effort signal rather than monitoring.

6.5 Do not mistake a BMS calibration for a fault

GoodWe packs periodically run a calibration charge, forcing the battery to 100% at a fixed rate and pulling the shortfall from the grid regardless of how much sun is available. On this system one such event imported 12.8 kWh in an afternoon. Without an explanation that looks exactly like a serious fault, so it is worth an informational notification rather than an alarm:

yaml · 12 lines
- alias: Battery calibration charge started
  trigger:
    - platform: state
      entity_id: sensor.goodwe_diag_status
      to: "BMS: Emergency charging"
  action:
    - service: notify.mobile_app_phone
      data:
        message: >-
          Battery calibration charge started. The inverter will force-charge to 100% and
          may import from the grid. This is normal and clears by itself.
  mode: single

Expect it on a new install. If it recurs every week, that is worth chasing, because in normal self-consumption mode nothing else should ever grid-charge the battery.

6.6 Flag a bill heading somewhere unexpected

The projected bill sensor from section 2.9 is a straight-line projection, so it is meaningless in the first few days of a period and reasonable after that:

yaml · 16 lines
- alias: Projected bill above budget
  trigger:
    - platform: numeric_state
      entity_id: sensor.electricity_bill_projected
      above: 120
  condition:
    - condition: template
      value_template: >-
        {{ state_attr('sensor.electricity_billing_period', 'days_elapsed') | int(0) >= 7 }}
  action:
    - service: notify.mobile_app_phone
      data:
        message: >-
          Projected bill is ${{ states('sensor.electricity_bill_projected') }} based on
          {{ state_attr('sensor.electricity_bill_projected', 'basis') }}.
  mode: single

The seven-day condition is the same guard the dashboard card uses, and for the same reason: three days of data multiplied out to a month is a number, not a projection.

What not to automate

Resist automating anything that writes inverter settings on a schedule: charge windows, discharge floors, export limits. Those interact with the inverter’s own EMS logic in ways that are hard to observe from Home Assistant, and a scheduled write that fights a manual change is very difficult to diagnose later. Read freely; write deliberately, and by hand.

7. Traps worth knowing

Prefer a real energy register over an integrated power sensor

It is easy to build kilowatt-hours with a Riemann sum over a power sensor, and sometimes you must. Where the meter exposes a real cumulative register, use the register for anything billing-relevant. Integration accrues error across polling gaps and restarts, and near zero flow it manufactures readings out of noise. Here, with export physically near zero, the integrated sensor still produced ~0.1 kWh/day of phantom export purely by integrating the meter’s tolerance band. The register correctly read nothing.

Where integration is the better option, such as a battery counter that under-reports at low power, validate it before trusting it. Sample power every 30 seconds, integrate by hand, compare against what the counter actually moved. Agreement inside a few percent is fine; a factor of two means the wrong source.

A counter that sits still is not necessarily broken

One day here imported exactly zero from the grid and the register sat unchanged from midnight to midnight, which is what a dead sensor looks like. It was not dead: independently integrating inverter power gave 0.19 kWh in, and the day really was self-sufficient with the battery carrying the night. The register’s 0.1 kWh resolution rounded it to nothing. Cross-check against an independent measurement before calling a counter stuck.

Never write a date into dashboard prose

One card carried a hand-written caveat: “Not representative, the system went in on 22 August. First meaningful projection: the period from 11 September.” True when written, false from 11 September, and it went on dismissing every projection for a reason that no longer applied. It now reads “Too early to trust” while fewer than seven days have elapsed and drops that by itself.

A related trap: a template is only as true as the entity it points at. One explainer described the method a sensor used before it was changed, and because the percentage in it was templated it rendered a live-looking figure that no longer matched what the sensor did. When a calculation changes, grep the dashboards for prose describing it.

Radial bars never render from a single state

An apexcharts radialBar for an instantaneous percentage will sit on “Loading…” forever. That chart type wants a data series; it does not draw from a current state, and it fails silently rather than erroring, so it looks like a slow load. A built-in gauge with needle: false gives the same filled arc and cannot fail that way.

The history API will quietly give you wrong answers

  • It defaults end_time to start + one day. Ask for thirteen days and you get the first twenty-four hours with no warning, which reads as “this sensor barely reports” when you are looking at a stale slice.
  • A naive timestamp is parsed as local time, not UTC. Feeding it UTC output with no offset shifts the window silently. The symptom is not an error, it is plausible-looking data from the wrong period.
  • It defaults to significant changes only, dropping every attribute-only change. Pass significant_changes_only=0 when analysing an attribute.

And one about windows generally: a total over a window says nothing about whether the behaviour is current. One counter showed 118,817 state changes over thirteen days, which looked like a live fault; 106,000 came from two days at the start and the rate was already forty times lower by the time anyone looked. Read the daily distribution before calling anything ongoing.

8. Validate before you save, not after

A bad entity reference renders as an “Entity not found” card and a broken template renders as nothing at all. Custom cards validate their own config and paint errors into their own body, so a broken card is invisible from the command line and absent from the log. Validate offline:

  1. Walk the config for every entity reference: entity: keys, entity_id in action data, and every entity named inside a Jinja template. Check each against the states API. The template case matters: a key-only diff will happily report a sensor as dropped when it is still referenced in a header.
  2. Render every template against /api/template and read the output. The rendered value is the check. A template that silently returns an empty string looks identical in the config to one that works.
  3. Validate custom-card configs against their own schema where one ships. Several HACS cards are built with zod and export their schema in a chunk you can import under a small DOM shim, then safeParse a candidate config offline.
  4. Diff before saving. Re-fetch the live config, confirm it still matches what you started from, and confirm only the cards you meant to touch have changed.

That process is what caught eight cards here carrying a config option that had changed type in a card update, every one rendering a red error band that had gone unnoticed for weeks on two other dashboards. The option had become an array; the old string value still parsed as valid YAML and was rejected only by the card itself, at render time.

9. What it took

Component Count
Column sections 7
Panels 22
Entities referenced 73
Live templates 27
Template sensors behind it 25
Custom cards stack-in-card, mushroom, apexcharts-card, sankey-chart, power-flow-card-plus, card-mod

The custom cards all come from HACS. None is essential in the sense that the data would be wrong without it. The sensors are the substance; the cards are presentation. That split is worth preserving: if a card is abandoned upstream you replace a panel rather than rebuild a system.

Takeaways

  • Build the sensor layer first. Verify sign conventions by measurement, never by reading option names.
  • Use column sections rather than one section per panel. It removes row-height whitespace entirely.
  • Column order is the only lever for mobile. Put the most important panel first.
  • Give unrelated percentages different shapes, and keep severity colours for health metrics.
  • Benchmark against a modelled ceiling, and treat any impossible value as a calibration fault.
  • Reconcile tariffs against a real bill and check whether the rates already include tax.
  • Never hard-code a date into dashboard prose.
  • Validate by rendering, not by reading. A clean config check proves nothing about whether an entity was created or a card will draw.

When it makes sense to call someone

If Home Assistant is a hobby, this is a few evenings of work and most of it is in this post. If the inverter is one of several on a commercial site, or you want the same tariff reconciliation and payback tracking across a fleet of properties, the sensor layer is what needs doing right first.

MobileTechs sets up Home Assistant, solar and battery monitoring and the network underneath it for Gold Coast homes and businesses. Get in touch or call 1300 644 588.