Power Management (Generic Energy Dashboard)
GEM talks to many power devices — battery gateways (Tesla Powerwall, Enphase Encharge, SPAN), solar inverters (Enphase, SolarEdge, Fronius, Sol-Ark), whole-home and circuit meters (Emporia Vue, Sense, Meross EM), UPS/PDUs (CyberPower, WattBox, Panamax) and EV chargers (OpenEVSE, Tesla Wall Connector). Each driver reports power data under its own attribute names and units. To make a single dashboard work across all of them without a per-site build, GEM normalizes that vocabulary in the attribute registry and exposes it through the Power Flow widget.
The Power Flow widget
Add it to any UI page like any other widget (UIs → Pages → add widget → Power Flow). It renders a live energy-flow diagram: a central Home node with Solar, Grid and Battery around it. Flow lines animate in the direction power is moving and speed up under load; the battery node fills from the bottom to show state of charge and shows a bolt overlay while it is charging. Nodes a device doesn't report (e.g. a solar-only inverter with no battery) are simply omitted. In aggregate mode the battery fill uses the same energy-weighted fleet SOC as the Battery Backup widget (see below), so the two never disagree.
Direction — importing / exporting, charging / discharging — is resolved through a deadband with hysteresis: a leg has to carry more than 50 W before it counts as flowing, and an established flow only drops back to idle once it falls under 25 W. A resting battery, whose meter reports a few watts of noise of either sign on every poll, therefore reads Idle instead of flipping between Charging and Discharging, and a solar inverter's night-time standby draw is idle rather than a reverse flow. Only the labels, flow lines and charge bolt use the deadband; the watts shown on each node are the raw reading.
Settings:
| Setting | Purpose |
|---|---|
| Power Device | The inverter / gateway / battery to visualize (e.g. a Tesla Powerwall device). |
| Aggregate All Power Devices | Ignore the device above and sum every power-reporting device on the site into one flow. |
| Refresh (ms) | Poll interval for the flow snapshot (default 5000). |
| Show Battery SOC | Render battery state of charge as the battery fill level. |
The Top Circuits widget
Where Power Flow answers "solar vs grid vs battery," Top Circuits answers "which loads."
It renders a live, ranked bar list of the highest-draw circuits — the per-circuit complement to
the flow diagram. It reads every zone tagged with the power.circuit role (SPAN panel breakers
today; any sub-meter that tags the role appears automatically), normalizes to watts and sorts by
live draw, biggest consumers first.
Settings:
| Setting | Purpose |
|---|---|
| Panel Device | Limit to circuits of a single panel / sub-metering device. Leave empty to include every circuit on the site. |
| Circuits Shown | How many top-draw circuits to list (default 8). |
| Refresh (ms) | Poll interval for the circuit snapshot (default 5000). |
| Show Total | Show the summed draw of all circuits (before the limit) in the header. |
Circuit values are signed: positive is consumption, negative is backfeed/export (a solar or
battery tab, or a circuit whose CT clamp is physically reversed — see the SPAN panel docs
and the zone ct_clamp_reversed attribute). Exporting circuits render in green.
More power widgets
All ride the same metric registry — no per-device code, no extra setup beyond tagging the driver.
| Widget | Shows | Reads |
|---|---|---|
| Self-Sufficiency | Ring gauge: share of current load met locally (solar + battery) vs the grid, (load − max(0, grid_import)) / load. | power.load, power.grid |
| Battery Backup | SOC ring, energy remaining (kWh), and estimated backup runtime at the current load (energy_remaining ÷ load). Charge/discharge status through the same 50 W / 25 W deadband as Power Flow, so a full battery floating at a few watts reads Idle; flips to "On Backup" when the grid is down. Aggregate mode uses the energy-weighted fleet SOC computed server-side (shared with Power Flow). | battery.soc, battery.energy_remaining, power.load, power.battery, power.grid_status |
| Energy Today | Today's consumed / produced / net energy, accumulated since local midnight. Survives restarts. | energy.consumed, energy.produced |
How "today" works. energy.* roles are cumulative monotonic counters, so "today" is current − the counter's value at the start of the local day. The server snapshots every counter into one
bounded system attribute (metric_energy_today_baseline) once per day — a UPSERT, not a
time-series — and the read path differences against it. A daily rollover task (every 5 min,
GemServer.startMetricEnergyRollup) captures the midnight boundary; the baseline persists, so a
mid-day restart preserves the day's totals. A counter reset/replacement is clamped to ≥ 0 and
self-heals at the next rollover. Day boundary is server-local time (GEM sets the OS clock to the
site timezone at boot).
Building the dashboard with the AI assistant
Placing the panels by hand is six ui_widget rows plus six placements, and it means working out
first which panels this site can populate — which is not visible anywhere in the admin UI. The
AI assistant's create_energy_dashboard skill does both in one call: ask it
to "build me an energy dashboard", "add a solar page to the main UI", or "build a circuits page for
the SPAN panel".
It surveys the site through the same resolvers the widgets use, so a panel is only added when the
data behind it exists. A solar-plus-battery Powerwall site gets Power Flow, Battery Backup,
Self-Sufficiency and Energy Today; a grid-only SPAN panel gets Top Circuits and
Energy Today and no battery tile that would sit empty forever. Device targeting follows the
same rule — one power device is pinned by device_id, several switch the targeted panels to
aggregate — and both can be overridden.
- Name the panels explicitly (
energy_flow,battery_backup,self_sufficiency,energy_today,top_circuits,water_flow,energy_demand) to skip the survey and place them in that order. - Water Flow is never auto-selected — ask for it, since it is irrigation rather than energy.
- Energy & Demand (kW rollup plus active OpenADR events) is admin-only, so put it on an integrator UI rather than a homeowner page.
- The page is created or reused by name, panels are appended after anything already on it, and every insert rolls back together if one fails.
When the survey finds no power metrics at all the skill reports that and builds nothing. That means
no driver on the site tags power metric roles — the fix is a reporting power device (see Drivers
currently tagged below), not more UI. The skill never creates power devices or writes
metric_role tags.
The Water Flow widget
The registry mechanism is not power-specific (see How it works below). Water Flow is the irrigation counterpart to Power Flow: add it to any UI page (UIs → Pages → add widget → Water Flow) and it renders a live diagram of an irrigation controller — a central supply node showing the flow rate (L/min) fanning out to the controller's zones. Lines animate toward whichever zones are watering, and the supply rate animates faster as flow rises; each zone node shows its sprinkler state plus either its run time-remaining (while watering) or its week-to-date volume (while idle). Point it at a single controller (e.g. a Hunter Hydrawise) or aggregate every water device on the site.
Like the power widgets it is generic over vendors — any driver that tags the water.*
metric roles lights up the same widget with no per-driver code. A device counts as a
"water device" when its driver tags water.flowrate (the controller's flow meter) or its
zones tag water.flowrate / water.usage. When no device-level meter is present the
supply rate falls back to the sum of per-zone flow.
Settings:
| Setting | Purpose |
|---|---|
| Water Controller | The irrigation controller device to visualize. Leave empty and enable Aggregate to combine all water devices. |
| Aggregate All Water Devices | Ignore the device above and combine every water-reporting controller on the site into one flow. |
| Units | Display flow and volume in metric (litres) or imperial (gallons). Canonical values are litres; conversion is display-only. |
| Refresh (ms) | Poll interval for the water-flow snapshot (default 5000). |
How it works — canonical metric roles
The role vocabulary and unit normalization live in lib/attribute_metrics.js (the single
source of truth for the catalog); resolution against live device/zone values lives in
lib/metric_resolver.js. Roles are domain-namespaced <domain>.<concept> (power.solar,
battery.soc, water.flowrate, water.usage, …) and the mechanism is deliberately
domain-agnostic — not power-specific. Irrigation is the worked non-power example: a Hydrawise
controller's flow meter tags water.flowrate (flow, L/min) and per-zone week-to-date volume
tags water.usage (counter, litres) under a dedicated volume dimension.
Each role declares a dimension (with a canonical unit and affine value × scale + offset
conversions, so e.g. temperature in °F converts correctly) and a kind: flow (instantaneous
rate, signed), counter (cumulative total, windowed by differencing), level (bounded gauge),
or status (enum). A dashboard can render a role it has never seen from its kind alone.
The attribute registry tags each power attribute with a metric_role and a source
unit. A generic consumer reads the role, not the driver-specific name — so production_now_w
(Enphase), pv_power (SolarEdge), pv_power_w (Fronius) and solar_power_w (Tesla) all resolve
to the same power.solar concept. Driver attribute names are never renamed; normalization lives
entirely in the registry plus the server resolver.
Instantaneous flow roles are normalized to watts with one fixed sign convention:
| Role | Meaning | Sign convention |
|---|---|---|
power.solar | Solar/PV generation | ≥ 0 |
power.load | Site/home consumption | ≥ 0 |
power.grid | Grid power | + import, − export |
power.battery | Battery power | + discharge, − charge |
power.generator | Generator output | ≥ 0 |
power.circuit | Per-circuit / sub-meter draw (zone-level, one per breaker) | + consume, − backfeed |
Cumulative counter roles (watt-hours): energy.consumed, energy.produced,
energy.grid_import, energy.grid_export. Storage/status roles: battery.soc (%),
battery.energy_remaining / battery.capacity (kWh), power.grid_status, power.mode.
Roles can be tagged on devices (whole-gateway flow) or zones (per-circuit draw); the resolver keys by the entity's driver either way.
When a driver's native attribute uses the opposite sign for its role, its seed row carries
metric_sign: 'inverted' and the resolver negates it (e.g. Enphase net_power_w, which the
driver reports as positive-for-export). When a driver's load attribute is missing or its sign is
unreliable (e.g. Fronius reports consumption as negative), the resolver derives load from the
energy balance: load = solar + grid + battery.
Drivers currently tagged
| Driver | Solar | Grid | Battery | Load | SOC | Notes |
|---|---|---|---|---|---|---|
| Tesla Powerwall | ✓ | ✓ | ✓ | ✓ | ✓ | Verified against live firmware 26.10.3. Lifetime energy counters also tagged (solar→energy.produced, load→energy.consumed, grid→energy.grid_import/energy.grid_export) → Energy Today. |
| Enphase Envoy/IQ | ✓ | ✓ | ✓ | ✓ | ✓ | Grid sign inverted in registry; battery sign should be field-verified. |
| SolarEdge | ✓ | ✓ | ✓ | ✓ | ✓ | Requires the power-flow API (consumption/storage meter). Values in kW. |
| Fronius | ✓ | ✓ | ✓ | derived | — | Load derived (Fronius P_Load sign convention). |
| SPAN | — | — | — | — | ✓ | Device-level SOC; per-circuit power (power.circuit) and energy counters (energy.consumed/energy.produced) tagged at the zone level → Top Circuits. |
| Sense | ✓ | — | — | ✓ | — | Whole-home load and solar (when a solar signal is configured) at the device level → Power Flow; per-detected-device/circuit draw (power.circuit) tagged at the zone level → Top Circuits. |
| Cordex HP | — | — | ✓ | ✓ | — | DC power plant. Load and battery power derived from bus voltage × current (the plant reports volts + amps, not watts). Battery sign follows the Cordex battery_current convention — verify against a discharge (AC-fail) event and set metric_sign: inverted if reversed. |
| Emporia Vue | — | — | — | — | — | Per-circuit draw (power.circuit) tagged at the zone level → Top Circuits. |
| Meross EM | — | — | — | — | — | Per-channel draw (power.circuit) tagged at the zone level → Top Circuits. |
Sub-meter / metered drivers expose per-circuit draw via the power.circuit zone role → Top
Circuits; SPAN, Emporia Vue and Meross EM are tagged. Sense additionally reports whole-home load
(and solar, when a solar signal is configured) at the device level → Power Flow. UPS/PDUs are not
yet tagged for the flow diagram.
Adding a new driver to the dashboard
No widget or server code is needed — tag the driver's attributes in
lib/attribute_registry_seed.js:
{name: 'pv_power_w', system_target: 'device', value_type: 'float', category: 'state',
context: 'my_inverter', history: true, unit: 'W',
description: 'PV production.', metric_role: 'power.solar'},
Pick the attribute whose native sign already matches the convention above; if none does, add
metric_sign: 'inverted'. Units are W (default), kW or MW. On the next boot the seeder
applies the tags and the Power Flow widget picks the device up automatically.
API — get_metric_energy_flow
Socket event (and GemApp.getMetricEnergyFlow(params) on the client):
params:{device_id}for a single device, or omit for a site-wide aggregate.- Returns
{devices: [...], totals: {...}}where each device is{device_id, device_name, driver, solar_w, grid_w, battery_w, load_w, battery_soc, grid_status, mode}with flows in watts following the sign convention above. totalssums the per-device flows (solar_w,grid_w,battery_w,load_w,battery_energy_remaining_wh,battery_capacity_wh) and adds a single fleetbattery_soc(%). It is energy-weighted — total stored ÷ total capacity — so batteries of different size or charge combine correctly rather than a plain mean of per-device percentages over-weighting a small full pack. For batteries that report a percentage but no capacity (e.g. SolarEdge, SPAN) it falls back to a simple mean of those devices' SOC;nullwhen no battery reports SOC. Both the Power Flow and Battery Backup widgets read this one number so their aggregate SOC always agrees.
API — get_metric_circuit_power
Socket event (and GemApp.getMetricCircuitPower(params) on the client):
params:{device_id?, limit?}— optionally restrict to one panel device and/or cap the number of returned circuits.- Returns
{circuits: [...], total_w}where each circuit is{zone_id, zone_name, label, device_id, device_name, watts}, sorted bywattsdescending (signed: biggest consumers first, exporters last).total_wsums all circuits before anylimitis applied.
These resolvers read live values straight from the in-memory device/zone objects, so they reflect the latest poll without a database round-trip.
API — get_metric_energy_today
Socket event (and GemApp.getMetricEnergyToday() on the client):
- Returns
{consumed_wh, produced_wh, net_wh, by_role, date, baseline_ready, counter_count}— today's energy summed across every entity carrying anenergy.*counter role, differenced against the persisted daily baseline.baseline_readyis false only in the brief window before the first rollover of the day. Read-only; the rollover task owns the baseline write.
The homeowner voice assistant answers daily-total questions from the same resolver (the
energy_today skill): "how much energy did I use today?", "how much solar did I make today?",
"am I net positive today?" — the cumulative-kWh sibling of the instantaneous power_flow and
state-of-charge battery_status skills.
Multi-day totals — the energy_history skill
The baseline differencer above only ever covers today. Anything longer — "how much did we use
yesterday?", "how much this month?", "how much solar did I make this week?", "are we using more
than last month?", "what was our biggest day?" — is answered by the energy_history voice skill,
which reads the banked daily rollup through the same query the Energy & Demand report uses, so
a spoken total and the emailed report can never disagree.
- Windows:
yesterday,last_7_days(default),this_week,last_week,this_month,last_month(a true calendar month, which is what a utility bill covers),last_30_days,this_year, or an explicit rolling window of 2–400 days. - Shapes: consumed, produced, net, grid import, grid export, average kWh per day, the peak day and its date, the top-consuming meters, or an estimated cost.
- Comparison: asking whether usage is up or down totals the immediately preceding window of the same length and reports the delta and percentage.
- Cost uses a rate the homeowner states in the question, or a site
cost_per_kwh/electricity_ratevariable. With neither, cost is simply left out — a rate is never invented.
Three limits are reported rather than smoothed over: today is never included (a day is only
banked at the local-midnight rollover, so "this month" means the 1st through yesterday), days the
controller was offline across a rollover come back merged and are reported as gap days, and when
nothing is banked yet the skill says so and points at energy_today for the live number.
"Is the power out?" — the backup_power_status skill
Watts are not an outage. power_flow answers "how much solar am I making", and battery_status
answers state-of-charge — a Powerwall sits at 78% whether or not the grid is up. The
utility-vs-backup question is answered by the backup_power_status voice skill: "is the power
out?", "are we on the generator?", "is the UPS on battery?", "how long do we have?",
"how long has the power been out?"
It is read-only and combines three independent signals into one verdict:
| Signal | Read from |
|---|---|
| Utility | The power.grid_status metric role (a Tesla Powerwall reports islanded during an outage), plus any attribute named grid_status / grid_state / utility_status / on_grid / island*, plus an automatic transfer switch's source-available bits. |
| Generator | Engine/genset running attributes (generator_running, engine_state, genset_state) and the transfer switch's contactor position — source2 means the generator is carrying the load. Exercise and test flags are read too, so a weekly generator exercise is not reported as an outage. |
| UPS | on_battery, output_status, runtime_remaining, battery_level, time_on_battery and low_battery on every APC / CyberPower / SNMP UPS. |
Matching is by attribute name across devices and zones, so any driver that names its attributes conventionally is picked up with no per-vendor work. Like the other metering skills it is site-wide rather than scoped to the caller's accessible zones — a utility feed, a generator and a UPS are infrastructure, not controllable zones.
Answer shapes: the default verdict sentence (plus how long an outage has been running), a one-word
verdict (utility / on_generator / on_battery / outage / unknown), utility only, generator
only, per-UPS detail, or worst-case remaining battery runtime. A named unit can be asked about
directly ("the rack UPS", "the garage generator").
Two things it deliberately will not do: it never invents a runtime the UPS did not report, and when no utility, generator or UPS signal is configured it says "I can't see the utility feed, a generator or a UPS on this system" rather than inferring that power is fine from the absence of monitoring. Note also that the network is not a power signal in either direction — a router on a UPS stays online right through a utility outage.
API — get_metric_water_flow
Socket event (and GemApp.getMetricWaterFlow(params) on the client) — the water counterpart to
get_metric_energy_flow:
params:{device_id}for a single controller, or omit for a site-wide aggregate of every water device.- Returns
{devices: [...], zones: [...], totals: {...}}. Each device is{device_id, device_name, driver, flowrate_lpm, zone_count, active_count, total_usage_l, zones: [...]}, and each zone is{zone_id, label, state, watering, usage_l, flowrate_lpm, time_remaining}. The top-levelzonesflattens every device's zones, sorted active-first then by usage, so a capped widget surfaces the zones that are actually watering.totalssumsflowrate_lpm,zone_count,active_countandtotal_usage_l. - Flow is canonical litres/min and volume canonical litres; the widget converts to gallons for imperial display. Reads live values straight from the in-memory device/zone objects.