The driver-to-driver protocol between a TV device driver and the Apple
Bridge. It covers how a driver named appleTV.c4z is discovered, how it
activates a remote session and receives its voice URL, how button presses
reach the device, and how session state is relayed to the Voice
Coordinator.
File
AppleBridge.c4z
Name
Apple Bridge
Proxy
AppleBridge
Minimum OS
3.0.0
Documented at
version 87
The model
The bridge is a singleton that stands between TV device drivers in the
project and a controller-local daemon that speaks to the physical
devices. Your driver never talks to the daemon; it exchanges plain
driver-to-driver commands with the bridge, and the bridge translates.
The pieces
Piece
What it is
Talks over
Your device driver
A driver whose file is named exactly
appleTV.c4z. Presents the TV to the project and,
with push-to-talk, serves as a voice target.
C4:SendToDevice to and from the bridge
Apple Bridge
The singleton this page documents. Registers each discovered
device with the daemon as a software remote and relays
session state back.
One local socket per device, plus one control socket
Device daemon
A controller service that pairs with the physical devices,
owns the sessions, and issues the voice URLs.
127.0.0.1:9200
Voice Coordinator
Optional consumer. The bridge pushes session transitions to
it so voice targets start without waiting for a poll. See the
Voice
Coordinator reference.
APPLETV_STATUS messages
Three identities matter, and confusing them is the usual integration
bug. Your device id is the Control4 id of your driver;
it addresses ACTIVATE and SEND_KEY and is
where the bridge sends replies. The bridge assigns each discovered
device an internal index that selects which daemon socket to use; you
never see it. The remote identifier is the daemon's id
for a pairable device, delivered in AVAILABLE_CHANGED and
echoed back inside the REMOTE string; it is also the id
embedded in the voice URL.
Registration comes in two grades. A device whose
driver.xml declares
<push-to-talk>true</push-to-talk> is
registered with the daemon as a voice remote and its sessions
carry a voice URL. Without the capability it is registered as a plain
remote: buttons work, ACTIVE still arrives, but its
URL is never populated for voice.
Call flow
Discovery, activation, and loss for one device.
Ochre arrows are bridge-initiated. Note that the driver sends
exactly one ACTIVATE; the retry loop against the daemon
lives in the bridge.
Conventions
Discovery is by exact filename, again
The bridge scans the project for devices whose driver file is named
appleTV.c4z and nothing else. This is the same rule the
Voice Coordinator applies, so one filename buys both integrations. At
discovery the bridge reads the device's declared capabilities; a
lowercased match on
<push-to-talk>true</push-to-talk> upgrades
the registration to a voice remote. Capabilities come from the device
data recorded when the device was added, so a capability edit takes
effect only after removing and re-adding the device.
Finding the bridge
The bridge is a singleton; address it by filename:
local bridgeId = next(C4:GetDevicesByC4iName("AppleBridge.c4z"))
The stock device driver installs the bridge automatically when it is
missing, so treat a nil result as "not installed yet" rather than an
error, and retry after a few seconds.
Entry points
Everything in both directions travels as
C4:SendToDevice and lands in
ExecuteCommand. Nothing on this interface uses proxies,
bindings, or UI requests.
The bridge only registers devices while discovery is authorized:
the Discover Remotes property is On (the default), or the
non-remote discovery property is On, or a ten-minute pairing window
opened from the mobile side is running. With every gate closed the
bridge shuts its daemon session down entirely, and nothing on this
page functions. The bridge also defers its own initialization until a
companion system driver, when present, reports ready.
Library conventions are not the contract
The command names, parameter names, the REMOTE string
format, and the URL shape are wire contract. The handler style is not:
this page writes a plain ExecuteCommand, and a driver
built on the shared library reaches the same handlers through its
EC. dispatch table. Helper names in the examples, such as
StoreVoiceUrl(), are placeholders to write yourself.
Commands you send
Sent with C4:SendToDevice(bridgeId, ...). All of them
require your device to be in the bridge's discovery map; a driver the
bridge has not discovered is silently ignored.
ACTIVATEDriver sends
Requests a session with one pairable device. The bridge extracts the
remote identifier from the bracketed number in REMOTE
and issues the daemon activation, retrying every 10 seconds until it
is acknowledged, so one send is enough.
Parameters
Name
Type
Notes
REMOTE
string req
The selection in "<name> [<identifier>]"
form, exactly as composed from
AVAILABLE_CHANGED. Only the first bracketed
number is parsed. An empty string is ignored.
ID
number req
Your own device id, C4:GetDeviceID(). Selects
which daemon connection carries the activation and where
ACTIVE comes back.
Returns. Nothing directly. Success arrives later as
ACTIVE; a device the daemon cannot reach simply never
produces one while the bridge keeps retrying.
Side effects. Identifier 0 (the "Not
Activated" entry) requests deactivation.
SEND_KEYDriver sends
Forwards one button event into the active session. The recognized
button names are menu, playpause,
tvhome, select, up,
right, down, left,
volup, voldown, audioinput,
power, and generic.
Parameters
Name
Type
Notes
ID
number req
Your device id.
BUTTON
string req
One of the names above.
STATE
string req
down, up, or press.
A press carries its own duration; down and up bracket a
hold.
DURATION
number
Milliseconds, for press. Defaults to 0.
TIMESTAMP
string req
A 31-bit millisecond tick value. Pair the up event's
timestamp with the down event's plus the hold time.
Side effects. A button error from the daemon makes
the bridge restart its session, at most once per five minutes.
TRIGGER_DISCOVERYDriver sends
Forces a discovery refresh, initializing the bridge first if nothing
has yet. No parameters. In the stock arrangement a companion system
driver sends this; a device driver only needs it to be picked up
immediately after install instead of waiting for the next project
event. Refreshes are debounced by 5 seconds.
The bridge also accepts management traffic that is not part of the
device-driver contract: REFRESH and
RESTART_DAEMON mirror the Composer actions, and
PAUSE_DISCOVERY, RESUME_DISCOVERY,
ENABLE_REMOTE_DISCOVERY,
ENABLE_SIRI_DISCOVERY, STATUS_UPDATE, and
CHANGEPROP belong to the companion system driver. Leave
them alone.
Commands that arrive
The bridge sends these to your driver's ExecuteCommand.
Handle all four; three of them change what your voice UI requests
should answer.
AVAILABLE_CHANGEDArrives
The current set of pairable devices, as a flat table of
tParams[identifier] = name. Sent only when the set
differs from what the bridge last sent your device. Present it to
the installer as a list, composing each entry as
name .. " [" .. identifier .. "]", and re-assert a
persisted selection when it reappears in the list.
function ExecuteCommand(strCommand, tParams)
if strCommand == "AVAILABLE_CHANGED" then
local entries = { "Not Activated [0]" }
for identifier, name in pairs(tParams) do
table.insert(entries, name .. " [" .. identifier .. "]")
end
UpdateSelectorList(entries) -- write this yourself
ReassertPersistedSelection(entries) -- sends ACTIVATE if found
end
end
ACTIVEArrives
A session is up. For a voice-remote registration URL
carries the voice endpoint; store it, mirror it to your proxy with
SET_PTT_URL, and serve it from
GET_VOICE_TARGET_CONFIG and GET_PTT_URL.
Parameters
Name
Type
Notes
URL
string
The voice endpoint, shaped
c4vc://<identifier>@<ip>:<port>/.
Relay it verbatim; the Voice Coordinator's parser requires
the scheme and the trailing slash. Absent for plain-remote
registrations.
ID
number
The remote identifier now active, matching a key from
AVAILABLE_CHANGED. Not your device id.
NOT_ACTIVEArrives
The session dropped, the activation was rejected, or the daemon's
connection for your device went away. No parameters. Clear the
stored URL and push an empty SET_PTT_URL. The bridge
does not re-activate for you; send ACTIVATE again,
ideally on a short delay rather than immediately.
NOT_SUPPORTEDArrives
The controller hardware cannot run the daemon, so nothing on this
interface will ever work in this project. Sent to every
appleTV.c4z device at bridge startup on such hardware.
Surface a status to the installer and stop expecting the handshake.
Alongside ACTIVE and NOT_ACTIVE the bridge
sends the Voice Coordinator
APPLETV_STATUS { STATUS, DEVICE_ID }, with your device id
as DEVICE_ID. That message is what lets the coordinator
start your voice target immediately instead of on its next timer pass;
your driver neither sends nor receives it, but it is why the ordering
works: the bridge delivers ACTIVE to you first, so your
config reply is already valid when the coordinator asks.
Data shapes
The REMOTE string
"<name> [<identifier>]", for example
"Media Room [3]". The bridge parses it with
"%[(%d+)%]": the first bracketed run of digits wins and
the name is ignored. Keep device names out of your own bracket
notation or the wrong identifier can be extracted.
The voice URL
c4vc://<identifier>@<ip>:<port>/. The
daemon composes it; the identifier is the active remote identifier and
the address points at the daemon's audio listener, not at your driver.
Nothing on the device-driver side should parse it. Store and relay.
Button events
-- a tap
{ ID = C4:GetDeviceID(), BUTTON = "select", STATE = "press",
DURATION = 100, TIMESTAMP = tostring(now) }
-- a hold, as a down/up pair
{ ID = ..., BUTTON = "menu", STATE = "down", TIMESTAMP = tostring(now) }
{ ID = ..., BUTTON = "menu", STATE = "up",
TIMESTAMP = tostring(now + holdMs) }
Composer surface
The bridge's own surface, for orientation while debugging. Your driver
does not manage any of it.
Properties
Property
Type
Behavior
Driver Version
STRING, read-only
Reports the installed version.
Log Level
LIST
0 - Fatal through 5 - Trace;
default 1 - Error.
Log Mode
LIST
Off, Print, Log,
Print and Log; default Off.
Setup Code
STRING, read-only
The pairing code, formatted 123-45-678, kept
in the controller registry.
Status
STRING, read-only
Shown only on unsupported controller hardware.
Discover Remotes
LIST
On / Off; default On. The main
discovery gate for this interface.
Discover Non-Remote Devices
LIST
On / Off; default Off. For the
companion integration, not for remotes.
Actions
Action
Effect
Refresh
Tears down and rebuilds the daemon session and all device
registrations.
Refresh Discovered Items
Re-runs discovery and resends registrations.
Factory Reset
Clears bridge state, including pairings.
Reset Setup Code
Generates a new pairing code.
Restart Daemon
Restarts the controller daemon itself; the heavy hammer
when sessions are wedged.
Behavior notes
One ACTIVATE is enough; re-activation is not automatic
The bridge retries the daemon activation every 10 seconds until
acknowledged, so do not spam ACTIVATE. But after
NOT_ACTIVE the loop is over; nothing re-activates
until you send again. Persist the selection and re-send on
AVAILABLE_CHANGED and on a delay after
NOT_ACTIVE.
Your driver never chooses the address
The voice URL is composed by the daemon and stays stable across
your driver's reloads. The ephemeral-port failure that plagues
transcoder-path voice targets cannot happen here; this path's
fragility is instead that it depends on the bridge being present
and its session staying up.
Voice requires the capability at discovery time
Without push-to-talk in your capabilities when the
device was added, the registration is a plain remote:
ACTIVE arrives without a usable voice URL and no
amount of activation fixes it. Re-add the device after adding the
capability.
ACTIVE can precede your first poll
The bridge tells you before it tells the coordinator. Have your
UI request handlers ready from initialization, because
GET_VOICE_TARGET_CONFIG can arrive within seconds of
ACTIVE.
AVAILABLE_CHANGED is deduplicated per device
You receive it only when the set changed relative to what the
bridge last sent you. Do not wait for a periodic refresh of it;
cache the last table and treat silence as "no change".
Discovery can be off entirely
With Discover Remotes Off, no pairing window open,
and the companion discovery Off, the bridge stops its daemon
session. Symptoms look like a dead daemon: no
AVAILABLE_CHANGED, no ACTIVE, ever.
Check the property before debugging deeper.
Identifiers are not stable names
The remote identifier comes from the daemon and can change when
pairings are rebuilt. Persist the full selection string, match it
against fresh AVAILABLE_CHANGED content by name if
the identifier is gone, and let the installer re-pick when
neither matches.
Button errors restart the session
A failed button send makes the bridge refresh its daemon session,
rate-limited to once per five minutes. Expect a brief
NOT_ACTIVE and ACTIVE bounce after one,
not a permanent loss.
Integration recipe
The minimum for a device driver that wants working voice through the
bridge. Requires the file to be named appleTV.c4z and
<push-to-talk>True</push-to-talk> in
driver.xml.
-- 1. Find the bridge; install it if absent, then retry.
local function BridgeId()
return tonumber(next(C4:GetDevicesByC4iName("AppleBridge.c4z")) or 0) or 0
end
-- 2. Handle the bridge's messages.
gVoiceUrl = ""
function ExecuteCommand(strCommand, tParams)
tParams = tParams or {}
if strCommand == "AVAILABLE_CHANGED" then
local entries = { "Not Activated [0]" }
for identifier, name in pairs(tParams) do
table.insert(entries, name .. " [" .. identifier .. "]")
end
UpdateSelectorList(entries) -- property list for the installer
local kept = PersistData.Selection or ""
for _, entry in ipairs(entries) do
if entry == kept then Activate(kept) end
end
elseif strCommand == "ACTIVE" then
gVoiceUrl = tParams.URL or ""
C4:SendToProxy(5001, "SET_PTT_URL", { URL = gVoiceUrl,
Protocol = "Control4" })
elseif strCommand == "NOT_ACTIVE" then
gVoiceUrl = ""
C4:SendToProxy(5001, "SET_PTT_URL", { URL = "", Protocol = "Control4" })
ScheduleReactivate(3) -- re-send ACTIVATE on a short delay
end
end
-- 3. Activate the installer's selection, and persist it.
function Activate(selection)
PersistData.Selection = selection
C4:SendToDevice(BridgeId(), "ACTIVATE",
{ REMOTE = selection, ID = C4:GetDeviceID() })
end
-- 4. Serve the URL to the voice stack. The coordinator reads the first-- two; Navigators read the third.
function UIRequest(strCommand, tParams)
if strCommand == "GET_VOICE_TARGET_TYPE" then
return "<TargetType>appletv</TargetType>"
elseif strCommand == "GET_VOICE_TARGET_CONFIG" then
return "<TargetConfig>" .. gVoiceUrl .. "</TargetConfig>"
elseif strCommand == "GET_PTT_URL" then
return "<PTT><URL>" .. gVoiceUrl ..
"</URL><Protocol>Control4</Protocol></PTT>"
end
end