Sphere connector protocol
The Sphere connector speaks the same envelope, hello, heartbeat and job
lifecycle as Axis agents and connectors (Agent and connector protocol),
and adds the messages and jobs of video management. The Go types in
entrosity-shared-go/proto/sphere are the single source of truth; this
page describes intent and behaviour.
Like the rest of the protocol, these types only grow: fields are added, never renamed or repurposed, so connectors in the field keep working.
Transport
- WebSocket:
wss://hub.entrosity.com/sphere/api/connector/v1/wswithAuthorization: Bearer <connector key>. JSON text frames, one envelope per frame, at most 1 MiB; the server pings every 30 seconds. - The first message must be
hello(capabilities: ["video"], plus"update"from builds that install signed releases, andpending_job_ids); the server answershello.ack, delivers the pending jobs and may offer an update (Self-update). Anything beforehellocloses the socket (1008). - The connector sends
heartbeatevery minute. It is online from itshellountil the socket closes, or after 3 minutes without a message. Going offline makes its NVRsunknownand its streams not live. - Jobs follow
job.assign→job.ack→job.progress→job.result. A job sent withoutjob.ackwithin 60 seconds is sent again; a job not finished before its expiry becomestimeout. - Close codes:
4003the key was revoked (connector removed, tenant suspended);4009a newer connection of the same installation took over;1008policy violation. - Video does not travel over this connection: it is published to the
media server on a separate host,
media.entrosity.com:8322(Media publishing).
HTTP endpoints (/api/connector/v1)
| Method and path | Purpose |
|---|---|
POST /enroll | {enrollment_token, hostname, domain, version} → {connector_id, connector_key, ws_url}. The token must be a Sphere connector token of an active tenant. The same computer (tenant, host name, domain) enrolling again rotates its key and closes the old connection. Rate-limited per IP (SPHERE_ENROLL_RATE_PER_MINUTE). |
GET /ws | The WebSocket. |
POST /heartbeat | HTTP fallback of heartbeat. |
GET /jobs/pending, POST /jobs/{id}/ack, …/progress, …/result | HTTP polling fallback of the job lifecycle. |
POST /alarms | HTTP fallback of sphere.alarms: body an AlarmsChunk (gzip accepted; 4 MiB compressed, 8 MiB decoded), response the AlarmsAck. |
POST /status | HTTP fallback of sphere.status. |
GET /nvrs/{id}/credentials | {username, password, version} of one of the connector's own NVRs (Cache-Control: no-store). Every fetch is recorded in the tenant's audit log as nvr.credentials_fetch. |
All but /enroll need the connector key. Everything a connector does is
confined to its tenant, and to its own NVRs.
Messages
Connector → server
| Type | Payload | When |
|---|---|---|
sphere.alarms | {first_seq, alarms: [Alarm]}: 1–500 consecutive alarms (alarms[i].seq = first_seq + i) | Whenever the connector's queue has alarms. |
sphere.status | {nvrs: [NVRStatus], streams: [StreamState]} (up to 1,000 of each) | At least every 30 seconds, and right after a change. |
NVRStatus is {nvr_id, status, error?, credentials_version?, info?}:
status is online, offline, auth_failed, unsupported or
disconnected; credentials_version the version the connector signs in
with; info (an NVRInfo) is sent after a (re)connection and when it
changed.
StreamState is {nvr_id, channel, profile?, session_id?, path, since, publishing, error?}: one stream the connector publishes, or retries
(publishing: false with the reason in error).
Server → connector
| Type | Payload | When |
|---|---|---|
sphere.alarms.ack | {acked_seq}, in reply (reply_to) to sphere.alarms | Every alarm up to acked_seq is stored; the connector drops them from its queue. |
What the server does with a status report
- It records each NVR's status and error, the credentials version
applied, and, with
info, the NVR's details and cameras (new channels become cameras, channels above the reported count are removed; the NVR's time zone is taken only if none is set in Sphere). - An NVR the connector reports but the tenant does not have (or no longer
has) is removed from the connector with
sphere.nvr.remove. - A stream the connector publishes that nobody wants, or that was
stopped (a lost stop job), is stopped again (
sphere.stream.stoporsphere.playback.stop). A wanted live stream it has not reported for 45 seconds is started again, even one that was live (the connector restarted, for example); a playback it no longer reports has reached its end and ends (stream.statestopped).
Jobs
| Job | Payload | Result | Timeout / expiry, priority | Connector lane |
|---|---|---|---|---|
sphere.nvr.apply | {nvr: NVRRef, desired_state, credentials_version} | {} | 2 min / 7 days, 10 | config (1 at a time) |
sphere.nvr.remove | {nvr_id} | {} | 1 min / 30 days | config |
sphere.nvr.test | {nvr: NVRRef, credentials_version} | {info: NVRInfo, rtsp_reachable, warnings?} | 1 min / 5 min, 50 | probe (2 in parallel) |
sphere.recordings.find | {nvr_id, channel, from, to} (at most 7 days) | {segments: [{start, end, type, size_bytes?}], truncated?} (at most 2,000) | 1 min / 2 min, 50 | probe |
sphere.nvr.tune_live | LiveTuneJob {nvr_id, channels?, keyframe_seconds} | LiveTuneResult {channels: [ChannelTune]} | 5 min / 5 min, 40 | probe |
sphere.stream.start | {nvr_id, channel, profile, target: {url, path}} | {} | 30 s / 30 s, 80 | stream (8 in parallel) |
sphere.stream.stop | {nvr_id, channel, profile} | {} | 30 s / 2 min, 60 | stream |
sphere.playback.start | {session_id, nvr_id, channel, start, end, target: {url, path}} (at most 24 hours) | {} | 30 s / 30 s, 80 | stream |
sphere.playback.stop | {session_id} | {} | 30 s / 2 min, 60 | stream |
sphere.ptz | {nvr_id, channel, code, action, speed?, preset?} | {} | 5 s / 5 s, 100 | ptz (4 in parallel) |
sphere.snapshot | {nvr_id, channel, upload_url} | {} | – | snapshot (2 in parallel) |
update_agent | UpdateJob (below) | UpdateResult | 30 min / 1 h, 0 | update (1 at a time) |
NVRRefis{nvr_id, driver, host, port, https?, rtsp_port, timezone?}: the NVR's LAN address, the HTTP(S) port of its API, whether it uses HTTPS (its self-signed certificate is accepted), its RTSP port, and the IANA time zone its clock runs in (empty: the connector's local zone). It never carries credentials.NVRInfois{device_type, serial, firmware, channels, cameras: [{channel, title, ptz?, main_codec?, sub_codec?, online}], timezone?}. Channels count from 1; codecs areH.264,H.265,MJPEGor empty.sphere.nvr.applymakes the connector hold the NVR indesired_state:connected(signed in, event stream followed, checked every 30 seconds, described again every 5 minutes) ordisconnected(signed out, every stream of the NVR stopped). Whencredentials_versionis newer than the credentials it holds, the connector first fetches them (GET /nvrs/{id}/credentials). Applying the same state again changes nothing. A new apply job for an NVR cancels its older open one. The server sends it when an NVR is added, connected or disconnected, and when a connected NVR's address, time zone or credentials change;nvr.reconcilesends it again when the connector does not hold the wanted state.sphere.nvr.removesigns out, stops the NVR's streams and forgets the NVR and its credentials; an unknown NVR is already removed.sphere.nvr.testsigns in once, whatever the desired state, describes the NVR, and plays the sub stream of channel 1 to setrtsp_reachable.warningsname H.265 and MJPEG channels, no channels, and an unknown time zone.sphere.stream.startpulls the channel'smainorsubstream from the NVR and publishes it totarget.url+/+target.pathuntilsphere.stream.stop; it succeeds once the media server has accepted the stream (within 8 seconds). Starting a stream already published to the same target succeeds at once, without a second copy. The server marks the streamliveas soon as the job succeeds, without waiting for the next status report.sphere.nvr.tune_livemakes each camera's sub stream send a keyframe at least everykeyframe_seconds(1–4): its keyframe interval becomes frame rate ×keyframe_secondsframes.channelsare 1-based; empty means every channel. An interval already as short is left alone, and main streams, which the NVR records, are never changed. Thedahuadriver setsEncode[i].ExtraFormat[0].Video.GOPwithconfigManager.cgi?action=setConfigand reads theEncodetable back afterwards. Then the connector checks every channel in its sub stream (RTSP, four at a time, up to 15 s each): it reads until three keyframes arrive and takes the gap between the second and the third from their RTP timestamps (an NVR opens a session with a keyframe of its own). Cameras apply a value the NVR passes on only after some minutes, and some keep their own (it syncs back to the NVR), so a channel whose stream keeps a longer interval is reportedchanged: falsewith anerror: not applied yet when it was set in this run (gop_before > gop_after), kept by the camera otherwise. EachChannelTuneis{channel, fps?, gop_before?, gop_after?, changed, error?, measured_ms?}(intervals in frames;measured_ms: the interval measured in the stream, absent when it could not be read;changed: falsewithouterror: already short enough). A channel's failure is reported in itserror; the job fails only when the NVR cannot be reached or its settings read. The simulator implements it too (sub streams at 25 fps, interval 50 until tuned).sphere.playback.startpublishes the channel's recording fromstart(toendat the latest) to the target; it ends by itself with the recording. Times are absolute; the connector converts them with the NVR's time zone.sphere.ptz:codeis one ofUp,Down,Left,Right,LeftUp,RightUp,LeftDown,RightDown,ZoomTele,ZoomWide,FocusNear,FocusFar,IrisLarge,IrisSmall,GotoPreset,SetPreset,ClearPreset;actionstartorstop;speed0–8 (0: the driver's default);preset1–255 for the preset codes. Its 5-second expiry means a camera never moves long after the request.sphere.snapshottakes a JPEG of the channel andPUTs it toupload_url(a presigned object URL; images never travel over the WebSocket). The connector implements it; the server does not send it yet.
Self-update (update_agent)
The same job type and payload as for Axis agents and connectors
(Protocol), with
component: sphere-connector (proto.UpdateJob, proto.UpdateResult in
entrosity-shared-go/proto). Only connectors that announce the
capability update (sphere.CapabilityUpdate) are sent it.
{"release_id": "…", "component": "sphere-connector",
"target": {"version": "0.2.0", "url": "https://hub.entrosity.com/sphere/api/releases/v1/connector/…/sphere-connector-0.2.0.msi?t=…",
"sha256": "…", "size_bytes": 9437184, "signature": "…"},
"rollback": {"version": "0.1.4", "url": "…", "sha256": "…", "size_bytes": 9412608, "signature": "…"}}
| Field | Meaning |
|---|---|
release_id | Sphere's id of the offered release. |
target | The MSI to install: version, url (a download link valid 2 hours, no sign-in), sha256, size_bytes, and signature, the Ed25519 signature (base64) of proto.ReleaseManifest ("rmm-release-v1\n<component>\n<version>\n<sha256>\n<size>\n") with SPHERE_RELEASE_SIGNING_KEY. |
rollback | The same for the version the connector runs, when Sphere still stores it: the watchdog reinstalls it if the new version does not start. |
JobResult.Result is an UpdateResult: {scheduled, from_version, to_version, rollback} (scheduled: the installer runs in a minute from
the scheduled task; rollback: a previous MSI is kept for the watchdog).
The job succeeds once the installation is scheduled; the new version shows
in the connector's next hello. The connector has no event for the
outcome: it logs it at its next start.
Sphere offers an update when a connector says hello and every 5 minutes
(releases.rollout): the newest release of SPHERE_CONNECTOR_UPDATE_CHANNEL
(stable: stable releases; dev: dev and stable) that is newer than the
connector's version, not while an update_agent job of the connector is
open, and the same version not again within an hour. What the connector
does: Sphere connector → Self-update.
Job error codes
job.result.error_code of Sphere jobs (sphere.ErrCode*, plus the
generic invalid_payload and exec_failed):
| Code | Meaning |
|---|---|
nvr_unreachable | The NVR did not answer, the connector holds it disconnected, or the NVR or the media server did not start the stream in time. |
nvr_auth_failed | The NVR refused the credentials, or the connector has none yet. |
channel_invalid | The NVR has no such channel. |
stream_limit | The connector already publishes as many streams as allowed (SPHERE_CONNECTOR_MAX_STREAMS). |
codec_unsupported | The stream has no video the relay can pass through (e.g. MJPEG). |
media_publish_failed | The media server refused or dropped the stream. |
no_recordings | Reserved: no recording matches (a search without recordings currently succeeds with no segments). |
unsupported | The driver or the NVR cannot do this (e.g. PTZ on a fixed camera). |
unknown_driver | This connector build has no driver of that name (simulator without --dev). |
unknown_nvr | The connector does not drive that NVR. |
invalid_payload | The payload could not be decoded or is invalid. |
exec_failed | The connector restarted before the job finished, or another failure; for update_agent, the update folder or scheduled task could not be created. |
update_unsigned | update_agent: the build has no release public key and refuses updates. |
signature_invalid | update_agent: a release signature did not verify. |
download_failed, hash_mismatch | update_agent: the MSI could not be downloaded, or does not match the release's SHA-256 and size. |
A failed or expired stream or playback start marks the stream failed
for its viewers (stream.state).
Credentials
NVR passwords never appear in job payloads, results, status reports, the
portal API, events or the audit log. Jobs carry only
credentials_version; when it is newer than what the connector holds, the
connector fetches {username, password, version} from
GET /api/connector/v1/nvrs/{id}/credentials with its own key. The
server answers only for the connector's own NVRs, decrypts the password
(SPHERE_CREDENTIALS_KEY) and records the fetch as
nvr.credentials_fetch. The connector keeps the credentials in
nvrs.json, sealed with machine-scope DPAPI, and reports the version it
signs in with (credentials_version in NVRStatus).
Alarms
{"seq": 88, "nvr_id": "…", "occurred_at": "2026-09-28T06:00:12Z",
"code": "VideoMotion", "action": "start", "channel": 3,
"data": {"Id": [0], "RegionName": ["Region1"]}}
| Field | Meaning |
|---|---|
seq | Assigned by the connector's queue: strictly increasing per connector installation. |
nvr_id, occurred_at, code, action | Required. code is the NVR's event code (VideoMotion, VideoLoss, AlarmLocal, CrossLineDetection, …, at most 64 characters) or the connector's own NVROnline / NVROffline; action is start, stop or pulse. |
channel | 1–256; 0 or absent for alarms not about a channel. |
data | The NVR's detail, a JSON object of at most 4 KiB. |
The connector raises NVROffline (a pulse) when a connected NVR that was
online stops answering or refuses the sign-in, and NVROnline when it
comes back.
Exactly once: the server keeps, per connector, the highest sequence
number it stored (alarms_last_seq). A chunk at or below it is a
duplicate and only acknowledged; the part of a chunk above it is stored
and the cursor moved, in one transaction. The connector keeps alarms on
disk until they are acknowledged, so a lost acknowledgement, a restart or
an outage only causes a resend. Over the WebSocket, a chunk without
acknowledgement within 15 seconds is sent again over HTTP.
On ingest the server resolves the camera from the NVR and channel and
streams the new alarms to the web app (alarm.new). occurred_at more
than 24 hours in the future or more than a year in the past is replaced
by the time of arrival (the NVR's clock is wrong).
Media publishing
| URL | target.url + / + target.path: rtsps://media.entrosity.com:8322/t/<tenant>/live/<camera>/<main|sub> or …/t/<tenant>/pb/<session> |
| User, password | The connector's id and its connector key (RTSP basic authentication) |
| Transport | RTSP ANNOUNCE/RECORD, interleaved over TCP. The connector opens this connection (TCP, TLS, OPTIONS) while the NVR answers its DESCRIBE and SETUP, not after, so only the publishing requests remain once the stream is known. With rtsps, the connector opens TLS itself (the server certificate is verified against the system roots) and speaks plain RTSP inside it, with no SRTP: Caddy terminates TLS on :8322 with the media host's certificate (the connector sends media.entrosity.com as SNI) and forwards plain RTSP to the media server. rtsp:// is accepted for development. |
| Media | RTP passed through unchanged, never transcoded: H.264 and H.265 video, G.711 audio (other audio is dropped). |
| Source | The NVR's RTSP, over TCP: rtsp://<host>:<rtsp_port>/cam/realmonitor?channel=N&subtype=0|1 (main, sub) and /cam/playback?channel=N&starttime=…&endtime=… for Dahua. |
| Reconnects | A live stream reconnects when the NVR sends nothing for 10 seconds or either side drops, with back-off from 1 to 30 seconds, until it is stopped. |
The media server asks the backend about every publish: the user must be
the connector whose key is the password, and the path must start with
t/<the connector's tenant>/. A connector can never publish into another
tenant (The media server).
Limits
| Limit | Value |
|---|---|
Alarms per sphere.alarms chunk | 500 (the connector also keeps a chunk under 256 KiB) |
Alarm data | 4 KiB |
| NVRs and streams per status report | 1,000 each |
| Channels per NVR | 256 |
| Recording search span, segments | 7 days, 2,000 |
| Playback span | 24 hours |
| PTZ speed, presets | 1–8, 1–255 |
| WebSocket message | 1 MiB |
| Streams published per connector | 64 (SPHERE_CONNECTOR_MAX_STREAMS) |
| Connector installer (release) | 64 MiB; the newest ten are stored |