Skip to main content

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/ws with Authorization: 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, and pending_job_ids); the server answers hello.ack, delivers the pending jobs and may offer an update (Self-update). Anything before hello closes the socket (1008).
  • The connector sends heartbeat every minute. It is online from its hello until the socket closes, or after 3 minutes without a message. Going offline makes its NVRs unknown and its streams not live.
  • Jobs follow job.assign → job.ack → job.progress → job.result. A job sent without job.ack within 60 seconds is sent again; a job not finished before its expiry becomes timeout.
  • Close codes: 4003 the key was revoked (connector removed, tenant suspended); 4009 a newer connection of the same installation took over; 1008 policy 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 pathPurpose
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 /wsThe WebSocket.
POST /heartbeatHTTP fallback of heartbeat.
GET /jobs/pending, POST /jobs/{id}/ack, …/progress, …/resultHTTP polling fallback of the job lifecycle.
POST /alarmsHTTP fallback of sphere.alarms: body an AlarmsChunk (gzip accepted; 4 MiB compressed, 8 MiB decoded), response the AlarmsAck.
POST /statusHTTP 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​

TypePayloadWhen
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​

TypePayloadWhen
sphere.alarms.ack{acked_seq}, in reply (reply_to) to sphere.alarmsEvery 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.stop or sphere.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.state stopped).

Jobs​

JobPayloadResultTimeout / expiry, priorityConnector lane
sphere.nvr.apply{nvr: NVRRef, desired_state, credentials_version}{}2 min / 7 days, 10config (1 at a time)
sphere.nvr.remove{nvr_id}{}1 min / 30 daysconfig
sphere.nvr.test{nvr: NVRRef, credentials_version}{info: NVRInfo, rtsp_reachable, warnings?}1 min / 5 min, 50probe (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, 50probe
sphere.nvr.tune_liveLiveTuneJob {nvr_id, channels?, keyframe_seconds}LiveTuneResult {channels: [ChannelTune]}5 min / 5 min, 40probe
sphere.stream.start{nvr_id, channel, profile, target: {url, path}}{}30 s / 30 s, 80stream (8 in parallel)
sphere.stream.stop{nvr_id, channel, profile}{}30 s / 2 min, 60stream
sphere.playback.start{session_id, nvr_id, channel, start, end, target: {url, path}} (at most 24 hours){}30 s / 30 s, 80stream
sphere.playback.stop{session_id}{}30 s / 2 min, 60stream
sphere.ptz{nvr_id, channel, code, action, speed?, preset?}{}5 s / 5 s, 100ptz (4 in parallel)
sphere.snapshot{nvr_id, channel, upload_url}{}–snapshot (2 in parallel)
update_agentUpdateJob (below)UpdateResult30 min / 1 h, 0update (1 at a time)
  • NVRRef is {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.
  • NVRInfo is {device_type, serial, firmware, channels, cameras: [{channel, title, ptz?, main_codec?, sub_codec?, online}], timezone?}. Channels count from 1; codecs are H.264, H.265, MJPEG or empty.
  • sphere.nvr.apply makes the connector hold the NVR in desired_state: connected (signed in, event stream followed, checked every 30 seconds, described again every 5 minutes) or disconnected (signed out, every stream of the NVR stopped). When credentials_version is 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.reconcile sends it again when the connector does not hold the wanted state.
  • sphere.nvr.remove signs out, stops the NVR's streams and forgets the NVR and its credentials; an unknown NVR is already removed.
  • sphere.nvr.test signs in once, whatever the desired state, describes the NVR, and plays the sub stream of channel 1 to set rtsp_reachable. warnings name H.265 and MJPEG channels, no channels, and an unknown time zone.
  • sphere.stream.start pulls the channel's main or sub stream from the NVR and publishes it to target.url + / + target.path until sphere.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 stream live as soon as the job succeeds, without waiting for the next status report.
  • sphere.nvr.tune_live makes each camera's sub stream send a keyframe at least every keyframe_seconds (1–4): its keyframe interval becomes frame rate × keyframe_seconds frames. channels are 1-based; empty means every channel. An interval already as short is left alone, and main streams, which the NVR records, are never changed. The dahua driver sets Encode[i].ExtraFormat[0].Video.GOP with configManager.cgi?action=setConfig and reads the Encode table 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 reported changed: false with an error: not applied yet when it was set in this run (gop_before > gop_after), kept by the camera otherwise. Each ChannelTune is {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: false without error: already short enough). A channel's failure is reported in its error; 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.start publishes the channel's recording from start (to end at 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: code is one of Up, Down, Left, Right, LeftUp, RightUp, LeftDown, RightDown, ZoomTele, ZoomWide, FocusNear, FocusFar, IrisLarge, IrisSmall, GotoPreset, SetPreset, ClearPreset; action start or stop; speed 0–8 (0: the driver's default); preset 1–255 for the preset codes. Its 5-second expiry means a camera never moves long after the request.
  • sphere.snapshot takes a JPEG of the channel and PUTs it to upload_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": "…"}}
FieldMeaning
release_idSphere's id of the offered release.
targetThe 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.
rollbackThe 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):

CodeMeaning
nvr_unreachableThe 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_failedThe NVR refused the credentials, or the connector has none yet.
channel_invalidThe NVR has no such channel.
stream_limitThe connector already publishes as many streams as allowed (SPHERE_CONNECTOR_MAX_STREAMS).
codec_unsupportedThe stream has no video the relay can pass through (e.g. MJPEG).
media_publish_failedThe media server refused or dropped the stream.
no_recordingsReserved: no recording matches (a search without recordings currently succeeds with no segments).
unsupportedThe driver or the NVR cannot do this (e.g. PTZ on a fixed camera).
unknown_driverThis connector build has no driver of that name (simulator without --dev).
unknown_nvrThe connector does not drive that NVR.
invalid_payloadThe payload could not be decoded or is invalid.
exec_failedThe connector restarted before the job finished, or another failure; for update_agent, the update folder or scheduled task could not be created.
update_unsignedupdate_agent: the build has no release public key and refuses updates.
signature_invalidupdate_agent: a release signature did not verify.
download_failed, hash_mismatchupdate_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"]}}
FieldMeaning
seqAssigned by the connector's queue: strictly increasing per connector installation.
nvr_id, occurred_at, code, actionRequired. 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.
channel1–256; 0 or absent for alarms not about a channel.
dataThe 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​

URLtarget.url + / + target.path: rtsps://media.entrosity.com:8322/t/<tenant>/live/<camera>/<main|sub> or …/t/<tenant>/pb/<session>
User, passwordThe connector's id and its connector key (RTSP basic authentication)
TransportRTSP 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.
MediaRTP passed through unchanged, never transcoded: H.264 and H.265 video, G.711 audio (other audio is dropped).
SourceThe 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.
ReconnectsA 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​

LimitValue
Alarms per sphere.alarms chunk500 (the connector also keeps a chunk under 256 KiB)
Alarm data4 KiB
NVRs and streams per status report1,000 each
Channels per NVR256
Recording search span, segments7 days, 2,000
Playback span24 hours
PTZ speed, presets1–8, 1–255
WebSocket message1 MiB
Streams published per connector64 (SPHERE_CONNECTOR_MAX_STREAMS)
Connector installer (release)64 MiB; the newest ten are stored