mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-19 00:56:16 +00:00
feat(soundtouch-web): add multi-room zone management
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) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
b5ed1745ff
commit
22f999edaf
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user