Install and verify the operator

This guide installs bluetooth-operator on a liken cluster and verifies that its pod claims the radio. The operator is an ordinary workload. Everything it needs is in one kustomize base, and nothing here touches a machine over SSH.

What you need

Create the device classes

A DeviceClass is cluster-scoped policy, the same convention a StorageClass follows: the cluster owner names and curates the classes workloads may ask for. The classes split by owner. If the DRA objects are new to you, read How a claim reaches your pod first.

The guard on the input attribute also keeps the adapter’s media bus out of this class. The bus is the audio operator’s to claim, through a class of its own that names the shared sound.liken.sh/supportsSound attribute.

Generic or specific

A class is the cluster’s vocabulary for a kind of device, and you choose its grain. A generic class such as bluetooth-input matches every paired input device: the class list stays short, and each claim picks its controller with a CEL selector. A specific class holds the selector itself. This one matches exactly one controller. A claim then names the class and writes no CEL, and you make the choice once, in cluster policy you control:

apiVersion: resource.k8s.io/v1
kind: DeviceClass
metadata:
  name: player-one-dualsense
spec:
  selectors:
    - cel:
        expression: |
          device.driver == "bluetooth.liken.sh" &&
          device.attributes["bluetooth.liken.sh"].address == "A0:AB:51:33:B7:12"

Start generic. When several workloads repeat the same selector, or when you want the choice in cluster policy rather than in each workload’s manifest, create a specific class.

Apply the manifests

This site serves the repository’s manifests as raw YAML under /deploy/ , so you can install from here without a clone. Apply the four files into liken-system, the namespace a liken cluster already has:

kubectl apply -n liken-system \
  -f https://liken.sh/bluetooth/deploy/deviceclasses.yaml \
  -f https://liken.sh/bluetooth/deploy/crds.yaml \
  -f https://liken.sh/bluetooth/deploy/rbac.yaml \
  -f https://liken.sh/bluetooth/deploy/operator.yaml

Or point your own GitOps at the same files with a Kustomization:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: liken-system
resources:
  - https://liken.sh/bluetooth/deploy/deviceclasses.yaml
  - https://liken.sh/bluetooth/deploy/crds.yaml
  - https://liken.sh/bluetooth/deploy/rbac.yaml
  - https://liken.sh/bluetooth/deploy/operator.yaml

The site serves the manifests of the current main, and the images in operator.yaml name :latest. To pin a release instead, reference the operator’s kustomize base at the operator’s version, which is the release tag that last published it. The GitHub release for each tag lists every component and its version. One tag versions the manifests and the three images together, so pin all four to the same version:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - https://github.com/liken-sh/liken//bluetooth-operator/deploy?ref=2026.09.29-002
images:
  - name: ghcr.io/liken-sh/bluetooth-operator
    newTag: 2026.09.29-002
  - name: ghcr.io/liken-sh/bluetoothd
    newTag: 2026.09.29-002
  - name: ghcr.io/liken-sh/bluetooth-bondfetch
    newTag: 2026.09.29-002

Whichever path you take, the manifests contain:

Keep the pods off nodes with no Bluetooth adapter

The DaemonSet makes a pod on every node. On a node with no Bluetooth adapter, the claim matches no device, and the pod stays Pending. To make no pod on such a node, label the node bluetooth.liken.sh/bluetooth: none.

The DaemonSet in the base carries this node affinity, so no patch is needed:

affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
        - matchExpressions:
            - key: bluetooth.liken.sh/bluetooth
              operator: NotIn
              values: ["none"]

NotIn matches a node whose label has a different value, and also a node that has no such label. The Kubernetes page on set-based requirements gives this rule. So with no label the DaemonSet makes a pod on every node, and a node labeled none gets no pod. When a label changes, the DaemonSet controller deletes the pod from a node that no longer matches, and adds one to a node that matches again.

Declare the label in the node’s Machine, so the label is part of the record of the machine:

spec:
  nodeLabels:
    bluetooth.liken.sh/bluetooth: none

A Machine accepts a key in a liken.sh subdomain from liken 2026.09.28-002 on, and an older release refuses it, so on an older release use the kubectl label way below.

The liken machine operator applies the label to the running node. A machine that is demoted or installed again registers a new Node, and the Node has the label from registration. To run the pod on that node again, remove the key from spec.nodeLabels, and liken removes the label from the node. liken also removes a label that you set with kubectl before the Machine declared it, when the key leaves the spec.

You can also label the node with kubectl:

kubectl label node node-1 bluetooth.liken.sh/bluetooth=none

The label stays on the node across reboots. liken leaves it in place, because liken removes only the labels that a Machine declared. The label goes with the Node object: a machine that is demoted or installed again registers a new Node, and you label it again. To run the pod on that node again, remove the label:

kubectl label node node-1 bluetooth.liken.sh/bluetooth-

A patch of your own that sets a node affinity on this DaemonSet replaces the list of terms in the base, and the none term with it. Copy the none requirement into each term of your patch.

Running a development build

A push to main that changes the operator publishes a development build of it. Its version is the most recent release tag plus a suffix: 2026.09.03-007-dev-003-abcdef01 is three commits past release 2026.09.03-007, at commit abcdef01. Every image of the operator has the same version, and :latest still names the most recent release.

A development build has no git tag, so the manifests pin to the commit’s full sha, and the image pins to the version:

resources:
  - https://github.com/liken-sh/liken//bluetooth-operator/deploy?ref=<full 40-character sha>
images:
  - name: ghcr.io/liken-sh/bluetooth-operator
    newTag: 2026.09.03-007-dev-003-abcdef01
  - name: ghcr.io/liken-sh/bluetoothd
    newTag: 2026.09.03-007-dev-003-abcdef01
  - name: ghcr.io/liken-sh/bluetooth-bondfetch
    newTag: 2026.09.03-007-dev-003-abcdef01

A git fetch by sha needs all forty characters; the eight in the version are not enough. The summary of the CI run for that commit gives the version.

The same build publishes the manifests as the OCI artifact oci://ghcr.io/liken-sh/bluetooth-operator-deploy:<version>, with each image of the operator set to that version. A Flux OCIRepository can pull that artifact by the version, with no sha.

How the pod finds the radio

The DaemonSet puts a pod on every node, and each pod claims one bluetooth-adapter device. On a node with an adapter the claim matches and the pod runs. On a node with no adapter the claim matches nothing, so the pod parks Pending and costs nothing. A node labeled bluetooth.liken.sh/bluetooth: none gets no pod, as Keep the pods off nodes with no Bluetooth adapter describes. Nobody writes down which machine has the radio, and a dongle moved to another machine works there on the next pod start.

The claim also makes the pod the only Bluetooth stack on that radio, because liken publishes the adapter as a device that allocates once. The kernel arbitrates nothing between two stacks on one adapter.

When the bluetoothd container starts, it powers every adapter off at the kernel before bluetoothd runs, and bluetoothd then powers each adapter on. A bluetoothd that was killed or crashed leaves its links up, and a new bluetoothd that inherits such a link marks the device connected but never opens its input, so the controller sends nothing. The power-off ends those links. Each controller connects again on its next press, the same as after any restart of the pod. The bluetoothd container’s log has a line for each adapter:

kubectl -n liken-system logs ds/bluetooth-operator -c bluetoothd

Verify

kubectl get pods -n liken-system -l app=bluetooth-operator

A healthy install has one Running pod on each machine with an adapter. Each machine without one has a Pending pod, or no pod when its node is labeled bluetooth.liken.sh/bluetooth: none. Then read the radio the operator holds:

$ kubectl get adapters
NAME                ALIAS   ADDRESS             NODE      POWERED   PRIVACY   BTMON   AGE
04-4a-69-66-92-27           04:4A:69:66:92:27   liken-1   true      off       false   1m

The operator creates an Adapter object for the radio its pod claimed, named for the radio’s address. The ResourceSlice of paired controllers appears when the first controller is paired: Pair a controller and give it to a pod is the next step.

Read the Events

The operator posts a Kubernetes Event when a window opens or closes, a device pairs, a link comes up or ends, a bond, a relay, or the radio fails, the operator powers the adapter off and on to recover a controller, a change of privacy restarts the pod, or the trace turns on or off. Read them with kubectl describe on the object. A Peripheral, an Adapter, and a Node are cluster-scoped, so their Events are in default, and kubectl events --for finds them only with -n default or -A:

kubectl describe pairingrequest new-gamepad -n liken-system
kubectl events -n default --for peripheral/a0-ab-51-33-b7-12
Reason Type Object What happened
PairingWindowOpened Normal PairingRequest The radio is discoverable and pairable until the window closes.
PairingWindowExpired Normal PairingRequest The window closed with no device paired.
PairingRefused Warning PairingRequest bluetoothd refused to pair the approved device. The window tries again, and the Event repeats only when the refusal changes.
Paired Normal PairingRequest, Peripheral The device holds a bond, and the operator created its Peripheral.
BondLost Warning Peripheral bluetoothd holds no bond with the device any more. The Peripheral stays until you delete it.
LinkUp Normal Peripheral The device connected.
Asleep Normal Peripheral The link timed out on a device that sleeps between sessions, such as a remote after an idle hour.
LinkLost Normal Peripheral The link timed out on a device that does not sleep: it went out of range, or its battery ran out.
ClosedByDevice Normal Peripheral The device ended the link: it was switched off, or it disconnected.
ClosedByRadio Normal Peripheral This radio ended the link, at an unpair or at the input recovery.
AuthenticationFailed Warning Peripheral The link ended because the keys did not match. Delete the Peripheral and pair the device again.
NotConnected Normal Peripheral The link ended, and the operator received no reason from bluetoothd.
NotBonded Normal Peripheral bluetoothd holds no object for the device. BondLost follows when it held a bond before.
InputRelayFailed Warning Peripheral The operator could not make the virtual input device that a claim on the controller receives.
AdapterPowerCycled Normal Peripheral bluetoothd did not disconnect a controller that has a link and no input, so the operator powered the adapter off and on. Every link on the adapter dropped.
AdapterPowerOnFailed Warning Peripheral The operator powered the adapter off to recover the controller, and bluetoothd did not power it on again. Delete the operator’s pod on that node to power the adapter on.
RadioClaimed Normal Node bluetoothd in the pod reports the radio.
RadioLost Warning Node The radio is gone from bluetoothd: the adapter was unplugged or reset.
PrivacyChanged Normal Adapter spec.privacy differs from the value bluetoothd started with, and the operator deletes its own pod so that bluetoothd starts with the new value.
BtmonChanged Normal Adapter spec.btmon differs from the value the btmon container reads, and the operator wrote the new value, so the container starts or stops its trace.

The reasons from LinkUp to NotBonded are the reasons of the Peripheral’s Connected condition, and each change of the condition posts one Event. A Low Energy remote ends its link after an idle hour, so a remote posts about two Events for each session. The bluetooth_disconnects_total metric counts the same changes by reason. A reconnect of a controller that holds a link with no input posts no Event, and the operator container’s log records it. A power cycle of the adapter for the same controller posts AdapterPowerCycled, as Pair a controller describes. The API server deletes an Event an hour after its last write. The status and the pod’s log keep each fact longer.

Inspect the Bluetooth stack

The bluetoothd image holds three tools for a person. Each runs as a direct kubectl exec, with no shell between, and every one of them needs the -i flag. BlueZ’s shells attach to their standard input. With stdin closed the attach fails, and the command never runs and prints nothing.

btmgmt info prints the adapter’s management settings, and its current settings line is where Connectable, Discoverable, and Bondable read. dbus-send calls any method on org.bluez. bluetoothctl list names what the daemon holds.

kubectl -n liken-system exec -i ds/bluetooth-operator -c bluetoothd -- btmgmt info
kubectl -n liken-system exec -i ds/bluetooth-operator -c bluetoothd -- bluetoothctl list

One limit: the image has no shell, and BlueZ’s argument parser runs one, so bluetoothctl and btmgmt refuse every command that takes an argument (“Unable to parse mandatory command arguments”). Only their no-argument commands work: bluetoothctl list, and btmgmt info, extinfo, con, keys, and ltks. dbus-send has no such limit, so use it to reach anything else. To connect one device by hand:

kubectl -n liken-system exec -i ds/bluetooth-operator -c bluetoothd -- \
  dbus-send --system --print-reply --dest=org.bluez \
  /org/bluez/hci0/dev_A0_AB_51_33_B7_12 org.bluez.Device1.Connect

The pod’s btmon container can trace the HCI link, the layer under D-Bus and under bluetoothd. The trace shows a disconnect reason or a retransmission that no higher layer reports. The trace is the container’s log, so it stays readable after a roll of the pod replaces the processes that saw a fault.

The trace is off until you turn it on, because btmon prints key material in plain text: the link keys, the long term keys, and the radio’s identity key. Anybody who can read the operator pod’s logs can read those keys. Turn it on for one radio while you diagnose a fault, with spec.btmon on its Adapter:

kubectl patch adapter <adapter> --type merge -p '{"spec":{"btmon":true}}'

The trace starts within seconds, and the pod does not restart, so no controller disconnects. A radio whose Adapter has spec.btmon: true when its pod starts is traced from the start of bluetoothd, so the trace also shows the commands where bluetoothd tells the kernel which bonded devices may reconnect. Read the trace on one machine:

kubectl -n liken-system logs -c btmon --timestamps \
  $(kubectl -n liken-system get pods -l app=bluetooth-operator \
      --field-selector spec.nodeName=<node> -o name)

Turn it off when you have what you need, with false in place of true. The lines already in the log stay there until the kubelet rotates the log or the pod goes away.

The privilege it takes

The pod is four containers, and the privilege is confined to two of them. The bluetoothd container takes four capabilities (NET_ADMIN, NET_BIND_SERVICE, SETUID, SETGID), because it is the Bluetooth stack. The btmon container takes only NET_RAW, because it binds the kernel’s HCI monitor channel, and that bind tests CAP_NET_RAW. Every container shares the pod’s hostNetwork. The operator and bondfetch containers drop every capability. The comments in deploy/operator.yaml state the kernel or daemon check behind each grant.

The pod mounts four host paths: the two kubelet plugin directories every DRA driver takes, /var/run/cdi, and /var/run/bluetooth.liken.sh/dbus, which holds the D-Bus socket a claim on the media bus delivers. The bus directory is a host path so that a prepared claim names the same socket across a restart of this pod.

Uninstall

Delete the workload. The published ResourceSlice stays, because the operator does not retract it on shutdown. Its pod restarts for ordinary reasons while consumers hold prepared claims. The Node owns the slice, so a node that leaves the cluster takes it along. To remove it now:

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