package live import ( "encoding/json" "errors" "net/http" "time" "github.com/Sendspin/sendspin-go/pkg/sendspin" "rpi-sendspin/internal/pipewire" ) type HTTPServer struct { mgr *Manager store *Store disc *Discovery roster *Roster srv *sendspin.Server } func NewHTTPServer(mgr *Manager, store *Store, disc *Discovery, roster *Roster, srv *sendspin.Server) *HTTPServer { return &HTTPServer{mgr: mgr, store: store, disc: disc, roster: roster, srv: srv} } func (h *HTTPServer) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /api/presets", h.listPresets) mux.HandleFunc("POST /api/presets", h.createPreset) mux.HandleFunc("GET /api/presets/{id}", h.getPreset) mux.HandleFunc("PUT /api/presets/{id}", h.putPreset) mux.HandleFunc("DELETE /api/presets/{id}", h.deletePreset) mux.HandleFunc("GET /api/clients", h.listClients) mux.HandleFunc("GET /api/sources", h.listSources) mux.HandleFunc("GET /api/state", h.getState) mux.HandleFunc("POST /api/active_preset", h.setActivePreset) mux.HandleFunc("POST /api/playback", h.setPlayback) mux.HandleFunc("POST /api/volume", h.setVolume) mux.Handle("/", staticHandler()) return mux } type setActivePresetReq struct { ID string `json:"id"` } func (h *HTTPServer) setActivePreset(w http.ResponseWriter, r *http.Request) { var req setActivePresetReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpError(w, http.StatusBadRequest, "invalid JSON") return } if err := h.mgr.SetActivePreset(req.ID); err != nil { httpError(w, MapManagerError(err), err.Error()) return } w.WriteHeader(http.StatusNoContent) } type setPlaybackReq struct { Command string `json:"command"` } func (h *HTTPServer) setPlayback(w http.ResponseWriter, r *http.Request) { var req setPlaybackReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpError(w, http.StatusBadRequest, "invalid JSON") return } if err := h.mgr.SetPlayback(req.Command); err != nil { httpError(w, MapManagerError(err), err.Error()) return } w.WriteHeader(http.StatusNoContent) } type setVolumeReq struct { Volume int `json:"volume"` } func (h *HTTPServer) setVolume(w http.ResponseWriter, r *http.Request) { var req setVolumeReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { httpError(w, http.StatusBadRequest, "invalid JSON") return } if err := h.mgr.SetVolume(req.Volume); err != nil { httpError(w, MapManagerError(err), err.Error()) return } w.WriteHeader(http.StatusNoContent) } func (h *HTTPServer) listPresets(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, h.store.List()) } func (h *HTTPServer) createPreset(w http.ResponseWriter, r *http.Request) { var p Preset if err := json.NewDecoder(r.Body).Decode(&p); err != nil { httpError(w, http.StatusBadRequest, "invalid JSON") return } if err := h.store.Create(p); err != nil { httpError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusCreated, p) } func (h *HTTPServer) getPreset(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") p, ok := h.store.Get(id) if !ok { httpError(w, http.StatusNotFound, "not found") return } writeJSON(w, http.StatusOK, p) } func (h *HTTPServer) putPreset(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") var p Preset if err := json.NewDecoder(r.Body).Decode(&p); err != nil { httpError(w, http.StatusBadRequest, "invalid JSON") return } if p.ID == "" { p.ID = id } if p.ID != id { httpError(w, http.StatusBadRequest, "id mismatch") return } if err := h.store.Put(p); err != nil { httpError(w, http.StatusBadRequest, err.Error()) return } writeJSON(w, http.StatusOK, p) } func (h *HTTPServer) deletePreset(w http.ResponseWriter, r *http.Request) { id := r.PathValue("id") // If the deleted preset is currently active, transition to off first. active, _, _, _ := h.mgr.State() if active == id { _ = h.mgr.SetActivePreset("") } ok, err := h.store.Delete(id) if err != nil { httpError(w, http.StatusInternalServerError, err.Error()) return } if !ok { httpError(w, http.StatusNotFound, "not found") return } w.WriteHeader(http.StatusNoContent) } type clientView struct { ClientID string `json:"client_id,omitempty"` Instance string `json:"instance,omitempty"` Name string `json:"name,omitempty"` Connected bool `json:"connected"` LastSeen time.Time `json:"last_seen,omitempty"` // Source indicates how this entry was discovered: // "connected" → currently in the active group (after ClientFilter accepted hello) // "roster" → handshaked at least once (we know its client_id; not currently in group) // "mdns" → only seen via mDNS; never handshaked, no client_id known Source string `json:"source"` } func (h *HTTPServer) listClients(w http.ResponseWriter, _ *http.Request) { // Build a unified view of every speaker we know about, keyed by // client_id wherever possible so the UI can present a real, stable // identifier rather than the mDNS instance name (which differs from // client_id on most ESPHome / firmware-derived setups). out := make([]clientView, 0) byID := map[string]int{} // 1) Currently-connected clients: authoritative; takes precedence. for _, c := range h.srv.Clients() { byID[c.ID] = len(out) out = append(out, clientView{ ClientID: c.ID, Name: c.Name, Connected: true, LastSeen: time.Now(), Source: "connected", }) } // 2) Roster: every client_id we've seen via hello, even if rejected. for _, e := range h.roster.List() { if _, ok := byID[e.ClientID]; ok { continue } byID[e.ClientID] = len(out) out = append(out, clientView{ ClientID: e.ClientID, Name: e.Name, Connected: false, LastSeen: e.LastSeen, Source: "roster", }) } // 3) mDNS-only entries: never handshaked, so we don't know // client_id. Useful for "the speaker is on the network but // something is wrong" diagnostics; not directly usable for // preset membership. for _, d := range h.disc.List() { out = append(out, clientView{ Instance: d.Instance, Name: d.Instance, LastSeen: d.LastSeen, Source: "mdns", }) } writeJSON(w, http.StatusOK, out) } type sourceView struct { Name string `json:"name"` Description string `json:"description"` IsMonitor bool `json:"is_monitor"` } func (h *HTTPServer) listSources(w http.ResponseWriter, _ *http.Request) { targets, err := pipewire.ListCaptureTargets() if err != nil { httpError(w, http.StatusInternalServerError, err.Error()) return } out := make([]sourceView, 0, len(targets)) for _, t := range targets { out = append(out, sourceView{ Name: t.Name, Description: t.Description, IsMonitor: t.IsMonitor, }) } writeJSON(w, http.StatusOK, out) } type stateView struct { ActivePreset string `json:"active_preset"` Playback Playback `json:"playback"` Volume int `json:"volume"` Members []MemberState `json:"members"` } func (h *HTTPServer) getState(w http.ResponseWriter, _ *http.Request) { active, pb, vol, members := h.mgr.State() // Fill in names for offline members from the roster — the manager // only knows names of currently-connected clients. if len(members) > 0 { roster := map[string]string{} for _, e := range h.roster.List() { roster[e.ClientID] = e.Name } for i := range members { if members[i].Name == "" { members[i].Name = roster[members[i].ClientID] } } } writeJSON(w, http.StatusOK, stateView{ ActivePreset: active, Playback: pb, Volume: vol, Members: members, }) } func writeJSON(w http.ResponseWriter, status int, body any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(body) } func httpError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } // MapManagerError converts manager sentinel errors to HTTP status codes. // Currently unused by the HTTP layer (handlers use sentinel directly), // but exported for potential use by the MQTT bridge command path. func MapManagerError(err error) int { switch { case errors.Is(err, errNotFound): return http.StatusNotFound case errors.Is(err, errBadRequest), errors.Is(err, errNoActivePreset): return http.StatusBadRequest default: return http.StatusInternalServerError } }