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**

| Part | Driver obligation | Effort |
| --- | --- | --- |
| 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

_Diagram: Sequence diagram: a driver records an event with the history agent using SendUIRequest and receives a record UUID, then attaches an image to that UUID with SET_METADATA_ON_RECORD. The history agent surfaces the entry in the surveillance timeline. Separately, for scheduled events, the driver sends SetFutureEvent to the timeline agent and receives TimelineAction back when a viewer taps an action._

_ 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

```lua
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.

```lua
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.

```lua
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.

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

**Parameters**

| Name | Notes |
| --- | --- |
| DEVICE_ID | The 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. |
| SEVERITY | `Critical`, `Warning` or `Info`. An unrecognised value is coerced to `Info`. |
| CATEGORY | Must be one Navigator recognises, or the entry appears in the History agent but not in the interface. |
| SUBCATEGORY | Optional but recommended. Part of the notification taxonomy. |
| TYPE | Free form. The event name users see and enable notifications against. |
| DESCRIPTION | Optional. The line the user reads. |
| METADATA | Optional. A JSON encoded table, applied at record time instead of a follow up call. |

### Categories

**Recognised pairings**

| Domain | Category | Subcategory |
| --- | --- | --- |
| Cameras | Cameras | IP Camera |
| Security systems | Security | zone or partition name |
| Access control | Security | Door |

`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`.

```lua
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.

```lua
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**

| Trap | Consequence |
| --- | --- |
| 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.

```lua
-- 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.

```lua
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**

| Condition | Result |
| --- | --- |
| Timestamp is in the past | failure |
| Same identity already exists and is still in the future | failure |

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**

| Name | Type | Notes |
| --- | --- | --- |
| command | string | The command string from the action you declared. |
| eventId | string | Which 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.

```lua
{
  "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**

| Field | Notes |
| --- | --- |
| source.deviceId | Where `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.displayName | Shown as the origin of the entry. |
| source.fileName | The installed package name. If it does not match, the entry cannot be tied back to a driver. Look it up rather than hardcoding. |
| event.id | Your identifier. A UUID per occurrence. The other half of the identity. |
| event.displayName | The line the user reads. |
| event.timestamp | Seconds since the epoch. Determines ordering, retention, and whether a future event is accepted. |
| event.actions | Optional. Omit for a purely informational event. |
| actions[].command | Returned verbatim. Yours to choose. |
| actions[].displayName | The button label. |
| actions[].params | Optional. 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.

```lua
-- 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.

```lua
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.

```lua
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**

| Symptom | Cause |
| --- | --- |
| Nothing recorded at all | `SendToDevice` used instead of `SendUIRequest`, or the agent was not resolved. Check the filename and the display name fallback. |
| No UUID returned | The record was not created. Same causes as above. |
| In History, not in the timeline | No image attached, or the experience is not opted in on that device. |
| Entry appears, not filed under a camera | `DEVICE_ID` is the driver device rather than the proxy device, or a gateway recorded on a child's behalf. |
| Every event appears twice | The driver writes to both the history agent and `SetPastEvent`. |
| Images missing or belong to a previous event | The record was written before the image file existed, or one filename is being reused per device. |
| Notifications never fire | Event types not registered, or an uncurated category. |
| Notification opens nothing | Navigation agent absent from the project. |
| Nothing works below OS 4.0.0 | The timeline agent disables itself at startup. | The surveillance experience, integration reference History, timeline and quick actions agents
