Pair a controller and give it to a pod

This guide pairs a game controller with kubectl and hands it to one pod. The example is a DualSense and a game in a namespace named arcade, on a liken cluster with the operator installed . Every step is a Kubernetes API call, so RBAC controls who may do each one, and nobody needs a shell on a node or in a pod.

Use the pairing command

liken plugins sync installs the kubectl liken bluetooth plugin, as Install the plugins describes. kubectl liken bluetooth pair runs steps 1 through 3 from a laptop. It opens a window on the radio, lists the devices the radio reports as they appear, and approves the one you pick. It drives the same PairingRequest flow the numbered steps write by hand, and it reads your kubeconfig, so RBAC governs it like every other call. The steps below are the way to script the flow or to read each object it writes.

The command takes these flags:

Flag What it does
--adapter The adapter to open the window on. The first argument after pair does the same. With neither, the command uses the cluster’s only adapter and stops with an error when the cluster has none or more than one.
--window How long the window stays open, in seconds. With no value, the PairingRequest takes its default of 180, and the range is 15 to 900.
-n, --namespace The namespace of the PairingRequest. The default is liken-system.
--force Silences the warning that the CLI’s version differs from the operator’s. The command runs either way.
--version Prints the CLI’s version and exits.

1. Open a pairing window

Read the name of the adapter first. It is the radio’s address in lowercase with dashes:

kubectl get adapters

Then create a PairingRequest for it:

kubectl apply -f - <<'EOF'
apiVersion: bluetooth.liken.sh/v1alpha1
kind: PairingRequest
metadata:
  name: new-gamepad
  namespace: liken-system
spec:
  adapter: 04-4a-69-66-92-27
  windowSeconds: 180
EOF

The operator opens a window on that radio. It scans, and the radio stays pairable and discoverable, for windowSeconds. The default is 180, and the range is 15 to 900. Between windows the radio is neither pairable nor discoverable, so nothing pairs with the cluster unless somebody asked for a window.

2. Put the controller in pairing mode and read what the radio reports

On a DualSense, hold Create and PS until the light bar flashes. Then read the request:

kubectl get pairingrequest new-gamepad -n liken-system -o yaml

A device appears in status.seen when the scan finds it and the cluster holds no bond with it. The entry has its address, its name, and when the radio first observed it.

3. Approve the device

Approval is a write to the request’s spec:

kubectl patch pairingrequest new-gamepad -n liken-system \
  --type merge -p '{"spec":{"device":"A0:AB:51:33:B7:12"}}'

The operator pairs that device, trusts it, records the bond as a Peripheral, and closes the window. Trust lets a later connection run with no agent registered. It does not make the device connect. What starts the connection depends on the device. A controller connects when you press its own button. The operator connects a speaker itself, whenever the speaker is powered on and in range. It retries a failed attempt, and the wait between attempts doubles from 10 seconds up to two minutes.

The request’s status.phase goes to Paired. When bluetoothd refuses the pairing, status.message gives its error, and kubectl describe pairingrequest shows a PairingRefused warning. The install guide lists every Event the operator posts. A request nobody approves only scans. An empty spec.device never pairs anything, and the window expires on its own. The finished request is collected after spec.ttlSecondsAfterFinished, a day by default.

To re-pair a device the cluster already records, set spec.device when you create the request. An address set at creation is an approval in advance.

4. See the published device

The bond is now a Peripheral, its keys are in a Secret the Peripheral owns, and the controller is a device in this node’s ResourceSlice:

$ 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"}

From here on, PS alone reconnects the controller. The keys are in the Secret, so they survive a pod restart, an upgrade, and a reboot.

The Adapter’s spec.privacy affects Low Energy links only, such as a remote’s. A DualSense pairs and reconnects over a classic link, which always uses the radio’s public address. Privacy explains the setting.

5. Claim the controller

If the Dynamic Resource Allocation (DRA) objects are new to you, read How a claim reaches your pod first. Then create a ResourceClaim that selects the controller by its address:

apiVersion: resource.k8s.io/v1
kind: ResourceClaim
metadata:
  name: player-one
  namespace: arcade
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

The toleration sets how long the radio may go silent before the eviction controller ends the pod. Tolerate bluetooth.liken.sh/disconnected and nothing else. Devices explains why the other taint must stay untolerated. Leave out the selector to claim any paired controller.

6. Give the claim to a pod

apiVersion: v1
kind: Pod
metadata:
  name: player
  namespace: arcade
spec:
  resourceClaims:
    - name: controller
      resourceClaimName: player-one
  containers:
    - name: game
      image: ...
      resources:
        claims:
          - name: controller

The container receives device nodes and nothing else: /dev/input/event* for the one controller the claim allocated, which on a DualSense is the gamepad and its motion sensors. No privilege, no host mount, no environment variable. The container’s user must be able to open the nodes.

If the controller is switched off, the pod parks Unschedulable and starts when somebody turns it on. If the controller disconnects while the pod runs, the eviction after tolerationSeconds ends the pod’s session.

In a Deployment, claim through a ResourceClaimTemplate instead of a standing ResourceClaim. A standing claim keeps its allocation across an eviction, so the ReplicaSet’s replacement pods would schedule onto a device that is gone and be evicted at once. A template gives each replacement pod a fresh claim. A fresh claim needs a new allocation, which the taints block.

When a connected controller sends no input

bluetoothd can bring a controller’s link up and never create its HID device. The Peripheral then shows Connected, the device drops its disconnected taint, and the pod receives no presses. The operator finds this state. A paired controller that is connected, has delivered input before, and has no Bluetooth HID device in the kernel is stuck. After 15 seconds in that state, the operator calls Disconnect and then Connect on the controller through bluetoothd, which runs the input profile again.

bluetoothd can also report a link that the kernel no longer has. Then the Disconnect fails or gets no reply, and a Connect would do nothing. So when the Disconnect fails, the operator powers the adapter off and on through bluetoothd instead. The power cycle drops every link on the adapter, not only the stuck one: each controller connects again on its next press, and the operator pages each speaker again. The operator posts an AdapterPowerCycled Event on the stuck controller’s Peripheral. If bluetoothd does not power the adapter on again, the operator posts an AdapterPowerOnFailed Warning, and the adapter stays off until you delete the operator’s pod on that node.

When a reconnect or a power cycle does not bring the HID device back, the operator waits a minute before the next one. The wait doubles after each one that does not help, up to 15 minutes. A device that has never delivered input, such as a speaker that lists a HID profile and never opens it, is never reconnected. The operator posts no Event for a reconnect. The operator container’s log has a line when it starts a reconnect or a power cycle and when it finishes:

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

Why a controller is not connected

kubectl get peripherals shows each bond’s link, the reason of its Connected condition, and how long ago the link came up or ended:

$ kubectl get peripherals
NAME                ALIAS         DEVICE      ADDRESS             BATTERY   PAIRED   CONNECTED   REASON   SINCE   NODE     AGE
c2-11-22-33-44-55   sofa-remote   T6-Remote   C2:11:22:33:44:55   75        true     False       Asleep   42m     node-2   24d

The reason comes from bluetoothd’s report of why the link ended:

Reason What it means
LinkUp The link is up.
Asleep The link timed out on a device that sleeps between sessions. A Low Energy remote ends its link after an idle hour, and its next press connects it again.
LinkLost The link timed out on a device that does not sleep. It went out of range, or its battery ran out.
ClosedByDevice The device ended the link: it was switched off, or it disconnected.
ClosedByRadio This radio ended the link, at an unpair or when the operator reconnected a controller that sent no input.
AuthenticationFailed The keys did not match. Delete the Peripheral and pair the device again.
NotConnected The operator received no reason. The link ended while the operator was not running, or bluetoothd restarted.
NotBonded bluetoothd holds no object for the device.

The reason changes only when the link changes, so a restart of the operator keeps Asleep on a remote that sleeps. Each change posts an Event on the Peripheral, as the install guide lists.

When a remote wakes from sleep

A remote that sleeps sends no key for the press that wakes it. So when a remote whose reason is Asleep connects again, at least 10 seconds after its link ended, the operator writes one KEY_UNKNOWN press into the remote’s virtual node, and the operator’s log has a line for it. A sleeping screen wakes on that press, so one press of any button wakes the remote and the room.

The press does not stand for the button you pressed. When the remote fell asleep during a film, the first press, such as a pause, does nothing, and the second press pauses.

A remote that went out of range sleeps too. When it comes back into range, it can connect with no press, and the operator writes the press then. So a remote that comes back into range wakes the room although nobody pressed a button.

Unpair

Deleting the Peripheral is the unpair:

kubectl delete peripheral a0-ab-51-33-b7-12

kubectl liken bluetooth unpair a0-ab-51-33-b7-12 deletes the same Peripheral. In bash it completes the paired device names.

The operator disconnects the controller, waits for any claim on it to release, retires the device from the slice, and removes the bond. The Secret with the keys is owned by the Peripheral, so it is collected with the object.