Files
api/endpoints/lighting.py

993 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."""
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.
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(),
}
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.
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
self._state.last_error = "2FA code required - check email and submit via API or Redis"
self._update_status_in_redis()
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())
# 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()
while time.time() - start_time < self.TWO_FA_TIMEOUT:
try:
# Check Redis for 2FA code
code = self.redis_client.get(self.REDIS_2FA_KEY)
if code:
code_str = code.decode() if isinstance(code, bytes) else str(code)
code_str = code_str.strip()
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)
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
await asyncio.sleep(self.TWO_FA_POLL_INTERVAL)
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)
# 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")
logger.info("Completing 2FA login...")
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)
# Now complete the connection
self._state.status = ConnectionStatus.CONNECTING
self._update_status_in_redis()
# Reconnect with the new token
await self._connect(force=True)
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.
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)
# 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()
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.
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:
data = json.loads(cached.decode() if isinstance(cached, bytes) else str(cached))
return JSONResponse(content=data)
# Fall back to current state
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(
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.
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.
Request body: {"code": "123456"}
"""
try:
body = await request.json()
code = body.get("code", "").strip()
if not code:
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")
# Store in Redis for the polling task to pick up
self.redis_client.set(self.REDIS_2FA_KEY, code, ex=self.TWO_FA_TIMEOUT)
logger.info("2FA code submitted via API")
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.
Requires admin or lighting role.
"""
if "lighting" not in user.get("roles", []) and "admin" not in user.get("roles", []):
raise HTTPException(status_code=403, detail="Insufficient permissions")
try:
logger.info("Force reconnect requested via API")
self._state.status = ConnectionStatus.CONNECTING
self._update_status_in_redis()
await self._connect(force=True)
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,
"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))