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
- A disconnected controller is tainted, never deleted. The device
stays in the slice with both taints, and a return clears them.
Deleting a device a claim still names would strand the claim: the
kubelet retries
NodePrepareResourcesagainst a device in no slice, with no bound on the retry. - A departed adapter taints everything. When the radio itself is
unplugged, the operator republishes the last paired set fully
tainted and
connected: false, so no allocation is stranded. The media bus republishes on the same pass with its ownNoScheduletaint. The slice is deleted only while no adapter has answered and nothing is paired, which is the window beforebluetoothdstarts. Unpairing the last controller empties the paired set, not the slice: the media bus stays in it. - The operator’s pod can restart under a live claim. The prepared
CDI files survive on the host, so a running consumer keeps its
device across the restart. The bus socket’s directory is a host
path for the same reason: a claim prepared against it names the
same directory after the restart, where an emptyDir’s host path is
under
/var/lib/kubelet/pods/, keyed by the pod’s UID, and changes with the replacement pod.
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.