rm test.conf
This commit is contained in:
@@ -42,6 +42,7 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class CyncConnectionState:
|
||||
"""Track the state of our Cync connection."""
|
||||
|
||||
session: Optional[aiohttp.ClientSession] = None
|
||||
auth: Optional[Auth] = None
|
||||
cync_api: Optional[Cync] = None
|
||||
@@ -53,52 +54,51 @@ class CyncConnectionState:
|
||||
class Lighting:
|
||||
"""
|
||||
Cync Lighting Controller
|
||||
|
||||
|
||||
Manages authentication and device control for Cync smart lights.
|
||||
Uses pycync library which maintains a TCP connection for device commands.
|
||||
"""
|
||||
|
||||
|
||||
# Configuration
|
||||
TOKEN_EXPIRY_BUFFER = 300 # Consider token expired 5 min before actual expiry
|
||||
CONNECTION_READY_TIMEOUT = 15 # Max seconds to wait for TCP connection to be ready
|
||||
COMMAND_DELAY = 0.3 # Delay between sequential commands
|
||||
MAX_RETRIES = 3
|
||||
|
||||
|
||||
def __init__(self, app: FastAPI, util: Any, constants: Any) -> None:
|
||||
load_dotenv()
|
||||
|
||||
|
||||
self.app = app
|
||||
self.util = util
|
||||
self.constants = constants
|
||||
|
||||
|
||||
# Redis for state persistence
|
||||
self.redis_client = redis.Redis(
|
||||
password=private.REDIS_PW,
|
||||
decode_responses=True
|
||||
password=private.REDIS_PW, decode_responses=True
|
||||
)
|
||||
self.lighting_key = "lighting:state"
|
||||
|
||||
|
||||
# Cync configuration from environment
|
||||
self.cync_email = os.getenv("CYNC_EMAIL")
|
||||
self.cync_password = os.getenv("CYNC_PASSWORD")
|
||||
self.cync_device_name = os.getenv("CYNC_DEVICE_NAME")
|
||||
self.token_cache_path = "cync_token.json"
|
||||
|
||||
|
||||
# Connection state
|
||||
self._state = CyncConnectionState()
|
||||
self._connection_lock = asyncio.Lock()
|
||||
self._health_task: Optional[asyncio.Task] = None
|
||||
|
||||
|
||||
# Register routes
|
||||
self._register_routes()
|
||||
|
||||
|
||||
def _register_routes(self) -> None:
|
||||
"""Register FastAPI routes."""
|
||||
common_deps = [
|
||||
Depends(RateLimiter(times=25, seconds=2)),
|
||||
Depends(get_current_user),
|
||||
]
|
||||
|
||||
|
||||
self.app.add_api_route(
|
||||
"/lighting/state",
|
||||
self.get_lighting_state,
|
||||
@@ -106,7 +106,7 @@ class Lighting:
|
||||
dependencies=common_deps,
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
|
||||
self.app.add_api_route(
|
||||
"/lighting/state",
|
||||
self.set_lighting_state,
|
||||
@@ -114,25 +114,25 @@ class Lighting:
|
||||
dependencies=common_deps,
|
||||
include_in_schema=False,
|
||||
)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Lifecycle Management
|
||||
# =========================================================================
|
||||
|
||||
|
||||
async def startup(self) -> None:
|
||||
"""Initialize on app startup. Call from lifespan context manager."""
|
||||
self._validate_config()
|
||||
|
||||
|
||||
try:
|
||||
await self._connect()
|
||||
logger.info("Cync lighting initialized successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to initialize Cync at startup: {e}")
|
||||
# Don't raise - allow app to start, will retry on first request
|
||||
|
||||
|
||||
# Start background health monitoring
|
||||
self._health_task = asyncio.create_task(self._health_monitor())
|
||||
|
||||
|
||||
async def shutdown(self) -> None:
|
||||
"""Cleanup on app shutdown. Call from lifespan context manager."""
|
||||
if self._health_task:
|
||||
@@ -141,10 +141,10 @@ class Lighting:
|
||||
await self._health_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
await self._disconnect()
|
||||
logger.info("Cync lighting shut down")
|
||||
|
||||
|
||||
def _validate_config(self) -> None:
|
||||
"""Validate required environment variables."""
|
||||
missing = []
|
||||
@@ -154,18 +154,18 @@ class Lighting:
|
||||
missing.append("CYNC_PASSWORD")
|
||||
if not self.cync_device_name:
|
||||
missing.append("CYNC_DEVICE_NAME")
|
||||
|
||||
|
||||
if missing:
|
||||
raise RuntimeError(f"Missing required env vars: {', '.join(missing)}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Connection Management
|
||||
# =========================================================================
|
||||
|
||||
|
||||
async def _connect(self, force: bool = False) -> None:
|
||||
"""
|
||||
Establish connection to Cync cloud.
|
||||
|
||||
|
||||
This creates the aiohttp session, authenticates, and initializes
|
||||
the pycync API which starts its TCP connection.
|
||||
"""
|
||||
@@ -173,124 +173,124 @@ class Lighting:
|
||||
# Check if we need to connect
|
||||
if not force and self._is_connection_valid():
|
||||
return
|
||||
|
||||
|
||||
logger.info("Establishing Cync connection...")
|
||||
|
||||
|
||||
# Clean up existing connection
|
||||
await self._disconnect_unlocked()
|
||||
|
||||
|
||||
# Create HTTP session
|
||||
timeout = aiohttp.ClientTimeout(total=30, connect=10)
|
||||
self._state.session = aiohttp.ClientSession(timeout=timeout)
|
||||
|
||||
|
||||
# Authenticate
|
||||
await self._authenticate()
|
||||
|
||||
|
||||
# Create Cync API (starts TCP connection)
|
||||
logger.info("Creating Cync API instance...")
|
||||
assert self._state.auth is not None # Set by _authenticate
|
||||
self._state.cync_api = await Cync.create(self._state.auth)
|
||||
|
||||
|
||||
# Wait for TCP connection to be ready
|
||||
await self._wait_for_connection_ready()
|
||||
|
||||
|
||||
self._state.connected_at = time.time()
|
||||
logger.info("Cync connection established")
|
||||
|
||||
|
||||
async def _disconnect(self) -> None:
|
||||
"""Disconnect and cleanup resources."""
|
||||
async with self._connection_lock:
|
||||
await self._disconnect_unlocked()
|
||||
|
||||
|
||||
async def _disconnect_unlocked(self) -> None:
|
||||
"""Disconnect without acquiring lock (internal use)."""
|
||||
# Shutdown pycync TCP connection
|
||||
if self._state.cync_api:
|
||||
try:
|
||||
# pycync's command client has a shut_down method
|
||||
client = getattr(self._state.cync_api, '_command_client', None)
|
||||
client = getattr(self._state.cync_api, "_command_client", None)
|
||||
if client:
|
||||
await client.shut_down()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error shutting down Cync client: {e}")
|
||||
|
||||
|
||||
# Close HTTP session
|
||||
if self._state.session and not self._state.session.closed:
|
||||
await self._state.session.close()
|
||||
await asyncio.sleep(0.1) # Allow cleanup
|
||||
|
||||
|
||||
# Reset state
|
||||
self._state = CyncConnectionState()
|
||||
|
||||
|
||||
def _is_connection_valid(self) -> bool:
|
||||
"""Check if current connection is usable."""
|
||||
if not self._state.cync_api or not self._state.session:
|
||||
return False
|
||||
|
||||
|
||||
if self._state.session.closed:
|
||||
return False
|
||||
|
||||
|
||||
# Check token expiry
|
||||
if self._is_token_expired():
|
||||
logger.info("Token expired or expiring soon")
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _is_token_expired(self) -> bool:
|
||||
"""Check if token is expired or will expire soon."""
|
||||
if not self._state.user:
|
||||
return True
|
||||
|
||||
expires_at = getattr(self._state.user, 'expires_at', 0)
|
||||
|
||||
expires_at = getattr(self._state.user, "expires_at", 0)
|
||||
return expires_at < (time.time() + self.TOKEN_EXPIRY_BUFFER)
|
||||
|
||||
|
||||
async def _wait_for_connection_ready(self) -> None:
|
||||
"""
|
||||
Wait for pycync TCP connection to be fully ready.
|
||||
|
||||
|
||||
pycync's TCP manager waits for login acknowledgment before sending
|
||||
any commands. We need to wait for this to complete.
|
||||
"""
|
||||
if not self._state.cync_api:
|
||||
raise RuntimeError("Cync API not initialized")
|
||||
|
||||
client = getattr(self._state.cync_api, '_command_client', None)
|
||||
|
||||
client = getattr(self._state.cync_api, "_command_client", None)
|
||||
if not client:
|
||||
logger.warning("Could not access command client")
|
||||
return
|
||||
|
||||
tcp_manager = getattr(client, '_tcp_manager', None)
|
||||
|
||||
tcp_manager = getattr(client, "_tcp_manager", None)
|
||||
if not tcp_manager:
|
||||
logger.warning("Could not access TCP manager")
|
||||
return
|
||||
|
||||
|
||||
# Wait for login to be acknowledged
|
||||
start = time.time()
|
||||
while not getattr(tcp_manager, '_login_acknowledged', False):
|
||||
while not getattr(tcp_manager, "_login_acknowledged", False):
|
||||
if time.time() - start > self.CONNECTION_READY_TIMEOUT:
|
||||
raise TimeoutError("Timed out waiting for Cync login acknowledgment")
|
||||
await asyncio.sleep(0.2)
|
||||
logger.debug("Waiting for Cync TCP login acknowledgment...")
|
||||
|
||||
|
||||
# Give a tiny bit more time for device probing to start
|
||||
await asyncio.sleep(0.5)
|
||||
logger.info(f"Cync TCP connection ready (took {time.time() - start:.1f}s)")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Authentication
|
||||
# =========================================================================
|
||||
|
||||
|
||||
async def _authenticate(self) -> None:
|
||||
"""Authenticate with Cync, using cached token if valid."""
|
||||
# Try cached token first
|
||||
cached_user = self._load_cached_token()
|
||||
|
||||
|
||||
# These are validated by _validate_config at startup
|
||||
assert self._state.session is not None
|
||||
assert self.cync_email is not None
|
||||
assert self.cync_password is not None
|
||||
|
||||
|
||||
if cached_user and not self._is_user_token_expired(cached_user):
|
||||
logger.info("Using cached Cync token")
|
||||
self._state.auth = Auth(
|
||||
@@ -301,7 +301,7 @@ class Lighting:
|
||||
)
|
||||
self._state.user = cached_user
|
||||
return
|
||||
|
||||
|
||||
# Need fresh login
|
||||
logger.info("Performing fresh Cync login...")
|
||||
self._state.auth = Auth(
|
||||
@@ -309,7 +309,7 @@ class Lighting:
|
||||
username=self.cync_email,
|
||||
password=self.cync_password,
|
||||
)
|
||||
|
||||
|
||||
try:
|
||||
self._state.user = await self._state.auth.login()
|
||||
self._save_cached_token(self._state.user)
|
||||
@@ -319,14 +319,14 @@ class Lighting:
|
||||
except AuthFailedError as e:
|
||||
logger.error(f"Cync authentication failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
async def _handle_2fa(self) -> None:
|
||||
"""Handle 2FA authentication."""
|
||||
import sys
|
||||
|
||||
|
||||
# Try environment variable first
|
||||
twofa_code = os.getenv("CYNC_2FA_CODE")
|
||||
|
||||
|
||||
# If not set, prompt interactively
|
||||
if not twofa_code:
|
||||
print("\n" + "=" * 50)
|
||||
@@ -336,23 +336,22 @@ class Lighting:
|
||||
print("Enter the code below (you have 60 seconds):")
|
||||
print("=" * 50)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
# Use asyncio to read with timeout
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
twofa_code = await asyncio.wait_for(
|
||||
loop.run_in_executor(None, input, "2FA Code: "),
|
||||
timeout=60.0
|
||||
loop.run_in_executor(None, input, "2FA Code: "), timeout=60.0
|
||||
)
|
||||
twofa_code = twofa_code.strip()
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("2FA code entry timed out")
|
||||
raise RuntimeError("2FA code entry timed out")
|
||||
|
||||
|
||||
if not twofa_code:
|
||||
logger.error("No 2FA code provided")
|
||||
raise RuntimeError("Cync 2FA required but no code provided")
|
||||
|
||||
|
||||
logger.info("Retrying Cync login with 2FA code")
|
||||
try:
|
||||
assert self._state.auth is not None
|
||||
@@ -362,48 +361,48 @@ class Lighting:
|
||||
except Exception as e:
|
||||
logger.error(f"Cync 2FA login failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def _is_user_token_expired(self, user: User) -> bool:
|
||||
"""Check if a user's token is expired."""
|
||||
expires_at = getattr(user, 'expires_at', 0)
|
||||
expires_at = getattr(user, "expires_at", 0)
|
||||
return expires_at < (time.time() + self.TOKEN_EXPIRY_BUFFER)
|
||||
|
||||
|
||||
def _load_cached_token(self) -> Optional[User]:
|
||||
"""Load cached authentication token from disk."""
|
||||
try:
|
||||
if not os.path.exists(self.token_cache_path):
|
||||
return None
|
||||
|
||||
with open(self.token_cache_path, 'r') as f:
|
||||
|
||||
with open(self.token_cache_path, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
|
||||
return User(
|
||||
access_token=data['access_token'],
|
||||
refresh_token=data['refresh_token'],
|
||||
authorize=data['authorize'],
|
||||
user_id=data['user_id'],
|
||||
expires_at=data['expires_at'],
|
||||
access_token=data["access_token"],
|
||||
refresh_token=data["refresh_token"],
|
||||
authorize=data["authorize"],
|
||||
user_id=data["user_id"],
|
||||
expires_at=data["expires_at"],
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load cached token: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def _save_cached_token(self, user: User) -> None:
|
||||
"""Save authentication token to disk."""
|
||||
try:
|
||||
data = {
|
||||
'access_token': user.access_token,
|
||||
'refresh_token': user.refresh_token,
|
||||
'authorize': user.authorize,
|
||||
'user_id': user.user_id,
|
||||
'expires_at': user.expires_at,
|
||||
"access_token": user.access_token,
|
||||
"refresh_token": user.refresh_token,
|
||||
"authorize": user.authorize,
|
||||
"user_id": user.user_id,
|
||||
"expires_at": user.expires_at,
|
||||
}
|
||||
with open(self.token_cache_path, 'w') as f:
|
||||
with open(self.token_cache_path, "w") as f:
|
||||
json.dump(data, f)
|
||||
logger.debug("Saved Cync token to disk")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save token: {e}")
|
||||
|
||||
|
||||
def _clear_cached_token(self) -> None:
|
||||
"""Remove cached token file."""
|
||||
try:
|
||||
@@ -412,17 +411,17 @@ class Lighting:
|
||||
logger.info("Cleared cached token")
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Health Monitoring
|
||||
# =========================================================================
|
||||
|
||||
|
||||
async def _health_monitor(self) -> None:
|
||||
"""Background task to monitor connection health and refresh tokens."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(300) # Check every 5 minutes
|
||||
|
||||
|
||||
# Proactively refresh if token is expiring
|
||||
if self._is_token_expired():
|
||||
logger.info("Token expiring, proactively reconnecting...")
|
||||
@@ -430,38 +429,38 @@ class Lighting:
|
||||
await self._connect(force=True)
|
||||
except Exception as e:
|
||||
logger.error(f"Proactive reconnection failed: {e}")
|
||||
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Health monitor error: {e}")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Device Control
|
||||
# =========================================================================
|
||||
|
||||
|
||||
async def _get_device(self):
|
||||
"""Get the target light device."""
|
||||
if not self._state.cync_api:
|
||||
raise RuntimeError("Cync not connected")
|
||||
|
||||
|
||||
devices = self._state.cync_api.get_devices()
|
||||
if not devices:
|
||||
raise RuntimeError("No devices found")
|
||||
|
||||
|
||||
device = next(
|
||||
(d for d in devices if getattr(d, 'name', None) == self.cync_device_name),
|
||||
None
|
||||
(d for d in devices if getattr(d, "name", None) == self.cync_device_name),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
if not device:
|
||||
available = [getattr(d, 'name', 'unnamed') for d in devices]
|
||||
available = [getattr(d, "name", "unnamed") for d in devices]
|
||||
raise RuntimeError(
|
||||
f"Device '{self.cync_device_name}' not found. Available: {available}"
|
||||
)
|
||||
|
||||
|
||||
return device
|
||||
|
||||
|
||||
async def _send_commands(
|
||||
self,
|
||||
power: str,
|
||||
@@ -470,13 +469,13 @@ class Lighting:
|
||||
) -> None:
|
||||
"""
|
||||
Send commands to the light device.
|
||||
|
||||
|
||||
Commands are sent sequentially with small delays to ensure
|
||||
the TCP connection processes each one.
|
||||
"""
|
||||
device = await self._get_device()
|
||||
logger.info(f"Sending commands to device: {device.name}")
|
||||
|
||||
|
||||
# Power
|
||||
if power == "on":
|
||||
await device.turn_on()
|
||||
@@ -485,107 +484,128 @@ class Lighting:
|
||||
await device.turn_off()
|
||||
logger.debug("Sent turn_off")
|
||||
await asyncio.sleep(self.COMMAND_DELAY)
|
||||
|
||||
|
||||
# Brightness
|
||||
if brightness is not None:
|
||||
await device.set_brightness(brightness)
|
||||
logger.debug(f"Sent brightness: {brightness}")
|
||||
await asyncio.sleep(self.COMMAND_DELAY)
|
||||
|
||||
|
||||
# Color
|
||||
if rgb:
|
||||
await device.set_rgb(rgb)
|
||||
logger.debug(f"Sent RGB: {rgb}")
|
||||
await asyncio.sleep(self.COMMAND_DELAY)
|
||||
|
||||
|
||||
self._state.last_command_at = time.time()
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# API Endpoints
|
||||
# =========================================================================
|
||||
|
||||
|
||||
async def get_lighting_state(self, user=Depends(get_current_user)) -> JSONResponse:
|
||||
"""Get the current lighting state from Redis."""
|
||||
if "lighting" not in user.get("roles", []) and "admin" not in user.get("roles", []):
|
||||
if "lighting" not in user.get("roles", []) and "admin" not in user.get(
|
||||
"roles", []
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
try:
|
||||
state = self.redis_client.get(self.lighting_key)
|
||||
if state:
|
||||
return JSONResponse(content=json.loads(str(state)))
|
||||
|
||||
|
||||
# Default state
|
||||
return JSONResponse(content={
|
||||
"power": "off",
|
||||
"brightness": 50,
|
||||
"color": {"r": 255, "g": 255, "b": 255},
|
||||
})
|
||||
return JSONResponse(
|
||||
content={
|
||||
"power": "off",
|
||||
"brightness": 50,
|
||||
"color": {"r": 255, "g": 255, "b": 255},
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting lighting state: {e}")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
async def set_lighting_state(self, request: Request,
|
||||
user=Depends(get_current_user)) -> JSONResponse:
|
||||
|
||||
async def set_lighting_state(
|
||||
self, request: Request, user=Depends(get_current_user)
|
||||
) -> JSONResponse:
|
||||
"""Set the lighting state and apply to Cync device."""
|
||||
try:
|
||||
if "lighting" not in user.get("roles", []) and "admin" not in user.get("roles", []):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
if "lighting" not in user.get("roles", []) and "admin" not in user.get(
|
||||
"roles", []
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Insufficient permissions")
|
||||
state = await request.json()
|
||||
logger.info(f"Lighting request: {state}")
|
||||
|
||||
|
||||
# Validate
|
||||
if not isinstance(state, dict):
|
||||
raise HTTPException(status_code=400, detail="State must be a JSON object")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400, detail="State must be a JSON object"
|
||||
)
|
||||
|
||||
power, brightness, rgb = self._parse_state(state)
|
||||
|
||||
|
||||
# Save to Redis (even if device command fails)
|
||||
self.redis_client.set(self.lighting_key, json.dumps(state))
|
||||
|
||||
|
||||
# Apply to device with retries
|
||||
await self._apply_state_with_retry(power, brightness, rgb)
|
||||
|
||||
logger.info(f"Successfully applied state: power={power}, brightness={brightness}, rgb={rgb}")
|
||||
return JSONResponse(content={
|
||||
"message": "Lighting state updated",
|
||||
"state": state,
|
||||
})
|
||||
|
||||
|
||||
logger.info(
|
||||
f"Successfully applied state: power={power}, brightness={brightness}, rgb={rgb}"
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
"message": "Lighting state updated",
|
||||
"state": state,
|
||||
}
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting lighting state: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _parse_state(self, state: dict) -> tuple[str, Optional[int], Optional[tuple]]:
|
||||
"""Parse and validate lighting state from request."""
|
||||
# Power
|
||||
power = state.get("power", "off")
|
||||
if power not in ("on", "off"):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid power: {power}")
|
||||
|
||||
|
||||
# Brightness
|
||||
brightness = None
|
||||
if "brightness" in state:
|
||||
brightness = state["brightness"]
|
||||
if not isinstance(brightness, (int, float)) or not (0 <= brightness <= 100):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid brightness: {brightness}")
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid brightness: {brightness}"
|
||||
)
|
||||
brightness = int(brightness)
|
||||
|
||||
|
||||
# Color
|
||||
rgb = None
|
||||
color = state.get("color")
|
||||
if color and isinstance(color, dict) and all(k in color for k in ("r", "g", "b")):
|
||||
if (
|
||||
color
|
||||
and isinstance(color, dict)
|
||||
and all(k in color for k in ("r", "g", "b"))
|
||||
):
|
||||
rgb = (color["r"], color["g"], color["b"])
|
||||
elif all(k in state for k in ("red", "green", "blue")):
|
||||
rgb = (state["red"], state["green"], state["blue"])
|
||||
|
||||
|
||||
if rgb:
|
||||
for i, name in enumerate(("red", "green", "blue")):
|
||||
if not isinstance(rgb[i], int) or not (0 <= rgb[i] <= 255):
|
||||
raise HTTPException(status_code=400, detail=f"Invalid {name}: {rgb[i]}")
|
||||
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid {name}: {rgb[i]}"
|
||||
)
|
||||
|
||||
return power, brightness, rgb
|
||||
|
||||
|
||||
async def _apply_state_with_retry(
|
||||
self,
|
||||
power: str,
|
||||
@@ -594,35 +614,37 @@ class Lighting:
|
||||
) -> None:
|
||||
"""Apply state to device with connection retry logic."""
|
||||
last_error: Optional[Exception] = None
|
||||
|
||||
|
||||
for attempt in range(self.MAX_RETRIES):
|
||||
try:
|
||||
# Ensure connection (force reconnect on retries)
|
||||
await self._connect(force=(attempt > 0))
|
||||
|
||||
|
||||
# Send commands
|
||||
await self._send_commands(power, brightness, rgb)
|
||||
return # Success
|
||||
|
||||
|
||||
except (AuthFailedError, TwoFactorRequiredError) as e:
|
||||
last_error = e
|
||||
logger.warning(f"Auth error on attempt {attempt + 1}: {e}")
|
||||
self._clear_cached_token()
|
||||
|
||||
|
||||
except TimeoutError as e:
|
||||
last_error = e
|
||||
logger.warning(f"Timeout on attempt {attempt + 1}: {e}")
|
||||
|
||||
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
logger.warning(f"Error on attempt {attempt + 1}: {type(e).__name__}: {e}")
|
||||
|
||||
logger.warning(
|
||||
f"Error on attempt {attempt + 1}: {type(e).__name__}: {e}"
|
||||
)
|
||||
|
||||
# Wait before retry (exponential backoff)
|
||||
if attempt < self.MAX_RETRIES - 1:
|
||||
wait_time = 2 ** attempt
|
||||
wait_time = 2**attempt
|
||||
logger.info(f"Retrying in {wait_time}s...")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
|
||||
# All retries failed
|
||||
logger.error(f"All {self.MAX_RETRIES} attempts failed")
|
||||
raise last_error or RuntimeError("Failed to apply lighting state")
|
||||
|
||||
Reference in New Issue
Block a user