Skip to main content

ONVIF

Driver: onvif

The ONVIF page finds IP cameras on the network, reads their RTSP stream URLs, and maps them to GEM camera zones. The same page maps a camera's relay outputs (door strikes, gate contacts, aux outputs on an ONVIF access controller) to zones you can command, and lists its digital inputs.

Open ONVIF

The page lives under Integrations > ONVIF. Everything on it works against ONVIF Profile S / T devices — cameras, NVRs, and ONVIF access controllers — over SOAP at http://<ip>:<port>/onvif/device_service.

What ONVIF gets you, and what it doesn't

Discovery, RTSP URLs, snapshots, PTZ and relay I/O are all standard ONVIF and work here. Vendor extras — analytics rules, on-camera recording, proprietary event topics — are not ONVIF and are not reachable from this page. Systems with their own GEM driver (Axis Camera Station, Hanwha Wisenet, Milestone XProtect, Blue Iris) can also be added as ordinary devices; use those where they exist, and use this page for everything generic.

Scan Configuration

The top section is ONVIF Network Scan.

Credentials

The scan supports multiple credential sets, so one pass can cover cameras from different manufacturers or with different passwords.

  • The form starts with one row pre-filled admin / admin. Overwrite it with a real login.
  • Click Add Credentials to add another username/password pair. New rows start with the username admin and a blank password.
  • Cameras are tried with each credential set in order until one succeeds.
  • Tick Show passwords to reveal every password field at once.
  • The trash button on a row removes that credential set.
  • Credentials are held only for the current page session. They are not saved in the browser, so re-enter them the next time you scan. Credentials for cameras you actually adopt are stored encrypted on the device or zone itself, so a repeat scan is the only thing that needs them again.
tip

Add your most common credentials first. During a range scan GEM tries each credential set on every reachable camera, so ordering by frequency shortens the scan.

Many cameras will not accept the web-UI login for ONVIF. Hikvision, Dahua/Amcrest, Uniview and others require a separate ONVIF user created in the camera's own settings, with media (and, for PTZ, PTZ) rights enabled.

Scan Settings

FieldWhat to enter
Scan TimeoutMilliseconds to wait before the scan stops. Range 1000–120000, default 60000 (one minute).
Target IP or RangeA single IP (192.168.1.100) or a subnet in CIDR notation (192.168.1.0/24). Auto-filled with the GEM server's own subnet when the page loads. Leave blank to use multicast discovery instead.

Running a scan

  1. Enter one or more credential sets.
  2. Adjust Target IP or Range and Scan Timeout if needed.
  3. Click Scan Network. A progress bar shows elapsed progress and a running count of devices found.
  4. Cameras appear in the Discovered Devices list as they are found — you do not have to wait for the scan to finish.
  5. Click Cancel Scan to stop early. Devices already found stay on the page.

The three scan modes

What you put in Target IP or Range decides how GEM looks, and the three modes do not return the same information.

Single IP — GEM connects directly, trying ports 80, 2020, 8080, 8000, 8899 and 554 in that order, and every credential set on each port, until one answers. If a camera rejects WS-Security (common on Amcrest and Dahua, which report a clock-check failure), GEM retries the same credentials using HTTP Digest. If nothing answers on any port, GEM additionally probes TCP 554 and, if it is open, offers manual RTSP entry.

CIDR range — a two-phase sweep:

  1. TCP probe. Every address in the range is tested on the six ONVIF ports, 100 targets at a time, and an address is dropped from further work as soon as it answers on one port.
  2. ONVIF connect. Reachable hosts are connected 15 at a time, 8 seconds per attempt, with the same WS-Security-then-HTTP-Digest fallback. Successful hosts are then queried for their full details.

When a range scan finishes, the toast reports how many devices were found, and how many hosts rejected every credential set — that count is your signal that the login, not the network, is the problem. Hosts that rejected all credentials still appear as cards with an error, so you can see which addresses to fix.

Blank — GEM sends a WS-Discovery multicast probe and collects whatever answers for the length of the timeout. This finds cameras whose address you don't know, but it collects less: a multicast-discovered card carries no PTZ credentials until you click Details on it, and it never offers the manual RTSP fallback.

tip

The pre-filled subnet is the right answer on almost every job. Leave it as-is unless the cameras are on a different VLAN than the GEM server, in which case type that subnet instead — multicast rarely crosses a VLAN boundary, but a routed CIDR scan does.

Discovered Devices

Each device is a card. Its heading shows a status dot, the camera's identity, and any capability badges:

  • Status dot — a spinner while GEM is still connecting, green once a stream URL is known, red when the connection failed, amber when the camera answered but produced no stream URL.
  • Identity — manufacturer and model, or Device at <ip> when the camera reports neither.
  • BadgesCamera, Relay, Input, Camera + Relay, Camera + Input, Relay + Input, or All. The badge is derived from what the device actually returned: a stream URL makes it a camera, relay outputs make it a relay, digital inputs make it an input.
  • Mapped to … — shown when the camera's stream URL already matches a camera zone. The zone name opens that zone in a reference modal over this page, so a scan in progress, the credentials you typed and the coordinator panel all survive the detour. It falls back to the Zones page when the modal can't host the record.

Below the heading the card lists IP, Port, and Serial (when the camera reports one), then the RTSP URL on its own line. A card with no stream URL yet reads Click "Details" to fetch camera information.

Relay outputs

Cameras and access controllers that expose relay I/O get a Relay Outputs (n) block. Each row shows the relay token — the exact string you will use as the zone Address — plus its mode and idle state when the device reports them, and a Map button. A relay already bound to a zone shows a ✓ and the zone name instead, and its Map button is disabled.

Tokens come from the device's GetRelayOutputs response. On firmware that doesn't implement that call, GEM falls back to the relay count in the device capabilities and synthesizes the tokens relay0, relay1, and so on. Those synthetic names are usually right, but not always — after you add the device, run get_relays against it to see the tokens the device itself reports, and correct the zone Address if they differ.

Digital inputs

Devices reporting input connectors get a Digital Inputs (n) block listing input0, input1, … marked (events only). These are informational: GEM does not currently map digital inputs to zones or raise events from them. To act on a dry contact, wire it to an input on a device GEM does poll, or use the camera's own rule engine to drive one of its relay outputs.

Device actions

ButtonWhat it does
DetailsRe-connects to the camera and fetches everything it will report — device info, profiles, capabilities, services, stream and snapshot URIs, relay outputs. The result is rendered as a Device Details block at the bottom of the page. This is also how a multicast-discovered camera gets its PTZ credentials filled in.
ViewOpens a live preview of the stream in a modal. Only shown once a stream URL is known.
Test RTSPOnly shown once a stream URL is known. This check is stubbed — it reports "RTSP URL is accessible" without contacting the camera, so treat a pass as meaningless. Use View to actually prove a stream.
Map to Zone / Remap ZoneOpens the camera mapping modal. When the camera is already mapped the button reads Remap Zone and asks for confirmation first.

Manual RTSP Mapping

Some cameras stream fine but expose no usable ONVIF service. When a single-IP scan finds no ONVIF service and TCP 554 is open, the card offers manual entry:

  1. Pick a manufacturer from the URL template… dropdown. GEM fills the field with rtsp://user:pass@<ip>:554<path>, using the first credential set that has both a username and a password.
  2. Edit the URL, or type one from scratch. Anything the camera accepts is fine.
  3. Click Set. The URL is attached to the card exactly as if ONVIF had reported it, and Map to Zone, View and the rest become available.

Templates cover TP-Link / TAPO (main and sub), Hikvision (main and sub), Dahua / Amcrest (main and sub), Reolink, Uniview, AXIS, Hanwha / Wisenet, Lorex / FLIR, Foscam, ONVIF Generic, and a bare /live.

note

Manual entry is offered on single-IP scans only. If a camera turns up in a range scan with no stream URL, re-scan that one address to get the manual field.

tip

If you don't know your camera's RTSP path, the iSpy camera URL database has paths for thousands of models.

Camera Mapping Modal

Map to Zone opens Map Camera to Zone, showing the camera being mapped and its IP, with two ways to land it:

  • Select Existing Camera Zone — pick any zone already in the Cameras subsystem.
  • Or Create New Camera Zone — type a name and GEM creates the zone in the Cameras subsystem for you.

Fill in one or the other, then click Save Mapping. The button stays disabled until you do.

Mapping writes these zone attributes:

AttributeValue
stream_urlThe RTSP URL including user:password@. Stored encrypted — see What the browser is told.
camera_ipThe camera's IP address.
camera_manufacturerManufacturer string the camera reported.
camera_modelModel string the camera reported.
snapshot_uriStill-image URL the camera advertised, when it advertised one.

PTZ Attributes

When the scan established that the camera supports PTZ and recorded the working ONVIF login, mapping also writes the attributes PTZ needs:

AttributeValue
onvif_hostCamera IP address.
onvif_portThe port the scan actually connected on — 80 on most cameras, 8000/8080 on some. Falls back to 2020 only if the scan recorded no port.
onvif_usernameONVIF username that worked.
onvif_passwordONVIF password (stored encrypted).
onvif_profileMedia profile token PTZ commands run against.

A camera mapped this way gets PTZ with no GEM device of its own — the zone attributes are the entire configuration.

warning

These attributes are only written when the card carries PTZ information, and a multicast scan (blank Target IP) never collects it. Scan by IP or CIDR, or click Details on the card before mapping, or the camera maps for streaming with no PTZ. If you notice afterwards, click Details and then Remap Zone onto the same zone.

Using PTZ

Bring a camera up on its own in the UI — one camera on screen, not a multi-camera tile view — and a PTZ button appears in the corner. It opens an overlay with:

  • an arrow pad — up, down, left, right, which repeat while held;
  • preset buttons 1–6 — tap to recall, press and hold to save the camera's current position into that slot;
  • zoom in and zoom out.

Saved presets are stored as ptz_preset_1ptz_preset_6 on the zone, holding the token the camera assigned. Recalling a slot that was never saved returns no preset saved in slot N. Deleting those attributes forgets the positions on the GEM side; the camera keeps its own.

The same commands are available anywhere you can send a command — a macro Command step, a UI control, a trigger action — addressed at the camera zone:

CommandArgsWhat it does
up / down / left / rightNudges the camera one small step in that direction.
zoom_in / zoom_outSteps the zoom in or out.
stopStops motion. Useful in a macro after a timed move; the overlay has no stop button of its own.
load_presetpresetRecalls the position saved in that slot.
save_presetpresetSaves the current position into that slot.

PTZ needs onvif_host (or camera_ip), onvif_username and onvif_password on the zone; without them the command fails with missing onvif connection attributes on zone. onvif_profile is optional — GEM resolves the camera's active profile when it is absent.

Snapshot URI Attribute

The scan also asks each camera for its still-image URI, and mapping writes it to the zone as snapshot_uri when the camera offered one.

AttributeDescription
snapshot_uriStill-image URL the camera advertised via GetSnapshotUri. Absent when the camera offers no stills.

Nothing reads this to take a picture — it is there to explain a specific and common failure: a camera whose stream works but whose snapshots don't. GetSnapshotUri routinely advertises a different host or port than the ONVIF service, and a NAT'd or multi-homed camera hands back an address the GEM server has no route to. Comparing snapshot_uri against camera_ip usually settles it in one glance.

An absent attribute means the camera never offered a URI, so it likely has no still-image support — map it for streaming and use a different camera for access-log snapshots.

The URI carries no credentials; ONVIF auth is applied per request.

Stream Performance Attributes

The RTSP proxy delivers a camera stream one of two ways — decoded to JPEG stills, or the camera's own H.264 remuxed for the browser. stream_mode picks which; see Stream mode: stills or video. The rest of these attributes tune the JPEG path, which is the only one that decodes:

AttributeDescription
stream_modejpeg (default) or video. Applies to both zones and camera device rows. Site-wide default: rtsp_stream_mode in gem.json.
stream_audioCarry the camera's microphone through to the browser. video mode only, off by default, and set on a zone or a camera device row like stream_mode — see Audio. Site-wide default: rtsp_stream_audio in gem.json, bitrate rtsp_audio_bitrate.
frame_rateOutput frame rate in fps (default: 3). Tile/preview views use a low rate; live/fullscreen views may request higher. Ignored in video mode, which delivers the camera's own rate.
resolutionOutput size as WxH (default: 720x480). The single biggest lever on CPU per stream. Ignored in video mode, which delivers the camera's own resolution.
frame_qualityJPEG quality, ffmpeg scale where lower is better (default: 8). Raise the number for smaller frames on a constrained link. Ignored in video mode.
hardware_acceleratorffmpeg -hwaccel value — none, auto, vaapi, qsv, cuda, videotoolbox or d3d11va. Unset inherits rtsp_hwaccel from gem.json, and failing that auto. If a configured accelerator is not available on the host, ffmpeg silently falls back to software decode and a warning is logged. Consulted in jpeg mode only — video mode instantiates no decoder, so GEM strips -hwaccel from that command line rather than leave a GPU apparently in a path it is not in.
stream_tls_insecureRecords that an HTTPS stream on this zone is not certificate-verified. HTTPS streams are not verified either way today — see below.
tip

The proxy runs ffmpeg single-threaded, since multiple concurrent decoders compete for the same cores. In jpeg mode, CPU is best controlled by lowering frame_rate and resolution rather than by skipping frames; video mode does no decoding at all, so none of those levers matter there. Audio is discarded on both paths unless a camera is opted in with stream_audio, which only video mode can honour.

What a stream URL may be

A stream_url must use rtsp, rtsps, http or https. Anything else is refused and logged. The value is an attribute, so whoever can write an attribute chooses what the decoder is pointed at, and the decoder understands protocols that read local files rather than network streams.

For an https stream, the camera's certificate is not verified. The decoder accepts whatever certificate the camera presents, and GEM does not override that. stream_tls_insecure makes the same choice explicit for one zone and is recorded in the log when the stream starts; it does not currently change the outcome.

This is a deliberate position rather than an oversight. Self-signed certificates are the norm on IP cameras, and several integrations address a camera by IP address, which no ordinary certificate can be issued for — enabling verification would stop a large share of working cameras with no operator-visible cause. What it means in practice:

  • Treat the camera network as part of your security boundary. An attacker with a foothold on the same segment can present any certificate, substitute the picture an operator is looking at, and read the camera credentials, because an ONVIF-discovered URL carries user:password@ in the URL.
  • Keep cameras off shared or guest segments, and prefer a dedicated VLAN with no client access.
  • Camera URLs themselves are no longer handed to browsers — see What the browser is told — so the credential is not readable from a user's session even though the transport is unauthenticated.

RTSP streams do not use this path at all.

What the browser is told

A camera URL is a credential. An ONVIF-discovered stream_url carries rtsp://user:password@host/..., and a snapshot image_url carries the same, so anything that hands that URL to a browser hands out the camera's password to whoever is sitting in front of it.

On zones, stream_url and image_url are therefore stored encrypted and never sent to a client. They are readable in the admin console by an elevated session, and nowhere else. The camera viewer asks the controller which kind of player a zone needs — snapshot, stream, video or jetstreamer — and the controller resolves the URL on its own side for both the live relay and the snapshot poll. Nothing an integrator does changes; a camera keeps working exactly as before.

Two deliberate exceptions:

  • video_url still reaches the browser, because the browser's own <video> element is what fetches it. Do not put credentials in a video_url.
  • stream_url on a device is untouched. That is the AV-source path (an HLS channel URL played directly by the stream widgets), not a camera.

Existing zones are converted on the first boot after upgrade; nothing needs to be re-entered.

Who may watch a stream

The live-frame connection is authenticated. A client must present a known client id, a valid session, and a resource token issued to that client before the controller will send it frames — the same evidence the main socket demands, and a token issued to a different session does not work. Nothing changes for the admin console, camera widgets or the ONVIF preview modal, which all present it automatically. A deployment whose topology genuinely cannot satisfy the check can set rtsp_auth_check: false in gem.json.

Stream URLs are never written to the log. Log lines about a relay name the zone or device instead, because journal contents travel further than the database does — they are read by the log viewer and land in support bundles and the nightly DR backup, and an ONVIF URL is a live camera credential.

Stream Resilience

Camera streams self-recover without operator action. A relay that can't reach its camera, or whose stream stalls mid-session, is detected and restarted automatically on a backoff schedule:

  • Socket I/O timeout — ffmpeg is bounded to 10 seconds of no read/write progress. A camera that is unreachable at start, or drops its connection mid-stream, causes ffmpeg to exit instead of blocking forever. The relay then restarts it.
  • Frame watchdog — for hangs that keep the connection alive while frames stop (decoder wedge, stalled hardware accelerator, RTP stream gone quiet), the relay forces a restart if no frame arrives for 15 seconds.
  • Backoff self-healing — successive restarts use an escalating delay so a flapping camera isn't hammered. Once a relay has streamed frames continuously for about a minute, that delay resets to the minimum, so an earlier rough patch doesn't penalize a later, unrelated reconnect.
  • Confirmed kill before respawn — when the watchdog force-kills a wedged ffmpeg, the replacement is spawned only after the old process has actually exited. A process stuck in uninterruptible I/O (dead camera behind a severe network stall) can survive a kill signal for minutes; spawning over it would leak one live decoder per watchdog trip. If the process outlives its kill for 60 seconds, a Camera Stream Wedged system alarm is raised, and it clears (with the stream restarting) once the process finally exits.
  • Global decode cap — at most 32 concurrent stream processes system-wide (override with rtsp_max_ffmpeg in gem.json), counting any killed-but-unreaped processes. A relay past the cap keeps retrying on the backoff schedule instead of spawning.
  • Load backpressure — new decoders are not spawned while the host's load exceeds 3× its core count; deferred relays retry automatically as load drains. This prevents a wedged-camera cascade from feeding itself. Only runnable load counts: tasks stuck in uninterruptible sleep are subtracted first, because they consume no CPU and declining to spawn does nothing to clear them. That subtraction counts every uninterruptible thread on the host, including waits on a hung graphics device — those inflate load average without ever appearing in the kernel's block-I/O wait counter, so a controller with a wedged GPU would otherwise look like a CPU crunch and lock every camera out permanently.
  • Write-off after 60 seconds — a process that outlives its kill signal for a minute is written off. It keeps being swept and re-killed, but it stops holding its stream hostage and stops counting against the decode cap, so the stream restarts rather than waiting on a death that may never arrive. A stream that has to do this three times without a healthy minute in between stays dark and keeps its alarm raised, so this can't become a leak.
  • Hardware decode quarantine — every camera on a host shares one decode device, so when two or more streams wedge unkillably with hardware acceleration enabled, GEM treats the device as the common cause and respawns all streams on software decode (rtsp relay disabling hardware decode for all streams in the log). Thumbnail decode is cheap in software, so this is a small CPU cost in exchange for pictures. The quarantine lasts until GEM restarts, which re-tries hardware decode.

A stalled stream typically recovers on its own within ~15 seconds of the camera becoming reachable again. Repeated rtsp relay watchdog forcing restart warnings in the logs for one zone indicate a camera that connects but never delivers stable frames — check the stream URL, resolution, and hardware_accelerator setting.

Stream mode: stills or video

There are two ways a camera can reach a browser, chosen per camera by the stream_mode attribute on the zone (or on a camera device row).

jpeg (default)video
What the server doesDecodes every frame, rescales it, re-encodes it as a JPEGCopies the camera's H.264 through untouched
What the browser gets~3 stills a second at 720x480The camera's own frame rate and resolution
Decoder on the serverYes — and a GPU, unless hardware_accelerator is noneNone at all
LatencySub-secondOne keyframe interval (see below)

video mode is almost always the better choice. Because it never decodes, ffmpeg never opens the decode device, so the whole class of failure described under Turning hardware decode off — a GPU hang dragging every camera process into an unkillable state — cannot occur on that stream. It is also far cheaper: a byte copy instead of a decode plus an encode.

Set it on one camera:

stream_mode = video

or site-wide in gem.json, with the per-zone attribute still winning where it is set:

{
"rtsp_stream_mode": "video"
}

Two things to know before switching a site over:

  • Latency is one keyframe interval. Fragments are cut at keyframes, so a camera with a 4-second I-frame interval gives roughly 4 seconds of latency. If a camera feels sluggish, shorten its GOP / I-frame interval in the camera's own web interface — 1 second is a good target — rather than changing anything in GEM.
  • H.265 cameras stay on stills. No browser plays HEVC through Media Source Extensions. The player detects this from the stream's own codec information and falls back to the JPEG path automatically, so an H.265 camera set to video still shows a picture; it just doesn't gain anything. GEM remembers the codec it saw for about an hour, so after the first view such a camera goes straight to the JPEG player instead of starting a stream process that the browser will refuse. Re-encode the camera to H.264 and video mode resumes on its own within the hour — no restart needed.

H.265 is worth hunting down rather than living with, because the stills path pays for it twice over: the server has to decode the full stream to produce a thumbnail, so a 4K 30fps HEVC camera costs roughly half a CPU core per viewer to make a 3fps 720x480 picture. Check the encoding on every camera you intend to view. Where a camera can't be moved off H.265, pointing its stream_url at a lower-resolution profile cuts that cost dramatically even though it stays on stills — the decode is what's expensive, and it scales with the source resolution, not the thumbnail's.

The log line to look for after switching a site to video names the codec per camera:

rtsp relay fmp4 init segment ready: front_door_camera codec: avc1.4d4029 884 bytes
rtsp relay stream codec is not playable in a browser, falling back to stills for this camera: pool_camera hvc1
  • A browser that can't keep up is restarted, not buffered. Stills are pulled by the client, so a slow viewer simply skips frames; video is pushed, and the controller tracks what each viewer has actually finished playing. A viewer that falls several seconds behind — a throttled background tab, a thin remote link — stops being sent to and picks the stream back up at the live edge once it drains, which reads as a brief freeze and a restart rather than an ever-growing delay. The log says rtsp relay viewer fell behind, pausing stream and then caught up, resuming stream.

Which surface follows the setting: the camera control and the Camera widget (both mount the same viewer) use video where it is set. The ONVIF page's own preview always uses the JPEG relay, and macro snapshots don't go through the relay at all — they ask the camera's driver for a still. A camera watched both ways at once simply runs two relays.

Audio

A camera in video mode can carry its microphone through to the browser. It is off everywhere until you ask for it, per camera:

stream_audio = true

or site-wide in gem.json, with the per-camera attribute still winning where it is set:

{
"rtsp_stream_audio": true,
"rtsp_audio_bitrate": 64
}

rtsp_audio_bitrate is the AAC bitrate in kbit/s — 64 by default, and clamped to 8–320 so neither an encoder refusal at the top end nor something nobody would call audio at the bottom can be configured by accident. A value that isn't a number takes the default rather than being handed to ffmpeg to reject.

The default is off deliberately, and not because of what it costs. A camera that can be watched becoming a camera that can be listened to changes what the system discloses about a room, and everyone who can already see that zone gets the new capability the moment it is switched on. That is a decision to make per camera, with the people in the room in mind — an upgrade is not consent to it.

A viewer hears nothing until they ask. The stream arrives muted and a speaker button appears in the bottom-left of the tile, clear of the zone title and the PTZ pad; audio starts on the click, and that click does not also expand the camera. That is partly a browser rule — muted playback is the only kind that starts without a user gesture, so a tile could not come up talking even if we wanted it to — and partly the right default for a camera wall.

The choice is remembered per camera, for the session. A camera you are listening to stays audible when its stream reconnects, and when you tap the tile to fill the screen — that expansion tears the player down and builds a new one, and re-muting there would silence the camera at the moment you asked for more of it. It is held in the browser only and never written down, so a wall panel that reboots overnight comes back silent rather than resuming a live microphone with nobody in the room to notice. Unmuting one tile does not mute the others: a wall with several audio cameras can have several talking at once, which is left to the person watching rather than enforced.

If a browser refuses to start unmuted playback, the tile starts muted rather than not starting at all — the picture is never traded for the sound, and the speaker button still works, because pressing it is itself the gesture the browser was waiting for.

Only cameras that actually send audio show the button. Plenty of cameras have no microphone, or have it disabled in their own configuration. GEM reads what the stream contains rather than what the attribute asked for, so a camera with stream_audio set that sends no audio track behaves exactly as it did before — no button, nothing to explain.

H.265 cameras get no audio, for the same reason they get no video mode: the whole stream falls back to stills, and the stills path has nowhere to put audio. The console says so on the affected tile.

A browser that won't play video and audio together drops to stills. Both tracks arrive in one buffer, so a browser that refuses the pair refuses the stream, and the tile falls back to the JPEG player — which from the outside is indistinguishable from an H.265 camera doing the same thing. The browser console names which half was refused so the two can be told apart:

rtsp video player: this browser plays avc1.4d401f but not alongside mp4a.40.2 - turn stream_audio off for this camera to keep video mode

Turning stream_audio off for that camera is the fix, and it puts the camera back in video mode.

Two operational notes:

  • GEM re-encodes to AAC rather than passing the camera's audio through. This is not a preference. Most cameras — and every SIP intercom — send G.711, and the MP4 container has no tag for it: a pass-through does not produce a silent stream, it fails to start at all (Could not find tag for codec pcm_mulaw), which through the relay's restart backoff would mean a camera that never comes back. Encoding costs about a third of one percent of a CPU core per camera, which is roughly a twelfth of what the same camera costs in jpeg mode, and it means the browser is handed the same audio format no matter what the camera sends.
  • Audio can never take video down with it. If ffmpeg refuses the audio encoder, or a stream repeatedly opens the camera without producing anything playable, GEM drops audio for that stream and keeps the video running rather than restarting into the same failure. The log line is rtsp relay dropping audio, video continues: with the reason. That decision stands until the stream is torn down and reopened, so a camera whose audio genuinely does not work here does not spend the rest of the day flapping. A camera that was simply unreachable spends none of that budget — a stream ffmpeg never opened says nothing about whether its audio would have worked.

Which state a relay started in is on the line that creates it:

rtsp relay created: front_door_camera 720x480 3fps q8 hwaccel: none audio: 64k aac
rtsp relay created: pool_camera 720x480 3fps q8 hwaccel: none audio: off

What a video tile shows before its first frame

Attaching to a stream is not instant — the relay has to start ffmpeg, open the RTSP session, and wait for the camera's next keyframe, which is a second or three. The tile shows a spinner for that whole period, clearing only once the browser has actually put a frame on screen — not merely buffered data, which a tile joining a running stream mid-keyframe-interval can have while still showing nothing — so a camera that is still connecting reads as connecting rather than as a black rectangle. The spinner returns whenever a stream is rebuilt, since that also goes back to waiting for a keyframe.

A tile that opens onto a stream already running skips the wait. Every viewer of one camera shares a single connection to the server, and the client keeps that stream's header, so a second tile of the same camera — or a tile torn down and rebuilt by switching between the grid and a maximized camera — is handed the header immediately and paints on the next fragment. Together with the grace window below, moving around a camera page normally involves no reconnect at all.

How long a stream stays warm after you close it

A relay outlives its last viewer, so flipping between views doesn't restart ffmpeg. The window differs by mode, because the two cost very different amounts to hold open:

WindowWhy
video (remux)30sCosts ~1% of a core. A viewer returning inside the window skips both the ffmpeg spawn and the wait for the camera's next keyframe — most of the delay before a stream first paints.
jpeg5sStill a full decode, around half a core on a 4K camera. Held open only long enough to absorb a view transition.
camera's power_zone is off0sTorn down immediately whatever the mode — the camera has no power.

Warm is cheap but not free: the relay keeps pulling the camera's full bitrate (~2 Mbit/s each) and keeps holding one of the camera's finite RTSP sessions, which the recorder is also drawing on. That is why this is half a minute rather than indefinite — too many concurrent pulls is what breaks live view on an intercom.

Tune per site in gem.json:

{
"rtsp_video_viewer_grace_ms": 30000,
"rtsp_viewer_grace_ms": 5000
}

0 is valid and means "tear down as soon as the last viewer leaves" — reasonable where bandwidth is tight. A negative or unparseable value is ignored rather than obeyed, since it would tear every relay down the instant a viewer blinked.

Turning hardware decode off

A host whose GPU can't be trusted with continuous decode can be taken off it entirely. Set hardware_accelerator to none on a camera zone for one stream, or rtsp_hwaccel to "none" in gem.json to change the default for every stream that doesn't set its own:

{
"rtsp_hwaccel": "none"
}

Unset, the default remains auto (ffmpeg picks an accelerator, falling back to software if none initializes). At the frame rates and resolutions used for camera thumbnails, software decode costs little — on a 16-core host, a few percent of one core per camera — so none is a reasonable standing choice on any site where a GPU driver hang has caused an outage.

A hung decode device is worth recognizing: streams go black with no ffmpeg using any CPU, the processes sit in D state and ignore kill -9, and load average climbs while the machine is idle. Nothing but a reboot clears it. The quarantine above exists to keep cameras working through it, not to fix the GPU.

Relay Mapping Modal

Map on a relay row opens Map Relay Output to Zone, showing the device, its IP and the relay token:

  1. Select Subsystem — which subsystem the relay zone belongs to. Pick the one that matches what the relay physically does (Access Control, Doors, Gates, Lighting); it decides where the zone appears and which control the UI offers for it.
  2. Select Existing Zone — an existing zone in that subsystem to bind the relay to, or
  3. Or Create New Zone — a name for a new zone in that subsystem.
  4. Click Save Mapping.

Mapping a relay does more than write a zone, and it is worth knowing what lands where:

  • The zone is bound to the coordinator — the onvif device selected in the coordinator panel, or the only one on the site. If none exists yet, GEM creates one named ONVIF Cameras and seeds the scan credentials as its default username and password.
  • The zone's Address is set to <camera_ip>/<relay_token> — the address alone identifies both the camera and the relay. If you ever change it by hand, the token half must stay an exact token from get_relays.
  • The camera's IP, its port when not 80, and the username and password that worked are written to the zone as its own connection attributes, so the relay keeps working even when the coordinator's default credentials differ.
  • Everything is an ordinary row afterwards. Edit it on the Devices and Zones pages like anything else.

Commanding a mapped relay

Once mapped, the zone accepts the driver's relay commands from a UI control, macro Command step, trigger action, or the command console:

CommandAliasesArgsWhat it does
closeon, activate, lockaddressEnergizes the relay (ONVIF logical state active) and sets the zone state to closed.
openoff, deactivate, unlockaddressReleases the relay (inactive) and sets the zone state to open.
pulsetrigger, momentaryaddress, delayCloses, waits, then opens. delay is in milliseconds and defaults to 1000. This is the one to use for a door strike or gate contact.
get_relaysstatusEnumerates the relay outputs of every coordinated camera — token, mode, delay time, idle state, and the ready-made <camera_ip>/<token> zone address for each.

A command aimed at the zone needs no arguments — the zone's Address supplies both the camera and the relay token (<camera_ip>/relay0; a bare relay0 works when the zone carries its own ip attribute or the device coordinates a single camera). Use the address argument, as <camera_ip>/<token>, when you send the command to the ONVIF device instead of a zone, which is how you drive a relay that has no zone of its own.

Relay state is written, not read

The driver reports closed and open from what GEM last commanded — it does not poll the camera for the relay's real position. If the relay is also thrown by the camera's own rules or by a physical override, GEM will not see it until the camera's relay event arrives (cameras that publish Device/Trigger/Relay keep the zone live through the event subscription). For a door that must report true position, wire a separate contact into something GEM does poll rather than trusting the relay zone's state.

The ONVIF device on its own

You can also add an ONVIF device by hand from the Devices page rather than via a scan — the coordinator itself, useful for an access controller with no camera. Set the Driver to onvif; the device carries no camera address of its own:

FieldRequiredDefaultNotes
Default UsernamenoSite-default ONVIF account for zones without their own credentials. Not necessarily the web-UI account.
Default PasswordnoStored encrypted.
Allow Off-Host Snapshot URInooffSee Where a snapshot may be fetched from.

The cameras live on the zones — bind each zone to this device and give it an ip attribute (and port when the ONVIF service is not on 80; some vendors expose it on 8000 or 8080). Then run get_relays to list relay tokens, and give each relay zone <camera_ip>/<token> as its Address.

Cameras behind NAT, a proxy, or a tunnel

Cameras advertise their own notion of their address in GetCapabilities — not the address they were reached on — and a camera behind a port-forward or tunnel therefore advertises a host the GEM server cannot route to. The driver pins every advertised service address (media, events, PTZ, snapshots) back to the zone's camera address whenever the advertised host differs, so such a camera works with no extra configuration: point the zone at the address that reaches the ONVIF service and everything else follows it. Snapshots opt out of the pin via allow_offhost_snapshot.

Stream Preview

View opens a Stream Preview modal with the live picture, titled with the camera's manufacturer and model. Close it with Close.

The preview reuses a single hidden zone named onvif_preview in the Cameras subsystem, created the first time anyone previews a camera and re-pointed at each camera you preview afterwards. It is deliberately excluded from the mapped-camera list, so it never looks like a real mapping — leave it alone rather than deleting it.

Because that zone is shared, closing the modal drops its stream connection outright instead of holding it open for reuse the way an ordinary camera tile does. Each View therefore reconnects against the camera you just picked, so previewing a second camera shows that camera rather than the last one you looked at. Each relay's frames are filed under the stream URL it was started with rather than whatever the shared zone currently points at, so a previous camera still finishing its shutdown grace window cannot flash a frame into the new preview.

tip

Preview before mapping. It is the only check on this page that genuinely proves credentials and stream URL, since Test RTSP does not contact the camera.

Snapshots

The onvif driver captures a still from any camera that publishes a snapshot URI, returning base-64 JPEG data.

CommandArgsWhat it does
snapshot / get_imageaddressCaptures a still. address is <camera_ip>/<profile_token> — a zone address after sync_zones. A bare token works when the device coordinates a single camera, and no address at all uses that camera's default profile.
get_profilesLists the media profiles of every coordinated camera — token, name, resolution, encoding, and whether a snapshot URI is available.

Each channel on an NVR backend, and each stream on a multi-profile camera, is its own media profile. Run get_profiles first to see what each camera advertises, then pass <camera_ip>/<token>:

get_profiles → { cameras: [ { camera: "10.0.0.5", port: 80,
profiles: [ { token: "Profile_1", name: "MainStream",
resolution: {width: 1920, height: 1080},
has_snapshot: true, current: true }, … ] } ] }

A device coordinating a single camera also returns the flat profiles array, which is the shape every pre-coordination consumer read.

A profile whose has_snapshot is false returned no GetSnapshotUri — that profile is stream-only and the command reports it rather than hanging. Some firmware only publishes the URI to a fully authorized account, so check the ONVIF user's media rights before concluding the camera can't do stills.

Every profile's snapshot URI is resolved once when the camera session opens, so a capture picks its profile by lookup rather than by changing shared session state — two overlapping captures on different profiles cannot photograph each other's stream. Captures on one coordinator still run one at a time, now as a load bound rather than for correctness: a badge read, a camera widget and a macro can all fire at once, and a camera is a small embedded HTTP server that answers a burst of megapixel stills badly.

Where a snapshot may be fetched from

The snapshot URI is chosen by the camera, not by GEM — GetSnapshotUri returns whatever host the device names. The driver handles that URI two ways:

  1. Host pinning. An advertised host that is not the zone's camera address is rewritten back to that camera's ip and port, keeping the advertised path — so a camera that misstates its own address (NAT, a stale self-address after a subnet change, a camera reached through a proxy or tunnel) still serves stills from the address GEM actually reaches it on. An advertised URI already on the camera's host passes through untouched, port and all.
  2. Address safety. Loopback, link-local (including 169.254.169.254) and multicast targets are refused before any fetch. Ordinary LAN addresses are allowed, so a normal camera on an RFC1918 network is unaffected.

Host pinning matters because ONVIF credentials are sent with the request — a snapshot fetched from a host other than the camera would hand that host the site's ONVIF username and password.

AttributeTypeDefaultPurpose
allow_offhost_snapshotbooleanfalseKeeps the camera's advertised off-host address instead of pinning it, and permits the fetch from that host.

Set allow_offhost_snapshot to true only when the off-host address is genuinely intentional — a NAT'd camera whose advertised address is the reachable one, or a published hostname. It does not disable the address-safety check.

warning

Access-control snapshots run on a physical badge read, with no GEM session involved. Enabling allow_offhost_snapshot on a door camera means the ONVIF credentials go to whatever host that camera names, every time someone presents a card.

Checking what the camera advertised

Two read-only attributes are written on each camera zone at connect:

AttributeMeaning
snapshot_uriThe still-image URL a capture will actually fetch — the camera's advertised URI, after off-host pinning. Blank means the camera returned none.
snapshot_profileThe profile token a snapshot with no address will use.

These are diagnostic. Captures always read the session's own profile list, never these values, so they cannot go stale in a way that breaks anything — the URI is resolved fresh on every connect.

GetSnapshotUri frequently advertises a different host or port than the ONVIF service itself — a NAT'd or multi-homed camera routinely hands back an address the GEM server has no route to. The driver pins an off-host advertisement back to the zone's camera address (see Where a snapshot may be fetched from), so the host in snapshot_uri normally matches the camera the zone names; what remains worth reading off it is the port and path — a camera serving stills on a port the server cannot reach shows it here, and that is the whole diagnosis. With allow_offhost_snapshot set, the attribute keeps the camera's own word verbatim.

The URI carries no credentials — ONVIF auth is applied per request — so it is safe to read and share.

Snapshots for access-control logs

Every access-control event captures a snapshot of the door. Readers with no camera of their own — 2N Access Unit and similar models — can borrow an ONVIF camera instead:

  1. Add the camera as an ONVIF device and confirm snapshot returns an image.
  2. On the access device, set camera_zone_id to the camera's zone — the attribute editor opens a zone picker, and GEM reads the device and the camera off the zone for you. The zone's Address does not need to be written yet: before sync_zones, the capture resolves the zone's own camera and uses its default profile.

If the camera has no zone, set camera_device_id to the ONVIF device instead, plus camera_address to <camera_ip>/<profile_token> covering that door (a bare profile token works when the device coordinates a single camera).

GEM then photographs the door from the camera on every card swipe, PIN entry, or call-button press. See Access Control.

Setting a camera's encoding

Cameras increasingly ship H.265 on every profile, and no browser can play H.265 — so GEM has to decode the whole stream just to show a picture, which on a 4K 30fps camera costs roughly half a CPU core per viewer. The encoding is settable over ONVIF, so this is configuration rather than a hardware limit.

CommandArgsWhat it does
get_video_encodersRead-only. Per camera and media profile: the encoder configuration it uses, its current encoding, resolution, frame rate and GOP, whether that configuration is shared with another profile, and every encoding the camera says it would accept.
provision_zone_stream_urlsoverwrite, plus every argument belowThe one-shot route. For every camera zone on the device: set the GEM-owned profile to H.264 at a sensible size, point the zone's stream_url at it, and switch the zone to stream_mode=video.
provision_stream_profilecamera, profile, encoding, resolution, framerate, gop, bitrate, forceSets the GEM-owned profile to an encoding the browser can play, and stops there — it does not touch any zone. Defaults to H.264 on media profile 2, applied to every coordinated camera.
All three require an elevated session

These commands name no zone and no address, so the central address confinement has no candidate to examine and cannot narrow them — they act on every camera the coordinator manages, including zones marked Elevated. Ungated, a lobby wall panel with one camera tile could rewrite resolution, bitrate and frame rate site-wide, and provision_zone_stream_urls overwrite=true could repoint every camera zone's stream_url — degrading what the NVR records and blacking out live view everywhere.

A non-elevated caller (a guest tablet, kiosk or PIN wall panel) gets not authorized: <command> requires an elevated session and nothing is written. Macros, triggers, schedules and internal driver callers are unaffected, as are the pre-existing commands — get_profiles, sync_zones, snapshot and every mapped zone command behave exactly as before. See Roles → Elevated devices and macros.

The one-shot route

provision_zone_stream_urls → fills in zones that have no stream_url
provision_zone_stream_urls overwrite=true → repoints zones that already have one

This is the command to reach for on a new site. It follows sync_zones' rule: a zone with a stream_url already set is left exactly as it is unless you pass overwrite=true. That rule also governs the camera — a camera whose zone is not going to be repointed is not reconfigured either, because changing a profile nothing then uses is pure disruption.

Resolution is chosen for you here: the largest the camera offers at or below 720p. This stream feeds a tile or a wall panel, not an evidence recording — the recorder keeps profile 1 for that — so more pixels only cost the panel decode work and the network bandwidth for detail nobody is looking at. Name resolution explicitly to override.

Which profile it picks

"Profile 2 is ours" is the convention, and it holds for cameras that publish main / sub / third streams. It does not hold for every camera, so the command reads what the camera actually offers before changing anything. Three shapes seen on one real site:

Profile 1Profile 2Profile 3What happens
H265 4KH265 352×240H265 704×480Profile 2 re-encoded to H.264 at 720p — the case the convention describes
H264 1080pJPEG 1080pH265 1080pRefused. Profile 2 is a deliberate MJPEG stream; converting it destroys that stream
H264 HQH264 640×480JPEGProfile 2 used as it is — already playable, so the camera is not written to at all

The rules behind that table:

  • A non-recorder profile that already carries the wanted encoding wins over re-encoding anything. The least disruptive fix is the one that changes nothing.
  • Profile 1 is never a candidate, however playable it is — a zone is not repointed onto the recorder's stream.
  • An MJPEG profile is never converted. Cameras that publish one publish it on purpose, and something is usually pulling it.

Naming profile explicitly opts out of the search and targets exactly the profile you named — but not out of the refusals below. Profile 1, a shared encoder configuration and an MJPEG stream are declined on either route, named or not, unless you pass force=true.

If a camera accepts the re-encode request but keeps its old encoding, its zone is left alone and the row reports the refusal. Pointing a zone at a stream the browser still cannot play would turn a clear camera error into a mysterious black tile.

{ provisioned: 7, skipped: 0, errors: 0,
zones: [ { zone_id: 166, zone: "front_walk_camera", camera_ip: "192.168.0.125",
action: "set", encoding: "H264", resolution: "1280x720",
profile: 2, reencoded: true,
profile_token: "profile2", stream_mode: "video",
stream_url: "rtsp://192.168.0.125:554/profile2" }, … ] }

Each row names the profile it settled on and whether the camera was written to: profile is the media profile the zone now points at — not necessarily the one you asked for — and reencoded: false means that profile already carried the wanted encoding and the camera's encoder settings were left untouched.

The reported stream_url is redacted — the real one embeds the camera's password, and this report travels into command results and logs.

Run get_video_encoders first — it tells you which cameras need changing and what each will accept:

profile 1 encoder=VideoEncode_token_1 H265 3840x2160 @30 gop=60 shared=false
profile 2 encoder=VideoEncode_token_2 H265 352x240 @30 gop=60 shared=false
supports: H264[1920x1080..352x240] H265[…] JPEG[…]

Then either run provision_zone_stream_urls for the whole device, or set one profile by hand and repoint the zone yourself:

provision_stream_profile resolution=1280x720 framerate=15 gop=15

Only the encoding is opinionated. Resolution, frame rate, bitrate and GOP keep the camera's current values unless you name them, so the command changes the codec rather than quietly re-specifying somebody's camera. The result is read back from the camera afterwards, because firmware that accepts the request and ignores half of it is common — applied: false means exactly that.

A profile that is already what you asked for reports changed: false, applied: true and the camera is not written to at all, so applied alone answers "is this profile now correct?" on either path.

gop is the keyframe interval, and in video stream mode it is the latency: fragments are cut at keyframes, so gop equal to framerate gives about one second.

The account needs rights to write

Reading encoder settings and changing them are different privilege levels on the camera. A media-only ONVIF account can run get_video_encoders perfectly and be refused by every set:

error: camera refused: ter:NotAuthorized - The current user does not have permission to access.
The ONVIF account GEM connects with (gem) can read encoder settings but not change them -
give it Administrator or Operator rights on the camera, or point the zone/device
credentials at an account that has them.

Fix it on the camera, in its own user administration: raise that account to Administrator (Operator is enough on most firmware), or put an account that already has those rights in the zone's username/password attributes. A quick way to tell before you try: if get_users is also refused for that account, it is not an administrator.

Three things it refuses to do

Each exists because getting it wrong silently re-encodes recorded evidence, or takes out a stream somebody else is watching. All three apply to both routes — including a profile you named yourself — and all three are overridable with force=true.

  • It will not touch media profile 1. Sites almost universally give the first profile to the recorder and keep the second for everything else. Pass force=true only for a camera you know has no NVR pulling it.
  • It will not change an encoder configuration that two profiles share. On cheaper firmware several media profiles point at one configuration, and there "changing profile 2" changes profile 1 as well. get_video_encoders reports this as shared: true before you try.
  • It will not convert an MJPEG profile. A camera that publishes one publishes it deliberately, and something is usually pulling it. This bites hardest on cameras laid out h264 / jpeg / h265 — an AXIS P12 is — because media profile 2 is this command's default, so a bare provision_stream_profile would otherwise destroy a stream the operator never named:
error: media profile 2 is an MJPEG stream, and converting it to H264 would destroy it.
Name a different profile, or pass force=true if this camera really should lose
its MJPEG stream.

Asking for encoding=JPEG is not a conversion, so it is not refused. Run get_video_encoders first if you are unsure what profile 2 is on a given model.

After a bare provision_stream_profile, repoint the zone's stream_url at the new profile (…/profile2) and set stream_mode to video yourself — provision_zone_stream_urls is the command that does both for you. GEM caches an undecodable codec for about an hour, so a camera that had already fallen back to stills returns to video on its own within the hour — no restart needed.

Coordinating Multiple Cameras

The onvif device is a coordinator: one device row serves any number of camera zones — the same shape as Axis Camera Station, minus the central server. Each zone IS a camera, named by its own ip attribute or the attributes camera mapping already writes (camera_ip, onvif_host, or the host inside stream_url), and the coordinator opens a per-camera ONVIF session on demand. A device converted from the old 1:1 shape — one camera zone — keeps working unchanged, bare profile-token addresses included.

The device row itself holds no camera address — only optional default credentials and the event settings. Everything that identifies a camera lives on its zone:

Zone attributePurpose
ip (or camera_ip / onvif_host, or the host inside stream_url)The camera's address — set whichever the mapping flow already wrote.
port (or onvif_port)ONVIF service port, default 80.
username / passwordThis camera's ONVIF account. Optional — see the credential order below.

To set it up:

  1. Bind each camera zone to the coordinator — set the zone's Device (device_id) to the onvif device — and make sure it carries a camera address attribute.
  2. Run sync_zones on the device.

sync_zones walks the device's bound zones and, for every camera zone whose Address is empty, reads the media profiles from that zone's own camera and writes the address the snapshot path uses: <ip>/<token>, e.g. 192.168.1.82/profile2. The address always carries the camera because profile tokens collide across cameras (profile1 is everyone's main stream) and a capture may be handed the address alone — camera_address on an access device, an explicit snapshot argument — with no zone attached.

The profile is chosen as: the zone's onvif_profile if set, else the first profile that advertises a snapshot URI, else the first profile. An address that is already set is never overwritten — the command reports it as skipped, so an integrator's explicit profile choice survives every re-run. Relay zones carry no camera identity in their address token and are ignored entirely.

Credentials for each camera resolve most-specific first: the zone's own username / password, then onvif_username / onvif_password (the scan page writes these only after connecting with them), then credentials embedded in stream_url, then the device's default username and password — so one shared ONVIF account lives once on the device instead of on thirty zones.

Once synced, snapshot routes by the address automatically — a zone-targeted capture, a snapshot with address / camera set to <ip>/<token>, and an access-control capture through camera_zone_id all reach the right camera. A zone-targeted capture also works before the sync — an address-less zone resolves its own camera on the default profile; what the written address adds is the profile choice, and a selector that can stand alone. Every per-camera request is pinned to the camera the zone names, so a camera advertising a stale or NAT'd address still answers on the address it was reached at, and the credentials never travel to a host the zone didn't name.

A zone-targeted capture works because the server stamps the resolved zone onto the command as zone_id, which the driver reads when no address is present. That stamp is the server's — a zone_id a non-elevated caller puts in the arguments itself is dropped before the driver sees it, so the way to photograph a specific camera is to target its zone (or name its address), not to pass an id in args. Elevated sessions and internal callers — macros, triggers, schedules, access-control captures — are unaffected. See Elevated devices and macros.

Coordinator tools on the scan page

The top of Integrations → ONVIF has a coordinator panel: select the onvif device (or create one in a click if none exists) and it lists every bound zone with its camera, address and type. From there you can capture a snapshot per camera zone, pulse / open / close relay zones, run Sync Zones with a per-zone report, inspect Event Status per camera, and enumerate Relays across every camera. Edit device beside the coordinator's status badges opens the coordinator's own device record — driver settings, credentials and defaults — in a reference modal, and each row of the Sync Zones report links its zone the same way, so neither costs you the page's live state. Mapping a camera from a scan offers a "bind to coordinator" checkbox that wires the zone's device_id and ip attribute in the same step, and relay mapping binds to the coordinator with a ready-made <ip>/<token> address.

Events

The onvif driver subscribes to each camera's own event stream — one subscription per coordinated camera — and turns motion, analytics and I/O notifications into zone attributes you can trigger automations from: the event lands on the zone that IS the camera. It is on by default; nothing needs configuring for a camera that supports it.

The transport is an ONVIF PullPoint subscription — GEM holds a long-poll request open on each camera and the camera answers it when something happens. Nothing connects back to GEM, so there is no delivery endpoint to keep reachable and no inbound port to open.

What you get

AttributeTypeFrom
person_detected / person_detected_atbool / intA person specifically — see Person detection
person_count, vehicle_count, bike_countintThe camera's counting rule (CountAggregation)
vehicle_detectedboolAn event that named its object class as a vehicle. Only cameras that stamp a class onto the event set it
motion / motion_atbool / intVideoSource/MotionAlarm, CellMotionDetector/Motion, or a Device/Sensor/PIR sensor
intrusion / intrusion_atbool / intRegion rule — an object is inside a configured area
line_crossed / line_crossed_atbool / intTripwire crossed
region_entry, region_exit (each with _at)boolObject entered / left a configured region
loitering / loitering_atbool / intObject lingered long enough to trip the loitering rule
face_detected / face_detected_atbool / intFace-detection rule — detects a face, does not identify it
sound_detectedboolAudio-detection rule
scene_change, image_too_blurry, image_too_dark, image_too_brightboolPicture-quality signals — lens covered, sprayed, or blinded
tamperingboolThe camera reports active interference (VideoSource/Tampering)
casing_openboolThe camera enclosure was opened (Device/Casing/Open)
signal_lossboolCamera lost its video signal
last_event_topicstringRaw topic of the most recent notification, mapped or not

All of the above are zone attributes on the camera's zone. The device carries two coordinator-level ones: events_connected (bool — true while any camera's subscription is up; per-camera detail is in event_status) and cameras_connected (string, e.g. 3/4).

Every spelling of motion drives the single motion attribute — the two video-motion topics and a PIR sensor alike — so an automation binds one thing regardless of what a given camera detects with. On hardware reporting more than one, they share the attribute and the most recent event wins.

tampering and casing_open are worth knowing about on AXIS in particular: they are firmware built-ins, so they work on a camera with no analytics application installed, which is also a camera that will publish no motion at all.

When the subscription first connects, the driver seeds a baseline false for every one of these attributes the camera actually advertises in its topic set — so intrusion or scene_change exists (and is visible to the condition builder and the alarm suggestions below) before the first event ever fires, and a camera without a loitering rule never grows a loitering attribute. person_detected needs person evidence, not just a rule topic, so it seeds only when the camera declares a counting rule or you have named rules in person_topics; on a camera that stamps object classes onto events it appears with the first detection instead. An attribute that already has a value is never overwritten by the baseline pass.

Relay and digital-input notifications update the relay/input zone's state rather than a camera attribute, using the same translation the command path uses. A relay thrown from the camera's own web UI, or a contact wired to its input, shows up in GEM without polling — and the camera-event attributes above never land on a relay zone.

The zone an event writes is always one of the emitting camera's own zones. A token in a notification is a single path segment (relay0, input1); one containing a / — the shape of a zone Address, not of a token — is refused and logged as onvif io token with a path separator refused. A bare token is matched against the addresses of the zones bound to the camera the event came from, so on a coordinator running several cameras one camera's relay0 can never write another camera's relay zone. The whole-coordinator lookup for a bare token remains only where the coordinator resolves exactly one camera, where there is no second camera to confuse it with. This matters beyond a misbehaving camera: a PullPoint response carries no integrity protection, and a relay-zone state change drives triggers and the macros bound to them.

Run get_event_topics to see what a camera actually publishes:

get_event_topics → { topics: [ … 53 topics … ],
mapped: [ { topic: "RuleEngine/LineDetector/Crossed",
attribute: "line_crossed" }, … ],
unmapped: [ "Configuration/Profile", … ] }

GetCapabilities saying an Events service exists does not mean the camera publishes anything useful, so get_event_topics is the better question to ask. Read it as a capability list, not a status list: cameras advertise topics for rules that are switched off, so a topic appearing here means the firmware supports it, not that it will fire. Whether a rule is enabled, scheduled and aimed at the right area is answered on the camera's own config page. What this command is genuinely authoritative for is the exact topic strings to put in person_topics.

Person detection

For a security response — lights and audio on an intruder — bind person_detected, not motion. Pixel-change motion fires for headlights, rain, foliage and shadows; on an outdoor camera it is close to useless as a trigger.

Getting there depends on how your camera reports a person, and there are two kinds:

The camera names the class in the event. Cameras that publish a counting rule send PersonCount / CarCount / BikeCount, and some stamp ObjectType="Human" onto rule events. GEM reads both and sets person_detected on its own. Nothing to configure.

The camera classifies internally and sends a plain rule event. This is the common case, including InVid/TVT, Hikvision and Dahua AI models. The camera decides a person crossed the tripwire, then sends LineDetector/Crossed with no mention of what crossed it. A car crossing the same tripwire sends the identical notification.

GEM cannot tell those apart from the wire, so it does not guess. For the second kind you tell it, in two steps:

  1. On the camera, open the rule (tripwire, perimeter/region) and set its object filter to person only — turn off car and motorcycle. This is the step that does the actual filtering.
  2. On the GEM device, set person_topics to that rule's topic, e.g. LineDetector/Crossed,FieldDetector/ObjectsInside.
warning

Step 1 is not optional. person_topics does not filter anything — it declares that a rule has already been restricted to people on the camera. Naming a rule that still allows vehicles makes person_detected fire for every passing car.

person_detected follows the shape of the rule behind it. A region rule holds it true for as long as the camera says someone is in the area, and clears it when they leave. A tripwire has no "un-crossed" notification, so it pulses for event_pulse_ms and clears itself.

Alarm Holding Time

AI rules usually carry an Alarm Holding Time (20 seconds by default on InVid/TVT). The camera keeps the rule in alarm for that long, which sets the floor on how long a region rule holds person_detected true and, on most firmware, suppresses repeat notifications inside the window. That is generally what you want for a deterrent — lights and audio that stay on rather than flickering — but it does mean a second person crossing eight seconds after the first may not produce a second event. Shorten it on the camera if you need faster re-triggering.

A worked example — intruder deterrence on a perimeter camera:

  1. On the camera, enable the perimeter/region rule, draw the area, set the object filter to person only.
  2. On the ONVIF device in GEM, set person_topics to FieldDetector/ObjectsInside.
  3. Confirm it works: walk the area and watch person_detected on the camera's zone, or watch last_event_topic to see the rule fire.
  4. Build an attribute trigger on person_detected becoming true, running a macro that turns on the floodlights and plays an audio warning.

Because person_detected tracks the region state, the same trigger can run a second macro when it goes false to put the lights back.

AXIS analytics

AXIS is worth calling out because a stock camera publishes no motion at all — motion comes from an application, not firmware. Installing AXIS Video Motion Detection or AXIS Object Analytics adds nine topics, and they split into two kinds.

Motion works with no configuration. The applications publish RuleEngine/MotionRegionDetector/Motion and VideoSource/MotionAlarm, both of which GEM already maps to motion. Install the application and the attribute starts moving.

Scenario topics need naming. The richer topics are per-scenario and user-defined:

tnsaxis:CameraApplicationPlatform/ObjectAnalytics/Device1Scenario1
tnsaxis:CameraApplicationPlatform/VMD/Camera1Profile1

GEM deliberately maps these to nothing. A scenario is whatever you drew on the camera — an area, a tripwire, a crossing line — with whatever object filter you set, and the event carries only active=1 / active=0. Nothing on the wire says which. Guessing would put a line crossing into intrusion, or make person_detected fire for a car.

So treat them the way Person detection describes: restrict the scenario to Human in the camera's own object filter, then name it in person_topics:

person_topics: ObjectAnalytics/Device1Scenario1

These are state rules — active goes 1 when an object enters the scenario and 0 when it leaves — so person_detected follows that state rather than pulsing, and stays true for as long as someone is in the area.

Two topics are noise and can be ignored: CameraApplicationPlatform/VMD/xinternal_data and the ObjectAnalytics equivalent stream a full SVG overlay of every detected box on every frame. GEM maps neither. The …/Device1ScenarioANY and …/Camera1ProfileANY forms are aggregates across all scenarios — usable in person_topics if you want any scenario to count, though a per-scenario topic is the safer choice.

Alarms

Seven of these attributes carry a suggested alarm definition. As soon as a camera on the site reports one, the suggestion appears on the Alarms page with an Enable button that opens the definition creator pre-filled — and because the driver seeds the attributes its camera advertises the moment events connect, the detection and tamper suggestions show up right away rather than after the first intruder. The person_detected suggestion needs its attribute to exist, which the seeding note above explains: a counting rule or person_topics makes it immediate; a class-stamping camera surfaces it with the first detection. Nothing is ever activated for you — you review the condition and save it, or dismiss the suggestion.

For the classic case — person-detection alarms only while the property should be empty — pair the Person Detected definition with an active schedule (e.g. 10 PM–8 AM): outside the window detections still update the attribute for automations, but no alarm events are raised.

AttributeSuggested alarmSeverityBehaviour
person_detectedPerson DetectedhighActivates immediately, clears with the underlying rule
intrusionRegion IntrusionhighActivates immediately, clears when they leave the region
loiteringLoitering DetectedmediumActivates on the loitering rule
scene_changeCamera View ChangedhighLatched, 30 s delay
image_too_blurryCamera Image BlurredmediumLatched, 60 s delay
signal_lossCamera Signal LosthighLatched, 30 s delay
events_connectedCamera Events OfflinemediumFires when every camera's subscription is down, 10 min delay

The three tamper alarms are latched — they stay open until acknowledged even after the condition clears. That is deliberate: someone who sprays a lens or turns a camera walks away afterwards, and the alarm clearing on its own would erase the only evidence anyone was there. The detection alarms are not latched, because a camera watching an entrance may see people all day and every one of them would otherwise need dismissing by hand.

events_connected is the one watching the watcher. It stays true while any camera's subscription is live, so it fires only when the coordinator has lost events entirely — at which point every other alarm on this page silently stops being able to fire, and nothing else would tell you. Its 10-minute delay sits well past the driver's own retry ceiling, so it reports subscriptions that are genuinely down rather than mid-retry; a single camera refusing events while its siblings work shows in event_status instead.

Do not enable overlapping alarms

If you drive person_detected from a rule via person_topics, that same rule also sets its own attribute — a person entering a region sets both person_detected and intrusion. Enable one alarm or the other, not both, or every event raises two.

Driving lights and audio from an alarm

An alarm definition can run a macro on activation and another when it clears — the Actions section of the definition creator. This is how an intrusion response gets wired:

  1. Build a macro that turns on the floodlights and plays the audio warning.
  2. Build a second macro that puts them back.
  3. Create the Person Detected or Region Intrusion alarm from its suggestion.
  4. In Actions, set the first macro on active and the second on cleared.

A region rule is the better base for this than a tripwire. It holds the alarm active for as long as the camera says someone is in the area, so the lights stay on and the cleared macro runs when they actually leave. A tripwire crossing is a moment with no end, so the alarm activates and clears seconds later and the response has to decide its own duration.

Remember the camera's own Alarm Holding Time sets the floor here — with the default 20 seconds, the cleared macro cannot run sooner than 20 seconds after the person leaves.

Settings

AttributeTypeDefaultPurpose
events_enabledbooleantrueSubscribe to camera events. Turn off for a camera whose event implementation misbehaves — snapshots, PTZ and relays are unaffected.
person_topicsstring(blank)Rule topics that mean "a person", after restricting those rules on the camera.
event_pull_timeoutint30Seconds each long-poll waits before returning empty.
event_pulse_msint3000How long one-shot events stay true before self-clearing.
event_state_timeoutint0Force-clear a stuck state after this many seconds. 0 trusts the camera.
event_topicsstring(blank)ONVIF topic filter applied at subscription time, e.g. tns1:RuleEngine//. for analytics only.
CommandWhat it does
get_event_topicsLists what a camera can publish, split into mapped and unmapped. Pass camera (an IP) to pick one on a multi-camera device; with no argument a single camera answers directly and several answer as a per-camera list.
event_statusSubscription state — enabled and running overall, then per camera: running, subscription URI, consecutive failures, last event and current values.
events_start / events_stopRestart or tear down every camera's subscription. Use events_start after changing event settings.

How the subscription looks after itself

Events are auxiliary: a camera that refuses a subscription is still a working camera. A failure here never marks the device offline and never interrupts snapshots or relay control.

  • Three consecutive pull failures rebuild that camera's subscription rather than hammering a reference the camera has forgotten. Rebuilds back off exponentially to a five-minute ceiling, so a camera with events genuinely unavailable costs one request every five minutes.
  • Each camera's subscription fails, retries and rebuilds on its own — one flaky camera never resubscribes the rest of the fleet.
  • The subscription URI a camera hands back is checked against the zone's camera address. Cameras state an address rather than echoing the one you called them on, so a camera whose own idea of its address has gone stale — after a subnet change, say — would otherwise hand back a reference nothing can reach. When the advertised host is already the camera the zone names, which is the normal case on a shared LAN, the URI is used exactly as given, port and all. It is only re-pointed when it names some other host, which also means these authenticated requests cannot be steered off the camera.
  • events_connected reports subscription health independently of the device connection, so a camera that is online for snapshots but refusing events is visible as exactly that. Per-camera detail is in event_status.
  • Cameras identify a subscription in one of two ways and GEM handles both. Most mint a unique URL per subscription (an InVid answers http://<ip>/onvif/event/subsription_13) and the address alone is the identity. AXIS instead returns the generic service address, http://<ip>/onvif/services, and puts the identity in a WS-Addressing reference parameter — a SubscriptionId element that has to be echoed as a SOAP header on every later call. GEM captures those parameters at subscribe time and replays them on each pull and on unsubscribe; the log line onvif subscription reference parameters shows what it picked up. Without that echo the subscription is created and then unreachable, and the camera answers each pull with SOAP 400.

Events do not arrive

Work down this list:

  1. events_connected is false. The subscription is not up. Run event_status for the failure count and check the ONVIF user has event rights on the camera.
  2. events_connected is true but nothing fires. Check the rule is actually enabled on the camera, on its own config page. Do not use get_event_topics to answer this — the topic set is a list of what the firmware can publish, not what is switched on. The verified InVid advertises RuleEngine/FieldDetector/ObjectsInside while its Region Intrusion rule is switched off, so a topic appearing there proves nothing about whether it will ever fire.
  3. An AXIS camera advertises no motion topics at all. AXIS publishes motion from an analytics application, not from firmware. A camera with none installed advertises only device and system topics, so there is no motion rule to enable — the built-in tamper topics (VideoSource/Tampering, Device/Casing/Open) and a PIR sensor where one is fitted are all you get. Install and start AXIS Video Motion Detection or AXIS Object Analytics, then re-run get_event_topics. See AXIS analytics for what appears and what to do with it.
  4. The rule is enabled but never fires. Check its schedule — most AI rules are schedulable and some ship armed only part of the day. Then check its detection area (the drawn line or region) and sensitivity.
  5. motion works but person_detected never sets. Expected on a camera that classifies internally — see Person detection. Watch last_event_topic while walking the scene to get the exact topic string, then set person_topics.
  6. person_detected fires for cars. The rule named in person_topics still has vehicles enabled in its object filter on the camera.
  7. Camera attributes update but a relay or input zone's state never moves. The event's token has to match the zone bound to that camera. Check the zone's Address is <camera_ip>/<token> with a token get_relays actually reports, and that the zone is bound to the camera emitting the event — a token from one camera is never matched against another camera's zones. onvif io token with a path separator refused in the log means the camera sent a composite where a bare token belongs.

Troubleshooting

Device Shows Offline Immediately After Adding

The onvif driver reports a connection state for every startup outcome, so a device that cannot connect shows connected = false rather than showing nothing at all. The reason is logged as onvif device offline: with one of:

  • no camera zones configured — no zone bound to this device carries a camera address. Bind zones with an ip attribute (or camera_ip / stream_url from the scan mapping); a zone reload reconnects the device on its own.
  • no cameras reachable — every named camera failed to answer. Per-camera reasons are logged as onvif camera error: — wrong credentials land there as Failed to initialize the device: …. The driver retries every 10 seconds while any camera is down, and cameras_connected (e.g. 1/3) shows the split on a degraded coordinator, which stays online as long as one camera answers.

Wrong credentials take as long as the camera's SOAP response (or the network timeout) to surface, so the state can lag the page by several seconds after a reload. Give it a moment before concluding nothing was written.

Intermittent 401s On A Device That Usually Works

WS-Security digest signs each request with a timestamp, and cameras reject one that drifts too far from their own clock. More than about five minutes of skew between the GEM server and the camera produces 401s that come and go. Point both at the same NTP source.

Relay Commands Return "missing relay target"

The command could not resolve which camera and relay it addresses. Set the zone's Address to <camera_ip>/<relay_token> (e.g. 10.0.0.5/relay0) — get_relays lists every camera's tokens with the ready-made address. A bare token works only when the zone carries its own ip (and credentials) attributes, or the device coordinates a single camera. relay0 and relay1 are typical tokens, but the camera's own answer is authoritative, especially on an NVR where the useful relays are per-channel rather than at the NVR level.

Relay Fires But The Zone Reads "closed" Instead Of "locked"

This driver writes the contact position literally — closed when the relay is energized, open when released — and does not run it through the subsystem's relay-state mapping the way most relay drivers do. So an ONVIF relay in a door or lock subsystem reads closed/open rather than locked/unlocked, whatever the subsystem's mapping says. The commands still work under every alias (lock and close do the same thing), and the physical behavior is correct; it is only the reported wording that stays in relay terms. Where a UI needs door wording, drive it from closed/open rather than expecting a translation. See Relay Semantics for how the mapping behaves on drivers that do apply it.

Snapshot Returns "No Snapshot URI"

  1. Run get_profiles and pick a token whose has_snapshot is true.
  2. If no profile has one, the camera exposes streams only — map it as an RTSP zone instead (see Manual RTSP Mapping).
  3. Check the ONVIF user's media privileges. Several vendors withhold the snapshot URI from accounts that lack them.

Snapshot Returns 401 Or Times Out

Read the zone's snapshot_uri attribute first — it holds the URI that zone's capture will actually fetch. An off-host advertisement has already been pinned back to the camera's address, so what is left to check is the port and path: GetSnapshotUri frequently names a different port than the ONVIF service, and a firewall or port-forward that only passes the ONVIF port blocks the stills. Confirm that URI is reachable from the server before checking anything else. See Checking what the camera advertised.

A 401 specifically means something different from a timeout. The account authenticated for ONVIF — the profiles resolved, or you would have seen a connect error instead — and is being refused on the snapshot URI itself. Grant that ONVIF user media/snapshot rights on the camera; on many models the stills endpoint is a separate permission from the ONVIF service.

You do not need to configure an authentication scheme. GEM answers whichever the camera asks for, Basic or Digest, reading it from the camera's own challenge, and sends the password only in reply to that challenge — never ahead of it. A Digest-only camera, which modern AXIS firmware is, works with nothing set.

Snapshot Returns "no media profile with token …"

An explicitly passed selector (the address or camera argument of a device-targeted command) names no media profile on that camera — the error lists the tokens the camera actually has. Run get_profiles and use a listed token, as <camera_ip>/<token>.

Zone-targeted captures do not fail this way:

  • A zone with an empty Address resolves straight to its own camera's default profile — sync_zones is what writes the address, but a capture works without it.
  • A zone whose address carries a stale or foreign token (the camera's profiles changed after the address was written, or a reused camera zone still carries another integration's selector) falls back to the camera's default profile and logs a warning — clear the zone's Address and run sync_zones to fix the address and silence it.
  • The host half of the address does not have to be spelled identically to the zone's ip attribute — a zone-targeted capture always uses the zone's own camera, and the address prefix only redirects it when it names another coordinated camera.

A relay zone's …/relay0 address identifies a relay output, not a media profile — a snapshot against it also falls back to the camera's default profile, which is usually what a "photograph this door" automation wants anyway.

Snapshot Returns "ambiguous camera"

The device coordinates several cameras and a bare selector named none of them. Pass <camera_ip>/<profile_token>, or target the zone — its address already carries the camera.

Snapshot Refused Because Of The Advertised URI

Two refusals come from the URI itself rather than from the camera:

  • camera advertised an unsafe snapshot uri: … — the URI points at loopback, a link-local address, or a multicast target. This is never a legitimate camera address; treat it as a misconfigured or untrusted device. It also appears when the zone's own camera address is loopback (an SSH tunnel on the GEM host itself), since the pin rewrites the snapshot URI to that address — put the tunnel listener on a non-loopback address instead.
  • camera <ip> advertised a snapshot uri on <host> — a safety net that should not appear in normal operation, since off-host URIs are pinned to the zone's camera address by default. If it does, compare the zone's snapshot_uri with its camera address: if the off-host address is expected and reachable (NAT, published hostname), set allow_offhost_snapshot on the device and reload it; otherwise reload the device so the pin re-resolves the URI. See Where a snapshot may be fetched from.

No Cameras Discovered

  1. Verify cameras are on the same network as the GEM server, or scan their subnet explicitly by CIDR — multicast discovery rarely crosses a VLAN boundary.
  2. Confirm ONVIF is enabled in each camera's settings. Many cameras require creating a separate ONVIF user account with media rights.
  3. Check that the firewall allows multicast traffic, if you are relying on a blank-target scan.
  4. Verify credentials — when a range scan reports cameras that rejected credentials, the login is the problem, not the network.
  5. Increase Scan Timeout if cameras respond slowly, or scan a narrower range.
  6. Some cameras (Amcrest, Dahua) use HTTP Digest instead of WS-Security — GEM retries automatically, but the password still has to be right.
  7. If the scan finds an open RTSP port but no ONVIF service, re-scan that single IP and use manual RTSP entry.

PTZ Buttons Don't Appear

The overlay only renders when the camera zone is on screen by itself (not in a multi-camera tile view) and the zone carries onvif_host or is bound to a device. If the attribute is missing, the camera was mapped from a scan that never collected PTZ information — click Details on the card, then Remap Zone onto the same zone.

Stream Not Working

  1. Check that RTSP port (typically 554) is accessible.
  2. Verify credentials are correct — preview the camera from this page rather than relying on Test RTSP, which does not contact the camera.
  3. Confirm the camera's streaming is enabled.
  4. The proxy retries a failed or stalled stream automatically (see Stream Resilience) — a brief outage clears on its own. Persistent rtsp relay watchdog forcing restart warnings for the same zone point to a camera that connects but never streams stable frames.
  5. In video mode, a tile that sits on its spinner has connected but has yet to get a frame on screen — check the camera's codec and keyframe interval. A camera with a very long GOP simply takes that long to paint the first time.