Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ jobs:

- uses: actions/setup-go@v7
with:
go-version: "1.26.x"
go-version-file: go.mod
cache: true

- name: Format check (gofmt)
Expand Down Expand Up @@ -50,5 +50,18 @@ jobs:
- name: golangci-lint
uses: golangci/golangci-lint-action@v9.3.0
with:
version: v2.12.2
version: v2.13.2
args: ./...

minimum-go:
runs-on: ubuntu-latest
env:
GOTOOLCHAIN: local
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v7
with:
go-version: "1.26.0"
cache: true
- name: Test minimum supported Go version
run: go test ./...
4 changes: 2 additions & 2 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Check out
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Node
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
Expand All @@ -54,4 +54,4 @@ jobs:

- name: Deploy
id: deployment
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
uses: actions/deploy-pages@368f82528645a54fb793d4d04e342629a3f51346 # v5.0.1
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@

## [Unreleased]

### Fixed
- Label initial `watch` notifications with the correct service even when they arrive before subscription setup finishes.

### Changed
- Prefer Go 1.26.8 for source, CI, and Docker builds while retaining Go 1.26 compatibility; refresh lint tooling, the Dockerfile frontend, and pinned documentation workflow actions.

## [0.3.4] - 2026-07-17

### Highlights
Expand Down
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.7
# syntax=docker/dockerfile:1.27.0

ARG GO_VERSION=1.26
ARG GO_VERSION=1.26.8

FROM golang:${GO_VERSION}-bookworm AS build
WORKDIR /src
Expand Down
2 changes: 2 additions & 0 deletions docs/commands/sonos-watch.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ description: Subscribe to live AVTransport and RenderingControl events from a sp

# `sonos watch`

Initial notifications are queued during subscription setup and labeled with their service once the subscriptions are established.

Subscribes to UPnP eventing on the target speaker and prints state changes as they arrive. Ctrl+C to stop. Useful for debugging, dashboards, or piping into another script.

## Synopsis
Expand Down
2 changes: 2 additions & 0 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ go install github.com/steipete/sonoscli/cmd/sonos@latest
sonos --version
```

Source builds retain Go 1.26 as the minimum and prefer Go 1.26.8 via `go.mod`. CI and Docker builds use the same preferred patch version.

The binary lands in `$(go env GOBIN)` (defaults to `$HOME/go/bin`) — make sure that's on your `PATH`.

## Prebuilt release archives
Expand Down
1 change: 1 addition & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Expected:
- All commands exit `0`
- CI should match `.github/workflows/ci.yml` (`gofmt`, coverage, `golangci-lint`, race tests, `go vet`)
- CI enforces a minimum total coverage of `75%` (statement coverage across `./...`) and a focused `85%` floor for the stream proxy package.
- The main CI job uses the preferred Go 1.26.8 toolchain from `go.mod`; a separate job tests the Go 1.26.0 source minimum with automatic toolchain switching disabled.

## Live network test plan (manual)

Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ module github.com/steipete/sonoscli

go 1.26

toolchain go1.26.8

require github.com/spf13/cobra v1.10.2

require (
Expand Down
27 changes: 13 additions & 14 deletions internal/cli/watch.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"os/signal"
"sort"
"strings"
"sync"
"time"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -80,7 +79,6 @@ func newWatchCmd(flags *rootFlags) *cobra.Command {
callbackURL := fmt.Sprintf("http://%s:%d/notify", listenIP, port)

events := make(chan watchEvent, 128)
var sidToService sync.Map // sid -> service name

mux := http.NewServeMux()
mux.HandleFunc("/notify", func(w http.ResponseWriter, r *http.Request) {
Expand All @@ -93,23 +91,17 @@ func newWatchCmd(flags *rootFlags) *cobra.Command {
body, _ := io.ReadAll(r.Body)
_ = r.Body.Close()

service := "unknown"
if v, ok := sidToService.Load(sid); ok {
service = v.(string)
}

vars, err := sonos.ParseEvent(body)
if err != nil {
vars = map[string]string{"parse_error": err.Error()}
}

select {
case events <- watchEvent{
Time: time.Now().UTC(),
Service: service,
SID: sid,
Seq: seq,
Vars: vars,
Time: time.Now().UTC(),
SID: sid,
Seq: seq,
Vars: vars,
}:
default:
// Drop if the consumer is too slow.
Expand All @@ -130,14 +122,16 @@ func newWatchCmd(flags *rootFlags) *cobra.Command {
return err
}
defer func() { _ = c.Unsubscribe(context.Background(), avtSub) }()
sidToService.Store(avtSub.SID, "avtransport")

rcSub, err := c.SubscribeRenderingControl(ctx, callbackURL, 0)
if err != nil {
return err
}
defer func() { _ = c.Unsubscribe(context.Background(), rcSub) }()
sidToService.Store(rcSub.SID, "renderingcontrol")
sidToService := map[string]string{
avtSub.SID: "avtransport",
rcSub.SID: "renderingcontrol",
}

if !isJSON(flags) && !isTSV(flags) {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Watching events (callback %s). Press Ctrl+C to stop.\n", callbackURL)
Expand All @@ -148,6 +142,11 @@ func newWatchCmd(flags *rootFlags) *cobra.Command {
case <-ctx.Done():
return nil
case ev := <-events:
// Initial NOTIFY requests can arrive before SUBSCRIBE returns its SID.
ev.Service = sidToService[ev.SID]
if ev.Service == "" {
ev.Service = "unknown"
}
if isJSON(flags) {
_ = writeJSONLine(cmd, ev)
continue
Expand Down
20 changes: 19 additions & 1 deletion internal/cli/watch_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ func TestWatchCmdValidatesTarget(t *testing.T) {
func TestWatchCmdEmitsJSONEvent(t *testing.T) {
// Fake Sonos speaker that accepts SUBSCRIBE and records callback URL.
callbackCh := make(chan string, 1)
responseReceived := make(chan struct{})
notifyDone := make(chan struct{})
releaseSubscribe := sync.OnceFunc(func() { close(notifyDone) })
defer releaseSubscribe()

handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
Expand Down Expand Up @@ -93,7 +97,15 @@ func TestWatchCmdEmitsJSONEvent(t *testing.T) {
return &sonos.Client{
IP: u.Hostname(),
Port: port,
HTTP: srv.Client(),
HTTP: &http.Client{Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) {
resp, err := srv.Client().Transport.RoundTrip(r)
if err == nil && r.Method == "SUBSCRIBE" && r.URL.Path == "/MediaRenderer/AVTransport/Event" {
// UPnP delivers the response first; application SID registration can still lag.
close(responseReceived)
<-notifyDone
}
return resp, err
})},
}
}

Expand All @@ -116,6 +128,11 @@ func TestWatchCmdEmitsJSONEvent(t *testing.T) {
case <-time.After(1 * time.Second):
t.Fatalf("timed out waiting for subscribe callback")
}
select {
case <-responseReceived:
case <-time.After(1 * time.Second):
t.Fatalf("timed out waiting for subscription response")
}

// Emit a NOTIFY event to the callback server.
ev := `<e:propertyset xmlns:e="urn:schemas-upnp-org:event-1-0"><e:property>` +
Expand All @@ -137,6 +154,7 @@ func TestWatchCmdEmitsJSONEvent(t *testing.T) {
}
_, _ = io.ReadAll(resp.Body)
_ = resp.Body.Close()
releaseSubscribe()

select {
case err := <-errCh:
Expand Down