Devices

This page describes what the operator publishes and what a claim on it delivers: the devices, their attributes and taints, the two device classes, and the objects of the pairing API. The operator is a Dynamic Resource Allocation (DRA) driver named bluetooth.liken.sh, and it publishes beside liken’s own driver on the same node.

The slice

The operator writes one ResourceSlice per node, named <node>-bluetooth.liken.sh, beside liken’s own <node>-liken.sh:

$ kubectl get resourceslice liken-1-bluetooth.liken.sh -o yaml
spec:
  driver: bluetooth.liken.sh
  nodeName: liken-1
  devices:
    - name: a0-ab-51-33-b7-12
      attributes:
        address: {string: "A0:AB:51:33:B7:12"}
        connected: {bool: true}
        name: {string: "DualSense Wireless Controller"}
        classOfDevice: {int: 9480}
        majorClass: {string: peripheral}
        minorClass: {string: gamepad}
        addressType: {string: public}
        icon: {string: input-gaming}
        input: {bool: true}
    - name: 04-4a-69-66-92-27-media
      attributes:
        address: {string: "04:4A:69:66:92:27"}
        kind: {string: mediaBus}
        sound.liken.sh/supportsSound: {bool: true}

The slice holds two kinds of device: one for each paired controller, and one media bus for the adapter itself.

The controller list follows the paired set, whether or not each controller is connected. A paired controller that is switched off still publishes, so a pod can claim it and start when somebody turns it on. A controller leaves the slice only when it is unpaired, which is a kubectl delete pairing.

The media bus publishes as soon as bluetoothd names the adapter, so the slice exists on a machine with a radio and nothing paired to it. The whole slice is deleted only while no adapter has answered and nothing is paired.

The device name is the controller’s MAC in lowercase with dashes, because a DRA device name must be a DNS label. The media bus takes the adapter’s own MAC in the same form, with a -media suffix. The MAC is the only identity on the machine that survives a reboot: the HID instance suffix in sysfs counts up from zero each boot, and the hci0:N handle changes on every reconnect, so a claim against either would allocate different hardware after a reboot.

The attributes

This section covers the paired controllers. The media bus carries its own three attributes, listed in its section.

A selector reads these as device.attributes["bluetooth.liken.sh"].<name>.

Two attributes are on every controller this operator publishes, in every state, the departed-adapter republish included:

Attribute Type What it is
address string the controller’s MAC, uppercase with colons: A0:AB:51:33:B7:12
connected bool whether bluetoothd holds a connection to it now

Every other attribute is present only when BlueZ reports the fact. The identity facts publish in two layers: the raw code, and the names and flags unpacked from it, so a selector never does bit arithmetic:

Attribute Type What it is
name string the controller’s alias in BlueZ, cut to 64 characters
classOfDevice int the raw 24-bit class word from the inquiry response
appearance int the LE appearance value; an LE-only device often reports this and no class word
modalias string the PnP vendor and product, as in bluetooth:v000ApFFFFdFFFF, cut to 64 characters
icon string BlueZ’s own class-to-icon name, such as audio-headphones or input-gaming
addressType string public or random
majorClass string class bits 12 to 8 as a name: audio-video, peripheral, phone, and the other assigned majors
minorClass string class bits 7 to 2, read under the major: headphones, gamepad, smartphone
servicePositioning, serviceNetworking, serviceRendering, serviceCapturing, serviceObjectTransfer, serviceAudio, serviceTelephony, serviceInformation bool one flag per service bit the class word sets, bits 16 to 23

The profile flags come from the service UUIDs the device advertised when it paired. Each one is true when the profile is advertised and absent otherwise, and a UUID outside this vocabulary publishes nothing:

Attribute The profile
audioSink A2DP sink: the device plays audio
audioSource A2DP source: the device sends audio
avrcpTarget the device takes play, pause, and volume
avrcpController the device sends play, pause, and volume
handsfree HFP, the hands-free microphone profile
headset HSP, the headset microphone profile
input HID, classic or over GATT: the device is an input device
battery the device reports a battery level
serialPort raw RFCOMM serial

The split between always and absent is a contract. The operator omits an attribute it has no value for, rather than publishing it empty. A selector’s read of an absent attribute does not evaluate to false; it fails, and the failure can abort the allocation instead of skipping the device. So a selector on anything past address and connected must guard the read:

has(device.attributes["bluetooth.liken.sh"].name) &&
device.attributes["bluetooth.liken.sh"].name.startsWith("DualSense")

A selector that reads only address or connected needs no guard, because the bluetooth-input class already limits the candidates to this driver’s paired input devices, and both attributes are always on them. Outside that class, the media bus carries address and no connected, so connected takes a guard like any other attribute.

The taints

Two taints go on a controller that cannot serve a claim, and they answer two different questions:

Taint Effect When
bluetooth.liken.sh/disconnected NoExecute bluetoothd reports the controller disconnected, or it registers no evdev node, or the adapter itself has departed
bluetooth.liken.sh/no-input-node NoSchedule the controller registers no evdev node, or the adapter itself has departed

The media bus takes one taint, and only when the adapter has departed:

Taint Effect When
bluetooth.liken.sh/disconnected NoSchedule the adapter has departed, so nothing answers on the bus

The effect differs from the controllers’ NoExecute on purpose. The pod that holds the bus is the machine’s one sound server, so an eviction would end its other playback too, and that playback does not need the radio. NoSchedule parks the next claim and leaves the running holder alone.

Tolerate /disconnected only. The NoExecute taint evicts a claim holder after its tolerationSeconds, so tolerating it sets how long a radio may be silent before the pod ends. The NoSchedule taint must stay untolerated, because it parks a claim on a switched-off controller as Unschedulable. Tolerate both and the scheduler allocates a controller with no evdev node, NodePrepareResources fails, and the pod churns between ContainerCreating and eviction for as long as the controller stays off.

The media bus

The media bus is one device per adapter: the claimable permission to connect to this pod’s bluetoothd over its private D-Bus. A Bluetooth speaker creates no kernel device. Its audio exists only while a sound server holds this bus and keeps a media endpoint registered, and BlueZ advertises no A2DP until an endpoint registers.

The audio operator claims the bus through sound.liken.sh/supportsSound, the attribute liken also stamps on each sound card it publishes. That operator’s class names the attribute and no driver, so one claim collects every device on a node that can serve a sound server. This operator ships no class for the bus and runs no sound server itself.

Attribute Type What it is
address string the adapter’s own MAC, uppercase with colons
kind string mediaBus
sound.liken.sh/supportsSound bool always true

sound.liken.sh/supportsSound is the one qualified name in this driver’s attributes. It lives in a domain neither driver owns, so a selector reads it as device.attributes["sound.liken.sh"].supportsSound, where every other attribute here reads under bluetooth.liken.sh.

The bus never carries input, so the bluetooth-input class, which guards on that attribute, never matches it.

The device is exclusive, which is resource.k8s.io/v1’s default: one radio serves one sound server, because two media endpoints registered on one bluetoothd have no contract over the streams.

A claim on the bus delivers a read-only mount of /var/run/bluetooth.liken.sh/dbus at the same path inside the container, and one environment variable that names the socket in it. No device node, no privilege, and no other host path.

DBUS_SYSTEM_BUS_ADDRESS=unix:path=/var/run/bluetooth.liken.sh/dbus/system_bus_socket

The device classes

The operator takes two DeviceClasses, and they split by owner:

DeviceClass Selector Who claims it
bluetooth-input device.driver == "bluetooth.liken.sh" and the input attribute, guarded your workloads, one paired input device each
bluetooth-adapter device.driver == "liken.sh" && device.attributes["liken.sh"].driver == "btusb" the operator’s own pod, for the raw radio

bluetooth-adapter ships with the deploy base, because the operator’s own claim template names it and the pod cannot start without it. bluetooth-input is yours to create, because a class a workload claims through is cluster policy, and Install the operator gives its YAML. It selects the input attribute rather than the whole driver, because the driver publishes more than input devices: a paired speaker publishes as its bond record, no workload should hold one, and the media bus belongs to the machine’s sound server.

The claim

A ResourceClaim against bluetooth-input alone allocates any paired input device. To name one, add a selector on its address:

spec:
  devices:
    requests:
      - name: controller
        exactly:
          deviceClassName: bluetooth-input
          selectors:
            - cel:
                expression: |
                  device.attributes["bluetooth.liken.sh"].address == "A0:AB:51:33:B7:12"
          tolerations:
            - key: bluetooth.liken.sh/disconnected
              operator: Exists
              effect: NoExecute
              tolerationSeconds: 30

Pair a controller and give it to a pod gives the whole flow, with the pod that takes the claim. In a Deployment, claim through a ResourceClaimTemplate rather than a standing ResourceClaim, because a standing claim keeps its allocation across an eviction.

What a claim delivers

What a claim delivers depends on the device it allocated. Both kinds arrive the same way, through the Container Device Interface (CDI) at container creation, and neither delivers any privilege.

A claim on a controller delivers device nodes, and nothing else: /dev/input/event* for the one controller the claim allocated. No host mount, no environment variable. The container’s user must be able to open the nodes. A claim on the media bus delivers the mount and the variable that The media bus lists, and no device node.

The legacy /dev/input/jsN interface stays out. liken’s kernel may not enable CONFIG_INPUT_JOYDEV at all, and joydev publishes a DualSense’s motion sensors as a wrong second jsN device.

A running pod’s device set never changes. The runtime injects the nodes when it creates the container, so the pod is one session, and the NoExecute taint ends it. A controller that reconnects usually returns as a different eventN, and the operator rewrites the claim’s CDI file so the next pod receives the node that exists now.

Lifecycle

The pairing API

Three CustomResourceDefinitions, group bluetooth.liken.sh/v1alpha1, each with its own reference page: an Adapter is one radio, a Pairing is one bond, and a PairingRequest is one pairing window. A person creates a PairingRequest, edits a Pairing’s spec, and deletes a Pairing to unpair; the operator creates and reconciles everything else.

Where the bonds are stored

One Secret for each bond, in the operator’s namespace, named bluetooth-bond-<device> after the controller’s MAC. Each Secret holds the two files BlueZ keeps for the bond, byte for byte, and a bluetooth.liken.sh/adapter label naming the radio the bond is keyed to. The bonds follow the radio: a dongle carried to another machine takes its bonds with it, because the pod that claims it there lists the same Secrets.

The keys are in the cluster datastore. Whether they are encrypted at rest is a property of the cluster, not of this operator. Without encryption at rest the keys are base64 in the datastore and its backups.