Add battery telemetry for plants with a hybrid inverter/ESS
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s

Confirmed against Sungrow's own docs and live-tested against a real
battery plant: SOC and charge/discharge power live at the *device*
level (getDeviceRealTimeData against the hybrid inverter's ps_key,
device_type 14), not in the plant-aggregate points used elsewhere in
this client -- querying them via the plant-level endpoint just returns
null. Battery status is fetched once per live "today" poll (not
backfilled historically) and stored in three new nullable columns,
self-migrated on startup since there's no Alembic here.

Also fixes the dashboard's self-consumption line to derive from
load - import rather than generation - export, which overstated
"solar -> house" for battery systems (a chunk of that gap was going
into charging, not the house). The live flow diagram now shows a
Battery node (SOC + charge/discharge) that stays hidden entirely for
solar-only plants like Kallangur.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
dlawler489 2026-09-09 12:25:09 +10:00
parent 3d7c8d9ca0
commit 9e66a9a97d
7 changed files with 151 additions and 2 deletions

View file

@ -122,4 +122,7 @@ def live_status() -> dict:
"load_kw": _kw(row.load_kwh),
"import_kw": _kw(row.import_kwh),
"export_kw": _kw(row.export_kwh),
"battery_soc_percent": row.battery_soc_percent,
"battery_charge_w": row.battery_charge_w,
"battery_discharge_w": row.battery_discharge_w,
}

View file

@ -50,6 +50,13 @@ class IntervalReadingRow(Base):
import_kwh: Mapped[float] = mapped_column(Float)
export_kwh: Mapped[float] = mapped_column(Float)
resolution_minutes: Mapped[int] = mapped_column(Integer)
# Only ever set for plants with a battery, and only on the live "today"
# poll (not backfilled/historical rows) -- see oauth_client.py's
# get_battery_status. Nullable so solar-only plants (and every row
# written before this column existed) are unaffected.
battery_soc_percent: Mapped[float | None] = mapped_column(Float, nullable=True)
battery_charge_w: Mapped[float | None] = mapped_column(Float, nullable=True)
battery_discharge_w: Mapped[float | None] = mapped_column(Float, nullable=True)
class PlanCacheRow(Base):

View file

@ -1,7 +1,7 @@
from collections.abc import Iterator
from contextlib import contextmanager
from sqlalchemy import create_engine
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import Session, sessionmaker
from app.config import settings
@ -10,9 +10,32 @@ from app.db.models import Base
engine = create_engine(settings.db_url, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(bind=engine, expire_on_commit=False)
# Columns added after the table already existed in a deployed database.
# `create_all` only creates missing *tables*, never adds columns to an
# existing one -- there's no Alembic here, so new nullable columns are
# self-migrated by hand on startup instead. Only ever append to this list.
_ADDED_COLUMNS: list[tuple[str, str, str]] = [
# (table, column, DDL type)
("interval_reading", "battery_soc_percent", "FLOAT"),
("interval_reading", "battery_charge_w", "FLOAT"),
("interval_reading", "battery_discharge_w", "FLOAT"),
]
def _apply_column_migrations() -> None:
inspector = inspect(engine)
if "interval_reading" not in inspector.get_table_names():
return # fresh DB -- create_all already created the table with every current column
existing = {(table, col["name"]) for table in {t for t, _, _ in _ADDED_COLUMNS} for col in inspector.get_columns(table)}
with engine.begin() as conn:
for table, column, ddl_type in _ADDED_COLUMNS:
if (table, column) not in existing:
conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} {ddl_type}"))
def init_db() -> None:
Base.metadata.create_all(engine)
_apply_column_migrations()
@contextmanager

View file

@ -81,9 +81,22 @@ PLANT_LIST_PATH = "/openapi/platform/queryPowerStationList"
DEVICE_LIST_PATH = "/openapi/platform/getDeviceListByPsId"
REALTIME_DATA_PATH = "/openapi/platform/getPowerStationRealTimeData"
MINUTE_HISTORY_PATH = "/openapi/platform/getPowerStationPointMinuteDataList"
DEVICE_REALTIME_DATA_PATH = "/openapi/platform/getDeviceRealTimeData"
MAX_HISTORY_WINDOW = timedelta(hours=3) # the endpoint's own documented limit
# Battery/hybrid-inverter telemetry (SOC, charge/discharge power) lives at
# the *device* level, not in the plant-aggregate points used elsewhere in
# this client -- confirmed against Sungrow's own docs ("Common Energy
# Storage Inverter Measuring Points") and live-tested against a real
# battery plant (querying these via the plant-level realtime endpoint
# just returns null). Device type 14 covers both pure hybrid inverters and
# combined inverter+battery "Energy Storage System" units.
ESS_DEVICE_TYPE = "14"
POINT_BATTERY_SOC = "13141" # fraction 0-1, not a percentage
POINT_BATTERY_CHARGE_W = "13126"
POINT_BATTERY_DISCHARGE_W = "13150"
class ISolarCloudAuthError(RuntimeError):
pass
@ -179,6 +192,38 @@ class ISolarCloudOAuthClient:
result = self._authenticated_post(DEVICE_LIST_PATH, {"ps_id": ps_id, "page": 1, "size": 100})
return result.get("pageList") or []
def get_battery_status(self, ps_id: str) -> dict[str, float] | None:
"""Live battery state for plants with a hybrid inverter/ESS device,
or None for solar-only plants that have no such device (e.g. a
plain string-inverter system)."""
ess_device = next(
(d for d in self.get_device_list(ps_id) if str(d.get("device_type")) == ESS_DEVICE_TYPE), None
)
if ess_device is None or not ess_device.get("ps_key"):
return None
result = self._authenticated_post(
DEVICE_REALTIME_DATA_PATH,
{
"ps_key_list": [ess_device["ps_key"]],
"point_id_list": [POINT_BATTERY_SOC, POINT_BATTERY_CHARGE_W, POINT_BATTERY_DISCHARGE_W],
"device_type": ESS_DEVICE_TYPE,
},
)
points_list = result.get("device_point_list") or []
if not points_list:
return None
point = points_list[0].get("device_point") or {}
soc = point.get(f"p{POINT_BATTERY_SOC}")
if soc is None:
return None
charge_w = point.get(f"p{POINT_BATTERY_CHARGE_W}")
discharge_w = point.get(f"p{POINT_BATTERY_DISCHARGE_W}")
return {
"soc_percent": round(float(soc) * 100, 1),
"charge_w": float(charge_w) if charge_w is not None else 0.0,
"discharge_w": float(discharge_w) if discharge_w is not None else 0.0,
}
def get_realtime_points(self, ps_id: str, point_ids: list[str]) -> dict[str, float]:
result = self._authenticated_post(
REALTIME_DATA_PATH, {"ps_id_list": [ps_id], "point_id_list": point_ids, "is_get_point_dict": "0"}
@ -286,6 +331,14 @@ class ISolarCloudOAuthClient:
set_sync_state(session, "isolarcloud_oauth_last_export_wh", str(export_wh))
set_sync_state(session, "isolarcloud_oauth_last_poll_ts", now.isoformat())
battery = None
try:
battery = self.get_battery_status(ps_id)
except Exception:
# A battery-status hiccup shouldn't cost the core solar
# reading for this poll -- just leave it unset this round.
_LOGGER.exception("Battery status fetch failed for ps_id=%s", ps_id)
step_hours = elapsed_minutes / 60
return [
IntervalDataPoint(
@ -295,5 +348,8 @@ class ISolarCloudOAuthClient:
import_kwh=import_kwh,
export_kwh=export_kwh,
resolution_minutes=round(elapsed_minutes),
battery_soc_percent=battery["soc_percent"] if battery else None,
battery_charge_w=battery["charge_w"] if battery else None,
battery_discharge_w=battery["discharge_w"] if battery else None,
)
]

View file

@ -10,6 +10,12 @@ class IntervalDataPoint(BaseModel):
import_kwh: float
export_kwh: float
resolution_minutes: int
# Only populated for plants with a battery (from a live device-level
# poll, not backfillable historical data) -- None for solar-only plants
# and for every historical/backfilled point.
battery_soc_percent: float | None = None
battery_charge_w: float | None = None
battery_discharge_w: float | None = None
def as_row(self, source: str) -> dict:
return {
@ -20,4 +26,7 @@ class IntervalDataPoint(BaseModel):
"import_kwh": self.import_kwh,
"export_kwh": self.export_kwh,
"resolution_minutes": self.resolution_minutes,
"battery_soc_percent": self.battery_soc_percent,
"battery_charge_w": self.battery_charge_w,
"battery_discharge_w": self.battery_discharge_w,
}

View file

@ -7,6 +7,7 @@
--accent: #2563eb;
--good: #16a34a;
--bad: #dc2626;
--battery: #d97706;
--card-bg: #f9fafb;
}
@ -19,6 +20,7 @@
--accent: #6ea8fe;
--good: #4ade80;
--bad: #f87171;
--battery: #fbbf24;
--card-bg: #1a1d24;
}
}

View file

@ -21,6 +21,12 @@
<!-- Solar -> Grid (export) / Grid -> House (import), same path, direction/color/arrow flipped by JS -->
<path id="flow-solar-grid" d="M 340 70 C 400 130, 420 150, 450 175" fill="none" stroke="var(--good)" stroke-width="3"
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line"></path>
<!-- Solar -> Battery (charging) -- only shown for plants with a battery -->
<path id="flow-solar-battery" d="M 300 90 L 300 161" fill="none" stroke="var(--battery)" stroke-width="3"
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line" style="display:none;"></path>
<!-- Battery -> House (discharging), reverse of the above -->
<path id="flow-battery-house" d="M 266 195 L 155 195" fill="none" stroke="var(--battery)" stroke-width="3"
stroke-dasharray="6 6" marker-end="url(#arrow)" class="flow-line" style="display:none;"></path>
<!-- Solar node -->
<g transform="translate(300 55)">
@ -57,6 +63,18 @@
</g>
<text x="480" y="163" text-anchor="middle" class="live-label">Grid</text>
<text id="live-grid" x="480" y="230" text-anchor="middle" class="live-value">&mdash;</text>
<!-- Battery node -- hidden entirely for plants with no battery -->
<g id="live-battery-node" style="display:none;">
<g transform="translate(300 195)">
<circle r="34" fill="var(--card-bg)" stroke="var(--border)" stroke-width="1.5"></circle>
<path d="M -10,-14 L 10,-14 L 10,-9 L 13,-9 L 13,14 L -13,14 L -13,-9 L -10,-9 Z"
fill="none" stroke="var(--battery)" stroke-width="2" stroke-linejoin="round"></path>
<rect id="live-battery-fill" x="-10" y="8" width="20" height="0" fill="var(--battery)"></rect>
</g>
<text x="300" y="163" text-anchor="middle" class="live-label">Battery</text>
<text id="live-battery" x="300" y="230" text-anchor="middle" class="live-value">&mdash;</text>
</g>
</svg>
</div>
</div>
@ -110,7 +128,10 @@ async function loadLiveFlow() {
const load = data.load_kw ?? 0;
const importKw = data.import_kw ?? 0;
const exportKw = data.export_kw ?? 0;
const selfConsumed = Math.max(0, generation - exportKw);
// From the house's own side of the meter, not generation's -- "generation
// minus export" overstates this for a battery system, since a chunk of
// that gap can be going into charging rather than the house.
const selfConsumed = Math.max(0, load - importKw);
document.getElementById('live-generation').textContent = fmtKw(generation).replace('&mdash;', '—');
document.getElementById('live-load').textContent = fmtKw(load).replace('&mdash;', '—');
@ -135,6 +156,34 @@ async function loadLiveFlow() {
gridLine.style.display = 'none';
gridLabel.textContent = 'no grid flow';
}
// Battery -- only plants with one report a SOC at all; the node stays
// hidden entirely (e.g. a solar-only plant) when it's null.
const batteryNode = document.getElementById('live-battery-node');
const soc = data.battery_soc_percent;
if (soc === null || soc === undefined) {
batteryNode.style.display = 'none';
document.getElementById('flow-solar-battery').style.display = 'none';
document.getElementById('flow-battery-house').style.display = 'none';
} else {
batteryNode.style.display = '';
const chargeKw = (data.battery_charge_w ?? 0) / 1000;
const dischargeKw = (data.battery_discharge_w ?? 0) / 1000;
document.getElementById('flow-solar-battery').style.display = chargeKw > 0.05 ? '' : 'none';
document.getElementById('flow-battery-house').style.display = dischargeKw > 0.05 ? '' : 'none';
const status = chargeKw > 0.05 ? `${fmtKw(chargeKw).replace('&mdash;', '—')} charging`
: dischargeKw > 0.05 ? `${fmtKw(dischargeKw).replace('&mdash;', '—')} discharging`
: 'idle';
document.getElementById('live-battery').textContent = `${soc.toFixed(0)}% · ${status}`;
// Fill rises from the bottom of the battery icon's body (y=14) as SOC
// increases, capped at a max height of 20 (near the icon's top).
const fillHeight = Math.max(0, Math.min(20, (soc / 100) * 20));
const fill = document.getElementById('live-battery-fill');
fill.setAttribute('y', 14 - fillHeight);
fill.setAttribute('height', fillHeight);
}
}
async function loadDashboard(days) {