Files
api/endpoints/lighting.py

1009 lines
37 KiB
Python
Raw Normal View History

2025-12-18 07:27:37 -05:00
"""
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
- 2FA codes are read from Redis key 'cync:2fa_code' - no stdin blocking
2025-12-18 07:27:37 -05:00
"""
import logging
import json
import os
import time
import asyncio
2025-12-18 07:27:37 -05:00
from typing import Optional, Any
from dataclasses import dataclass, field
from enum import Enum
2025-12-18 07:27:37 -05:00
import aiohttp
from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi_throttle import RateLimiter
from fastapi.responses import JSONResponse
2025-12-18 07:27:37 -05:00
from auth.deps import get_current_user
from dotenv import load_dotenv
2025-12-18 07:27:37 -05:00
2025-10-02 10:45:30 -04:00
from pycync.user import User # type: ignore
2025-12-18 07:27:37 -05:00
from pycync.cync import Cync # type: ignore
from pycync import Auth # type: ignore
from pycync.exceptions import TwoFactorRequiredError, AuthFailedError # type: ignore
2025-11-22 21:43:48 -05:00
2025-12-18 07:27:37 -05:00
# Configure logging
logger = logging.getLogger(__name__)
2025-11-22 21:43:48 -05:00
class ConnectionStatus(Enum):
"""Connection status enum for better tracking."""
2026-02-07 21:26:10 -05:00
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
CONNECTED = "connected"
AWAITING_2FA = "awaiting_2fa"
ERROR = "error"
2025-12-18 07:27:37 -05:00
@dataclass
class CyncConnectionState:
"""Track the state of our Cync connection."""
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
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
last_successful_command: Optional[float] = None
status: ConnectionStatus = ConnectionStatus.DISCONNECTED
consecutive_failures: int = 0
last_error: Optional[str] = None
2025-11-22 21:43:48 -05:00
2025-10-02 10:45:30 -04:00
2025-12-18 07:27:37 -05:00
class Lighting:
"""
2025-12-18 07:27:37 -05:00
Cync Lighting Controller
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
Manages authentication and device control for Cync smart lights.
Uses pycync library which maintains a TCP connection for device commands.
2026-02-07 21:26:10 -05:00
2FA Handling:
- When 2FA is required, status changes to AWAITING_2FA
- Set the 2FA code via Redis: SET cync:2fa_code "123456"
- Or via environment variable: CYNC_2FA_CODE=123456
- The system polls for the code and automatically retries login
"""
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# 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
MAX_CONSECUTIVE_FAILURES = 5 # Force full reconnect after this many failures
HEALTH_CHECK_INTERVAL = 30 # Check connection health every 30s
TWO_FA_POLL_INTERVAL = 5 # Poll for 2FA code every 5 seconds
TWO_FA_TIMEOUT = 300 # 5 minutes to enter 2FA code
REDIS_2FA_KEY = "cync:2fa_code"
REDIS_STATUS_KEY = "cync:connection_status"
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
def __init__(self, app: FastAPI, util: Any, constants: Any) -> None:
load_dotenv()
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
self.app = app
self.util = util
self.constants = constants
2025-12-18 07:30:39 -05:00
# 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"
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# 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"
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Connection state
self._state = CyncConnectionState()
self._connection_lock = asyncio.Lock()
self._health_task: Optional[asyncio.Task] = None
self._2fa_task: Optional[asyncio.Task] = None
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Register routes
self._register_routes()
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
def _register_routes(self) -> None:
"""Register FastAPI routes."""
common_deps = [
Depends(RateLimiter(times=25, seconds=2)),
Depends(get_current_user),
]
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
self.app.add_api_route(
"/lighting/state",
self.get_lighting_state,
methods=["GET"],
dependencies=common_deps,
include_in_schema=False,
)
2025-12-18 07:30:39 -05:00
self.app.add_api_route(
"/lighting/state",
self.set_lighting_state,
methods=["POST"],
2025-12-18 07:27:37 -05:00
dependencies=common_deps,
include_in_schema=False,
)
2025-12-18 07:30:39 -05:00
# Status endpoint - no auth required for monitoring
self.app.add_api_route(
"/lighting/connection-status",
self.get_connection_status,
methods=["GET"],
include_in_schema=False,
)
# 2FA submission endpoint - no auth required since 2FA is pre-auth
self.app.add_api_route(
"/lighting/2fa",
self.submit_2fa_code,
methods=["POST"],
dependencies=[Depends(RateLimiter(times=5, seconds=60))], # Rate limit only
include_in_schema=False,
)
# Force reconnect endpoint - requires auth
self.app.add_api_route(
"/lighting/reconnect",
self.force_reconnect,
methods=["POST"],
dependencies=common_deps,
include_in_schema=False,
)
2025-12-18 07:27:37 -05:00
# =========================================================================
# Lifecycle Management
# =========================================================================
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
async def startup(self) -> None:
"""Initialize on app startup. Call from lifespan context manager."""
self._validate_config()
2025-12-18 07:30:39 -05:00
try:
2025-12-18 07:27:37 -05:00
await self._connect()
logger.info("Cync lighting initialized successfully")
except TwoFactorRequiredError:
logger.warning("Cync requires 2FA - waiting for code via Redis or API")
# Don't raise - 2FA polling task will handle this
except Exception as e:
2025-12-18 07:27:37 -05:00
logger.error(f"Failed to initialize Cync at startup: {e}")
self._state.status = ConnectionStatus.ERROR
self._state.last_error = str(e)
2025-12-18 07:27:37 -05:00
# Don't raise - allow app to start, will retry on first request
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Start background health monitoring
self._health_task = asyncio.create_task(self._health_monitor())
self._update_status_in_redis()
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
async def shutdown(self) -> None:
"""Cleanup on app shutdown. Call from lifespan context manager."""
if self._health_task:
self._health_task.cancel()
try:
2025-12-18 07:27:37 -05:00
await self._health_task
2025-11-21 12:29:12 -05:00
except asyncio.CancelledError:
2025-12-18 07:27:37 -05:00
pass
2025-12-18 07:30:39 -05:00
if self._2fa_task:
self._2fa_task.cancel()
try:
await self._2fa_task
except asyncio.CancelledError:
pass
2025-12-18 07:27:37 -05:00
await self._disconnect()
logger.info("Cync lighting shut down")
2025-12-18 07:30:39 -05:00
def _update_status_in_redis(self) -> None:
"""Update connection status in Redis for monitoring."""
try:
status_data = {
"status": self._state.status.value,
"connected_at": self._state.connected_at,
"last_command_at": self._state.last_command_at,
"last_successful_command": self._state.last_successful_command,
"consecutive_failures": self._state.consecutive_failures,
"last_error": self._state.last_error,
"updated_at": time.time(),
}
2026-02-07 21:26:10 -05:00
self.redis_client.set(
self.REDIS_STATUS_KEY, json.dumps(status_data), ex=300
)
except Exception as e:
logger.debug(f"Failed to update status in Redis: {e}")
2025-12-18 07:27:37 -05:00
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")
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
if missing:
raise RuntimeError(f"Missing required env vars: {', '.join(missing)}")
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# =========================================================================
# Connection Management
# =========================================================================
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
async def _connect(self, force: bool = False) -> None:
"""
Establish connection to Cync cloud.
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
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
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
logger.info("Establishing Cync connection...")
self._state.status = ConnectionStatus.CONNECTING
self._update_status_in_redis()
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Clean up existing connection
await self._disconnect_unlocked()
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Create HTTP session
timeout = aiohttp.ClientTimeout(total=30, connect=10)
self._state.session = aiohttp.ClientSession(timeout=timeout)
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Authenticate
await self._authenticate()
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# 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)
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Wait for TCP connection to be ready
await self._wait_for_connection_ready()
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
self._state.connected_at = time.time()
self._state.status = ConnectionStatus.CONNECTED
self._state.last_error = None
self._update_status_in_redis()
2025-12-18 07:27:37 -05:00
logger.info("Cync connection established")
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
async def _disconnect(self) -> None:
"""Disconnect and cleanup resources."""
async with self._connection_lock:
await self._disconnect_unlocked()
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
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
2025-12-18 07:30:39 -05:00
client = getattr(self._state.cync_api, "_command_client", None)
2025-12-18 07:27:37 -05:00
if client:
await client.shut_down()
except Exception as e:
2025-12-18 07:27:37 -05:00
logger.warning(f"Error shutting down Cync client: {e}")
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# 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
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Reset state
self._state = CyncConnectionState()
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
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
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
if self._state.session.closed:
return False
2025-12-18 07:30:39 -05:00
2026-01-25 13:14:00 -05:00
if not self._is_tcp_connected():
logger.info("Cync TCP manager not connected; will reconnect")
return False
2025-12-18 07:27:37 -05:00
# Check token expiry
if self._is_token_expired():
logger.info("Token expired or expiring soon")
return False
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
return True
2025-12-18 07:30:39 -05:00
2026-01-25 13:14:00 -05:00
def _is_tcp_connected(self) -> bool:
"""Best-effort check that the pycync TCP connection is alive."""
client = getattr(self._state.cync_api, "_command_client", None)
if not client:
return False
tcp_manager = getattr(client, "_tcp_manager", None)
if not tcp_manager:
return False
# If login was never acknowledged or was cleared, treat as disconnected
if not getattr(tcp_manager, "_login_acknowledged", False):
return False
writer = getattr(tcp_manager, "_writer", None)
reader = getattr(tcp_manager, "_reader", None)
# If underlying streams are closed, reconnect
if writer and writer.is_closing():
return False
if reader and reader.at_eof():
return False
# Some versions expose a _closed flag
if getattr(tcp_manager, "_closed", False):
return False
return True
2025-12-18 07:27:37 -05:00
def _is_token_expired(self) -> bool:
"""Check if token is expired or will expire soon."""
if not self._state.user:
return True
2025-12-18 07:30:39 -05:00
expires_at = getattr(self._state.user, "expires_at", 0)
2025-12-18 07:27:37 -05:00
return expires_at < (time.time() + self.TOKEN_EXPIRY_BUFFER)
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
async def _wait_for_connection_ready(self) -> None:
"""
Wait for pycync TCP connection to be fully ready.
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
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")
2025-12-18 07:30:39 -05:00
client = getattr(self._state.cync_api, "_command_client", None)
2025-12-18 07:27:37 -05:00
if not client:
logger.warning("Could not access command client")
return
2025-12-18 07:30:39 -05:00
tcp_manager = getattr(client, "_tcp_manager", None)
2025-12-18 07:27:37 -05:00
if not tcp_manager:
logger.warning("Could not access TCP manager")
return
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Wait for login to be acknowledged
start = time.time()
2025-12-18 07:30:39 -05:00
while not getattr(tcp_manager, "_login_acknowledged", False):
2025-12-18 07:27:37 -05:00
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...")
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# 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)")
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# =========================================================================
# Authentication
# =========================================================================
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
async def _authenticate(self) -> None:
"""Authenticate with Cync, using cached token if valid."""
# Try cached token first
cached_user = self._load_cached_token()
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# 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
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
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
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# 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,
)
2025-12-18 07:30:39 -05:00
try:
2025-12-18 07:27:37 -05:00
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
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
async def _handle_2fa(self) -> None:
"""
Handle 2FA authentication by polling Redis for the code.
2026-02-07 21:26:10 -05:00
This is non-blocking - it sets the status to AWAITING_2FA and starts
a background task to poll for the code. The code can be provided via:
1. Environment variable CYNC_2FA_CODE (checked first)
2. Redis key 'cync:2fa_code' (polled continuously)
3. POST /lighting/2fa endpoint (sets the Redis key)
"""
# Try environment variable first (for initial startup)
2025-12-18 07:27:37 -05:00
twofa_code = os.getenv("CYNC_2FA_CODE")
2025-12-18 07:30:39 -05:00
if twofa_code:
await self._complete_2fa_login(twofa_code.strip())
return
2025-12-18 07:30:39 -05:00
# Set status and start polling Redis
self._state.status = ConnectionStatus.AWAITING_2FA
2026-02-07 21:26:10 -05:00
self._state.last_error = (
"2FA code required - check email and submit via API or Redis"
)
self._update_status_in_redis()
2026-02-07 21:26:10 -05:00
logger.warning(
"Cync 2FA required. Submit code via POST /lighting/2fa or "
f"set Redis key '{self.REDIS_2FA_KEY}'"
)
2025-12-18 07:30:39 -05:00
# Start background polling task if not already running
if self._2fa_task is None or self._2fa_task.done():
self._2fa_task = asyncio.create_task(self._poll_for_2fa_code())
2026-02-07 21:26:10 -05:00
# Raise to signal caller that we're waiting for 2FA
raise TwoFactorRequiredError("Awaiting 2FA code via Redis or API")
async def _poll_for_2fa_code(self) -> None:
"""Background task to poll Redis for 2FA code."""
start_time = time.time()
2026-02-07 21:26:10 -05:00
while time.time() - start_time < self.TWO_FA_TIMEOUT:
try:
# Check Redis for 2FA code
code = self.redis_client.get(self.REDIS_2FA_KEY)
2026-02-07 21:26:10 -05:00
if code:
code_str = code.decode() if isinstance(code, bytes) else str(code)
code_str = code_str.strip()
2026-02-07 21:26:10 -05:00
if code_str:
logger.info("Found 2FA code in Redis, attempting login...")
# Clear the code from Redis immediately
self.redis_client.delete(self.REDIS_2FA_KEY)
2026-02-07 21:26:10 -05:00
try:
await self._complete_2fa_login(code_str)
logger.info("2FA login successful via Redis polling")
return
except Exception as e:
logger.error(f"2FA login failed: {e}")
self._state.last_error = f"2FA login failed: {e}"
self._update_status_in_redis()
# Continue polling in case user wants to retry
2026-02-07 21:26:10 -05:00
await asyncio.sleep(self.TWO_FA_POLL_INTERVAL)
2026-02-07 21:26:10 -05:00
except asyncio.CancelledError:
logger.info("2FA polling task cancelled")
raise
except Exception as e:
logger.error(f"Error polling for 2FA code: {e}")
await asyncio.sleep(self.TWO_FA_POLL_INTERVAL)
2026-02-07 21:26:10 -05:00
# Timeout reached
logger.error(f"2FA code timeout after {self.TWO_FA_TIMEOUT}s")
self._state.status = ConnectionStatus.ERROR
self._state.last_error = f"2FA code timeout after {self.TWO_FA_TIMEOUT}s"
self._update_status_in_redis()
async def _complete_2fa_login(self, code: str) -> None:
"""Complete the 2FA login process with the provided code."""
if not code:
raise ValueError("Empty 2FA code provided")
2026-02-07 21:26:10 -05:00
logger.info("Completing 2FA login...")
2026-02-07 21:26:10 -05:00
2025-12-18 07:27:37 -05:00
try:
assert self._state.auth is not None, "Auth not initialized"
self._state.user = await self._state.auth.login(two_factor_code=code)
2025-12-18 07:27:37 -05:00
self._save_cached_token(self._state.user)
2026-02-07 21:26:10 -05:00
# Now complete the connection
self._state.status = ConnectionStatus.CONNECTING
self._update_status_in_redis()
2026-02-07 21:26:10 -05:00
# Reconnect with the new token
await self._connect(force=True)
2026-02-07 21:26:10 -05:00
2025-12-18 07:27:37 -05:00
logger.info("Cync 2FA login successful")
except TwoFactorRequiredError:
# Code was invalid, still needs 2FA
self._state.status = ConnectionStatus.AWAITING_2FA
self._state.last_error = "Invalid 2FA code - try again"
self._update_status_in_redis()
raise
except Exception as e:
self._state.status = ConnectionStatus.ERROR
self._state.last_error = f"2FA login failed: {e}"
self._update_status_in_redis()
2025-12-18 07:27:37 -05:00
logger.error(f"Cync 2FA login failed: {e}")
raise
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
def _is_user_token_expired(self, user: User) -> bool:
"""Check if a user's token is expired."""
2025-12-18 07:30:39 -05:00
expires_at = getattr(user, "expires_at", 0)
2025-12-18 07:27:37 -05:00
return expires_at < (time.time() + self.TOKEN_EXPIRY_BUFFER)
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
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
2025-12-18 07:30:39 -05:00
with open(self.token_cache_path, "r") as f:
2025-12-18 07:27:37 -05:00
data = json.load(f)
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
return User(
2025-12-18 07:30:39 -05:00
access_token=data["access_token"],
refresh_token=data["refresh_token"],
authorize=data["authorize"],
user_id=data["user_id"],
expires_at=data["expires_at"],
2025-12-18 07:27:37 -05:00
)
except Exception as e:
logger.warning(f"Failed to load cached token: {e}")
return None
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
def _save_cached_token(self, user: User) -> None:
"""Save authentication token to disk."""
try:
data = {
2025-12-18 07:30:39 -05:00
"access_token": user.access_token,
"refresh_token": user.refresh_token,
"authorize": user.authorize,
"user_id": user.user_id,
"expires_at": user.expires_at,
}
2025-12-18 07:30:39 -05:00
with open(self.token_cache_path, "w") as f:
json.dump(data, f)
2025-12-18 07:27:37 -05:00
logger.debug("Saved Cync token to disk")
except Exception as e:
2025-12-18 07:27:37 -05:00
logger.warning(f"Failed to save token: {e}")
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
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
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# =========================================================================
# Health Monitoring
# =========================================================================
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
async def _health_monitor(self) -> None:
"""
Background task to monitor connection health and reconnect aggressively.
2026-02-07 21:26:10 -05:00
Checks every HEALTH_CHECK_INTERVAL seconds and reconnects if:
- Token is expiring soon
- TCP connection appears dead
- Too many consecutive command failures
"""
2025-12-18 07:27:37 -05:00
while True:
try:
await asyncio.sleep(self.HEALTH_CHECK_INTERVAL)
2026-02-07 21:26:10 -05:00
# Skip health checks if awaiting 2FA
if self._state.status == ConnectionStatus.AWAITING_2FA:
continue
2026-01-25 13:14:00 -05:00
needs_reconnect = False
reason = ""
# Check consecutive failures
if self._state.consecutive_failures >= self.MAX_CONSECUTIVE_FAILURES:
needs_reconnect = True
reason = f"{self._state.consecutive_failures} consecutive failures"
self._state.consecutive_failures = 0 # Reset counter
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Proactively refresh if token is expiring
elif self._is_token_expired():
2026-01-25 13:14:00 -05:00
needs_reconnect = True
reason = "token expiring"
2026-01-25 13:14:00 -05:00
# Reconnect if TCP connection looks dead
elif not self._is_tcp_connected():
2026-01-25 13:14:00 -05:00
needs_reconnect = True
reason = "TCP connection lost"
2026-01-25 13:14:00 -05:00
if needs_reconnect:
logger.warning(f"Health monitor triggering reconnection: {reason}")
self._state.status = ConnectionStatus.CONNECTING
self._update_status_in_redis()
2026-02-07 21:26:10 -05:00
2025-12-18 07:27:37 -05:00
try:
await self._connect(force=True)
logger.info("Health monitor reconnection successful")
except TwoFactorRequiredError:
logger.warning("Reconnection requires 2FA - waiting for code")
# 2FA handler will update status
2025-12-18 07:27:37 -05:00
except Exception as e:
logger.error(f"Health monitor reconnection failed: {e}")
self._state.status = ConnectionStatus.ERROR
self._state.last_error = str(e)
self._update_status_in_redis()
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
except asyncio.CancelledError:
break
except Exception as e:
logger.error(f"Health monitor error: {e}")
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# =========================================================================
# Device Control
# =========================================================================
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
async def _get_device(self):
"""Get the target light device."""
if not self._state.cync_api:
raise RuntimeError("Cync not connected")
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
devices = self._state.cync_api.get_devices()
if not devices:
raise RuntimeError("No devices found")
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
device = next(
2025-12-18 07:30:39 -05:00
(d for d in devices if getattr(d, "name", None) == self.cync_device_name),
None,
2025-12-18 07:27:37 -05:00
)
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
if not device:
2025-12-18 07:30:39 -05:00
available = [getattr(d, "name", "unnamed") for d in devices]
2025-12-18 07:27:37 -05:00
raise RuntimeError(
f"Device '{self.cync_device_name}' not found. Available: {available}"
)
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
return device
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
async def _send_commands(
self,
power: str,
brightness: Optional[int] = None,
rgb: Optional[tuple[int, int, int]] = None,
) -> None:
"""
2025-12-18 07:27:37 -05:00
Send commands to the light device.
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
Commands are sent sequentially with small delays to ensure
the TCP connection processes each one.
"""
2025-12-18 07:27:37 -05:00
device = await self._get_device()
logger.info(f"Sending commands to device: {device.name}")
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# 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)
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Brightness
if brightness is not None:
await device.set_brightness(brightness)
logger.debug(f"Sent brightness: {brightness}")
await asyncio.sleep(self.COMMAND_DELAY)
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Color
if rgb:
await device.set_rgb(rgb)
logger.debug(f"Sent RGB: {rgb}")
await asyncio.sleep(self.COMMAND_DELAY)
2025-12-18 07:30:39 -05:00
# Track success
now = time.time()
self._state.last_command_at = now
self._state.last_successful_command = now
self._state.consecutive_failures = 0
self._update_status_in_redis()
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# =========================================================================
# API Endpoints
# =========================================================================
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
async def get_lighting_state(self, user=Depends(get_current_user)) -> JSONResponse:
"""Get the current lighting state from Redis."""
2025-12-18 07:30:39 -05:00
if "lighting" not in user.get("roles", []) and "admin" not in user.get(
"roles", []
):
2025-12-18 07:27:37 -05:00
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)))
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Default state
2025-12-18 07:30:39 -05:00
return JSONResponse(
content={
"power": "off",
"brightness": 50,
"color": {"r": 255, "g": 255, "b": 255},
}
)
except Exception as e:
2025-12-18 07:27:37 -05:00
logger.error(f"Error getting lighting state: {e}")
raise HTTPException(status_code=500, detail="Internal server error")
2025-12-18 07:30:39 -05:00
async def set_lighting_state(
self, request: Request, user=Depends(get_current_user)
) -> JSONResponse:
2025-12-18 07:27:37 -05:00
"""Set the lighting state and apply to Cync device."""
try:
2025-12-18 07:30:39 -05:00
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()
2025-12-18 07:27:37 -05:00
logger.info(f"Lighting request: {state}")
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Validate
if not isinstance(state, dict):
2025-12-18 07:30:39 -05:00
raise HTTPException(
status_code=400, detail="State must be a JSON object"
)
2025-12-18 07:27:37 -05:00
power, brightness, rgb = self._parse_state(state)
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Save to Redis (even if device command fails)
self.redis_client.set(self.lighting_key, json.dumps(state))
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Apply to device with retries
await self._apply_state_with_retry(power, brightness, rgb)
2025-12-18 07:30:39 -05:00
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:
2025-12-18 07:27:37 -05:00
logger.error(f"Error setting lighting state: {e}")
raise HTTPException(status_code=500, detail=str(e))
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
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}")
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Brightness
brightness = None
if "brightness" in state:
brightness = state["brightness"]
if not isinstance(brightness, (int, float)) or not (0 <= brightness <= 100):
2025-12-18 07:30:39 -05:00
raise HTTPException(
status_code=400, detail=f"Invalid brightness: {brightness}"
)
2025-12-18 07:27:37 -05:00
brightness = int(brightness)
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Color
rgb = None
color = state.get("color")
2025-12-18 07:30:39 -05:00
if (
color
and isinstance(color, dict)
and all(k in color for k in ("r", "g", "b"))
):
2025-12-18 07:27:37 -05:00
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"])
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
if rgb:
for i, name in enumerate(("red", "green", "blue")):
if not isinstance(rgb[i], int) or not (0 <= rgb[i] <= 255):
2025-12-18 07:30:39 -05:00
raise HTTPException(
status_code=400, detail=f"Invalid {name}: {rgb[i]}"
)
2025-12-18 07:27:37 -05:00
return power, brightness, rgb
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
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
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
for attempt in range(self.MAX_RETRIES):
try:
# Ensure connection (force reconnect on retries)
await self._connect(force=(attempt > 0))
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# Send commands
await self._send_commands(power, brightness, rgb)
return # Success
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
except (AuthFailedError, TwoFactorRequiredError) as e:
last_error = e
self._state.consecutive_failures += 1
self._state.last_error = str(e)
2025-12-18 07:27:37 -05:00
logger.warning(f"Auth error on attempt {attempt + 1}: {e}")
self._clear_cached_token()
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
except TimeoutError as e:
last_error = e
self._state.consecutive_failures += 1
self._state.last_error = str(e)
2025-12-18 07:27:37 -05:00
logger.warning(f"Timeout on attempt {attempt + 1}: {e}")
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
except Exception as e:
last_error = e
self._state.consecutive_failures += 1
self._state.last_error = str(e)
2025-12-18 07:30:39 -05:00
logger.warning(
f"Error on attempt {attempt + 1}: {type(e).__name__}: {e}"
)
2025-12-18 07:27:37 -05:00
# Wait before retry (exponential backoff)
if attempt < self.MAX_RETRIES - 1:
2025-12-18 07:30:39 -05:00
wait_time = 2**attempt
2025-12-18 07:27:37 -05:00
logger.info(f"Retrying in {wait_time}s...")
await asyncio.sleep(wait_time)
2025-12-18 07:30:39 -05:00
2025-12-18 07:27:37 -05:00
# All retries failed
self._update_status_in_redis()
2025-12-18 07:27:37 -05:00
logger.error(f"All {self.MAX_RETRIES} attempts failed")
raise last_error or RuntimeError("Failed to apply lighting state")
# =========================================================================
# Connection Status & 2FA Endpoints
# =========================================================================
async def get_connection_status(self) -> JSONResponse:
"""
Get the current Cync connection status.
2026-02-07 21:26:10 -05:00
Returns status, error info, and timing information.
No authentication required - useful for monitoring.
"""
try:
# Try to get from Redis first (more up-to-date)
cached = self.redis_client.get(self.REDIS_STATUS_KEY)
if cached:
2026-02-07 21:26:10 -05:00
data = json.loads(
cached.decode() if isinstance(cached, bytes) else str(cached)
)
return JSONResponse(content=data)
2026-02-07 21:26:10 -05:00
# Fall back to current state
2026-02-07 21:26:10 -05:00
return JSONResponse(
content={
"status": self._state.status.value,
"connected_at": self._state.connected_at,
"last_command_at": self._state.last_command_at,
"last_successful_command": self._state.last_successful_command,
"consecutive_failures": self._state.consecutive_failures,
"last_error": self._state.last_error,
"updated_at": time.time(),
}
)
except Exception as e:
logger.error(f"Error getting connection status: {e}")
return JSONResponse(
2026-02-07 21:26:10 -05:00
status_code=500, content={"error": str(e), "status": "unknown"}
)
async def submit_2fa_code(self, request: Request) -> JSONResponse:
"""
Submit a 2FA code for Cync authentication.
2026-02-07 21:26:10 -05:00
The code will be stored in Redis and picked up by the polling task.
No authentication required since 2FA is needed to set up the connection.
2026-02-07 21:26:10 -05:00
Request body: {"code": "123456"}
"""
try:
body = await request.json()
code = body.get("code", "").strip()
2026-02-07 21:26:10 -05:00
if not code:
2026-02-07 21:26:10 -05:00
raise HTTPException(
status_code=400, detail="Missing 'code' in request body"
)
if not code.isdigit() or len(code) != 6:
raise HTTPException(status_code=400, detail="Code must be 6 digits")
2026-02-07 21:26:10 -05:00
# Store in Redis for the polling task to pick up
self.redis_client.set(self.REDIS_2FA_KEY, code, ex=self.TWO_FA_TIMEOUT)
2026-02-07 21:26:10 -05:00
logger.info("2FA code submitted via API")
2026-02-07 21:26:10 -05:00
return JSONResponse(
content={
"message": "2FA code submitted successfully",
"status": self._state.status.value,
"note": "The code will be used on the next authentication attempt",
}
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error submitting 2FA code: {e}")
raise HTTPException(status_code=500, detail=str(e))
async def force_reconnect(self, user=Depends(get_current_user)) -> JSONResponse:
"""
Force a reconnection to the Cync service.
2026-02-07 21:26:10 -05:00
Requires admin or lighting role.
"""
2026-02-07 21:26:10 -05:00
if "lighting" not in user.get("roles", []) and "admin" not in user.get(
"roles", []
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
2026-02-07 21:26:10 -05:00
try:
logger.info("Force reconnect requested via API")
self._state.status = ConnectionStatus.CONNECTING
self._update_status_in_redis()
2026-02-07 21:26:10 -05:00
await self._connect(force=True)
2026-02-07 21:26:10 -05:00
return JSONResponse(
content={
"message": "Reconnection successful",
"status": self._state.status.value,
}
)
except TwoFactorRequiredError:
return JSONResponse(
status_code=202,
content={
"message": "Reconnection requires 2FA",
"status": ConnectionStatus.AWAITING_2FA.value,
2026-02-07 21:26:10 -05:00
"action": "Submit 2FA code via POST /lighting/2fa",
},
)
except Exception as e:
logger.error(f"Force reconnect failed: {e}")
raise HTTPException(status_code=500, detail=str(e))