diff --git a/backends/advanced-backend/Docs/auth.md b/backends/advanced-backend/Docs/auth.md index 7cb9e3db..b3494067 100644 --- a/backends/advanced-backend/Docs/auth.md +++ b/backends/advanced-backend/Docs/auth.md @@ -2,7 +2,7 @@ ## Overview -Friend-Lite uses a comprehensive authentication system built on `fastapi-users` with support for multiple authentication methods including JWT tokens, cookies, and Google OAuth. The system provides secure user management with proper data isolation and role-based access control. +Friend-Lite uses a comprehensive authentication system built on `fastapi-users` with support for multiple authentication methods including JWT tokens, cookies, and Google OAuth. The system provides secure user management with proper data isolation and role-based access control using MongoDB ObjectIds for user identification. ## Architecture Components @@ -10,9 +10,6 @@ Friend-Lite uses a comprehensive authentication system built on `fastapi-users` ```python class User(BeanieBaseUser, Document): - # Primary identifier - 6-character alphanumeric - user_id: str = Field(default_factory=generate_user_id) - # Standard fastapi-users fields email: str hashed_password: str @@ -24,33 +21,38 @@ class User(BeanieBaseUser, Document): display_name: Optional[str] = None profile_picture: Optional[str] = None oauth_accounts: list[OAuthAccount] = [] + + @property + def user_id(self) -> str: + """Return string representation of MongoDB ObjectId for backward compatibility.""" + return str(self.id) ``` **Key Features:** -- **Dual Identity**: Users have both `email` and `user_id` for authentication -- **user_id**: 6-character lowercase alphanumeric identifier (e.g., `abc123`) -- **Email**: Standard email address for authentication +- **Email-based Authentication**: Users authenticate using email addresses +- **MongoDB ObjectId**: Uses MongoDB's native ObjectId as unique identifier - **MongoDB Integration**: Uses Beanie ODM for document storage - **OAuth Support**: Integrated Google OAuth account linking +- **Backward Compatibility**: user_id property provides ObjectId as string ### 2. Authentication Manager (`auth.py`) ```python class UserManager(BaseUserManager[User, PydanticObjectId]): async def authenticate(self, credentials: dict) -> Optional[User]: - """Authenticate with either email or user_id""" + """Authenticate with email+password""" username = credentials.get("username") - # Supports both email and user_id authentication + # Email-based authentication only - async def get_by_email_or_user_id(self, identifier: str) -> Optional[User]: - """Get user by either email or 6-character user_id""" + async def get_by_email(self, email: str) -> Optional[User]: + """Get user by email address""" ``` **Key Features:** -- **Flexible Authentication**: Login with either email or user_id -- **Auto-detection**: Automatically detects if identifier is user_id or email +- **Email Authentication**: Login with email address only - **Password Management**: Secure password hashing and verification -- **User Creation**: Auto-generates unique user_id if not provided +- **Standard fastapi-users**: Uses standard user creation without custom IDs +- **MongoDB ObjectId**: Relies on MongoDB's native unique ID generation ### 3. Authentication Backends @@ -78,7 +80,7 @@ class UserManager(BaseUserManager[User, PydanticObjectId]): **Admin-Only Registration:** ```bash -# Create user with auto-generated user_id +# Create user with auto-generated MongoDB ObjectId curl -X POST "http://localhost:8000/api/create_user" \ -H "Authorization: Bearer $ADMIN_TOKEN" \ -H "Content-Type: application/json" \ @@ -88,21 +90,7 @@ curl -X POST "http://localhost:8000/api/create_user" \ "display_name": "John Doe" }' -# Response: {"user_id": "abc123", "email": "user@example.com", ...} -``` - -**User ID Specification:** -```bash -# Create user with specific user_id -curl -X POST "http://localhost:8000/api/create_user" \ - -H "Authorization: Bearer $ADMIN_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "email": "user@example.com", - "password": "userpass", - "user_id": "user01", - "display_name": "John Doe" - }' +# Response: {"id": "507f1f77bcf86cd799439011", "email": "user@example.com", ...} ``` ### 2. Authentication Methods @@ -114,13 +102,6 @@ curl -X POST "http://localhost:8000/auth/jwt/login" \ -d "username=user@example.com&password=userpass" ``` -#### User ID-based Login -```bash -curl -X POST "http://localhost:8000/auth/jwt/login" \ - -H "Content-Type: application/x-www-form-urlencoded" \ - -d "username=abc123&password=userpass" -``` - #### Google OAuth Login ```bash # Redirect to Google OAuth @@ -142,22 +123,23 @@ const ws = new WebSocket('ws://localhost:8000/ws_pcm?device_name=phone'); ## Client ID Management -### Format: `user_id-device_name` +### Format: `user_id_suffix-device_name` The system automatically generates client IDs by combining: -- **user_id**: 6-character user identifier +- **user_id_suffix**: Last 6 characters of MongoDB ObjectId - **device_name**: Sanitized device identifier **Examples:** -- `abc123-phone` (user: abc123, device: phone) -- `admin-desktop` (user: admin, device: desktop) -- `user01-havpe` (user: user01, device: havpe) +- `a39011-phone` (user ObjectId ending in a39011, device: phone) +- `cd7994-desktop` (user ObjectId ending in cd7994, device: desktop) +- `f86cd7-havpe` (user ObjectId ending in f86cd7, device: havpe) ### Benefits: - **User Association**: Clear mapping between clients and users - **Device Tracking**: Multiple devices per user - **Data Isolation**: Each user only accesses their own data - **Audit Trail**: Track activity by user and device +- **Unique IDs**: MongoDB ObjectId ensures global uniqueness ## Security Features @@ -172,7 +154,7 @@ The system automatically generates client IDs by combining: - **Secure Transport**: HTTPS recommended for production ### 3. Data Isolation -- **User Scoping**: All data scoped to user_id +- **User Scoping**: All data scoped to user's MongoDB ObjectId - **Client Filtering**: Users only see their own clients - **Admin Override**: Superusers can access all data @@ -236,12 +218,11 @@ GOOGLE_CLIENT_SECRET=your-google-client-secret ### 1. Client Implementation ```python -# Prefer user_id for programmatic access -AUTH_USERNAME = "abc123" # 6-character user_id +# Use email for authentication +AUTH_USERNAME = "user@example.com" # Email address AUTH_PASSWORD = "secure_password" -# Use single AUTH_USERNAME variable -# System auto-detects if it's email or user_id +# Use single AUTH_USERNAME variable for email authentication ``` ### 2. Token Management @@ -280,9 +261,9 @@ ADMIN_EMAIL=admin@yourdomain.com # Check credentials curl -X POST "http://localhost:8000/auth/jwt/login" \ -H "Content-Type: application/x-www-form-urlencoded" \ - -d "username=test&password=test" + -d "username=user@example.com&password=test" -# Verify user exists +# Verify user exists by email # Check password is correct # Ensure user is active ``` @@ -317,35 +298,31 @@ curl -H "Authorization: Bearer $TOKEN" http://localhost:8000/api/users/me ## Migration Guide -### From Basic Auth to fastapi-users +### From Custom user_id to Email-Only Authentication 1. **Update Environment Variables** ```bash # Old - AUTH_EMAIL=user@example.com - AUTH_USER_ID=abc123 + AUTH_USERNAME=abc123 # Custom user_id # New - AUTH_USERNAME=abc123 # Can be email or user_id + AUTH_USERNAME=user@example.com # Email address only ``` 2. **Update Client Code** ```python # Old - username = AUTH_USER_ID if AUTH_USER_ID else AUTH_EMAIL + username = AUTH_USERNAME # Could be email or user_id # New - username = AUTH_USERNAME + username = AUTH_USERNAME # Email address only ``` 3. **Test Authentication** ```bash - # Verify both email and user_id work + # Only email authentication works now curl -X POST "http://localhost:8000/auth/jwt/login" \ -d "username=user@example.com&password=pass" - - curl -X POST "http://localhost:8000/auth/jwt/login" \ - -d "username=abc123&password=pass" ``` ## Advanced Features @@ -373,9 +350,9 @@ async def get_all_data(user: User = Depends(current_superuser)): ### 3. Multi-Device Support ```python # Single user, multiple devices -# Client IDs: user123-phone, user123-tablet, user123-desktop +# Client IDs: a39011-phone, cd7994-tablet, f86cd7-desktop # Separate conversation streams per device # Unified user dashboard ``` -This authentication system provides enterprise-grade security with developer-friendly APIs, supporting both simple email/password authentication and modern OAuth flows while maintaining proper data isolation and user management capabilities. \ No newline at end of file +This authentication system provides enterprise-grade security with developer-friendly APIs, supporting email/password authentication and modern OAuth flows while maintaining proper data isolation and user management capabilities using MongoDB's robust ObjectId system. \ No newline at end of file diff --git a/backends/advanced-backend/src/auth.py b/backends/advanced-backend/src/auth.py index bdeedfe4..619640d4 100644 --- a/backends/advanced-backend/src/auth.py +++ b/backends/advanced-backend/src/auth.py @@ -17,7 +17,7 @@ import re from httpx_oauth.clients.google import GoogleOAuth2 import logging -from users import User, UserCreate, get_user_db, get_user_by_user_id, generate_user_id +from users import User, UserCreate, get_user_db from dotenv import load_dotenv logger = logging.getLogger(__name__) @@ -76,48 +76,25 @@ def parse_id(self, value: str) -> PydanticObjectId: return value return PydanticObjectId(value) - async def get_by_email_or_user_id(self, identifier: str) -> Optional[User]: + async def get_by_email(self, email: str) -> Optional[User]: """ - Get user by either email or user_id. + Get user by email address. Args: - identifier: Either email address or user_id + email: Email address Returns: User if found, None otherwise """ - # Check if it looks like an email (contains @ symbol) - if '@' in identifier: - return await self.user_db.get_by_email(identifier) - else: - # Try to find by user_id first (for properly formatted user_ids) - user = await get_user_by_user_id(identifier) - if user: - return user - - # If not found, try to find by user_id field directly using the existing database connection - # This handles cases where user_id might not follow the 6-character rule (legacy users) - try: - # Use the existing database connection from the user_db - collection = self.user_db.collection - user_doc = await collection.find_one({"user_id": identifier}) - if user_doc: - # Convert to User object - user = User.parse_obj(user_doc) - return user - - except Exception as e: - logger.warning(f"Failed to search for user by user_id field: {e}") - - return None + return await self.user_db.get_by_email(email) async def authenticate(self, credentials) -> Optional[User]: """ - Authenticate user with either email+password or user_id+password. + Authenticate user with email+password. Args: credentials: OAuth2PasswordRequestForm with username and password - 'username' can be either email or user_id + 'username' should be email address Returns: User if authentication successful, None otherwise @@ -133,7 +110,7 @@ async def authenticate(self, credentials) -> Optional[User]: if not username or not password: return None - user = await self.get_by_email_or_user_id(username) + user = await self.get_by_email(username) if not user: return None @@ -153,27 +130,24 @@ async def authenticate(self, credentials) -> Optional[User]: return user async def create(self, user_create: UserCreate, safe: bool = True, request: Optional[Request] = None) -> User: - """Create user with auto-generated user_id if not provided.""" - # Ensure user_id is set and unique - if not user_create.user_id: - # Generate unique user_id - max_attempts = 10 - for _ in range(max_attempts): - candidate_user_id = generate_user_id() - existing_user = await get_user_by_user_id(candidate_user_id) - if not existing_user: - user_create.user_id = candidate_user_id - break - else: - raise ValueError("Could not generate unique user_id after multiple attempts") - else: - # Validate provided user_id is unique - existing_user = await get_user_by_user_id(user_create.user_id) - if existing_user: - raise ValueError(f"User ID '{user_create.user_id}' already exists") + """Create user using standard fastapi-users approach with proper superuser handling.""" + # Call parent create method - MongoDB ObjectId will be auto-generated + user = await super().create(user_create, safe=safe, request=request) + + # Update user with superuser and verified status if needed + # This is required because the base implementation may not preserve these fields + update_needed = False + if user_create.is_superuser != user.is_superuser: + user.is_superuser = user_create.is_superuser + update_needed = True + if user_create.is_verified != user.is_verified: + user.is_verified = user_create.is_verified + update_needed = True - # Call parent create method - return await super().create(user_create, safe=safe, request=request) + if update_needed: + await user.save() + + return user async def on_after_register(self, user: User, request: Optional[Request] = None): """Called after a user registers.""" @@ -267,21 +241,18 @@ async def create_admin_user_if_needed(): user_db_gen = get_user_db() user_db = await user_db_gen.__anext__() - # Check if admin user already exists (check both email and user_id) - existing_admin_email = await user_db.get_by_email(ADMIN_EMAIL) - existing_admin_user_id = await get_user_by_user_id(ADMIN_USERNAME) + # Check if admin user already exists by email + existing_admin = await user_db.get_by_email(ADMIN_EMAIL) - if existing_admin_email or existing_admin_user_id: - existing_user = existing_admin_email or existing_admin_user_id - print(f"āœ… Admin user already exists: {existing_user.user_id} ({existing_user.email})") + if existing_admin: + print(f"āœ… Admin user already exists: {existing_admin.user_id} ({existing_admin.email})") return - # Create admin user with specific user_id + # Create admin user user_manager_gen = get_user_manager(user_db) user_manager = await user_manager_gen.__anext__() admin_create = UserCreate( - user_id="admin1", # Use a proper 6-character user_id email=ADMIN_EMAIL, password=ADMIN_PASSWORD, is_superuser=True, diff --git a/backends/advanced-backend/src/main.py b/backends/advanced-backend/src/main.py index 06d4b523..cbfc7287 100644 --- a/backends/advanced-backend/src/main.py +++ b/backends/advanced-backend/src/main.py @@ -1482,8 +1482,8 @@ async def ws_endpoint( await ws.accept() - # Generate proper client_id using user_id and device_name - client_id = generate_client_id(user.user_id, device_name) + # Generate proper client_id using user and device_name + client_id = generate_client_id(user, device_name) audio_logger.info(f"šŸ”Œ WebSocket connection accepted - User: {user.user_id} ({user.email}), Client: {client_id}") decoder = OmiOpusDecoder() @@ -1555,8 +1555,8 @@ async def ws_endpoint_pcm( await ws.accept() - # Generate proper client_id using user_id and device_name - client_id = generate_client_id(user.user_id, device_name) + # Generate proper client_id using user and device_name + client_id = generate_client_id(user, device_name) audio_logger.info( f"šŸ”Œ PCM WebSocket connection accepted - User: {user.user_id} ({user.email}), Client: {client_id}" ) diff --git a/backends/advanced-backend/src/users.py b/backends/advanced-backend/src/users.py index c9e7d338..3cc8c6fb 100644 --- a/backends/advanced-backend/src/users.py +++ b/backends/advanced-backend/src/users.py @@ -4,15 +4,10 @@ import string from typing import Optional -from beanie import Document, PydanticObjectId, Indexed +from beanie import Document, PydanticObjectId from fastapi_users.db import BaseOAuthAccount, BeanieBaseUser, BeanieUserDatabase from fastapi_users.schemas import BaseUser, BaseUserCreate, BaseUserUpdate -from pydantic import Field, validator - - -def generate_user_id() -> str: - """Generate a unique 6-character alphanumeric user ID.""" - return ''.join(random.choices(string.ascii_lowercase + string.digits, k=6)) +from pydantic import Field class OAuthAccount(BaseOAuthAccount): @@ -23,22 +18,15 @@ class OAuthAccount(BaseOAuthAccount): class User(BeanieBaseUser, Document): """User model extending fastapi-users BeanieBaseUser with custom fields.""" - # Primary identifier - 6-character alphanumeric - user_id: Indexed(str, unique=True) = Field(default_factory=generate_user_id) - # Custom fields for your application display_name: Optional[str] = None profile_picture: Optional[str] = None oauth_accounts: list[OAuthAccount] = Field(default_factory=list) - @validator('user_id') - def validate_user_id(cls, v): - """Validate user_id format: 6-character alphanumeric.""" - if not v: - return generate_user_id() - if len(v) != 6 or not v.isalnum() or not v.islower(): - raise ValueError('user_id must be 6 lowercase alphanumeric characters') - return v + @property + def user_id(self) -> str: + """Return string representation of MongoDB ObjectId for backward compatibility.""" + return str(self.id) class Settings: name = "fastapi_users" # Collection name in MongoDB @@ -50,14 +38,12 @@ class Settings: class UserRead(BaseUser[PydanticObjectId]): """Schema for reading user data.""" - user_id: str display_name: Optional[str] = None profile_picture: Optional[str] = None class UserCreate(BaseUserCreate): """Schema for creating user data.""" - user_id: Optional[str] = None # Optional - will be auto-generated if not provided display_name: Optional[str] = None @@ -72,27 +58,33 @@ async def get_user_db(): yield BeanieUserDatabase(User, OAuthAccount) -async def get_user_by_user_id(user_id: str) -> Optional[User]: - """Get user by user_id (for user_id+password authentication).""" - return await User.find_one(User.user_id == user_id) +async def get_user_by_id(user_id: str) -> Optional[User]: + """Get user by MongoDB ObjectId string.""" + try: + return await User.get(PydanticObjectId(user_id)) + except Exception: + return None -def generate_client_id(user_id: str, device_name: Optional[str] = None) -> str: +def generate_client_id(user: User, device_name: Optional[str] = None) -> str: """ - Generate a client_id in the format: user_id-device_suffix + Generate a client_id in the format: user_id_suffix-device_suffix Args: - user_id: The user's 6-character identifier + user: The User object device_name: Optional device name (e.g., 'havpe', 'phone', 'tablet') Returns: - client_id in format: user_id-device_suffix + client_id in format: user_id_suffix-device_suffix """ + # Use last 6 characters of MongoDB ObjectId as user identifier + user_id_suffix = str(user.id)[-6:] + if device_name: # Sanitize device name: lowercase, alphanumeric + hyphens only, max 10 chars sanitized_device = ''.join(c for c in device_name.lower() if c.isalnum() or c == '-')[:10] - return f"{user_id}-{sanitized_device}" + return f"{user_id_suffix}-{sanitized_device}" else: # Generate random 4-character suffix if no device name provided suffix = ''.join(random.choices(string.ascii_lowercase + string.digits, k=4)) - return f"{user_id}-{suffix}" \ No newline at end of file + return f"{user_id_suffix}-{suffix}" \ No newline at end of file diff --git a/extras/havpe-relay/.dockerignore b/extras/havpe-relay/.dockerignore index 3e88997c..cdac220a 100644 --- a/extras/havpe-relay/.dockerignore +++ b/extras/havpe-relay/.dockerignore @@ -1,4 +1,5 @@ * !main.py !pyproject.toml -!uv.lock \ No newline at end of file +!uv.lock +!.env \ No newline at end of file diff --git a/extras/havpe-relay/.env.template b/extras/havpe-relay/.env.template index ed2b1427..2c13fda5 100644 --- a/extras/havpe-relay/.env.template +++ b/extras/havpe-relay/.env.template @@ -1,2 +1,11 @@ -WS_URL="ws://host.docker.internal:8000/ws_pcm" +# Backend Configuration +BACKEND_URL="http://host.docker.internal:8000" +BACKEND_WS_URL="ws://host.docker.internal:8000" + +# Authentication +AUTH_USERNAME= +AUTH_PASSWORD= + +# Device Configuration +DEVICE_NAME=havpe TCP_PORT=8989 \ No newline at end of file diff --git a/extras/havpe-relay/main.py b/extras/havpe-relay/main.py index 1cc072b3..9899385d 100644 --- a/extras/havpe-relay/main.py +++ b/extras/havpe-relay/main.py @@ -6,6 +6,7 @@ - Forwards audio to backend """ +import os import argparse import asyncio import logging @@ -13,6 +14,7 @@ from typing import Optional import numpy as np +import requests from wyoming.audio import AudioChunk from easy_audio_interfaces import RollingFileSink @@ -25,10 +27,122 @@ SAMP_WIDTH = 2 # bytes (16-bit) RECONNECT_DELAY = 5 # seconds +# Authentication configuration +BACKEND_URL = "http://host.docker.internal:8000" # Backend API URL +BACKEND_WS_URL = "ws://host.docker.internal:8000" # Backend WebSocket URL +AUTH_USERNAME = os.getenv("AUTH_USERNAME") # Can be email or 6-character user_id +AUTH_PASSWORD = os.getenv("AUTH_PASSWORD") +DEVICE_NAME = "havpe" # Device name for client ID generation + logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +async def get_jwt_token(username: str, password: str, backend_url: str) -> Optional[str]: + """ + Get JWT token from backend using username and password. + + Args: + username: User email/username + password: User password + backend_url: Backend API URL + + Returns: + JWT token string or None if authentication failed + """ + try: + logger.info(f"šŸ” Authenticating with backend as: {username}") + + # Run the blocking request in a thread pool to avoid blocking the event loop + loop = asyncio.get_running_loop() + response = await loop.run_in_executor( + None, + lambda: requests.post( + f"{backend_url}/auth/jwt/login", + data={'username': username, 'password': password}, + headers={'Content-Type': 'application/x-www-form-urlencoded'}, + timeout=10 + ) + ) + + if response.status_code == 200: + auth_data = response.json() + token = auth_data.get('access_token') + + if token: + logger.info("āœ… JWT authentication successful") + return token + else: + logger.error("āŒ No access token in response") + return None + else: + error_msg = "Invalid credentials" + try: + error_data = response.json() + error_msg = error_data.get('detail', error_msg) + except: + pass + logger.error(f"āŒ Authentication failed: {error_msg}") + return None + + except requests.exceptions.Timeout: + logger.error("āŒ Authentication request timed out") + return None + except requests.exceptions.RequestException as e: + logger.error(f"āŒ Authentication request failed: {e}") + return None + except Exception as e: + logger.error(f"āŒ Unexpected authentication error: {e}") + return None + + +def create_authenticated_websocket_uri(base_ws_url: str, client_id: str, jwt_token: str) -> str: + """ + Create WebSocket URI with JWT authentication. + + Args: + base_ws_url: Base WebSocket URL (e.g., "ws://localhost:8000") + client_id: Client ID for the connection (not used in URL anymore) + jwt_token: JWT token for authentication + + Returns: + Authenticated WebSocket URI + """ + return f"{base_ws_url}/ws_pcm?token={jwt_token}&device_name={DEVICE_NAME}" + + +async def get_authenticated_socket_client( + backend_url: str, + backend_ws_url: str, + username: str, + password: str +) -> Optional[SocketClient]: + """ + Create an authenticated WebSocket client for the backend. + + Args: + backend_url: Backend API URL for authentication + backend_ws_url: Backend WebSocket URL + username: Authentication username (email or user_id) + password: Authentication password + + Returns: + Authenticated SocketClient or None if authentication failed + """ + # Get JWT token + jwt_token = await get_jwt_token(username, password, backend_url) + if not jwt_token: + logger.error("Failed to get JWT token, cannot create authenticated WebSocket client") + return None + + # Create authenticated WebSocket URI (client_id will be generated by backend) + ws_uri = create_authenticated_websocket_uri(backend_ws_url, "", jwt_token) + logger.info(f"šŸ”— Creating WebSocket connection to: {backend_ws_url}/ws_pcm?token={jwt_token[:20]}...&device_name={DEVICE_NAME}") + + # Create socket client + return SocketClient(uri=ws_uri) + + class ESP32TCPServer(TCPServer): """ A TCP server for ESP32 devices streaming 32-bit stereo audio. @@ -110,33 +224,83 @@ async def read(self) -> Optional[AudioChunk]: async def ensure_socket_connection(socket_client: SocketClient) -> bool: """Ensure socket client is connected, with retry logic.""" - while True: + max_retries = 3 + for attempt in range(max_retries): try: - logger.info("Attempting to connect to socket...") + logger.info(f"Attempting to connect to authenticated WebSocket (attempt {attempt + 1}/{max_retries})...") await socket_client.open() - logger.info("Socket connection established") + logger.info("āœ… Authenticated WebSocket connection established") return True except Exception as e: - logger.error(f"Failed to connect to socket: {e}") - logger.info(f"Retrying in {RECONNECT_DELAY} seconds...") - await asyncio.sleep(RECONNECT_DELAY) + logger.error(f"āŒ Failed to connect to WebSocket: {e}") + if attempt < max_retries - 1: + logger.info(f"Retrying in {RECONNECT_DELAY} seconds...") + await asyncio.sleep(RECONNECT_DELAY) + else: + logger.error("āŒ All WebSocket connection attempts failed") + return False + return False -async def send_with_retry(socket_client: SocketClient, chunk: AudioChunk) -> bool: - """Send chunk with retry logic.""" - max_retries = 3 +async def create_and_connect_socket_client() -> Optional[SocketClient]: + """Create a new authenticated socket client and connect it.""" + if not AUTH_USERNAME: + logger.error("āŒ AUTH_USERNAME is required for authentication") + return None + + socket_client = await get_authenticated_socket_client( + backend_url=BACKEND_URL, + backend_ws_url=BACKEND_WS_URL, + username=str(AUTH_USERNAME), + password=str(AUTH_PASSWORD) + ) + + if not socket_client: + logger.error("āŒ Failed to create authenticated socket client") + return None + + # Try to connect + if await ensure_socket_connection(socket_client): + return socket_client + else: + logger.error("āŒ Failed to establish connection with new socket client") + return None + + +async def send_with_retry(socket_client: SocketClient, chunk: AudioChunk) -> tuple[bool, bool]: + """ + Send chunk with retry logic. + + Returns: + Tuple of (success, needs_reconnect) + - success: True if chunk was sent successfully + - needs_reconnect: True if we should create a new authenticated client + """ + max_retries = 2 for attempt in range(max_retries): try: await socket_client.write(chunk) - return True + return True, False # Success, no reconnect needed except Exception as e: - logger.warning(f"Failed to send chunk (attempt {attempt + 1}): {e}") + error_str = str(e).lower() + + # Check for authentication-related errors + if any(auth_err in error_str for auth_err in ['401', 'unauthorized', 'forbidden', 'authentication']): + logger.warning(f"āŒ Authentication error detected: {e}") + return False, True # Failed, needs new auth token + + logger.warning(f"āš ļø Failed to send chunk (attempt {attempt + 1}): {e}") if attempt < max_retries - 1: - await ensure_socket_connection(socket_client) + if await ensure_socket_connection(socket_client): + continue # Try again with reconnected client + else: + logger.warning("šŸ”„ Connection failed, will need fresh authentication") + return False, True # Connection failed, try new auth else: - logger.error("Failed to send chunk after all retries") - return False - return False + logger.error("āŒ Failed to send chunk after all retries") + return False, True # Failed after retries, try new auth + + return False, True @@ -148,23 +312,21 @@ async def process_esp32_audio( asr_client: Optional[AsyncClient] = None, file_sink: Optional[RollingFileSink] = None ): - """Process audio chunks from ESP32 server, save to file sink and send to ASR client.""" + """Process audio chunks from ESP32 server, save to file sink and send to authenticated backend.""" if (not socket_client) and (not asr_client): raise ValueError("Either socket_client or asr_client must be provided") - - if socket_client: - await ensure_socket_connection(socket_client) try: - logger.info("Starting to process ESP32 audio for ASR and file saving...") + logger.info("šŸŽµ Starting to process ESP32 audio with authentication...") chunk_count = 0 failed_sends = 0 + auth_failures = 0 async for chunk in esp32_server: chunk_count += 1 - if chunk_count % 10 == 1: # Log every 10th chunk + if chunk_count % 100 == 1: # Log every 100th chunk to reduce spam logger.debug( - f"Received chunk {chunk_count} from ESP32, size: {len(chunk.audio)} bytes" + f"šŸ“¦ Processed {chunk_count} chunks from ESP32, current chunk size: {len(chunk.audio)} bytes" ) # Write to rolling file sink @@ -172,32 +334,56 @@ async def process_esp32_audio( try: await file_sink.write(chunk) except Exception as e: - logger.warning(f"Failed to write to file sink: {e}") + logger.warning(f"āš ļø Failed to write to file sink: {e}") - # Send to backend + # Send to authenticated backend if socket_client: - success = await send_with_retry(socket_client, chunk) - if not success: - failed_sends += 1 - if failed_sends > 10: - logger.error("Too many failed sends, reconnecting...") - await ensure_socket_connection(socket_client) - failed_sends = 0 - else: + success, needs_reconnect = await send_with_retry(socket_client, chunk) + + if success: failed_sends = 0 + auth_failures = 0 + elif needs_reconnect: + auth_failures += 1 + logger.warning(f"šŸ”„ Need to re-authenticate (failure #{auth_failures})") + + # Create new authenticated client + new_socket_client = await create_and_connect_socket_client() + if new_socket_client: + socket_client = new_socket_client + logger.info("āœ… Successfully re-authenticated and reconnected") + auth_failures = 0 + + # Retry sending this chunk with new client + retry_success, _ = await send_with_retry(socket_client, chunk) + if retry_success: + logger.debug("āœ… Chunk sent successfully after re-authentication") + else: + logger.warning("āš ļø Failed to send chunk even after re-authentication") + else: + logger.error("āŒ Failed to re-authenticate, will retry on next chunk") + if auth_failures > 5: + logger.error("āŒ Too many authentication failures, stopping audio processor") + break + else: + failed_sends += 1 + if failed_sends > 20: + logger.error("āŒ Too many consecutive send failures, stopping audio processor") + break - # Send to ASR + # Send to ASR (if implemented) # await asr_client.write_event(chunk.event()) + except asyncio.CancelledError: - logger.info("ESP32 audio processor cancelled") + logger.info("šŸ›‘ ESP32 audio processor cancelled") raise except Exception as e: - logger.error(f"Error in ESP32 audio processor: {e}") + logger.error(f"āŒ Error in ESP32 audio processor: {e}") raise async def run_audio_processor(args, esp32_file_sink): - """Run the audio processor with reconnect logic.""" + """Run the audio processor with authentication and reconnect logic.""" while True: try: # Create ESP32 TCP server with automatic I²S swap detection @@ -209,12 +395,23 @@ async def run_audio_processor(args, esp32_file_sink): sample_width=4, ) - socket_client = SocketClient(uri="ws://host.docker.internal:8000/ws_pcm?user_id=havpe") + # Create authenticated WebSocket client for sending audio to backend + logger.info(f"šŸ” Setting up authenticated connection to backend...") + logger.info(f"šŸ“” Backend API: {BACKEND_URL}") + logger.info(f"🌐 Backend WebSocket: {BACKEND_WS_URL}") + logger.info(f"šŸ‘¤ Auth Username: {AUTH_USERNAME}") + logger.info(f"šŸ”§ Device: {DEVICE_NAME}") + + socket_client = await create_and_connect_socket_client() + if not socket_client: + logger.error("āŒ Failed to create authenticated WebSocket client, retrying...") + await asyncio.sleep(RECONNECT_DELAY) + continue # Start ESP32 server async with esp32_server: - logger.info(f"ESP32 server listening on {args.host}:{args.port}") - logger.info("Starting audio recording and processing...") + logger.info(f"šŸŽ§ ESP32 server listening on {args.host}:{args.port}") + logger.info("šŸŽµ Starting authenticated audio recording and processing...") # Start audio processing task await process_esp32_audio( @@ -225,16 +422,19 @@ async def run_audio_processor(args, esp32_file_sink): ) except KeyboardInterrupt: - logger.info("Interrupted – stopping") + logger.info("šŸ›‘ Interrupted – stopping") break except Exception as e: - logger.error(f"Audio processor error: {e}") - logger.info(f"Restarting in {RECONNECT_DELAY} seconds...") + logger.error(f"āŒ Audio processor error: {e}") + logger.info(f"šŸ”„ Restarting in {RECONNECT_DELAY} seconds...") await asyncio.sleep(RECONNECT_DELAY) async def main(): - parser = argparse.ArgumentParser(description="TCP WAV recorder with ESP32 I²S swap detection") + # Override global constants with command line arguments + global BACKEND_URL, BACKEND_WS_URL, AUTH_USERNAME, AUTH_PASSWORD + + parser = argparse.ArgumentParser(description="TCP WAV recorder with ESP32 I²S swap detection and backend authentication") parser.add_argument( "--port", type=int, @@ -253,12 +453,72 @@ async def main(): default=5, help="Duration of each audio segment in seconds (default 5)", ) + parser.add_argument( + "--username", + type=str, + default=AUTH_USERNAME, + help="Backend authentication username (email or 6-character user_id)", + ) + parser.add_argument( + "--password", + type=str, + default=AUTH_PASSWORD, + help="Backend authentication password", + ) + parser.add_argument( + "--backend-url", + type=str, + default=BACKEND_URL, + help=f"Backend API URL (default: {BACKEND_URL})", + ) + parser.add_argument( + "--backend-ws-url", + type=str, + default=BACKEND_WS_URL, + help=f"Backend WebSocket URL (default: {BACKEND_WS_URL})", + ) parser.add_argument("-v", "--verbose", action="count", default=0, help="-v: INFO, -vv: DEBUG") parser.add_argument("--debug-audio", action="store_true", help="Debug audio recording") args = parser.parse_args() + + BACKEND_URL = args.backend_url + BACKEND_WS_URL = args.backend_ws_url + AUTH_USERNAME = args.username + AUTH_PASSWORD = args.password loglevel = logging.WARNING - (10 * min(args.verbose, 2)) logging.basicConfig(format="%(asctime)s %(levelname)s %(message)s", level=loglevel) + + # Print startup banner with authentication info + logger.info("šŸŽµ ========================================") + logger.info("šŸŽµ Friend-Lite HAVPE Relay with Authentication") + logger.info("šŸŽµ ========================================") + logger.info(f"šŸŽ§ ESP32 Server: {args.host}:{args.port}") + logger.info(f"šŸ“” Backend API: {BACKEND_URL}") + logger.info(f"🌐 Backend WebSocket: {BACKEND_WS_URL}") + logger.info(f"šŸ‘¤ Auth Username: {AUTH_USERNAME}") + logger.info(f"šŸ”§ Device: {DEVICE_NAME}") + logger.info(f"šŸ”§ Debug Audio: {'Enabled' if args.debug_audio else 'Disabled'}") + logger.info("šŸŽµ ========================================") + + # Test authentication on startup + logger.info("šŸ” Testing backend authentication...") + try: + if not AUTH_USERNAME or not AUTH_PASSWORD: + logger.error("āŒ Missing authentication credentials") + logger.error("šŸ’” Set AUTH_USERNAME and AUTH_PASSWORD environment variables or use command line arguments") + return + test_token = await get_jwt_token(AUTH_USERNAME, AUTH_PASSWORD, BACKEND_URL) + if test_token: + logger.info("āœ… Authentication test successful! Ready to start.") + else: + logger.error("āŒ Authentication test failed! Please check credentials.") + logger.error("šŸ’” Update AUTH_USERNAME and AUTH_PASSWORD constants or use command line arguments") + return + except Exception as e: + logger.error(f"āŒ Authentication test error: {e}") + logger.error("šŸ’” Make sure the backend is running and accessible") + return # Create recordings directory recordings = pathlib.Path("audio_chunks") diff --git a/extras/havpe-relay/pyproject.toml b/extras/havpe-relay/pyproject.toml index 159ef46f..9dc7a9e8 100644 --- a/extras/havpe-relay/pyproject.toml +++ b/extras/havpe-relay/pyproject.toml @@ -6,6 +6,7 @@ readme = "README.md" requires-python = ">=3.12" dependencies = [ "easy-audio-interfaces>=0.5", + "requests>=2.32.4", "websockets>=15.0.1", ] diff --git a/extras/havpe-relay/uv.lock b/extras/havpe-relay/uv.lock index 7b2901e4..f2432816 100644 --- a/extras/havpe-relay/uv.lock +++ b/extras/havpe-relay/uv.lock @@ -25,6 +25,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/71/54e999902aed72baf26bca0d50781b01838251a462612966e9fc4891eadd/black-25.1.0-py3-none-any.whl", hash = "sha256:95e8176dae143ba9097f351d174fdaf0ccd29efb414b362ae3fd72bf0f710717", size = 207646 }, ] +[[package]] +name = "certifi" +version = "2025.7.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/8a/c729b6b60c66a38f590c4e774decc4b2ec7b0576be8f1aa984a53ffa812a/certifi-2025.7.9.tar.gz", hash = "sha256:c1d2ec05395148ee10cf672ffc28cd37ea0ab0d99f9cc74c43e588cbd111b079", size = 160386 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/66/f3/80a3f974c8b535d394ff960a11ac20368e06b736da395b551a49ce950cce/certifi-2025.7.9-py3-none-any.whl", hash = "sha256:d842783a14f8fdd646895ac26f719a061408834473cfc10203f6a575beb15d39", size = 159230 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/33/89c2ced2b67d1c2a61c19c6751aa8902d46ce3dacb23600a283619f5a12d/charset_normalizer-3.4.2.tar.gz", hash = "sha256:5baececa9ecba31eff645232d59845c07aa030f0c81ee70184a90d35099a0e63", size = 126367 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/a4/37f4d6035c89cac7930395a35cc0f1b872e652eaafb76a6075943754f095/charset_normalizer-3.4.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0c29de6a1a95f24b9a1aa7aefd27d2487263f00dfd55a77719b530788f75cff7", size = 199936 }, + { url = "https://files.pythonhosted.org/packages/ee/8a/1a5e33b73e0d9287274f899d967907cd0bf9c343e651755d9307e0dbf2b3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cddf7bd982eaa998934a91f69d182aec997c6c468898efe6679af88283b498d3", size = 143790 }, + { url = "https://files.pythonhosted.org/packages/66/52/59521f1d8e6ab1482164fa21409c5ef44da3e9f653c13ba71becdd98dec3/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcbe676a55d7445b22c10967bceaaf0ee69407fbe0ece4d032b6eb8d4565982a", size = 153924 }, + { url = "https://files.pythonhosted.org/packages/86/2d/fb55fdf41964ec782febbf33cb64be480a6b8f16ded2dbe8db27a405c09f/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d41c4d287cfc69060fa91cae9683eacffad989f1a10811995fa309df656ec214", size = 146626 }, + { url = "https://files.pythonhosted.org/packages/8c/73/6ede2ec59bce19b3edf4209d70004253ec5f4e319f9a2e3f2f15601ed5f7/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4e594135de17ab3866138f496755f302b72157d115086d100c3f19370839dd3a", size = 148567 }, + { url = "https://files.pythonhosted.org/packages/09/14/957d03c6dc343c04904530b6bef4e5efae5ec7d7990a7cbb868e4595ee30/charset_normalizer-3.4.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf713fe9a71ef6fd5adf7a79670135081cd4431c2943864757f0fa3a65b1fafd", size = 150957 }, + { url = "https://files.pythonhosted.org/packages/0d/c8/8174d0e5c10ccebdcb1b53cc959591c4c722a3ad92461a273e86b9f5a302/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a370b3e078e418187da8c3674eddb9d983ec09445c99a3a263c2011993522981", size = 145408 }, + { url = "https://files.pythonhosted.org/packages/58/aa/8904b84bc8084ac19dc52feb4f5952c6df03ffb460a887b42615ee1382e8/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a955b438e62efdf7e0b7b52a64dc5c3396e2634baa62471768a64bc2adb73d5c", size = 153399 }, + { url = "https://files.pythonhosted.org/packages/c2/26/89ee1f0e264d201cb65cf054aca6038c03b1a0c6b4ae998070392a3ce605/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7222ffd5e4de8e57e03ce2cef95a4c43c98fcb72ad86909abdfc2c17d227fc1b", size = 156815 }, + { url = "https://files.pythonhosted.org/packages/fd/07/68e95b4b345bad3dbbd3a8681737b4338ff2c9df29856a6d6d23ac4c73cb/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:bee093bf902e1d8fc0ac143c88902c3dfc8941f7ea1d6a8dd2bcb786d33db03d", size = 154537 }, + { url = "https://files.pythonhosted.org/packages/77/1a/5eefc0ce04affb98af07bc05f3bac9094513c0e23b0562d64af46a06aae4/charset_normalizer-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dedb8adb91d11846ee08bec4c8236c8549ac721c245678282dcb06b221aab59f", size = 149565 }, + { url = "https://files.pythonhosted.org/packages/37/a0/2410e5e6032a174c95e0806b1a6585eb21e12f445ebe239fac441995226a/charset_normalizer-3.4.2-cp312-cp312-win32.whl", hash = "sha256:db4c7bf0e07fc3b7d89ac2a5880a6a8062056801b83ff56d8464b70f65482b6c", size = 98357 }, + { url = "https://files.pythonhosted.org/packages/6c/4f/c02d5c493967af3eda9c771ad4d2bbc8df6f99ddbeb37ceea6e8716a32bc/charset_normalizer-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:5a9979887252a82fefd3d3ed2a8e3b937a7a809f65dcb1e068b090e165bbe99e", size = 105776 }, + { url = "https://files.pythonhosted.org/packages/ea/12/a93df3366ed32db1d907d7593a94f1fe6293903e3e92967bebd6950ed12c/charset_normalizer-3.4.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:926ca93accd5d36ccdabd803392ddc3e03e6d4cd1cf17deff3b989ab8e9dbcf0", size = 199622 }, + { url = "https://files.pythonhosted.org/packages/04/93/bf204e6f344c39d9937d3c13c8cd5bbfc266472e51fc8c07cb7f64fcd2de/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:eba9904b0f38a143592d9fc0e19e2df0fa2e41c3c3745554761c5f6447eedabf", size = 143435 }, + { url = "https://files.pythonhosted.org/packages/22/2a/ea8a2095b0bafa6c5b5a55ffdc2f924455233ee7b91c69b7edfcc9e02284/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fddb7e2c84ac87ac3a947cb4e66d143ca5863ef48e4a5ecb83bd48619e4634e", size = 153653 }, + { url = "https://files.pythonhosted.org/packages/b6/57/1b090ff183d13cef485dfbe272e2fe57622a76694061353c59da52c9a659/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:98f862da73774290f251b9df8d11161b6cf25b599a66baf087c1ffe340e9bfd1", size = 146231 }, + { url = "https://files.pythonhosted.org/packages/e2/28/ffc026b26f441fc67bd21ab7f03b313ab3fe46714a14b516f931abe1a2d8/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c9379d65defcab82d07b2a9dfbfc2e95bc8fe0ebb1b176a3190230a3ef0e07c", size = 148243 }, + { url = "https://files.pythonhosted.org/packages/c0/0f/9abe9bd191629c33e69e47c6ef45ef99773320e9ad8e9cb08b8ab4a8d4cb/charset_normalizer-3.4.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e635b87f01ebc977342e2697d05b56632f5f879a4f15955dfe8cef2448b51691", size = 150442 }, + { url = "https://files.pythonhosted.org/packages/67/7c/a123bbcedca91d5916c056407f89a7f5e8fdfce12ba825d7d6b9954a1a3c/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1c95a1e2902a8b722868587c0e1184ad5c55631de5afc0eb96bc4b0d738092c0", size = 145147 }, + { url = "https://files.pythonhosted.org/packages/ec/fe/1ac556fa4899d967b83e9893788e86b6af4d83e4726511eaaad035e36595/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ef8de666d6179b009dce7bcb2ad4c4a779f113f12caf8dc77f0162c29d20490b", size = 153057 }, + { url = "https://files.pythonhosted.org/packages/2b/ff/acfc0b0a70b19e3e54febdd5301a98b72fa07635e56f24f60502e954c461/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:32fc0341d72e0f73f80acb0a2c94216bd704f4f0bce10aedea38f30502b271ff", size = 156454 }, + { url = "https://files.pythonhosted.org/packages/92/08/95b458ce9c740d0645feb0e96cea1f5ec946ea9c580a94adfe0b617f3573/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:289200a18fa698949d2b39c671c2cc7a24d44096784e76614899a7ccf2574b7b", size = 154174 }, + { url = "https://files.pythonhosted.org/packages/78/be/8392efc43487ac051eee6c36d5fbd63032d78f7728cb37aebcc98191f1ff/charset_normalizer-3.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4a476b06fbcf359ad25d34a057b7219281286ae2477cc5ff5e3f70a246971148", size = 149166 }, + { url = "https://files.pythonhosted.org/packages/44/96/392abd49b094d30b91d9fbda6a69519e95802250b777841cf3bda8fe136c/charset_normalizer-3.4.2-cp313-cp313-win32.whl", hash = "sha256:aaeeb6a479c7667fbe1099af9617c83aaca22182d6cf8c53966491a0f1b7ffb7", size = 98064 }, + { url = "https://files.pythonhosted.org/packages/e9/b0/0200da600134e001d91851ddc797809e2fe0ea72de90e09bec5a2fbdaccb/charset_normalizer-3.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:aa6af9e7d59f9c12b33ae4e9450619cf2488e2bbe9b44030905877f0b2324980", size = 105641 }, + { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626 }, +] + [[package]] name = "click" version = "8.2.1" @@ -48,7 +92,7 @@ wheels = [ [[package]] name = "easy-audio-interfaces" -version = "0.4.0" +version = "0.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "fire" }, @@ -59,9 +103,9 @@ dependencies = [ { name = "websockets" }, { name = "wyoming" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3d/e4/e15a6349080b65ebf784d67299274360997def214ac85deb9e3a01baa239/easy_audio_interfaces-0.4.0.tar.gz", hash = "sha256:be023a2bc4fb7fdf4a658e8271b535583b5bbe774dc1f3d172e6f4c8f7b0672e", size = 31157 } +sdist = { url = "https://files.pythonhosted.org/packages/cc/0a/ab7313eca395daf3e69ca484ac39d555aa17609e7cd4209a489f33408ca8/easy_audio_interfaces-0.6.0.tar.gz", hash = "sha256:70fbf82df690a59aeb0e41799289a9f18ccb77b58978935269b928c617b5568d", size = 35602 } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/92/0d7424bb767731dc7caf306cb9c384854ddf620ba1f6410f786776de5946/easy_audio_interfaces-0.4.0-py3-none-any.whl", hash = "sha256:bb1a98352472959a589dd89171d55c092b38d1de0fd70500d544a2b440ec04b3", size = 36568 }, + { url = "https://files.pythonhosted.org/packages/69/a5/9eaecdbdcdc0d429f129c69e7f6a085578e2a066dffab104ef073d7f53c5/easy_audio_interfaces-0.6.0-py3-none-any.whl", hash = "sha256:146888390f77e9b00dad4d8812e0cdbb1d48c31bb201eb33910408b010bfbd2d", size = 41550 }, ] [[package]] @@ -80,6 +124,7 @@ version = "0.1.0" source = { virtual = "." } dependencies = [ { name = "easy-audio-interfaces" }, + { name = "requests" }, { name = "websockets" }, ] @@ -90,13 +135,23 @@ dev = [ [package.metadata] requires-dist = [ - { name = "easy-audio-interfaces", specifier = ">=0.4" }, + { name = "easy-audio-interfaces", specifier = ">=0.5" }, + { name = "requests", specifier = ">=2.32.4" }, { name = "websockets", specifier = ">=15.0.1" }, ] [package.metadata.requires-dev] dev = [{ name = "black", specifier = ">=25.1.0" }] +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + [[package]] name = "markdown-it-py" version = "3.0.0" @@ -210,6 +265,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, ] +[[package]] +name = "requests" +version = "2.32.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e1/0a/929373653770d8a0d7ea76c37de6e41f11eb07559b103b1c02cafb3f7cf8/requests-2.32.4.tar.gz", hash = "sha256:27d0316682c8a29834d3264820024b62a36942083d52caf2f14c0591336d3422", size = 135258 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7c/e4/56027c4a6b4ae70ca9de302488c5ca95ad4a39e190093d6c1a8ace08341b/requests-2.32.4-py3-none-any.whl", hash = "sha256:27babd3cda2a6d50b30443204ee89830707d396671944c998b5975b031ac2b2c", size = 64847 }, +] + [[package]] name = "rich" version = "13.9.4" @@ -295,6 +365,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4f/bd/de8d508070629b6d84a30d01d57e4a65c69aa7f5abe7560b8fad3b50ea59/termcolor-3.1.0-py3-none-any.whl", hash = "sha256:591dd26b5c2ce03b9e43f391264626557873ce1d379019786f99b0c2bee140aa", size = 7684 }, ] +[[package]] +name = "urllib3" +version = "2.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795 }, +] + [[package]] name = "websockets" version = "15.0.1"