
<!-- Generated from deploy/crds.yaml by crdref. Do not edit. -->

# `Sink`

A `Sink` is one playback endpoint as a Kubernetes resource: an
analog jack, an HDMI or DisplayPort output, the playback side of a
USB card, or a Bluetooth speaker. The operator creates one for every
endpoint it publishes, cluster-scoped like a `Node`, named by the
same device name the `ResourceSlice` carries. You never create or
delete one. The operator writes `status`: where the endpoint is, the
controls the card declares, and the values it last read. The media
operator writes `status.session`, the volume asks of a remote's keys.
You write `spec`, which states the settings you want for the endpoint.

```yaml
apiVersion: audio.liken.sh/v1alpha1
kind: Sink
metadata:
  name: node-1-usb-0573-1573-a34004801402-usb-audio
spec:
  volume:
    level: 80
    max: 100
    step: 5
  controls:
    PCM Playback Volume: "120"
status:
  node: node-1
  location: "1-6"
  connectionType: usb
  card:
    number: 1
    id: HID
    driver: USB-Audio
    name: USB Audio and HID
  pcm:
    device: 0
    id: USB Audio
  nodeName: liken.audio.card1-pcm0
  layoutSource: ChannelMap
  capabilities:
    PCM Playback Volume:
      type: integer
      min: 0
      max: 127
      minDecibels: "-63.50"
      maxDecibels: "0.00"
      channels: 2
    PCM Playback Switch:
      type: boolean
      channels: 1
  observed:
    volume: 80
    mute: false
    controls:
      PCM Playback Volume: "120"
      PCM Playback Switch: "on"
  conditions:
    - type: Connected
      status: "True"
    - type: Ready
      status: "True"
    - type: LayoutApplied
      status: "True"
```

The claim and the `Sink` answer two different needs. A pod that
plays sound still holds the output through a claim, as the
[claim guide](/docs/guides/claim/) shows, and it still has its own
stream volume. The `Sink` is for everything about the output that
is not the sound itself: its default level, its mute, and the
card's own controls. Because it is an ordinary Kubernetes resource,
anything with the right RBAC can change it. A pod that only wants
to mute the kitchen needs no claim, and a rule that lowers every
speaker at night is one patch per `Sink`.
[Set endpoint volume and controls](/docs/guides/rest/) walks through
it.

One playback endpoint: an analog jack, an HDMI or DisplayPort output, a USB card's playback side, or a Bluetooth speaker. Its status reports hardware facts and the values the operator last read. Its spec declares desired endpoint settings. The name is the machine's name and the card's identity, as the Sink reference gives them. When a USB card's name would pass 63 characters, it holds the first eight hex digits of the SHA-256 of the serial in place of the serial.

## spec

The desired settings for the endpoint. Every field is optional. The operator writes volume.level and mute when they change and when the endpoint appears, such as a speaker that reconnects or a node that PipeWire builds again, and otherwise reports the level the endpoint holds. It writes a declared control or codec back when the endpoint diverges from it. It never writes a field that the spec leaves out.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| <span id="spec--volume"></span>`volume` | [object](#specvolume) | no | The endpoint's volume, in percent of unity. level is the default level that the operator applies. max and step bound the volume asks the media operator writes into status.session, and the operator does not read them. |
| <span id="spec--mute"></span>`mute` | boolean | no | Whether the endpoint is silent. The operator applies this setting at the same layer as volume.level, and on the same terms. |
| <span id="spec--controls"></span>`controls` | map[string]string | no | The card's own controls, keyed by the kernel's control name as status.capabilities lists them, such as Master Playback Volume. An integer control takes a number within its range. A boolean control takes on or off. An enumerated control takes one of its values. The operator writes a control only when spec states it. When two endpoints share one control, the last write wins because the hardware has one register. |
| <span id="spec--layout"></span>`layout` | []string | no | The channel positions of an ALSA sink, in PCM slot order, in PipeWire's channel names, such as FL, FR, FC, LFE, RL, RR, SL, SR, RLC, RRC, TFL, TFR, NA for a slot that plays nothing, and AUX0 to AUX63. A 7.1 receiver on HDMI takes [FL, FR, RL, RR, FC, LFE, SL, SR], and a 5.1 one takes [FL, FR, RL, RR, FC, LFE]. When this field is absent, the operator selects the layout from the monitor's ELD on HDMI and DisplayPort, from the device's own channel map on USB, and declares no positions on the analog jack, whose streams then play in stereo. The operator declares the layout to PipeWire, and a change restarts PipeWire inside its container once no stream plays on the machine. The number of positions must be a channel count the device accepts, or PipeWire ignores the layout. The operator does not write the kernel's channel map, so on HDMI each slot reaches the speaker the kernel's standard allocation gives it, and a height position names a slot that the kernel routes elsewhere. A Bluetooth speaker ignores this field. |
| <span id="spec--codec"></span>`codec` | string | no | The A2DP codec to apply when no claim allocates the Bluetooth speaker. The value must be one of status.bluetooth.codecs. A claim's codec parameter takes precedence while the claim allocates the speaker. A change here waits until the claim ends because switching codecs replaces the speaker's node and interrupts playback. The operator ignores this field on an ALSA endpoint. |

### spec.volume

The endpoint's volume, in percent of unity. level is the default level that the operator applies. max and step bound the volume asks the media operator writes into status.session, and the operator does not read them.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| <span id="specvolume--level"></span>`level` | integer | no | The endpoint's default level, as a percent of unity, applied to every channel alike. On an ALSA endpoint it is the gain PipeWire applies in software. On a Bluetooth speaker it is the speaker's own volume, sent over AVRCP when the speaker supports absolute volume, and a software gain when it does not. The operator applies it when it changes and when the endpoint appears, under a claim or not, and a claim holder's own stream fader is a separate level above it. A level that changes after that, at the device or by a volume ask, stays, and status.observed.volume reports it. When it is absent, a node that PipeWire builds while the operator runs starts at unity. |
| <span id="specvolume--max"></span>`max` | integer | no | The highest level a volume ask can set. The media operator holds its asks at or below it. A person at the device can still set a higher level. Default: `100`. |
| <span id="specvolume--step"></span>`step` | integer | no | How far one press of a remote's volume key moves the level the media operator asks for. Default: `5`. |

## status

What the hardware declares and what the operator last read. The operator owns every field here apart from session, which the media operator owns.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| <span id="status--session"></span>`session` | [object](#statussession) | no | The unit that uses the Sink, and the last volume ask a press made. The media operator writes this block by server-side apply under its own field manager. The operator writes its own status fields as a merge patch that never names this block, so its writes leave the block in place. It is status and not spec because no person declares it, and a status write changes no metadata.generation. |
| <span id="status--node"></span>`node` | string | no | The machine that holds the endpoint now. For a Bluetooth speaker, the value changes when the speaker moves. The name of every other Sink starts with this machine's name, so a card that moves to another machine gets a new Sink. The operator on each machine lists and watches the Sinks by this field. |
| <span id="status--location"></span>`location` | string | no | Where the card is on the machine, in the kernel's spelling: a PCI address such as 0000:00:1f.3, or a USB port path such as 1-6. Absent on a Bluetooth speaker. |
| <span id="status--connectiontype"></span>`connectionType` | string | no | How sound leaves the machine. One of: `analog`, `hdmi`, `displayport`, `usb`, `bluetooth`. |
| <span id="status--card"></span>`card` | [object](#statuscard) | no | The ALSA card the endpoint is on. The number and the id are this boot's, and a second card can change both, so nothing durable is keyed to them. |
| <span id="status--pcm"></span>`pcm` | [object](#statuspcm) | no | The PCM device the endpoint plays through. |
| <span id="status--monitor"></span>`monitor` | [object](#statusmonitor) | no | The monitor an HDMI or DisplayPort slot feeds, from the ELD the graphics driver wrote into the card. Absent while no monitor answers, and absent on every other connection type. An Intel HDMI codec binds a pin to the first free slot when a monitor appears, so a card with two monitors can swap which slot feeds which between plug events, and this object is where that shows. |
| <span id="status--bluetooth"></span>`bluetooth` | [object](#statusbluetooth) | no | The speaker behind a Bluetooth endpoint. Absent on every other connection type. |
| <span id="status--nodename"></span>`nodeName` | string | no | The PipeWire node a consumer's streams target, the same value a prepared claim delivers as PIPEWIRE_NODE. Absent while PipeWire holds no node for the endpoint. |
| <span id="status--format"></span>`format` | [object](#statusformat) | no | The format the node runs at, from PipeWire's own Format parameter. Absent while the node is not running. |
| <span id="status--capabilities"></span>`capabilities` | [map\[string\]object](#statuscapabilities) | no | The card's own controls for this endpoint, keyed by the kernel's control name. A Playback control applies to the card's analog and USB sinks. A control with no direction, such as Auto-Mute Mode, also applies to those sinks. An IEC958 Playback Switch applies to the HDMI slot with the same ordinal. It is the only control an HDMI slot lists because an HDMI PCM has no volume element. The operator omits read-only jack controls because they feed the Connected condition. A Bluetooth speaker lists no card controls. |
| <span id="status--layout"></span>`layout` | []string | no | The channel positions the operator declares the sink's node with, in PCM slot order. Absent when layoutSource is ChannelMap, because PipeWire reads the positions from the device, and absent when it is None. While LayoutApplied is False with the reason Restarting, this is the layout PipeWire loads when its container starts again. |
| <span id="status--layoutsource"></span>`layoutSource` | string | no | Where the layout came from. Spec is spec.layout. ELD is the monitor's speaker allocation, capped by the largest LPCM channel count it accepts. ChannelMap is the channel map a USB device describes. None is no positions, and a multichannel stream then plays in stereo. An HDMI sink keeps a layout from the ELD while its monitor is off. Absent on a Bluetooth speaker. One of: `Spec`, `ELD`, `ChannelMap`, `None`. |
| <span id="status--observed"></span>`observed` | [object](#statusobserved) | no | The last value the operator read for each setting. The operator reads the card's control device for every event it receives. It reads PipeWire's graph for every change PipeWire reports. A change from a physical knob or a client therefore appears here without polling. |
| <span id="status--claim"></span>`claim` | [object](#statusclaim) | no | The claim that currently allocates the endpoint. This field is absent when no claim allocates it. It identifies the workload that has the speakers. |
| <span id="status--conditions"></span>`conditions` | [\[\]object](#statusconditions) | no | Connected reports whether the endpoint can play now. It is true for a monitor on an HDMI slot, a plug in an analog jack, a connected speaker, and every USB endpoint. Ready reports whether PipeWire has a node for the endpoint. These two conditions expose the same facts as the device's no-monitor and no-sink taints, in a form a person can read. LayoutApplied, on a sink of a sound card, reports whether PipeWire runs the sink with the layout the operator selected. It is False with the reason AwaitingIdle while a new layout waits for every stream on the machine to end, and with the reason Restarting while PipeWire restarts in its container to load it. |

### status.session

The unit that uses the Sink, and the last volume ask a press made. The media operator writes this block by server-side apply under its own field manager. The operator writes its own status fields as a merge patch that never names this block, so its writes leave the block in place. It is status and not spec because no person declares it, and a status write changes no metadata.generation.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| <span id="statussession--player"></span>`player` | string | no | The Player whose unit uses the Sink, as namespace/name. |
| <span id="statussession--volumeask"></span>`volumeAsk` | [object](#statussessionvolumeask) | no | The last level a press asked for. The operator applies each new ask once, to the node or to a Bluetooth speaker's Route, the same write that spec.volume.level takes, without waiting for the settle window that gathers hardware events into one ResourceSlice write. When several asks arrive before the operator applies one, it applies only the newest. It applies no ask that it finds the first time it reads the Sink, as in its first pass after a start, and no ask for an endpoint that has no node. It never writes an ask's level again, so a later press of a speaker's own button stays, and status.observed reports the level the endpoint holds. |

#### status.session.volumeAsk

The last level a press asked for. The operator applies each new ask once, to the node or to a Bluetooth speaker's Route, the same write that spec.volume.level takes, without waiting for the settle window that gathers hardware events into one ResourceSlice write. When several asks arrive before the operator applies one, it applies only the newest. It applies no ask that it finds the first time it reads the Sink, as in its first pass after a start, and no ask for an endpoint that has no node. It never writes an ask's level again, so a later press of a speaker's own button stays, and status.observed reports the level the endpoint holds.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| <span id="statussessionvolumeask--level"></span>`level` | integer | yes | The level, as a percent of unity. |
| <span id="statussessionvolumeask--mute"></span>`mute` | boolean | no | Whether the endpoint is silent. Absent means not muted. |
| <span id="statussessionvolumeask--at"></span>`at` | string | yes | When the press made the ask, with milliseconds. Each new time is one ask. |

### status.card

The ALSA card the endpoint is on. The number and the id are this boot's, and a second card can change both, so nothing durable is keyed to them.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| <span id="statuscard--number"></span>`number` | integer | no | The card number the kernel assigned this boot. |
| <span id="statuscard--id"></span>`id` | string | no | The card's short id, such as PCH, with the suffix the kernel appends on a clash, such as PCH_1. |
| <span id="statuscard--driver"></span>`driver` | string | no | The kernel driver that binds the card, such as HDA-Intel. |
| <span id="statuscard--name"></span>`name` | string | no | The card's name, as the driver states it, such as HDA Intel PCH. |

### status.pcm

The PCM device the endpoint plays through.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| <span id="statuspcm--device"></span>`device` | integer | no | The PCM device number on the card, this boot. |
| <span id="statuspcm--id"></span>`id` | string | no | The driver's name for the PCM, such as HDMI 0 or USB Audio. It is the part of the endpoint's name that outlives the number. |

### status.monitor

The monitor an HDMI or DisplayPort slot feeds, from the ELD the graphics driver wrote into the card. Absent while no monitor answers, and absent on every other connection type. An Intel HDMI codec binds a pin to the first free slot when a monitor appears, so a card with two monitors can swap which slot feeds which between plug events, and this object is where that shows.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| <span id="statusmonitor--display"></span>`display` | string | no | The name of the Display the display operator publishes for the same monitor, which is the pairing identity monitor.liken.sh/id. |
| <span id="statusmonitor--manufacturer"></span>`manufacturer` | string | no | The monitor's three-letter PNP id, such as GSM. |
| <span id="statusmonitor--product"></span>`product` | string | no | The monitor's product code, four lowercase hexadecimal digits. |
| <span id="statusmonitor--name"></span>`name` | string | no | The monitor's name, from the same EDID descriptor the Display reports as model. |

### status.bluetooth

The speaker behind a Bluetooth endpoint. Absent on every other connection type.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| <span id="statusbluetooth--address"></span>`address` | string | no | The speaker's address, uppercase with colons. |
| <span id="statusbluetooth--name"></span>`name` | string | no | The name the speaker reports for itself. |
| <span id="statusbluetooth--peripheral"></span>`peripheral` | string | no | The name of the Peripheral the bluetooth operator publishes for this speaker, which is the address in lowercase with dashes. |
| <span id="statusbluetooth--codec"></span>`codec` | string | no | The A2DP codec the transport negotiated, present while the speaker is connected. |
| <span id="statusbluetooth--codecs"></span>`codecs` | []string | no | Every codec the speaker and this image both support, the one playing first. |

### status.format

The format the node runs at, from PipeWire's own Format parameter. Absent while the node is not running.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| <span id="statusformat--rate"></span>`rate` | integer | no | The sample rate in hertz. |
| <span id="statusformat--channels"></span>`channels` | integer | no | The channel count. |
| <span id="statusformat--positions"></span>`positions` | []string | no | The channel positions in order, in PipeWire's names: FL, FR, FC, LFE, RL, RR. |

### status.capabilities.*

The card's own controls for this endpoint, keyed by the kernel's control name. A Playback control applies to the card's analog and USB sinks. A control with no direction, such as Auto-Mute Mode, also applies to those sinks. An IEC958 Playback Switch applies to the HDMI slot with the same ordinal. It is the only control an HDMI slot lists because an HDMI PCM has no volume element. The operator omits read-only jack controls because they feed the Connected condition. A Bluetooth speaker lists no card controls.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| <span id="statuscapabilities--type"></span>`type` | string | no | What the control takes. One of: `integer`, `boolean`, `enumerated`. |
| <span id="statuscapabilities--min"></span>`min` | integer | no | The smallest number an integer control accepts. |
| <span id="statuscapabilities--max"></span>`max` | integer | no | The largest number an integer control accepts. |
| <span id="statuscapabilities--step"></span>`step` | integer | no | The step between numbers an integer control accepts, absent when every number in the range is accepted. |
| <span id="statuscapabilities--mindecibels"></span>`minDecibels` | string | no | The level at min, in decibels, when the control declares one, as a decimal string such as -65.25. The string form keeps the value exact where a float would not. |
| <span id="statuscapabilities--maxdecibels"></span>`maxDecibels` | string | no | The level at max, in decibels, when the control declares one. |
| <span id="statuscapabilities--values"></span>`values` | []string | no | Every value an enumerated control accepts. |
| <span id="statuscapabilities--channels"></span>`channels` | integer | no | How many channels the control carries. A write from spec sets every channel to the same value. |

### status.observed

The last value the operator read for each setting. The operator reads the card's control device for every event it receives. It reads PipeWire's graph for every change PipeWire reports. A change from a physical knob or a client therefore appears here without polling.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| <span id="statusobserved--volume"></span>`volume` | integer | no | The level the endpoint plays at, as a percent of unity, from the node's gain or the Bluetooth device's own volume. A suspended node reports no current level of its own: PipeWire applies a write to it without announcing it, and the node prints no level or the level it last ran at. So a suspended endpoint reports the level the operator last wrote to it, from the spec or a volume ask, which is the level it will run at. With no such write since the node last ran, it reports what the node prints. |
| <span id="statusobserved--mute"></span>`mute` | boolean | no | Whether the endpoint is silent, on the same terms as volume. |
| <span id="statusobserved--codec"></span>`codec` | string | no | The codec a Bluetooth speaker plays with now. |
| <span id="statusobserved--controls"></span>`controls` | map[string]string | no | The value of every control in capabilities, in the same spelling spec.controls takes. A control with several channels reports the first channel's value. |

### status.claim

The claim that currently allocates the endpoint. This field is absent when no claim allocates it. It identifies the workload that has the speakers.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| <span id="statusclaim--namespace"></span>`namespace` | string | no |  |
| <span id="statusclaim--name"></span>`name` | string | no |  |

### status.conditions[]

Connected reports whether the endpoint can play now. It is true for a monitor on an HDMI slot, a plug in an analog jack, a connected speaker, and every USB endpoint. Ready reports whether PipeWire has a node for the endpoint. These two conditions expose the same facts as the device's no-monitor and no-sink taints, in a form a person can read. LayoutApplied, on a sink of a sound card, reports whether PipeWire runs the sink with the layout the operator selected. It is False with the reason AwaitingIdle while a new layout waits for every stream on the machine to end, and with the reason Restarting while PipeWire restarts in its container to load it.

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| <span id="statusconditions--type"></span>`type` | string | yes |  |
| <span id="statusconditions--status"></span>`status` | string | yes | One of: `True`, `False`, `Unknown`. |
| <span id="statusconditions--reason"></span>`reason` | string | yes |  |
| <span id="statusconditions--message"></span>`message` | string | no |  |
| <span id="statusconditions--lasttransitiontime"></span>`lastTransitionTime` | string | yes |  |

## The name

The name is built from the hardware's own identity, so it survives
a reboot and a second card:

| Endpoint | Form | Example |
| --- | --- | --- |
| onboard PCI card | node, PCI address, PCM id | `node-1-pci-0000-00-1f-3-hdmi-0` |
| USB card with a serial | node, vendor, product, serial, PCM id | `node-1-usb-0573-1573-a34004801402-usb-audio` |
| USB card with a serial, past 63 characters | node, vendor, product, serial hash, PCM id | `studio-screen-12-usb-046d-0a44-5fe3de05-usb-audio` |
| USB card with no serial | node, USB port path, PCM id | `node-1-usb-1-6-usb-audio` |
| Bluetooth speaker | address | `7c-66-ef-01-23-45` |

The PCM id is the driver's name for the endpoint, `HDMI 0` or
`USB Audio`, lowercased with dashes. Every ALSA form starts with the
node, because a USB serial is not unique across machines: dongles of
one model can all report the same serial. The serial tells apart two
identical dongles on one machine. A card that moves to another
machine becomes a new `Sink`, and so does a card with no serial that
moves to another port. The old `Sink` stays, with its `spec`, and
the operator on the old machine sets its `Connected` condition to
`False`. Delete it when you no longer need its declaration. A card that plays and records through one PCM
gives its `Source` the same name with `-capture` on the end.

A name is a DNS label, so it holds at most 63 characters. A USB
serial is often 20 characters or more, so on a machine with a longer
name the serial form can pass that. Then the name holds the first
eight hex digits of the serial's SHA-256 in place of the serial, and
keeps the rest. The hash is stable and differs for each serial, so
two identical dongles on one machine still get two names. The
operator tests each name alone, and `-capture` adds eight
characters. So a dongle whose `Sink` name is 56 to 63 characters
keeps the serial in the `Sink` name and holds the hash in the
`Source` name. To compute the hash by hand:

```sh
printf %s ABCDEF0123456789ABCDEF0 | sha256sum | cut -c1-8
```

The operator refuses any other name that passes 63 characters, and
its log names the endpoint and the length.

On an Intel HDMI codec, `hdmi-0` names the card's first HDMI slot
and not a physical port. A pin binds to the first free slot when a
monitor appears, so on a card with two monitors the slot each one
lands in can change between plug events. `status.monitor` reports
which monitor the slot feeds now, and a machine with one HDMI
monitor never sees the difference.

## How the operator applies the `spec`

The operator writes a declared `volume.level` and `mute` when the
declaration changes and when the endpoint appears: a speaker that
reconnects, or a node that PipeWire builds again. At every other
time it follows the device. A press of a speaker's own button, a
client that changes the graph, and a volume ask move the level, and
the operator reports the new level in `status.observed` and does not
write the declaration back. A declared control and a declared `codec`
are standing instructions: the operator compares each one with the
value it last read, and writes the hardware only where the two
diverge. A declared control is
validated against `status.capabilities`: a name the card does not
declare, or a value out of its range, fails the pass with the reason
in the operator's log and is never written. An empty `spec` writes
nothing at all. The operator invents no value: an endpoint with no
declarations keeps whatever the hardware holds, except that every
sink starts at unity gain so that no hidden multiplier costs
resolution before the codec runs. The unity write goes only to a
node PipeWire builds while the operator runs. After a restart, the
operator writes nothing to a sink that was there before it started,
and the sink keeps the level it holds.

A restart writes no declared level either. The level an endpoint
holds when the operator starts can be a volume ask or a press of the
speaker's button that the declaration does not know, and an idle
node reports no level, so the operator cannot read whether it
already holds the declaration. The first pass treats the declaration
as the level each endpoint holds, and a later change of the
declaration is written at once.

`volume.max` and `volume.step` bound the volume asks below, and the
operator writes neither to the hardware. Each one has a default, 100
and 5 percent, which the API server fills in when `spec.volume` is
present.

`volume.level`, `mute`, and `controls` apply at once, whether a claim
holds the endpoint or not. `codec` waits for the claim to end,
because a codec switch replaces the speaker's node and interrupts
playback, and a claim's own `codec` parameter wins while it holds
the speaker.

## The volume asks

A remote's volume key reaches a `Sink` through the media operator.
It turns each press into an absolute level, at most `volume.max`,
and writes it into `status.session.volumeAsk` with the time of the
press. It writes that block by server-side apply under its own field
manager, and this operator writes the rest of `status` as a merge
patch that never names it, so neither writer removes the other's
fields.

```yaml
status:
  session:
    player: media/den
    volumeAsk:
      level: 45
      mute: false
      at: "2026-10-04T12:15:25.164Z"
```

Each new `at` is one ask, and the operator applies it once, with the
same write that `volume.level` takes: the node's gain, or a
Bluetooth speaker's own volume over AVRCP. The ask does not wait for
the 1.5 second settle window that gathers a burst of hardware events
into one `ResourceSlice` write, because a level write changes no
`ResourceSlice`. When several asks arrive before the operator applies
one, it applies only the newest. When the write lands, the operator
writes the asked level and mute into `status.observed` at once, and
the next pass replaces them with what PipeWire reports. It applies no ask that it finds the
first time it reads the `Sink`, as in its first pass after a start,
because the last operator applied that ask, or the device's level is
newer than it. An ask for an endpoint with no node is dropped, and
the endpoint takes `volume.level` when it appears.

## Observation

`status.observed` follows two event sources and no timer. The
card's control device reports every control write from any process,
every jack change, every monitor change, and a knob turned on a USB
DAC. PipeWire's graph reports every node and device change. So a
change a person made with a knob, a remote, or a speaker's own
buttons shows in `observed` within about a second. A declared
control or codec is written back on the same event, and a declared
level is not.

## The channel layout

The operator declares every sink of a sound card to PipeWire with a
channel layout: the position of each PCM slot, in PipeWire's channel
names. WirePlumber links each channel of a stream to the sink's
channel of the same name. It links every stream to a sink with no
positions as two channels, `FL` and `FR`, so a 5.1 or 7.1 stream is
mixed down into them.

The operator takes the layout from the first source that gives one,
and `status.layoutSource` names it:

| Source | Where the positions come from |
| --- | --- |
| `Spec` | `spec.layout` |
| `ELD` | the HDMI or DisplayPort monitor's speaker allocation, capped by the largest LPCM channel count it accepts |
| `ChannelMap` | the channel map a USB device describes in its descriptors. PipeWire reads it when it opens the device, so `status.layout` is absent |
| `None` | nothing. The analog jack reports no speakers, and neither does an HDMI output whose monitor was off when the pod started |

From the ELD, the operator selects one of the HDMI layouts that
PipeWire's own card profiles use:

| The monitor advertises | `status.layout` |
| --- | --- |
| 8-channel LPCM and `FL/FR`, `LFE`, `FC`, `RL/RR`, `RLC/RRC` | `FL, FR, RL, RR, FC, LFE, SL, SR` |
| 6-channel LPCM and `FL/FR`, `LFE`, `FC`, `RL/RR` | `FL, FR, RL, RR, FC, LFE` |
| anything else | `FL, FR` |

Most televisions accept 8-channel LPCM and have two speakers. Such a
set gets `FL, FR`, so PipeWire mixes the center channel, which
carries the dialog, into the front pair. An HDMI sink keeps its
layout from the ELD while its monitor is off, so a receiver that
turns off and on again changes nothing.

PipeWire reads the layout once, when it starts. When a sink's layout
changes, because `spec.layout` changed or a monitor with another
layout answers, the operator writes the new declaration, and the
PipeWire container's first process restarts PipeWire in place, so the
restart never waits in the kubelet's crash backoff. The restart ends every
stream on the machine, so the operator waits until no stream plays.
`LayoutApplied` is `False` with the reason `AwaitingIdle` while it
waits, and with the reason `Restarting` until the new PipeWire
starts. Each change posts one `LayoutChanged` `Event` on the `Sink`,
which names the old layout, the new one, and the source.

Two things the operator does not do:

* It does not write the kernel's channel map. On HDMI the kernel
  routes each slot by its standard allocation for the channel count,
  so a height position such as `TFL` in `spec.layout` names a slot
  that the kernel sends to another speaker. The kernel can route
  front heights (CEA allocation 0x2f), but only when a program
  writes the PCM's channel map while the device is open and stopped.
* It does not pass a bitstream through. A receiver that plays height
  speakers from Dolby Atmos or DTS:X needs the compressed stream,
  and the operator sends PCM. Passthrough is a separate design.

## Events

The operator posts a Kubernetes `Event` on a `Sink` for each change
of a condition and for each action it takes. A `Sink` is
cluster-scoped, so its `Event`s are in the `default` namespace.
`kubectl describe sink` shows them. `kubectl events --for` shows them
only with `-n default` or `-A`:

    kubectl events -n default --for sink/<name>

The API server deletes an `Event` one hour after its last write, so
the conditions and the operator's log hold the facts for longer. The
operator patches the count of an `Event` that repeats within 10
minutes, in place of a new `Event`.

Each condition change posts one `Event` with the condition's own
reason and message, such as `NoMonitor`, `JackEmpty`, or
`AwaitingIdle`. The first status write posts one for each condition.
A change of `Ready` to `False` is a `Warning` while `Connected` is
`True`, because sound can leave the endpoint and PipeWire holds no
node to send it through. Every other condition change is `Normal`: a
television that turns off and a plug pulled from a jack are things a
person does.

The operator posts these reasons for the actions and faults that
change no condition:

| Reason | Type | When |
|---|---|---|
| `LayoutChanged` | `Normal` | The operator wrote a new channel layout, and PipeWire restarts in its container to apply it. |
| `LayoutWriteFailed` | `Warning` | The operator could not write the declaration that holds a new layout. The sink keeps its layout, and each pass tries the write again. |
| `SpecRefused` | `Warning` | The `spec` states a value the endpoint does not take, such as a codec the speaker does not offer. The message names each refused value. |
| `PipeWireLost` | `Warning` | A read of PipeWire's graph failed. After 3 failed reads in a row, the operator taints every output and restarts. |
| `PipeWireRecovered` | `Normal` | PipeWire answers a graph read again, after one or more that failed. |
| `BluetoothUnavailable` | `Warning` | On a speaker's `Sink`: `bluetoothd` did not answer a read of the paired speakers. The speaker publishes with a taint until it answers. |
| `BluetoothAvailable` | `Normal` | On a speaker's `Sink`: `bluetoothd` answers again. |
| `Captured` | `Normal` | The capture API returned the audio of the `Sink` to a caller. The message names the caller and the format. |

A fault posts once when the operator first meets it, not once for
each pass that meets it again.

