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>
97 lines
3.8 KiB
Python
97 lines
3.8 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import DateTime, Float, Integer, String, UniqueConstraint
|
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
from sqlalchemy.types import TypeDecorator
|
|
|
|
|
|
class UTCDateTime(TypeDecorator):
|
|
"""SQLite has no real timezone-aware datetime type: SQLAlchemy's
|
|
`DateTime(timezone=True)` silently stores and returns *naive* datetimes
|
|
on this backend, which then get misinterpreted as system-local time by
|
|
`.astimezone()` anywhere they're read back (e.g. every time-of-use
|
|
match in the costing engine) -- a correctness bug that only shows up
|
|
when the host's local timezone isn't UTC. This type makes the
|
|
round-trip explicit: always stores naive-UTC, always returns
|
|
timezone-aware UTC.
|
|
"""
|
|
|
|
impl = DateTime
|
|
cache_ok = True
|
|
|
|
def process_bind_param(self, value: datetime | None, dialect):
|
|
if value is None:
|
|
return None
|
|
if value.tzinfo is None:
|
|
raise ValueError("UTCDateTime requires a timezone-aware datetime")
|
|
return value.astimezone(timezone.utc).replace(tzinfo=None)
|
|
|
|
def process_result_value(self, value: datetime | None, dialect):
|
|
if value is None:
|
|
return None
|
|
return value.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
class Base(DeclarativeBase):
|
|
pass
|
|
|
|
|
|
class IntervalReadingRow(Base):
|
|
__tablename__ = "interval_reading"
|
|
__table_args__ = (UniqueConstraint("ts", "source", name="uq_interval_reading_ts_source"),)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
ts: Mapped[datetime] = mapped_column(UTCDateTime, index=True)
|
|
source: Mapped[str] = mapped_column(String) # 'isolarcloud' | 'mock'
|
|
generation_kwh: Mapped[float] = mapped_column(Float)
|
|
load_kwh: Mapped[float] = mapped_column(Float)
|
|
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):
|
|
__tablename__ = "plan_cache"
|
|
|
|
plan_id: Mapped[str] = mapped_column(String, primary_key=True)
|
|
retailer: Mapped[str] = mapped_column(String)
|
|
plan_name: Mapped[str] = mapped_column(String)
|
|
fuel_type: Mapped[str] = mapped_column(String)
|
|
distributors_json: Mapped[str] = mapped_column(String) # JSON list
|
|
last_refreshed: Mapped[datetime] = mapped_column(UTCDateTime)
|
|
raw_detail_json: Mapped[str] = mapped_column(String)
|
|
parsed_tariff_json: Mapped[str] = mapped_column(String)
|
|
|
|
|
|
class CurrentPlanConfigRow(Base):
|
|
__tablename__ = "current_plan_config"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
|
retailer_name: Mapped[str] = mapped_column(String)
|
|
plan_name: Mapped[str] = mapped_column(String)
|
|
tariff_json: Mapped[str] = mapped_column(String)
|
|
|
|
|
|
class HouseholdConfigRow(Base):
|
|
__tablename__ = "household_config"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, default=1)
|
|
distributor: Mapped[str] = mapped_column(String, default="Energex")
|
|
postcode: Mapped[str] = mapped_column(String, default="")
|
|
isolarcloud_ps_id: Mapped[str] = mapped_column(String, default="")
|
|
|
|
|
|
class SyncStateRow(Base):
|
|
__tablename__ = "sync_state"
|
|
|
|
key: Mapped[str] = mapped_column(String, primary_key=True)
|
|
value: Mapped[str] = mapped_column(String)
|