mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-24 14:47:23 +00:00
See https://github.com/gesellix/Bose-SoundTouch/issues/181#issuecomment-4313151490
216 lines
5.9 KiB
Go
216 lines
5.9 KiB
Go
// Package main provides a web UI for controlling Bose SoundTouch devices.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/handlers"
|
|
"github.com/gesellix/bose-soundtouch/cmd/soundtouch-web/webtypes"
|
|
"github.com/gesellix/bose-soundtouch/pkg/client"
|
|
"github.com/gesellix/bose-soundtouch/pkg/config"
|
|
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/urfave/cli/v2"
|
|
)
|
|
|
|
//go:embed static
|
|
var staticFS embed.FS
|
|
|
|
func main() {
|
|
app := &cli.App{
|
|
Name: "soundtouch-web",
|
|
Usage: "Web UI for controlling Bose SoundTouch devices",
|
|
Flags: []cli.Flag{
|
|
&cli.StringFlag{
|
|
Name: "port",
|
|
Aliases: []string{"p"},
|
|
Usage: "HTTP port to listen on",
|
|
Value: "8080",
|
|
EnvVars: []string{"PORT"},
|
|
},
|
|
&cli.StringFlag{
|
|
Name: "bind",
|
|
Usage: "Network interface to bind to",
|
|
EnvVars: []string{"BIND_ADDR"},
|
|
},
|
|
},
|
|
Action: func(c *cli.Context) error {
|
|
port := c.String("port")
|
|
bindAddr := c.String("bind")
|
|
|
|
addr := ":" + port
|
|
if bindAddr != "" {
|
|
addr = bindAddr + ":" + port
|
|
}
|
|
|
|
// Create web app without templates (SPA mode)
|
|
webApp := handlers.NewWebApp()
|
|
|
|
// Initialize discovery service
|
|
cfg, err := config.LoadFromEnv()
|
|
if err != nil {
|
|
log.Printf("Failed to load config: %v, using defaults", err)
|
|
|
|
cfg = config.DefaultConfig()
|
|
}
|
|
|
|
cfg.DiscoveryTimeout = 10 * time.Second
|
|
cfg.CacheEnabled = true
|
|
|
|
discoveryService := discovery.NewUnifiedDiscoveryService(cfg)
|
|
|
|
// Discover devices on startup
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
webApp.BroadcastDiscoveryStatus("starting", len(webApp.Devices))
|
|
|
|
discoverDevices(ctx, webApp, discoveryService)
|
|
|
|
webApp.BroadcastDiscoveryStatus("completed", len(webApp.Devices))
|
|
webApp.BroadcastDeviceList()
|
|
}()
|
|
|
|
r := setupRoutes(webApp, discoveryService)
|
|
|
|
log.Printf("SoundTouch Web UI starting on http://%s", addr)
|
|
|
|
return http.ListenAndServe(addr, r)
|
|
},
|
|
}
|
|
|
|
if err := app.Run(os.Args); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func setupRoutes(app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) *chi.Mux {
|
|
r := chi.NewRouter()
|
|
|
|
// Static assets (embedded in binary)
|
|
subFS, _ := fs.Sub(staticFS, "static")
|
|
r.Get("/static/*", http.StripPrefix("/static", http.FileServer(http.FS(subFS))).ServeHTTP)
|
|
|
|
// Serve index.html for SPA routes
|
|
serveIndex := func(w http.ResponseWriter, _ *http.Request) {
|
|
data, _ := staticFS.ReadFile("static/index.html")
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
_, _ = w.Write(data)
|
|
}
|
|
|
|
// WebSocket endpoint
|
|
r.Get("/ws", app.HandleWebSocket)
|
|
|
|
// API endpoints
|
|
r.Get("/api/devices", app.HandleAPIDevices)
|
|
r.Get("/api/device/{id}", app.HandleAPIDevice)
|
|
r.Post("/api/discover", func(w http.ResponseWriter, r *http.Request) {
|
|
app.HandleAPIDiscover(w, r)
|
|
// Trigger discovery
|
|
//nolint:contextcheck // Context is created within goroutine
|
|
go func() {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// Broadcast discovery start
|
|
app.BroadcastDiscoveryStatus("starting", len(app.Devices))
|
|
|
|
discoverDevices(ctx, app, discoveryService)
|
|
|
|
// Broadcast discovery completion and updated device list
|
|
app.BroadcastDiscoveryStatus("completed", len(app.Devices))
|
|
app.BroadcastDeviceList()
|
|
}()
|
|
})
|
|
|
|
// Device control endpoints (GET for most actions, POST for volume/bass)
|
|
r.Get("/api/control/{id}/{action}", app.HandleAPIControl)
|
|
r.Post("/api/control/{id}/{action}", app.HandleAPIControl)
|
|
|
|
// TuneIn browse, search, and playback
|
|
r.Get("/api/tunein/search", app.HandleTuneInSearch)
|
|
r.Get("/api/tunein/navigate", app.HandleTuneInNavigate)
|
|
r.Get("/api/tunein/navigate/*", app.HandleTuneInNavigate)
|
|
r.Post("/api/tunein/play/{id}", app.HandlePlayTuneIn)
|
|
|
|
// Enhanced device control endpoints
|
|
r.Post("/api/device-key/{id}/{key}", app.HandleDeviceKey)
|
|
r.Post("/api/device-volume/{id}/{volume}", app.HandleDirectVolumeControl)
|
|
r.Post("/api/device-power/{id}", app.HandleDevicePower)
|
|
r.Get("/api/device-power-status/{id}", app.HandleDevicePowerStatus)
|
|
r.Get("/api/device-ws/{id}", app.HandleDeviceWebSocket)
|
|
|
|
// SPA routes - serve index.html for client-side routing
|
|
r.Get("/", serveIndex)
|
|
r.Get("/devices", serveIndex)
|
|
r.Get("/device/*", serveIndex)
|
|
|
|
return r
|
|
}
|
|
|
|
func discoverDevices(ctx context.Context, app *handlers.WebApp, discoveryService *discovery.UnifiedDiscoveryService) {
|
|
log.Println("Starting device discovery...")
|
|
|
|
devices, err := discoveryService.DiscoverDevices(ctx)
|
|
if err != nil {
|
|
log.Printf("Discovery failed: %v", err)
|
|
app.BroadcastDiscoveryStatus("failed", len(app.Devices))
|
|
|
|
return
|
|
}
|
|
|
|
log.Printf("Found %d devices", len(devices))
|
|
|
|
for _, device := range devices {
|
|
deviceID := device.Host // Use host as unique ID for now
|
|
|
|
// Skip if we already have this device
|
|
if _, exists := app.Devices[deviceID]; exists {
|
|
app.Devices[deviceID].LastSeen = time.Now()
|
|
continue
|
|
}
|
|
|
|
// Create new device connection
|
|
clientConfig := &client.Config{
|
|
Host: device.Host,
|
|
Port: device.Port,
|
|
Timeout: 10 * time.Second,
|
|
}
|
|
|
|
soundTouchClient := client.NewClient(clientConfig)
|
|
|
|
// Get device info
|
|
deviceInfo, err := soundTouchClient.GetDeviceInfo()
|
|
if err != nil {
|
|
log.Printf("Failed to get device info for %s: %v", device.Host, err)
|
|
continue
|
|
}
|
|
|
|
// Create device connection
|
|
conn := &webtypes.DeviceConnection{
|
|
Client: soundTouchClient,
|
|
DeviceInfo: deviceInfo,
|
|
LastSeen: time.Now(),
|
|
Status: webtypes.DeviceStatus{
|
|
IsConnected: false,
|
|
LastActivity: time.Now(),
|
|
},
|
|
}
|
|
|
|
// Initial status fetch asynchronously to avoid blocking discovery
|
|
go app.UpdateDeviceStatus(deviceID, conn)
|
|
|
|
app.Devices[deviceID] = conn
|
|
|
|
log.Printf("Added device: %s (%s) at %s", deviceInfo.Name, deviceInfo.Type, device.Host)
|
|
}
|
|
}
|