formatting
This commit is contained in:
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user