Enhance Cync authentication flow with improved token management and 2FA handling. Add periodic token validation and logging for better debugging. Introduce FLAC stream check in bulk download process.
This commit is contained in:
@@ -3,6 +3,7 @@ import json
|
||||
import os
|
||||
import time
|
||||
import aiohttp
|
||||
import asyncio
|
||||
from fastapi import FastAPI, Depends, HTTPException, Request
|
||||
from fastapi_throttle import RateLimiter
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -14,6 +15,8 @@ from pycync.user import User # type: ignore
|
||||
from pycync.cync import Cync as Cync # type: ignore
|
||||
from pycync import Auth # type: ignore
|
||||
from pycync.exceptions import TwoFactorRequiredError, AuthFailedError # type: ignore
|
||||
import inspect
|
||||
import getpass
|
||||
|
||||
|
||||
class Lighting(FastAPI):
|
||||
@@ -38,41 +41,59 @@ class Lighting(FastAPI):
|
||||
# Check if session is closed or missing
|
||||
if not self.session or getattr(self.session, "closed", False):
|
||||
self.session = aiohttp.ClientSession()
|
||||
# Load cached token and check validity
|
||||
self.cync_user = None
|
||||
cached_user = self._load_cached_user()
|
||||
token_status = None
|
||||
if cached_user:
|
||||
if hasattr(cached_user, "expires_at"):
|
||||
if cached_user.expires_at > time.time():
|
||||
token_status = "valid"
|
||||
else:
|
||||
token_status = "expired"
|
||||
else:
|
||||
token_status = "missing expires_at"
|
||||
else:
|
||||
token_status = "no cached user"
|
||||
logging.info(f"Cync token status: {token_status}")
|
||||
|
||||
if token_status == "valid" and cached_user is not None:
|
||||
# Use cached token
|
||||
self.auth = Auth(
|
||||
session=self.session,
|
||||
user=cached_user,
|
||||
username=cync_email,
|
||||
password=cync_password,
|
||||
)
|
||||
self.cync_user = cached_user
|
||||
logging.info("Reusing valid cached token, no 2FA required.")
|
||||
else:
|
||||
# Need fresh login
|
||||
self.auth = Auth(
|
||||
session=self.session, username=cync_email, password=cync_password
|
||||
session=self.session,
|
||||
username=cync_email,
|
||||
password=cync_password,
|
||||
)
|
||||
# Try to refresh token
|
||||
self.cync_user = None
|
||||
if (
|
||||
self.auth.user
|
||||
and hasattr(self.auth.user, "expires_at")
|
||||
and self.auth.user.expires_at > time.time()
|
||||
):
|
||||
try:
|
||||
await self.auth.async_refresh_user_token()
|
||||
self.cync_user = self.auth.user
|
||||
self._save_cached_user(self.cync_user)
|
||||
except AuthFailedError:
|
||||
pass
|
||||
# If no valid token, login
|
||||
if not self.cync_user:
|
||||
try:
|
||||
self.cync_user = await self.auth.login()
|
||||
self._save_cached_user(self.cync_user)
|
||||
except TwoFactorRequiredError:
|
||||
logging.error(
|
||||
"Cync 2FA required. Set CYNC_2FA_CODE in env if needed."
|
||||
)
|
||||
raise Exception("Cync 2FA required.")
|
||||
twofa_code = os.getenv("CYNC_2FA_CODE")
|
||||
if not twofa_code:
|
||||
print("Cync 2FA required. Please enter your code:")
|
||||
twofa_code = getpass.getpass("2FA Code: ")
|
||||
if twofa_code:
|
||||
logging.info("Retrying Cync login with 2FA code.")
|
||||
try:
|
||||
self.cync_user = await self.auth.login(two_factor_code=twofa_code)
|
||||
self._save_cached_user(self.cync_user)
|
||||
logging.info("Logged in with 2FA successfully.")
|
||||
except Exception as e:
|
||||
logging.error("Cync 2FA login failed: %s", e)
|
||||
raise Exception("Cync 2FA code invalid or not accepted.")
|
||||
else:
|
||||
logging.error("Cync 2FA required but no code provided.")
|
||||
raise Exception("Cync 2FA required.")
|
||||
except AuthFailedError as e:
|
||||
logging.error("Failed to authenticate with Cync API: %s", e)
|
||||
raise Exception("Cync authentication failed.")
|
||||
@@ -125,55 +146,23 @@ class Lighting(FastAPI):
|
||||
f"Missing required environment variables: {', '.join(missing_vars)}"
|
||||
)
|
||||
|
||||
self.session = aiohttp.ClientSession()
|
||||
cached_user = self._load_cached_user()
|
||||
if cached_user:
|
||||
self.auth = Auth(
|
||||
session=self.session,
|
||||
user=cached_user,
|
||||
username=self.cync_email or "",
|
||||
password=self.cync_password or "",
|
||||
)
|
||||
else:
|
||||
self.auth = Auth(
|
||||
session=self.session,
|
||||
username=self.cync_email or "",
|
||||
password=self.cync_password or "",
|
||||
)
|
||||
# Try to refresh token
|
||||
if (
|
||||
self.auth.user
|
||||
and hasattr(self.auth.user, "expires_at")
|
||||
and self.auth.user.expires_at > time.time()
|
||||
):
|
||||
try:
|
||||
await self.auth.async_refresh_user_token()
|
||||
self.cync_user = self.auth.user
|
||||
self._save_cached_user(self.cync_user)
|
||||
except AuthFailedError:
|
||||
pass
|
||||
# If no valid token, login
|
||||
if not self.cync_user:
|
||||
try:
|
||||
self.cync_user = await self.auth.login()
|
||||
self._save_cached_user(self.cync_user)
|
||||
except TwoFactorRequiredError:
|
||||
logging.error(
|
||||
"Cync 2FA required. Set CYNC_2FA_CODE in env if needed."
|
||||
)
|
||||
raise Exception("Cync 2FA required.")
|
||||
except AuthFailedError as e:
|
||||
logging.error("Failed to authenticate with Cync API: %s", e)
|
||||
raise Exception("Cync authentication failed.")
|
||||
# Create persistent Cync API object
|
||||
self.cync_api = await Cync.create(self.auth)
|
||||
# Use ensure_cync_connection which has proper token caching
|
||||
await self.ensure_cync_connection()
|
||||
|
||||
# Create persistent Cync API object
|
||||
if self.auth:
|
||||
self.cync_api = await Cync.create(self.auth)
|
||||
|
||||
# Schedule periodic token validation
|
||||
asyncio.create_task(self._schedule_token_validation())
|
||||
|
||||
# Register endpoints
|
||||
self.endpoints: dict = {
|
||||
"lighting/state": self.get_lighting_state,
|
||||
}
|
||||
|
||||
for endpoint, handler in self.endpoints.items():
|
||||
app.add_api_route(
|
||||
self.app.add_api_route(
|
||||
f"/{endpoint}",
|
||||
handler,
|
||||
methods=["GET"],
|
||||
@@ -184,7 +173,7 @@ class Lighting(FastAPI):
|
||||
],
|
||||
)
|
||||
|
||||
app.add_api_route(
|
||||
self.app.add_api_route(
|
||||
"/lighting/state",
|
||||
self.set_lighting_state,
|
||||
methods=["POST"],
|
||||
@@ -195,6 +184,75 @@ class Lighting(FastAPI):
|
||||
],
|
||||
)
|
||||
|
||||
async def _refresh_or_login(self):
|
||||
if not self.auth:
|
||||
logging.error("Auth object is not initialized.")
|
||||
raise Exception("Cync authentication not initialized.")
|
||||
try:
|
||||
user = getattr(self.auth, 'user', None)
|
||||
if user and hasattr(user, "expires_at") and user.expires_at > time.time():
|
||||
refresh = getattr(self.auth, 'async_refresh_user_token', None)
|
||||
if callable(refresh):
|
||||
try:
|
||||
result = refresh()
|
||||
if inspect.isawaitable(result):
|
||||
await result
|
||||
else:
|
||||
pass # do nothing if not awaitable
|
||||
except AuthFailedError as e:
|
||||
logging.warning("Token refresh failed: %s", e)
|
||||
login = getattr(self.auth, 'login', None)
|
||||
if callable(login):
|
||||
try:
|
||||
result = login()
|
||||
if inspect.isawaitable(result):
|
||||
self.cync_user = await result
|
||||
else:
|
||||
self.cync_user = result
|
||||
self._save_cached_user(self.cync_user)
|
||||
logging.info("Logged in successfully.")
|
||||
except TwoFactorRequiredError:
|
||||
twofa_code = os.getenv("CYNC_2FA_CODE")
|
||||
if not twofa_code:
|
||||
# Prompt interactively if not set
|
||||
print("Cync 2FA required. Please enter your code:")
|
||||
twofa_code = getpass.getpass("2FA Code: ")
|
||||
if twofa_code:
|
||||
logging.info("Retrying Cync login with 2FA code.")
|
||||
try:
|
||||
result = login(two_factor_code=twofa_code)
|
||||
if inspect.isawaitable(result):
|
||||
self.cync_user = await result
|
||||
else:
|
||||
self.cync_user = result
|
||||
self._save_cached_user(self.cync_user)
|
||||
logging.info("Logged in with 2FA successfully.")
|
||||
except Exception as e:
|
||||
logging.error("Cync 2FA login failed: %s", e)
|
||||
raise Exception("Cync 2FA code invalid or not accepted.")
|
||||
else:
|
||||
logging.error("Cync 2FA required but no code provided.")
|
||||
raise Exception("Cync 2FA required.")
|
||||
else:
|
||||
raise Exception("Auth object missing login method.")
|
||||
except AuthFailedError as e:
|
||||
logging.error("Failed to authenticate with Cync API: %s", e)
|
||||
raise Exception("Cync authentication failed.")
|
||||
except Exception as e:
|
||||
logging.error("Unexpected error during authentication: %s", e)
|
||||
raise
|
||||
|
||||
async def _schedule_token_validation(self):
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(300)
|
||||
user = getattr(self.auth, 'user', None)
|
||||
if user and hasattr(user, "expires_at") and user.expires_at - time.time() < 600:
|
||||
logging.info("Token is about to expire. Refreshing...")
|
||||
await self._refresh_or_login()
|
||||
except Exception as e:
|
||||
logging.error("Error during periodic token validation: %s", e)
|
||||
|
||||
def _load_cached_user(self):
|
||||
try:
|
||||
if os.path.exists(self.token_cache_path):
|
||||
@@ -253,8 +311,10 @@ class Lighting(FastAPI):
|
||||
"""
|
||||
Set the lighting state and apply it to the Cync device.
|
||||
"""
|
||||
logging.info("=== LIGHTING STATE REQUEST RECEIVED ===")
|
||||
try:
|
||||
state = await request.json()
|
||||
logging.info(f"Requested state: {state}")
|
||||
# Validate state (basic validation)
|
||||
if not isinstance(state, dict):
|
||||
raise HTTPException(
|
||||
@@ -302,10 +362,14 @@ class Lighting(FastAPI):
|
||||
try:
|
||||
# Use persistent Cync API object
|
||||
if not self.cync_api:
|
||||
raise Exception("Cync API not initialized.")
|
||||
logging.warning("Cync API not initialized, attempting to reconnect...")
|
||||
await self.ensure_cync_connection()
|
||||
if not self.cync_api:
|
||||
raise Exception("Cync API still not initialized after reconnection.")
|
||||
|
||||
logging.info("Getting devices from Cync API...")
|
||||
devices = self.cync_api.get_devices()
|
||||
if not devices or not isinstance(devices, (list, tuple)):
|
||||
raise Exception("No devices returned from Cync API.")
|
||||
logging.info(f"Devices returned from Cync API: {[getattr(d, 'name', None) for d in devices]}")
|
||||
light = next(
|
||||
(
|
||||
d
|
||||
@@ -315,27 +379,30 @@ class Lighting(FastAPI):
|
||||
None,
|
||||
)
|
||||
if not light:
|
||||
logging.error(f"Device '{self.cync_device_name}' not found in {[getattr(d, 'name', None) for d in devices]}")
|
||||
raise Exception(f"Device '{self.cync_device_name}' not found")
|
||||
|
||||
logging.info(f"Selected device: {light}")
|
||||
# Set power
|
||||
if power == "on":
|
||||
await light.turn_on()
|
||||
result = await light.turn_on()
|
||||
logging.info(f"turn_on result: {result}")
|
||||
else:
|
||||
await light.turn_off()
|
||||
|
||||
result = await light.turn_off()
|
||||
logging.info(f"turn_off result: {result}")
|
||||
# Set brightness
|
||||
if "brightness" in state:
|
||||
await light.set_brightness(brightness)
|
||||
|
||||
result = await light.set_brightness(brightness)
|
||||
logging.info(f"set_brightness result: {result}")
|
||||
# Set color
|
||||
if rgb:
|
||||
await light.set_rgb(rgb)
|
||||
result = await light.set_rgb(rgb)
|
||||
logging.info(f"set_rgb result: {result}")
|
||||
|
||||
break # Success, exit retry loop
|
||||
except Exception as e:
|
||||
except (aiohttp.ClientConnectionError, aiohttp.ClientOSError) as e:
|
||||
if attempt < max_retries - 1:
|
||||
logging.warning(
|
||||
"Device operation failed (attempt %d/%d): %s. Retrying with reconnection.",
|
||||
"Connection closed (attempt %d/%d): %s. Retrying with reconnection.",
|
||||
attempt + 1,
|
||||
max_retries,
|
||||
e,
|
||||
@@ -343,11 +410,20 @@ class Lighting(FastAPI):
|
||||
await self.ensure_cync_connection()
|
||||
else:
|
||||
logging.error(
|
||||
"Device operation failed after %d attempts: %s",
|
||||
"Connection failed after %d attempts: %s",
|
||||
max_retries,
|
||||
e,
|
||||
)
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error("Unexpected error during device operation: %s", e)
|
||||
logging.error("Error type: %s", type(e).__name__)
|
||||
# Try to reconnect on any error for next attempt
|
||||
if attempt < max_retries - 1:
|
||||
logging.warning("Attempting reconnection due to error...")
|
||||
await self.ensure_cync_connection()
|
||||
else:
|
||||
raise
|
||||
|
||||
logging.info(
|
||||
"Successfully applied state to device '%s': %s",
|
||||
|
Reference in New Issue
Block a user