diff --git a/.example.env b/.example.env index e69de29..7dab029 100644 --- a/.example.env +++ b/.example.env @@ -0,0 +1,12 @@ +# collector +SERIAL_PORT=/dev/ttyACM0 +BAUDRATE=115200 + +# DB +DB_NAME=audio_analyzer +DB_USER=postgres +DB_PASSWORD=postgres +DB_PORT=5432 + +# api +DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/audio_analyzer diff --git a/db/init.sql b/db/init.sql index 8316a2d..775a90d 100644 --- a/db/init.sql +++ b/db/init.sql @@ -6,7 +6,7 @@ CREATE EXTENSION IF NOT EXISTS timescaledb; -- Create audio data table CREATE TABLE IF NOT EXISTS audio_data ( time TIMESTAMPTZ NOT NULL, - rms_db REAL CHECK (rms_db >= -40.0 AND rms_db <= 80.0), + rms_db REAL CHECK (rms_db >= -50.0 AND rms_db <= 0.0), frequency_hz INTEGER CHECK (frequency_hz >= 100 AND frequency_hz <= 8000), is_silence BOOLEAN NOT NULL DEFAULT FALSE ); diff --git a/docker-compose.yml b/docker-compose.yml index 5b2152a..574166f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -64,7 +64,11 @@ services: networks: - audio_network healthcheck: - test: ["CMD-SHELL", "curl --fail http://localhost:8000/health || exit 1"] + test: + [ + "CMD-SHELL", + "curl --fail http://localhost:8000/api/v1/health/live || exit 1", + ] interval: 10s timeout: 5s retries: 5 diff --git a/services/api/Dockerfile b/services/api/Dockerfile new file mode 100644 index 0000000..e2cbe49 --- /dev/null +++ b/services/api/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] diff --git a/services/api/app/api/v1/endpoints/audio.py b/services/api/app/api/v1/endpoints/audio.py new file mode 100644 index 0000000..45c7704 --- /dev/null +++ b/services/api/app/api/v1/endpoints/audio.py @@ -0,0 +1,36 @@ +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import get_db +from app.repositories.audio_repository import AudioRepository +from app.schemas.base import ApiResponse +from app.schemas.audio import AudioPoint +from app.services.audio_service import AudioService + +router = APIRouter() + + +@router.get("/latest", response_model=ApiResponse[list[AudioPoint]]) +async def latest( + limit: int = Query(100, ge=1, le=10000), db: AsyncSession = Depends(get_db) +): + service = AudioService(AudioRepository(db)) + items = await service.latest(limit) + return ApiResponse(data=items, count=len(items)) + + +@router.get("/range", response_model=ApiResponse[list[AudioPoint]]) +async def range_( + time_from: datetime = Query(..., alias="from"), + time_to: datetime = Query(..., alias="to"), + db: AsyncSession = Depends(get_db), +): + if time_from >= time_to: + raise HTTPException( + status_code=400, + detail="'from' timestamp must be earlier than 'to' timestamp", + ) + service = AudioService(AudioRepository(db)) + items = await service.range(time_from, time_to) + return ApiResponse(data=items, count=len(items)) diff --git a/services/api/app/api/v1/endpoints/events.py b/services/api/app/api/v1/endpoints/events.py new file mode 100644 index 0000000..71c0f9c --- /dev/null +++ b/services/api/app/api/v1/endpoints/events.py @@ -0,0 +1,35 @@ +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import get_db +from app.repositories.audio_repository import AudioRepository +from app.schemas.base import ApiResponse +from app.schemas.events import LoudEvent +from app.services.events_service import EventsService + +router = APIRouter() + + +@router.get("/loud", response_model=ApiResponse[list[LoudEvent]]) +async def loud_events( + threshold: float = Query( + default=-35.0, ge=-50.0, le=0.0, description="RMS dB threshold" + ), + time_from: Optional[datetime] = Query(None, alias="from"), + time_to: Optional[datetime] = Query(None, alias="to"), + db: AsyncSession = Depends(get_db), +): + if time_from and time_to and time_from >= time_to: + raise HTTPException( + status_code=400, + detail="'from' timestamp must be earlier than 'to' timestamp", + ) + + service = EventsService(AudioRepository(db)) + events = await service.loud_events( + threshold=threshold, time_from=time_from, time_to=time_to + ) + return ApiResponse(data=events, count=len(events)) diff --git a/services/api/app/api/v1/endpoints/export.py b/services/api/app/api/v1/endpoints/export.py new file mode 100644 index 0000000..a3a9393 --- /dev/null +++ b/services/api/app/api/v1/endpoints/export.py @@ -0,0 +1,42 @@ +from datetime import datetime +import csv +import io + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import get_db +from app.repositories.audio_repository import AudioRepository + +router = APIRouter() + + +@router.get("/csv") +async def export_csv( + time_from: datetime = Query(..., alias="from"), + time_to: datetime = Query(..., alias="to"), + db: AsyncSession = Depends(get_db), +): + if time_from >= time_to: + raise HTTPException( + status_code=400, + detail="'from' timestamp must be earlier than 'to' timestamp", + ) + + repo = AudioRepository(db) + rows = await repo.range(time_from, time_to) + + buf = io.StringIO() + w = csv.writer(buf) + w.writerow(["time", "rms_db", "frequency_hz", "is_silence"]) + for r in rows: + w.writerow([r.time.isoformat(), r.rms_db, r.frequency_hz, r.is_silence]) + buf.seek(0) + + filename = f"audio_{time_from:%Y%m%d_%H%M%S}_to_{time_to:%Y%m%d_%H%M%S}.csv" + return StreamingResponse( + iter([buf.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) diff --git a/services/api/app/api/v1/endpoints/health.py b/services/api/app/api/v1/endpoints/health.py new file mode 100644 index 0000000..fd12cd7 --- /dev/null +++ b/services/api/app/api/v1/endpoints/health.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import get_db +from app.schemas.base import ApiResponse + +router = APIRouter() + + +@router.get("/live", response_model=ApiResponse[dict]) +async def live() -> ApiResponse[dict]: + return ApiResponse(data={"status": "alive"}) + + +@router.get("/ready", response_model=ApiResponse[dict]) +async def ready(db: AsyncSession = Depends(get_db)) -> ApiResponse[dict]: + await db.execute(text("SELECT 1")) + return ApiResponse(data={"status": "ready", "db": "ok"}) diff --git a/services/api/app/api/v1/endpoints/stats.py b/services/api/app/api/v1/endpoints/stats.py new file mode 100644 index 0000000..38d25e1 --- /dev/null +++ b/services/api/app/api/v1/endpoints/stats.py @@ -0,0 +1,23 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import get_db +from app.repositories.audio_repository import AudioRepository +from app.schemas.base import ApiResponse +from app.schemas.stats import StatsSummary +from app.services.stats_service import StatsService + +router = APIRouter() + + +@router.get("/summary", response_model=ApiResponse[StatsSummary]) +async def summary( + period: str = Query("1h", pattern="^(10s|1m|1h|6h|24h|7d|30d)$"), + db: AsyncSession = Depends(get_db), +): + service = StatsService(AudioRepository(db)) + try: + data = await service.summary(period) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + return ApiResponse(data=data) diff --git a/services/api/app/api/v1/router.py b/services/api/app/api/v1/router.py new file mode 100644 index 0000000..9b3f041 --- /dev/null +++ b/services/api/app/api/v1/router.py @@ -0,0 +1,9 @@ +from fastapi import APIRouter +from app.api.v1.endpoints import audio, stats, events, export, health + +router = APIRouter() +router.include_router(health.router, prefix="/health", tags=["health"]) +router.include_router(audio.router, prefix="/audio", tags=["audio"]) +router.include_router(stats.router, prefix="/stats", tags=["stats"]) +router.include_router(events.router, prefix="/events", tags=["events"]) +router.include_router(export.router, prefix="/export", tags=["export"]) diff --git a/services/api/app/core/config.py b/services/api/app/core/config.py new file mode 100644 index 0000000..a3acd34 --- /dev/null +++ b/services/api/app/core/config.py @@ -0,0 +1,11 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@db:5432/audio_analyzer" + API_V1_PREFIX: str = "/api/v1" + + +settings = Settings() diff --git a/services/api/app/db/base.py b/services/api/app/db/base.py new file mode 100644 index 0000000..fa2b68a --- /dev/null +++ b/services/api/app/db/base.py @@ -0,0 +1,5 @@ +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass diff --git a/services/api/app/db/session.py b/services/api/app/db/session.py new file mode 100644 index 0000000..1822512 --- /dev/null +++ b/services/api/app/db/session.py @@ -0,0 +1,12 @@ +from typing import AsyncGenerator +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.core.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=False, pool_pre_ping=True) +SessionLocal = async_sessionmaker(engine, expire_on_commit=False, autoflush=False) + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + async with SessionLocal() as session: + yield session diff --git a/services/api/app/main.py b/services/api/app/main.py new file mode 100644 index 0000000..7633efd --- /dev/null +++ b/services/api/app/main.py @@ -0,0 +1,46 @@ +from fastapi import FastAPI +import asyncio +from contextlib import asynccontextmanager, suppress +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1.router import router as v1_router +from app.core.config import settings +from app.ws.router import router as ws_router +from app.ws.broadcaster import audio_live_broadcaster + + +@asynccontextmanager +async def lifespan(app: FastAPI): + task = asyncio.create_task(audio_live_broadcaster()) + try: + yield + finally: + task.cancel() + with suppress(asyncio.CancelledError): + await task + + +def create_app() -> FastAPI: + app = FastAPI( + title="Audio Analyzer API", + version="1.0.0", + docs_url="/api/docs", + redoc_url="/api/redoc", + lifespan=lifespan, + ) + + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + app.include_router(v1_router, prefix=settings.API_V1_PREFIX) + app.include_router(ws_router) # /ws/live + return app + + +app = create_app() diff --git a/services/api/app/models/audio_data.py b/services/api/app/models/audio_data.py new file mode 100644 index 0000000..a7810b8 --- /dev/null +++ b/services/api/app/models/audio_data.py @@ -0,0 +1,14 @@ +from sqlalchemy import Boolean, Integer, Float +from sqlalchemy.dialects.postgresql import TIMESTAMP +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class AudioData(Base): + __tablename__ = "audio_data" + + time: Mapped[object] = mapped_column(TIMESTAMP(timezone=True), primary_key=True) + rms_db: Mapped[float] = mapped_column(Float, nullable=False) + frequency_hz: Mapped[int] = mapped_column(Integer, nullable=False) + is_silence: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) diff --git a/services/api/app/repositories/audio_repository.py b/services/api/app/repositories/audio_repository.py new file mode 100644 index 0000000..fcf846d --- /dev/null +++ b/services/api/app/repositories/audio_repository.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from sqlalchemy import and_, func, select, text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.audio_data import AudioData + + +class AudioRepository: + def __init__(self, db: AsyncSession): + self.db = db + + async def latest(self, limit: int) -> list[AudioData]: + q = select(AudioData).order_by(AudioData.time.desc()).limit(limit) + res = await self.db.execute(q) + return list(res.scalars().all()) + + async def range(self, time_from: datetime, time_to: datetime) -> list[AudioData]: + q = ( + select(AudioData) + .where(and_(AudioData.time >= time_from, AudioData.time <= time_to)) + .order_by(AudioData.time.asc()) + ) + res = await self.db.execute(q) + return list(res.scalars().all()) + + async def loud_samples( + self, + threshold: float, + time_from: datetime | None, + time_to: datetime | None, + ) -> list[AudioData]: + cond = [AudioData.rms_db >= threshold] + if time_from: + cond.append(AudioData.time >= time_from) + if time_to: + cond.append(AudioData.time <= time_to) + + q = select(AudioData).where(and_(*cond)).order_by(AudioData.time.asc()) + res = await self.db.execute(q) + return list(res.scalars().all()) + + async def summary_since(self, since: datetime) -> dict: + q = select( + func.avg(AudioData.rms_db).label("avg_db"), + func.max(AudioData.rms_db).label("max_db"), + func.sum(func.case((AudioData.is_silence.is_(True), 1), else_=0)).label( + "silence_count" + ), + func.count().label("total_count"), + ).where(AudioData.time >= since) + + res = await self.db.execute(q) + row = res.one() + + # dominant freq excluding silence + fq = ( + select(AudioData.frequency_hz, func.count().label("cnt")) + .where(and_(AudioData.time >= since, AudioData.is_silence.is_(False))) + .group_by(AudioData.frequency_hz) + .order_by(text("cnt DESC")) + .limit(1) + ) + fres = await self.db.execute(fq) + frow = fres.first() + + return { + "avg_db": float(row.avg_db or 0.0), + "max_db": float(row.max_db or 0.0), + "dominant_freq": int(frow[0]) if frow else 0, + "silence_count": int(row.silence_count or 0), + "total_count": int(row.total_count or 0), + } diff --git a/services/api/app/schemas/audio.py b/services/api/app/schemas/audio.py new file mode 100644 index 0000000..cda6f40 --- /dev/null +++ b/services/api/app/schemas/audio.py @@ -0,0 +1,9 @@ +from datetime import datetime +from pydantic import BaseModel + + +class AudioPoint(BaseModel): + time: datetime + rms_db: float + frequency_hz: int + is_silence: bool diff --git a/services/api/app/schemas/base.py b/services/api/app/schemas/base.py new file mode 100644 index 0000000..a536bc8 --- /dev/null +++ b/services/api/app/schemas/base.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from typing import Generic, TypeVar +from pydantic import BaseModel, Field + +T = TypeVar("T") + + +class ApiError(BaseModel): + code: str = Field(..., examples=["validation_error", "db_error"]) + message: str + details: dict | None = None + + +class ApiResponse(BaseModel, Generic[T]): + success: bool = True + errors: list[ApiError] | None = None + count: int | None = None + data: T | None = None diff --git a/services/api/app/schemas/events.py b/services/api/app/schemas/events.py new file mode 100644 index 0000000..70df199 --- /dev/null +++ b/services/api/app/schemas/events.py @@ -0,0 +1,9 @@ +from datetime import datetime +from pydantic import BaseModel + + +class LoudEvent(BaseModel): + time: datetime + rms_db: float + frequency_hz: int + duration_sec: float | None = None diff --git a/services/api/app/schemas/stats.py b/services/api/app/schemas/stats.py new file mode 100644 index 0000000..7e6ada6 --- /dev/null +++ b/services/api/app/schemas/stats.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel + + +class StatsSummary(BaseModel): + avg_db: float + max_db: float + dominant_freq: int + silence_percent: float diff --git a/services/api/app/services/audio_service.py b/services/api/app/services/audio_service.py new file mode 100644 index 0000000..e1d4754 --- /dev/null +++ b/services/api/app/services/audio_service.py @@ -0,0 +1,16 @@ +from datetime import datetime +from app.repositories.audio_repository import AudioRepository +from app.schemas.audio import AudioPoint + + +class AudioService: + def __init__(self, repo: AudioRepository): + self.repo = repo + + async def latest(self, limit: int) -> list[AudioPoint]: + rows = await self.repo.latest(limit) + return [AudioPoint.model_validate(r, from_attributes=True) for r in rows] + + async def range(self, time_from: datetime, time_to: datetime) -> list[AudioPoint]: + rows = await self.repo.range(time_from, time_to) + return [AudioPoint.model_validate(r, from_attributes=True) for r in rows] diff --git a/services/api/app/services/events_service.py b/services/api/app/services/events_service.py new file mode 100644 index 0000000..925491b --- /dev/null +++ b/services/api/app/services/events_service.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from datetime import datetime +from app.repositories.audio_repository import AudioRepository +from app.schemas.events import LoudEvent + + +class EventsService: + def __init__(self, repo: AudioRepository): + self.repo = repo + + async def loud_events( + self, + threshold: float, + time_from: datetime | None, + time_to: datetime | None, + max_gap_sec: float = 1.0, + ) -> list[LoudEvent]: + samples = await self.repo.loud_samples(threshold, time_from, time_to) + if not samples: + return [] + + events: list[LoudEvent] = [] + start = samples[0].time + end = samples[0].time + max_db = samples[0].rms_db + freq = samples[0].frequency_hz + + for s in samples[1:]: + gap = (s.time - end).total_seconds() + if gap <= max_gap_sec: + end = s.time + if s.rms_db > max_db: + max_db = s.rms_db + else: + events.append( + LoudEvent( + time=start, + rms_db=round(float(max_db), 2), + frequency_hz=int(freq), + duration_sec=round((end - start).total_seconds(), 2), + ) + ) + start, end, max_db, freq = s.time, s.time, s.rms_db, s.frequency_hz + + events.append( + LoudEvent( + time=start, + rms_db=round(float(max_db), 2), + frequency_hz=int(freq), + duration_sec=round((end - start).total_seconds(), 2), + ) + ) + return events diff --git a/services/api/app/services/stats_service.py b/services/api/app/services/stats_service.py new file mode 100644 index 0000000..5264000 --- /dev/null +++ b/services/api/app/services/stats_service.py @@ -0,0 +1,35 @@ +from datetime import datetime, timedelta +from app.repositories.audio_repository import AudioRepository +from app.schemas.stats import StatsSummary + +_PERIODS = { + "10s": timedelta(seconds=10), + "1m": timedelta(minutes=1), + "1h": timedelta(hours=1), + "6h": timedelta(hours=6), + "24h": timedelta(hours=24), + "7d": timedelta(days=7), + "30d": timedelta(days=30), +} + + +class StatsService: + def __init__(self, repo: AudioRepository): + self.repo = repo + + async def summary(self, period: str) -> StatsSummary: + if period not in _PERIODS: + raise ValueError(f"Unsupported period: {period}") + + since = datetime.utcnow() - _PERIODS[period] + raw = await self.repo.summary_since(since) + + total = raw["total_count"] + silence_percent = (raw["silence_count"] / total * 100.0) if total else 0.0 + + return StatsSummary( + avg_db=round(raw["avg_db"], 2), + max_db=round(raw["max_db"], 2), + dominant_freq=raw["dominant_freq"], + silence_percent=round(silence_percent, 2), + ) diff --git a/services/api/app/ws/broadcaster.py b/services/api/app/ws/broadcaster.py new file mode 100644 index 0000000..fae9e00 --- /dev/null +++ b/services/api/app/ws/broadcaster.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import asyncio +from contextlib import suppress +from datetime import timezone +from sqlalchemy.ext.asyncio import AsyncSession + +from app.db.session import SessionLocal +from app.repositories.audio_repository import AudioRepository +from app.ws.manager import manager + + +def _iso_z(dt) -> str: + # dt ожидается timezone-aware + return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +async def audio_live_broadcaster(poll_interval_sec: float = 0.2) -> None: + last_time = None + + while True: + try: + async with SessionLocal() as db: # AsyncSession + repo = AudioRepository(db) + rows = await repo.latest(1) + if rows: + row = rows[0] + if last_time is None or row.time > last_time: + last_time = row.time + await manager.broadcast_json( + { + "time": _iso_z(row.time), + "rms_db": float(row.rms_db), + "freq_hz": int(row.frequency_hz), + } + ) + except Exception: + # чтобы WS не умирал из-за временных проблем с БД + pass + + await asyncio.sleep(poll_interval_sec) diff --git a/services/api/app/ws/manager.py b/services/api/app/ws/manager.py new file mode 100644 index 0000000..bc3c2c9 --- /dev/null +++ b/services/api/app/ws/manager.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +import asyncio +from typing import Any +from fastapi import WebSocket + + +class ConnectionManager: + def __init__(self) -> None: + self._connections: set[WebSocket] = set() + self._lock = asyncio.Lock() + + async def connect(self, ws: WebSocket) -> None: + await ws.accept() + async with self._lock: + self._connections.add(ws) + + async def disconnect(self, ws: WebSocket) -> None: + async with self._lock: + self._connections.discard(ws) + + async def broadcast_json(self, payload: dict[str, Any]) -> None: + async with self._lock: + conns = list(self._connections) + + to_remove: list[WebSocket] = [] + for ws in conns: + try: + await ws.send_json(payload) + except Exception: + to_remove.append(ws) + + if to_remove: + async with self._lock: + for ws in to_remove: + self._connections.discard(ws) + + +manager = ConnectionManager() diff --git a/services/api/app/ws/router.py b/services/api/app/ws/router.py new file mode 100644 index 0000000..785bb11 --- /dev/null +++ b/services/api/app/ws/router.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, WebSocket +from starlette.websockets import WebSocketDisconnect + +from app.ws.manager import manager + +router = APIRouter() + + +@router.websocket("/ws/live") +async def ws_live(ws: WebSocket) -> None: + await manager.connect(ws) + try: + # Держим соединение + while True: + await ws.receive_text() + except WebSocketDisconnect: + await manager.disconnect(ws) + except Exception: + await manager.disconnect(ws) diff --git a/services/api/production.Dockerfile b/services/api/production.Dockerfile new file mode 100644 index 0000000..d575034 --- /dev/null +++ b/services/api/production.Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 8000 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/services/api/requirements.txt b/services/api/requirements.txt new file mode 100644 index 0000000..9b445de --- /dev/null +++ b/services/api/requirements.txt @@ -0,0 +1,8 @@ +fastapi +uvicorn[standard] +sqlalchemy +asyncpg +pydantic +pydantic-settings +python-dateutil +websockets