
# Install the operator

This guide installs `equipment-operator` on a
[`liken`](https://liken.sh/docs/) 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:

* A `liken` cluster with the
  [`media-operator`](https://liken.sh/media/) installed. When a
  `Player` plays, `media-operator` writes what the receiver should do
  into the `Receiver`'s status, and this operator carries it out.
* A receiver on the network that speaks the Denon and Marantz control
  protocol, with network control enabled in its own menu, or a WiiM
  device.
* `kubectl` with cluster-admin access, because the install creates a
  CRD and a `ClusterRole`.

## Apply the manifests

This site serves the repository's
[`deploy/`](/deploy/kustomization.yaml) 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](#keep-the-pods-off-nodes-with-no-cec-adapter)
describes. The [`CECBus`](/docs/reference/cecbuses/)
reference describes what the pod reports, and the
[`Television`](/docs/reference/televisions/) 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](https://github.com/liken-sh/liken/releases)
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:

```yaml
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](https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/#set-based-requirement)
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`](https://kubernetes.io/docs/concepts/workloads/controllers/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`:

```yaml
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.

```yaml
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`](https://liken.sh/display/) 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`](/docs/reference/receivers/) 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.

