Control4 Subsystem Reference

# Apple Bridge

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

_Diagram: Sequence of a device driver working with the bridge. Discovery phase: the bridge finds every driver named appleTV.c4z, registers each with the local daemon as a remote, the daemon replies with the list of pairable devices, and the bridge sends the driver AVAILABLE_CHANGED carrying a flat table of remote identifier to name. Activation phase: the driver sends ACTIVATE with the REMOTE string and its own device id; the bridge issues an activate call to the daemon and repeats it every 10 seconds until acknowledged; when the daemon reports an active session it includes the voice URL, and the bridge sends the driver ACTIVE with URL and the active remote identifier, and simultaneously sends APPLETV_STATUS with STATUS ACTIVE to the Voice Coordinator, which starts the voice target immediately. Session loss phase: when the daemon reports no active session or its connection drops, the bridge sends the driver NOT_ACTIVE with no parameters and APPLETV_STATUS with STATUS NOT_ACTIVE to the coordinator. The driver should clear its stored URL; re-activation requires a new ACTIVATE._

_ 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:

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

**Dispatch**

| Message | Arrives at | On |
| --- | --- | --- |
| ACTIVATE, SEND_KEY, TRIGGER_DISCOVERY | ExecuteCommand | The bridge |
| AVAILABLE_CHANGED, ACTIVE, NOT_ACTIVE, NOT_SUPPORTED | ExecuteCommand | Your driver |
| APPLETV_STATUS | ExecuteCommand | The Voice Coordinator agent |

### Discovery gates

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.

ACTIVATE Driver 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_KEY Driver 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_DISCOVERY Driver 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_CHANGED Arrives

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.

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

ACTIVE Arrives

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_ACTIVE Arrives

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_SUPPORTED Arrives

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

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

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

Apple Bridge, driver protocol reference Requires Control4 OS 3.0.0 or later
