emberplus

package module
v1.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 14, 2026 License: MIT Imports: 15 Imported by: 0

README

goemberplusclient

An Ember+ consumer (client) for Go.

Ember+ is a control protocol for broadcast equipment. A provider (usually a device) offers a tree of nodes, parameters, matrices and functions; a consumer (a control or monitoring system) browses and controls that tree. This module implements the consumer side only. It has no dependencies outside the standard library.

go get github.com/lukirs95/goemberplusclient

Usage

import emberplus "github.com/lukirs95/goemberplusclient"

c, err := emberplus.Dial(ctx, "192.168.1.10:9000")
if err != nil {
	return err
}
defer c.Close()

// Read a parameter. The client loads the parts of the tree it needs.
el, err := c.Get(ctx, "root/settings/info/version")

// Set a value. The result is the value the provider confirms.
v, err := c.SetValue(ctx, "root/dsp/gain", glow.Real(-3))

// Observe changes.
stop, err := c.Watch(ctx, "root/dsp/gain", func(e emberplus.Event) {
	fmt.Println(e.Element.(*glow.Parameter).Value)
})
defer stop()

The client offers:

Method Purpose
GetDirectory request the children of a node, or a parameter, matrix or function itself
Get, Children read from the mirrored tree, loading missing parts once
IsOnline, IdentifierPath online state (with inheritance) and stable identifier path of an element
SetValue change a parameter value
Invoke call a function
Connect change matrix crosspoints
Watch receive changes of an element
Subscribe start a stream parameter such as a level meter

All methods are safe for concurrent use. See the package documentation for details and examples.

Paths

Elements are addressed by identifier paths ("root/settings/info"), numeric paths ("1.100.3") or a mix of both ("root/100/info"). The client resolves every path itself. Numbers are only valid for one connection, so applications should store identifier paths; IdentifierPath converts any path to that form.

Identifier paths are separated by / only. Identifiers may contain ., as in "dsp/gain.left".

Watch and Subscribe

A provider reports changes of all elements below a node once its directory has been loaded, and Watch takes care of loading it. Stream parameters, typically audio level meters, only report values while they are subscribed and then send updates every 50 to 80 ms. To avoid unintended traffic, Watch never subscribes: call Subscribe explicitly. glow.Parameter.IsStream tells whether a parameter is a stream; Subscribe refuses parameters that are not.

Packages

Package Contents
emberplus the client
pkg/glow the Glow data schema: elements, values, encoding and decoding
pkg/ber the subset of ASN.1 BER used by Ember+
pkg/s101 S101 framing, keep-alives and multi-packet messages

The lower-level packages can be used on their own, for example to analyse captured Ember+ traffic.

emberplus-cli

cmd/emberplus-cli is a terminal browser built on the library. It shows the provider tree, the details of the selected element and live values.

Download the archive for your platform from the releases and run:

emberplus-cli 192.168.1.10 9000    # or 192.168.1.10:9000

To build it from source, run make build; the binary is placed in bin/.

Key Action
↑ ↓ / k j move
→ / l open a node, or move to its first child
e / Enter edit a parameter, invoke a function or show the crosspoints of a matrix (Enter opens nodes)
← / h close a node, or move to its parent
Space open or close a node
g G, PgUp PgDn jump
s subscribe to or unsubscribe from a stream parameter
r reload the children of an open node
q quit

Editing depends on the parameter type: numbers, strings and octets are typed in (with the displayed unit, range checked before sending), enums are chosen from a list, booleans are toggled and triggers are fired after a confirmation. Functions ask for each argument; functions without arguments ask for a confirmation. The status line shows the value the provider confirmed or the result of the invocation.

The crosspoint view of a matrix shows sources as rows and targets as columns, with labels if the matrix reports them. Enter connects or disconnects the selected crosspoint: matrices with one source per target replace the source, N:N matrices add or remove it. Esc returns to the tree.

Key Action in the crosspoint view
↑ ↓ ← → / k j h l move
Space, PgDn, Ctrl+F / b, PgUp, Ctrl+B one page of sources down / up
L, Shift+→ / H, Shift+← one page of targets right / left
g / G first / last source
0 / $ first / last target
Enter connect or disconnect

The CLI is a separate Go module, so its dependencies (Bubble Tea and Lip Gloss) are not added to projects that use the library.

Development

Command Purpose
make test gofmt check, go vet and all tests with the race detector, for the library and the CLI
make fuzz run the fuzz tests (FUZZTIME=30s by default)
make build build emberplus-cli for this machine into bin/
make dist build release archives for Linux, macOS and Windows (amd64 and arm64) into dist/

GitHub Actions run make test and make dist for every push and pull request. Pushing a tag such as v0.1.0 creates a release with the archives.

Compatibility

  • Glow DTD up to 2.50, including matrices, functions, streams with stream descriptors and null values. Templates are skipped, which the specification allows for consumers.
  • Both S101 variants are decoded; the client sends the escaping variant.
  • Tested against a recorded session of a Lawo provider (a GP-I/O box with a matrix): the client sends the same requests as Lawo's own consumer and handles the provider's responses, including matrices whose connections arrive one per message. The recording is part of the tests (pkg/glow/testdata).
  • Functions are implemented according to the specification but have not been tested against a device yet.

Specification

Ember+ is specified by Lawo GmbH. The specification (Ember+ Documentation.pdf) is published in the ember-plus repository. References such as (spec: "Keep-Alive mechanism") in the source code name its chapters.

This project is an independent implementation. It is not affiliated with or endorsed by Lawo GmbH.

License

MIT, see LICENSE.

Documentation

Overview

Package emberplus implements an Ember+ consumer (client). It does not implement a provider.

A Client connects to a provider, keeps a mirror of the provider tree and offers the operations of Ember+: browsing the tree, reading and setting parameter values, invoking functions, connecting matrix crosspoints and observing changes.

c, err := emberplus.Dial(ctx, "192.168.1.10:9000")
if err != nil {
	return err
}
defer c.Close()

el, err := c.Get(ctx, "root/settings/info/version")

Paths

Elements are addressed by path strings:

  • identifier paths separated by "/": "root/settings/info"
  • numeric paths separated by ".": "1.100.3", as printed by glow.Path
  • mixed paths separated by "/": "root/100/info"

In "/" separated paths, segments consisting of digits only are numbers, since identifiers must begin with a letter or "_". Identifiers may contain ".", as in "dsp/gain.left"; "." only separates purely numeric paths.

The client resolves every path itself and loads missing parts of the tree with GetDirectory as needed; later accesses are served from the mirror. Numbers are only valid for one session, so applications should store identifier paths. IdentifierPath converts a path to its identifier form.

Observing changes

A provider reports changes of all elements below a node once their directory has been loaded. Watch registers a callback for an element and loads what is needed for its changes to be reported.

Stream parameters, typically audio level meters, are different: they only report values while subscribed, and usually do so every 50 to 80 ms. Subscribing is therefore never implicit. Use Subscribe to start a stream and Watch to receive its values; glow.Parameter.IsStream reports whether a parameter is a stream.

Concurrency

All methods of Client are safe for concurrent use. The client reads from the connection in its own goroutine, answers keep-alive requests and delivers Watch callbacks from a separate goroutine, so a slow callback does not stall the connection.

Ember+ has no request identifiers except for function invocations. Responses are therefore matched by path: a GetDirectory is answered by the first message reporting children of the requested node, a SetValue by the next value of the parameter. Requests that a provider never answers end when their context ends, or after the timeout set with WithRequestTimeout.

Packages

The client builds on three packages that can also be used on their own: s101 for message framing, ber for the encoding and glow for the data schema.

References such as (spec: "Keep-Alive mechanism") name chapters of the Ember+ specification ("Ember+ Documentation.pdf"), published by Lawo GmbH at https://github.com/Lawo/ember-plus/tree/master/documentation.

Example
package main

import (
	"context"
	"fmt"
	"log"

	emberplus "github.com/lukirs95/goemberplusclient"
	"github.com/lukirs95/goemberplusclient/pkg/glow"
)

func main() {
	ctx := context.Background()
	c, err := emberplus.Dial(ctx, "192.168.1.10:9000")
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	el, err := c.Get(ctx, "root/settings/info/version")
	if err != nil {
		log.Fatal(err)
	}
	fmt.Println(el.(*glow.Parameter).Value)
}

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	ErrClosed           = errors.New("emberplus: client closed")
	ErrNotFound         = errors.New("emberplus: element not found")
	ErrKeepAliveTimeout = errors.New("emberplus: keep-alive timeout")
)
View Source
var ErrNotStream = errors.New("emberplus: parameter is not a stream")
View Source
var ErrWrongType = errors.New("emberplus: wrong element type")

Functions

This section is empty.

Types

type Client

type Client struct {
	// contains filtered or unexported fields
}

Client is an Ember+ consumer. Its methods are safe for concurrent use.

func Dial

func Dial(ctx context.Context, addr string, opts ...Option) (*Client, error)

Dial connects to an Ember+ provider via TCP.

func NewClient

func NewClient(conn io.ReadWriteCloser, opts ...Option) *Client

NewClient starts a client on an established connection. The client owns conn and closes it on Close.

func (*Client) Children

func (c *Client) Children(ctx context.Context, path string) ([]glow.Element, error)

Children returns snapshots of the children of the element at path. Unlike GetDirectory, it only sends a request if the children have not been loaded yet.

Example

This example prints the provider tree, loading every directory.

package main

import (
	"context"
	"fmt"
	"log"

	emberplus "github.com/lukirs95/goemberplusclient"
	"github.com/lukirs95/goemberplusclient/pkg/glow"
)

func main() {
	ctx := context.Background()
	c, err := emberplus.Dial(ctx, "192.168.1.10:9000")
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	var walk func(path string, depth int)
	walk = func(path string, depth int) {
		children, err := c.Children(ctx, path)
		if err != nil {
			log.Fatal(err)
		}
		for _, child := range children {
			id, _ := c.IdentifierPath(ctx, child.ElementPath().String())
			fmt.Printf("%*s%s\n", 2*depth, "", id)
			if n, ok := child.(*glow.Node); ok && (n.IsOnline == nil || *n.IsOnline) {
				walk(child.ElementPath().String(), depth+1)
			}
		}
	}
	walk("", 0)
}

func (*Client) Close

func (c *Client) Close() error

Close closes the connection. Pending requests return ErrClosed.

func (*Client) Connect

func (c *Client) Connect(ctx context.Context, path string, target uint32, sources []uint32, op glow.ConnectionOperation) (glow.Connection, error)

Connect changes the sources connected to a target of a matrix and returns the connection the provider reports. Its Disposition tells whether the change was applied (Modified), queued (Pending) or refused because the target is locked (Locked). With glow.OpAbsolute, sources replaces all connected sources; an empty list disconnects the target.

Example
package main

import (
	"context"
	"fmt"
	"log"

	emberplus "github.com/lukirs95/goemberplusclient"
	"github.com/lukirs95/goemberplusclient/pkg/glow"
)

func main() {
	ctx := context.Background()
	c, err := emberplus.Dial(ctx, "192.168.1.10:9000")
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	// Connect source 3 to target 0, replacing its current sources.
	conn, err := c.Connect(ctx, "root/router/matrix", 0, []uint32{3}, glow.OpAbsolute)
	if err != nil {
		log.Fatal(err)
	}
	if conn.Disposition == glow.Locked {
		fmt.Println("target 0 is locked")
	}
}

func (*Client) Done

func (c *Client) Done() <-chan struct{}

Done is closed when the connection is closed, by Close or because of an error.

func (*Client) Err

func (c *Client) Err() error

Err returns nil while the connection is open. Afterwards it returns ErrClosed after Close, or the error that closed the connection.

func (*Client) Get

func (c *Client) Get(ctx context.Context, path string) (glow.Element, error)

Get returns a snapshot of the element at path. Elements that are not in the mirror yet are loaded with GetDirectory on their ancestors; afterwards Get needs no network access.

The value of a stream parameter is only kept current while it is subscribed; otherwise Get returns the last value received.

func (*Client) GetDirectory

func (c *Client) GetDirectory(ctx context.Context, path string) ([]glow.Element, error)

GetDirectory requests the children of a node, or the element itself for parameters, matrices and functions, and returns a snapshot from the mirror. For the root, path is "".

The request is answered by the first message that reports a child of a node (or the node without properties, meaning it has no children). If a provider spreads the children of a node over several messages, the snapshot may be incomplete; later messages still update the mirror. A matrix is answered once a connection is known for every target.

func (*Client) IdentifierPath

func (c *Client) IdentifierPath(ctx context.Context, path string) (string, error)

IdentifierPath returns the identifier path of the element at path, such as "root/settings/info", which stays valid across sessions.

func (*Client) Invoke

func (c *Client) Invoke(ctx context.Context, path string, args ...glow.Value) (*glow.InvocationResult, error)

Invoke calls a function and waits for its result. The result reports whether the provider executed the function successfully. A function may be invoked more than once by redundant consumers, so functions should not change state relatively (spec: "Ember+ 1.2: Function Extensions").

Example
package main

import (
	"context"
	"fmt"
	"log"

	emberplus "github.com/lukirs95/goemberplusclient"
	"github.com/lukirs95/goemberplusclient/pkg/glow"
)

func main() {
	ctx := context.Background()
	c, err := emberplus.Dial(ctx, "192.168.1.10:9000")
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	res, err := c.Invoke(ctx, "root/presets/recall", glow.Int(4))
	if err != nil {
		log.Fatal(err)
	}
	if !res.Success {
		fmt.Println("recall failed")
	}
}

func (*Client) IsOnline

func (c *Client) IsOnline(ctx context.Context, path string) (bool, error)

IsOnline reports whether the element at path is online: an element is offline if it or one of its ancestors is marked offline (spec: "Glow specific properties").

func (*Client) SetValue

func (c *Client) SetValue(ctx context.Context, path string, v glow.Value) (glow.Value, error)

SetValue requests a new value for a parameter and returns the value the provider confirms. A provider answers an invalid value with the unchanged current value (spec: "Value change requests"), so the result may differ from v.

Another value update of the parameter that arrives before the provider's answer, e.g. caused by a front panel, is taken as the answer.

Example
package main

import (
	"context"
	"fmt"
	"log"
	"time"

	emberplus "github.com/lukirs95/goemberplusclient"
	"github.com/lukirs95/goemberplusclient/pkg/glow"
)

func main() {
	ctx := context.Background()
	c, err := emberplus.Dial(ctx, "192.168.1.10:9000")
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()
	v, err := c.SetValue(ctx, "root/dsp/gain", glow.Real(-3))
	if err != nil {
		log.Fatal(err)
	}
	if v != glow.Real(-3) {
		fmt.Println("the provider kept", v)
	}
}

func (*Client) Subscribe

func (c *Client) Subscribe(ctx context.Context, path string) (unsubscribe func() error, err error)

Subscribe requests the values of a stream parameter, such as audio level meters (spec: "Application defined commands"). Streams typically report values every 50 to 80 ms, so they should only be subscribed while needed. Use Watch to receive the values.

Parameters that are not streams return ErrNotStream without sending anything; glow.Parameter.IsStream reports whether a parameter is a stream. Subscriptions are counted: the provider is unsubscribed when the last unsubscribe function is called.

Example

Stream parameters such as meters only report values while subscribed. This example checks that a parameter is a stream before subscribing.

package main

import (
	"context"
	"fmt"
	"log"
	"time"

	emberplus "github.com/lukirs95/goemberplusclient"
	"github.com/lukirs95/goemberplusclient/pkg/glow"
)

func main() {
	ctx := context.Background()
	c, err := emberplus.Dial(ctx, "192.168.1.10:9000")
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	const meter = "root/mixer/ch1/level"
	el, err := c.Get(ctx, meter)
	if err != nil {
		log.Fatal(err)
	}
	if !el.(*glow.Parameter).IsStream() {
		log.Fatal(meter, " is not a stream")
	}

	unsubscribe, err := c.Subscribe(ctx, meter)
	if err != nil {
		log.Fatal(err)
	}
	defer unsubscribe()
	stop, err := c.Watch(ctx, meter, func(e emberplus.Event) {
		fmt.Println(e.Update.(*glow.Parameter).Value)
	})
	if err != nil {
		log.Fatal(err)
	}
	defer stop()

	time.Sleep(10 * time.Second)
}

func (*Client) Watch

func (c *Client) Watch(ctx context.Context, path string, fn func(Event)) (stop func(), err error)

Watch calls fn for every change of the element at path. Watching a parameter or node loads the directory of its parent if needed, which makes the provider report changes (spec: "Application defined commands"). Watching a matrix additionally loads the matrix itself, which subscribes to its connection changes (spec: "Ember+ 1.1: Matrix Extensions").

Watch never subscribes to streams: a stream parameter only reports values while it is subscribed with Subscribe.

fn is called from a single goroutine in the order the changes arrived. A slow fn delays later events but never the connection itself; events are queued without limit until fn catches up. After stop returns, fn is not called again.

Example
package main

import (
	"context"
	"fmt"
	"log"

	emberplus "github.com/lukirs95/goemberplusclient"
	"github.com/lukirs95/goemberplusclient/pkg/glow"
)

func main() {
	ctx := context.Background()
	c, err := emberplus.Dial(ctx, "192.168.1.10:9000")
	if err != nil {
		log.Fatal(err)
	}
	defer c.Close()

	stop, err := c.Watch(ctx, "root/dsp/gain", func(e emberplus.Event) {
		if v := e.Update.(*glow.Parameter).Value; v != nil {
			fmt.Println("gain changed to", v)
		}
	})
	if err != nil {
		log.Fatal(err)
	}
	defer stop()

	<-c.Done()
}

type Event

type Event struct {
	// Element is a snapshot of the element after the change was applied.
	Element glow.Element
	// Update holds only the fields the provider transmitted.
	Update glow.Element
}

Event reports a change of a watched element.

type Option

type Option func(*config)

Option configures a Client.

func WithKeepAlive

func WithKeepAlive(idle, timeout time.Duration) Option

WithKeepAlive sets how long the connection may be idle before the client sends a keep-alive request, and how long it waits for any data afterwards before closing the connection. The defaults are 5 s each (spec: "Keep-Alive mechanism"). An idle duration of 0 disables keep-alive requests; requests from the provider are always answered.

func WithLogger

func WithLogger(l *slog.Logger) Option

WithLogger sets the logger for protocol errors that do not close the connection, such as undecodable messages. By default nothing is logged.

func WithRequestTimeout

func WithRequestTimeout(d time.Duration) Option

WithRequestTimeout sets the timeout for requests whose context has no deadline. Ember+ does not define a response to requests on unknown elements, so without a timeout such requests could wait forever. The default is 10 s.

Directories

Path Synopsis
pkg
ber
Package ber implements the subset of the Basic Encoding Rules (X.690) used by Ember+ (EmBER).
Package ber implements the subset of the Basic Encoding Rules (X.690) used by Ember+ (EmBER).
glow
Package glow implements the Glow data schema of Ember+ (Glow DTD).
Package glow implements the Glow data schema of Ember+ (Glow DTD).
s101
Package s101 implements S101, the framing protocol that transports Ember+ messages (spec: "Message Framing").
Package s101 implements S101, the framing protocol that transports Ember+ messages (spec: "Message Framing").

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL