Files
-rpi-sendspin/internal/live/http.go

294 lines
7.9 KiB
Go

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()
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
}
}