Compare commits
No files matched your search
+2
-1
@@ -16,7 +16,8 @@ wheels/
|
||||
alerts.jsonl
|
||||
output.jsonl
|
||||
output.mp4
|
||||
static/alerts/
|
||||
static/
|
||||
screenshots/
|
||||
events/
|
||||
logs/
|
||||
media/
|
||||
Vendored
+2
-1
@@ -5,6 +5,7 @@
|
||||
"**/*.rpymc": true,
|
||||
"**/cache/": true
|
||||
},
|
||||
"pylint.args": ["--generated-members=cv2.*", "--extension-pkg-whitelist=cv2"]
|
||||
"pylint.args": ["--generated-members=cv2.*", "--extension-pkg-whitelist=cv2"],
|
||||
"java.configuration.updateBuildConfiguration": "interactive"
|
||||
// "editor.fontFamily": "'JetbrainsMono Nerd Font', Consolas, 'Courier New', monospace"
|
||||
}
|
||||
@@ -63,6 +63,9 @@ The server runs the detection pipeline in the background while serving a REST AP
|
||||
# Default: webcam 0, classic pipeline
|
||||
uv run server.py
|
||||
|
||||
# Package entrypoint (same server)
|
||||
uv run -m server
|
||||
|
||||
# Use stage-based pipeline with a specific camera
|
||||
uv run server.py --staged --source 2
|
||||
|
||||
@@ -76,6 +79,14 @@ uv run server.py --host 0.0.0.0 --port 9000
|
||||
uvicorn server:app --host 0.0.0.0 --port 8000
|
||||
```
|
||||
|
||||
### Live + Clip Control (Server Runtime)
|
||||
|
||||
The server now runs a lifecycle manager for the live pipeline and a queued clip-job worker:
|
||||
|
||||
- **Live manager**: start/stop/pause/resume/switch source at runtime.
|
||||
- **Clip jobs**: submit files to a queue, process one-at-a-time, track status.
|
||||
- **Resource policy**: clip jobs can temporarily pause live processing and resume after job completion.
|
||||
|
||||
On startup the server prints the LAN address for the mobile app:
|
||||
|
||||
```
|
||||
@@ -122,7 +133,7 @@ uv run main.py --source videos/test.mp4 --debug
|
||||
### Check Available Webcams
|
||||
|
||||
```bash
|
||||
uv run -m tools.webcam_check
|
||||
uv run -m scripts.webcam_check
|
||||
```
|
||||
|
||||
### CLI Arguments
|
||||
@@ -181,6 +192,10 @@ The system generates multiple artifacts during execution:
|
||||
```text
|
||||
gp/
|
||||
├── server.py # FastAPI server (runs pipeline + REST API)
|
||||
├── server/ # Modular server runtime + routes
|
||||
│ ├── runtime.py # PipelineManager + ClipJobManager
|
||||
│ ├── routes.py # API routers (/live/*, /clips/*, auth/history)
|
||||
│ └── __main__.py # `uv run -m server` entrypoint
|
||||
├── main.py # CLI entry point (standalone pipeline)
|
||||
├── pyproject.toml # Project config (uv/pip)
|
||||
├── models/ # Directory for .pt model weights
|
||||
@@ -228,8 +243,9 @@ gp/
|
||||
│ ├── geometry.py # Box expansion, zone filtering
|
||||
│ └── drawing.py # Visualization helpers
|
||||
│
|
||||
├── tools/
|
||||
│ └── webcam_check.py # Utility to list available webcams
|
||||
├── scripts/
|
||||
│ ├── webcam_check.py # Utility to list available webcams
|
||||
│ └── live_stress_test.py # Stress test for /live/* and queue responsiveness
|
||||
│
|
||||
└── SmokeDetectionApp/ # Android companion app
|
||||
└── app/src/main/java/com/example/smokedetection/
|
||||
@@ -288,18 +304,57 @@ pipeline = SmokingDetectionPipeline(..., config=config)
|
||||
|
||||
The server (`server.py`) exposes these endpoints:
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| :----- | :--------------- | :---------------------------------- |
|
||||
| GET | `/ping` | Health check (verify connectivity) |
|
||||
| POST | `/register` | Create a new user account |
|
||||
| POST | `/login` | Authenticate and get user ID |
|
||||
| GET | `/history` | Get detection records for a user |
|
||||
| POST | `/clear_history` | Delete all records for a user |
|
||||
| GET | `/snapshot` | Latest camera frame as JPEG |
|
||||
| GET | `/video_feed` | MJPEG stream with detection overlay |
|
||||
| Method | Endpoint | Description |
|
||||
| :----- | :-------------------- | :---------------------------------------------- |
|
||||
| GET | `/ping` | Health check (verify connectivity) |
|
||||
| POST | `/register` | Create a new user account |
|
||||
| POST | `/login` | Authenticate and get user ID |
|
||||
| GET | `/history` | Get detection records (optionally by `user_id`) |
|
||||
| POST | `/clear_history` | Delete detection records (optional `user_id`) |
|
||||
| GET | `/snapshot` | Latest camera frame as JPEG |
|
||||
| GET | `/video_feed` | MJPEG stream with detection overlay |
|
||||
| GET | `/live/status` | Live pipeline state (`running/paused/source`) |
|
||||
| POST | `/live/start` | Start live pipeline (optional `source`) |
|
||||
| POST | `/live/stop` | Stop live pipeline |
|
||||
| POST | `/live/pause` | Pause live pipeline |
|
||||
| POST | `/live/resume` | Resume paused live pipeline |
|
||||
| POST | `/live/switch_source` | Switch active webcam/RTSP source |
|
||||
| POST | `/clips/submit` | Queue a clip job (upload file or local path) |
|
||||
| GET | `/clips` | List recent clip jobs + queue stats |
|
||||
| GET | `/clips/{job_id}` | Get status/details for a clip job |
|
||||
|
||||
Interactive docs at **<http://localhost:8000/docs>** (Swagger UI).
|
||||
|
||||
### Quick Test Calls
|
||||
|
||||
PowerShell examples:
|
||||
|
||||
```powershell
|
||||
$base='http://127.0.0.1:8000'
|
||||
|
||||
# Live controls
|
||||
Invoke-WebRequest -UseBasicParsing -Uri ($base+'/live/status')
|
||||
Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($base+'/live/switch_source') -Body @{source='2'}
|
||||
Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($base+'/live/pause')
|
||||
Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($base+'/live/resume')
|
||||
|
||||
# Queue clip from local path
|
||||
Invoke-WebRequest -UseBasicParsing -Method Post -Uri ($base+'/clips/submit') -Body @{path='test_media/139474-772542450_medium.mp4'}
|
||||
|
||||
# List jobs and inspect one job
|
||||
Invoke-WebRequest -UseBasicParsing -Uri ($base+'/clips')
|
||||
Invoke-WebRequest -UseBasicParsing -Uri ($base+'/clips/<job_id>')
|
||||
```
|
||||
|
||||
curl examples:
|
||||
|
||||
```bash
|
||||
curl http://127.0.0.1:8000/live/status
|
||||
curl -X POST -F "source=2" http://127.0.0.1:8000/live/switch_source
|
||||
curl -X POST -F "path=test_media/139474-772542450_medium.mp4" http://127.0.0.1:8000/clips/submit
|
||||
curl http://127.0.0.1:8000/clips
|
||||
```
|
||||
|
||||
## 📱 Android App
|
||||
|
||||
The companion app (`SmokeDetectionApp/`) connects to the server for auth, alert history, and live camera feed.
|
||||
|
||||
+1
-1
Submodule SmokeDetectionApp updated: 7ace087582...e296efdac5.
@@ -1,7 +1,7 @@
|
||||
"""Sinks for pipeline output handling."""
|
||||
|
||||
from pipeline.sinks.alerts import FileAlertStore
|
||||
from pipeline.sinks.database import DatabaseSink
|
||||
from pipeline.sinks.database import DatabaseSink, DatabaseSmartEventSink
|
||||
from pipeline.sinks.display import WindowDisplaySink
|
||||
from pipeline.sinks.events import SmartEventSink
|
||||
from pipeline.sinks.metrics import MetricsSink
|
||||
@@ -10,6 +10,7 @@ from pipeline.sinks.video import VideoFileStore
|
||||
|
||||
__all__ = [
|
||||
"DatabaseSink",
|
||||
"DatabaseSmartEventSink",
|
||||
"FileAlertStore",
|
||||
"MetricsSink",
|
||||
"ScreenshotSink",
|
||||
|
||||
@@ -8,6 +8,7 @@ import cv2
|
||||
|
||||
from db.repository import Repository
|
||||
from pipeline.ops.drawing import draw_alert_banner, draw_detections
|
||||
from pipeline.sinks.events import SmartEventSink
|
||||
from pipeline.types import Frame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -72,3 +73,52 @@ class DatabaseSink:
|
||||
|
||||
def close(self):
|
||||
pass # DB connection is managed by the repository
|
||||
|
||||
|
||||
class DatabaseSmartEventSink(SmartEventSink):
|
||||
"""Records event clips and stores clip path in DB as alert evidence."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_repo: Repository,
|
||||
fps: float,
|
||||
user_id: int = 1,
|
||||
camera_source: str = "Pipeline",
|
||||
output_dir: str = "static/events",
|
||||
pre_event_seconds: float = 2,
|
||||
post_event_seconds: float = 3,
|
||||
):
|
||||
super().__init__(
|
||||
output_dir=output_dir,
|
||||
fps=fps,
|
||||
pre_event_seconds=pre_event_seconds,
|
||||
post_event_seconds=post_event_seconds,
|
||||
)
|
||||
self.db_repo = db_repo
|
||||
self.user_id = user_id
|
||||
self.camera_source = camera_source
|
||||
|
||||
def _trigger_event(self, trigger_frame: Frame):
|
||||
was_recording = self.recording
|
||||
super()._trigger_event(trigger_frame)
|
||||
|
||||
# Save one DB row per newly started event clip
|
||||
if was_recording or not self.recording or self.current_event_id is None:
|
||||
return
|
||||
|
||||
ticket = trigger_frame.ticket
|
||||
if not ticket:
|
||||
return
|
||||
|
||||
filename = f"event_{self.current_event_id}.mp4"
|
||||
web_path = f"/static/events/{filename}"
|
||||
try:
|
||||
self.db_repo.save_detection(
|
||||
user_id=self.user_id,
|
||||
image_path=web_path,
|
||||
confidence=ticket.confidence,
|
||||
camera_source=self.camera_source,
|
||||
)
|
||||
logger.info("Saved event alert to DB: %s", web_path)
|
||||
except Exception as e:
|
||||
logger.error("Failed to save event alert to DB: %s", e)
|
||||
@@ -3,6 +3,7 @@
|
||||
import glob
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Iterator
|
||||
|
||||
import cv2
|
||||
@@ -19,9 +20,10 @@ class FileStreamSource(StreamSource):
|
||||
def __init__(self, path: str):
|
||||
self.path = path
|
||||
self._fps = 30.0
|
||||
self._image_exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
|
||||
|
||||
# Probe for FPS if it's a single file
|
||||
if "*" not in self.path:
|
||||
if "*" not in self.path and not self._is_image_path(self.path):
|
||||
try:
|
||||
cap = cv2.VideoCapture(self.path)
|
||||
if cap.isOpened():
|
||||
@@ -30,6 +32,16 @@ class FileStreamSource(StreamSource):
|
||||
except Exception as e:
|
||||
logger.warning("Could not probe FPS from %s: %s", path, e)
|
||||
|
||||
def _is_image_path(self, value: str) -> bool:
|
||||
return Path(value).suffix.lower() in self._image_exts
|
||||
|
||||
def _iter_image_files(self, directory: Path) -> list[str]:
|
||||
files: list[str] = []
|
||||
for ext in self._image_exts:
|
||||
files.extend(glob.glob(str(directory / f"*{ext}")))
|
||||
files.extend(glob.glob(str(directory / f"*{ext.upper()}")))
|
||||
return sorted(set(files))
|
||||
|
||||
@property
|
||||
def fps(self) -> float:
|
||||
return self._fps
|
||||
@@ -45,6 +57,28 @@ class FileStreamSource(StreamSource):
|
||||
yield Frame(timestamp=time.time(), data=img, frame_id=frame_id)
|
||||
return
|
||||
|
||||
file_path = Path(self.path)
|
||||
|
||||
# Handle single image file
|
||||
if self._is_image_path(self.path):
|
||||
img = cv2.imread(self.path)
|
||||
if img is None:
|
||||
logger.warning("Could not read image: %s", self.path)
|
||||
return
|
||||
logger.info("Loaded single image: %s", self.path)
|
||||
yield Frame(timestamp=time.time(), data=img, frame_id=0)
|
||||
return
|
||||
|
||||
# Handle directory of images
|
||||
if file_path.exists() and file_path.is_dir():
|
||||
files = self._iter_image_files(file_path)
|
||||
logger.info("Loading %d images from directory: %s", len(files), self.path)
|
||||
for frame_id, file in enumerate(files):
|
||||
img = cv2.imread(file)
|
||||
if img is not None:
|
||||
yield Frame(timestamp=time.time(), data=img, frame_id=frame_id)
|
||||
return
|
||||
|
||||
logger.info("Opening video file: %s", self.path)
|
||||
cap = cv2.VideoCapture(self.path)
|
||||
frame_id = 0
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ dependencies = [
|
||||
"torch>=2.9.1",
|
||||
"torchvision>=0.24.1",
|
||||
"ultralytics>=8.3.248",
|
||||
"uvicorn>=0.41.0",
|
||||
"uvicorn[standard]>=0.41.0",
|
||||
]
|
||||
|
||||
[[tool.uv.index]]
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
{
|
||||
"started_at": "2026-03-01T19:28:11",
|
||||
"base_url": "http://127.0.0.1:8000",
|
||||
"config": {
|
||||
"cycles": 20,
|
||||
"request_timeout": 8.0,
|
||||
"sleep_ms": 120,
|
||||
"pause_every": 3,
|
||||
"pause_sleep_ms": 120,
|
||||
"source_a": "0",
|
||||
"source_b": "2"
|
||||
},
|
||||
"summary": {
|
||||
"total": 75,
|
||||
"ok": 75,
|
||||
"err": 0,
|
||||
"avg_ms": 286.4,
|
||||
"max_ms": 1051,
|
||||
"over_500ms": 16,
|
||||
"over_1000ms": 12
|
||||
},
|
||||
"errors": [],
|
||||
"tail": [
|
||||
{
|
||||
"name": "cycle18-pause",
|
||||
"ok": true,
|
||||
"duration_ms": 1003,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline paused\",\"live\":{\"running\":false,\"starting\":false,\"paused\":true,\"source\":\"2\",\"last_error\":null,\"thread_alive\":false}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle18-resume",
|
||||
"ok": true,
|
||||
"duration_ms": 357,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline resumed\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle18-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle19-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle19-switch-0",
|
||||
"ok": true,
|
||||
"duration_ms": 1001,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=0)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle19-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 18,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle20-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 2,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle20-switch-2",
|
||||
"ok": true,
|
||||
"duration_ms": 401,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=2)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle20-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 2,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "final-status",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
}
|
||||
],
|
||||
"all_results": [
|
||||
{
|
||||
"name": "warmup-status",
|
||||
"ok": true,
|
||||
"duration_ms": 23,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "warmup-start",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"fail\",\"message\":\"Pipeline already running\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle1-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle1-switch-0",
|
||||
"ok": true,
|
||||
"duration_ms": 275,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=0)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle1-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle2-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 2,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle2-switch-2",
|
||||
"ok": true,
|
||||
"duration_ms": 520,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=2)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle2-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 24,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle3-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 2,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle3-switch-0",
|
||||
"ok": true,
|
||||
"duration_ms": 1051,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=0)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle3-pause",
|
||||
"ok": true,
|
||||
"duration_ms": 1003,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline paused\",\"live\":{\"running\":false,\"starting\":false,\"paused\":true,\"source\":\"0\",\"last_error\":null,\"thread_alive\":false}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle3-resume",
|
||||
"ok": true,
|
||||
"duration_ms": 151,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline resumed\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle3-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 2,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle4-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 2,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle4-switch-2",
|
||||
"ok": true,
|
||||
"duration_ms": 494,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=2)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle4-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle5-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 2,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle5-switch-0",
|
||||
"ok": true,
|
||||
"duration_ms": 1024,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=0)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle5-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle6-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle6-switch-2",
|
||||
"ok": true,
|
||||
"duration_ms": 451,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=2)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle6-pause",
|
||||
"ok": true,
|
||||
"duration_ms": 1006,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline paused\",\"live\":{\"running\":false,\"starting\":false,\"paused\":true,\"source\":\"2\",\"last_error\":null,\"thread_alive\":false}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle6-resume",
|
||||
"ok": true,
|
||||
"duration_ms": 341,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline resumed\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle6-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle7-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 25,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle7-switch-0",
|
||||
"ok": true,
|
||||
"duration_ms": 998,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=0)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle7-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 21,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle8-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 2,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle8-switch-2",
|
||||
"ok": true,
|
||||
"duration_ms": 424,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=2)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle8-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle9-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 17,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle9-switch-0",
|
||||
"ok": true,
|
||||
"duration_ms": 1034,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=0)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle9-pause",
|
||||
"ok": true,
|
||||
"duration_ms": 1013,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline paused\",\"live\":{\"running\":false,\"starting\":false,\"paused\":true,\"source\":\"0\",\"last_error\":null,\"thread_alive\":false}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle9-resume",
|
||||
"ok": true,
|
||||
"duration_ms": 150,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline resumed\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle9-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 17,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle10-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 16,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle10-switch-2",
|
||||
"ok": true,
|
||||
"duration_ms": 389,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=2)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle10-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle11-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle11-switch-0",
|
||||
"ok": true,
|
||||
"duration_ms": 1013,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=0)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle11-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 21,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle12-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle12-switch-2",
|
||||
"ok": true,
|
||||
"duration_ms": 461,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=2)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle12-pause",
|
||||
"ok": true,
|
||||
"duration_ms": 1004,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline paused\",\"live\":{\"running\":false,\"starting\":false,\"paused\":true,\"source\":\"2\",\"last_error\":null,\"thread_alive\":false}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle12-resume",
|
||||
"ok": true,
|
||||
"duration_ms": 340,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline resumed\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle12-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle13-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle13-switch-0",
|
||||
"ok": true,
|
||||
"duration_ms": 999,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=0)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle13-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 14,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle14-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle14-switch-2",
|
||||
"ok": true,
|
||||
"duration_ms": 400,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=2)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle14-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle15-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle15-switch-0",
|
||||
"ok": true,
|
||||
"duration_ms": 1017,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=0)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle15-pause",
|
||||
"ok": true,
|
||||
"duration_ms": 1004,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline paused\",\"live\":{\"running\":false,\"starting\":false,\"paused\":true,\"source\":\"0\",\"last_error\":null,\"thread_alive\":false}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle15-resume",
|
||||
"ok": true,
|
||||
"duration_ms": 130,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline resumed\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle15-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 22,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle16-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 2,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle16-switch-2",
|
||||
"ok": true,
|
||||
"duration_ms": 382,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=2)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle16-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 26,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle17-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 2,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle17-switch-0",
|
||||
"ok": true,
|
||||
"duration_ms": 977,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=0)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle17-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 14,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle18-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 15,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle18-switch-2",
|
||||
"ok": true,
|
||||
"duration_ms": 353,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=2)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle18-pause",
|
||||
"ok": true,
|
||||
"duration_ms": 1003,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline paused\",\"live\":{\"running\":false,\"starting\":false,\"paused\":true,\"source\":\"2\",\"last_error\":null,\"thread_alive\":false}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle18-resume",
|
||||
"ok": true,
|
||||
"duration_ms": 357,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline resumed\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle18-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle19-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle19-switch-0",
|
||||
"ok": true,
|
||||
"duration_ms": 1001,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=0)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle19-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 18,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle20-status-before",
|
||||
"ok": true,
|
||||
"duration_ms": 2,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"0\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle20-switch-2",
|
||||
"ok": true,
|
||||
"duration_ms": 401,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"message\":\"Pipeline started (source=2)\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "cycle20-status-after",
|
||||
"ok": true,
|
||||
"duration_ms": 2,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
},
|
||||
{
|
||||
"name": "final-status",
|
||||
"ok": true,
|
||||
"duration_ms": 1,
|
||||
"status_code": 200,
|
||||
"body": "{\"status\":\"success\",\"live\":{\"running\":true,\"starting\":false,\"paused\":false,\"source\":\"2\",\"last_error\":null,\"thread_alive\":true}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Stress-test live control endpoints for the smoking detection server.
|
||||
|
||||
Usage example:
|
||||
python scripts/live_stress_test.py --cycles 20 --request-timeout 8 --sleep-ms 120
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
@dataclass
|
||||
class StepResult:
|
||||
name: str
|
||||
ok: bool
|
||||
duration_ms: int
|
||||
status_code: int | None
|
||||
body: str
|
||||
|
||||
|
||||
def _call(
|
||||
base_url: str,
|
||||
name: str,
|
||||
method: str,
|
||||
path: str,
|
||||
timeout_seconds: float,
|
||||
form: dict[str, Any] | None = None,
|
||||
) -> StepResult:
|
||||
url = f"{base_url}{path}"
|
||||
data = None
|
||||
headers: dict[str, str] = {}
|
||||
|
||||
if form is not None:
|
||||
data = urlencode(form).encode("utf-8")
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
|
||||
request = Request(url=url, data=data, method=method, headers=headers)
|
||||
|
||||
start = time.perf_counter()
|
||||
try:
|
||||
with urlopen(request, timeout=timeout_seconds) as response:
|
||||
body = response.read().decode("utf-8", errors="replace")
|
||||
status = getattr(response, "status", None)
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
return StepResult(
|
||||
name=name, ok=True, duration_ms=duration_ms, status_code=status, body=body
|
||||
)
|
||||
except HTTPError as err:
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
try:
|
||||
body = err.read().decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
body = str(err)
|
||||
return StepResult(
|
||||
name=name,
|
||||
ok=False,
|
||||
duration_ms=duration_ms,
|
||||
status_code=err.code,
|
||||
body=body,
|
||||
)
|
||||
except URLError as err:
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
return StepResult(
|
||||
name=name,
|
||||
ok=False,
|
||||
duration_ms=duration_ms,
|
||||
status_code=None,
|
||||
body=str(err.reason),
|
||||
)
|
||||
except Exception as err:
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
return StepResult(
|
||||
name=name,
|
||||
ok=False,
|
||||
duration_ms=duration_ms,
|
||||
status_code=None,
|
||||
body=str(err),
|
||||
)
|
||||
|
||||
|
||||
def _summarize(results: list[StepResult]) -> dict[str, Any]:
|
||||
ok_results = [r for r in results if r.ok]
|
||||
err_results = [r for r in results if not r.ok]
|
||||
durations = [r.duration_ms for r in ok_results]
|
||||
|
||||
return {
|
||||
"total": len(results),
|
||||
"ok": len(ok_results),
|
||||
"err": len(err_results),
|
||||
"avg_ms": round(sum(durations) / len(durations), 1) if durations else 0,
|
||||
"max_ms": max(durations) if durations else 0,
|
||||
"over_500ms": sum(1 for d in durations if d > 500),
|
||||
"over_1000ms": sum(1 for d in durations if d > 1000),
|
||||
}
|
||||
|
||||
|
||||
def run_stress(args: argparse.Namespace) -> tuple[list[StepResult], dict[str, Any]]:
|
||||
results: list[StepResult] = []
|
||||
|
||||
def add(result: StepResult) -> None:
|
||||
results.append(result)
|
||||
if args.verbose or not result.ok:
|
||||
status = "OK" if result.ok else "ERR"
|
||||
print(
|
||||
f"{status}|{result.name}|{result.duration_ms}ms|"
|
||||
f"status={result.status_code}|{result.body[:180]}"
|
||||
)
|
||||
|
||||
add(
|
||||
_call(
|
||||
args.base_url, "warmup-status", "GET", "/live/status", args.request_timeout
|
||||
)
|
||||
)
|
||||
add(
|
||||
_call(
|
||||
args.base_url,
|
||||
"warmup-start",
|
||||
"POST",
|
||||
"/live/start",
|
||||
args.request_timeout,
|
||||
{"source": args.source_b},
|
||||
)
|
||||
)
|
||||
|
||||
for cycle in range(1, args.cycles + 1):
|
||||
add(
|
||||
_call(
|
||||
args.base_url,
|
||||
f"cycle{cycle}-status-before",
|
||||
"GET",
|
||||
"/live/status",
|
||||
args.request_timeout,
|
||||
)
|
||||
)
|
||||
|
||||
src = args.source_b if cycle % 2 == 0 else args.source_a
|
||||
add(
|
||||
_call(
|
||||
args.base_url,
|
||||
f"cycle{cycle}-switch-{src}",
|
||||
"POST",
|
||||
"/live/switch_source",
|
||||
args.request_timeout,
|
||||
{"source": src},
|
||||
)
|
||||
)
|
||||
|
||||
if args.pause_every > 0 and cycle % args.pause_every == 0:
|
||||
add(
|
||||
_call(
|
||||
args.base_url,
|
||||
f"cycle{cycle}-pause",
|
||||
"POST",
|
||||
"/live/pause",
|
||||
args.request_timeout,
|
||||
)
|
||||
)
|
||||
time.sleep(args.pause_sleep_ms / 1000.0)
|
||||
add(
|
||||
_call(
|
||||
args.base_url,
|
||||
f"cycle{cycle}-resume",
|
||||
"POST",
|
||||
"/live/resume",
|
||||
args.request_timeout,
|
||||
)
|
||||
)
|
||||
|
||||
add(
|
||||
_call(
|
||||
args.base_url,
|
||||
f"cycle{cycle}-status-after",
|
||||
"GET",
|
||||
"/live/status",
|
||||
args.request_timeout,
|
||||
)
|
||||
)
|
||||
|
||||
time.sleep(args.sleep_ms / 1000.0)
|
||||
|
||||
add(
|
||||
_call(
|
||||
args.base_url, "final-status", "GET", "/live/status", args.request_timeout
|
||||
)
|
||||
)
|
||||
|
||||
summary = _summarize(results)
|
||||
return results, summary
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Stress test /live/* endpoints")
|
||||
parser.add_argument("--base-url", default="http://127.0.0.1:8000")
|
||||
parser.add_argument("--cycles", type=int, default=20)
|
||||
parser.add_argument("--request-timeout", type=float, default=8.0)
|
||||
parser.add_argument("--sleep-ms", type=int, default=120)
|
||||
parser.add_argument("--pause-every", type=int, default=3)
|
||||
parser.add_argument("--pause-sleep-ms", type=int, default=120)
|
||||
parser.add_argument("--source-a", default="0")
|
||||
parser.add_argument("--source-b", default="2")
|
||||
parser.add_argument("--out", default="reports/live_stress_latest.json")
|
||||
parser.add_argument("--verbose", action="store_true")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
started_at = datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
results, summary = run_stress(args)
|
||||
|
||||
report = {
|
||||
"started_at": started_at,
|
||||
"base_url": args.base_url,
|
||||
"config": {
|
||||
"cycles": args.cycles,
|
||||
"request_timeout": args.request_timeout,
|
||||
"sleep_ms": args.sleep_ms,
|
||||
"pause_every": args.pause_every,
|
||||
"pause_sleep_ms": args.pause_sleep_ms,
|
||||
"source_a": args.source_a,
|
||||
"source_b": args.source_b,
|
||||
},
|
||||
"summary": summary,
|
||||
"errors": [asdict(r) for r in results if not r.ok],
|
||||
"tail": [asdict(r) for r in results[-10:]],
|
||||
"all_results": [asdict(r) for r in results],
|
||||
}
|
||||
|
||||
out_path = Path(args.out)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(json.dumps(report, indent=2), encoding="utf-8")
|
||||
|
||||
print(
|
||||
"SUMMARY|"
|
||||
f"total={summary['total']}|ok={summary['ok']}|err={summary['err']}|"
|
||||
f"avg_ms={summary['avg_ms']}|max_ms={summary['max_ms']}|"
|
||||
f"over_500ms={summary['over_500ms']}|over_1000ms={summary['over_1000ms']}"
|
||||
)
|
||||
print(f"REPORT|{out_path.as_posix()}")
|
||||
|
||||
if summary["err"] > 0:
|
||||
print("ERRORS_BEGIN")
|
||||
for err in report["errors"][:8]:
|
||||
print(
|
||||
f"ERR|{err['name']}|{err['duration_ms']}ms|"
|
||||
f"status={err['status_code']}|{err['body'][:180]}"
|
||||
)
|
||||
print("ERRORS_END")
|
||||
return 1
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File renamed without changes.
@@ -15,122 +15,27 @@ Usage:
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
from fastapi import FastAPI, Form, Response
|
||||
from fastapi.responses import StreamingResponse
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from db import SQLiteRepository
|
||||
from db.repository import Repository
|
||||
import server.runtime as rt
|
||||
from pipeline.config import PipelineConfig
|
||||
from pipeline.core import SmokingDetectionPipeline
|
||||
from pipeline.models import YoloCigaretteDetector, YoloClassifier, YoloPersonDetector
|
||||
from pipeline.protocols import Sink
|
||||
from pipeline.sinks import FileAlertStore, MetricsSink, SmartEventSink
|
||||
from pipeline.protocols import Sink, StreamSource
|
||||
from pipeline.sinks import FileAlertStore, MetricsSink
|
||||
from pipeline.sinks.database import DatabaseSmartEventSink
|
||||
from pipeline.sinks.video import VideoFileStore
|
||||
from pipeline.sources.file import FileStreamSource
|
||||
from pipeline.sources.stream import ThreadedStreamSource
|
||||
from pipeline.staged_core import StageBasedPipeline
|
||||
from pipeline.types import Frame
|
||||
from server.routes import router as api_router
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Global state (set during lifespan)
|
||||
# ---------------------------------------------------------------------------
|
||||
db: Repository = SQLiteRepository() # default: data/smoke.db
|
||||
pipeline = None # set at startup
|
||||
source: ThreadedStreamSource | None = None # set at startup
|
||||
live_frame: Frame | None = None # latest frame for MJPEG / snapshot
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sink that exposes frames to the web layer
|
||||
# ---------------------------------------------------------------------------
|
||||
class LiveFrameSink(Sink):
|
||||
"""Captures the latest frame so API endpoints can stream it."""
|
||||
|
||||
def handle(self, frame: Frame):
|
||||
global live_frame
|
||||
live_frame = frame
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class DatabaseSmartEventSink(SmartEventSink):
|
||||
"""Records event clips and stores clip path in DB as alert evidence."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_repo: Repository,
|
||||
fps: float,
|
||||
user_id: int = 1,
|
||||
camera_source: str = "Pipeline",
|
||||
output_dir: str = "static/events",
|
||||
):
|
||||
super().__init__(output_dir=output_dir, fps=fps)
|
||||
self.db_repo = db_repo
|
||||
self.user_id = user_id
|
||||
self.camera_source = camera_source
|
||||
|
||||
def _trigger_event(self, trigger_frame: Frame):
|
||||
was_recording = self.recording
|
||||
super()._trigger_event(trigger_frame)
|
||||
|
||||
# Save one DB row per newly started event clip
|
||||
if was_recording or not self.recording or self.current_event_id is None:
|
||||
return
|
||||
|
||||
ticket = trigger_frame.ticket
|
||||
if not ticket:
|
||||
return
|
||||
|
||||
filename = f"event_{self.current_event_id}.mp4"
|
||||
web_path = f"/static/events/{filename}"
|
||||
try:
|
||||
self.db_repo.save_detection(
|
||||
user_id=self.user_id,
|
||||
image_path=web_path,
|
||||
confidence=ticket.confidence,
|
||||
camera_source=self.camera_source,
|
||||
)
|
||||
logger.info("Saved event alert to DB: %s", web_path)
|
||||
except Exception as e:
|
||||
logger.error("Failed to save event alert to DB: %s", e)
|
||||
|
||||
|
||||
def _get_lan_address() -> tuple[str, str]:
|
||||
"""Return (ip, hostname) for the primary LAN interface."""
|
||||
ip = "127.0.0.1"
|
||||
try:
|
||||
# Open a UDP socket to an external IP (doesn't actually send anything)
|
||||
# to figure out which local NIC the OS would route through.
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(("8.8.8.8", 80))
|
||||
ip = s.getsockname()[0]
|
||||
s.close()
|
||||
except Exception:
|
||||
pass
|
||||
hostname = socket.getfqdn()
|
||||
return ip, hostname
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pipeline runner (background thread)
|
||||
# ---------------------------------------------------------------------------
|
||||
def _run_pipeline():
|
||||
"""Blocking call: runs in a daemon thread."""
|
||||
assert pipeline is not None and source is not None
|
||||
try:
|
||||
pipeline.process_stream(source)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Pipeline crashed")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI args (parsed before uvicorn when run as __main__)
|
||||
@@ -174,8 +79,6 @@ _args = argparse.Namespace(
|
||||
# ---------------------------------------------------------------------------
|
||||
@asynccontextmanager
|
||||
async def app_lifespan(_app: FastAPI):
|
||||
global pipeline, source # noqa: PLW0603
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if _args.debug else logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
@@ -185,7 +88,7 @@ async def app_lifespan(_app: FastAPI):
|
||||
|
||||
# 1. Database
|
||||
logger.info("Initializing database...")
|
||||
db.initialize()
|
||||
rt.db.initialize()
|
||||
|
||||
# 2. Models
|
||||
logger.info("Loading models...")
|
||||
@@ -194,44 +97,108 @@ async def app_lifespan(_app: FastAPI):
|
||||
verifier = YoloClassifier(_args.verify_model)
|
||||
logger.info("Models loaded.")
|
||||
|
||||
# 3. Source (webcam / RTSP)
|
||||
cam = int(_args.source) if _args.source.isdigit() else _args.source
|
||||
source = ThreadedStreamSource(cam)
|
||||
logger.info("Camera source ready: %s (%.1f FPS)", _args.source, source.fps)
|
||||
# 3. Configure live pipeline manager
|
||||
def source_factory(source_spec: str) -> StreamSource:
|
||||
if source_spec.lower() in {"phone", "android", "mobile"}:
|
||||
rt.phone_source.reset()
|
||||
return rt.phone_source
|
||||
cam = int(source_spec) if source_spec.isdigit() else source_spec
|
||||
return ThreadedStreamSource(cam)
|
||||
|
||||
# 4. Sinks
|
||||
sinks: list[Sink] = [
|
||||
LiveFrameSink(),
|
||||
DatabaseSmartEventSink(db, fps=source.fps, output_dir="static/events"),
|
||||
MetricsSink(log_interval=200),
|
||||
FileAlertStore("alerts.jsonl"),
|
||||
]
|
||||
def sink_factory(src: StreamSource) -> list[Sink]:
|
||||
return [
|
||||
rt.LiveFrameSink(),
|
||||
DatabaseSmartEventSink(rt.db, fps=src.fps, output_dir="static/events"),
|
||||
MetricsSink(log_interval=200),
|
||||
FileAlertStore("alerts.jsonl"),
|
||||
]
|
||||
|
||||
# 5. Pipeline config
|
||||
config = PipelineConfig(
|
||||
idle_interval=0.5,
|
||||
active_interval=0.0,
|
||||
cooldown_frames=int(source.fps * 2),
|
||||
def pipeline_factory(src: StreamSource, sinks: list[Sink]):
|
||||
config = PipelineConfig(
|
||||
idle_interval=0.5,
|
||||
active_interval=0.0,
|
||||
cooldown_frames=int(src.fps * 2),
|
||||
)
|
||||
|
||||
if _args.staged or _args.vlm:
|
||||
vlm_key = _args.vlm_key or os.environ.get(
|
||||
"OPENAI_API_KEY"
|
||||
if _args.vlm_provider == "openai"
|
||||
else "ANTHROPIC_API_KEY"
|
||||
)
|
||||
return StageBasedPipeline(
|
||||
person_model=person_model,
|
||||
cigarette_model=cig_model,
|
||||
verifier_model=verifier,
|
||||
vlm_api_key=vlm_key if _args.vlm else None,
|
||||
vlm_provider=_args.vlm_provider,
|
||||
vlm_model=_args.vlm_model,
|
||||
use_vlm=_args.vlm,
|
||||
sinks=sinks,
|
||||
config=config,
|
||||
)
|
||||
|
||||
return SmokingDetectionPipeline(
|
||||
person_model=person_model,
|
||||
cigarette_model=cig_model,
|
||||
verifier_model=verifier,
|
||||
sinks=sinks,
|
||||
config=config,
|
||||
)
|
||||
|
||||
rt.manager.configure(
|
||||
source_factory=source_factory,
|
||||
sink_factory=sink_factory,
|
||||
pipeline_factory=pipeline_factory,
|
||||
initial_source=_args.source,
|
||||
)
|
||||
|
||||
# 6. Build pipeline
|
||||
if _args.staged or _args.vlm:
|
||||
vlm_key = _args.vlm_key or os.environ.get(
|
||||
"OPENAI_API_KEY" if _args.vlm_provider == "openai" else "ANTHROPIC_API_KEY"
|
||||
def clip_source_factory(path: str) -> FileStreamSource:
|
||||
return FileStreamSource(path)
|
||||
|
||||
def clip_sink_factory(src: FileStreamSource, job_id: str) -> list[Sink]:
|
||||
job_dir = Path("static/clip_jobs") / job_id
|
||||
events_dir = job_dir / "events"
|
||||
job_dir.mkdir(parents=True, exist_ok=True)
|
||||
events_dir.mkdir(parents=True, exist_ok=True)
|
||||
return [
|
||||
VideoFileStore(str(job_dir / "processed.mp4"), fps=src.fps),
|
||||
DatabaseSmartEventSink(
|
||||
rt.db,
|
||||
fps=src.fps,
|
||||
camera_source=f"ClipJob:{job_id}",
|
||||
output_dir=str(events_dir),
|
||||
),
|
||||
MetricsSink(log_interval=200),
|
||||
FileAlertStore(str(job_dir / "alerts.jsonl")),
|
||||
]
|
||||
|
||||
def clip_pipeline_factory(src: FileStreamSource, sinks: list[Sink]):
|
||||
config = PipelineConfig(
|
||||
idle_interval=0.5,
|
||||
active_interval=0.0,
|
||||
cooldown_frames=max(1, int(src.fps * 2)),
|
||||
)
|
||||
pipeline = StageBasedPipeline(
|
||||
person_model=person_model,
|
||||
cigarette_model=cig_model,
|
||||
verifier_model=verifier,
|
||||
vlm_api_key=vlm_key if _args.vlm else None,
|
||||
vlm_provider=_args.vlm_provider,
|
||||
vlm_model=_args.vlm_model,
|
||||
use_vlm=_args.vlm,
|
||||
sinks=sinks,
|
||||
config=config,
|
||||
)
|
||||
else:
|
||||
pipeline = SmokingDetectionPipeline(
|
||||
|
||||
if _args.staged or _args.vlm:
|
||||
vlm_key = _args.vlm_key or os.environ.get(
|
||||
"OPENAI_API_KEY"
|
||||
if _args.vlm_provider == "openai"
|
||||
else "ANTHROPIC_API_KEY"
|
||||
)
|
||||
return StageBasedPipeline(
|
||||
person_model=person_model,
|
||||
cigarette_model=cig_model,
|
||||
verifier_model=verifier,
|
||||
vlm_api_key=vlm_key if _args.vlm else None,
|
||||
vlm_provider=_args.vlm_provider,
|
||||
vlm_model=_args.vlm_model,
|
||||
use_vlm=_args.vlm,
|
||||
sinks=sinks,
|
||||
config=config,
|
||||
)
|
||||
|
||||
return SmokingDetectionPipeline(
|
||||
person_model=person_model,
|
||||
cigarette_model=cig_model,
|
||||
verifier_model=verifier,
|
||||
@@ -239,13 +206,18 @@ async def app_lifespan(_app: FastAPI):
|
||||
config=config,
|
||||
)
|
||||
|
||||
# 7. Launch pipeline in background thread
|
||||
t = threading.Thread(target=_run_pipeline, daemon=True, name="pipeline")
|
||||
t.start()
|
||||
logger.info("Pipeline running in background.")
|
||||
rt.clip_jobs.configure(
|
||||
source_factory=clip_source_factory,
|
||||
sink_factory=clip_sink_factory,
|
||||
pipeline_factory=clip_pipeline_factory,
|
||||
)
|
||||
ok, msg = rt.manager.start_async()
|
||||
if not ok:
|
||||
raise RuntimeError(msg)
|
||||
logger.info("%s (source=%s)", msg, _args.source)
|
||||
|
||||
# Show real addresses
|
||||
ip, hostname = _get_lan_address()
|
||||
ip, hostname = rt.get_lan_address()
|
||||
port = _args.port if hasattr(_args, "port") else 8000
|
||||
logger.info("=" * 50)
|
||||
logger.info(" Server ready")
|
||||
@@ -259,10 +231,9 @@ async def app_lifespan(_app: FastAPI):
|
||||
|
||||
# Shutdown
|
||||
logger.info("Shutting down...")
|
||||
source.stop()
|
||||
for s in sinks:
|
||||
s.close()
|
||||
db.close()
|
||||
rt.manager.stop()
|
||||
rt.clip_jobs.shutdown()
|
||||
rt.db.close()
|
||||
logger.info("Goodbye.")
|
||||
|
||||
|
||||
@@ -270,111 +241,15 @@ async def app_lifespan(_app: FastAPI):
|
||||
# FastAPI app
|
||||
# ---------------------------------------------------------------------------
|
||||
app = FastAPI(title="Smoking Detection", lifespan=app_lifespan) # type: ignore
|
||||
app.include_router(api_router)
|
||||
|
||||
# Serve static assets (alert images, CSS/JS, etc.)
|
||||
Path("static/alerts").mkdir(parents=True, exist_ok=True)
|
||||
Path("static/events").mkdir(parents=True, exist_ok=True)
|
||||
Path("static/clip_jobs").mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
# -- Auth ------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/ping")
|
||||
def ping():
|
||||
"""Health check for the mobile app to verify connectivity."""
|
||||
return {"status": "ok", "name": "SmokingDetection"}
|
||||
|
||||
|
||||
@app.post("/register")
|
||||
def register(username: str = Form(...), password: str = Form(...)):
|
||||
try:
|
||||
if db.get_user_by_username(username):
|
||||
return {"status": "fail", "message": "Username already taken"}
|
||||
new_id = db.create_user(username, password)
|
||||
return {"status": "success", "user_id": new_id, "message": "Account created"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@app.post("/login")
|
||||
def login(username: str = Form(...), password: str = Form(...)):
|
||||
try:
|
||||
user = db.get_user_by_credentials(username, password)
|
||||
if user:
|
||||
return {"status": "success", "user_id": user.id, "username": user.username}
|
||||
return {"status": "fail", "message": "Invalid username or password"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
# -- Detection history -----------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/history")
|
||||
def get_history(user_id: int | None = None):
|
||||
try:
|
||||
records = db.get_detections(user_id)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"user_id": r.user_id,
|
||||
"image_path": r.image_path,
|
||||
"confidence": r.confidence,
|
||||
"timestamp": r.timestamp,
|
||||
"camera_source": r.camera_source,
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@app.post("/clear_history")
|
||||
def clear_history():
|
||||
try:
|
||||
deleted = db.clear_detections()
|
||||
logger.info("Cleared %d records", deleted)
|
||||
return {"status": "success", "message": "History cleared"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
# -- Video / Snapshot ------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/snapshot")
|
||||
def snapshot():
|
||||
"""Return the latest frame as a JPEG."""
|
||||
if live_frame is not None:
|
||||
_, jpg = cv2.imencode(".jpg", live_frame.data)
|
||||
return Response(content=jpg.tobytes(), media_type="image/jpeg")
|
||||
return Response(status_code=404)
|
||||
|
||||
|
||||
@app.get("/video_feed")
|
||||
def video_feed():
|
||||
"""MJPEG stream - drop into an <img> tag."""
|
||||
|
||||
def generate():
|
||||
while True:
|
||||
if live_frame is not None:
|
||||
from pipeline.ops.drawing import draw_detections
|
||||
|
||||
img = live_frame.data.copy()
|
||||
draw_detections(img, live_frame.detections)
|
||||
_, jpg = cv2.imencode(".jpg", img)
|
||||
yield (
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: image/jpeg\r\n\r\n" + jpg.tobytes() + b"\r\n"
|
||||
)
|
||||
time.sleep(0.033) # ~30 FPS cap
|
||||
|
||||
return StreamingResponse(
|
||||
generate(), media_type="multipart/x-mixed-replace;boundary=frame"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Entry-point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,9 @@
|
||||
"""Package entrypoint so `python -m server` / `uv run -m server` works."""
|
||||
|
||||
import runpy
|
||||
from pathlib import Path
|
||||
|
||||
# Execute top-level server.py as __main__
|
||||
runpy.run_path(
|
||||
str(Path(__file__).resolve().parent.parent / "server.py"), run_name="__main__"
|
||||
)
|
||||
@@ -0,0 +1,371 @@
|
||||
"""API route definitions for the smoking detection server."""
|
||||
|
||||
import asyncio
|
||||
import shutil
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
File,
|
||||
Form,
|
||||
Response,
|
||||
UploadFile,
|
||||
WebSocket,
|
||||
WebSocketDisconnect,
|
||||
)
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
import server.runtime as rt
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
def ping():
|
||||
"""Health check for the mobile app to verify connectivity."""
|
||||
return {"status": "ok", "name": "SmokingDetection"}
|
||||
|
||||
|
||||
@router.post("/register")
|
||||
def register(username: str = Form(...), password: str = Form(...)):
|
||||
try:
|
||||
if rt.db.get_user_by_username(username):
|
||||
return {"status": "fail", "message": "Username already taken"}
|
||||
new_id = rt.db.create_user(username, password)
|
||||
return {"status": "success", "user_id": new_id, "message": "Account created"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/login")
|
||||
def login(username: str = Form(...), password: str = Form(...)):
|
||||
try:
|
||||
user = rt.db.get_user_by_credentials(username, password)
|
||||
if user:
|
||||
return {"status": "success", "user_id": user.id, "username": user.username}
|
||||
return {"status": "fail", "message": "Invalid username or password"}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.get("/history")
|
||||
def get_history(user_id: int | None = None):
|
||||
try:
|
||||
records = rt.db.get_detections(user_id)
|
||||
return [
|
||||
{
|
||||
"id": r.id,
|
||||
"user_id": r.user_id,
|
||||
"image_path": r.image_path,
|
||||
"confidence": r.confidence,
|
||||
"timestamp": r.timestamp,
|
||||
"camera_source": r.camera_source,
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/clear_history")
|
||||
def clear_history(user_id: int | None = Form(default=None)):
|
||||
try:
|
||||
deleted = rt.db.clear_detections(user_id)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "History cleared",
|
||||
"deleted": deleted,
|
||||
"user_id": user_id,
|
||||
}
|
||||
except Exception as e:
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
@router.get("/live/status")
|
||||
def live_status():
|
||||
"""Get current live pipeline status."""
|
||||
return {"status": "success", "live": rt.manager.status()}
|
||||
|
||||
|
||||
@router.websocket("/ws/updates")
|
||||
async def ws_updates(websocket: WebSocket, history_limit: int = 10):
|
||||
await websocket.accept()
|
||||
clamped_limit = max(1, min(history_limit, 50))
|
||||
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
records = rt.db.get_detections(None)[:clamped_limit]
|
||||
history = [
|
||||
{
|
||||
"id": r.id,
|
||||
"user_id": r.user_id,
|
||||
"image_path": r.image_path,
|
||||
"confidence": r.confidence,
|
||||
"timestamp": r.timestamp,
|
||||
"camera_source": r.camera_source,
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
except Exception:
|
||||
history = []
|
||||
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "snapshot",
|
||||
"live": rt.manager.status(),
|
||||
"history": history,
|
||||
"history_count": len(history),
|
||||
"server_time": int(time.time()),
|
||||
}
|
||||
)
|
||||
await asyncio.sleep(1.0)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
|
||||
@router.post("/live/start")
|
||||
def live_start(source: str | None = Form(default=None)):
|
||||
"""Start live pipeline (optionally with source override)."""
|
||||
ok, message = rt.manager.start(source)
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/live/start_phone")
|
||||
def live_start_phone():
|
||||
"""Start live pipeline using phone frame push source."""
|
||||
ok, message = rt.manager.switch_source("phone")
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
"hint": "Push JPEG frames to /live/ws/push_frames",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/live/source_history")
|
||||
def live_source_history(limit: int = 10):
|
||||
return {
|
||||
"status": "success",
|
||||
"current_source": rt.manager.status().get("source"),
|
||||
"history": rt.manager.source_history(limit=limit),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/live/switch_previous")
|
||||
def live_switch_previous():
|
||||
ok, message = rt.manager.switch_previous()
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/live/stop")
|
||||
def live_stop():
|
||||
"""Stop live pipeline."""
|
||||
ok, message = rt.manager.stop()
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/live/pause")
|
||||
def live_pause():
|
||||
"""Pause live pipeline (stops source, resumable)."""
|
||||
ok, message = rt.manager.pause()
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/live/resume")
|
||||
def live_resume():
|
||||
"""Resume paused live pipeline."""
|
||||
ok, message = rt.manager.resume()
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/live/switch_source")
|
||||
def live_switch_source(source: str = Form(...)):
|
||||
"""Switch active live source (webcam index or RTSP URL)."""
|
||||
ok, message = rt.manager.switch_source(source)
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/live/push_frame")
|
||||
async def live_push_frame(frame: UploadFile = File(...)):
|
||||
"""Push one JPEG frame from phone/app into live source queue."""
|
||||
try:
|
||||
data = await frame.read()
|
||||
finally:
|
||||
await frame.close()
|
||||
|
||||
if not data:
|
||||
return {"status": "fail", "message": "Empty frame payload"}
|
||||
|
||||
ok, message = rt.push_live_frame_bytes(data)
|
||||
return {
|
||||
"status": "success" if ok else "fail",
|
||||
"message": message,
|
||||
"live": rt.manager.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.websocket("/live/ws/push_frames")
|
||||
async def live_ws_push_frames(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
accepted = 0
|
||||
dropped = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
message = await websocket.receive()
|
||||
if message.get("type") == "websocket.disconnect":
|
||||
break
|
||||
|
||||
payload = message.get("bytes")
|
||||
if payload is None:
|
||||
text_payload = message.get("text")
|
||||
if text_payload == "ping":
|
||||
await websocket.send_json({"type": "pong"})
|
||||
continue
|
||||
|
||||
ok, _ = rt.push_live_frame_bytes(payload)
|
||||
if ok:
|
||||
accepted += 1
|
||||
else:
|
||||
dropped += 1
|
||||
|
||||
if (accepted + dropped) % 60 == 0:
|
||||
await websocket.send_json(
|
||||
{
|
||||
"type": "stats",
|
||||
"accepted": accepted,
|
||||
"dropped": dropped,
|
||||
"live": rt.manager.status(),
|
||||
}
|
||||
)
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
|
||||
|
||||
@router.post("/clips/submit")
|
||||
async def clips_submit(
|
||||
file: UploadFile | None = File(default=None),
|
||||
path: str | None = Form(default=None),
|
||||
):
|
||||
"""Queue a clip processing job from uploaded file or existing local path."""
|
||||
if file is None and not path:
|
||||
return {
|
||||
"status": "fail",
|
||||
"message": "Provide either an uploaded file or a local file path",
|
||||
}
|
||||
|
||||
clip_input_path = ""
|
||||
source_name = None
|
||||
|
||||
try:
|
||||
if file is not None:
|
||||
uploads_dir = Path("media/clip_jobs/uploads")
|
||||
uploads_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
source_name = file.filename or "upload.mp4"
|
||||
suffix = Path(source_name).suffix or ".mp4"
|
||||
safe_name = f"clip_{int(time.time() * 1000)}{suffix}"
|
||||
saved_path = uploads_dir / safe_name
|
||||
|
||||
with saved_path.open("wb") as out:
|
||||
shutil.copyfileobj(file.file, out)
|
||||
|
||||
clip_input_path = str(saved_path)
|
||||
else:
|
||||
local_path = Path(path or "")
|
||||
if not local_path.exists() or not local_path.is_file():
|
||||
return {
|
||||
"status": "fail",
|
||||
"message": f"File not found: {local_path}",
|
||||
}
|
||||
clip_input_path = str(local_path)
|
||||
source_name = local_path.name
|
||||
finally:
|
||||
if file is not None:
|
||||
await file.close()
|
||||
|
||||
job = rt.clip_jobs.submit(clip_input_path, source_name)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": "Clip job queued",
|
||||
"job": job,
|
||||
"queue": rt.clip_jobs.status(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/clips")
|
||||
def clips_list(limit: int = 20):
|
||||
"""List recent clip jobs."""
|
||||
return {
|
||||
"status": "success",
|
||||
"queue": rt.clip_jobs.status(),
|
||||
"jobs": rt.clip_jobs.list(limit=max(1, min(limit, 200))),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/clips/{job_id}")
|
||||
def clips_get(job_id: str):
|
||||
"""Get one clip job state."""
|
||||
job = rt.clip_jobs.get(job_id)
|
||||
if not job:
|
||||
return {"status": "fail", "message": "Job not found", "job_id": job_id}
|
||||
return {"status": "success", "job": job}
|
||||
|
||||
|
||||
@router.get("/snapshot")
|
||||
def snapshot():
|
||||
"""Return the latest frame as a JPEG."""
|
||||
if rt.live_frame is not None:
|
||||
_, jpg = cv2.imencode(".jpg", rt.live_frame.data)
|
||||
return Response(content=jpg.tobytes(), media_type="image/jpeg")
|
||||
return Response(status_code=404)
|
||||
|
||||
|
||||
@router.get("/video_feed")
|
||||
def video_feed():
|
||||
"""MJPEG stream - drop into an <img> tag."""
|
||||
|
||||
def generate():
|
||||
while True:
|
||||
if rt.live_frame is not None:
|
||||
from pipeline.ops.drawing import draw_detections
|
||||
|
||||
img = rt.live_frame.data.copy()
|
||||
draw_detections(img, rt.live_frame.detections)
|
||||
_, jpg = cv2.imencode(".jpg", img)
|
||||
yield (
|
||||
b"--frame\r\n"
|
||||
b"Content-Type: image/jpeg\r\n\r\n" + jpg.tobytes() + b"\r\n"
|
||||
)
|
||||
time.sleep(0.033)
|
||||
|
||||
return StreamingResponse(
|
||||
generate(), media_type="multipart/x-mixed-replace;boundary=frame"
|
||||
)
|
||||
@@ -0,0 +1,650 @@
|
||||
"""Runtime state and utility helpers for the FastAPI server."""
|
||||
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from queue import Empty, Queue
|
||||
from typing import Any, Callable
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from db import SQLiteRepository
|
||||
from db.repository import Repository
|
||||
from pipeline.protocols import Sink, StreamSource
|
||||
from pipeline.sources.file import FileStreamSource
|
||||
from pipeline.types import Frame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global runtime state
|
||||
# (kept as module-level singletons for current single-instance server design)
|
||||
db: Repository = SQLiteRepository()
|
||||
pipeline = None
|
||||
source: StreamSource | None = None
|
||||
live_frame: Frame | None = None
|
||||
|
||||
|
||||
SourceFactory = Callable[[str], StreamSource]
|
||||
SinkFactory = Callable[[StreamSource], list[Sink]]
|
||||
PipelineFactory = Callable[[StreamSource, list[Sink]], Any]
|
||||
|
||||
ClipSourceFactory = Callable[[str], FileStreamSource]
|
||||
ClipSinkFactory = Callable[[FileStreamSource, str], list[Sink]]
|
||||
ClipPipelineFactory = Callable[[FileStreamSource, list[Sink]], Any]
|
||||
|
||||
|
||||
class LiveFrameSink(Sink):
|
||||
"""Captures the latest frame so API endpoints can stream it."""
|
||||
|
||||
def handle(self, frame: Frame):
|
||||
global live_frame
|
||||
live_frame = frame
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
|
||||
class PushFrameSource(StreamSource):
|
||||
"""Frame source that receives JPEG frames pushed via API."""
|
||||
|
||||
def __init__(self, fps: float = 15.0, max_queue: int = 120):
|
||||
self._fps = fps
|
||||
self._queue: Queue[Frame] = Queue(maxsize=max_queue)
|
||||
self._running = True
|
||||
self._frame_id = 0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
@property
|
||||
def fps(self) -> float:
|
||||
return self._fps
|
||||
|
||||
def reset(self):
|
||||
with self._lock:
|
||||
self._running = True
|
||||
self._frame_id = 0
|
||||
while not self._queue.empty():
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
self._queue.task_done()
|
||||
except Empty:
|
||||
break
|
||||
|
||||
def push_jpeg(self, data: bytes) -> tuple[bool, str]:
|
||||
if not self._running:
|
||||
return False, "phone source not running"
|
||||
|
||||
arr = np.frombuffer(data, dtype=np.uint8)
|
||||
image = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
||||
if image is None:
|
||||
return False, "invalid image payload"
|
||||
|
||||
with self._lock:
|
||||
frame = Frame(timestamp=time.time(), data=image, frame_id=self._frame_id)
|
||||
self._frame_id += 1
|
||||
|
||||
if self._queue.full():
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
self._queue.task_done()
|
||||
except Empty:
|
||||
pass
|
||||
|
||||
self._queue.put(frame)
|
||||
return True, "frame pushed"
|
||||
|
||||
def stream(self):
|
||||
while self._running:
|
||||
try:
|
||||
frame = self._queue.get(timeout=0.5)
|
||||
except Empty:
|
||||
continue
|
||||
try:
|
||||
yield frame
|
||||
finally:
|
||||
self._queue.task_done()
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
|
||||
|
||||
phone_source = PushFrameSource()
|
||||
|
||||
|
||||
def push_live_frame_bytes(data: bytes) -> tuple[bool, str]:
|
||||
return phone_source.push_jpeg(data)
|
||||
|
||||
|
||||
class PipelineManager:
|
||||
"""Controls the live pipeline lifecycle (start/stop/pause/resume/switch)."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.RLock()
|
||||
self._thread: threading.Thread | None = None
|
||||
self._start_thread: threading.Thread | None = None
|
||||
self._sinks: list[Sink] = []
|
||||
self._session_id = 0
|
||||
|
||||
self._source_factory: SourceFactory | None = None
|
||||
self._sink_factory: SinkFactory | None = None
|
||||
self._pipeline_factory: PipelineFactory | None = None
|
||||
|
||||
self._source_spec: str = "0"
|
||||
self._source_history: list[dict[str, Any]] = []
|
||||
self.running = False
|
||||
self.starting = False
|
||||
self.stopping = False
|
||||
self.paused = False
|
||||
self.last_error: str | None = None
|
||||
|
||||
def _record_source_locked(self, source_spec: str):
|
||||
if (
|
||||
self._source_history
|
||||
and self._source_history[-1].get("source") == source_spec
|
||||
):
|
||||
return
|
||||
self._source_history.append(
|
||||
{
|
||||
"source": source_spec,
|
||||
"timestamp": datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
)
|
||||
if len(self._source_history) > 50:
|
||||
self._source_history = self._source_history[-50:]
|
||||
|
||||
def configure(
|
||||
self,
|
||||
source_factory: SourceFactory,
|
||||
sink_factory: SinkFactory,
|
||||
pipeline_factory: PipelineFactory,
|
||||
initial_source: str = "0",
|
||||
):
|
||||
with self._lock:
|
||||
self._source_factory = source_factory
|
||||
self._sink_factory = sink_factory
|
||||
self._pipeline_factory = pipeline_factory
|
||||
self._source_spec = initial_source
|
||||
self._record_source_locked(initial_source)
|
||||
|
||||
def _close_sinks(self, sinks: list[Sink] | None = None):
|
||||
sink_list = self._sinks if sinks is None else sinks
|
||||
for sink in sink_list:
|
||||
try:
|
||||
sink.close()
|
||||
except Exception:
|
||||
logger.exception("Error closing sink: %s", sink.__class__.__name__)
|
||||
if sinks is None:
|
||||
self._sinks = []
|
||||
|
||||
def _run_wrapper(self, session_id: int):
|
||||
global pipeline, source
|
||||
assert pipeline is not None and source is not None
|
||||
try:
|
||||
pipeline.process_stream(source)
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self.last_error = str(e)
|
||||
logger.exception("Pipeline crashed")
|
||||
finally:
|
||||
with self._lock:
|
||||
if self._session_id == session_id:
|
||||
self.running = False
|
||||
self.stopping = False
|
||||
self.starting = False
|
||||
self.paused = False
|
||||
self._close_sinks()
|
||||
pipeline = None
|
||||
source = None
|
||||
|
||||
def start(self, source_spec: str | None = None) -> tuple[bool, str]:
|
||||
global pipeline, source, live_frame
|
||||
with self._lock:
|
||||
if self.starting:
|
||||
return False, "Pipeline is starting"
|
||||
|
||||
if self.stopping:
|
||||
return False, "Pipeline is stopping"
|
||||
|
||||
if self.running:
|
||||
return False, "Pipeline already running"
|
||||
|
||||
if source_spec is not None:
|
||||
self._source_spec = source_spec
|
||||
|
||||
if (
|
||||
not self._source_factory
|
||||
or not self._sink_factory
|
||||
or not self._pipeline_factory
|
||||
):
|
||||
return False, "PipelineManager not configured"
|
||||
|
||||
source_spec_local = self._source_spec
|
||||
source_factory = self._source_factory
|
||||
sink_factory = self._sink_factory
|
||||
pipeline_factory = self._pipeline_factory
|
||||
self.starting = True
|
||||
|
||||
try:
|
||||
source_obj = source_factory(source_spec_local)
|
||||
sinks_obj = sink_factory(source_obj)
|
||||
pipeline_obj = pipeline_factory(source_obj, sinks_obj)
|
||||
except Exception as e:
|
||||
with self._lock:
|
||||
self.starting = False
|
||||
self.last_error = str(e)
|
||||
return False, f"Failed to build pipeline: {e}"
|
||||
|
||||
with self._lock:
|
||||
if self.running:
|
||||
self.starting = False
|
||||
try:
|
||||
source_obj.stop()
|
||||
except Exception:
|
||||
logger.exception("Error stopping source after race")
|
||||
return False, "Pipeline already running"
|
||||
|
||||
source = source_obj
|
||||
self._sinks = sinks_obj
|
||||
pipeline = pipeline_obj
|
||||
live_frame = None
|
||||
self._session_id += 1
|
||||
session_id = self._session_id
|
||||
|
||||
self._thread = threading.Thread(
|
||||
target=self._run_wrapper,
|
||||
args=(session_id,),
|
||||
daemon=True,
|
||||
name="pipeline",
|
||||
)
|
||||
self._thread.start()
|
||||
self.starting = False
|
||||
self.running = True
|
||||
self.paused = False
|
||||
self.last_error = None
|
||||
return True, f"Pipeline started (source={self._source_spec})"
|
||||
|
||||
def start_async(self, source_spec: str | None = None) -> tuple[bool, str]:
|
||||
with self._lock:
|
||||
if self.starting:
|
||||
return False, "Pipeline is starting"
|
||||
if self.running:
|
||||
return False, "Pipeline already running"
|
||||
|
||||
def _runner():
|
||||
ok, msg = self.start(source_spec)
|
||||
if ok:
|
||||
logger.info(msg)
|
||||
else:
|
||||
logger.warning(msg)
|
||||
|
||||
self._start_thread = threading.Thread(
|
||||
target=_runner,
|
||||
daemon=True,
|
||||
name="pipeline-start",
|
||||
)
|
||||
self._start_thread.start()
|
||||
return True, "Pipeline start requested"
|
||||
|
||||
def stop(self) -> tuple[bool, str]:
|
||||
global pipeline, source
|
||||
with self._lock:
|
||||
if self.stopping:
|
||||
return False, "Pipeline is stopping"
|
||||
|
||||
self.stopping = True
|
||||
thread = self._thread
|
||||
source_obj = source
|
||||
stop_session_id = self._session_id
|
||||
|
||||
if source_obj is not None:
|
||||
try:
|
||||
source_obj.stop()
|
||||
except Exception:
|
||||
logger.exception("Error stopping source")
|
||||
|
||||
if thread and thread.is_alive():
|
||||
thread.join(timeout=3.0)
|
||||
|
||||
with self._lock:
|
||||
if self._session_id != stop_session_id:
|
||||
self.stopping = False
|
||||
return True, "Pipeline stop request completed"
|
||||
self.starting = False
|
||||
self.running = False
|
||||
self.stopping = False
|
||||
self.paused = False
|
||||
self._close_sinks()
|
||||
pipeline = None
|
||||
source = None
|
||||
return True, "Pipeline stopped"
|
||||
|
||||
def pause(self) -> tuple[bool, str]:
|
||||
with self._lock:
|
||||
if not self.running:
|
||||
return False, "Pipeline is not running"
|
||||
|
||||
ok, msg = self.stop()
|
||||
if ok:
|
||||
with self._lock:
|
||||
self.paused = True
|
||||
return True, "Pipeline paused"
|
||||
return False, msg
|
||||
|
||||
def resume(self) -> tuple[bool, str]:
|
||||
with self._lock:
|
||||
if self.running:
|
||||
return False, "Pipeline already running"
|
||||
if not self.paused:
|
||||
return False, "Pipeline is not paused"
|
||||
|
||||
ok, msg = self.start()
|
||||
if ok:
|
||||
with self._lock:
|
||||
self.paused = False
|
||||
return True, "Pipeline resumed"
|
||||
return False, msg
|
||||
|
||||
def switch_source(self, source_spec: str) -> tuple[bool, str]:
|
||||
with self._lock:
|
||||
self._source_spec = source_spec
|
||||
self._record_source_locked(source_spec)
|
||||
running = self.running
|
||||
paused = self.paused
|
||||
|
||||
if running:
|
||||
self.stop()
|
||||
return self.start(source_spec)
|
||||
|
||||
if paused:
|
||||
return True, f"Source set to {source_spec} (will apply on resume)"
|
||||
|
||||
return self.start(source_spec)
|
||||
|
||||
def source_history(self, limit: int = 10) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
clamped = max(1, min(limit, 50))
|
||||
items = self._source_history[-clamped:]
|
||||
return list(reversed(items))
|
||||
|
||||
def switch_previous(self) -> tuple[bool, str]:
|
||||
with self._lock:
|
||||
current = self._source_spec
|
||||
previous = None
|
||||
for item in reversed(self._source_history):
|
||||
source_spec = item.get("source")
|
||||
if source_spec and source_spec != current:
|
||||
previous = source_spec
|
||||
break
|
||||
|
||||
if previous is None:
|
||||
return False, "No previous source in history"
|
||||
|
||||
return self.switch_source(previous)
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"running": self.running,
|
||||
"starting": self.starting,
|
||||
"stopping": self.stopping,
|
||||
"paused": self.paused,
|
||||
"source": self._source_spec,
|
||||
"source_history": self.source_history(limit=10),
|
||||
"last_error": self.last_error,
|
||||
"thread_alive": bool(self._thread and self._thread.is_alive()),
|
||||
}
|
||||
|
||||
|
||||
manager = PipelineManager()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClipJob:
|
||||
job_id: str
|
||||
input_path: str
|
||||
submitted_at: str
|
||||
source_name: str | None = None
|
||||
status: str = "queued" # queued | running | done | failed
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"job_id": self.job_id,
|
||||
"input_path": self.input_path,
|
||||
"source_name": self.source_name,
|
||||
"status": self.status,
|
||||
"submitted_at": self.submitted_at,
|
||||
"started_at": self.started_at,
|
||||
"finished_at": self.finished_at,
|
||||
"error": self.error,
|
||||
"outputs": clip_job_outputs(self.job_id),
|
||||
}
|
||||
|
||||
|
||||
def clip_job_outputs(job_id: str) -> dict[str, Any]:
|
||||
base_local = Path("static") / "clip_jobs" / job_id
|
||||
events_local = base_local / "events"
|
||||
|
||||
event_clips: list[str] = []
|
||||
if events_local.exists():
|
||||
for event_file in sorted(events_local.glob("event_*.mp4")):
|
||||
event_clips.append(f"/static/clip_jobs/{job_id}/events/{event_file.name}")
|
||||
|
||||
return {
|
||||
"processed_video": f"/static/clip_jobs/{job_id}/processed.mp4",
|
||||
"alerts_log": f"/static/clip_jobs/{job_id}/alerts.jsonl",
|
||||
"events_dir": f"/static/clip_jobs/{job_id}/events/",
|
||||
"event_clips": event_clips,
|
||||
}
|
||||
|
||||
|
||||
class ClipJobManager:
|
||||
"""Simple queued clip processor with a single worker."""
|
||||
|
||||
def __init__(self):
|
||||
self._lock = threading.RLock()
|
||||
self._queue: Queue[str] = Queue()
|
||||
self._jobs: dict[str, ClipJob] = {}
|
||||
self._ordered_ids: list[str] = []
|
||||
|
||||
self._source_factory: ClipSourceFactory | None = None
|
||||
self._sink_factory: ClipSinkFactory | None = None
|
||||
self._pipeline_factory: ClipPipelineFactory | None = None
|
||||
|
||||
self._shutdown = False
|
||||
self._worker = threading.Thread(
|
||||
target=self._worker_loop,
|
||||
daemon=True,
|
||||
name="clip-worker",
|
||||
)
|
||||
self._worker.start()
|
||||
|
||||
def configure(
|
||||
self,
|
||||
source_factory: ClipSourceFactory,
|
||||
sink_factory: ClipSinkFactory,
|
||||
pipeline_factory: ClipPipelineFactory,
|
||||
):
|
||||
with self._lock:
|
||||
self._source_factory = source_factory
|
||||
self._sink_factory = sink_factory
|
||||
self._pipeline_factory = pipeline_factory
|
||||
|
||||
def submit(self, input_path: str, source_name: str | None = None) -> dict[str, Any]:
|
||||
now = datetime.now().isoformat(timespec="seconds")
|
||||
job_id = f"job_{int(time.time() * 1000)}"
|
||||
job = ClipJob(
|
||||
job_id=job_id,
|
||||
input_path=input_path,
|
||||
source_name=source_name,
|
||||
submitted_at=now,
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
self._jobs[job_id] = job
|
||||
self._ordered_ids.append(job_id)
|
||||
self._queue.put(job_id)
|
||||
return job.to_dict()
|
||||
|
||||
def get(self, job_id: str) -> dict[str, Any] | None:
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
return job.to_dict() if job else None
|
||||
|
||||
def list(self, limit: int = 20) -> list[dict[str, Any]]:
|
||||
with self._lock:
|
||||
ids = self._ordered_ids[-limit:]
|
||||
ids.reverse()
|
||||
return [self._jobs[job_id].to_dict() for job_id in ids]
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
queued = sum(1 for job in self._jobs.values() if job.status == "queued")
|
||||
running = sum(1 for job in self._jobs.values() if job.status == "running")
|
||||
done = sum(1 for job in self._jobs.values() if job.status == "done")
|
||||
failed = sum(1 for job in self._jobs.values() if job.status == "failed")
|
||||
return {
|
||||
"queued": queued,
|
||||
"running": running,
|
||||
"done": done,
|
||||
"failed": failed,
|
||||
"worker_alive": self._worker.is_alive(),
|
||||
}
|
||||
|
||||
def shutdown(self):
|
||||
with self._lock:
|
||||
self._shutdown = True
|
||||
|
||||
if self._worker.is_alive():
|
||||
self._worker.join(timeout=2.0)
|
||||
|
||||
def _set_job_state(
|
||||
self,
|
||||
job_id: str,
|
||||
status: str,
|
||||
*,
|
||||
started_at: str | None = None,
|
||||
finished_at: str | None = None,
|
||||
error: str | None = None,
|
||||
):
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
job.status = status
|
||||
if started_at is not None:
|
||||
job.started_at = started_at
|
||||
if finished_at is not None:
|
||||
job.finished_at = finished_at
|
||||
if error is not None:
|
||||
job.error = error
|
||||
|
||||
def _worker_loop(self):
|
||||
while True:
|
||||
with self._lock:
|
||||
if self._shutdown:
|
||||
return
|
||||
|
||||
try:
|
||||
job_id = self._queue.get(timeout=0.5)
|
||||
except Empty:
|
||||
continue
|
||||
|
||||
self._process_job(job_id)
|
||||
self._queue.task_done()
|
||||
|
||||
def _process_job(self, job_id: str):
|
||||
with self._lock:
|
||||
job = self._jobs.get(job_id)
|
||||
source_factory = self._source_factory
|
||||
sink_factory = self._sink_factory
|
||||
pipeline_factory = self._pipeline_factory
|
||||
|
||||
if not job:
|
||||
return
|
||||
|
||||
if not source_factory or not sink_factory or not pipeline_factory:
|
||||
self._set_job_state(job_id, "failed", error="ClipJobManager not configured")
|
||||
return
|
||||
|
||||
self._set_job_state(
|
||||
job_id,
|
||||
"running",
|
||||
started_at=datetime.now().isoformat(timespec="seconds"),
|
||||
error=None,
|
||||
)
|
||||
|
||||
paused_live = False
|
||||
try:
|
||||
live_status = manager.status()
|
||||
if live_status.get("running"):
|
||||
ok, _ = manager.pause()
|
||||
paused_live = ok
|
||||
|
||||
src = source_factory(job.input_path)
|
||||
sinks = sink_factory(src, job_id)
|
||||
pipe = pipeline_factory(src, sinks)
|
||||
|
||||
try:
|
||||
pipe.process_stream(src)
|
||||
finally:
|
||||
try:
|
||||
src.stop()
|
||||
except Exception:
|
||||
logger.exception("Failed to stop clip source for %s", job_id)
|
||||
|
||||
for sink in sinks:
|
||||
try:
|
||||
sink.close()
|
||||
except Exception:
|
||||
logger.exception("Failed to close clip sink for %s", job_id)
|
||||
|
||||
self._set_job_state(
|
||||
job_id,
|
||||
"done",
|
||||
finished_at=datetime.now().isoformat(timespec="seconds"),
|
||||
)
|
||||
logger.info("Clip job completed: %s", job_id)
|
||||
except Exception as e:
|
||||
logger.exception("Clip job failed: %s", job_id)
|
||||
self._set_job_state(
|
||||
job_id,
|
||||
"failed",
|
||||
finished_at=datetime.now().isoformat(timespec="seconds"),
|
||||
error=str(e),
|
||||
)
|
||||
finally:
|
||||
if paused_live:
|
||||
manager.resume()
|
||||
|
||||
|
||||
clip_jobs = ClipJobManager()
|
||||
|
||||
|
||||
def get_lan_address() -> tuple[str, str]:
|
||||
"""Return (ip, hostname) for the primary LAN interface."""
|
||||
ip = "127.0.0.1"
|
||||
try:
|
||||
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
probe.connect(("8.8.8.8", 80))
|
||||
ip = probe.getsockname()[0]
|
||||
probe.close()
|
||||
except Exception:
|
||||
pass
|
||||
hostname = socket.getfqdn()
|
||||
return ip, hostname
|
||||
|
||||
|
||||
def run_pipeline():
|
||||
"""Blocking call used by the background pipeline thread."""
|
||||
assert pipeline is not None and source is not None
|
||||
try:
|
||||
pipeline.process_stream(source)
|
||||
except Exception:
|
||||
logger.exception("Pipeline crashed")
|
||||
@@ -293,7 +293,7 @@ dependencies = [
|
||||
{ name = "torchvision", version = "0.25.0", source = { registry = "https://pypi.org/simple" }, marker = "sys_platform != 'linux' and sys_platform != 'win32'" },
|
||||
{ name = "torchvision", version = "0.25.0+cu130", source = { registry = "https://download.pytorch.org/whl/cu130" }, marker = "sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
{ name = "ultralytics" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
@@ -310,7 +310,7 @@ requires-dist = [
|
||||
{ name = "torchvision", marker = "sys_platform != 'linux' and sys_platform != 'win32'", specifier = ">=0.24.1" },
|
||||
{ name = "torchvision", marker = "sys_platform == 'linux' or sys_platform == 'win32'", specifier = ">=0.24.1", index = "https://download.pytorch.org/whl/cu130" },
|
||||
{ name = "ultralytics", specifier = ">=8.3.248" },
|
||||
{ name = "uvicorn", specifier = ">=0.41.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.41.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -322,6 +322,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
@@ -1345,3 +1367,133 @@ sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
|
||||
]
|
||||
Reference in new issue
Block a user