Remote
A Remote is one physical controller: the device it is and, where
its model needs one, the Keymap
for its
model. The base table already gives a Remote with no Keymap the
arrows, OK, back, and every KEY_* code the device emits, and a
Keymap corrects a device the base gets wrong. It names no
player. A Player names the Remotes it owns through
spec.remotes, so the unit that owns a controller is the one that
lists it, and one controller can drive several units.
A Remote name is at most 32 characters, because media-operator builds the names
of the objects it creates from it, and Kubernetes limits some of those
names to 63 characters.
apiVersion: media.liken.sh/v1alpha1
kind: Remote
metadata:
name: den-pad
namespace: den
spec:
device:
class: gamepad
selector: device.attributes["bluetooth.liken.sh"].address == "04:4A:5B:11:22:33"
keymap: dualsense
spec.device.parameters is opaque configuration for the driver that
prepares the controller. A Remote uses it to name the classes of
input it needs, in the driver’s own words. The bluetooth operator’s
manual documents its inputs parameter and the classes it accepts.
spec:
device:
class: gamepad
selector: device.attributes["bluetooth.liken.sh"].address == "7C:66:EF:22:E7:80"
parameters:
driver: bluetooth.liken.sh
values:
inputs: [joystick]
One physical controller, selected by its device and mapped by the default mapping and, where its model needs one, by its Keymap. A Player names the Remotes it owns; the Remote names no player.
spec
The controller, and the Keymap for its model where its model needs one.
| Field | Type | Required | Description |
|---|---|---|---|
device |
object | yes | The controller itself, selected out of the devices the hardware operators publish, and the parameters its driver prepares it with. |
keymap |
string | no | The Keymap for this controller’s model, by name. A Keymap is cluster-scoped, so the name has no namespace. The field is optional and rarely needed: the default mapping already passes every KEY_* code and turns the hats into the arrows, so a Keymap is for a device the kernel names wrongly. A device maps one way on every unit, as it does under hwdb. |
discovery |
boolean | no | Discovery mode, for mapping a controller that has no Keymap yet. The Remote’s pod keeps every input node the claim delivered and logs each event the way a Keymap names it, so a person presses every button and reads the codes out of the pod log. The pod still publishes keys in discovery exactly as it does outside it, so the controller keeps driving its unit while a person maps it. Turning discovery on or off replaces the Remote’s pod, which drops controller input for a few seconds. A pod in discovery reads every event the claim delivers. Outside discovery the pod reads only the keys and hats it publishes. |
spec.device
The controller itself, selected out of the devices the hardware operators publish, and the parameters its driver prepares it with.
| Field | Type | Required | Description |
|---|---|---|---|
class |
string | yes | The DeviceClass the claim allocates through. Consumer classes are the cluster owner’s vocabulary, so the name is whatever this cluster calls its controllers. |
selector |
string | no | A CEL expression over device.attributes that picks this one controller, such as a match on its address. Omit it, and the class alone chooses. |
parameters |
object | no | Opaque configuration for the driver that prepares the controller, carried onto the claim unread. This is where a Remote names the classes of input it needs. The bluetooth operator’s manual documents its inputs parameter. |
spec.device.parameters
Opaque configuration for the driver that prepares the controller, carried onto the claim unread. This is where a Remote names the classes of input it needs. The bluetooth operator’s manual documents its inputs parameter.
| Field | Type | Required | Description |
|---|---|---|---|
driver |
string | yes | The driver the parameters are for, such as bluetooth.liken.sh. |
values |
object | no | The parameters themselves. The driver defines them, and this operator copies them to the claim. |
status
What the operator reports about this controller: the unit its presses reach now, the bonded device its claim allocated, and the declared codes its Keymap leaves unbound.
| Field | Type | Required | Description |
|---|---|---|---|
player |
string | no | The Player this Remote’s focus mark names now: the unit its presses reach, idle or playing. It is empty while no Player lists this Remote. |
peripheral |
string | no | The Peripheral for the device this Remote’s claim allocated. The name is the device’s address in lowercase with dashes. Read that object for the link and the battery level. The field is empty while the claim has no allocation, and for a controller another driver publishes. |
unbound |
[]object | no | Every code that this controller declares and its Keymap does not bind. The list holds only the unbound codes, not every code. A controller whose Keymap binds every declared code reports nothing here, and the field is absent until the Remote’s pod has reported. The list shrinks as the Keymap grows, so it measures a mapping’s progress during discovery and provides a completeness check after discovery. |
status.unbound[]
Every code that this controller declares and its Keymap does not bind. The list holds only the unbound codes, not every code. A controller whose Keymap binds every declared code reports nothing here, and the field is absent until the Remote’s pod has reported. The list shrinks as the Keymap grows, so it measures a mapping’s progress during discovery and provides a completeness check after discovery.
| Field | Type | Required | Description |
|---|---|---|---|
code |
integer | yes | The raw evdev code, the number the controller reports on the wire. |
name |
string | no | The evdev name for the code, which is the name a Keymap binds it by. It is empty when the kernel gives the code no name, and such a code cannot be bound. |
type |
string | yes | Which event type carries the code: key for a button, abs for a hat axis. One of: key, abs. |
The Remote’s pod
The operator reconciles one pod for every Remote in the
cluster, whether or not a Player names it. The pod holds the
controller’s claim, reads its evdev nodes directly, folds the base
table with the Remote’s Keymap, publishes each event under the
kernel’s key name, and synthesises the repeat stream for a control
that does not autorepeat. That work runs beside the device because
that is where hwdb runs on any Linux machine, and one pod then serves
every consumer at once.
The pod asks the kernel for only the keys and the hats it publishes,
so a device that reports motion while it rests costs the pod nothing.
The driver decides which events reach the node, and the claim’s
parameters configure that choice. A pod in spec.discovery
asks for no narrowing and reads every event the claim delivers.
The claim tolerates the bluetooth.liken.sh/disconnected
taint with no time limit, so a controller that sleeps keeps its
allocation and the pod keeps running. It does not tolerate
bluetooth.liken.sh/no-input-node, so the pod stays Pending until
the controller first connects, then keeps running through every later sleep.
Status
The operator writes three facts on a Remote. status.player is the
Player its focus mark names now. status.peripheral names the
Peripheral for the device the Remote’s claim allocated, which is
where a person reads the controller’s link and its battery level.
status.unbound is the gap: every
declared control that the base table and the Keymap together map to
nothing, or map to none. A declared KEY_* code passes as itself,
so it is never unbound, and a keyboard remote starts with an empty
list. kubectl get remotes shows each controller’s Keymap, the unit
it drives, its Peripheral, and its age. A Keymap that does not
compile is logged by the operator, and the Remote keeps its last
good table.
Events
The operator posts a KeymapRefused Warning on the Remote when its
Keymap does not compile. The message gives the compiler’s words, and
the last good key table stays on the bus. The operator posts it once
for as long as the same refusal stands. kubectl describe remote
prints it.
On the bus
Each Remote owns one branch of the bus
topic tree, remotes/<namespace>/<name>/, under the cluster’s topic
base. The bus page gives the rules every topic follows and lists
every writer and reader of each.
| topic | writer | retained | carries |
|---|---|---|---|
events |
the Remote’s pod |
no | one key event |
keys |
operator | yes | the controller’s key table |
codes |
the Remote’s pod |
yes | the declared code set |
availability |
the Remote’s pod |
yes | online or offline |
focus |
operator | yes | the name of the Player it drives |
focus/cycle |
the focus holder | no | a request to advance focus |
events
The Remote’s pod publishes each event under the kernel’s name for
the control, after it folded the base table with the Keymap:
{"key": "KEY_UP", "value": 1}
value is the kernel’s: 0 is the release, 1 the press, and 2 the
autorepeat. A keyboard’s own autorepeat passes through, and the pod
synthesises value 2 for a gamepad button or a hat with a repeat
block. A control the folded table maps to nothing is not published. The
topic is not retained
(why
), so a
subscriber that joins later reads no stale press.
The operator reads the volume keys here: KEY_VOLUMEUP and
KEY_VOLUMEDOWN on each press and repeat, and KEY_MUTE and
KEY_UNMUTE on each press. It sets the level of the unit the
controller’s focus mark names
(how
), so neither the playback pod
nor the idle client handles them.
keys
The controller’s key table, as the operator compiled it: the base
folded with the Remote’s Keymap, one row per control, with the
evdev type, code, and value on the left and the key name on the
right, and the repeat delay and interval in milliseconds where a row
repeats:
[{"type": 1, "code": 304, "value": 1, "key": "KEY_ENTER"},
{"type": 3, "code": 17, "value": -1, "key": "KEY_UP",
"repeatDelay": 400, "repeatInterval": 250}]
The operator is the only writer, and the topic is retained, so the pod
reads the current table the instant it connects and a Keymap edit
reaches it with no pod restart. The operator republishes only when the
table changes, and it clears the topic with an empty payload when the
Remote is deleted.
codes
The codes the controller declares, read from its nodes’ capability bitmaps at every node open:
{"keys": [304, 305], "axes": [16, 17]}
The set is complete with no button pressed, because the bitmaps
state every code a node can report. The topic is retained
(why
), and the pod
clears it with an empty payload when the nodes vanish. The operator
subtracts the folded table from the set and reports the gap as
status.unbound on the Remote.
availability
online or offline, retained, the
availability
signal for the
codes above. The Remote’s pod names this topic as its MQTT Last
Will with offline as the payload, and publishes online once it
connects. When the pod dies, the broker writes offline, so the
retained codes a dead pod left behind do not read as a live
declaration.
focus and focus/cycle
The focus mark is the plain name of the Player this controller
drives now, as bytes, not JSON. The operator is the only writer,
and the topic is retained, so a press reaches its unit even while
the operator is down, except a volume key, which the operator
reads itself. Every reader of the controller’s presses gates on the
mark. The playback pod’s command sidecar acts only when
the mark names the Player its film runs on. An idle unit’s sidecar
acts only when the mark names that Player itself, and the idle
screen draws a small hexagon beside the focused controller in its
parts list.
The operator moves the marks. When a Play starts on a Player,
each of that unit’s controllers is marked to it, so the controller
in a person’s hand drives the film they just started. A mark that
names a deleted Player, or a Player that no longer lists the
controller, moves to the first bound Player by name. A Play
that finishes moves no mark: the unit stays focused and shows its
idle screen. A controller that no Player lists any more has nothing
to drive, so the operator clears its mark with an empty retained
payload.
A press of KEY_CYCLEWINDOWS publishes on focus/cycle, with an
empty payload: the topic names the controller, and the request
carries nothing else. Only the holder of focus publishes it, the
playback pod’s command sidecar during a film and the idle screen
client between films. The operator
reads the request and advances the mark to the next bound Player
by name, wrapping the last back to the first. A controller bound to
one unit wraps to the same Player, and the operator republishes the
mark, which the idle screen answers with a pulse of its hexagon.
The idle screen acts on a mark only when a person caused it. A live
mark that moves to its Player wakes the screen and pulses the
hexagon, and so does the repeat that answers the screen’s own cycle
request. Any other repeat of the mark the screen holds changes
nothing, so a publisher that sends the same mark again does not wake
a sleeping screen.
The operator does not publish a mark again when it restarts. The broker keeps each retained mark across the operator’s restart and delivers it on the new session. A restarted broker keeps no mark, so after each new session the operator waits two seconds for the broker’s retained marks. It then publishes each mark it holds that the broker did not deliver.