diff --git a/app/api/usage.py b/app/api/usage.py
index 283eb25..5d3396a 100644
--- a/app/api/usage.py
+++ b/app/api/usage.py
@@ -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,
}
diff --git a/app/db/models.py b/app/db/models.py
index 953bb68..b1f1c60 100644
--- a/app/db/models.py
+++ b/app/db/models.py
@@ -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):
diff --git a/app/db/session.py b/app/db/session.py
index dc7d984..170f81c 100644
--- a/app/db/session.py
+++ b/app/db/session.py
@@ -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
diff --git a/app/isolarcloud/oauth_client.py b/app/isolarcloud/oauth_client.py
index b113a83..52d57b0 100644
--- a/app/isolarcloud/oauth_client.py
+++ b/app/isolarcloud/oauth_client.py
@@ -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,
)
]
diff --git a/app/isolarcloud/schemas.py b/app/isolarcloud/schemas.py
index 8f8156a..d19aa61 100644
--- a/app/isolarcloud/schemas.py
+++ b/app/isolarcloud/schemas.py
@@ -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,
}
diff --git a/app/static/css/style.css b/app/static/css/style.css
index 109f713..71c0efc 100644
--- a/app/static/css/style.css
+++ b/app/static/css/style.css
@@ -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;
}
}
diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html
index 39a0611..c667e39 100644
--- a/app/web/templates/dashboard.html
+++ b/app/web/templates/dashboard.html
@@ -21,6 +21,12 @@
+
+
+
+
@@ -57,6 +63,18 @@
Grid
—
+
+
+
+
+
+
+
+
+ Battery
+ —
+
@@ -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('—', '—');
document.getElementById('live-load').textContent = fmtKw(load).replace('—', '—');
@@ -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('—', '—')} charging`
+ : dischargeKw > 0.05 ? `${fmtKw(dischargeKw).replace('—', '—')} 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) {