""" Cync Lighting Control API This module provides a FastAPI endpoint for controlling Cync smart lights. It maintains a persistent connection to the Cync cloud service and handles authentication, token caching, and connection lifecycle management. Key behaviors: - pycync uses a TCP/TLS connection that requires login acknowledgment before commands work - Commands are sent through a WiFi-connected "hub" device to the Bluetooth mesh - The TCP manager auto-reconnects on disconnect with a 10-second delay - We wait for the connection to be fully ready before sending commands """ import logging import json import os import time import asyncio from typing import Optional, Any from dataclasses import dataclass import aiohttp from fastapi import FastAPI, Depends, HTTPException, Request from fastapi_throttle import RateLimiter from fastapi.responses import JSONResponse from auth.deps import get_current_user from dotenv import load_dotenv from pycync.user import User # type: ignore from pycync.cync import Cync # type: ignore from pycync import Auth # type: ignore from pycync.exceptions import TwoFactorRequiredError, AuthFailedError # type: ignore # Configure logging 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 user: Optional[User] = None connected_at: Optional[float] = None last_command_at: Optional[float] = None 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 - use shared sync client import shared self.redis_client = shared.get_redis_sync_client(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, methods=["GET"], dependencies=common_deps, include_in_schema=False, ) self.app.add_api_route( "/lighting/state", self.set_lighting_state, methods=["POST"], 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: self._health_task.cancel() try: 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 = [] if not self.cync_email: missing.append("CYNC_EMAIL") if not self.cync_password: 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. """ async with self._connection_lock: # 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) 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) 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) if not client: logger.warning("Could not access command client") return 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): 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( session=self._state.session, user=cached_user, username=self.cync_email, password=self.cync_password, ) self._state.user = cached_user return # Need fresh login logger.info("Performing fresh Cync login...") self._state.auth = Auth( session=self._state.session, username=self.cync_email, password=self.cync_password, ) try: self._state.user = await self._state.auth.login() self._save_cached_token(self._state.user) logger.info("Cync login successful") except TwoFactorRequiredError: await self._handle_2fa() 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) print("CYNC 2FA REQUIRED") print("=" * 50) print("Check your email for the Cync verification code.") 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 ) 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 self._state.user = await self._state.auth.login(two_factor_code=twofa_code) self._save_cached_token(self._state.user) logger.info("Cync 2FA login successful") 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) 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: 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"], ) 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, } 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: if os.path.exists(self.token_cache_path): os.remove(self.token_cache_path) 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...") try: 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, ) if not device: 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, brightness: Optional[int] = None, rgb: Optional[tuple[int, int, int]] = None, ) -> 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() logger.debug("Sent turn_on") else: 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", [] ): 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}, } ) 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: """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") 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" ) 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, } ) 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}" ) 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")) ): 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]}" ) return power, brightness, rgb async def _apply_state_with_retry( self, power: str, brightness: Optional[int], rgb: Optional[tuple], ) -> 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}" ) # Wait before retry (exponential backoff) if attempt < self.MAX_RETRIES - 1: 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")