formatting

This commit is contained in:
2026-02-07 21:26:10 -05:00
parent 435fcc3b2e
commit 9d16c96490
4 changed files with 117 additions and 98 deletions

View File

@@ -41,6 +41,7 @@ logger = logging.getLogger(__name__)
class ConnectionStatus(Enum):
"""Connection status enum for better tracking."""
DISCONNECTED = "disconnected"
CONNECTING = "connecting"
CONNECTED = "connected"
@@ -70,7 +71,7 @@ class Lighting:
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"
@@ -222,7 +223,9 @@ class Lighting:
"last_error": self._state.last_error,
"updated_at": time.time(),
}
self.redis_client.set(self.REDIS_STATUS_KEY, json.dumps(status_data), ex=300)
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}")
@@ -442,7 +445,7 @@ class Lighting:
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)
@@ -458,9 +461,11 @@ class Lighting:
# 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._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}'"
@@ -469,28 +474,28 @@ class Lighting:
# 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")
@@ -500,16 +505,16 @@ class Lighting:
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
@@ -520,21 +525,21 @@ class Lighting:
"""Complete the 2FA login process with the provided code."""
if not code:
raise ValueError("Empty 2FA code provided")
logger.info("Completing 2FA login...")
try:
assert self._state.auth is not None, "Auth not initialized"
self._state.user = await self._state.auth.login(two_factor_code=code)
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)
logger.info("Cync 2FA login successful")
except TwoFactorRequiredError:
# Code was invalid, still needs 2FA
@@ -606,7 +611,7 @@ class Lighting:
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
@@ -615,7 +620,7 @@ class Lighting:
while True:
try:
await asyncio.sleep(self.HEALTH_CHECK_INTERVAL)
# Skip health checks if awaiting 2FA
if self._state.status == ConnectionStatus.AWAITING_2FA:
continue
@@ -643,7 +648,7 @@ class Lighting:
logger.warning(f"Health monitor triggering reconnection: {reason}")
self._state.status = ConnectionStatus.CONNECTING
self._update_status_in_redis()
try:
await self._connect(force=True)
logger.info("Health monitor reconnection successful")
@@ -894,7 +899,7 @@ class Lighting:
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.
"""
@@ -902,56 +907,63 @@ class Lighting:
# 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))
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(),
})
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"}
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")
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"
})
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:
@@ -961,31 +973,35 @@ class Lighting:
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", []):
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,
})
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"
}
"action": "Submit 2FA code via POST /lighting/2fa",
},
)
except Exception as e:
logger.error(f"Force reconnect failed: {e}")