Files
-rpi-sendspin/third_party/sendspin-go/pkg/protocol/client.go

643 lines
18 KiB
Go

// ABOUTME: WebSocket client for Sendspin Protocol communication
// ABOUTME: Handles connection, handshake, and message routing
package protocol
import (
"context"
"encoding/binary"
"encoding/json"
"fmt"
"log"
"net/url"
"sync"
"time"
"github.com/gorilla/websocket"
)
const (
// BinaryMessageHeaderSize is the size of binary message header (type byte + timestamp)
BinaryMessageHeaderSize = 1 + 8 // 9 bytes: 1 byte type + 8 byte timestamp
// AudioChunkMessageType is the binary message type ID for audio chunks.
// Per spec: Player role binary messages use IDs 4-7 (bits 000001xx), slot 0 is audio.
AudioChunkMessageType = 4
// Artwork binary message type IDs per spec: Artwork role uses 8-11, one per channel.
// ArtworkChannel0MessageType is channel 0; channels 1-3 use 9, 10, 11 respectively.
ArtworkChannel0MessageType = 8
ArtworkChannel1MessageType = 9
ArtworkChannel2MessageType = 10
ArtworkChannel3MessageType = 11
// ArtworkChannelCount is the maximum number of artwork channels the spec allocates binary IDs for.
ArtworkChannelCount = 4
)
// Heartbeat parameters. Vars (not consts) so tests can override with shorter
// values; production code never mutates them.
//
// pingPeriod: how often we send a control Ping to the server.
// pongWait: read deadline; reset on every Pong arrival.
// writeWait: bound on each WriteControl call so a slow socket doesn't
// block the ping goroutine forever.
//
// Invariant: pingPeriod < pongWait. Otherwise the deadline can expire
// before our next ping has a chance to elicit a pong.
var (
pingPeriod = 30 * time.Second
pongWait = 60 * time.Second
writeWait = 10 * time.Second
)
type Config struct {
ServerAddr string
ClientID string
Name string
Version int
DeviceInfo DeviceInfo
PlayerV1Support PlayerV1Support
ArtworkV1Support *ArtworkV1Support
VisualizerV1Support *VisualizerV1Support
// SupportedRoles overrides the auto-built role list in the client/hello
// message. When nil or empty, handshake() builds the list from the V1
// support fields above (player@v1 + metadata@v1 always, plus artwork@v1
// and visualizer@v1 when their support structs are set). Set this to
// advertise a specific subset — e.g. []string{"metadata@v1"} for a
// metadata-only client, or []string{"controller@v1"} for controller
// scenarios where advertising player@v1 would incorrectly activate
// the audio stream.
SupportedRoles []string
}
type Client struct {
config Config
conn *websocket.Conn
mu sync.RWMutex
AudioChunks chan AudioChunk
ArtworkChunks chan ArtworkChunk
ControlMsgs chan PlayerCommand
TimeSyncResp chan ServerTime
StreamStart chan StreamStart
StreamClear chan StreamClear
StreamEnd chan StreamEnd
ServerState chan ServerStateMessage
GroupUpdate chan GroupUpdate
// serverHello holds the parsed server/hello message received during
// handshake. Nil until Start()/Connect() completes successfully.
serverHello *ServerHello
// rawServerHello holds the raw JSON envelope bytes of the server/hello
// message, useful for conformance testing and protocol debugging where
// the exact wire representation matters.
rawServerHello []byte
connected bool
ctx context.Context
cancel context.CancelFunc
}
type AudioChunk struct {
Timestamp int64 // Microseconds, server clock
Data []byte
}
// ArtworkChunk is an incoming binary artwork frame routed from the artwork@v1 role.
type ArtworkChunk struct {
Channel int // 0-3; derived from the binary message type minus ArtworkChannel0MessageType
Timestamp int64 // Microseconds, server clock
Data []byte
}
func NewClient(config Config) *Client {
return newClient(config)
}
// NewClientFromConn wraps an already-established websocket connection. Use
// this for server-initiated scenarios: the caller has accepted an incoming
// connection on a listening socket and now needs the library to run the
// client-side protocol (handshake, message loop, channel routing) over it.
//
// Unlike NewClient+Connect, the returned client is NOT yet running — call
// Start to perform the handshake and launch the read loop.
//
// The caller transfers ownership of conn to the client; Close will close it.
func NewClientFromConn(config Config, conn *websocket.Conn) *Client {
c := newClient(config)
c.conn = conn
c.connected = true
return c
}
func newClient(config Config) *Client {
ctx, cancel := context.WithCancel(context.Background())
return &Client{
config: config,
AudioChunks: make(chan AudioChunk, 100),
ArtworkChunks: make(chan ArtworkChunk, 10),
ControlMsgs: make(chan PlayerCommand, 10),
TimeSyncResp: make(chan ServerTime, 10),
StreamStart: make(chan StreamStart, 1),
StreamClear: make(chan StreamClear, 10),
StreamEnd: make(chan StreamEnd, 1),
ServerState: make(chan ServerStateMessage, 10),
GroupUpdate: make(chan GroupUpdate, 10),
ctx: ctx,
cancel: cancel,
}
}
// Connect dials the configured server, runs the handshake, and starts the
// message read loop. Use NewClientFromConn + Start when you already have
// an accepted connection (server-initiated scenarios).
func (c *Client) Connect() error {
u := url.URL{Scheme: "ws", Host: c.config.ServerAddr, Path: "/sendspin"}
log.Printf("Connecting to %s", u.String())
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
return fmt.Errorf("dial failed: %w", err)
}
c.mu.Lock()
c.conn = conn
c.connected = true
c.mu.Unlock()
return c.Start()
}
// Start runs the client/hello handshake and launches the background read
// loop. The connection must already be set via NewClientFromConn or Connect.
// Calling Start more than once on the same client is undefined.
func (c *Client) Start() error {
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return fmt.Errorf("no connection: use NewClientFromConn or Connect before Start")
}
conn.SetReadLimit(1 << 20) // 1MB
if err := c.handshake(); err != nil {
c.Close()
return fmt.Errorf("handshake failed: %w", err)
}
// Snapshot the heartbeat vars on Start's goroutine so the values we
// pass to pingLoop and the PongHandler are read here, not from the
// goroutines we're about to spawn. Tests mutate these package-level
// vars between test cases; the snapshot establishes a happens-before
// from the mutation site to the goroutine read, which the race
// detector requires.
pp, pw, ww := pingPeriod, pongWait, writeWait
// Install PongHandler that resets the read deadline. The handler runs
// synchronously from ReadMessage's goroutine, so SetReadDeadline is
// safe to call from here.
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(pw))
})
// Initial deadline. If the server never pongs, ReadMessage in
// readMessages will hit this deadline and exit, triggering Close().
if err := conn.SetReadDeadline(time.Now().Add(pw)); err != nil {
c.Close()
return fmt.Errorf("set read deadline: %w", err)
}
go c.readMessages()
go c.pingLoop(pp, ww)
return nil
}
// pingLoop sends a periodic WebSocket control ping. The server's pong
// reply arrives via the PongHandler installed in Start, which resets
// the read deadline. Exits on context cancel or write failure; on
// write failure we just return — readMessages's defer is responsible
// for the Close, so calling it from here would race that path.
//
// The period and write deadline are passed as args (rather than read
// from the package vars) so the read happens-before the goroutine
// launch; tests that mutate the package vars between cases stay clean
// under -race.
func (c *Client) pingLoop(period, writeDeadline time.Duration) {
ticker := time.NewTicker(period)
defer ticker.Stop()
for {
select {
case <-ticker.C:
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return
}
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(writeDeadline)); err != nil {
// Write failure on a control frame means the connection
// is going down. Don't bother retrying — readMessages
// will hit the read deadline and tear down.
return
}
case <-c.ctx.Done():
return
}
}
}
func (c *Client) handshake() error {
roles := c.buildSupportedRoles()
hello := ClientHello{
ClientID: c.config.ClientID,
Name: c.config.Name,
Version: c.config.Version,
SupportedRoles: roles,
DeviceInfo: &c.config.DeviceInfo,
ArtworkV1Support: c.config.ArtworkV1Support,
VisualizerV1Support: c.config.VisualizerV1Support,
}
// Only advertise player@v1_support when player@v1 is in the role list.
// Strict peers (aiosendspin) reject a client/hello that carries a
// player@v1_support block without a matching role because the schema
// marks supported_formats as non-nullable, and a zero-value Go
// PlayerV1Support encodes its nil slice as JSON null.
if containsRole(roles, "player@v1") {
hello.PlayerV1Support = &c.config.PlayerV1Support
}
msg := Message{
Type: "client/hello",
Payload: hello,
}
if err := c.sendJSON(msg); err != nil {
return fmt.Errorf("failed to send client/hello: %w", err)
}
c.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
_, data, err := c.conn.ReadMessage()
if err != nil {
return fmt.Errorf("failed to read server/hello: %w", err)
}
c.conn.SetReadDeadline(time.Time{}) // Clear deadline
var serverMsg Message
if err := json.Unmarshal(data, &serverMsg); err != nil {
return fmt.Errorf("failed to parse server/hello: %w", err)
}
if serverMsg.Type != "server/hello" {
return fmt.Errorf("expected server/hello, got %s", serverMsg.Type)
}
// Parse the server hello payload into a typed struct and cache both
// the parsed form and the raw envelope bytes for later retrieval via
// ServerHello() / RawServerHello().
payloadBytes, err := json.Marshal(serverMsg.Payload)
if err != nil {
return fmt.Errorf("failed to re-marshal server/hello payload: %w", err)
}
var parsedHello ServerHello
if err := json.Unmarshal(payloadBytes, &parsedHello); err != nil {
return fmt.Errorf("failed to decode server/hello payload: %w", err)
}
c.mu.Lock()
c.serverHello = &parsedHello
c.rawServerHello = append([]byte(nil), data...)
c.mu.Unlock()
log.Printf("Handshake complete with server")
// Send initial state per spec (client/state with nested player object)
state := ClientStateMessage{
Player: &PlayerState{
State: "synchronized",
Volume: 100,
Muted: false,
},
}
stateMsg := Message{
Type: "client/state",
Payload: state,
}
if err := c.sendJSON(stateMsg); err != nil {
return fmt.Errorf("failed to send initial state: %w", err)
}
return nil
}
// ServerHello returns the parsed server/hello message received during
// handshake, or nil if handshake has not yet completed. Safe for concurrent
// reads; callers should not mutate the returned struct.
func (c *Client) ServerHello() *ServerHello {
c.mu.RLock()
defer c.mu.RUnlock()
return c.serverHello
}
// RawServerHello returns the raw JSON envelope bytes of the server/hello
// message received during handshake, or nil if handshake has not yet
// completed. Useful for conformance testing and protocol debugging where
// the exact wire representation matters. Returns a fresh copy; callers
// may mutate it without affecting the client.
func (c *Client) RawServerHello() []byte {
c.mu.RLock()
defer c.mu.RUnlock()
if c.rawServerHello == nil {
return nil
}
out := make([]byte, len(c.rawServerHello))
copy(out, c.rawServerHello)
return out
}
// Send writes a typed envelope to the connected peer. The message is
// wrapped as {"type": msgType, "payload": payload} and sent as a text
// WebSocket frame. Use this for protocol messages the library does not
// yet have a dedicated sender for (e.g. client/command in controller
// scenarios).
func (c *Client) Send(msgType string, payload any) error {
return c.sendJSON(Message{Type: msgType, Payload: payload})
}
// containsRole reports whether the given role is in the list.
func containsRole(roles []string, role string) bool {
for _, r := range roles {
if r == role {
return true
}
}
return false
}
// buildSupportedRoles returns the role list for the client/hello message.
// An explicit Config.SupportedRoles wins when set, since the caller is
// telling us exactly which roles to advertise (metadata-only, controller,
// etc.). Otherwise we build the default list from the V1 support fields:
// player@v1 and metadata@v1 are always advertised for backwards
// compatibility, and artwork@v1 / visualizer@v1 join the list when their
// support structs are set.
func (c *Client) buildSupportedRoles() []string {
if len(c.config.SupportedRoles) > 0 {
return c.config.SupportedRoles
}
roles := []string{"player@v1", "metadata@v1", "controller@v1"}
if c.config.ArtworkV1Support != nil {
roles = append(roles, "artwork@v1")
}
if c.config.VisualizerV1Support != nil {
roles = append(roles, "visualizer@v1")
}
return roles
}
func (c *Client) sendJSON(msg Message) error {
c.mu.RLock()
defer c.mu.RUnlock()
if !c.connected {
return fmt.Errorf("not connected")
}
return c.conn.WriteJSON(msg)
}
func (c *Client) readMessages() {
defer c.Close()
for {
select {
case <-c.ctx.Done():
return
default:
}
messageType, data, err := c.conn.ReadMessage()
if err != nil {
log.Printf("Read error: %v", err)
return
}
if messageType == websocket.BinaryMessage {
c.handleBinaryMessage(data)
} else if messageType == websocket.TextMessage {
c.handleJSONMessage(data)
} else {
log.Printf("Unknown WebSocket message type: %d", messageType)
}
}
}
func (c *Client) handleBinaryMessage(data []byte) {
if len(data) < BinaryMessageHeaderSize {
log.Printf("Invalid binary message: too short")
return
}
msgType := data[0]
timestamp := int64(binary.BigEndian.Uint64(data[1:BinaryMessageHeaderSize]))
payload := data[BinaryMessageHeaderSize:]
switch {
case msgType == AudioChunkMessageType:
select {
case c.AudioChunks <- AudioChunk{Timestamp: timestamp, Data: payload}:
case <-c.ctx.Done():
}
case msgType >= ArtworkChannel0MessageType && msgType <= ArtworkChannel3MessageType:
select {
case c.ArtworkChunks <- ArtworkChunk{
Channel: int(msgType) - ArtworkChannel0MessageType,
Timestamp: timestamp,
Data: payload,
}:
case <-c.ctx.Done():
}
default:
log.Printf("Unknown binary message type: %d", msgType)
}
}
// handleJSONMessage routes JSON messages per spec
func (c *Client) handleJSONMessage(data []byte) {
var msg Message
if err := json.Unmarshal(data, &msg); err != nil {
log.Printf("Failed to parse JSON message: %v", err)
return
}
payloadBytes, err := json.Marshal(msg.Payload)
if err != nil {
log.Printf("Failed to marshal payload for %s: %v", msg.Type, err)
return
}
switch msg.Type {
case "server/command":
var cmdMsg ServerCommandMessage
if err := json.Unmarshal(payloadBytes, &cmdMsg); err != nil {
log.Printf("Failed to parse server/command: %v", err)
return
}
if cmdMsg.Player != nil {
select {
case c.ControlMsgs <- *cmdMsg.Player:
case <-c.ctx.Done():
}
}
case "server/time":
var timeMsg ServerTime
if err := json.Unmarshal(payloadBytes, &timeMsg); err != nil {
log.Printf("Failed to parse server/time: %v", err)
return
}
select {
case c.TimeSyncResp <- timeMsg:
case <-c.ctx.Done():
}
case "stream/start":
var start StreamStart
if err := json.Unmarshal(payloadBytes, &start); err != nil {
log.Printf("Failed to parse stream/start: %v", err)
return
}
select {
case c.StreamStart <- start:
case <-c.ctx.Done():
}
case "stream/clear":
var clear StreamClear
if err := json.Unmarshal(payloadBytes, &clear); err != nil {
log.Printf("Failed to parse stream/clear: %v", err)
return
}
select {
case c.StreamClear <- clear:
case <-c.ctx.Done():
}
case "stream/end":
var end StreamEnd
if err := json.Unmarshal(payloadBytes, &end); err != nil {
log.Printf("Failed to parse stream/end: %v", err)
return
}
select {
case c.StreamEnd <- end:
case <-c.ctx.Done():
}
case "server/state":
var state ServerStateMessage
if err := json.Unmarshal(payloadBytes, &state); err != nil {
log.Printf("Failed to parse server/state: %v", err)
return
}
if state.Metadata != nil {
log.Printf("Metadata: %v - %v (%v)",
derefString(state.Metadata.Artist),
derefString(state.Metadata.Title),
derefString(state.Metadata.Album))
}
select {
case c.ServerState <- state:
case <-time.After(100 * time.Millisecond):
log.Printf("Server state channel full, dropping message")
}
case "group/update":
var update GroupUpdate
if err := json.Unmarshal(payloadBytes, &update); err != nil {
log.Printf("Failed to parse group/update: %v", err)
return
}
log.Printf("Group update: id=%v, state=%v",
derefString(update.GroupID),
derefString(update.PlaybackState))
select {
case c.GroupUpdate <- update:
case <-time.After(100 * time.Millisecond):
log.Printf("Group update channel full, dropping message")
}
default:
log.Printf("Unknown message type: %s", msg.Type)
}
}
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}
// SendState sends a client/state message per spec
func (c *Client) SendState(state PlayerState) error {
msg := Message{
Type: "client/state",
Payload: ClientStateMessage{
Player: &state,
},
}
return c.sendJSON(msg)
}
// SendGoodbye sends a client/goodbye message before disconnecting
func (c *Client) SendGoodbye(reason string) error {
msg := Message{
Type: "client/goodbye",
Payload: ClientGoodbye{
Reason: reason,
},
}
return c.sendJSON(msg)
}
func (c *Client) SendTimeSync(t1 int64) error {
msg := Message{
Type: "client/time",
Payload: ClientTime{
ClientTransmitted: t1,
},
}
return c.sendJSON(msg)
}
func (c *Client) Close() {
c.mu.Lock()
defer c.mu.Unlock()
if c.connected {
c.connected = false
c.cancel()
c.conn.Close()
log.Printf("Connection closed")
}
}
// Done returns a channel that is closed when the client connection is lost.
func (c *Client) Done() <-chan struct{} {
return c.ctx.Done()
}
func (c *Client) IsConnected() bool {
c.mu.RLock()
defer c.mu.RUnlock()
return c.connected
}