Control4 DriverWorks References / Recently Played Manager: Agent API / Markdown

Control4 Agent Reference

Recently Played Manager

A project-wide agent that records what each streaming driver played, in which room, and hands that history back to Navigator so a listener can resume it in one tap. This is the command surface a driver talks to.

File
recentlyplayed-agent.c4z
Proxy
recentlyplayed-agent
Control
lua_gen
Minimum OS
3.3.1

The model

The agent stores containers: an album, a playlist, a station, a track. It does not store individual songs as they roll past. One entry is written when playback of a container starts, and it is updated in place if the same container plays again.

Every entry gets a random 20-character key and is filed into three indexes: by key, by room, and by driver. A fourth table holds the timestamp used for ordering. Reads are always scoped. A Navigator asks for a room's history or a driver's history, never the whole store.

Each index is capped at 20 entries. Writes are persisted on a five-second debounce, so a burst of updates costs one flush.

Storage
TableKeyed byHolds
InfoByKeykeyThe container record, its driver, and the driver icon.
KeyByRoomroom idEvery key played in that room, capped at 20.
KeyByDriverdevice idEvery key written by that driver, capped at 20.
TimestampByKeykeyos.time() of the last write. Sorts newest first.

Call flow

Service driver Agent Navigator On playback SetHistoryItem GET_CONTAINER_INFO only if title, subtitle and image are all absent SetHistoryItem { keyToUpdate } historyUpdated On resume GetHistoryItemsByRooms SelectHistoryItem { key } PLAY_RECENT { rooms, info }
The agent is not a passive store: ochre arrows are agent-initiated and land on the driver's ReceivedFromProxy. A driver that implements only the write path will record history that nothing can replay.

Conventions

Finding the agent

The agent is installed automatically and there is exactly one per project. Resolve its device id by filename, and re-resolve it when rooms are registered rather than caching it across a driver restart.

RECENTLY_PLAYED_AGENT = next(C4:GetDevicesByC4iName("recentlyplayed-agent.c4z"))

If the lookup returns nil, skip the integration silently. Every call below is an ordinary C4:SendToDevice to that id.

Payload encoding

Structured arguments cross the boundary as base64-encoded JSON. Anything the agent returns arrives wrapped in a <b64json> element that must be unwrapped before decoding.

-- writing
local key = C4:SendToDevice(RECENTLY_PLAYED_AGENT, "SetHistoryItem", {
  itemInfo = Serialize(itemInfo),
})

-- reading
local ret = C4:SendToDevice(RECENTLY_PLAYED_AGENT, "GetHistoryItemsByRooms", {
  rooms = roomId,
})

local inner = string.match(ret, "<b64json>(.+)</b64json>")
local items = Deserialize(inner)

Room arguments

Anywhere a rooms value is accepted it may be a number, a comma-separated string, or a table of either (nested tables are flattened). A call that resolves to zero rooms is rejected.

Entry points

Which DriverWorks handler a command lands in depends on what is being addressed. The agent has no proxy binding of its own, so commands sent to it arrive as ordinary driver commands; your service driver does have a proxy, so commands the agent sends back arrive on that binding instead.

Dispatch
CommandHandlerOn
SetHistoryItem
SelectHistoryItem
RemoveHistoryItem
GetHistoryItemsBy…
ClearHistory
ExecuteCommand The agent. Reached with C4:SendToDevice from any driver.
SelectHistoryItem
RemoveHistoryItem
GetHistoryItemsBy…
UIRequest The agent. Used by Navigator's own Listen screen.
PLAY_RECENT
GET_CONTAINER_INFO
ReceivedFromProxy Your driver, on its media service proxy binding.

The four read and replay commands are deliberately exposed on both of the agent's entry points, and the UI handlers forward straight through, so there is one behavior to reason about rather than two. The three maintenance commands are reachable as Composer actions, which arrive as an ExecuteCommand named LUA_ACTION carrying the real name in tParams.ACTION.

Nothing here is declared as a driver command in the agent's own manifest, so none of it appears in Composer programming. The three actions are the entire Composer-visible surface; everything else is code-only.

Library conventions are not the contract

The examples in this document are written against the shared driver library, where EC., UIR. and RFP. are dispatch tables that a generated ExecuteCommand, UIRequest and ReceivedFromProxy route into. That layer is a convenience, not part of the agent's interface. A driver that does not use it writes the plain handler and branches on the command name:

function ReceivedFromProxy(idBinding, strCommand, tParams)
  if strCommand == "PLAY_RECENT" then
    -- …
  elseif strCommand == "GET_CONTAINER_INFO" then
    -- …
  end
end

The same applies to the helpers. Serialize and Deserialize are base64 over JSON and nothing more: C4:Base64Encode(JSON:encode(t)) and its inverse. GetContainerInfo is not an agent concept at all: it is a stub the media service player library ships for drivers to override, called from that library's own GET_CONTAINER_INFO branch. Without the library there is no such function, only the command.

What is genuinely fixed is the wire contract: the command names, the parameter names, and base64-encoded JSON for itemInfo and info.

Driver → agent

Commands a driver sends to the agent's device id. Every one of these is a no-op returning nil while Agent Enabled is Off, except the three maintenance commands at the end.

SetHistoryItem Write

Records or updates one container. Returns the entry's key as a string. Hold on to it and pass it back as keyToUpdate for subsequent writes of the same playback session.

Parameters
NameTypeNotes
itemInfo b64 JSON req A serialized item payload. Shape below.
itemInfo = {
  driverId    = PROXY_ID,        -- required, the calling driver's PROXY device id
  rooms       = "101,102",       -- required, at least one room must resolve
  keyToUpdate = nil,             -- set to update an existing entry in place
  info = {
    driverId  = PROXY_ID,
    container = {
      id       = "12345",        -- required, coerced to a string
      itemType = "album",        -- required, driver's own vocabulary
      title    = "Kind of Blue",
      subtitle = "Miles Davis",
      image    = "https://…/640x640.jpg",
    },
  },
}

Returns early with no key when keyToUpdate is absent and the container carries none of title, subtitle or image. In that case the agent issues GET_CONTAINER_INFO back at the driver and waits for a second call.

Deduplication. Without a keyToUpdate, the agent looks for an existing entry from the same driver with a matching itemType and id that is also present in the first room of the request. A match is reused rather than duplicated, and its timestamp moves to the front.

Side effects. Fills in driverIcon from the driver's own package, defaults a missing container.image to that same icon, stamps the timestamp, trims both indexes, emits historyUpdated, and schedules a persist.

Use the proxy device id for driverId, not C4:GetDeviceID(). It is both the address the agent will later send PLAY_RECENT to and the id it resolves the icon path from, so a driver that reports its own device id instead of its proxy will store entries that never replay.

SelectHistoryItem Play

Replays a stored entry. The agent looks up the key's owning driver and sends it PLAY_RECENT.

Parameters
NameTypeNotes
keystring reqEntry key. Must be a string.
roomsrooms reqTarget rooms for playback.

Returns <b64json> wrapping { success = true }, or an { error = … } object reading No room specified, Invalid or missing key, or Key not found.

GetHistoryItemsByRooms Read

Returns the merged history for one or more rooms, newest first.

Parameters
NameTypeNotes
roomsrooms reqOne or more room ids.
limitnumberDefaults to 20.

A room is skipped unless Control4 Digital Audio is one of its listen devices. A room with history but no digital audio path returns nothing. This is the single most common reason a populated store looks empty.

GetHistoryItemsByDriver Read

Returns one driver's history, newest first, with no room filtering and no digital audio check.

Parameters
NameTypeNotes
driverIdnumber reqDevice id of the driver. Defaults to 0, which matches nothing.
limitnumberDefaults to 20.
RemoveHistoryItem Write

Deletes one entry from every index, emits historyUpdated carrying the rooms and driver that were affected, and schedules a persist. Takes a single key. Removing a key that does not exist is harmless.

ClearHistory Maintenance

Empties all four tables. Runs regardless of the enabled state, and is also invoked automatically when Agent Enabled is switched to Off.

PrintHistoryByRoom Maintenance

Prints every room's history to the Lua output window as title (driver name). Diagnostic only.

PrintHistoryByDriver Maintenance

Prints every driver's history to the Lua output window, grouped by driver name. Diagnostic only.

Agent → driver

Two commands the agent sends back. They arrive on the driver's proxy binding and must be handled in ReceivedFromProxy. Neither is optional for a driver that wants working history.

PLAY_RECENT Inbound

Resume this container in these rooms. The driver owns the decision about how to do it (queue replace, shuffle, station tune). The agent only supplies the identifier it was given.

Parameters
NameTypeNotes
roomsstringComma-separated room ids.
infob64 JSONThe stored record, including container.id and container.itemType.
function RFP.PLAY_RECENT(idBinding, strCommand, tParams, args)
  local info      = Deserialize(tParams.info)
  local container = info.container or {}

  if container.id and container.itemType and tParams.rooms then
    PlayItem(container.itemType, container.id, tParams.rooms)
  end
end

The itemType here is the driver's original vocabulary, not the display string shown in Navigator.

GET_CONTAINER_INFO Inbound

A request for the metadata the driver did not supply. Look up the container, then call SetHistoryItem a second time with keyToUpdate set to the key handed back here. Until that second call lands, nothing is stored.

Parameters
NameTypeNotes
keyToUpdatestringEcho this back unchanged.
containerIdstringThe id originally submitted.
containerTypestringThe itemType originally submitted.
roomsstringPass through to the second write.

The lookup is normally an asynchronous HTTP call, so the handler returns immediately and the second write happens in the response callback. A driver that always sends complete metadata on the first write never sees this command.

Navigator surface

Navigator's built-in Listen screen is the primary consumer, and it reaches the agent through the UI request path rather than by sending driver commands. It reads a room's history when the screen opens, replays an entry on tap, and removes one on swipe. These are the same four operations documented above, under the same names and with the same parameters and return values.

historyUpdated

After any write or removal the agent pushes an event to attached user interfaces so a list on screen can refresh without polling.

<historyUpdated>
  <key>a1b2c3…</key>
  <rooms>101,102</rooms>
  <driverId>42</driverId>
</historyUpdated>

On removal, rooms lists only the rooms the entry was actually pulled from, and driverId is omitted entirely if no driver index held the key. Parse these elements by name. The order they are emitted in is not stable between events.

Data shapes

History item

One element of the array returned by either read command.

{
  key       = "kQ7m…",             -- 20 characters
  timestamp = 1731024000,          -- os.time() of the last write
  driverId  = 42,
  roomIds   = "101,102",           -- every room this entry appears in
  info = {
    driverId   = 42,
    driverIcon = "controller://driver/<driver>/icons/device/experience_512.png",
    container  = {
      id               = "12345",
      itemType         = "Album",  -- display string, capitalized
      itemTypeForDriver= "album",  -- original value, use this to replay
      title            = "Kind of Blue",
      subtitle         = "Miles Davis",
      image            = "https://…/640x640.jpg",
    },
  },
}

Results are sorted newest first and deduplicated on a hash of id and itemType, so the same album surfacing from two rooms appears once. The limit is applied after deduplication.

Item type display names

The agent capitalizes the first letter of itemType for display and carries a small internal table of overrides, keyed on driver filename, for the handful of cases where a raw value would read badly in a list, such as an all-caps constant or a fully qualified metadata class name. That table is not extensible from outside the agent, so choosing an itemType that already reads as a noun is the only way to control the label.

Composer surface

Properties
PropertyTypeBehavior
Agent Versionstring, read-onlyDriver version. Carries the disable reason if the OS is below the minimum.
Agent EnabledOn / OffDefaults to On. Switching to Off clears the entire history.
History Sizestring, read-onlyCount of stored entries. Refreshed when the store persists, not on every write.
Debug ModeOn / OffReverts itself to Off after ten hours.
Actions and variables
NameKindNotes
Clear HistoryactionRuns ClearHistory.
Print History By RoomactionDumps the store grouped by room.
Print History By DriveractionDumps the store grouped by driver.
AgentEnabledvariable, boolMirrors the enabled property for programming.

Behavior notes

Rooms without digital audio read as empty

GetHistoryItemsByRooms filters out any room that does not list Control4 Digital Audio among its listen devices. Writes are never filtered, so a room can hold history that it will never return.

Disabling clears, it does not pause

Setting Agent Enabled to Off wipes the store. Turning it back on starts from nothing; there is no suspended state to return to.

Removing a device purges its history

The agent watches for item removal and drops every entry belonging to a deleted driver or room. Uninstalling a service driver takes its history with it.

The write is the container, not the track

Per-track updates are deliberately not recorded. History shows what a listener chose to play, so replaying an entry restarts the album or station rather than resuming one song.

Cap enforcement is index-local

Each index keeps its newest 20 keys. An entry is deleted outright once it has aged out of both its room index and its driver index during the same write, so a record can briefly outlive its position in one list.

Icons are resolved from the calling driver

The agent derives driverIcon from the caller's own package path, so a driver only needs to ship icons/device/experience_512.png to get correct branding in the list.

Casting protocols cannot participate

Where playback is initiated outside Control4, such as casting protocols where a phone app picks the content and the driver only carries the stream, the driver never learns which container was chosen and has nothing to write. The same is true of any non-streaming source: disc players, turntables, line-in devices.

Integration recipe

The minimum a service driver needs. Resolve the agent when rooms are registered, write on playback, and handle the two inbound commands.

-- 1. resolve, when rooms are registered
RECENTLY_PLAYED_AGENT = next(C4:GetDevicesByC4iName("recentlyplayed-agent.c4z"))

-- 2. write, when a container starts playing
local function RecordPlayback(container, rooms)
  if not RECENTLY_PLAYED_AGENT then
    return
  end

  local itemInfo = {
    driverId    = PROXY_ID,
    rooms       = table.concat(rooms, ","),
    keyToUpdate = CurrentQueue.RecentlyPlayedKey,
    info = {
      driverId  = PROXY_ID,
      container = {
        id       = container.id,
        itemType = container.itemType,
        title    = container.title,
        subtitle = container.subtitle,
        image    = container.image,
      },
    },
  }

  local key = C4:SendToDevice(RECENTLY_PLAYED_AGENT, "SetHistoryItem", {
    itemInfo = Serialize(itemInfo),
  })

  if key then
    CurrentQueue.RecentlyPlayedKey = key
  end
end

-- 3. replay
function RFP.PLAY_RECENT(idBinding, strCommand, tParams, args)
  local info      = Deserialize(tParams.info)
  local container = info.container or {}

  if container.id and container.itemType and tParams.rooms then
    PlayItem(container.itemType, container.id, tParams.rooms)
  end
end

-- 4. backfill metadata, only needed if step 2 can omit title and image
function GetContainerInfo(containerId, containerType, keyToUpdate, rooms)
  if not (containerId and containerType and keyToUpdate) then
    return
  end

  LookUpContainer(containerId, containerType, function(data)
    local itemInfo = {
      keyToUpdate = keyToUpdate,
      driverId    = PROXY_ID,
      rooms       = rooms,
      info = {
        driverId  = PROXY_ID,
        container = {
          id       = containerId,
          itemType = containerType,
          title    = data.title,
          subtitle = data.artist,
          image    = data.image,
        },
      },
    }

    C4:SendToDevice(RECENTLY_PLAYED_AGENT, "SetHistoryItem", {
      itemInfo = Serialize(itemInfo),
    })
  end)
end

Drivers built on the standard media service player library get steps 1 and 2 for free: the library resolves the agent when it registers rooms, and writes an entry whenever a queue is built with container information attached. Steps 3 and 4 stay the driver's responsibility, since only the driver knows how to turn an id back into playback.