Add QLab OSC client library with TCP/SLIP transport
This commit is contained in:
152
lib/qlab/osc.go
Normal file
152
lib/qlab/osc.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package qlab
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
func oscPad(n int) int {
|
||||
return (4 - n%4) % 4
|
||||
}
|
||||
|
||||
func buildOSC(addr string, args ...any) []byte {
|
||||
var buf []byte
|
||||
|
||||
buf = append(buf, []byte(addr)...)
|
||||
buf = append(buf, 0)
|
||||
for range oscPad(len(addr) + 1) {
|
||||
buf = append(buf, 0)
|
||||
}
|
||||
|
||||
typetag := ","
|
||||
for _, arg := range args {
|
||||
switch arg.(type) {
|
||||
case int32:
|
||||
typetag += "i"
|
||||
case float32:
|
||||
typetag += "f"
|
||||
case string:
|
||||
typetag += "s"
|
||||
case []byte:
|
||||
typetag += "b"
|
||||
case int64:
|
||||
typetag += "h"
|
||||
case float64:
|
||||
typetag += "d"
|
||||
}
|
||||
}
|
||||
buf = append(buf, []byte(typetag)...)
|
||||
buf = append(buf, 0)
|
||||
for range oscPad(len(typetag) + 1) {
|
||||
buf = append(buf, 0)
|
||||
}
|
||||
|
||||
for _, arg := range args {
|
||||
switch v := arg.(type) {
|
||||
case int32:
|
||||
buf = binary.BigEndian.AppendUint32(buf, uint32(v))
|
||||
case float32:
|
||||
buf = binary.BigEndian.AppendUint32(buf, math.Float32bits(v))
|
||||
case string:
|
||||
buf = append(buf, []byte(v)...)
|
||||
buf = append(buf, 0)
|
||||
for range oscPad(len(v) + 1) {
|
||||
buf = append(buf, 0)
|
||||
}
|
||||
case []byte:
|
||||
buf = binary.BigEndian.AppendUint32(buf, uint32(len(v)))
|
||||
buf = append(buf, v...)
|
||||
for range oscPad(len(v)) {
|
||||
buf = append(buf, 0)
|
||||
}
|
||||
case int64:
|
||||
buf = binary.BigEndian.AppendUint64(buf, uint64(v))
|
||||
case float64:
|
||||
buf = binary.BigEndian.AppendUint64(buf, math.Float64bits(v))
|
||||
}
|
||||
}
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
func parseOSC(data []byte) (addr string, args []any, err error) {
|
||||
if len(data) < 4 {
|
||||
return "", nil, fmt.Errorf("osc: message too short")
|
||||
}
|
||||
|
||||
end := 0
|
||||
for end < len(data) && data[end] != 0 {
|
||||
end++
|
||||
}
|
||||
addr = string(data[:end])
|
||||
pos := end + 1 + oscPad(end+1)
|
||||
|
||||
if pos >= len(data) || data[pos] != ',' {
|
||||
return addr, nil, nil
|
||||
}
|
||||
|
||||
ttEnd := pos
|
||||
for ttEnd < len(data) && data[ttEnd] != 0 {
|
||||
ttEnd++
|
||||
}
|
||||
typetag := string(data[pos+1 : ttEnd])
|
||||
pos = ttEnd + 1 + oscPad(ttEnd-pos)
|
||||
|
||||
for _, t := range typetag {
|
||||
switch t {
|
||||
case 'i':
|
||||
if pos+4 > len(data) {
|
||||
return addr, args, fmt.Errorf("osc: truncated int32")
|
||||
}
|
||||
args = append(args, int32(binary.BigEndian.Uint32(data[pos:])))
|
||||
pos += 4
|
||||
case 'f':
|
||||
if pos+4 > len(data) {
|
||||
return addr, args, fmt.Errorf("osc: truncated float32")
|
||||
}
|
||||
args = append(args, math.Float32frombits(binary.BigEndian.Uint32(data[pos:])))
|
||||
pos += 4
|
||||
case 's':
|
||||
end := pos
|
||||
for end < len(data) && data[end] != 0 {
|
||||
end++
|
||||
}
|
||||
args = append(args, string(data[pos:end]))
|
||||
pos = end + 1 + oscPad(end-pos+1)
|
||||
case 'b':
|
||||
if pos+4 > len(data) {
|
||||
return addr, args, fmt.Errorf("osc: truncated blob size")
|
||||
}
|
||||
size := int(binary.BigEndian.Uint32(data[pos:]))
|
||||
pos += 4
|
||||
if pos+size > len(data) {
|
||||
return addr, args, fmt.Errorf("osc: truncated blob")
|
||||
}
|
||||
b := make([]byte, size)
|
||||
copy(b, data[pos:pos+size])
|
||||
args = append(args, b)
|
||||
pos += size + oscPad(size)
|
||||
case 'h':
|
||||
if pos+8 > len(data) {
|
||||
return addr, args, fmt.Errorf("osc: truncated int64")
|
||||
}
|
||||
args = append(args, int64(binary.BigEndian.Uint64(data[pos:])))
|
||||
pos += 8
|
||||
case 'd':
|
||||
if pos+8 > len(data) {
|
||||
return addr, args, fmt.Errorf("osc: truncated float64")
|
||||
}
|
||||
args = append(args, math.Float64frombits(binary.BigEndian.Uint64(data[pos:])))
|
||||
pos += 8
|
||||
case 'T':
|
||||
args = append(args, true)
|
||||
case 'F':
|
||||
args = append(args, false)
|
||||
case 'N':
|
||||
args = append(args, nil)
|
||||
}
|
||||
}
|
||||
|
||||
return addr, args, nil
|
||||
}
|
||||
400
lib/qlab/qlab.go
Normal file
400
lib/qlab/qlab.go
Normal file
@@ -0,0 +1,400 @@
|
||||
package qlab
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultPort = 53000
|
||||
|
||||
slipEnd = 0xC0
|
||||
slipEsc = 0xDB
|
||||
slipEscEnd = 0xDC
|
||||
slipEscEsc = 0xDD
|
||||
)
|
||||
|
||||
type Workspace struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
UniqueID string `json:"uniqueID"`
|
||||
HasPasscode bool `json:"hasPasscode"`
|
||||
}
|
||||
|
||||
type Cue struct {
|
||||
UniqueID string `json:"uniqueID"`
|
||||
Number string `json:"number"`
|
||||
Name string `json:"name"`
|
||||
ListName string `json:"listName"`
|
||||
Type string `json:"type"`
|
||||
ColorName string `json:"colorName"`
|
||||
Flagged bool `json:"flagged"`
|
||||
Armed bool `json:"armed"`
|
||||
Cues []Cue `json:"cues"`
|
||||
}
|
||||
|
||||
type Reply struct {
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Address string `json:"address"`
|
||||
Status string `json:"status"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
|
||||
type Update struct {
|
||||
Address string
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
conn net.Conn
|
||||
mu sync.Mutex
|
||||
pending map[string]chan *Reply
|
||||
idSeq atomic.Uint64
|
||||
updates chan Update
|
||||
}
|
||||
|
||||
func Dial(host string, port int) (*Client, error) {
|
||||
conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", host, port), 5*time.Second)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &Client{
|
||||
conn: conn,
|
||||
pending: make(map[string]chan *Reply),
|
||||
updates: make(chan Update, 64),
|
||||
}
|
||||
go c.readLoop()
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *Client) Close() error {
|
||||
return c.conn.Close()
|
||||
}
|
||||
|
||||
func (c *Client) Updates() <-chan Update {
|
||||
return c.updates
|
||||
}
|
||||
|
||||
func (c *Client) readLoop() {
|
||||
buf := make([]byte, 0, 65536)
|
||||
tmp := make([]byte, 4096)
|
||||
for {
|
||||
n, err := c.conn.Read(tmp)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
buf = append(buf, tmp[:n]...)
|
||||
for {
|
||||
frame, rest, ok := extractSLIPFrame(buf)
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
buf = rest
|
||||
c.handleFrame(frame)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func extractSLIPFrame(data []byte) (frame []byte, rest []byte, ok bool) {
|
||||
start := -1
|
||||
for i, b := range data {
|
||||
if b == slipEnd {
|
||||
if start == -1 {
|
||||
start = i
|
||||
} else {
|
||||
raw := data[start+1 : i]
|
||||
frame := slipDecode(raw)
|
||||
return frame, data[i+1:], true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, data, false
|
||||
}
|
||||
|
||||
func slipEncode(data []byte) []byte {
|
||||
out := []byte{slipEnd}
|
||||
for _, b := range data {
|
||||
switch b {
|
||||
case slipEnd:
|
||||
out = append(out, slipEsc, slipEscEnd)
|
||||
case slipEsc:
|
||||
out = append(out, slipEsc, slipEscEsc)
|
||||
default:
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
out = append(out, slipEnd)
|
||||
return out
|
||||
}
|
||||
|
||||
func slipDecode(data []byte) []byte {
|
||||
out := make([]byte, 0, len(data))
|
||||
for i := 0; i < len(data); i++ {
|
||||
if data[i] == slipEsc && i+1 < len(data) {
|
||||
switch data[i+1] {
|
||||
case slipEscEnd:
|
||||
out = append(out, slipEnd)
|
||||
case slipEscEsc:
|
||||
out = append(out, slipEsc)
|
||||
}
|
||||
i++
|
||||
} else {
|
||||
out = append(out, data[i])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *Client) handleFrame(frame []byte) {
|
||||
addr, args, err := parseOSC(frame)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if len(addr) > 8 && addr[:8] == "/update/" {
|
||||
c.handleUpdate(addr)
|
||||
return
|
||||
}
|
||||
|
||||
if len(addr) > 7 && addr[:7] == "/reply/" {
|
||||
if len(args) == 0 {
|
||||
return
|
||||
}
|
||||
jsonStr, ok := args[0].(string)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var reply Reply
|
||||
if err := json.Unmarshal([]byte(jsonStr), &reply); err != nil {
|
||||
return
|
||||
}
|
||||
replyAddr := addr[6:]
|
||||
c.mu.Lock()
|
||||
ch, exists := c.pending[replyAddr]
|
||||
if exists {
|
||||
delete(c.pending, replyAddr)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if exists {
|
||||
ch <- &reply
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) handleUpdate(addr string) {
|
||||
select {
|
||||
case c.updates <- Update{Address: addr}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) send(addr string, args ...any) error {
|
||||
msg := buildOSC(addr, args...)
|
||||
encoded := slipEncode(msg)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
_, err := c.conn.Write(encoded)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) sendAndWait(addr string, timeout time.Duration, args ...any) (*Reply, error) {
|
||||
ch := make(chan *Reply, 1)
|
||||
c.mu.Lock()
|
||||
c.pending[addr] = ch
|
||||
c.mu.Unlock()
|
||||
|
||||
if err := c.send(addr, args...); err != nil {
|
||||
c.mu.Lock()
|
||||
delete(c.pending, addr)
|
||||
c.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
select {
|
||||
case reply := <-ch:
|
||||
if reply.Status != "ok" {
|
||||
return reply, fmt.Errorf("qlab: %s: %s", addr, reply.Status)
|
||||
}
|
||||
return reply, nil
|
||||
case <-time.After(timeout):
|
||||
c.mu.Lock()
|
||||
delete(c.pending, addr)
|
||||
c.mu.Unlock()
|
||||
return nil, fmt.Errorf("qlab: %s: timeout", addr)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) request(addr string, args ...any) (*Reply, error) {
|
||||
return c.sendAndWait(addr, 5*time.Second, args...)
|
||||
}
|
||||
|
||||
func (c *Client) Version() (string, error) {
|
||||
reply, err := c.request("/version")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var v string
|
||||
if err := json.Unmarshal(reply.Data, &v); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (c *Client) Workspaces() ([]Workspace, error) {
|
||||
reply, err := c.request("/workspaces")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var ws []Workspace
|
||||
if err := json.Unmarshal(reply.Data, &ws); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ws, nil
|
||||
}
|
||||
|
||||
func (c *Client) Connect(workspaceID string, passcode string) error {
|
||||
addr := fmt.Sprintf("/workspace/%s/connect", workspaceID)
|
||||
if passcode != "" {
|
||||
_, err := c.request(addr, passcode)
|
||||
return err
|
||||
}
|
||||
_, err := c.request(addr)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *Client) Disconnect(workspaceID string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/disconnect", workspaceID))
|
||||
}
|
||||
|
||||
func (c *Client) AlwaysReply(workspaceID string, enable bool) error {
|
||||
v := int32(0)
|
||||
if enable {
|
||||
v = 1
|
||||
}
|
||||
return c.send(fmt.Sprintf("/workspace/%s/alwaysReply", workspaceID), v)
|
||||
}
|
||||
|
||||
func (c *Client) EnableUpdates(workspaceID string, enable bool) error {
|
||||
v := int32(0)
|
||||
if enable {
|
||||
v = 1
|
||||
}
|
||||
return c.send(fmt.Sprintf("/workspace/%s/updates", workspaceID), v)
|
||||
}
|
||||
|
||||
func (c *Client) Thump(workspaceID string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/thump", workspaceID))
|
||||
}
|
||||
|
||||
func (c *Client) Go(workspaceID string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/go", workspaceID))
|
||||
}
|
||||
|
||||
func (c *Client) GoTo(workspaceID string, cueNumber string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/go", workspaceID), cueNumber)
|
||||
}
|
||||
|
||||
func (c *Client) Stop(workspaceID string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/stop", workspaceID))
|
||||
}
|
||||
|
||||
func (c *Client) Pause(workspaceID string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/pause", workspaceID))
|
||||
}
|
||||
|
||||
func (c *Client) Resume(workspaceID string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/resume", workspaceID))
|
||||
}
|
||||
|
||||
func (c *Client) Panic(workspaceID string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/panic", workspaceID))
|
||||
}
|
||||
|
||||
func (c *Client) Reset(workspaceID string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/reset", workspaceID))
|
||||
}
|
||||
|
||||
func (c *Client) CueLists(workspaceID string) ([]Cue, error) {
|
||||
addr := fmt.Sprintf("/workspace/%s/cueLists", workspaceID)
|
||||
reply, err := c.request(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cues []Cue
|
||||
if err := json.Unmarshal(reply.Data, &cues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cues, nil
|
||||
}
|
||||
|
||||
func (c *Client) SelectedCues(workspaceID string) ([]Cue, error) {
|
||||
addr := fmt.Sprintf("/workspace/%s/selectedCues", workspaceID)
|
||||
reply, err := c.request(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cues []Cue
|
||||
if err := json.Unmarshal(reply.Data, &cues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cues, nil
|
||||
}
|
||||
|
||||
func (c *Client) RunningCues(workspaceID string) ([]Cue, error) {
|
||||
addr := fmt.Sprintf("/workspace/%s/runningCues", workspaceID)
|
||||
reply, err := c.request(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var cues []Cue
|
||||
if err := json.Unmarshal(reply.Data, &cues); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cues, nil
|
||||
}
|
||||
|
||||
func (c *Client) CueGet(workspaceID string, cueID string, property string) (*Reply, error) {
|
||||
addr := fmt.Sprintf("/workspace/%s/cue_id/%s/%s", workspaceID, cueID, property)
|
||||
return c.request(addr)
|
||||
}
|
||||
|
||||
func (c *Client) CueGetByNumber(workspaceID string, cueNumber string, property string) (*Reply, error) {
|
||||
addr := fmt.Sprintf("/workspace/%s/cue/%s/%s", workspaceID, cueNumber, property)
|
||||
return c.request(addr)
|
||||
}
|
||||
|
||||
func (c *Client) CueSet(workspaceID string, cueID string, property string, value any) error {
|
||||
addr := fmt.Sprintf("/workspace/%s/cue_id/%s/%s", workspaceID, cueID, property)
|
||||
return c.send(addr, value)
|
||||
}
|
||||
|
||||
func (c *Client) CueSetByNumber(workspaceID string, cueNumber string, property string, value any) error {
|
||||
addr := fmt.Sprintf("/workspace/%s/cue/%s/%s", workspaceID, cueNumber, property)
|
||||
return c.send(addr, value)
|
||||
}
|
||||
|
||||
func (c *Client) CueStart(workspaceID string, cueID string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/cue_id/%s/start", workspaceID, cueID))
|
||||
}
|
||||
|
||||
func (c *Client) CueStop(workspaceID string, cueID string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/cue_id/%s/stop", workspaceID, cueID))
|
||||
}
|
||||
|
||||
func (c *Client) CuePause(workspaceID string, cueID string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/cue_id/%s/pause", workspaceID, cueID))
|
||||
}
|
||||
|
||||
func (c *Client) CueResume(workspaceID string, cueID string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/cue_id/%s/resume", workspaceID, cueID))
|
||||
}
|
||||
|
||||
func (c *Client) CueLoad(workspaceID string, cueID string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/cue_id/%s/load", workspaceID, cueID))
|
||||
}
|
||||
|
||||
func (c *Client) CueReset(workspaceID string, cueID string) error {
|
||||
return c.send(fmt.Sprintf("/workspace/%s/cue_id/%s/reset", workspaceID, cueID))
|
||||
}
|
||||
Reference in New Issue
Block a user