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
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
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.
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
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.
-- 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.
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.
SetFutureEventSchedule
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.
ModifyFutureEventSchedule
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.
RemoveFutureEventSchedule
Withdraws a scheduled event. Send it when the thing is no longer going
to happen, otherwise it sits there until its moment passes.
SetPastEventReport
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, ListFutureEventsRead
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.
TimelineActionInbound
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.
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.
-- 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
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.