Control4 DriverWorks References / The Surveillance Experience / Markdown

Control4 Subsystem Reference

The Surveillance Experience

An opt in experience that unifies events, security and cameras into one timeline with actions attached. Nothing about it is specific to a camera brand, or to cameras at all. This is the integration path for a driver that wants to appear in it.

History
control4_agent_history.c4i
Timeline
agent-timeline.c4z
Quick Actions
agent-quick-actions.c4z
Minimum OS
4.0.0

The model

The experience is assembled from three independent agents plus the camera proxy. There is no single surveillance API. A driver participates by talking to whichever agents are relevant to it.

What each part wants from a driver
PartDriver obligationEffort
History agent Record events with a category and an attached image. This is the integration. The real work
Timeline agent Nothing for reporting. It displays history entries itself. Needed only for scheduled events and the actions attached to them. Only if you schedule
Quick Actions agent None. Implement a supported proxy and you are discovered. Nothing
Camera proxy Live view, snapshots, optional runtime controls. Only for cameras

A driver reporting things that have already happened, such as a detection, an alarm, or a door opening, needs only the history agent. Writing the same occurrence to the timeline as well produces two entries for one event.

The timeline agent is the correct surface for future events: things scheduled to happen that a user may intervene in beforehand. That is where its validation, modification and action attachment apply.

The experience is opt in per system, and the opt in lives inside the experience rather than in settings. Open a camera in the app and accept the prompt there. A driver cannot enable it and should not assume it is on.

The timeline agent requires OS 4.0.0. Below that it disables itself at startup and every timeline command silently does nothing. The history agent has no such floor.

Call flow

Your driver History Timeline Reporting RECORD_HISTORICAL_EVENT <uuid> SET_METADATA_ON_RECORD media_filename, required for the timeline entry surfaces automatically Scheduled events only SetFutureEvent, then TimelineAction back
One write to history feeds the History list, push notifications and the surveillance timeline. The ochre arrow returns the record UUID, which the image attachment needs.

History agent

The primary surface. A single write feeds three things at once: the History list in the app, per type push notifications, and the surveillance timeline.

Resolving

HISTORY = next(C4:GetDevicesByC4iName("control4_agent_history.c4i"))

Older helper snippets look for shorter names that do not match what current systems install. If the filename lookup fails, the agent can also be found by display name, which is what appears in Director traces.

if not HISTORY then
  for id, _ in pairs(C4:GetDevicesByName("History") or {}) do
    HISTORY = id
  end
end

Recording an event

Use C4:SendUIRequest. SendToDevice appears to succeed, returns nothing, and creates no record.

local reply = C4:SendUIRequest(HISTORY, "RECORD_HISTORICAL_EVENT", {
  DEVICE_ID   = C4:GetProxyDevices(),   -- the proxy device
  SEVERITY    = "Info",                 -- Critical, Warning or Info
  CATEGORY    = "Cameras",
  SUBCATEGORY = "IP Camera",
  TYPE        = "Vehicle",              -- the event name users see
  DESCRIPTION = "Vehicle detected on Driveway",
})

The call returns the record's UUID wrapped in XML. Parse it and keep it, because attaching an image needs it.

local parsed = C4:ParseXml(reply)
local uuid = (parsed and parsed.Name == "uuid") and parsed.Value or nil
Parameters
NameNotes
DEVICE_IDThe proxy device id. Determines which device the entry is filed under. Passing the driver's own device id files it against the driver, which for a gateway has no camera behind it.
SEVERITYCritical, Warning or Info. An unrecognised value is coerced to Info.
CATEGORYMust be one Navigator recognises, or the entry appears in the History agent but not in the interface.
SUBCATEGORYOptional but recommended. Part of the notification taxonomy.
TYPEFree form. The event name users see and enable notifications against.
DESCRIPTIONOptional. The line the user reads.
METADATAOptional. A JSON encoded table, applied at record time instead of a follow up call.

Categories

Recognised pairings
DomainCategorySubcategory
CamerasCamerasIP Camera
Security systemsSecurityzone or partition name
Access controlSecurityDoor

Cameras with IP Camera is the pairing the interface expects for camera events. Security is correct for alarm panels and access control. Using it for a camera detection logs an entry the surveillance timeline does not treat as a camera event.

Attaching images

A record with no image appears in the History list but does not surface in the surveillance timeline. The image is what distinguishes a surveillance entry from a log line.

Attach it with a second call, using the record UUID. This one is SendToDevice.

C4:SendToDevice(HISTORY, "SET_METADATA_ON_RECORD", {
  RECORD_UUID    = uuid,
  METADATA_NAME  = "media_filename",
  METADATA_VALUE = "/path/to/snapshot.jpg",
})

media_filename is a path to a file on the controller, not image bytes and not a URL. The file must already exist when this call is made. If the driver receives images as base64 or fetches them over HTTP, write them to disk in the driver's own directory first.

local dir = C4:GetDriverFileDirectory()
local f = io.open("snapshot_01.jpg", "wb")
f:write(jpegBytes)
f:close()
-- then attach dir .. "/snapshot_01.jpg"
Two traps
TrapConsequence
Ordering If the image is fetched asynchronously, the record must not be written until the file exists. Writing first and attaching later produces entries whose images are missing or belong to a previous event.
Filename reuse Records reference the file by path, so overwriting one filename changes the image on entries already recorded. Rotate through a bounded set of names. Sixty comfortably outlives the timeline's retention.

Event registration

Registering event types builds the taxonomy the History agent uses for filtering, and populates the curated list users choose from when enabling push notifications. It is independent of recording and belongs at driver init.

Two forms exist, both in shipping use.

-- current form
local result = C4:RegisterEvents(eventsXml)

-- older form, addressed to the driver's own proxy
C4:SendToDevice(proxyId, "REG_HISTORY_EVENTS", { XML = eventsXml })

C4:RegisterEvents has changed return type across versions. Older builds return a boolean, where false means retry. Newer builds return a number, where -6 means retry and -1 means the registration failed. Handle both rather than assuming one.

Deep links from a notification into the relevant device require the Navigation agent (control4_agent_navigation.c4i) in the project. Without it entries still record and notify, but tapping one goes nowhere.

Timeline agent

Holds two sets, past and future. Past events are populated automatically from the history agent. Future events are things scheduled to happen, which a user can intervene in beforehand, and that is what this agent is for from a driver's perspective.

Every command carries one serialized payload and returns a small XML document, either <success>true</success> or <success>false</success>. Compare against the literal string.

TIMELINE = next(C4:GetDevicesByC4iName("agent-timeline.c4z"))

Do not write past events for occurrences the history agent already recorded. SetPastEvent is accepted unconditionally, so this fails silently as a duplicated timeline rather than as an error.

SetFutureEvent Schedule

Registers something due to happen. Validated, and rejects rather than corrects.

Rejection cases
ConditionResult
Timestamp is in the pastfailure
Same identity already exists and is still in the futurefailure

Identity is a hash of the source device and the event id. Generate a fresh id per occurrence rather than per schedule entry.

ModifyFutureEvent Schedule

Replaces an existing future event in place, keyed by the same identity. Fails if the event does not exist, and fails if it has already happened.

RemoveFutureEvent Schedule

Withdraws a scheduled event. Send it when the thing is no longer going to happen, otherwise it sits there until its moment passes.

SetPastEvent Report

Records something that already happened, accepted unconditionally, then sorted and trimmed against the retention limits. Legitimate uses are narrow: promoting an elapsed future event, or recording something that should appear in the timeline but not in History. For ordinary reporting, use the history agent.

ListPastEvents, ListFutureEvents Read

Return the stored sets as XML. Available to drivers as well as the interface, which makes them the practical way to confirm during development that a write landed.

TimelineAction Inbound

Arrives at the device named in the event's source.deviceId when a viewer taps one of that event's actions.

Parameters
NameTypeNotes
commandstringThe command string from the action you declared.
eventIdstringWhich event it applies to.

It arrives as an ordinary device command, handled in ExecuteCommand alongside Composer actions, not on a proxy binding. In drivers built on the standard template libraries that is the EX_CMD dispatch table.

Expect it twice. A single tap delivers the command more than once. Latch the command name and clear it on a short timer, or the action runs repeatedly.

Event payload

One object, base64 encoded JSON in the payload parameter.

{
  "source": {
    "deviceId":    123,
    "displayName": "Side Gate",
    "fileName":    "your-driver.c4z"
  },
  "event": {
    "id":          "9f2c7a10-4e55-4b21-8f0e-1c3d5a7b9e42",
    "displayName": "Motion at the side gate",
    "timestamp":   1725213610,
    "actions": [
      { "command": "muteThisSensor", "displayName": "Mute For Now" },
      {
        "command":     "snoozeThisSensor",
        "displayName": "Snooze",
        "params": [ { "id": "hours", "allowedValues": [1, 2, 3, 4, 5] } ]
      }
    ]
  }
}
Fields
FieldNotes
source.deviceIdWhere TimelineAction is delivered, and half of the event identity. Must be a device the experience can present. For a camera event, the camera proxy device.
source.displayNameShown as the origin of the entry.
source.fileNameThe installed package name. If it does not match, the entry cannot be tied back to a driver. Look it up rather than hardcoding.
event.idYour identifier. A UUID per occurrence. The other half of the identity.
event.displayNameThe line the user reads.
event.timestampSeconds since the epoch. Determines ordering, retention, and whether a future event is accepted.
event.actionsOptional. Omit for a purely informational event.
actions[].commandReturned verbatim. Yours to choose.
actions[].displayNameThe button label.
actions[].paramsOptional. Each entry offers a fixed set of allowed values.

Quick Actions agent

Supplies the controls offered alongside a notification. It has no driver facing command surface. Everything it exposes is for the user interface.

The agent scans the project for devices whose driver matches an enabled entry in its allow list, then drives them with ordinary proxy traffic such as select, scene activation, or macro execution. Those entries are proxy types rather than specific products, so any driver implementing a supported proxy is eligible without writing anything.

The list is editable per project, with paired properties for moving an entry between enabled and disabled, and a parallel set for macros. A driver that expects to appear and does not should be checked against the enabled list, and against whether the agent is in the project at all.

Behavior notes

History drives the timeline

Anything recorded through the history agent with an attached image appears in the surveillance timeline. This is why most drivers need no timeline code at all.

An image is what makes a surveillance entry

A record without one is a log line. It will be in History and absent from the timeline, with no error to explain the difference.

Attribution is by proxy device

Entries group by the device in DEVICE_ID. The call takes no device argument beyond the proxy of whichever driver makes it, so a gateway cannot record on a child's behalf and have it attributed correctly. Each child must make its own call.

Retention is short and installer visible

Past events are bounded by both an age in days and a count, each configurable per project with modest ceilings. Treat the timeline as a recent activity view, never a durable log.

Ordering is by nearness to now

Events sort by absolute distance from the current time rather than newest first. An event backdated by an hour can sort below one scheduled a few minutes out.

Identity is the device and the event id together

Reusing an id from the same driver targets the existing event rather than creating a new one. That is the mechanism behind modify and remove, and the trap behind duplicate reports.

An action is a promise you have to keep

The agent only relays the tap. If the action means the event should disappear or move, the driver must issue the corresponding remove or modify itself.

An expired future event vanishes

When its moment arrives the agent deletes it and reports it expired. It does not become a past event. If the thing happened and should be remembered, the driver writes the past event itself.

Every part is independently absent

Any of these agents may be missing, and the experience is opt in. Resolve each separately and degrade quietly.

Integration recipe

A driver reporting a detection. Helper names are placeholders for whatever the driver already has.

-- 1. resolve the history agent, tolerating absence
local HISTORY

local function ResolveHistory()
  HISTORY = next(C4:GetDevicesByC4iName("control4_agent_history.c4i"))

  if not HISTORY then
    for id, _ in pairs(C4:GetDevicesByName("History") or {}) do
      HISTORY = id
    end
  end

  print("history agent: " .. tostring(HISTORY))
  return HISTORY
end

-- 2. record the event, then attach its image
local function ReportDetection(what, cameraName, jpegPath)
  if not HISTORY and not ResolveHistory() then
    return
  end

  local reply = C4:SendUIRequest(HISTORY, "RECORD_HISTORICAL_EVENT", {
    DEVICE_ID   = C4:GetProxyDevices(),
    SEVERITY    = "Info",
    CATEGORY    = "Cameras",
    SUBCATEGORY = "IP Camera",
    TYPE        = what,
    DESCRIPTION = what .. " detected on " .. cameraName,
  })

  local parsed = C4:ParseXml(reply)
  local uuid = (parsed and parsed.Name == "uuid") and parsed.Value or nil

  if not uuid then
    print("history: no uuid returned, record was not created")
    return
  end

  -- without this the entry stays out of the surveillance timeline
  if jpegPath and jpegPath ~= "" then
    C4:SendToDevice(HISTORY, "SET_METADATA_ON_RECORD", {
      RECORD_UUID    = uuid,
      METADATA_NAME  = "media_filename",
      METADATA_VALUE = jpegPath,
    })
  end

  return uuid
end

Fetching the image first

The file must exist before the record is written, so an asynchronous fetch has to complete first. Guard it with a timeout so a slow or missing image still produces an entry rather than none at all.

local imageSeq = 0

local function ReportWithSnapshot(what, cameraName, cameraId)
  imageSeq = (imageSeq % 60) + 1        -- rotate, never overwrite
  local name = ("detect_%02d.jpg"):format(imageSeq)
  local done = false

  local function finish(path)
    if done then return end
    done = true
    ReportDetection(what, cameraName, path)
  end

  C4:SetTimer(6000, function() finish(nil) end)

  FetchSnapshot(cameraId, function(ok, jpeg)
    if not ok or not jpeg or #jpeg < 200 then
      finish(nil)
      return
    end

    local f = io.open(name, "wb")
    if not f then
      finish(nil)
      return
    end

    f:write(jpeg)
    f:close()
    finish(C4:GetDriverFileDirectory() .. "/" .. name)
  end)
end

Gateway and child drivers

The record is filed against the calling driver's proxy device, and there is no argument to override that. A gateway therefore cannot record on a child's behalf in a way the experience attributes correctly. The working arrangement is for the gateway to resolve the event and broadcast it to its children, and for the child owning that device to make the call. The gateway can still prepare the image, since it holds the credentials, and pass the path along with the broadcast.

Promoting a future event to a past one

An elapsed future event is deleted rather than converted, so a driver that schedules work closes the loop itself. Keep the payload you scheduled with, then resend it as a past event once the thing happens.

local function PromoteToPast(eventId, ranEarly)
  local payload = ScheduledPayloads[eventId]
  if not payload then return end

  payload.event.actions = nil            -- a completed event offers nothing

  if ranEarly then
    payload.event.timestamp = os.time()  -- when it really happened
  end

  local reply = C4:SendToDevice(TIMELINE, "SetPastEvent", {
    payload = C4:Base64Encode(C4:JsonEncode(payload, false, true)),
  })

  if reply ~= "<success>true</success>" then
    print("timeline rejected SetPastEvent: " .. tostring(reply))
  end

  ScheduledPayloads[eventId] = nil
end

This is one of the few legitimate uses of SetPastEvent, because the event was never in History to begin with. A cancel action removes the future event and writes nothing to the past. A run now action removes it, performs the work, and writes the past event with a corrected timestamp.

Troubleshooting

Symptom and cause
SymptomCause
Nothing recorded at allSendToDevice used instead of SendUIRequest, or the agent was not resolved. Check the filename and the display name fallback.
No UUID returnedThe record was not created. Same causes as above.
In History, not in the timelineNo image attached, or the experience is not opted in on that device.
Entry appears, not filed under a cameraDEVICE_ID is the driver device rather than the proxy device, or a gateway recorded on a child's behalf.
Every event appears twiceThe driver writes to both the history agent and SetPastEvent.
Images missing or belong to a previous eventThe record was written before the image file existed, or one filename is being reused per device.
Notifications never fireEvent types not registered, or an uncurated category.
Notification opens nothingNavigation agent absent from the project.
Nothing works below OS 4.0.0The timeline agent disables itself at startup.