Skip to content

Latest commit

 

History

History
745 lines (574 loc) · 17.9 KB

File metadata and controls

745 lines (574 loc) · 17.9 KB

REST API Reference

cctvQL exposes a REST API when running in serve mode. All endpoints return JSON unless otherwise stated.

Base URL: http://localhost:8000 (default)
Interactive docs: http://localhost:8000/docs (Swagger UI)


Authentication

Single-tenant (default)

Set the CCTVQL_API_KEY environment variable to enable API key authentication. All requests must then include the header:

X-API-Key: your-api-key-here

If the variable is not set, all endpoints are open (suitable for private network deployments).

Multi-tenant mode

Set CCTVQL_MULTI_TENANT=1 to enable per-user JWT authentication with camera permission groups.

export CCTVQL_MULTI_TENANT=1
export CCTVQL_SECRET_KEY=your-random-secret   # optional, auto-generated if omitted
export CCTVQL_DB_PATH=/data/cctvql.db         # required for user persistence

All requests (except /health) must include a Bearer token:

Authorization: Bearer <token>

Get a token via POST /auth/login. See Multi-Tenant & User Management below.


Multi-Tenant & User Management

Only available when CCTVQL_MULTI_TENANT=1.

POST /auth/login

Obtain a JWT access token.

Request body:

{"username": "alice", "password": "s3cur3P@ss"}

Response:

{
  "access_token": "eyJ...",
  "token_type": "bearer",
  "expires_in": 86400,
  "user_id": "abc123",
  "username": "alice",
  "role": "admin"
}

POST /auth/register

Create a new user account.

  • The first registration becomes admin automatically (bootstrap — no token needed).
  • All subsequent registrations require an admin Bearer token.

Request body:

{
  "username": "bob",
  "password": "s3cur3P@ss",
  "role": "viewer",
  "camera_groups": ["Front Door", "Driveway"]
}

camera_groups restricts which cameras the user can query. Empty list = all cameras.

Returns 201 Created with the user object (no password hash).

GET /users

List all user accounts (admin only).

GET /users/me

Return the currently authenticated user's profile.

PATCH /users/{user_id}

Update a user account (admin only). Accepts: role, camera_groups, active, password.

# Restrict a viewer to two cameras
curl -X PATCH http://localhost:8000/users/abc123 \
  -H "Authorization: Bearer <admin-token>" \
  -H "Content-Type: application/json" \
  -d '{"camera_groups": ["Front Door", "Backyard"]}'

# Deactivate a user
curl -X PATCH http://localhost:8000/users/abc123 \
  -H "Authorization: Bearer <admin-token>" \
  -d '{"active": false}'

DELETE /users/{user_id}

Delete a user account (admin only). Returns 409 if attempting to delete the last admin.


POST /query

Submit a natural language query. Supports multi-turn conversation via session_id.

Request body:

{
  "query": "Was there any motion on the driveway camera last night?",
  "session_id": "my-session"
}
Field Type Required Description
query string Natural language question
session_id string Session ID for multi-turn conversation (auto-generated if omitted)

Response:

{
  "answer": "Yes — 2 motion events on Driveway between 23:12 and 23:58.",
  "intent": "get_events",
  "session_id": "my-session"
}

Example — multi-turn:

# Turn 1
curl -X POST http://localhost:8000/query \
  -H "Content-Type: application/json" \
  -d '{"query": "Show cameras", "session_id": "s1"}'

# Turn 2 — follow-up
curl -X POST http://localhost:8000/query \
  -H "Content-Type: application/json" \
  -d '{"query": "Any motion on the first one today?", "session_id": "s1"}'

Conversation history is persisted to SQLite when the database is configured. Sessions survive server restarts. See persistence.md.


GET /cameras

List all cameras in the connected system.

Response:

[
  {
    "id": "front_door",
    "name": "front_door",
    "status": "online",
    "location": null,
    "zones": ["driveway", "porch"],
    "snapshot_url": "http://192.168.1.100:5000/api/front_door/latest.jpg",
    "stream_url": "http://192.168.1.100:5000/live/front_door"
  }
]

POST /cameras/{camera_id}/ptz

Send a PTZ (Pan / Tilt / Zoom) command to a camera.

Only adapters that support PTZ will execute the command. The demo adapter returns 501 Not Implemented.

Path parameter:

Parameter Description
camera_id Camera ID as returned by GET /cameras

Request body:

{
  "action": "left",
  "speed": 50
}
Field Type Required Description
action string One of: left, right, up, down, zoom_in, zoom_out, home, preset
speed integer Movement speed 1–100 (default: 50)
preset_id integer Required when action is preset

Response:

{"status": "ok", "camera_id": "front_door", "action": "left"}

Error responses:

  • 404 — camera not found
  • 422 — invalid action or missing preset_id
  • 501 — PTZ not supported by the active adapter

Examples:

# Pan left
curl -X POST http://localhost:8000/cameras/front_door/ptz \
  -H "Content-Type: application/json" \
  -d '{"action": "left", "speed": 30}'

# Go to preset 2
curl -X POST http://localhost:8000/cameras/front_door/ptz \
  -H "Content-Type: application/json" \
  -d '{"action": "preset", "preset_id": 2}'

# Return to home position
curl -X POST http://localhost:8000/cameras/front_door/ptz \
  -H "Content-Type: application/json" \
  -d '{"action": "home"}'

GET /cameras/{camera_id}/ptz/presets

List saved PTZ presets for a camera.

Response:

[
  {"id": 1, "name": "Home"},
  {"id": 2, "name": "Gate"},
  {"id": 3, "name": "Driveway"}
]

GET /events

Fetch events with optional filters.

Query parameters:

Parameter Type Description
camera string Camera name or ID (partial match)
label string Object label (person, car, dog, etc.)
zone string Zone name
after integer Unix timestamp — events after this time
before integer Unix timestamp — events before this time
limit integer Max results (1–200, default: 20)

Example:

# Person detections on driveway in the last hour
curl "http://localhost:8000/events?camera=driveway&label=person&after=$(date -d '1 hour ago' +%s)"

Response:

[
  {
    "id": "1abc2def",
    "camera": "driveway",
    "type": "object_detected",
    "start_time": "2026-04-13T22:14:31",
    "end_time": "2026-04-13T22:14:44",
    "objects": [
      {"label": "person", "confidence": 0.96}
    ],
    "zones": ["front_yard"],
    "snapshot_url": "http://192.168.1.100:5000/api/events/1abc2def/snapshot.jpg",
    "clip_url": "http://192.168.1.100:5000/api/events/1abc2def/clip.mp4"
  }
]

GET /events/export

Export events as a downloadable CSV or JSON file.

Query parameters:

Parameter Type Default Description
fmt string csv Export format: csv or json
camera string Filter by camera name (partial match)
label string Filter by object label
limit integer 1000 Max events to export

CSV export (default):

curl "http://localhost:8000/events/export" -o events.csv

The CSV includes headers:

id,camera,type,start_time,end_time,objects,zones,snapshot_url,clip_url

Response headers:

Content-Type: text/csv; charset=utf-8
Content-Disposition: attachment; filename="cctvql_events.csv"

JSON export:

curl "http://localhost:8000/events/export?fmt=json" -o events.json

Filtered export:

# Export only Front Door person detections
curl "http://localhost:8000/events/export?camera=Front+Door&label=person&limit=500" -o persons.csv

GET /health

Check health of the adapter and LLM backend.

Response:

{
  "status": "ok",
  "adapter": "frigate",
  "llm": "ollama",
  "adapter_ok": true,
  "llm_ok": true
}

status is "ok" when both adapter and LLM are healthy, "degraded" otherwise.


GET /health/cameras

Get the latest health status for each individual camera.

The health monitor polls the adapter every CCTVQL_HEALTH_POLL_INTERVAL seconds (default: 60). On first startup, the list may be empty until the first poll completes.

Response:

[
  {
    "camera_id": "front_door",
    "camera_name": "Front Door",
    "status": "online",
    "last_checked": "2026-04-14T09:31:02",
    "latency_ms": 42
  },
  {
    "camera_id": "backyard",
    "camera_name": "Backyard",
    "status": "offline",
    "last_checked": "2026-04-14T09:31:03",
    "latency_ms": null
  }
]

GET /anomalies

Detect statistically unusual activity across cameras.

Compares a recent observe window against a historical baseline of the same hour-of-day. Returns both spikes (more events than normal) and silences (unusually quiet periods).

Query parameters:

Parameter Type Default Description
hours integer 24 Observe window in hours (1–168)
baseline_days integer 7 Days of history used to build the normal baseline (1–30)
camera string Restrict to a specific camera name
threshold float 2.0 Z-score threshold above which activity is anomalous (0.5–10.0)

Example:

# Last 24 hours with default threshold
curl "http://localhost:8000/anomalies"

# Last 6 hours, Front Door only, more sensitive
curl "http://localhost:8000/anomalies?hours=6&camera=Front+Door&threshold=1.5"

Response:

{
  "observe_start": "2026-04-13T10:00",
  "observe_end":   "2026-04-14T10:00",
  "baseline_days": 7,
  "threshold": 2.0,
  "total": 2,
  "high": 1,
  "medium": 1,
  "low": 0,
  "anomalies": [
    {
      "camera": "Front Door",
      "period_start": "2026-04-14T02:00",
      "period_end":   "2026-04-14T03:00",
      "event_count": 18,
      "expected_count": 1.4,
      "z_score": 5.83,
      "anomaly_type": "spike",
      "severity": "high",
      "top_labels": ["person", "car"]
    },
    {
      "camera": "Backyard",
      "period_start": "2026-04-14T08:00",
      "period_end":   "2026-04-14T09:00",
      "event_count": 0,
      "expected_count": 4.2,
      "z_score": -2.31,
      "anomaly_type": "silence",
      "severity": "medium",
      "top_labels": []
    }
  ]
}

Severity bands:

Severity Z-score range
low threshold ≤ |z| < 2× threshold
medium 2× threshold ≤ |z| < 3× threshold
high |z| ≥ 3× threshold

Results are sorted high → medium → low, then chronologically.

Natural language equivalent:

curl -X POST http://localhost:8000/query \
  -H "Content-Type: application/json" \
  -d '{"query": "Anything unusual today?"}'

GET /events/timeline

Returns events grouped into time buckets for timeline visualisation. Powers the /timeline web UI.

Query parameters:

Parameter Type Default Description
hours integer 24 Time window size in hours (1–168)
bucket_minutes integer 0 Bucket width in minutes. 0 = auto: 15 min for ≤6 h, 60 min for >6 h
camera string Filter to a single camera name

Example:

# Last 6 hours, 15-minute buckets
curl "http://localhost:8000/events/timeline?hours=6"

# Last 7 days, 1-hour buckets, Front Door only
curl "http://localhost:8000/events/timeline?hours=168&camera=Front+Door"

Response:

{
  "range_start": "2026-04-13T10:00",
  "range_end":   "2026-04-14T10:00",
  "hours": 24,
  "bucket_minutes": 60,
  "cameras": ["Front Door", "Backyard"],
  "buckets": ["2026-04-13T10:00", "2026-04-13T11:00", "..."],
  "data": {
    "Front Door": {
      "2026-04-13T22:00": {
        "count": 3,
        "labels": ["person", "person", "car"],
        "top_label": "person"
      }
    }
  }
}

The data object is a sparse dict: only buckets that contain at least one event are present.

Timeline web UI: open http://localhost:8000/timeline in a browser for the interactive heatmap view.


GET /discover/onvif

Discover ONVIF-compatible cameras on the local network using WS-Discovery (UDP multicast to 239.255.255.250:3702). No external dependencies required.

Useful for bootstrapping — run this endpoint to find cameras and copy their host/port into config.yaml.

Query parameters:

Parameter Type Default Description
timeout float 3.0 Probe wait time in seconds (0.5–10.0)
interface string "" Local interface IP to bind (default: all interfaces)

Example:

curl "http://localhost:8000/discover/onvif?timeout=5"

Response:

[
  {
    "address": "http://192.168.1.101:80/onvif/device_service",
    "host": "192.168.1.101",
    "port": 80,
    "name": "FrontDoorCam",
    "hardware": "DS-2CD2T43G2-2I",
    "types": ["NetworkVideoTransmitter"],
    "scopes": [
      "onvif://www.onvif.org/name/FrontDoorCam",
      "onvif://www.onvif.org/hardware/DS-2CD2T43G2-2I"
    ]
  }
]

Returns an empty list [] if no devices respond within the timeout.

CLI equivalent:

cctvql discover
cctvql discover --timeout 5 --yaml   # prints config.yaml snippet

Alert Rules

GET /alerts

List all configured alert rules.

Response:

[
  {
    "id": "rule_abc123",
    "name": "Night Person Alert",
    "description": "Alert when person detected after 10pm",
    "camera_name": "Front Door",
    "label": "person",
    "time_start": "22:00",
    "time_end": "06:00",
    "webhook_url": "https://example.com/hook",
    "cooldown_seconds": 300,
    "enabled": true,
    "created_at": "2026-04-13T18:00:00"
  }
]

POST /alerts

Create a new alert rule.

Request body:

{
  "name": "Night Person Alert",
  "description": "Alert when person detected after 10pm",
  "camera_name": "Front Door",
  "label": "person",
  "time_start": "22:00",
  "time_end": "06:00",
  "webhook_url": "https://example.com/hook",
  "cooldown_seconds": 300
}
Field Type Required Description
name string Human-readable rule name
description string Longer description
camera_name string Restrict to a specific camera (any camera if omitted)
label string Restrict to a specific object label
time_start string Active window start (HH:MM, 24h)
time_end string Active window end (HH:MM, 24h, may wrap midnight e.g. 22:0006:00)
webhook_url string Fire a POST to this URL on match
cooldown_seconds integer Minimum seconds between firings (default 300; 0 = no cooldown)

Returns 201 Created with the created rule including its id.

GET /alerts/{rule_id}

Get a specific alert rule.

Returns 404 if the rule does not exist.

PATCH /alerts/{rule_id}

Update an alert rule (partial update — only send fields you want to change).

# Disable a rule
curl -X PATCH http://localhost:8000/alerts/rule_abc123 \
  -H "Content-Type: application/json" \
  -d '{"enabled": false}'

DELETE /alerts/{rule_id}

Delete an alert rule permanently.


GET /metrics

Prometheus-compatible metrics endpoint for Grafana, alerting, and observability.

curl http://localhost:8000/metrics

Exposed metrics:

Metric Type Description
cctvql_queries_total counter Total NLP queries processed
cctvql_active_sessions gauge Number of active conversation sessions
cctvql_adapter_status gauge 1 = adapter healthy, 0 = degraded
cctvql_llm_status gauge 1 = LLM healthy, 0 = degraded
cctvql_cameras_online gauge Cameras currently reporting online
cctvql_cameras_offline gauge Cameras currently reporting offline
cctvql_alert_rules_total gauge Number of configured alert rules

Example output:

# HELP cctvql_queries_total Total NLP queries processed
# TYPE cctvql_queries_total counter
cctvql_queries_total 47

# HELP cctvql_cameras_online Cameras currently online
# TYPE cctvql_cameras_online gauge
cctvql_cameras_online 3

# HELP cctvql_cameras_offline Cameras currently offline
# TYPE cctvql_cameras_offline gauge
cctvql_cameras_offline 1

DELETE /sessions/{session_id}

Clear conversation history for a session. Also removes the session from the database if persistence is enabled.

curl -X DELETE http://localhost:8000/sessions/my-session

Response:

{"status": "cleared", "session_id": "my-session"}

Returns 200 even if the session did not exist.


WebSocket — GET /ws/events

Real-time event stream. Each message is a JSON object representing a new event from the adapter.

ws://localhost:8000/ws/events

Example (wscat):

wscat -c ws://localhost:8000/ws/events

Sample message:

{
  "id": "evt_001",
  "camera": "Front Door",
  "type": "object_detected",
  "start_time": "2026-04-14T09:44:12",
  "objects": [{"label": "person", "confidence": 0.94}],
  "zones": ["porch"],
  "snapshot_url": "http://192.168.1.100:5000/api/events/evt_001/snapshot.jpg"
}

Home Assistant Integration

Use the /query endpoint from Home Assistant automations or scripts:

# configuration.yaml
rest_command:
  cctvql_query:
    url: "http://192.168.1.x:8000/query"
    method: POST
    headers:
      Content-Type: "application/json"
    payload: '{"query": "{{ query }}", "session_id": "homeassistant"}'

Then call it in an automation:

action:
  - service: rest_command.cctvql_query
    data:
      query: "Any motion on the front door camera in the last 10 minutes?"