Remote Control¶
Control the Milestone XProtect Smart Client remotely via a REST API with interactive Swagger UI documentation. External systems, automation scripts, or control room software can switch views, display cameras, change workspaces, and more - all over HTTP.
Quick Start¶
- Install the plugin and open Smart Client
- Go to Settings > Remote Control
- The server starts automatically on
127.0.0.1:9500 - Click Open Swagger UI to explore the API interactively
- Copy your API token and click Authorize in Swagger UI to authenticate
API Endpoints¶
All endpoints require a Bearer token in the Authorization header. Use the Swagger UI Authorize button or pass it directly.
Discovery¶
| Method | Path | Description |
|---|---|---|
GET |
/api/views |
List all views with FQID and path |
GET |
/api/cameras |
List all cameras with FQID and group path |
GET |
/api/workspaces |
List all workspaces |
GET |
/api/windows |
List Smart Client windows |
GET |
/api/status |
Server status and current SC mode |
Use the id field from discovery endpoints in all action requests.
Actions¶
| Method | Path | Description |
|---|---|---|
POST |
/api/views/switch |
Switch to a view |
POST |
/api/cameras/show |
Show N cameras with auto-layout |
POST |
/api/cameras/set |
Set a camera in a specific view slot |
POST |
/api/workspaces/switch |
Switch workspace |
POST |
/api/application/control |
Application commands |
POST |
/api/windows/close |
Close window(s) |
POST |
/api/clear |
Clear/blank the view |
POST / GET / DELETE |
/api/overlays[/{id}] |
Draw SVG overlays on cameras |
Dynamic Camera Layout¶
Send an array of camera IDs to /api/cameras/show and a grid view is automatically created:
The smallest grid layout that fits is selected automatically (1x1, 1x2, 2x2, 2x3, 3x3, up to 4x5 = 20 cameras max). Grid views are created in a "Remote Control" folder under Private Views.
Application Commands¶
Available values for POST /api/application/control:
| Command | Description |
|---|---|
ToggleFullscreen |
Toggle fullscreen mode |
EnterFullscreen |
Enter fullscreen |
ExitFullscreen |
Exit fullscreen |
ShowSidePanel |
Show the side panel |
HideSidePanel |
Hide the side panel |
Maximize |
Maximize the window |
Minimize |
Minimize the window |
Restore |
Restore the window |
Delayed Clear¶
Clear a view after a delay (useful for temporarily showing cameras then returning to blank):
Maximum delay is 300 seconds (5 minutes).
SVG Overlays¶
External systems can push SVG graphics that render on top of any camera in the Smart Client. The overlay appears in every viewport currently showing the target camera (main window, floating windows, every slot of a grid), and automatically re-applies when the user switches views or drags the camera into a new slot.
| Method | Path | Description |
|---|---|---|
POST |
/api/overlays |
Create or replace an overlay (upsert by overlayId) |
GET |
/api/overlays |
List active overlays |
GET |
/api/overlays/{id} |
Get one overlay including the original SVG |
DELETE |
/api/overlays/{id} |
Remove one overlay |
DELETE |
/api/overlays?cameraId=... |
Clear all overlays for a camera (omit query to clear everything) |
Request body¶
POST /api/overlays
{
"overlayId": "alarm-12345",
"cameraId": "<camera-guid>",
"svg": "<svg viewBox=\"0 0 1000 1000\">...</svg>",
"ttlSeconds": 60,
"zOrder": 100
}
overlayIdis caller-supplied and stable. Posting the sameoverlayIdagain replaces the overlay in place without flicker, ideal for live meters that update many times per second.cameraIdis the FQID fromGET /api/cameras.ttlSecondsis optional. Omit or pass0for "persist until DELETE". The store is in-memory; everything clears on Smart Client restart.zOrderdefaults to100. Higher numbers draw on top of lower ones.
Coordinate space¶
Author your SVG against a viewBox. If the viewBox attribute is missing, the plugin assumes 0 0 1000 1000. Coordinates are scaled to the rendered viewport at draw time, so a shape at x=500 lands at the horizontal centre regardless of the camera's resolution or aspect ratio.
Supported SVG subset¶
rect, circle, ellipse, line, polyline, polygon, path (full d= command set), text, g with transform="translate|scale|rotate|matrix". Style attributes honored: fill, stroke, stroke-width, opacity, fill-opacity, stroke-opacity, font-family, font-size, font-weight, font-style. Both presentation attributes and inline style="..." are read.
Caps to keep the UI responsive against a misbehaving integrator: max 500 shapes per overlay, max 32 overlays per camera, max 50 KB SVG body.
Off-screen targets¶
If you post an overlay for a camera that is not currently displayed in any viewport, the API still returns 201 with a warning field. The overlay is queued; the moment the camera appears in any viewport, it renders automatically. This is by design, you can pre-load overlays before switching views.
Example: simple shapes¶
POST /api/overlays
{
"overlayId": "demo-box",
"cameraId": "<camera-guid>",
"svg": "<svg viewBox='0 0 1000 1000'>
<rect x='100' y='100' width='300' height='200' fill='red' fill-opacity='0.4' stroke='red' stroke-width='4'/>
<text x='110' y='90' fill='red' font-size='40' font-weight='bold'>INTRUDER</text>
</svg>"
}
Example: live multi-gauge strip¶
A common ask is to put a live gauge on top of a camera, e.g. tank level, occupancy, queue length, battery, air quality bracket. The external sensor posts the same overlayId on every tick; the plugin updates in place with no flicker.
See Example: live multi-gauge overlay at the bottom of this page for a full runnable script that composes four gauge styles (semi-circle with needle, segmented donut, horizontal band, thermometer) into a single SVG and animates it. A self-contained variant also ships with the plugin sources as Smart Client Plugins/SCRemoteControl/test-api.py (use --demo to skip the test pass and just animate the strip).
Tips for live overlays
- Reuse the same
overlayIdon every tick. A fresh POST replaces the shapes in place, no flicker, no allocation churn. - Author everything against the
0..1000viewBox so the overlay rescales cleanly with the viewport. - Compute value-dependent colors in your code before building the SVG, the plugin does not evaluate
<style>blocks or CSS selectors. - When the sensor goes offline,
DELETE /api/overlays/{id}so a stale value does not linger on screen. - For overlays that should auto-clear after a fixed time (alarms, transient annotations), set
ttlSeconds. The plugin prunes them server-side.
Settings¶
Open Settings > Remote Control in the Smart Client.
Server Status¶
Shows whether the server is running, the listen URL, and any errors. Buttons:
- Restart Server - Applies settings and restarts the HTTP server
- Open Swagger UI - Opens the interactive API documentation in the browser
Network Configuration¶
| Setting | Description | Default |
|---|---|---|
| Listen Interface | Network interface to bind to | 127.0.0.1 (loopback) |
| Port | HTTP port | 9500 |
| Use HTTPS | Enable TLS encryption | Off |
| PFX Certificate | Certificate file for HTTPS (.pfx format) |
- |
| PFX Password | Password for the PFX file | - |
Listen Interface
The default 127.0.0.1 only allows connections from the local machine. Select All Interfaces (0.0.0.0) or a specific IP to allow remote access. When using 0.0.0.0, ensure your firewall is configured appropriately.
HTTPS
When HTTPS is disabled, API tokens are sent in plaintext over the network. Use HTTPS when the listen interface is not loopback, or ensure the network is trusted.
API Tokens¶
Tokens authenticate API requests. At least one token is always required.
- Add Token - Generate a new random 256-bit token
- Copy - Copy the token value to clipboard
- Remove - Delete a token (cannot remove the last one)
Security¶
- Authentication: All
/api/*endpoints require a valid Bearer token. Swagger UI is accessible without authentication for convenience. - CORS: Only same-origin requests are allowed from browsers. Non-browser HTTP clients (scripts, automation) work from any machine.
- Token storage: API tokens and PFX passwords are encrypted at rest using Windows DPAPI.
- Default loopback: The server defaults to
127.0.0.1, limiting access to the local machine until explicitly configured otherwise.
Example: Python¶
import requests
BASE = "http://localhost:9500"
TOKEN = "your-token-here"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
# List cameras
cameras = requests.get(f"{BASE}/api/cameras", headers=HEADERS).json()
for cam in cameras:
print(f"{cam['name']} ({cam['id']})")
# Show first 4 cameras in a 2x2 grid
ids = [cam["id"] for cam in cameras[:4]]
requests.post(f"{BASE}/api/cameras/show",
json={"cameraIds": ids}, headers=HEADERS)
# Clear after 10 seconds
requests.post(f"{BASE}/api/clear",
json={"delaySeconds": 10}, headers=HEADERS)
Example: Go¶
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
)
const (
base = "http://localhost:9500"
token = "your-token-here"
)
func api(method, path string, body any) (map[string]any, error) {
var reqBody io.Reader
if body != nil {
b, _ := json.Marshal(body)
reqBody = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, reqBody)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result map[string]any
json.NewDecoder(resp.Body).Decode(&result)
return result, nil
}
func main() {
// List cameras
resp, _ := api("GET", "/api/cameras", nil)
fmt.Println(resp)
// Show 2 cameras in auto-layout
api("POST", "/api/cameras/show", map[string]any{
"cameraIds": []string{"cam-guid-1", "cam-guid-2"},
})
// Clear after 10 seconds
api("POST", "/api/clear", map[string]any{
"delaySeconds": 10,
})
}
Example: PowerShell¶
$base = "http://localhost:9500"
$headers = @{ Authorization = "Bearer your-token-here" }
# List views
$views = Invoke-RestMethod -Uri "$base/api/views" -Headers $headers
$views | Format-Table name, path
# Switch to first view
$body = @{ viewId = $views[0].id } | ConvertTo-Json
Invoke-RestMethod -Uri "$base/api/views/switch" -Method POST `
-Headers $headers -ContentType "application/json" -Body $body
Example: live multi-gauge overlay¶
Composes four gauge styles into a single SVG and pushes the same overlayId every ~300 ms so the strip animates in place. Uses only the supported subset (rect, circle, polyline, polygon, line, text) - no text-anchor, no gradients, no CSS - and centers text manually by offsetting x. Referenced from the SVG Overlays section.
import math
import time
import requests
BASE = "http://localhost:9500"
TOKEN = "your-token-here"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}
CAMERA = "<camera-guid>"
GAUGE_BANDS = [(0, 20, "#ef4444"), (20, 40, "#f97316"), (40, 60, "#facc15"),
(60, 80, "#84cc16"), (80, 100, "#22c55e")]
COOL_BANDS = [(0, 20, "#1d4ed8"), (20, 40, "#2563eb"), (40, 60, "#0ea5e9"),
(60, 80, "#06b6d4"), (80, 100, "#14b8a6")]
PINK_BANDS = [(0, 20, "#e11d48"), (20, 40, "#f97316"), (40, 60, "#facc15"),
(60, 80, "#a3e635"), (80, 100, "#22c55e")]
def _text_w(s, fs, k=0.50): return len(s) * fs * k
def _band_color(v, bands=GAUGE_BANDS):
for lo, hi, c in bands:
if lo <= v <= hi: return c
return bands[-1][2]
def _polar(cx, cy, r, deg):
rad = math.radians(deg)
return cx + r * math.cos(rad), cy + r * math.sin(rad)
def _arc(cx, cy, r, a, b, steps=18):
return " ".join(f"{x:.1f},{y:.1f}" for x, y in
(_polar(cx, cy, r, a + (b - a) * i / steps) for i in range(steps + 1)))
def _semi(parts, cx, cy, r, v, num, ring_w=10, bands=GAUGE_BANDS):
for lo, hi, c in bands:
parts.append(f"<polyline points='{_arc(cx, cy, r, 180 - hi*1.8, 180 - lo*1.8)}' "
f"fill='none' stroke='{c}' stroke-width='{ring_w}' "
f"stroke-linecap='round' stroke-linejoin='round'/>")
deg = 180 - v * 1.8
nx, ny = _polar(cx, cy, r - 14, deg)
parts.append(f"<line x1='{cx}' y1='{cy}' x2='{nx:.1f}' y2='{ny:.1f}' "
f"stroke='#d1d5db' stroke-width='3' stroke-linecap='round'/>")
parts.append(f"<circle cx='{cx}' cy='{cy}' r='6' fill='#111827' "
f"stroke='white' stroke-opacity='0.55' stroke-width='1'/>")
top = cy - 36
parts.append(f"<rect x='{cx - 15}' y='{top}' width='30' height='18' rx='8' ry='8' "
f"fill='#111827' fill-opacity='0.92' stroke='white' "
f"stroke-opacity='0.25' stroke-width='1'/>")
parts.append(f"<text x='{cx - _text_w(num, 12)/2:.1f}' y='{top + 14}' "
f"fill='white' font-size='12' font-weight='bold'>{num}</text>")
def _donut(parts, cx, cy, r, v):
parts.append(f"<rect x='{cx - r - 16}' y='{cy - r - 16}' width='{2*r + 32}' "
f"height='{2*r + 32}' rx='18' ry='18' fill='#05070a' fill-opacity='0.36' "
f"stroke='white' stroke-opacity='0.08' stroke-width='1'/>")
for i, (_, _, c) in enumerate(COOL_BANDS):
parts.append(f"<polyline points='{_arc(cx, cy, r, -90 + i*72, -90 + (i+1)*72)}' "
f"fill='none' stroke='{c}' stroke-width='12' stroke-linecap='round'/>")
deg = -90 + v * 3.6
sx, sy = _polar(cx, cy, 16, deg)
nx, ny = _polar(cx, cy, r - 9, deg)
tx, ty = _polar(cx, cy, r, deg)
parts.append(f"<line x1='{sx:.1f}' y1='{sy:.1f}' x2='{nx:.1f}' y2='{ny:.1f}' "
f"stroke='white' stroke-width='3' stroke-linecap='round'/>")
parts.append(f"<circle cx='{tx:.1f}' cy='{ty:.1f}' r='4' fill='white' "
f"stroke='#111827' stroke-width='1.5'/>")
parts.append(f"<circle cx='{cx}' cy='{cy}' r='13' fill='#111827' fill-opacity='0.92'/>")
n = str(int(round(v)))
parts.append(f"<text x='{cx - _text_w(n, 12)/2:.1f}' y='{cy + 3}' "
f"fill='white' font-size='12' font-weight='bold'>{n}</text>")
def _linear(parts, x, y, w, h, v, bands=PINK_BANDS):
parts.append(f"<rect x='{x}' y='{y}' width='{w}' height='{h}' rx='{h/2}' ry='{h/2}' "
f"fill='#05070a' fill-opacity='0.40' stroke='white' "
f"stroke-opacity='0.08' stroke-width='1'/>")
ix, iy, iw, ih = x + 6, y + 6, w - 12, h - 12
for lo, hi, c in bands:
parts.append(f"<rect x='{ix + iw*lo/100:.1f}' y='{iy}' "
f"width='{iw*(hi-lo)/100:.1f}' height='{ih}' "
f"rx='{max(2, ih/3):.1f}' ry='{max(2, ih/3):.1f}' fill='{c}'/>")
mx = ix + iw * v / 100
cw, ch, cy_ = 22, 14, y - 22
parts.append(f"<rect x='{mx - cw/2:.1f}' y='{cy_}' width='{cw}' height='{ch}' "
f"rx='5' ry='5' fill='#111827' fill-opacity='0.92' "
f"stroke='white' stroke-opacity='0.25' stroke-width='1'/>")
parts.append(f"<polygon points='{mx:.1f},{y + 1} {mx - 5:.1f},{cy_ + ch} "
f"{mx + 5:.1f},{cy_ + ch}' fill='#111827' stroke='white' "
f"stroke-opacity='0.5' stroke-width='1'/>")
p = str(int(round(v)))
parts.append(f"<text x='{mx - _text_w(p, 8)/2:.1f}' y='{cy_ + 10}' "
f"fill='white' font-size='8' font-weight='bold'>{p}</text>")
def _thermo(parts, x, y, v, bands=COOL_BANDS):
ow, th, br, iw = 14, 150, 13, 10
cx, cy = x + ow/2, y + th + 2
col = _band_color(v, bands)
parts.append(f"<rect x='{x - 22}' y='{y - 12}' width='70' height='196' rx='18' ry='18' "
f"fill='#05070a' fill-opacity='0.36' stroke='white' "
f"stroke-opacity='0.08' stroke-width='1'/>")
parts.append(f"<circle cx='{cx}' cy='{cy}' r='{br}' fill='white' fill-opacity='0.12'/>")
parts.append(f"<rect x='{x}' y='{y}' width='{ow}' height='{th + 6}' rx='7' ry='7' "
f"fill='white' fill-opacity='0.12'/>")
ix, iy, ih = x + (ow - iw)/2, y + 7, th - 10
parts.append(f"<rect x='{ix}' y='{iy}' width='{iw}' height='{ih}' "
f"rx='3' ry='3' fill='#0b0f14'/>")
parts.append(f"<circle cx='{cx}' cy='{cy}' r='{br - 4}' fill='{col}'/>")
lh = ih * v / 100
ly = iy + ih - lh
parts.append(f"<rect x='{ix}' y='{ly:.1f}' width='{iw}' height='{lh + 6:.1f}' "
f"rx='3' ry='3' fill='{col}'/>")
parts.append(f"<line x1='{x - 10}' y1='{ly:.1f}' x2='{x - 2}' y2='{ly:.1f}' "
f"stroke='{col}' stroke-width='2'/>")
pct = f"{int(round(v))}%"
cw, ch = 28, 14
cxp, cyp = x + 20, ly - ch/2
parts.append(f"<rect x='{cxp}' y='{cyp:.1f}' width='{cw}' height='{ch}' rx='5' ry='5' "
f"fill='#111827' fill-opacity='0.92'/>")
parts.append(f"<text x='{cxp + (cw - _text_w(pct, 8))/2:.1f}' y='{cyp + 10:.1f}' "
f"fill='white' font-size='8' font-weight='bold'>{pct}</text>")
def gauge_svg(value: float) -> str:
v = max(0.0, min(100.0, float(value)))
# Four correlated channels so the demo looks alive without four sensors.
a, b, c, d = v, min(100, v*0.88 + 6), max(0, 100 - v*0.45), min(100, 35 + v*0.5)
parts = ["<rect x='18' y='18' width='964' height='324' rx='24' ry='24' "
"fill='#05070a' fill-opacity='0.10'/>"]
_semi(parts, 150, 118, 44, a, str(int(round(a))))
_donut(parts, 385, 104, 42, b)
_linear(parts, 520, 184, 210, 26, c)
_thermo(parts, 835, 92, d)
return "<svg viewBox='0 0 1000 360'>" + "".join(parts) + "</svg>"
# Push the same overlayId every ~300 ms; the plugin upserts in place.
t0 = time.time()
for _ in range(200):
v = 50 + 45 * math.sin((time.time() - t0) * 0.6)
requests.post(f"{BASE}/api/overlays", headers=HEADERS, json={
"overlayId": "demo-gauge-strip",
"cameraId": CAMERA,
"svg": gauge_svg(v),
})
time.sleep(0.3)