power-compare/app/isolarcloud/shared_token_store.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

58 lines
1.7 KiB
Python

"""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())