Install the operator

This guide installs equipment-operator on a liken cluster and declares a Receiver. At the end, the operator runs in liken-system and reports the receiver’s power, input, and volume.

You need:

Apply the manifests

This site serves the repository’s deploy/ directory as raw YAML, so the install needs no clone:

kubectl apply -n liken-system \
  -f https://liken.sh/equipment/deploy/receivers-crd.yaml \
  -f https://liken.sh/equipment/deploy/cecbuses-crd.yaml \
  -f https://liken.sh/equipment/deploy/televisions-crd.yaml \
  -f https://liken.sh/equipment/deploy/deviceclasses.yaml \
  -f https://liken.sh/equipment/deploy/rbac.yaml \
  -f https://liken.sh/equipment/deploy/operator.yaml \
  -f https://liken.sh/equipment/deploy/cec.yaml

cec.yaml runs the CEC node workload, the equipment-operator-cec DaemonSet. Its pod claims a USB CEC adapter through the cec-adapter DeviceClass, so on a node with no adapter the pod stays Pending, and the DaemonSet never reports all its pods ready. A node labeled equipment.liken.sh/cec: none gets no pod, as Keep the pods off nodes with no CEC adapter describes. The CECBus reference describes what the pod reports, and the Television reference describes the TV that a CECBus in Control finds.

For GitOps, point a Kustomization at the base and pin <version> to 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. The base sets the namespace liken-system with a transformer that leaves an object’s own namespace alone, so the example sets no top-level namespace: field. That field overwrites a namespace an object states, and it would move the monitoring component’s dashboard out of the monitoring namespace. The base names the image at latest, so pin the image to the same version:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - https://github.com/liken-sh/liken//equipment-operator/deploy?ref=<version>
images:
  - name: ghcr.io/liken-sh/equipment-operator
    newTag: <version>

Keep the pods off nodes with no CEC adapter

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

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

affinity:
  nodeAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      nodeSelectorTerms:
        - matchExpressions:
            - key: equipment.liken.sh/cec
              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:
    equipment.liken.sh/cec: 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 equipment.liken.sh/cec=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 equipment.liken.sh/cec-

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.

Which devices the operator adopts

Other brands build on the LinkPlay platform that WiiM uses. Their devices advertise the same _linkplay._tcp.local. mDNS service and answer the same getStatusEx request, and the mDNS answer names no model. The operator therefore checks each device in two places, and drives it only when neither check finds another brand.

Discovery checks first, before it creates a Receiver. Every LinkPlay device serves a UPnP device description over plain HTTP at /description.xml, on the port that its mDNS SRV record names. When a search finds a device that discovery has not judged, discovery reads that description with one GET. It creates a Receiver only when the modelName begins with WiiM, in any letter case. The check reads the model and not the manufacturer, because LinkPlay also makes devices that other brands sell:

Device manufacturer modelName Verdict
WiiM Amp Linkplay Technology Inc. WiiM Amp a WiiM
Arylic amplifier Rakoit Technology(SZ) Co., Ltd. A50 not a WiiM

The description is on its own port over plain HTTP, so the check also works for a device that refuses the HTTPS control API. An Arylic amplifier refused HTTPS from the operator’s address and still served its description. Discovery keeps each verdict until the operator restarts. For a device that is not a WiiM, discovery creates no Receiver, deletes the Receiver that it created for the device earlier, sends the device nothing more, and writes one line to its log:

discovery skipped the LinkPlay device <uuid> at <address>: its UPnP description names the manufacturer Rakoit Technology(SZ) Co., Ltd. and the model A50, which is not a WiiM

A description that discovery cannot read or parse is not a verdict. Discovery writes one line with the cause, creates the Receiver as it does for a WiiM, and reads the description again on the next search that finds the device. The driver’s check then decides:

discovery could not read the UPnP description of the LinkPlay device <uuid> at <address>, so its getStatusEx project decides whether it is a WiiM: <cause>

The driver checks second, on every poll. It reads the project field of the getStatusEx answer and drives a device only when the field begins with WiiM, in any letter case. A WiiM Amp reports WiiM_Amp_4layer, and an Arylic amplifier reports ARYLIC_A50TE. This check covers a Receiver that exists before discovery reads the description, such as a Receiver that you declare. The operator already reads getStatusEx on every poll, so the check sends a WiiM no extra request. For a device whose project does not begin with WiiM, the operator sends getStatusEx and nothing else. It sends no setting, no command, no event subscription, and no read of the description. It writes one line to its log for each such device:

discovery skipped the LinkPlay device <uuid> at <address>: its project is ARYLIC_A50TE, which is not a WiiM

When the answer names another brand, the operator deletes the Receiver that discovery created for the device, and discovery creates no other for it until the operator restarts. Neither check deletes a Receiver that you declare. That Receiver reports the device unreachable, and the operator still sends the device only getStatusEx. The driver does not judge a device that does not answer getStatusEx, or a device whose answer has no project field.

Turn network discovery off

By default, the operator searches the LAN for WiiM amps with mDNS and SSDP. Each search takes 4 seconds, and the next one starts 30 seconds after it ends. It creates a Receiver for each amp that no Receiver names, and then it reads and drives that amp. Turn the search off when the cluster shares its LAN with amps that it must not drive, for example a test cluster on the same network as a home’s own equipment. The EQUIPMENT_NETWORK_DISCOVERY variable on the Deployment takes on or off. Set it to off with a patch in your Kustomization:

patches:
  - patch: |-
      apiVersion: apps/v1
      kind: Deployment
      metadata:
        name: equipment-operator
      spec:
        template:
          spec:
            containers:
              - name: operator
                env:
                  - name: EQUIPMENT_NETWORK_DISCOVERY
                    value: "off"

Keep the quotes. A YAML 1.1 reader, such as kubectl, reads a bare off as the boolean false. Any value other than on or off stops the operator at start with an error that names the value, so a wrong value never leaves the search on.

With the search off, the operator writes one line to its log at start that says so. It sends no mDNS or SSDP search and creates no Receiver. A Receiver that you declare works as before. A WiiM Receiver must state spec.wiim.address, because nothing else finds the amp’s address. A WiiM Receiver with no address sends nothing and reports the amp unreachable.

The operator does not delete the Receiver objects that the search created before you turned it off, because only a search, or a device that the driver finds to be another brand, deletes one. So a discovered Receiver also stays when you declare a Receiver for the same amp, and the two objects then name one amp. Each discovered Receiver has the equipment.liken.sh/discovered label. List them, and delete the ones you do not want:

kubectl get receivers -l equipment.liken.sh/discovered

The setting covers the network search only. The CEC node workload still creates a CECBus in Listen for an adapter that no CECBus names, and a Listen adapter sends nothing on the HDMI wire. A Television is created only for a CECBus that a person sets to Control.

Declare the receiver

A Receiver names the protocol, the address, and the wiring. The wiring is the fact nothing can discover: which machine’s HDMI output connects to which input. A receiver forwards one EDID on every input, so every entry names the machine as well as the monitor id.

apiVersion: equipment.liken.sh/v1alpha1
kind: Receiver
metadata:
  name: living-room
spec:
  denon:
    address: receiver.example
  inputs:
    - name: MPLAY
      machine: node-1
      monitor: don-0070-denon-avr
  volume:
    max: 72
    step: 0.5

The input name is the receiver’s own spelling. The monitor id is the one the display-operator publishes for that cable. The volume block is in the receiver’s own scale. max is the loudest level a press may set the room to, and a Denon requires it, because the limit a Denon reports moves with the volume. step is how far one press moves the volume, and half steps are allowed. indicator is Player by default, so the Player’s screens draw the volume bar. Set it to Receiver when the receiver shows its own overlay on the TV. media-operator reads indicator, and equipment-operator does not. kubectl get receivers shows the receiver’s model, what the receiver last reported, and the Player whose session holds it:

NAME          MODEL        POWER   INPUT   VOLUME   PLAYER              REACHABLE   AGE
living-room   AVR-X1700H   On      MPLAY   50.0     house/living-room   True        2m

The driver reads the model from the receiver’s UPnP description, into status.model, and the maker into status.manufacturer. A WiiM serves its description on port 49152, and a Denon or Marantz receiver of the AVR-X 2016 generation or later serves one on port 60006. A receiver that serves none leaves the MODEL column empty.

kubectl get receivers -o wide adds the driver, the address, whether a Play stands, and the sound mode.

Put it under a Player

You declare no session by hand. The media-operator resolves each Player screen to a machine and monitor ID, then finds the input that matches both values. It applies status.session while the Player has that screen, including the idle screen. A media-operator that does not write status.session applies spec.session instead, and the operator reads that block while status.session is absent.

There are no topics to configure. The operator connects to no message bus, and media-operator writes each press of the room remote into the session as an ask. A press of a volume key writes a volumeAsk with an absolute level in the receiver’s own scale, at or below spec.volume.max. The operator sends each new ask to the receiver once, and when asks arrive faster than the receiver reports the volume it was last sent, it sends only the newest. So the room remote changes the receiver’s level while a film plays and while the screen is idle. The Receiver reference describes each ask.

When a Play starts, the session powers the receiver on and selects the input once. It sends each command only when the receiver reports another value. Waking the screen also triggers those commands through status.session.awake, even with no Play. Starting an idle screen after a reboot does not by itself power the receiver on, and an operator restart sends nothing for the sessions it finds.

The remote’s power button turns the whole room on or off. The media-operator writes a powerAsk into the session. When the session’s input names a Display that a Television lists, the TV’s power decides what the press does: a TV that is on means the press turns the room off, and a TV in standby means the press turns the room on. The CEC node workload asks the TV for its power at each press, because no timer asks the TV between presses, and a TV that a person turned off with its own remote may say nothing on the wire. The press waits up to 3 seconds for that answer, and it decides from the Television’s status.power when none arrives. With no Television, the receiver’s power decides. A press that turns the room off asks the TV for standby over CEC and puts the receiver in standby. A WiiM has no standby command, so it stays on, and its log line says so. A press that turns the room on wakes the TV, shows the machine’s input, and turns the receiver on. Only the power button turns the TV off. A Play that ends, a screen that goes idle, and an operator restart leave the TV as it is, because the TV can show another input, such as a streaming player, while the room’s player is idle.

A person at the receiver’s own remote can change the receiver without the cluster changing it back immediately. If the person selects another input, the status records that input. The operator selects the configured input again only when active or awake changes from false to true. If the person turns the volume knob, the status reports the new volume, and media-operator steps the next press from that level.

Read what happened to a receiver

Each change of a Receiver’s Reachable, SettingsConfirmed, or InputSelected condition posts one Kubernetes Event with the condition’s reason and message. A receiver that stops answering posts Unreachable, a Warning, and posts Connected when it answers again. A declared setting that the receiver does not confirm after 3 sends posts NotConfirmed, a Warning. A Receiver is cluster-scoped, so its Events are in the default namespace, and kubectl events finds them only with -n default or -A:

kubectl describe receiver living-room
kubectl events -n default --for receiver/living-room

The API server deletes an Event one hour after its last change. The conditions and the operator’s log keep the facts after that.