From 22f999edaf38af4f1eeafb286affea9ce284f4d7 Mon Sep 17 00:00:00 2001 From: Tobias Gesellchen Date: Sun, 17 May 2026 23:41:49 +0200 Subject: [PATCH] feat(soundtouch-web): add multi-room zone management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports app's commit b040c8a. Five new handlers + five new routes for master/slave stereo-pair and multi-room management; the Zone.js frontend was already shipped in the Preact swap. HandleGetZone GET /api/zone/{id} Returns zone info enriched with member names and role flags (isMaster / isSlave / isStandalone) computed from the perspective of the queried device. Each member carries IP, hwID, and friendly name so the frontend can render readable rows. HandleZoneAdd POST /api/zone/{id}/add/{slaveId} Adds a slave to the zone where {id} is or becomes the master. Standalone master gets a fresh ZoneRequest; existing zone is extended via ToZoneRequest + AddMember. HandleZoneRemove POST /api/zone/{id}/remove/{slaveId} Removes a named slave from the master's existing zone. HandleZoneDissolve POST /api/zone/{id}/dissolve Issues a single-member ZoneRequest so the master goes standalone. HandleZoneLeave POST /api/zone/{id}/leave Slave-side leave: looks up the master via findIPByHwID using the slave's current zone info, then dispatches RemoveMember against the master's client (the speaker protocol requires the master to own the SetZone call). Translation notes: - All handlers go through app.GetDevice(id) instead of direct app.Devices[id] access — matches main's encapsulated-registry refactor (post-base on main, see registry_test.go). - findIPByHwID iterates via app.DeviceSnapshot() instead of ranging over the raw map. - pkg/client (GetZone/SetZone) and pkg/models (ZoneInfo/ZoneRequest/ Member/NewZoneRequest/AddMember/RemoveMember/IsStandalone/ ToZoneRequest) API surface confirmed unchanged from app's base — verbatim function calls. Risk recap (per the earlier audit): this was flagged medium-risk because of pkg/client zone-API drift. Verified clean — all symbols exist with the expected signatures on current main. The #252 stereo- pair work that landed on main was in cmd/soundtouch-cli/cmd_group.go (parallel POST to LEFT and RIGHT), which doesn't intersect with the single-master SetZone pattern these handlers use. go build ./... clean. go test ./... clean (only pre-existing TestDocsConsistency fails, untracked-file issue, unrelated). golangci-lint run ./pkg/service/soundtouchweb/... ./cmd/soundtouch-web/... 0 issues. Co-Authored-By: Claude Opus 4.7 (1M context) --- pkg/service/soundtouchweb/handler.go | 244 +++++++++++++++++++++++++++ pkg/service/soundtouchweb/mount.go | 5 + 2 files changed, 249 insertions(+) diff --git a/pkg/service/soundtouchweb/handler.go b/pkg/service/soundtouchweb/handler.go index 405b90f..f19e140 100644 --- a/pkg/service/soundtouchweb/handler.go +++ b/pkg/service/soundtouchweb/handler.go @@ -681,6 +681,250 @@ func (app *WebApp) HandleTuneInNavigate(w http.ResponseWriter, r *http.Request) } } +// findIPByHwID returns the registry key (IP) for the device whose +// hardware ID matches hwID. Used by zone handlers to bridge between +// the speaker's hwID-keyed zone protocol and our IP-keyed registry. +// Returns "" when no match is found. +func (app *WebApp) findIPByHwID(hwID string) string { + for _, entry := range app.DeviceSnapshot() { + if entry.Device.DeviceInfo != nil && entry.Device.DeviceInfo.DeviceID == hwID { + return entry.ID + } + } + + return "" +} + +// HandleGetZone returns zone info for a device, enriched with member +// names and role flags (isMaster / isSlave / isStandalone) computed +// from the perspective of the queried device. +func (app *WebApp) HandleGetZone(w http.ResponseWriter, r *http.Request) { + deviceID := chi.URLParam(r, "id") + + device, exists := app.GetDevice(deviceID) + if !exists { + app.sendError(w, "Device not found", http.StatusNotFound) + return + } + + if device.Client == nil { + app.sendError(w, "Device client not available", http.StatusInternalServerError) + return + } + + zone, err := device.Client.GetZone() + if err != nil { + app.sendError(w, err.Error(), http.StatusInternalServerError) + return + } + + currentHwID := "" + if device.DeviceInfo != nil { + currentHwID = device.DeviceInfo.DeviceID + } + + masterIP := app.findIPByHwID(zone.Master) + + masterName := "" + if conn, ok := app.GetDevice(masterIP); ok && conn.DeviceInfo != nil { + masterName = conn.DeviceInfo.Name + } + + type memberInfo struct { + IP string `json:"ip"` + HwID string `json:"hwId"` + Name string `json:"name"` + } + + members := make([]memberInfo, 0, len(zone.Members)) + + for _, m := range zone.Members { + name := "" + if conn, ok := app.GetDevice(m.IP); ok && conn.DeviceInfo != nil { + name = conn.DeviceInfo.Name + } + + members = append(members, memberInfo{IP: m.IP, HwID: m.DeviceID, Name: name}) + } + + isMaster := zone.Master == currentHwID && !zone.IsStandalone() + isSlave := false + + for _, m := range zone.Members { + if m.DeviceID == currentHwID { + isSlave = true + break + } + } + + w.Header().Set("Content-Type", "application/json") + + if encErr := json.NewEncoder(w).Encode(webtypes.APIResponse{ + Success: true, + Data: map[string]interface{}{ + "masterIp": masterIP, + "masterHwId": zone.Master, + "masterName": masterName, + "members": members, + "isMaster": isMaster, + "isSlave": isSlave, + "isStandalone": !isMaster && !isSlave, + }, + }); encErr != nil { + http.Error(w, "Failed to encode response", http.StatusInternalServerError) + } +} + +// HandleZoneAdd adds a slave device to the zone where {id} is or +// becomes the master. +func (app *WebApp) HandleZoneAdd(w http.ResponseWriter, r *http.Request) { + masterIP := chi.URLParam(r, "id") + slaveIP := chi.URLParam(r, "slaveId") + + masterConn, ok := app.GetDevice(masterIP) + if !ok { + app.sendError(w, "Master device not found", http.StatusNotFound) + return + } + + slaveConn, ok := app.GetDevice(slaveIP) + if !ok { + app.sendError(w, "Slave device not found", http.StatusNotFound) + return + } + + if masterConn.Client == nil || masterConn.DeviceInfo == nil || slaveConn.DeviceInfo == nil { + app.sendError(w, "Device not ready", http.StatusInternalServerError) + return + } + + masterHwID := masterConn.DeviceInfo.DeviceID + slaveHwID := slaveConn.DeviceInfo.DeviceID + + zone, err := masterConn.Client.GetZone() + if err != nil { + app.sendError(w, err.Error(), http.StatusInternalServerError) + return + } + + var zoneReq *models.ZoneRequest + if zone.IsStandalone() { + zoneReq = models.NewZoneRequest(masterHwID) + } else { + zoneReq = zone.ToZoneRequest() + } + + zoneReq.AddMember(slaveHwID, slaveIP) + + w.Header().Set("Content-Type", "application/json") + app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Device added to zone") +} + +// HandleZoneRemove removes a slave from the zone. +func (app *WebApp) HandleZoneRemove(w http.ResponseWriter, r *http.Request) { + masterIP := chi.URLParam(r, "id") + slaveIP := chi.URLParam(r, "slaveId") + + masterConn, ok := app.GetDevice(masterIP) + if !ok { + app.sendError(w, "Master device not found", http.StatusNotFound) + return + } + + slaveConn, ok := app.GetDevice(slaveIP) + if !ok { + app.sendError(w, "Slave device not found", http.StatusNotFound) + return + } + + if masterConn.Client == nil || slaveConn.DeviceInfo == nil { + app.sendError(w, "Device not ready", http.StatusInternalServerError) + return + } + + zone, err := masterConn.Client.GetZone() + if err != nil { + app.sendError(w, err.Error(), http.StatusInternalServerError) + return + } + + zoneReq := zone.ToZoneRequest() + zoneReq.RemoveMember(slaveConn.DeviceInfo.DeviceID) + + w.Header().Set("Content-Type", "application/json") + app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Device removed from zone") +} + +// HandleZoneDissolve dissolves the zone, making all devices standalone. +func (app *WebApp) HandleZoneDissolve(w http.ResponseWriter, r *http.Request) { + masterIP := chi.URLParam(r, "id") + + masterConn, ok := app.GetDevice(masterIP) + if !ok { + app.sendError(w, "Device not found", http.StatusNotFound) + return + } + + if masterConn.Client == nil || masterConn.DeviceInfo == nil { + app.sendError(w, "Device not ready", http.StatusInternalServerError) + return + } + + zoneReq := models.NewZoneRequest(masterConn.DeviceInfo.DeviceID) + + w.Header().Set("Content-Type", "application/json") + app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Zone dissolved") +} + +// HandleZoneLeave removes the calling device from its zone (slave +// perspective). The slave is identified by {id}; the master is +// located by walking the registry for the hwID the slave's zone +// names as Master, then SetZone is issued against that master. +func (app *WebApp) HandleZoneLeave(w http.ResponseWriter, r *http.Request) { + slaveIP := chi.URLParam(r, "id") + + slaveConn, ok := app.GetDevice(slaveIP) + if !ok { + app.sendError(w, "Device not found", http.StatusNotFound) + return + } + + if slaveConn.Client == nil || slaveConn.DeviceInfo == nil { + app.sendError(w, "Device not ready", http.StatusInternalServerError) + return + } + + zone, err := slaveConn.Client.GetZone() + if err != nil { + app.sendError(w, err.Error(), http.StatusInternalServerError) + return + } + + masterIP := app.findIPByHwID(zone.Master) + if masterIP == "" { + app.sendError(w, "Zone master not found in device list", http.StatusNotFound) + return + } + + masterConn, ok := app.GetDevice(masterIP) + if !ok || masterConn.Client == nil { + app.sendError(w, "Master device not available", http.StatusInternalServerError) + return + } + + masterZone, err := masterConn.Client.GetZone() + if err != nil { + app.sendError(w, err.Error(), http.StatusInternalServerError) + return + } + + zoneReq := masterZone.ToZoneRequest() + zoneReq.RemoveMember(slaveConn.DeviceInfo.DeviceID) + + w.Header().Set("Content-Type", "application/json") + app.sendControlResponse(w, masterConn.Client.SetZone(zoneReq), "Left zone") +} + // HandleDeviceRecents returns recently played items for a device. func (app *WebApp) HandleDeviceRecents(w http.ResponseWriter, r *http.Request) { deviceID := chi.URLParam(r, "id") diff --git a/pkg/service/soundtouchweb/mount.go b/pkg/service/soundtouchweb/mount.go index 7b766fa..48b81f6 100644 --- a/pkg/service/soundtouchweb/mount.go +++ b/pkg/service/soundtouchweb/mount.go @@ -61,6 +61,11 @@ func (app *WebApp) Mount(r chi.Router, discoveryService *discovery.UnifiedDiscov r.Get("/api/device-power-status/{id}", app.HandleDevicePowerStatus) r.Get("/api/device-recents/{id}", app.HandleDeviceRecents) r.Post("/api/device-play/{id}", app.HandleDevicePlay) + r.Get("/api/zone/{id}", app.HandleGetZone) + r.Post("/api/zone/{id}/add/{slaveId}", app.HandleZoneAdd) + r.Post("/api/zone/{id}/remove/{slaveId}", app.HandleZoneRemove) + r.Post("/api/zone/{id}/dissolve", app.HandleZoneDissolve) + r.Post("/api/zone/{id}/leave", app.HandleZoneLeave) r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket) // SPA routes — serve index.html for client-side routing