equipment-operator

InputUnitCoveredTotalPercent
Gostatements69474293.5%

Go

694 of 742 statements, 93.5%.

FileCovered statementsTotal statementsPercent
api.go33100.0%
apiclient.go596196.7%
bus.go9710691.5%
denon.go12713296.2%
level.go5656100.0%
main.go1250.0%
mqtt.go768095.0%
reconcile.go10712387.0%
session.go12413591.9%
status.go1818100.0%
watch.go2626100.0%
api.go 100.0%
1package main23// The wire types are hand-written, the way liken and the sibling4// operators write theirs. The Kubernetes API is HTTPS that serves5// JSON, and importing client-go for a dozen structs brings informers,6// work queues, and a release cadence this program does not use. Each7// type carries only the fields this operator reads or writes; the8// API server fills in the rest.910// The group this operator serves.11const equipmentAPIVersion = "equipment.liken.sh/v1alpha1"1213// ObjectMeta carries what this operator reads or writes: name for the14// URL, and resourceVersion for the conditional write.15type ObjectMeta struct {16	Name            string `json:"name,omitempty"`17	ResourceVersion string `json:"resourceVersion,omitempty"`18	Generation      int64  `json:"generation,omitempty"`19}2021// A list's own resourceVersion is the revision of the whole22// collection, which is what a watch resumes from.23type ListMeta struct {24	ResourceVersion string `json:"resourceVersion,omitempty"`25}2627// A Receiver is one piece of A/V equipment on the far end of a28// machine's cable. It is cluster-scoped, because the machines that feed29// it are the cluster's.30type Receiver struct {31	APIVersion string         `json:"apiVersion,omitempty"`32	Kind       string         `json:"kind,omitempty"`33	Metadata   ObjectMeta     `json:"metadata"`34	Spec       ReceiverSpec   `json:"spec"`35	Status     ReceiverStatus `json:"status,omitempty"`36}3738type ReceiverList struct {39	Metadata ListMeta   `json:"metadata"`40	Items    []Receiver `json:"items"`41}4243// The spec names one protocol block, the inputs liken machines feed,44// and the session that holds the receiver now.45type ReceiverSpec struct {46	Denon   *DenonProtocol   `json:"denon,omitempty"`47	Volume  *ReceiverVolume  `json:"volume,omitempty"`48	Inputs  []ReceiverInput  `json:"inputs,omitempty"`49	Session *ReceiverSession `json:"session,omitempty"`50}5152// How loud the room may get and how far one press moves it, both in the53// receiver's own scale. A Denon requires max. An absent step is one54// whole unit of that scale.55type ReceiverVolume struct {56	Max  float64 `json:"max,omitempty"`57	Step float64 `json:"step,omitempty"`58}5960// The Denon and Marantz control protocol, and the address it answers61// on.62type DenonProtocol struct {63	Address string `json:"address"`64}6566// One input of the receiver, and the machine and monitor id that feed67// it.68type ReceiverInput struct {69	Name    string `json:"name"`70	Machine string `json:"machine"`71	Monitor string `json:"monitor"`72}7374// What a session names: the Player, the input it plays through, the75// topic it takes the level from, and the two flags the media operator76// flips on it. Active says a Play stands. Awake says the room's screen77// is awake. The media operator holds a session whenever the Player has78// a screen, the idle screen included, so a session with both flags off79// owns the level and sends the equipment nothing.80type ReceiverSession struct {81	Player      string `json:"player"`82	Input       string `json:"input"`83	VolumeTopic string `json:"volumeTopic"`84	Active      bool   `json:"active,omitempty"`85	Awake       bool   `json:"awake,omitempty"`86}8788// withoutFlags is the session apart from Active and Awake, which is89// what tells one session from another. A flip of either flag reaches90// the session that already stands. Anything else replaces it.91func (s ReceiverSession) withoutFlags() ReceiverSession {92	s.Active = false93	s.Awake = false94	return s95}9697// What the receiver last said, in its own units, and the Reachable98// condition.99type ReceiverStatus struct {100	Power      string      `json:"power,omitempty"`101	Input      string      `json:"input,omitempty"`102	Volume     string      `json:"volume,omitempty"`103	VolumeMax  string      `json:"volumeMax,omitempty"`104	Mute       bool        `json:"mute,omitempty"`105	SoundMode  string      `json:"soundMode,omitempty"`106	Service    string      `json:"service,omitempty"`107	Conditions []Condition `json:"conditions,omitempty"`108}109110// ConditionStatus is the three-valued verdict a condition carries.111type ConditionStatus string112113const (114	ConditionTrue    ConditionStatus = "True"115	ConditionFalse   ConditionStatus = "False"116	ConditionUnknown ConditionStatus = "Unknown"117)118119// Condition mirrors metav1.Condition, the shape Kubernetes uses120// everywhere, and liken's own. Anyone who reads kubectl describe121// output on a Pod already knows how to read one of these.122//123// ObservedGeneration records which metadata.generation the condition124// judged. Generation counts spec edits, so a reader can tell "Reachable,125// for the spec as it stands" from "Reachable, but for a spec two edits126// ago".127type Condition struct {128	Type               string          `json:"type"`129	Status             ConditionStatus `json:"status"`130	ObservedGeneration int64           `json:"observedGeneration,omitempty"`131	Reason             string          `json:"reason,omitempty"`132	Message            string          `json:"message,omitempty"`133	LastTransitionTime string          `json:"lastTransitionTime"`134}
apiclient.go 96.7%
1package main23// This is a Kubernetes client written straight against the HTTP API,4// following liken's own (kubernetes/apiclient.go) and the media5// operator's, for the same reason: the API is HTTPS that serves6// JSON, and client-go would bring informers, work queues, and7// generated types this program does not use.8//9// Every pod already holds what it needs to reach the API server.10// Kubernetes injects two environment variables that name the11// server's in-cluster address, and the kubelet mounts a CA12// certificate and a ServiceAccount token at a known path. Those five13// values are the whole of an in-cluster config.1415import (16	"bytes"17	"context"18	"crypto/tls"19	"crypto/x509"20	"encoding/json"21	"errors"22	"fmt"23	"io"24	"net"25	"net/http"26	"os"27	"time"28)2930// serviceAccountDir is a variable so a test points it at a directory31// it controls.32var serviceAccountDir = "/var/run/secrets/kubernetes.io/serviceaccount"3334// These two answers are values, not failures. An absent object is35// the normal state the caller answers by creating it, and a conflict36// is the normal state under optimistic concurrency that the caller37// answers by reading again.38var (39	ErrNotFound = errors.New("not found")40	ErrConflict = errors.New("conflict: something else wrote this object first")41)4243type Client struct {44	base        string45	http        *http.Client46	credentials string47}4849// NewClient builds a client from its three parts. InClusterClient50// reads them from the pod's environment; a test hands in an51// httptest server's base and no credentials.52func NewClient(base string, httpClient *http.Client, credentials string) *Client {53	return &Client{base: base, http: httpClient, credentials: credentials}54}5556func InClusterClient() (*Client, error) {57	host, port := os.Getenv("KUBERNETES_SERVICE_HOST"), os.Getenv("KUBERNETES_SERVICE_PORT")58	if host == "" || port == "" {59		return nil, fmt.Errorf("not running in a cluster: KUBERNETES_SERVICE_HOST unset")60	}6162	// The client trusts the cluster's own CA and not the system63	// store, so it accepts this API server and no other server that64	// answers on the address.65	caPEM, err := os.ReadFile(serviceAccountDir + "/ca.crt")66	if err != nil {67		return nil, fmt.Errorf("reading service account CA: %w", err)68	}69	roots := x509.NewCertPool()70	if !roots.AppendCertsFromPEM(caPEM) {71		return nil, fmt.Errorf("service account CA contains no certificates")72	}7374	return NewClient("https://"+host+":"+port, &http.Client{75		Transport: &http.Transport{76			TLSClientConfig: &tls.Config{RootCAs: roots},77			// Each timeout bounds the same failure: a server that78			// stops answering without sending anything. There is no79			// overall client timeout, because the watch is a request80			// whose response never ends, and a whole-request81			// deadline would cut the stream on schedule.82			DialContext: (&net.Dialer{83				Timeout:   5 * time.Second,84				KeepAlive: 10 * time.Second,85			}).DialContext,86			ResponseHeaderTimeout: 10 * time.Second,87			IdleConnTimeout:       30 * time.Second,88		},89	}, serviceAccountDir), nil90}9192// The two content types this client sends. A body is JSON,93// except an apply, which the API server reads as a partial object94// under the caller's field manager. The apply media type is named for95// YAML and accepts JSON, because YAML is its superset.96const (97	jsonContentType  = "application/json"98	applyContentType = "application/apply-patch+yaml"99)100101// fieldManager names this operator's writes to the API server, so102// server-side apply gives it the fields it applies and leaves every103// other writer's fields alone.104const fieldManager = "equipment-operator"105106// Do sends one request and hands back the open response, which is107// what the watch needs and what RequestJSON is built on. The context108// governs the whole exchange, the response body included, so109// cancelling it is what ends a read of a stream that never ends.110func (c *Client) Do(ctx context.Context, method, path string, body []byte) (*http.Response, error) {111	return c.send(ctx, method, path, jsonContentType, body)112}113114func (c *Client) send(ctx context.Context, method, path, contentType string, body []byte) (*http.Response, error) {115	var reader io.Reader116	if body != nil {117		reader = bytes.NewReader(body)118	}119	req, err := http.NewRequestWithContext(ctx, method, c.base+path, reader)120	if err != nil {121		return nil, err122	}123	// The token is read from disk on every request. The mounted124	// token is short-lived and the kubelet refreshes the file as125	// each one nears expiry, so a client that held one in memory126	// would start getting 401s.127	if c.credentials != "" {128		token, err := os.ReadFile(c.credentials + "/token")129		if err != nil {130			return nil, fmt.Errorf("reading service account token: %w", err)131		}132		req.Header.Set("Authorization", "Bearer "+string(token))133	}134	req.Header.Set("Accept", "application/json")135	if body != nil {136		req.Header.Set("Content-Type", contentType)137	}138	return c.http.Do(req)139}140141// RequestJSON sends one request and decodes the answer, turning142// every non-2xx status into an error that carries the server's own143// message.144func (c *Client) RequestJSON(method, path string, body []byte, out any) error {145	return c.requestJSON(method, path, jsonContentType, body, out)146}147148func (c *Client) requestJSON(method, path, contentType string, body []byte, out any) error {149	resp, err := c.send(context.Background(), method, path, contentType, body)150	if err != nil {151		return err152	}153	defer drain(resp.Body)154155	if resp.StatusCode == http.StatusNotFound {156		return ErrNotFound157	}158	if resp.StatusCode == http.StatusConflict {159		return ErrConflict160	}161	if resp.StatusCode < 200 || resp.StatusCode > 299 {162		message, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))163		return fmt.Errorf("%s %s: %s: %s", method, path, resp.Status, message)164	}165	return json.NewDecoder(resp.Body).Decode(out)166}167168// drain reads whatever the caller left in the body, then closes it.169// Go returns a connection to its pool only when the body reaches170// EOF, so an early close costs a fresh connection and TLS handshake,171// and reaches the server as a hang-up on a request it answered.172const maxDrain = 4 << 20173174func drain(body io.ReadCloser) {175	_, _ = io.Copy(io.Discard, io.LimitReader(body, maxDrain))176	_ = body.Close()177}178179// A Receiver is cluster-scoped, so the collection path carries no180// namespace, the way a StorageClass path carries none.181const receiversPath = "/apis/" + equipmentAPIVersion + "/receivers"182183func receiverPath(name string) string {184	return receiversPath + "/" + name185}186187// ListReceivers answers a whole pass with one request, and the list's188// resourceVersion is where a watch resumes from.189func ListReceivers(c *Client) (*ReceiverList, error) {190	list := &ReceiverList{}191	if err := c.RequestJSON(http.MethodGet, receiversPath, nil, list); err != nil {192		return nil, err193	}194	return list, nil195}196197func GetReceiver(c *Client, name string) (*Receiver, error) {198	receiver := &Receiver{}199	if err := c.RequestJSON(http.MethodGet, receiverPath(name), nil, receiver); err != nil {200		return nil, err201	}202	return receiver, nil203}204205// WatchReceivers opens the stream the loop wakes on. A watch is an206// ordinary GET whose response never ends: the API server holds the207// connection open and writes one JSON event per change. The caller208// owns the body, and resumes from the resourceVersion a list gave it,209// so no change is missed between reconnects. Bookmarks cost one line210// each and keep the resume point current while nothing changes.211func WatchReceivers(ctx context.Context, c *Client, resourceVersion string) (*http.Response, error) {212	path := receiversPath + "?watch=true&allowWatchBookmarks=true&resourceVersion=" + resourceVersion213	return c.Do(ctx, http.MethodGet, path, nil)214}215216// receiverStatusApply is the partial object an apply sends: the217// identity the API server matches on, and the status this operator218// owns. It carries no spec, so an apply can never state a field a219// person declared.220type receiverStatusApply struct {221	APIVersion string         `json:"apiVersion"`222	Kind       string         `json:"kind"`223	Metadata   ObjectMeta     `json:"metadata"`224	Status     ReceiverStatus `json:"status"`225}226227// ApplyReceiverStatus writes the status subresource under this228// operator's own field manager. Server-side apply keeps the write to229// the fields the body states, and removes the fields this manager230// owned and no longer states, so a reading that stops arriving leaves231// no stale value behind. force settles a conflict in this manager's232// favour, because nothing else writes a Receiver's status.233func ApplyReceiverStatus(c *Client, name string, status ReceiverStatus) (*Receiver, error) {234	body, err := json.Marshal(&receiverStatusApply{235		APIVersion: equipmentAPIVersion,236		Kind:       "Receiver",237		Metadata:   ObjectMeta{Name: name},238		Status:     status,239	})240	if err != nil {241		return nil, err242	}243	path := receiverPath(name) + "/status?fieldManager=" + fieldManager + "&force=true"244	written := &Receiver{}245	if err := c.requestJSON(http.MethodPatch, path, applyContentType, body, written); err != nil {246		return nil, err247	}248	return written, nil249}
bus.go 91.5%
1package main23// The bus client is one live TCP connection to the broker, with a4// reader, a single writer, and a keepalive timer, and it reconnects5// with backoff whenever any of the three fails.6//7// One session holds one Bus, so no caller writes the socket directly.89import (10	"bufio"11	"context"12	"net"13	"sort"14	"sync"15	"time"16)1718// The keepalive the client asks the broker for, and the queue the19// writer drains. The client sends a PINGREQ once the connection has20// been idle longer than half the keepalive, so the broker never21// reaches the keepalive without hearing from the client. The queue is22// bounded, and a publish that would overflow it is dropped, which is23// correct at QoS 0.24const (25	busKeepalive  = 3026	busQueueDepth = 6427)2829// The reconnect backoff bounds. The client waits busMinBackoff after30// the first failure and doubles the wait up to busMaxBackoff, so a31// broker that is down does not become a tight reconnect loop. Both are32// variables so a test drives a reconnect in milliseconds.33var (34	busMinBackoff = time.Second35	busMaxBackoff = 30 * time.Second36)3738// busHandler receives one inbound message's topic and payload. The Bus39// calls it on the reader goroutine, so a handler that blocks holds up40// every later message on the connection.41type busHandler func(topic string, payload []byte)4243// busWill is the MQTT Last Will the client names at connect time. The44// broker publishes it on any disconnect the client does not make45// cleanly.46//47// busWill is the message the broker publishes when this connection dies48// without a clean disconnect. It is how a killed operator's owner mark49// is cleared.50type busWill struct {51	Topic    string52	Payload  []byte53	Retained bool54}5556// Bus holds the connection's parts and the state that outlives one57// connection: the remembered subscriptions and the packet identifier58// counter. out is the current connection's write queue, or nil while59// disconnected, and the mutex guards both it and the fields around it.60type Bus struct {61	clientID  string62	will      *busWill63	onConnect func(*Bus)64	handler   busHandler65	dial      func(context.Context) (net.Conn, error)6667	mutex    sync.Mutex68	filters  map[string]struct{}69	out      chan []byte70	packetID uint1671}7273// newBus builds a client that dials the address over TCP. The address,74// the client identifier, the will, the connect callback, and the75// inbound handler are fixed for the client's life; the connection they76// drive is not.77func newBus(address, clientID string, will *busWill, onConnect func(*Bus), handler busHandler) *Bus {78	bus := &Bus{79		clientID:  clientID,80		will:      will,81		onConnect: onConnect,82		handler:   handler,83		filters:   map[string]struct{}{},84	}85	bus.dial = func(ctx context.Context) (net.Conn, error) {86		dialer := &net.Dialer{}87		return dialer.DialContext(ctx, "tcp", address)88	}89	return bus90}9192// Run holds the connection open until ctx ends. It dials, connects,93// and serves one session, then waits a backoff and dials again. A94// session that reached a CONNACK resets the backoff to its floor, so a95// connection that drops after an hour reconnects at once, while a96// broker that never answers is retried ever more slowly.97func (b *Bus) Run(ctx context.Context) {98	backoff := busMinBackoff99	for ctx.Err() == nil {100		connected := b.runSession(ctx)101		if ctx.Err() != nil {102			return103		}104		if connected {105			backoff = busMinBackoff106		}107		select {108		case <-ctx.Done():109			return110		case <-time.After(backoff):111		}112		if !connected {113			backoff *= 2114			if backoff > busMaxBackoff {115				backoff = busMaxBackoff116			}117		}118	}119}120121// runSession dials, completes the CONNECT handshake, and serves the122// connection until its reader or writer fails. It returns whether the123// handshake reached a CONNACK, which is what tells Run to reset the124// backoff.125func (b *Bus) runSession(parent context.Context) (connected bool) {126	conn, err := b.dial(parent)127	if err != nil {128		return false129	}130	defer conn.Close()131132	if _, err := conn.Write(encodeConnect(b.clientID, busKeepalive, b.will)); err != nil {133		return false134	}135	reader := bufio.NewReader(conn)136	first, body, err := readPacket(reader)137	if err != nil || first&0xF0 != mqttConnack {138		return false139	}140	if err := parseConnack(body); err != nil {141		return false142	}143144	ctx, cancel := context.WithCancel(parent)145	defer cancel()146	// The reader blocks in Read until the broker writes or the147	// connection closes. Closing the connection when the session ends148	// is what unblocks a reader waiting on a silent broker.149	defer context.AfterFunc(ctx, func() { conn.Close() })()150151	out := make(chan []byte, busQueueDepth)152	b.mutex.Lock()153	b.out = out154	b.mutex.Unlock()155	defer func() {156		b.mutex.Lock()157		b.out = nil158		b.mutex.Unlock()159	}()160161	var writing sync.WaitGroup162	writing.Add(1)163	go func() {164		defer writing.Done()165		// A write failure ends the session, so the reader stops too.166		defer cancel()167		b.writeLoop(ctx, conn, out)168	}()169170	// The remembered subscriptions go out first, so the broker is171	// delivering again before onConnect re-publishes any retained172	// state.173	b.resubscribe(out)174	if b.onConnect != nil {175		b.onConnect(b)176	}177178	b.readLoop(reader)179	cancel()180	writing.Wait()181	return true182}183184// writeLoop is the one goroutine that writes the connection. It sends185// each queued frame and, when no frame has gone out for half the186// keepalive, sends a PINGREQ so the broker hears from the client187// before the keepalive elapses.188func (b *Bus) writeLoop(ctx context.Context, conn net.Conn, out <-chan []byte) {189	idle := time.Duration(busKeepalive) * time.Second / 2190	ticker := time.NewTicker(idle)191	defer ticker.Stop()192	last := time.Now()193	for {194		select {195		case <-ctx.Done():196			return197		case frame := <-out:198			if _, err := conn.Write(frame); err != nil {199				return200			}201			last = time.Now()202		case <-ticker.C:203			if time.Since(last) >= idle {204				if _, err := conn.Write(encodePingreq()); err != nil {205					return206				}207				last = time.Now()208			}209		}210	}211}212213// readLoop reads whole packets and delivers each inbound PUBLISH to the214// handler. A SUBACK and a PINGRESP are read and dropped: the client215// asks for QoS 0, so a SUBACK carries nothing to act on, and a216// PINGRESP only proves the broker is alive, which a successful read217// already shows. Any read error ends the loop and the session.218func (b *Bus) readLoop(reader *bufio.Reader) {219	for {220		first, body, err := readPacket(reader)221		if err != nil {222			return223		}224		if first&0xF0 == mqttPublish {225			topic, payload, ok := parsePublish(body)226			if ok && b.handler != nil {227				b.handler(topic, payload)228			}229		}230	}231}232233// Publish enqueues a QoS 0 PUBLISH from any goroutine. While the client234// is disconnected the queue does not exist and the publish is dropped,235// which is correct at QoS 0: a caller re-publishes its retained state236// from onConnect, so the broker holds the current value again within237// the reconnect.238func (b *Bus) Publish(topic string, payload []byte, retained bool) {239	b.mutex.Lock()240	out := b.out241	b.mutex.Unlock()242	if out == nil {243		return244	}245	select {246	case out <- encodePublish(topic, payload, retained):247	default:248	}249}250251// Subscribe remembers the filter and sends it if the client is252// connected. The remembered set is what runSession re-sends on every253// reconnect, so a subscription outlives the connection it was made on.254func (b *Bus) Subscribe(filter string) {255	b.mutex.Lock()256	b.filters[filter] = struct{}{}257	out := b.out258	frame := encodeSubscribe(b.nextPacketID(), filter)259	b.mutex.Unlock()260	if out == nil {261		return262	}263	select {264	case out <- frame:265	default:266	}267}268269// resubscribe sends every remembered filter on a fresh connection. The270// filters go out in sorted order so the frames a reconnect writes are271// the same every time, which keeps a test deterministic.272func (b *Bus) resubscribe(out chan<- []byte) {273	b.mutex.Lock()274	filters := make([]string, 0, len(b.filters))275	for filter := range b.filters {276		filters = append(filters, filter)277	}278	frames := make([][]byte, 0, len(filters))279	sort.Strings(filters)280	for _, filter := range filters {281		frames = append(frames, encodeSubscribe(b.nextPacketID(), filter))282	}283	b.mutex.Unlock()284	for _, frame := range frames {285		select {286		case out <- frame:287		default:288		}289	}290}291292// nextPacketID hands out the identifier a SUBSCRIBE carries and its293// SUBACK echoes. It skips zero, which the protocol reserves. The caller294// holds the mutex.295func (b *Bus) nextPacketID() uint16 {296	b.packetID++297	if b.packetID == 0 {298		b.packetID = 1299	}300	return b.packetID301}
denon.go 96.2%
1package main23// One receiver's control connection: the ASCII protocol a Denon speaks4// on port 23, the state the operator folds every line into, and the5// reconnect that keeps the socket honest.6// The protocol reference is https://assets.denon.com/documentmaster/uk/7// avr1713_avr1613_protocol_v860.pdf. The lines this file parses were8// also read from a live AVR-X1700H.910import (11	"bufio"12	"context"13	"net"14	"strings"15	"sync"16	"time"17)1819// The port a Denon answers control on, used when the declared address20// names none.21const denonPort = "23"2223// Commands and replies alike are ASCII terminated by a carriage return.24const denonTerminator = '\r'2526// The five queries the operator sends on every connect. Their answers27// are the whole of the status.28var denonQueries = []string{"PW?", "MV?", "MU?", "SI?", "MS?"}2930// The set commands this operator sends.31const (32	denonPowerOnCommand      = "PWON"33	denonMuteOnCommand       = "MUON"34	denonMuteOffCommand      = "MUOFF"35	denonVolumeCommandPrefix = "MV"36	denonInputCommandPrefix  = "SI"37)3839// The two power words the status carries.40const (41	powerOn      = "on"42	powerStandby = "standby"43)4445// The timings of one connection. The heartbeat is a query the receiver46// answers. Silence longer than the limit ends the session even though47// the socket is still open, because a half-open socket reads as live48// and swallows every write.49var (50	denonDialTimeout  = 5 * time.Second51	denonHeartbeat    = 30 * time.Second52	denonSilenceLimit = 75 * time.Second53	denonMinBackoff   = time.Second54	denonMaxBackoff   = 30 * time.Second55)5657// The write queue for one connection. A command that would overflow it58// is dropped, because the operator re-asserts nothing.59const denonQueueDepth = 326061// The fields a line can name, which is what a listener switches on.62const (63	denonPowerField     = "power"64	denonInputField     = "input"65	denonVolumeField    = "volume"66	denonVolumeMaxField = "volumeMax"67	denonMuteField      = "mute"68	denonSoundModeField = "soundMode"69	denonReachableField = "reachable"70)7172// denonState is what the receiver last said, in the receiver's own73// units, and whether the operator can still reach it.74type denonState struct {75	Power     string76	Input     string77	SoundMode string78	Mute      bool79	Volume    int80	VolumeMax int81	Reachable ConditionStatus82}8384// The state before the receiver has said anything.85func newDenonState() denonState {86	return denonState{87		Volume:    unknownHalves,88		VolumeMax: unknownHalves,89		Reachable: ConditionUnknown,90	}91}9293// denonEvent is one recognized line: the field it named and the whole94// state after it.95type denonEvent struct {96	Field string97	State denonState98}99100// applyDenonLine folds one line into the state and answers which field101// it named. A line this operator does not know is ignored, because a102// Denon volunteers dozens of settings nothing here reads.103func applyDenonLine(state denonState, line string) (denonState, string, bool) {104	switch {105	case line == "PWON":106		state.Power = powerOn107		return state, denonPowerField, true108	case line == "PWSTANDBY":109		state.Power = powerStandby110		return state, denonPowerField, true111	case line == denonMuteOnCommand:112		state.Mute = true113		return state, denonMuteField, true114	case line == denonMuteOffCommand:115		state.Mute = false116		return state, denonMuteField, true117	case strings.HasPrefix(line, "MVMAX "):118		halves, ok := parseHalfSteps(strings.TrimSpace(line[len("MVMAX "):]))119		if !ok {120			return state, "", false121		}122		state.VolumeMax = halves123		return state, denonVolumeMaxField, true124	case strings.HasPrefix(line, denonVolumeCommandPrefix):125		halves, ok := parseHalfSteps(line[len(denonVolumeCommandPrefix):])126		if !ok {127			return state, "", false128		}129		state.Volume = halves130		return state, denonVolumeField, true131	case strings.HasPrefix(line, denonInputCommandPrefix) && len(line) > 2:132		state.Input = line[2:]133		return state, denonInputField, true134	case strings.HasPrefix(line, "MS") && len(line) > 2:135		state.SoundMode = line[2:]136		return state, denonSoundModeField, true137	}138	return state, "", false139}140141// denonVolumeCommand is the set command for one half-step count.142func denonVolumeCommand(halves int) string {143	return denonVolumeCommandPrefix + halfStepDigits(halves)144}145146// denonMuteCommand is the set command for one mute state.147func denonMuteCommand(muted bool) string {148	if muted {149		return denonMuteOnCommand150	}151	return denonMuteOffCommand152}153154// denonInputCommand selects one input by the name the receiver carries155// for it.156func denonInputCommand(input string) string {157	return denonInputCommandPrefix + input158}159160// denonAddress fills in the control port when the declared address161// names none.162func denonAddress(address string) string {163	if _, _, err := net.SplitHostPort(address); err == nil {164		return address165	}166	return net.JoinHostPort(address, denonPort)167}168169// denonClient holds one receiver's connection, the state it reports,170// and the queue of commands waiting to go out. out is the current171// connection's queue, or nil while disconnected.172type denonClient struct {173	address  string174	listener func(denonEvent)175176	mutex sync.Mutex177	state denonState178	out   chan string179}180181func newDenonClient(address string, listener func(denonEvent)) *denonClient {182	return &denonClient{183		address:  denonAddress(address),184		listener: listener,185		state:    newDenonState(),186	}187}188189// State answers what the receiver last said, from any goroutine.190func (d *denonClient) State() denonState {191	d.mutex.Lock()192	defer d.mutex.Unlock()193	return d.state194}195196// Send queues one command. While the client is disconnected the queue197// does not exist and the command is dropped. Every command this198// operator sends is a one-shot, and a stale one sent after a reconnect199// would fight a hand on the equipment.200func (d *denonClient) Send(command string) {201	d.mutex.Lock()202	out := d.out203	d.mutex.Unlock()204	if out == nil {205		return206	}207	select {208	case out <- command:209	default:210	}211}212213// Run holds the connection open until ctx ends, and waits a backoff214// between sessions. A session that got an answer resets the backoff, so215// a receiver that was unplugged for an hour reconnects at once, while216// an address that never answers is retried more and more slowly.217func (d *denonClient) Run(ctx context.Context) {218	backoff := denonMinBackoff219	for ctx.Err() == nil {220		answered := d.runSession(ctx)221		d.record(ConditionFalse)222		if ctx.Err() != nil {223			return224		}225		if answered {226			backoff = denonMinBackoff227		}228		select {229		case <-ctx.Done():230			return231		case <-time.After(backoff):232		}233		if !answered {234			backoff *= 2235			if backoff > denonMaxBackoff {236				backoff = denonMaxBackoff237			}238		}239	}240}241242// runSession dials, asks the five queries, and reads until the receiver243// falls silent or the socket fails. It answers whether the receiver244// ever replied, which is what tells Run to reset the backoff.245func (d *denonClient) runSession(parent context.Context) (answered bool) {246	dialer := &net.Dialer{Timeout: denonDialTimeout}247	conn, err := dialer.DialContext(parent, "tcp", d.address)248	if err != nil {249		return false250	}251	defer conn.Close()252253	ctx, cancel := context.WithCancel(parent)254	defer cancel()255	// The reader blocks until the receiver writes or the socket closes.256	// Closing the socket is what unblocks a reader on a silent receiver.257	defer context.AfterFunc(ctx, func() { conn.Close() })()258259	out := make(chan string, denonQueueDepth)260	d.mutex.Lock()261	d.out = out262	d.mutex.Unlock()263	defer func() {264		d.mutex.Lock()265		d.out = nil266		d.mutex.Unlock()267	}()268269	var writing sync.WaitGroup270	writing.Add(1)271	go func() {272		defer writing.Done()273		defer cancel()274		d.writeLoop(ctx, conn, out)275	}()276277	for _, query := range denonQueries {278		d.Send(query)279	}280281	answered = d.readLoop(conn)282	cancel()283	writing.Wait()284	return answered285}286287// writeLoop is the one goroutine that writes the socket. It sends the288// heartbeat query whenever nothing else has gone out for a heartbeat's289// time.290func (d *denonClient) writeLoop(ctx context.Context, conn net.Conn, out <-chan string) {291	ticker := time.NewTicker(denonHeartbeat)292	defer ticker.Stop()293	last := time.Now()294	for {295		select {296		case <-ctx.Done():297			return298		case command := <-out:299			if _, err := conn.Write([]byte(command + string(denonTerminator))); err != nil {300				return301			}302			last = time.Now()303		case <-ticker.C:304			if time.Since(last) < denonHeartbeat {305				continue306			}307			if _, err := conn.Write([]byte("PW?" + string(denonTerminator))); err != nil {308				return309			}310			last = time.Now()311		}312	}313}314315// readLoop folds every line into the state until the receiver falls316// silent longer than the limit or the socket fails.317func (d *denonClient) readLoop(conn net.Conn) (answered bool) {318	reader := bufio.NewReader(conn)319	for {320		if err := conn.SetReadDeadline(time.Now().Add(denonSilenceLimit)); err != nil {321			return answered322		}323		line, err := reader.ReadString(denonTerminator)324		if err != nil {325			return answered326		}327		line = strings.Trim(line, "\r\n")328		if line == "" {329			continue330		}331		if d.fold(line) {332			answered = true333		}334	}335}336337// fold applies one line under the lock, hands the listener the state338// that came out, and answers whether the line was one this operator339// reads.340func (d *denonClient) fold(line string) bool {341	d.mutex.Lock()342	folded, field, known := applyDenonLine(d.state, line)343	if !known {344		d.mutex.Unlock()345		return false346	}347	// A line the operator reads is the answered round trip that Reachable348	// stands on.349	folded.Reachable = ConditionTrue350	d.state = folded351	d.mutex.Unlock()352353	d.notify(denonEvent{Field: field, State: folded})354	return true355}356357// record moves the reachability verdict and tells the listener, which358// is how a dropped connection reaches the status.359func (d *denonClient) record(status ConditionStatus) {360	d.mutex.Lock()361	if d.state.Reachable == status {362		d.mutex.Unlock()363		return364	}365	d.state.Reachable = status366	state := d.state367	d.mutex.Unlock()368369	d.notify(denonEvent{Field: denonReachableField, State: state})370}371372// notify runs on the reading goroutine, so a listener that blocks holds373// up every later line on the connection.374func (d *denonClient) notify(event denonEvent) {375	if d.listener != nil {376		d.listener(event)377	}378}
level.go 100.0%
1package main23// The level path: the bus payload the room's level travels in, the4// owner mark that says who applies it, and the arithmetic between the5// bus scale and the receiver's own.67import (8	"encoding/json"9	"math"10	"strconv"11)1213// The bus scale runs 0 to 100, and 100 is as loud as the room ever14// goes.15const (16	minLevel = 017	maxLevel = 10018)1920// volumeState is the whole payload on a Player's volume topic, the21// shape every pod for the unit applies. It matches the media operator's22// own type.23type volumeState struct {24	Level int  `json:"level"`25	Muted bool `json:"muted"`26}2728// clamped holds the level inside the bus scale, so no arithmetic29// elsewhere bounds itself.30func (v volumeState) clamped() volumeState {31	if v.Level < minLevel {32		v.Level = minLevel33	}34	if v.Level > maxLevel {35		v.Level = maxLevel36	}37	return v38}3940// parseVolumeState reads one message off the topic. A payload that does41// not decode is no state at all.42func parseVolumeState(payload []byte) (volumeState, bool) {43	var state volumeState44	if err := json.Unmarshal(payload, &state); err != nil {45		return volumeState{}, false46	}47	return state.clamped(), true48}4950// marshalVolumeState encodes a state for the topic. The clamp runs here51// too, so nothing this operator publishes is out of range.52func marshalVolumeState(state volumeState) ([]byte, error) {53	return json.Marshal(state.clamped())54}5556// volumeOwner is the retained payload on <volumeTopic>/owner. While it57// stands, the named owner applies the level and every pod leaves mpv at58// unity.59type volumeOwner struct {60	Owner string `json:"owner"`61}6263// ownerTopic is where the mark for one volume topic sits.64func ownerTopic(volumeTopic string) string {65	return volumeTopic + "/owner"66}6768// ownerMark is the payload this operator publishes while it holds a69// receiver's session.70func ownerMark(receiver string) ([]byte, error) {71	return json.Marshal(volumeOwner{Owner: "receiver/" + receiver})72}7374// The receiver's scale is counted in half steps, because a Denon moves75// in halves and an integer count of them never rounds. unknownHalves is76// a value the receiver has not reported yet.77const unknownHalves = -17879// parseHalfSteps reads the digits a Denon sends. Two digits are whole80// steps and three digits are tenths, and the receiver only ever sends a81// tenth of five.82func parseHalfSteps(digits string) (int, bool) {83	value, err := strconv.Atoi(digits)84	if err != nil || value < 0 {85		return unknownHalves, false86	}87	switch len(digits) {88	case 2:89		return value * 2, true90	case 3:91		if value%5 != 0 {92			return unknownHalves, false93		}94		return value / 5, true95	}96	return unknownHalves, false97}9899// formatHalfSteps writes a count the way a person reads it, which is100// what the status carries.101func formatHalfSteps(halves int) string {102	if halves < 0 {103		return ""104	}105	if halves%2 == 0 {106		return strconv.Itoa(halves / 2)107	}108	return strconv.Itoa(halves/2) + ".5"109}110111// halfStepDigits writes the same count in the digits a set command112// carries: two for a whole step, three for a half.113func halfStepDigits(halves int) string {114	whole := halves / 2115	digits := strconv.Itoa(whole)116	if whole < 10 {117		digits = "0" + digits118	}119	if halves%2 == 1 {120		digits += "5"121	}122	return digits123}124125// halvesForLevel maps the bus scale onto the receiver's, so 100 is the126// ceiling the room is allowed. A ceiling that is not known yet cannot127// be mapped onto.128func halvesForLevel(level, maxHalves int) (int, bool) {129	if maxHalves <= 0 {130		return unknownHalves, false131	}132	if level < minLevel {133		level = minLevel134	}135	if level > maxLevel {136		level = maxLevel137	}138	return int(math.Round(float64(level) * float64(maxHalves) / maxLevel)), true139}140141// levelForHalves maps the receiver's scale back onto the bus against142// the same ceiling, which is what the session publishes after every143// move.144func levelForHalves(halves, maxHalves int) (int, bool) {145	if maxHalves <= 0 || halves < 0 {146		return 0, false147	}148	level := int(math.Round(float64(halves) * maxLevel / float64(maxHalves)))149	if level > maxLevel {150		level = maxLevel151	}152	return level, true153}154155// Where no step is declared, one press moves the receiver by one whole156// unit of its own scale, which is two half steps.157const defaultStepHalves = 2158159// halvesFromScale reads a figure a person wrote in the receiver's own160// scale as a count of half steps. A figure between two half steps is161// not one the receiver can take, so it goes to the nearest.162func halvesFromScale(value float64) int {163	return int(math.Round(value * 2))164}165166// The top of a Denon's own scale, 98, which no declared ceiling may167// exceed.168const denonScaleTop = 196169170// ceilingHalves is what 100 on the bus means. It is the ceiling a171// person declared and nothing else. The receiver's own MVMAX line is172// not a limit: one AVR reported 69.5, then 70.5, then 64.5 in one173// evening, so a ceiling read from it would move under the room.174func ceilingHalves(rule ReceiverVolume) int {175	stated := halvesFromScale(rule.Max)176	if stated <= 0 {177		return 0178	}179	if stated > denonScaleTop {180		return denonScaleTop181	}182	return stated183}184185// pressHalves is how far one press moves the receiver.186func pressHalves(rule ReceiverVolume) int {187	if stated := halvesFromScale(rule.Step); stated > 0 {188		return stated189	}190	return defaultStepHalves191}
main.go 50.0%
1// equipment-operator drives the A/V receivers of a liken cluster and2// reports what each one says. It reaches each receiver over the3// network, applies the session a Player holds on it, and owns the4// room's level while that session stands.5package main67import "os"89// The operator's own environment. Each value is one the operator10// cannot derive, so the Deployment states it and this program reads11// it here.12const (13	// POD_NAMESPACE is the namespace the Deployment runs in. Nothing reads14	// it until the Service front lands.15	podNamespaceVariable = "POD_NAMESPACE"1617	// EQUIPMENT_BUS_ADDRESS is the broker a session's volume topic is read18	// from, as host:port.19	busAddressVariable = "EQUIPMENT_BUS_ADDRESS"20)2122// settings is the whole of the operator's configuration.23type settings struct {24	namespace  string25	busAddress string26}2728// readSettings takes the configuration from the environment alone.29// An unset variable reads as empty, and the caller decides what an30// empty value means, so a missing setting never stops the read.31func readSettings() settings {32	return settings{33		namespace:  os.Getenv(podNamespaceVariable),34		busAddress: os.Getenv(busAddressVariable),35	}36}3738func main() {39	operate()40}
mqtt.go 95.0%
1package main23// The MQTT 3.1.1 wire codec, written straight against the protocol4// the way apiclient.go writes the Kubernetes client. The broker is5// Mosquitto and the protocol is a published standard, so a client6// that speaks the few packets this operator needs costs less than a7// third-party library and its release cadence.8//9// This operator uses QoS 0 alone, because it republishes every retained10// payload it owns on each reconnect.1112import (13	"bufio"14	"fmt"15	"io"16)1718// The MQTT control packet types, in the high nibble of a fixed19// header's first byte. The low nibble carries per-type flags, which20// matter here only for a PUBLISH, where bit 0 is the retain flag.21const (22	mqttConnect   = 0x1023	mqttConnack   = 0x2024	mqttPublish   = 0x3025	mqttSubscribe = 0x8026	mqttSuback    = 0x9027	mqttPingreq   = 0xC028	mqttPingresp  = 0xD029)3031// The MQTT 3.1.1 protocol name and level. The name is the literal32// string "MQTT" and the level is 4, which together tell the broker33// which version of the protocol this client speaks.34const (35	mqttProtocolName  = "MQTT"36	mqttProtocolLevel = 0x0437)3839// The CONNECT flag bits this client sets. Clean session starts every40// connection with no server-side state, which is correct because the41// client re-subscribes and re-publishes on each connect. The will42// bits arrive only when the caller states a will. Username and43// password stay unset, because the in-cluster network is the trust44// boundary and the broker accepts the cluster's own pods.45const (46	connectCleanSession = 0x0247	connectWillFlag     = 0x0448	connectWillRetain   = 0x2049)5051// encodeRemainingLength writes the packet length in the variable-byte52// form the protocol uses: seven bits of length per byte, and the high53// bit set on every byte but the last. One byte covers up to 127, and54// four bytes cover the 268435455 the protocol allows.55func encodeRemainingLength(length int) []byte {56	var encoded []byte57	for {58		digit := byte(length % 128)59		length /= 12860		if length > 0 {61			digit |= 0x8062		}63		encoded = append(encoded, digit)64		if length == 0 {65			return encoded66		}67	}68}6970// decodeRemainingLength reads the variable-byte length back. It reads71// at most four bytes, because a fifth would exceed the protocol's72// limit and marks a stream that has lost frame alignment.73func decodeRemainingLength(reader io.ByteReader) (int, error) {74	length := 075	multiplier := 176	for count := 0; count < 4; count++ {77		digit, err := reader.ReadByte()78		if err != nil {79			return 0, err80		}81		length += int(digit&0x7F) * multiplier82		if digit&0x80 == 0 {83			return length, nil84		}85		multiplier *= 12886	}87	return 0, fmt.Errorf("mqtt: remaining length runs past four bytes")88}8990// appendString writes one length-prefixed UTF-8 string, the shape the91// protocol uses for a topic, a filter, and the client identifier: two92// bytes of length, most significant first, then the bytes.93func appendString(buffer []byte, value string) []byte {94	buffer = append(buffer, byte(len(value)>>8), byte(len(value)))95	return append(buffer, value...)96}9798// appendBytes writes one length-prefixed byte string, the shape a will99// payload takes.100func appendBytes(buffer []byte, value []byte) []byte {101	buffer = append(buffer, byte(len(value)>>8), byte(len(value)))102	return append(buffer, value...)103}104105// packet frames one control packet: a fixed-header first byte, then106// the remaining length, then the body. Every encode function ends107// here, so the length is computed once from the finished body.108func packet(first byte, body []byte) []byte {109	frame := make([]byte, 0, 2+len(body))110	frame = append(frame, first)111	frame = append(frame, encodeRemainingLength(len(body))...)112	return append(frame, body...)113}114115// encodeConnect builds the first packet the client sends. The body is116// the protocol name and level, one flags byte, the keepalive in117// seconds, and the payload: the client identifier and, when the caller118// states one, the will topic and payload. The client authenticates119// with nothing more than its identifier, because the broker accepts120// the cluster's own pods.121func encodeConnect(clientID string, keepalive uint16, will *busWill) []byte {122	flags := byte(connectCleanSession)123	if will != nil {124		flags |= connectWillFlag125		if will.Retained {126			flags |= connectWillRetain127		}128	}129130	var body []byte131	body = appendString(body, mqttProtocolName)132	body = append(body, mqttProtocolLevel, flags)133	body = append(body, byte(keepalive>>8), byte(keepalive))134	body = appendString(body, clientID)135	if will != nil {136		body = appendString(body, will.Topic)137		body = appendBytes(body, will.Payload)138	}139	return packet(mqttConnect, body)140}141142// encodePublish builds a QoS 0 PUBLISH. The retain flag is bit 0 of143// the first byte, and a retained publish tells the broker to hold this144// payload as the topic's last value and deliver it to every later145// subscriber. QoS 0 carries no packet identifier, so the body is the146// length-prefixed topic and then the raw payload.147func encodePublish(topic string, payload []byte, retained bool) []byte {148	first := byte(mqttPublish)149	if retained {150		first |= 0x01151	}152	body := appendString(nil, topic)153	body = append(body, payload...)154	return packet(first, body)155}156157// encodeSubscribe builds a SUBSCRIBE for one topic filter at QoS 0.158// The fixed header is 0x82, because bit 1 is reserved and must be set159// on a SUBSCRIBE. The body is the packet identifier the broker echoes160// in its SUBACK, then the length-prefixed filter and one byte of161// requested QoS.162func encodeSubscribe(packetID uint16, filter string) []byte {163	body := []byte{byte(packetID >> 8), byte(packetID)}164	body = appendString(body, filter)165	body = append(body, 0x00)166	return packet(mqttSubscribe|0x02, body)167}168169// encodePingreq builds the keepalive packet. It carries no body, so it170// is the two bytes 0xC0 0x00, and the broker answers with a PINGRESP.171func encodePingreq() []byte {172	return []byte{mqttPingreq, 0x00}173}174175// readPacket reads one whole control packet: the fixed-header first176// byte, the remaining length, and that many bytes of body. It returns177// the first byte so the caller reads both the packet type in the high178// nibble and the flags in the low nibble.179func readPacket(reader *bufio.Reader) (byte, []byte, error) {180	first, err := reader.ReadByte()181	if err != nil {182		return 0, nil, err183	}184	length, err := decodeRemainingLength(reader)185	if err != nil {186		return 0, nil, err187	}188	body := make([]byte, length)189	if _, err := io.ReadFull(reader, body); err != nil {190		return 0, nil, err191	}192	return first, body, nil193}194195// parseConnack reads the broker's answer to a CONNECT. The body is one196// byte of acknowledge flags and one byte of return code, and a return197// code other than zero is the broker refusing the connection.198func parseConnack(body []byte) error {199	if len(body) < 2 {200		return fmt.Errorf("mqtt: a CONNACK carried %d bytes, want 2", len(body))201	}202	if body[1] != 0x00 {203		return fmt.Errorf("mqtt: the broker refused the connection with code %d", body[1])204	}205	return nil206}207208// parseSuback reads the broker's answer to a SUBSCRIBE. The body is the209// echoed packet identifier and one return code per filter, and a210// return code of 0x80 is the broker refusing that subscription.211func parseSuback(body []byte) error {212	if len(body) < 3 {213		return fmt.Errorf("mqtt: a SUBACK carried %d bytes, want at least 3", len(body))214	}215	for _, code := range body[2:] {216		if code == 0x80 {217			return fmt.Errorf("mqtt: the broker refused a subscription")218		}219	}220	return nil221}222223// parsePublish reads an inbound PUBLISH body into its topic and224// payload. The client subscribes at QoS 0 alone, so an inbound publish225// carries no packet identifier and the payload begins right after the226// length-prefixed topic.227func parsePublish(body []byte) (topic string, payload []byte, ok bool) {228	if len(body) < 2 {229		return "", nil, false230	}231	topicLength := int(body[0])<<8 | int(body[1])232	if len(body) < 2+topicLength {233		return "", nil, false234	}235	return string(body[2 : 2+topicLength]), body[2+topicLength:], true236}
reconcile.go 87.0%
1package main23// The operator's loop: level-triggered, woken by a watch, with a ticker4// as the backstop. A pass reads the whole collection, so a lost event5// costs at most one tick and a restarted operator starts correct.6// One client per Receiver holds the receiver's connection for as long7// as the Receiver stands. A debounced writer folds the burst of lines8// that follows one change into a single status write.910import (11	"context"12	"fmt"13	"os"14	"sync"15	"sync/atomic"16	"time"17)1819// How often the loop reconciles with nothing to prompt it.20const backstopInterval = 30 * time.Second2122// How long a burst of lines is collected before one status write.23var statusDebounce = 250 * time.Millisecond2425// receiverUnit is one Receiver's running parts: the connection, the26// status writer, and the session that holds the level.27type receiverUnit struct {28	name       string29	address    string30	client     *Client31	busAddress string32	now        func() time.Time33	denon      *denonClient34	cancel     context.CancelFunc35	dirty      chan struct{}36	generation atomic.Int6437	// The ceiling and the step live here and not on the session, so an38	// edit to them reaches a standing session with no restart.39	volume atomic.Pointer[ReceiverVolume]4041	mutex   sync.Mutex42	session *session43	applied ReceiverStatus44	written bool45}4647// observe is where every line the receiver sends reaches the operator.48// It wakes the status writer, and it reaches the session that owns the49// level.50func (u *receiverUnit) observe(event denonEvent) {51	poke(u.dirty)52	u.mutex.Lock()53	held := u.session54	u.mutex.Unlock()55	if held != nil {56		held.observe(event)57	}58}5960// report writes the status a burst of lines settles on, one write per61// burst, and only when the write would change something.62func (u *receiverUnit) report(ctx context.Context) {63	for {64		select {65		case <-ctx.Done():66			return67		case <-u.dirty:68		}69		select {70		case <-ctx.Done():71			return72		case <-time.After(statusDebounce):73		}74		drainPokes(u.dirty)75		// A select answers a ready timer as readily as a ready context, so a76		// unit stopped inside the debounce is asked again here before it77		// writes.78		if ctx.Err() != nil {79			return80		}81		u.write()82	}83}8485func (u *receiverUnit) write() {86	status := buildReceiverStatus(u.denon.State(), u.generation.Load(), u.applied.Conditions, u.now())87	if u.written && sameStatus(status, u.applied) {88		return89	}90	if _, err := ApplyReceiverStatus(u.client, u.name, status); err != nil {91		fmt.Fprintf(os.Stderr, "writing the status of receiver %s: %v\n", u.name, err)92		return93	}94	u.applied, u.written = status, true95}9697// setSession starts, flips, replaces, or lifts the session. A session98// that has not changed is left alone, because power and input are one-99// shots the receiver answers once.100//101// A flip of either flag is not a change of session: it reaches the102// session that stands, which keeps its broker connection and its103// adopted level.104func (u *receiverUnit) setSession(ctx context.Context, spec *ReceiverSession) {105	u.mutex.Lock()106	held := u.session107	u.mutex.Unlock()108109	if held != nil && spec != nil && held.spec == spec.withoutFlags() {110		held.setFlags(spec.Active, spec.Awake)111		return112	}113	if held != nil {114		u.mutex.Lock()115		u.session = nil116		u.mutex.Unlock()117		held.stop()118	}119	if spec == nil {120		return121	}122	started := startSession(ctx, u.name, *spec, u.denon, u.busAddress, u.volumeRule)123	u.mutex.Lock()124	u.session = started125	u.mutex.Unlock()126}127128// setVolume records the ceiling and the step a person declared, which129// every press reads.130func (u *receiverUnit) setVolume(spec *ReceiverVolume) {131	rule := ReceiverVolume{}132	if spec != nil {133		rule = *spec134	}135	u.volume.Store(&rule)136}137138// volumeRule answers what the spec states now, so a press made after an139// edit is measured against the edited scale.140func (u *receiverUnit) volumeRule() ReceiverVolume {141	if held := u.volume.Load(); held != nil {142		return *held143	}144	return ReceiverVolume{}145}146147// stop lifts the session and closes the connection, which is what a148// deleted Receiver leaves behind.149func (u *receiverUnit) stop() {150	u.mutex.Lock()151	held := u.session152	u.session = nil153	u.mutex.Unlock()154	if held != nil {155		held.stop()156	}157	u.cancel()158}159160// controller holds what every pass needs and the units it runs.161type controller struct {162	client     *Client163	busAddress string164	wake       chan struct{}165	now        func() time.Time166	units      map[string]*receiverUnit167}168169func newController(client *Client, busAddress string) *controller {170	return &controller{171		client:     client,172		busAddress: busAddress,173		wake:       make(chan struct{}, 1),174		now:        time.Now,175		units:      map[string]*receiverUnit{},176	}177}178179// pass derives every unit from the collection as it stands now. It180// starts a client for a Receiver that names a protocol, moves a session181// that changed, and stops the client of a Receiver that is gone.182func (c *controller) pass(ctx context.Context) error {183	list, err := ListReceivers(c.client)184	if err != nil {185		return err186	}187188	live := map[string]bool{}189	for index := range list.Items {190		receiver := &list.Items[index]191		if receiver.Spec.Denon == nil {192			continue193		}194		live[receiver.Metadata.Name] = true195		c.reconcile(ctx, receiver)196	}197198	for name, unit := range c.units {199		if !live[name] {200			unit.stop()201			delete(c.units, name)202		}203	}204	return nil205}206207// reconcile brings one Receiver's unit up to its spec. An address that208// changed is a different receiver, so the unit is replaced and not209// redialled.210func (c *controller) reconcile(ctx context.Context, receiver *Receiver) {211	name := receiver.Metadata.Name212	unit, held := c.units[name]213	if held && unit.address != receiver.Spec.Denon.Address {214		unit.stop()215		delete(c.units, name)216		held = false217	}218	if !held {219		unit = c.start(ctx, receiver)220		c.units[name] = unit221	}222	unit.generation.Store(receiver.Metadata.Generation)223	unit.setVolume(receiver.Spec.Volume)224	unit.setSession(ctx, receiver.Spec.Session)225}226227func (c *controller) start(parent context.Context, receiver *Receiver) *receiverUnit {228	ctx, cancel := context.WithCancel(parent)229	unit := &receiverUnit{230		name:       receiver.Metadata.Name,231		address:    receiver.Spec.Denon.Address,232		client:     c.client,233		busAddress: c.busAddress,234		now:        c.now,235		cancel:     cancel,236		dirty:      make(chan struct{}, 1),237	}238	unit.setVolume(receiver.Spec.Volume)239	unit.denon = newDenonClient(receiver.Spec.Denon.Address, unit.observe)240	// The generation is stored before anything can write, so the first241	// status names the spec it was built from.242	unit.generation.Store(receiver.Metadata.Generation)243	go unit.denon.Run(ctx)244	go unit.report(ctx)245	// The first write says the operator holds the receiver and has not246	// reached it yet, before any line arrives.247	poke(unit.dirty)248	return unit249}250251// run reconciles once before any event arrives, then on every wake and252// every backstop tick, until ctx ends.253func (c *controller) run(ctx context.Context) {254	ticker := time.NewTicker(backstopInterval)255	defer ticker.Stop()256	for {257		if err := c.pass(ctx); err != nil {258			fmt.Fprintf(os.Stderr, "listing receivers: %v\n", err)259		}260		select {261		case <-ctx.Done():262			c.stopAll()263			return264		case <-c.wake:265		case <-ticker.C:266		}267	}268}269270func (c *controller) stopAll() {271	for name, unit := range c.units {272		unit.stop()273		delete(c.units, name)274	}275}276277// poke never blocks, and a wake channel buffers exactly one, because278// the pass that answers a wake reads the whole collection.279func poke(wake chan<- struct{}) {280	select {281	case wake <- struct{}{}:282	default:283	}284}285286// drainPokes clears the wakes a burst queued behind the one already287// taken.288func drainPokes(wake <-chan struct{}) {289	for {290		select {291		case <-wake:292		default:293			return294		}295	}296}297298// operate reads the configuration and hands it to serve. Every failure299// here ends the process, because the kubelet restarts the pod with300// backoff and the failure shows in kubectl instead of hiding in a retry301// loop.302func operate() {303	config := readSettings()304	if config.busAddress == "" {305		fmt.Fprintf(os.Stderr, "%s is unset; the Deployment must name the broker\n", busAddressVariable)306		os.Exit(1)307	}308309	client, err := InClusterClient()310	if err != nil {311		fmt.Fprintf(os.Stderr, "in-cluster config: %v\n", err)312		os.Exit(1)313	}314315	if err := serve(context.Background(), client, config.busAddress); err != nil {316		fmt.Fprintln(os.Stderr, err)317		os.Exit(1)318	}319}320321// serve proves the collection can be read, starts the watch from the322// version that first list carried, and runs the loop until ctx ends.323func serve(ctx context.Context, client *Client, busAddress string) error {324	list, err := ListReceivers(client)325	if err != nil {326		return fmt.Errorf("listing receivers: %w", err)327	}328329	operator := newController(client, busAddress)330	go watchReceivers(ctx, client, list.Metadata.ResourceVersion, operator.wake)331	operator.run(ctx)332	return nil333}
session.go 91.9%
1package main23// The session a Player holds on a receiver: the one-shot power and4// input, the owner mark on the bus, the level the operator applies5// while the mark stands, and the knob turn it publishes back.67import (8	"context"9	"fmt"10	"os"11	"sync"12	"sync/atomic"13	"time"14)1516// How long the operator waits for the receiver to answer PWON before it17// selects the input anyway.18var sessionPowerWait = 10 * time.Second1920// How often the adopt looks again for what it needs. The spec that21// states the ceiling can land after the session starts, so an adopt22// that cannot map a level yet waits and does not give up.23var sessionAdoptRetry = 500 * time.Millisecond2425// How long a stop waits for the cleared owner mark to reach the broker26// before it closes the connection.27var sessionStopGrace = 200 * time.Millisecond2829// session is one live session: the connection to the broker, the level30// it last sent the receiver, and the marks that tell an echo of its own31// write from a hand on the equipment.32type session struct {33	receiver string34	spec     ReceiverSession35	denon    *denonClient36	bus      *Bus37	// The session's own context, held because a flip of either flag starts38	// the one-shots long after the session started, and they stop when it39	// does.40	ctx    context.Context41	cancel context.CancelFunc4243	// The two flags: whether a Play stands, and whether the room's screen44	// is awake. They gate the one-shots and nothing else. The level path45	// runs whatever they say.46	active atomic.Bool47	awake  atomic.Bool4849	// The gate the input selection waits on, closed when the receiver50	// reports itself on. It is armed again for each selection, so a second51	// Play waits for its own power-on.52	powerMutex sync.Mutex53	powered    chan struct{}5455	reachedOnce sync.Once56	reached     chan struct{}5758	completeOnce sync.Once59	complete     chan struct{}6061	connectedOnce sync.Once62	connected     chan struct{}6364	// scale is read on every press and never held, so a spec edit reaches65	// a standing session with no restart.66	scale func() ReceiverVolume6768	mutex      sync.Mutex69	latest     volumeState70	haveLatest bool7172	awaiting     volumeState73	haveAwaiting bool74	adopted      bool75}7677// startSession opens the session's own broker connection and claims the78// level with a retained owner mark.79//80// Power and input go out once for a session that starts with either81// flag on, and once only when both are on at the start. A session that82// starts with both off owns the level and sends the equipment nothing.83func startSession(ctx context.Context, receiver string, spec ReceiverSession, denon *denonClient, busAddress string, scale func() ReceiverVolume) *session {84	ctx, cancel := context.WithCancel(ctx)85	s := &session{86		receiver:  receiver,87		spec:      spec.withoutFlags(),88		denon:     denon,89		ctx:       ctx,90		cancel:    cancel,91		scale:     scale,92		powered:   make(chan struct{}),93		reached:   make(chan struct{}),94		complete:  make(chan struct{}),95		connected: make(chan struct{}),96	}97	s.mark(denon.State())9899	// The will clears the mark, so an operator that dies hands the level100	// back to the pods that were leaving it alone.101	will := &busWill{Topic: ownerTopic(spec.VolumeTopic), Retained: true}102	s.bus = newBus(busAddress, "equipment-operator-"+receiver, will, s.claim, s.receive)103	s.bus.Subscribe(spec.VolumeTopic)104	go s.bus.Run(ctx)105	go s.adopt(ctx)106	s.setFlags(spec.Active, spec.Awake)107	return s108}109110// setFlags takes both flags as the media operator wrote them: active111// when a Play starts or ends, awake when the room's screen wakes or112// sleeps. Either one turning on runs the one-shots once, and both113// turning on in one write runs them once and not twice. A screen that114// wakes under a standing Play selects the input again, which is what a115// person who reached for the receiver's own power button needs.116func (s *session) setFlags(active, awake bool) {117	played := s.raise(&s.active, active)118	woke := s.raise(&s.awake, awake)119	if played || woke {120		go s.selectInput(s.ctx)121	}122}123124// raise stores one flag and answers whether this is the false to true125// that runs the one-shots. True to false sends nothing, because the126// room may still be listening.127func (s *session) raise(flag *atomic.Bool, on bool) bool {128	if !on {129		flag.Store(false)130		return false131	}132	return flag.CompareAndSwap(false, true)133}134135// stop clears the owner mark, waits for it to reach the broker, and136// closes the connection. It never powers the receiver off, because the137// room may still be listening to something else.138func (s *session) stop() {139	s.publishOwner(nil)140	time.Sleep(sessionStopGrace)141	s.cancel()142}143144// claim publishes the mark on every fresh broker session, because a145// broker that restarted holds none of it.146func (s *session) claim(*Bus) {147	mark, err := ownerMark(s.receiver)148	if err != nil {149		fmt.Fprintf(os.Stderr, "marking the owner of %s: %v\n", s.spec.VolumeTopic, err)150		return151	}152	s.publishOwner(mark)153	s.connectedOnce.Do(func() { close(s.connected) })154}155156func (s *session) publishOwner(payload []byte) {157	s.bus.Publish(ownerTopic(s.spec.VolumeTopic), payload, true)158}159160// receive reads one message off the topic. A message that differs from161// the state the session holds is a press, and the session moves the162// receiver one step in its direction.163func (s *session) receive(topic string, payload []byte) {164	if topic != s.spec.VolumeTopic {165		return166	}167	state, ok := parseVolumeState(payload)168	if !ok {169		return170	}171	s.mutex.Lock()172	// The broker delivers a client's own publish back to it, and it sends173	// the topic's old retained state ahead of it. So the session's own174	// adopt message coming back is the line between the level a previous175	// session left and a press meant for this one. Everything before that176	// line was written for mpv's scale and moves nothing.177	if !s.adopted {178		if s.haveAwaiting && s.awaiting == state {179			s.latest, s.haveLatest, s.adopted = state, true, true180		}181		s.mutex.Unlock()182		return183	}184	// A state the session already holds is its own message coming back.185	// Applying it again would map the level onto a half step the equipment186	// is not on.187	if s.haveLatest && s.latest == state {188		s.mutex.Unlock()189		return190	}191	previous := s.latest192	s.latest, s.haveLatest = state, true193	s.mutex.Unlock()194195	s.press(previous, state)196}197198// press reads the message as a direction and moves the receiver one199// step from where it actually stands, never to a level mapped through200// two scales. Mute is absolute.201func (s *session) press(previous, state volumeState) {202	reading := s.denon.State()203	sent := false204	if state.Muted != reading.Mute {205		s.denon.Send(denonMuteCommand(state.Muted))206		sent = true207	}208	if state.Level != previous.Level {209		if target, moves := s.nextPosition(reading, state.Level > previous.Level); moves {210			s.denon.Send(denonVolumeCommand(target))211			sent = true212		}213	}214	// A press the receiver answers is reported when its answer arrives.215	// One that moves nothing has no answer coming, so the position goes216	// back to the topic now.217	if !sent {218		s.report(reading)219	}220}221222// nextPosition answers where one press puts the receiver, and whether223// it moves at all.224func (s *session) nextPosition(reading denonState, up bool) (int, bool) {225	ceiling := ceilingHalves(s.scale())226	if ceiling <= 0 || reading.Volume < 0 {227		return 0, false228	}229	// A hand can leave the receiver above the ceiling. A press up then230	// moves nothing, and never drops the receiver to the ceiling.231	if up && reading.Volume >= ceiling {232		return 0, false233	}234	// The ceiling bounds the way up and never the way down, so a receiver235	// above it steps down one press at a time.236	step := pressHalves(s.scale())237	if !up {238		target := max(reading.Volume-step, 0)239		return target, target != reading.Volume240	}241	target := min(reading.Volume+step, ceiling)242	return target, target != reading.Volume243}244245// report puts where the receiver actually stands back on the topic, so246// the sidecar's next press counts from a value that matches the247// equipment.248func (s *session) report(reading denonState) {249	level, ok := levelForHalves(reading.Volume, ceilingHalves(s.scale()))250	if !ok {251		return252	}253	position := volumeState{Level: level, Muted: reading.Mute}.clamped()254	payload, err := marshalVolumeState(position)255	if err != nil {256		fmt.Fprintf(os.Stderr, "publishing the level of %s: %v\n", s.spec.VolumeTopic, err)257		return258	}259260	s.mutex.Lock()261	defer s.mutex.Unlock()262	if s.haveLatest && s.latest == position {263		return264	}265	s.bus.Publish(s.spec.VolumeTopic, payload, true)266	s.latest, s.haveLatest = position, true267}268269// observe is the session's half of every line the receiver sends. It270// releases the waits the one-shots stand on, and it reports a position271// the operator did not ask for, such as a knob turn.272func (s *session) observe(event denonEvent) {273	s.mark(event.State)274	switch event.Field {275	case denonVolumeField, denonMuteField:276		s.report(event.State)277	}278}279280// mark releases the three waits a session stands on: the connection the281// commands go out over, the power the input selection follows, and the282// volume reading the adopt needs.283func (s *session) mark(state denonState) {284	if state.Reachable == ConditionTrue {285		s.reachedOnce.Do(func() { close(s.reached) })286	}287	if state.Power == powerOn {288		s.notePower()289	}290	if state.Reachable == ConditionTrue && state.Volume != unknownHalves {291		s.completeOnce.Do(func() { close(s.complete) })292	}293}294295// notePower releases whoever waits for the receiver to come on.296func (s *session) notePower() {297	s.powerMutex.Lock()298	defer s.powerMutex.Unlock()299	select {300	case <-s.powered:301	default:302		close(s.powered)303	}304}305306// armPower answers the gate that closes when the receiver next reports307// itself on. A selection arms it before it reads the power, so a308// receiver that answers in between still releases the wait.309func (s *session) armPower() <-chan struct{} {310	s.powerMutex.Lock()311	defer s.powerMutex.Unlock()312	s.powered = make(chan struct{})313	return s.powered314}315316// adopt is what makes the session willing to apply a level. The topic317// holds whatever the last session left on it, in mpv's scale and not318// this receiver's. So the session publishes where the equipment already319// stands and takes that as the state it holds. Until that message comes320// back, every message on the topic is history and moves nothing.321func (s *session) adopt(ctx context.Context) {322	select {323	case <-ctx.Done():324		return325	case <-s.complete:326	}327	select {328	case <-ctx.Done():329		return330	case <-s.connected:331	}332333	ticker := time.NewTicker(sessionAdoptRetry)334	defer ticker.Stop()335	for !s.publishPosition() {336		select {337		case <-ctx.Done():338			return339		case <-ticker.C:340		}341	}342}343344// publishPosition puts the receiver's position on the topic and answers345// whether it went out. Everything that can stop it is something the346// next try may have: a ceiling nobody had declared yet, or a volume the347// receiver has not reported.348func (s *session) publishPosition() bool {349	state := s.denon.State()350	level, ok := levelForHalves(state.Volume, ceilingHalves(s.scale()))351	if !ok {352		return false353	}354	held := volumeState{Level: level, Muted: state.Mute}.clamped()355	payload, err := marshalVolumeState(held)356	if err != nil {357		fmt.Fprintf(os.Stderr, "adopting the level of %s: %v\n", s.spec.VolumeTopic, err)358		return false359	}360361	s.mutex.Lock()362	defer s.mutex.Unlock()363	s.bus.Publish(s.spec.VolumeTopic, payload, true)364	s.awaiting, s.haveAwaiting = held, true365	return true366}367368// selectInput powers the receiver on, waits for it to say so, and369// selects the input once for whoever asked.370//371// It runs once per flip of either flag, and never re-asserts inside372// one: a hand on the equipment outranks the cluster.373func (s *session) selectInput(ctx context.Context) {374	// A command sent before the connection is open is dropped, and a one-375	// shot is never re-asserted, so the wait for the connection is what376	// makes the one-shot land. The session's own lifetime is the bound: a377	// receiver that never answers has nothing to select.378	select {379	case <-ctx.Done():380		return381	case <-s.reached:382	}383	powered := s.armPower()384	if s.denon.State().Power != powerOn {385		s.denon.Send(denonPowerOnCommand)386		select {387		case <-ctx.Done():388			return389		case <-powered:390		case <-time.After(sessionPowerWait):391		}392	}393	if ctx.Err() != nil {394		return395	}396	s.denon.Send(denonInputCommand(s.spec.Input))397}
status.go 100.0%
1package main23// The status one receiver reports: what it last said, in its own units,4// and the one condition that says whether the operator can still reach5// it.67import "time"89// The one condition this operator reports, and the reason for each10// verdict.11const (12	reachableConditionType = "Reachable"13	reasonConnected        = "Connected"14	reasonUnreachable      = "Unreachable"15	reasonConnecting       = "Connecting"16)1718// reachableWords is the reason and the message each verdict carries.19func reachableWords(status ConditionStatus) (reason, message string) {20	switch status {21	case ConditionTrue:22		return reasonConnected, "the receiver answered"23	case ConditionFalse:24		return reasonUnreachable, "the receiver did not answer"25	}26	return reasonConnecting, "the operator has not reached the receiver yet"27}2829// timestamp writes a moment the way the API server holds one.30func timestamp(at time.Time) string {31	return at.UTC().Format(time.RFC3339)32}3334// reachable builds the condition. It keeps the moment the verdict last35// changed, so the stamp moves only when the verdict flips.36func reachable(status ConditionStatus, generation int64, previous []Condition, now time.Time) Condition {37	reason, message := reachableWords(status)38	condition := Condition{39		Type:               reachableConditionType,40		Status:             status,41		ObservedGeneration: generation,42		Reason:             reason,43		Message:            message,44		LastTransitionTime: timestamp(now),45	}46	for _, held := range previous {47		if held.Type == reachableConditionType && held.Status == status && held.LastTransitionTime != "" {48			condition.LastTransitionTime = held.LastTransitionTime49		}50	}51	return condition52}5354// buildReceiverStatus is the whole status one receiver's state makes,55// in the receiver's own units. status.service stays empty until the56// operator makes the Service front.57func buildReceiverStatus(state denonState, generation int64, previous []Condition, now time.Time) ReceiverStatus {58	return ReceiverStatus{59		Power:      state.Power,60		Input:      state.Input,61		Volume:     formatHalfSteps(state.Volume),62		VolumeMax:  formatHalfSteps(state.VolumeMax),63		Mute:       state.Mute,64		SoundMode:  state.SoundMode,65		Conditions: []Condition{reachable(state.Reachable, generation, previous, now)},66	}67}6869// sameStatus answers whether a write would change anything.70func sameStatus(a, b ReceiverStatus) bool {71	if a.Power != b.Power || a.Input != b.Input || a.Volume != b.Volume ||72		a.VolumeMax != b.VolumeMax || a.Mute != b.Mute ||73		a.SoundMode != b.SoundMode || a.Service != b.Service ||74		len(a.Conditions) != len(b.Conditions) {75		return false76	}77	for index := range a.Conditions {78		if a.Conditions[index] != b.Conditions[index] {79			return false80		}81	}82	return true83}
watch.go 100.0%
1package main23// The watch is an ordinary GET whose response never ends. The API4// server holds the connection open and writes one JSON event per5// change. The event carries no object to the loop, because every pass6// re-lists, so an event is only a wake.78import (9	"context"10	"encoding/json"11	"fmt"12	"net/http"13	"os"14	"time"15)1617// How long a watch waits before it re-lists after a dropped stream.18var watchRetryPause = 2 * time.Second1920// watchReceivers wakes the loop on every change and resumes each stream21// from a resourceVersion, so no change is missed between reconnects. A22// dropped stream and a 410 Gone recover the same way: list the23// collection, wake the loop, and watch again from the list's own24// version.25func watchReceivers(ctx context.Context, client *Client, resourceVersion string, wake chan<- struct{}) {26	for ctx.Err() == nil {27		// The request carries the context, because a read of a stream that28		// never ends blocks until the far end writes, and closing the body29		// from elsewhere waits on that same read.30		resp, err := WatchReceivers(ctx, client, resourceVersion)31		if err == nil && resp.StatusCode == http.StatusOK {32			resourceVersion = readWatchStream(resp, resourceVersion, wake)33		}34		if resp != nil {35			drain(resp.Body)36		}3738		select {39		case <-ctx.Done():40			return41		case <-time.After(watchRetryPause):42		}43		list, err := ListReceivers(client)44		if err != nil {45			fmt.Fprintf(os.Stderr, "listing receivers to resume the watch: %v\n", err)46			continue47		}48		resourceVersion = list.Metadata.ResourceVersion49		poke(wake)50	}51}5253// readWatchStream reads one connection's events. The returned version54// is where the next watch resumes.55func readWatchStream(resp *http.Response, resourceVersion string, wake chan<- struct{}) string {56	decoder := json.NewDecoder(resp.Body)57	for {58		var event struct {59			Type   string `json:"type"`60			Object struct {61				Metadata ObjectMeta `json:"metadata"`62			} `json:"object"`63		}64		if err := decoder.Decode(&event); err != nil {65			return resourceVersion66		}67		if event.Type == "ERROR" {68			return resourceVersion69		}70		if event.Object.Metadata.ResourceVersion != "" {71			resourceVersion = event.Object.Metadata.ResourceVersion72		}73		if event.Type == "BOOKMARK" {74			continue75		}76		poke(wake)77	}78}