power-compare/app/api/isolarcloud.py
dlawler489 1dbfe3007b
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s
Share one iSolarCloud OAuth token across both household instances
Sungrow's OAuth server keeps only one active token session per (app,
account), not per plant -- with Kallangur and McDowall each managing their
own independent token against the same account, whichever one refreshed
last was silently invalidating the other's refresh token. Both instances
now share one token file (a common bind-mounted volume) instead of each
holding their own.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 08:04:30 +10:00

87 lines
3.3 KiB
Python

from __future__ import annotations
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from app.config import settings
from app.db.repositories import get_sync_state
from app.db.session import get_session
from app.isolarcloud import shared_token_store
router = APIRouter(prefix="/api/isolarcloud", tags=["isolarcloud"])
@router.get("/status")
def status() -> dict:
if settings.isolarcloud_auth_mode == "oauth2" and shared_token_store.enabled():
with shared_token_store.locked() as fd:
state = shared_token_store.read(fd)
has_token = bool(state and state.get("access_token"))
else:
token_key = "isolarcloud_oauth_access_token" if settings.isolarcloud_auth_mode == "oauth2" else "isolarcloud_token"
with get_session() as session:
has_token = bool(get_sync_state(session, token_key))
return {"use_mock": settings.use_mock_isolarcloud, "auth_mode": settings.isolarcloud_auth_mode, "connected": has_token}
@router.get("/oauth/authorize-url")
def oauth_authorize_url() -> dict:
from app.isolarcloud.oauth_client import ISolarCloudOAuthClient
return {"url": ISolarCloudOAuthClient().authorize_url()}
class OAuthCodeIn(BaseModel):
code: str
@router.post("/oauth/exchange")
def oauth_exchange(body: OAuthCodeIn) -> JSONResponse:
"""Completes the OAuth2 consent flow: trades the `code` param (copied
from the address bar after the user approved access) for a token pair,
and returns the plants the user actually authorized."""
from app.isolarcloud.oauth_client import ISolarCloudAuthError, ISolarCloudOAuthClient
client = ISolarCloudOAuthClient()
try:
auth_ps_list = client.exchange_code(body.code)
except ISolarCloudAuthError as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=400)
except Exception as e:
return JSONResponse({"ok": False, "error": f"Unexpected error: {e}"}, status_code=502)
plants = []
try:
plants = client.get_plant_list()
except Exception:
pass
plants_by_id = {str(p.get("ps_id")): p.get("ps_name") for p in plants}
return JSONResponse(
{
"ok": True,
"authorized_plants": [{"ps_id": ps_id, "ps_name": plants_by_id.get(ps_id)} for ps_id in auth_ps_list],
}
)
@router.post("/connect")
def connect() -> JSONResponse:
"""Logs in with the credentials configured in .env and, on success,
returns the account's visible plants so the user can pick a
`isolarcloud_plant_id` in Settings if they have more than one."""
from app.isolarcloud.client import ISolarCloudAuthError, ISolarCloudClient
if settings.use_mock_isolarcloud:
return JSONResponse({"ok": False, "error": "USE_MOCK_ISOLARCLOUD is still true -- set it to false in .env first."}, status_code=400)
client = ISolarCloudClient()
try:
client.login()
plants = client.get_plant_list()
except ISolarCloudAuthError as e:
return JSONResponse({"ok": False, "error": str(e)}, status_code=400)
except Exception as e: # network/HTTP errors etc.
return JSONResponse({"ok": False, "error": f"Unexpected error: {e}"}, status_code=502)
return JSONResponse({"ok": True, "plants": [{"ps_id": p.get("ps_id"), "ps_name": p.get("ps_name")} for p in plants]})