Share one iSolarCloud OAuth token across both household instances
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 24s

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>
This commit is contained in:
dlawler489 2026-09-13 08:04:30 +10:00
parent ad232447b8
commit 1dbfe3007b
7 changed files with 200 additions and 8 deletions

View file

@ -7,12 +7,18 @@ from pydantic import BaseModel
from app.config import settings from app.config import settings
from app.db.repositories import get_sync_state from app.db.repositories import get_sync_state
from app.db.session import get_session from app.db.session import get_session
from app.isolarcloud import shared_token_store
router = APIRouter(prefix="/api/isolarcloud", tags=["isolarcloud"]) router = APIRouter(prefix="/api/isolarcloud", tags=["isolarcloud"])
@router.get("/status") @router.get("/status")
def status() -> dict: 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" token_key = "isolarcloud_oauth_access_token" if settings.isolarcloud_auth_mode == "oauth2" else "isolarcloud_token"
with get_session() as session: with get_session() as session:
has_token = bool(get_sync_state(session, token_key)) has_token = bool(get_sync_state(session, token_key))

View file

@ -32,6 +32,18 @@ class Settings(BaseSettings):
isolarcloud_oauth_app_id: str = "" # "applicationId" from the developer portal isolarcloud_oauth_app_id: str = "" # "applicationId" from the developer portal
isolarcloud_oauth_redirect_uri: str = "https://localhost:8000/isolarcloud/callback" isolarcloud_oauth_redirect_uri: str = "https://localhost:8000/isolarcloud/callback"
# If this app is deployed as more than one independent instance against
# the SAME iSolarCloud account (e.g. two households' plants under one
# family member's login), Sungrow's OAuth server only keeps one active
# token session per (app, account) alive -- each instance refreshing its
# own token independently silently invalidates every other instance's
# refresh token. Pointing every instance at the same file here (a path
# under a volume shared/bind-mounted across all of them) makes them
# share one token session instead of fighting over it. Leave blank for a
# single-instance deployment; tokens then stay in this instance's own
# DB as before.
isolarcloud_shared_token_path: str = ""
# Set to true until real credentials are configured; the scheduler then # Set to true until real credentials are configured; the scheduler then
# polls app.isolarcloud.mock_client instead of the real API. # polls app.isolarcloud.mock_client instead of the real API.
use_mock_isolarcloud: bool = True use_mock_isolarcloud: bool = True

View file

@ -54,6 +54,7 @@ import httpx
from app.config import settings from app.config import settings
from app.db.repositories import get_sync_state, set_sync_state from app.db.repositories import get_sync_state, set_sync_state
from app.db.session import get_session from app.db.session import get_session
from app.isolarcloud import shared_token_store
from app.isolarcloud.parsing import POINT_EXPORT, POINT_GENERATION, POINT_IMPORT, POINT_LOAD, TS_FORMAT, merge_frames from app.isolarcloud.parsing import POINT_EXPORT, POINT_GENERATION, POINT_IMPORT, POINT_LOAD, TS_FORMAT, merge_frames
from app.isolarcloud.schemas import IntervalDataPoint from app.isolarcloud.schemas import IntervalDataPoint
@ -147,13 +148,50 @@ class ISolarCloudOAuthClient:
def _store_tokens(self, result_data: dict) -> None: def _store_tokens(self, result_data: dict) -> None:
expires_at = int(time.time()) + int(result_data["expires_in"]) - 60 expires_at = int(time.time()) + int(result_data["expires_in"]) - 60
token_state = {
"access_token": result_data["access_token"],
"refresh_token": result_data["refresh_token"],
"expires_at": expires_at,
}
if shared_token_store.enabled():
with shared_token_store.locked() as fd:
shared_token_store.write(fd, token_state)
else:
with get_session() as session: with get_session() as session:
set_sync_state(session, "isolarcloud_oauth_access_token", result_data["access_token"]) set_sync_state(session, "isolarcloud_oauth_access_token", token_state["access_token"])
set_sync_state(session, "isolarcloud_oauth_refresh_token", result_data["refresh_token"]) set_sync_state(session, "isolarcloud_oauth_refresh_token", token_state["refresh_token"])
set_sync_state(session, "isolarcloud_oauth_expires_at", str(expires_at)) set_sync_state(session, "isolarcloud_oauth_expires_at", str(expires_at))
self._access_token = result_data["access_token"] self._access_token = token_state["access_token"]
def _get_access_token(self) -> str: def _get_access_token(self) -> str:
if shared_token_store.enabled():
# Holds the lock across the whole check-and-maybe-refresh
# sequence (including the network round-trip), not just the
# file I/O either side of it -- otherwise two instances waking
# up around the same moment could both read the same
# about-to-expire refresh_token and race to redeem it, and
# only one of those calls will succeed.
with shared_token_store.locked() as fd:
state = shared_token_store.read(fd)
if not state or not state.get("access_token") or not state.get("refresh_token"):
raise ISolarCloudAuthError(
"No iSolarCloud OAuth tokens stored yet -- connect your account from the Settings page first."
)
if int(state.get("expires_at") or 0) < int(time.time()):
data = self._post(REFRESH_PATH, {"refresh_token": state["refresh_token"]})
result_data = data.get("result_data") or {}
if not isinstance(result_data, dict) or not result_data.get("access_token"):
raise ISolarCloudAuthError(f"Token refresh failed: {data} -- reconnect from Settings.")
expires_at = int(time.time()) + int(result_data["expires_in"]) - 60
state = {
"access_token": result_data["access_token"],
"refresh_token": result_data["refresh_token"],
"expires_at": expires_at,
}
shared_token_store.write(fd, state)
self._access_token = state["access_token"]
return state["access_token"]
with get_session() as session: with get_session() as session:
access_token = get_sync_state(session, "isolarcloud_oauth_access_token") access_token = get_sync_state(session, "isolarcloud_oauth_access_token")
refresh_token = get_sync_state(session, "isolarcloud_oauth_refresh_token") refresh_token = get_sync_state(session, "isolarcloud_oauth_refresh_token")

View file

@ -0,0 +1,58 @@
"""File-based OAuth token store shared across multiple instances of this
app that authenticate against the same iSolarCloud account (see the
comment on `Settings.isolarcloud_shared_token_path`).
Uses `flock` on the shared file itself as the lock, not a separate lock
file -- `flock` is a host-kernel-level advisory lock keyed on the
underlying inode, so it works across container boundaries as long as
every instance bind-mounts the *same* host file (not independent copies).
The lock is held for the whole read-check-refresh-write sequence in
`oauth_client._get_access_token`, not just the file I/O, so two instances
waking up at the same moment can't both refresh against the
soon-to-be-superseded refresh token.
"""
from __future__ import annotations
import contextlib
import fcntl
import json
import os
from collections.abc import Iterator
from pathlib import Path
from app.config import settings
def enabled() -> bool:
return bool(settings.isolarcloud_shared_token_path)
@contextlib.contextmanager
def locked() -> Iterator[int]:
path = Path(settings.isolarcloud_shared_token_path)
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_RDWR | os.O_CREAT, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX)
yield fd
finally:
fcntl.flock(fd, fcntl.LOCK_UN)
os.close(fd)
def read(fd: int) -> dict | None:
os.lseek(fd, 0, os.SEEK_SET)
raw = os.read(fd, 1_000_000)
if not raw:
return None
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
def write(fd: int, data: dict) -> None:
os.lseek(fd, 0, os.SEEK_SET)
os.ftruncate(fd, 0)
os.write(fd, json.dumps(data).encode())

View file

View file

@ -0,0 +1,67 @@
import time
import pytest
from app.config import settings
from app.isolarcloud import shared_token_store
from app.isolarcloud.oauth_client import ISolarCloudOAuthClient
@pytest.fixture
def shared_path(tmp_path, monkeypatch):
path = tmp_path / "isolarcloud_oauth.json"
monkeypatch.setattr(settings, "isolarcloud_shared_token_path", str(path))
return path
def test_disabled_by_default(monkeypatch):
monkeypatch.setattr(settings, "isolarcloud_shared_token_path", "")
assert shared_token_store.enabled() is False
def test_read_write_roundtrip(shared_path):
assert shared_token_store.enabled() is True
with shared_token_store.locked() as fd:
assert shared_token_store.read(fd) is None # file just created, empty
shared_token_store.write(fd, {"access_token": "a", "refresh_token": "r", "expires_at": 123})
with shared_token_store.locked() as fd:
assert shared_token_store.read(fd) == {"access_token": "a", "refresh_token": "r", "expires_at": 123}
def test_second_instance_reuses_token_written_by_first(shared_path):
"""The whole point: two ISolarCloudOAuthClient instances (standing in
for two separate Docker containers) pointed at the same shared path
see each other's tokens rather than needing their own independent
authorization."""
client_a = ISolarCloudOAuthClient()
client_a._store_tokens({"access_token": "tok-a", "refresh_token": "ref-a", "expires_in": 3600})
client_b = ISolarCloudOAuthClient()
# Not expired yet, so this must return client_a's token without
# attempting any HTTP refresh call.
client_b._post = lambda *a, **k: pytest.fail("should not need to refresh a still-valid token")
assert client_b._get_access_token() == "tok-a"
def test_expired_token_refreshes_once_and_shares_result(shared_path):
client_a = ISolarCloudOAuthClient()
with shared_token_store.locked() as fd:
shared_token_store.write(fd, {"access_token": "stale", "refresh_token": "ref-old", "expires_at": int(time.time()) - 10})
refresh_calls = []
def fake_post(path, payload, access_token=None):
refresh_calls.append(payload)
return {"result_data": {"access_token": "fresh", "refresh_token": "ref-new", "expires_in": 3600}}
client_a._post = fake_post
assert client_a._get_access_token() == "fresh"
assert len(refresh_calls) == 1
assert refresh_calls[0] == {"refresh_token": "ref-old"}
# A second instance reads the already-refreshed token and doesn't
# trigger a second refresh call of its own.
client_b = ISolarCloudOAuthClient()
client_b._post = lambda *a, **k: pytest.fail("token was already refreshed by client_a")
assert client_b._get_access_token() == "fresh"

View file

@ -14,10 +14,21 @@ services:
- /app/data/power-compare-persistent/.env - /app/data/power-compare-persistent/.env
environment: environment:
- DB_PATH=/data/power_compare.db - DB_PATH=/data/power_compare.db
# Shared with the McDowall stack's identical setting via the volume
# below -- both instances authenticate against the same iSolarCloud
# account, and Sungrow's OAuth server only keeps one active token
# session per (app, account), so each instance refreshing its own
# copy independently was silently invalidating the other's. Pointing
# both at the same bind-mounted file makes them share one session.
- ISOLARCLOUD_SHARED_TOKEN_PATH=/shared-oauth/isolarcloud_oauth.json
volumes: volumes:
# Bind-mount SOURCE paths, by contrast, are resolved by the real # Bind-mount SOURCE paths, by contrast, are resolved by the real
# Docker daemon against the actual host filesystem (the daemon isn't # Docker daemon against the actual host filesystem (the daemon isn't
# inside the stack manager's container) -- so this one stays a real # inside the stack manager's container) -- so this one stays a real
# host path, not an /app/data/... one. # host path, not an /app/data/... one.
- /Users/davidlawler/docker/power-compare-data/data:/data - /Users/davidlawler/docker/power-compare-data/data:/data
# Same host path as the McDowall stack -- this MUST stay identical
# across both compose files, since the point is for both containers
# to bind-mount the literal same file.
- /Users/davidlawler/docker/power-compare-shared-oauth:/shared-oauth
restart: unless-stopped restart: unless-stopped