Devices

display-operator publishes one Dynamic Resource Allocation (DRA) device for each connector on the graphics card its pod claims from liken . The device count follows the card’s connectors, whether or not a monitor is plugged into each one. A cabled monitor that is asleep still answers its EDID, so its output publishes untainted and a claim on it starts a pod. An empty connector publishes with the disconnected taint, so a claim on it parks instead of failing.

The operator publishes the devices into one ResourceSlice per node, named <node>-display.liken.sh, beside the slice liken itself publishes:

kubectl get resourceslice <node>-display.liken.sh -o yaml
spec:
  driver: display.liken.sh
  nodeName: kitchen
  devices:
    - name: hdmi-a-1
      attributes:
        connector: {string: "HDMI-A-1"}
        appId: {string: "hdmi-a-1"}
        manufacturer: {string: "GSM"}
        model: {string: "LG HDR WQHD"}
        serial: {string: "202NTRLCC070"}
        widthPixels: {int: 3840}
        heightPixels: {int: 1600}
        refreshMillihertz: {int: 59999}
        widthMillimeters: {int: 879}
        heightMillimeters: {int: 366}
        modes: {string: "3840x1600 3840x2160 3440x1440 1920x1080 1680x1050 1600x900"}
        monitor.liken.sh/id: {string: "gsm-7716-lg-hdr-wqhd"}
    - name: dp-1
      attributes:
        connector: {string: "DP-1"}
        appId: {string: "dp-1"}
      taints:
        - key: display.liken.sh/disconnected
          effect: NoExecute

The device class

A consumer claims through a DeviceClass that selects this driver. You create it, because a class a workload claims through is cluster policy, and this manual calls it display-output:

apiVersion: resource.k8s.io/v1
kind: DeviceClass
metadata:
  name: display-output
spec:
  selectors:
    - cel:
        expression: |
          device.driver == "display.liken.sh" &&
          has(device.attributes["display.liken.sh"].appId)

The appId guard keeps the class on outputs, because the driver also publishes each panel’s control device , which carries no appId.

The class is yours to rename or narrow, the way a StorageClass is; Install the operator explains the split between the shipped wiring classes and this one, and its Generic or specific section shows a class that names one screen. This generic class alone allocates any output that has a monitor. To name one screen from a claim instead, add a selector on the attributes below, as Put a window on a screen shows.

The other three classes from the install guide, display-gpu, display-render, and display-i2c, are not for consumers. They select the raw card node, the render node, and the card’s monitor-control wires that liken’s own driver publishes, and the operator’s own pod claims one of each. The pod cannot start without them. Devices in the liken manual describes those raw devices.

The attributes

The device name is the connector in lowercase, because a DRA device name must be a DNS label. Every attribute except connector and appId comes from the monitor’s EDID, which the operator reads from sysfs.

Attribute Type What it is
connector string the kernel’s connector name: HDMI-A-1
appId string the connector in lowercase, delivered as DISPLAY_APP_ID: hdmi-a-1. Nothing routes by it, and the device class selects on it to keep the class on outputs
manufacturer string the EDID’s three-letter PNP id: GSM is LG
model string the monitor name the EDID states
serial string the serial the EDID states
widthPixels, heightPixels int the preferred mode, which the compositor drives unless a claim states another
refreshMillihertz int the preferred mode’s refresh rate, in millihertz: a selector that wants 60 Hz exactly asks for 60000, and a real monitor may answer 59999
widthMillimeters, heightMillimeters int the panel’s physical size
modes string the resolutions the monitor accepts, described below
currentMode string the mode the output runs right now, described below
physicalAddress string the HDMI-CEC physical address of the port this cable is in, 1.2.0.0, from the EDID’s HDMI vendor block. It follows the EDID on the wire now, so a dark connector publishes none; the Display keeps the last address
monitor.liken.sh/id string the pairing identity, described below
controlsBrightness bool the panel answered the brightness control over DDC/CI, described below
controlsPower bool the panel answered the power control over DDC/CI, described below

A selector reads an unqualified attribute through the driver’s domain: device.attributes["display.liken.sh"].model.

Only connector and appId are always present. The monitor’s attributes leave when the monitor does, and a monitor that states nothing in a field publishes no attribute for it. A selector that reads a missing attribute fails the whole allocation, so guard every monitor attribute first:

has(device.attributes["display.liken.sh"].model) &&
device.attributes["display.liken.sh"].model == "LG HDR WQHD"

The modes

modes holds the resolutions the kernel says the connector can drive, space joined, with the preferred mode first and the rest in descending order. A name carries no refresh rate, so a resolution the monitor accepts at both 60 Hz and 30 Hz appears once. The preferred mode is the same one widthPixels, heightPixels, and refreshMillihertz describe.

The preferred mode is the same one widthPixels, heightPixels, and refreshMillihertz describe, and it is what an output runs until a claim states another one.

The list is one string because a device attribute holds one bool, int, string, or version, and no array. A selector asks with .contains():

has(device.attributes["display.liken.sh"].modes) &&
device.attributes["display.liken.sh"].modes.contains("3840x2160")

The list can be shorter than the monitor’s. The API stops a string attribute at 64 characters, so the value ends on the last whole resolution that fits, and the descending order makes the dropped tail the smallest ones. A monitor that accepts sixteen resolutions publishes about six. Read the attribute as the large modes the monitor accepts, never as the whole list.

A claim selects one of these names with an opaque parameter this driver reads. The operator writes the name into the compositor’s config and restarts the compositor, which is why every Wayland client on every output of that card ends when a claim states a mode. Ask for a mode has the whole recipe and the warning.

spec:
  devices:
    config:
      - opaque:
          driver: display.liken.sh
          parameters:
            mode: "1280x720"

This driver reads three parameters, mode, brightness, and power, and a key it does not read fails the prepare, so a typo stops the pod instead of running a mode nobody asked for. The value of mode is a resolution name, spelled exactly as the kernel spells it, with an optional refresh after an @: 1280x720 takes whatever rate the compositor picks for that name, and 3840x1600@24 asks for the 24 Hz timing. The refresh is a whole number of hertz. @59.94 fails the prepare, because the compositor reads a refresh as an integer and a fraction would fall back silently.

The mode is validated against the connector’s own kernel mode list and never against this attribute, so a mode the attribute’s 64-character cut dropped is still a mode a claim can ask for. A name the connector does not offer fails the prepare, and the failure names the whole list. A refresh the connector does not offer for that name also fails the prepare, and the failure names the refreshes that exist. The whole list, with every refresh, is on the panel’s Display under status.modes, which has no length limit.

A DeviceClass can carry the same block as cluster policy. The scheduler resolves the class’s config and the claim’s into one list on the allocation and marks each entry’s source, and the claim’s own choice wins over the class’s, whichever order the two are listed in.

The current mode

currentMode is the mode the output runs right now, with its refresh: 3840x1600@24. It is read from the card itself with the DRM GETCRTC ioctl on every reconcile pass, and the name stands alone when the card reports no refresh. It follows a claim’s mode, and it is what shows the mode a released claim left behind, because releasing a claim restarts nothing.

The attribute is absent while the output drives nothing, which covers a connector with no monitor and one the compositor left disabled, and absent when the card could not answer the ioctl. It is also absent while the operator holds no connection to a compositor, for example during a compositor restart. The operator opens the card only while it holds a connection to a compositor, so that its read cannot take DRM master from the compositor. Guard it with has(), like every other monitor attribute.

The pairing identity

monitor.liken.sh/id pairs a screen with that screen’s speakers, which the audio operator publishes from the same monitor’s HDMI ELD. Both drivers build the value the same way, byte for byte, because the scheduler compares them under a matchAttribute constraint. The value is the lowercase PNP id, the four-digit hexadecimal product code, then the lowercase monitor name with each run of spaces turned to one dash. An LG ultrawide reads gsm-5b09-lg-ultrawide. A monitor with no name keeps the two-part form, boe-095f. Two monitors of one model share one value, so either pairing satisfies a constraint.

The attribute has its own domain because an unqualified name belongs to the driver that published it. A bare model here and a bare model in the audio driver’s slice would never match.

The identity needs the manufacturer, so a monitor whose EDID states no readable manufacturer publishes no monitor.liken.sh/id at all, even while it is connected. A value without the manufacturer would match every other monitor that also states none. Guard a selector on this attribute with has(), like every other monitor attribute.

The panel’s controls

A monitor’s brightness and power live in the panel, not in the graphics card, and DDC/CI is the channel that reaches them: a slow serial protocol on two wires of the display cable, speaking to the same settings as the buttons on the monitor’s bezel. Support is per panel. Of two monitors on one lab machine, one answers the protocol and one refuses it, and a panel that answers at all may still carry only some controls.

So the operator asks instead of assuming. At inventory it probes each connected panel, read-only: it reads the panel’s own feature list, the MCCS capability string, and asks each declared core control for its value. Two of the answers publish on the devices as attributes:

Each attribute is present and true, or absent. The whole list, brightness and power beside contrast, sharpness, color preset, input source, and the audio controls, publishes on the panel’s Display under status.capabilities. The probe runs once per monitor and its answer is cached against the monitor’s EDID, so steady hardware costs no bus traffic. A panel that refused the probe is asked again about once a minute, because DDC/CI can arrive later than the panel: an input switch or a menu toggle turns it on with no event the operator could see.

A claim sets two opaque parameters, beside mode:

spec:
  devices:
    config:
      - opaque:
          driver: display.liken.sh
          parameters:
            brightness: 87
            power: OnWhileClaimed

brightness is a whole number from 0 to 100, a percentage of the panel’s own maximum, because one panel counts its scale to 100 and another to 255. The operator reads the brightness at prepare, sets it when the panel holds another value, and reads it back. A readback that disagrees fails the claim, because a panel acknowledges a write whether it applies the value or not.

power takes two values, On and OnWhileClaimed. It also takes the lowercase on and onWhileClaimed, with the same meaning. On powers the panel on at prepare and never touches it again. OnWhileClaimed also powers the panel back down 30 seconds after the claim ends, for a claimant that owns the screen outright. A Deployment that replaces its pod ends one claim and makes another, so a prepare on the same connector inside the 30 seconds cancels the power-down, and the screen does not blink on a rollout. The operator records the pending power-down in its pod’s config volume, so an operator container restart inside the 30 seconds starts the period again. The power-down reads the power mode first and writes nothing to a panel that is already down. It writes standby first and falls back to off, because some panels implement only a subset of the power values.

At prepare, the operator reads the power mode before it writes it, and a panel that is already on takes no write. A panel that does not answer the read takes no write, and the prepare fails with the read’s error.

For a claim, the operator writes to a panel only when the claim states one of these parameters. A claim with no parameters changes nothing, at prepare or after. The panel’s Display is the declarative write path beside this one: a resting declaration or an override there is also written by the operator, and only the operator.

The scheduler reads no opaque parameter, so a selector is what keeps a claim off a panel that cannot serve it. Select on the attribute that matches the parameter you state:

has(device.attributes["display.liken.sh"].controlsBrightness) &&
device.attributes["display.liken.sh"].controlsBrightness

A claim that states a parameter anyway, on a panel with no matching attribute, fails at prepare with the missing capability named.

The control device

A connector whose panel answered at least one control publishes a second device, named after the output with a -control suffix: hdmi-a-2 and hdmi-a-2-control. Its claim is for a pod that drives the panel itself while it runs, with its own ddcutil and its own protocol code, rather than a value stated once at prepare. For a panel that only needs its settings held or temporarily overridden, the Display does the same job with no wire handover: state the desire on the resource, and the operator writes it.

The control device’s attributes are connector, the monitor.liken.sh/id pairing identity, the two control booleans, and control, a marker that is always true. A class selects on the marker:

apiVersion: resource.k8s.io/v1
kind: DeviceClass
metadata:
  name: display-control
spec:
  selectors:
    - cel:
        expression: |
          device.driver == "display.liken.sh" &&
          has(device.attributes["display.liken.sh"].control)

Like display-output, this class is yours to create and to narrow; the operator ships no consumer class. The pairing identity is on both of the connector’s devices, so one claim takes a screen and its control channel with a matchAttribute constraint across its two requests.

A prepared control claim delivers two things and performs no write:

What Value
device node the connector’s /dev/i2c-N, read-write
DISPLAY_CONTROL_BUS that node’s path

Read the bus from the variable, never guess the number: the kernel numbers i2c adapters in the order it registers them, and the number holds for one boot only.

The three parameters above act on outputs, and a control request takes none of them. A config block that names no request applies to every request in a claim, so a claim that asks for a screen and its control channel together must name the screen’s request on the block that states the parameters, or the prepare fails.

Do not write to any i2c address other than 0x37, the DDC/CI address. The node is the connector’s whole i2c bus, and the monitor’s EDID EEPROM answers on the same bus at address 0x50. On some panels that EEPROM accepts writes, and a write corrupts the monitor’s identity block on every machine it ever plugs into, until someone reprograms the chip. Tools built for DDC/CI, such as ddcutil, stay on the right address.

One writer per wire. The operator itself writes this bus for two of its surfaces, a claim on the same connector that states brightness or power, at prepare and at unprepare, and the panel’s Display , whenever its spec diverges from the panel. The i2c layer does not arbitrate between two userspace writers. So on a screen whose control device a pod holds, the holder owns the panel: no claim states power, no Display spec declares a resting value, and no writer sets an override there. A claim may still state brightness. That write lands once at the claim’s prepare, and a holder that dims the panel restores the value it last read from the panel, so the two writers never pull in opposite directions.

The operator’s own exchanges with one panel take turns. A claim’s prepare, the Display pass, the probe, and a restore each wait for the bus, and each waits 50 ms after the last one ends, because a panel holds one reply at a time and needs that gap before the next message.

The control device exists only while a monitor is connected. It takes the disconnected taint when a different monitor replaced the one the connector carried, the same as the output beside it. It does not take the taint when no compositor answers, because the i2c bus reaches the panel with no compositor running. A claim that holds only control devices prepares while the compositor restarts, and its pod keeps running through the restart. A claim that also holds an output or a draw device waits for the compositor, because those devices deliver a Wayland socket.

The taint

display.liken.sh/disconnected, with effect NoExecute, is the one taint a device has, and it means the output can serve nobody right now. It appears in two cases:

A connector that carried a monitor and then went dark is not one of them. It keeps that monitor for as long as it stays dark, and the slice keeps publishing the monitor’s identity, so the claim on that screen still allocates and the pod on it keeps running. A monitor that shows another input drops hot plug detect, and the kernel reports that exactly as it reports a cable somebody pulled out. An A/V receiver does the same for about eighty seconds on every input change. Nothing on the wire tells the two apart, so the operator keeps the screen and the Connected condition on the Display reports what the wire says.

The cost is that a monitor somebody really unplugged keeps its devices claimable, and the pod on it keeps drawing into nothing. Read Connected on the Display to see the wire.

A consumer tolerates it with a tolerationSeconds, which is how long the pod may hold a dark screen before the eviction controller ends it. Thirty seconds covers a reseated cable and an operator restart. The operator taints a device and never deletes it: the allocation on a running claim names the device, and a device removed from the slice would strand the kubelet’s prepare retries.

What a prepared claim delivers

An output’s delivery is a mount and three environment variables, which the runtime applies to the container. It has no device node, because a Wayland client draws through the compositor, which holds the card. A control device’s delivery is the node and the variable the control device section lists.

What Value
mount /var/run/display.liken.sh, read-write, the same path as on the host
XDG_RUNTIME_DIR /var/run/display.liken.sh
WAYLAND_DISPLAY wayland-<the claim's UID>, a socket the compositor opened for this claim
DISPLAY_APP_ID the allocated output’s name; nothing reads it, and a later release stops delivering it

The socket is the identity. A window that arrives on it belongs to this claim, and nothing in the container can name another claim’s socket.

A claim that allocates two outputs into one container delivers two sockets and two app-ids, and only the last of each survives. One container drives one screen; a pod that drives two screens runs two containers, each naming its own request.

The slice’s lifetime

The operator creates its slice on the first pass, rewrites it when it differs from sysfs, and never deletes it. The Node owns the slice, so a node that leaves the cluster takes the slice with it. The slice outlives the operator’s pod on purpose, so removing the operator for good ends with:

kubectl delete resourceslice <node>-display.liken.sh