Files
Tobias GesellchenandClaude Opus 4.7 c668c732df fix(#308): handle placeholder presets without panicking
The ST10's /presets response after a factory reset emits self-closing
<preset/> entries with no ContentItem child. cmd/soundtouch-cli's
getPresets() handled the missing ContentItem in GetDisplayName() but
then dereferenced preset.ContentItem.Source on the next line, panicking
with "invalid memory address or nil pointer dereference" the moment the
loop reached the first empty entry.

A second placeholder shape was observed on healthy devices that were
never reset: <preset id="0"><ContentItem source="INVALID_SOURCE"
isPresetable="true"/></preset>. ContentItem is non-nil here, so the
previous "ContentItem != nil" guard at other call sites still let
these placeholders through into listings and into the AfterTouch
datastore.

Fix shape:

  pkg/models/presets.go - extend Preset.IsEmpty() to recognise both
  shapes (ContentItem == nil, OR Source == "" / "INVALID_SOURCE").
  HasPresets, GetEmptyPresetSlots and GetUsedPresetSlots become honest
  about which slots actually carry playable content.

  cmd/soundtouch-cli/cmd_info.go (the crash site) - filter the slice
  via IsEmpty before the print loop, and switch the still-printed
  fields to the existing nil-safe Get* helpers.

  pkg/service/setup/setup.go - upgrade syncPresets's "ContentItem ==
  nil" continue-guard to IsEmpty so Shape B placeholders don't get
  persisted in the AfterTouch datastore and then surface as junk
  rows in the admin web UI.

  cmd/soundtouch-cli/cmd_events.go, cmd/websocket-demo/main.go - same
  nil-guard upgrade. These already nil-checked so were crash-safe;
  the change is for consistency and to stop printing
  "Preset 0:  (INVALID_SOURCE)" demo lines.

  examples/preset-management/main.go - had the same latent crash as
  cmd_info.go; same fix shape.

Regression tests in pkg/models/presets_test.go cover both shapes using
the exact XML observed in the wild: the reporter's three <preset/>
placeholders plus the three INVALID_SOURCE entries from a live device.
The reporter XML test walks every preset through the same accessor
path the CLI used and asserts no panic.

The soundtouch-web Go code does not deref preset.ContentItem.X
anywhere - presets flow through as JSON - so no separate crash trap
exists there. The web frontend will pick up the cleaner data once
syncPresets stops persisting placeholders.

Closes #308

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 11:36:55 +02:00

719 lines
19 KiB
Go

package main
import (
"fmt"
"time"
"github.com/gesellix/bose-soundtouch/pkg/models"
"github.com/urfave/cli/v2"
)
// getDeviceInfo handles the device info command
func getDeviceInfo(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
PrintDeviceHeader("Getting device information", clientConfig.Host, clientConfig.Port)
deviceInfo, err := client.GetDeviceInfo()
if err != nil {
return fmt.Errorf("failed to get device info: %w", err)
}
// Display basic device information
fmt.Printf("Device Information:\n")
fmt.Printf(" Name: %s\n", deviceInfo.Name)
fmt.Printf(" Type: %s\n", deviceInfo.Type)
fmt.Printf(" Device ID: %s\n", deviceInfo.DeviceID)
if deviceInfo.MargeAccountUUID != "" {
fmt.Printf(" Account UUID: %s\n", deviceInfo.MargeAccountUUID)
}
if len(deviceInfo.NetworkInfo) > 0 {
fmt.Printf(" Network Info:\n")
for _, net := range deviceInfo.NetworkInfo {
fmt.Printf(" - Type: %s\n", net.Type)
fmt.Printf(" MAC Address: %s\n", net.MacAddress)
fmt.Printf(" IP Address: %s\n", net.IPAddress)
}
}
if len(deviceInfo.Components) > 0 {
fmt.Printf(" Components:\n")
for _, component := range deviceInfo.Components {
fmt.Printf(" - Category: %s\n", component.ComponentCategory)
if component.SoftwareVersion != "" {
fmt.Printf(" Software Version: %s\n", component.SoftwareVersion)
}
if component.SerialNumber != "" {
fmt.Printf(" Serial Number: %s\n", component.SerialNumber)
}
}
}
return nil
}
// getDeviceName handles getting the device name
func getDeviceName(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
PrintDeviceHeader("Getting device name", clientConfig.Host, clientConfig.Port)
name, err := client.GetName()
if err != nil {
return fmt.Errorf("failed to get device name: %w", err)
}
fmt.Printf("Device Name: %s\n", name)
return nil
}
// setDeviceName handles setting the device name
func setDeviceName(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
newName := c.String("value")
if newName == "" {
return fmt.Errorf("device name cannot be empty")
}
PrintDeviceHeader(fmt.Sprintf("Setting device name to '%s'", newName), clientConfig.Host, clientConfig.Port)
err = client.SetName(newName)
if err != nil {
return fmt.Errorf("failed to set device name: %w", err)
}
PrintSuccess(fmt.Sprintf("Device name set to '%s'", newName))
return nil
}
// getCapabilities handles getting device capabilities
func getCapabilities(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
PrintDeviceHeader("Getting device capabilities", clientConfig.Host, clientConfig.Port)
capabilities, err := client.GetCapabilities()
if err != nil {
return fmt.Errorf("failed to get capabilities: %w", err)
}
fmt.Printf("Device Capabilities:\n")
fmt.Printf(" Device ID: %s\n", capabilities.DeviceID)
// Network capabilities
networkCaps := capabilities.GetNetworkCapabilities()
if len(networkCaps) > 0 {
fmt.Printf(" Network Capabilities:\n")
for _, cap := range networkCaps {
fmt.Printf(" - %s\n", cap)
}
}
// Extended capabilities
capNames := capabilities.GetCapabilityNames()
if len(capNames) > 0 {
fmt.Printf(" Extended Capabilities:\n")
for _, capName := range capNames {
capability := capabilities.GetCapabilityByName(capName)
fmt.Printf(" - %s", capName)
if capability.URL != "" {
fmt.Printf(" (%s)", capability.URL)
}
fmt.Println()
}
}
return nil
}
// getPresets handles getting device presets
func getPresets(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
PrintDeviceHeader("Getting device presets", clientConfig.Host, clientConfig.Port)
presets, err := client.GetPresets()
if err != nil {
return fmt.Errorf("failed to get presets: %w", err)
}
fmt.Printf("Device Presets:\n")
// Filter out placeholder presets the firmware emits for unconfigured
// slots (issue #308): self-closing <preset/> after factory reset,
// or <ContentItem source="INVALID_SOURCE"/> on healthy devices.
// IsEmpty covers both shapes; accessing fields like ContentItem.Source
// directly on the first shape panics.
configured := make([]models.Preset, 0, len(presets.Preset))
for _, p := range presets.Preset {
if !p.IsEmpty() {
configured = append(configured, p)
}
}
if len(configured) == 0 {
fmt.Printf(" No presets configured\n")
return nil
}
fmt.Printf(" Configured Presets:\n")
for _, preset := range configured {
fmt.Printf(" %d. %s\n", preset.ID, preset.GetDisplayName())
fmt.Printf(" Source: %s\n", preset.GetSource())
if account := preset.GetSourceAccount(); account != "" && account != preset.GetSource() {
fmt.Printf(" Account: %s\n", account)
}
if location := preset.GetLocation(); location != "" {
fmt.Printf(" Location: %s\n", location)
}
// Show preset creation time if available
if preset.CreatedOn != nil && *preset.CreatedOn != 0 {
createdTime := time.Unix(*preset.CreatedOn, 0)
fmt.Printf(" Created: %s\n", createdTime.Format("2006-01-02 15:04:05"))
}
fmt.Println()
}
return nil
}
// getSupportedURLs handles getting supported URLs/endpoints
func getSupportedURLs(c *cli.Context) error {
clientConfig := GetClientConfig(c)
PrintDeviceHeader("Getting supported URLs", clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
supportedURLs, err := client.GetSupportedURLs()
if err != nil {
PrintError(fmt.Sprintf("Failed to get supported URLs: %v", err))
return err
}
printSupportedURLs(supportedURLs, c)
return nil
}
// printSupportedURLs formats and displays supported URLs information
func printSupportedURLs(supportedURLs *models.SupportedURLsResponse, c *cli.Context) {
verbose := c.Bool("verbose")
showFeatures := c.Bool("features")
fmt.Printf("Device Supported URLs:\n")
fmt.Printf(" Device ID: %s\n", supportedURLs.DeviceID)
fmt.Printf(" Total Endpoints: %d\n", supportedURLs.GetURLCount())
// Show feature completeness score
completeness, supported, total := supportedURLs.GetFeatureCompleteness()
fmt.Printf(" Feature Coverage: %d%% (%d/%d features)\n\n", completeness, supported, total)
if showFeatures || (!verbose && !showFeatures) {
// Show feature mapping (default view)
printFeatureMapping(supportedURLs, verbose)
}
if verbose {
fmt.Println()
printDetailedEndpoints(supportedURLs)
}
if !showFeatures && !verbose {
fmt.Printf("\n💡 Options:\n")
fmt.Printf(" --features Show detailed feature mapping and CLI commands\n")
fmt.Printf(" --verbose Show complete endpoint list\n")
}
}
// printFeatureMapping displays the feature-to-endpoint mapping
func printFeatureMapping(supportedURLs *models.SupportedURLsResponse, verbose bool) {
fmt.Printf("🎯 Device Feature Support:\n\n")
// Get features organized by category
featuresByCategory := supportedURLs.GetFeaturesByCategory()
printFeatureCategories(featuresByCategory, supportedURLs, verbose)
printMissingEssentialFeatures(supportedURLs)
printPartiallyImplementedFeatures(supportedURLs, verbose)
}
func printFeatureCategories(featuresByCategory map[string][]models.EndpointFeature, supportedURLs *models.SupportedURLsResponse, verbose bool) {
categoryInfo := map[string]string{
"Core": "⚡",
"Audio": "🔊",
"Playback": "▶️",
"Sources": "📱",
"Content": "📻",
"Presets": "⭐",
"Multiroom": "🏠",
"Network": "🌐",
"System": "⚙️",
}
categoryOrder := []string{"Core", "Audio", "Playback", "Sources", "Content", "Presets", "Multiroom", "Network", "System"}
for _, category := range categoryOrder {
features := featuresByCategory[category]
if len(features) == 0 {
continue
}
emoji := categoryInfo[category]
fmt.Printf("%s %s (%d features):\n", emoji, category, len(features))
for _, feature := range features {
printFeatureStatus(feature, supportedURLs, verbose)
}
fmt.Println()
}
}
func printFeatureStatus(feature models.EndpointFeature, supportedURLs *models.SupportedURLsResponse, verbose bool) {
supportedEndpoints := countSupportedEndpoints(feature, supportedURLs)
status := "✅"
if supportedEndpoints < len(feature.Endpoints) && len(feature.Endpoints) > 1 {
status = "⚠️" // Partial support
}
fmt.Printf(" %s %s", status, feature.Name)
if feature.Essential {
fmt.Printf(" ⭐")
}
fmt.Printf("\n")
if verbose {
printVerboseFeatureDetails(feature, supportedEndpoints)
}
}
func countSupportedEndpoints(feature models.EndpointFeature, supportedURLs *models.SupportedURLsResponse) int {
supportedEndpoints := 0
for _, endpoint := range feature.Endpoints {
if supportedURLs.HasURL(endpoint) {
supportedEndpoints++
}
}
return supportedEndpoints
}
func printVerboseFeatureDetails(feature models.EndpointFeature, supportedEndpoints int) {
fmt.Printf(" %s\n", feature.Description)
fmt.Printf(" CLI: %s\n", feature.CLICommand)
fmt.Printf(" Endpoints: %d/%d supported", supportedEndpoints, len(feature.Endpoints))
if supportedEndpoints < len(feature.Endpoints) {
fmt.Printf(" (partial)")
}
fmt.Printf("\n")
}
func printMissingEssentialFeatures(supportedURLs *models.SupportedURLsResponse) {
missingEssential := supportedURLs.GetMissingEssentialFeatures()
if len(missingEssential) > 0 {
fmt.Printf("⚠️ Missing Essential Features:\n")
for _, feature := range missingEssential {
fmt.Printf(" ❌ %s - %s\n", feature.Name, feature.Description)
}
fmt.Println()
}
}
func printPartiallyImplementedFeatures(supportedURLs *models.SupportedURLsResponse, verbose bool) {
partial := supportedURLs.GetPartiallyImplementedFeatures()
if len(partial) > 0 && verbose {
fmt.Printf("⚠️ Partially Supported Features:\n")
for _, feature := range partial {
fmt.Printf(" 🟡 %s\n", feature.Name)
for _, endpoint := range feature.Endpoints {
status := "❌"
if supportedURLs.HasURL(endpoint) {
status = "✅"
}
fmt.Printf(" %s %s\n", status, endpoint)
}
}
fmt.Println()
}
}
// printDetailedEndpoints shows the traditional endpoint listing
func printDetailedEndpoints(supportedURLs *models.SupportedURLsResponse) {
fmt.Printf("📋 Detailed Endpoint Analysis:\n\n")
// Show core functionality
coreURLs := supportedURLs.GetCoreURLs()
if len(coreURLs) > 0 {
fmt.Printf("🎮 Core Functionality (%d endpoints):\n", len(coreURLs))
for _, url := range coreURLs {
fmt.Printf(" • %s\n", url)
}
fmt.Println()
}
// Show streaming functionality
streamingURLs := supportedURLs.GetStreamingURLs()
if len(streamingURLs) > 0 {
fmt.Printf("📻 Streaming Services (%d endpoints):\n", len(streamingURLs))
for _, url := range streamingURLs {
fmt.Printf(" • %s\n", url)
}
fmt.Println()
}
// Show advanced audio functionality
advancedURLs := supportedURLs.GetAdvancedURLs()
if len(advancedURLs) > 0 {
fmt.Printf("🔧 Advanced Audio (%d endpoints):\n", len(advancedURLs))
for _, url := range advancedURLs {
fmt.Printf(" • %s\n", url)
}
fmt.Println()
}
// Show network functionality
networkURLs := supportedURLs.GetNetworkURLs()
if len(networkURLs) > 0 {
fmt.Printf("🌐 Network & Connectivity (%d endpoints):\n", len(networkURLs))
for _, url := range networkURLs {
fmt.Printf(" • %s\n", url)
}
fmt.Println()
}
// Show all supported URLs
fmt.Printf("📝 Complete Endpoint List:\n")
allURLs := supportedURLs.GetURLs()
for i, url := range allURLs {
fmt.Printf(" %3d. %s\n", i+1, url)
}
}
// getDeviceAnalysis handles comprehensive device capability analysis
func getDeviceAnalysis(c *cli.Context) error {
clientConfig := GetClientConfig(c)
PrintDeviceHeader("Analyzing device capabilities", clientConfig.Host, clientConfig.Port)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
supportedURLs, err := client.GetSupportedURLs()
if err != nil {
PrintError(fmt.Sprintf("Failed to get supported URLs: %v", err))
return err
}
printDeviceAnalysis(supportedURLs)
return nil
}
// printDeviceAnalysis provides comprehensive device capability analysis
func printDeviceAnalysis(supportedURLs *models.SupportedURLsResponse) {
fmt.Printf("🔍 Device Capability Analysis:\n")
fmt.Printf(" Device ID: %s\n", supportedURLs.DeviceID)
// Overall score
completeness, supported, total := supportedURLs.GetFeatureCompleteness()
fmt.Printf(" Feature Coverage: %d%% (%d/%d features)\n", completeness, supported, total)
// Device classification
classification := classifyDevice(supportedURLs)
fmt.Printf(" Device Type: %s\n\n", classification)
// Essential features check
missingEssential := supportedURLs.GetMissingEssentialFeatures()
if len(missingEssential) > 0 {
fmt.Printf("❌ Missing Essential Features:\n")
for _, feature := range missingEssential {
fmt.Printf(" • %s - %s\n", feature.Name, feature.Description)
fmt.Printf(" Impact: Device may not function properly without this\n")
}
fmt.Println()
} else {
fmt.Printf("✅ All essential features are supported\n\n")
}
// Show what works
supportedFeatures := supportedURLs.GetSupportedFeatures()
fmt.Printf("✅ Available Features (%d):\n", len(supportedFeatures))
categoryCount := make(map[string]int)
for _, feature := range supportedFeatures {
categoryCount[feature.Category]++
}
for category, count := range categoryCount {
emoji := getCategoryEmoji(category)
fmt.Printf(" %s %s: %d features\n", emoji, category, count)
}
fmt.Println()
// Show what's missing
unsupportedFeatures := supportedURLs.GetUnsupportedFeatures()
if len(unsupportedFeatures) > 0 {
fmt.Printf("❌ Unsupported Features (%d):\n", len(unsupportedFeatures))
for _, feature := range unsupportedFeatures {
fmt.Printf(" • %s - %s\n", feature.Name, feature.Description)
}
fmt.Println()
}
// Partial implementations
partial := supportedURLs.GetPartiallyImplementedFeatures()
if len(partial) > 0 {
fmt.Printf("⚠️ Partially Supported Features (%d):\n", len(partial))
for _, feature := range partial {
supportedCount := 0
for _, endpoint := range feature.Endpoints {
if supportedURLs.HasURL(endpoint) {
supportedCount++
}
}
fmt.Printf(" • %s (%d/%d endpoints)\n", feature.Name, supportedCount, len(feature.Endpoints))
}
fmt.Println()
}
// Recommendations
printRecommendations(supportedURLs)
// CLI usage suggestions
printCLIUsageSuggestions(supportedURLs)
}
// classifyDevice determines the device type based on supported features
func classifyDevice(supportedURLs *models.SupportedURLsResponse) string {
if supportedURLs.HasMultiroomSupport() && supportedURLs.HasAdvancedAudioSupport() {
return "Premium SoundTouch Speaker (Full Feature Set)"
}
if supportedURLs.HasMultiroomSupport() {
return "Standard SoundTouch Speaker (Multiroom Capable)"
}
if supportedURLs.HasStreamingSupport() && supportedURLs.HasPresetSupport() {
return "Basic SoundTouch Speaker"
}
if supportedURLs.HasCorePlaybackSupport() {
return "Essential SoundTouch Device"
}
return "Limited SoundTouch Device"
}
// printRecommendations provides usage recommendations based on device capabilities
func printRecommendations(supportedURLs *models.SupportedURLsResponse) {
fmt.Printf("💡 Recommendations:\n")
if supportedURLs.HasMultiroomSupport() {
fmt.Printf(" 🏠 This device supports multiroom - you can create speaker groups\n")
fmt.Printf(" Try: soundtouch-cli zone create --master <this-device> --members <other-devices>\n")
}
if supportedURLs.HasPresetSupport() {
fmt.Printf(" ⭐ Save your favorite content as presets for quick access\n")
fmt.Printf(" Try: soundtouch-cli preset store-current --slot 1\n")
}
if supportedURLs.HasStreamingSupport() {
fmt.Printf(" 📻 Browse and discover new content from streaming services\n")
fmt.Printf(" Try: soundtouch-cli browse tunein, station search-tunein --query jazz\n")
}
if supportedURLs.HasAdvancedAudioSupport() {
fmt.Printf(" 🔧 Fine-tune your audio with advanced controls\n")
fmt.Printf(" Try: soundtouch-cli audio dsp get, audio tone get\n")
}
if !supportedURLs.HasURL("/bassCapabilities") {
fmt.Printf(" ⚠️ Device may have limited bass control options\n")
}
if !supportedURLs.HasURL("/balance") {
fmt.Printf(" ⚠️ No balance control available on this device\n")
}
fmt.Println()
}
// printCLIUsageSuggestions shows common CLI commands for this device
func printCLIUsageSuggestions(supportedURLs *models.SupportedURLsResponse) {
fmt.Printf("🚀 Common Commands for This Device:\n")
// Always available
fmt.Printf(" • Get device info: soundtouch-cli info get\n")
fmt.Printf(" • Control volume: soundtouch-cli volume set --level 50\n")
if supportedURLs.HasURL("/nowPlaying") {
fmt.Printf(" • Check what's playing: soundtouch-cli play now\n")
}
if supportedURLs.HasURL("/sources") {
fmt.Printf(" • List audio sources: soundtouch-cli source list\n")
}
if supportedURLs.HasURL("/presets") {
fmt.Printf(" • Manage presets: soundtouch-cli preset list\n")
}
if supportedURLs.HasURL("/bass") {
fmt.Printf(" • Adjust bass: soundtouch-cli bass set --level 5\n")
}
if supportedURLs.HasURL("/setZone") {
fmt.Printf(" • Create speaker group: soundtouch-cli zone create\n")
}
if supportedURLs.HasURL("/search") {
fmt.Printf(" • Search content: soundtouch-cli station search-tunein --query \"classic rock\"\n")
}
fmt.Println()
}
// getCategoryEmoji returns emoji for feature categories
func getCategoryEmoji(category string) string {
emojis := map[string]string{
"Core": "⚡",
"Audio": "🔊",
"Playback": "▶️",
"Sources": "📱",
"Content": "📻",
"Presets": "⭐",
"Multiroom": "🏠",
"Network": "🌐",
"System": "⚙️",
}
if emoji, exists := emojis[category]; exists {
return emoji
}
return "📋"
}
// getTrackInfo gets the track information
func getTrackInfo(c *cli.Context) error {
clientConfig := GetClientConfig(c)
PrintDeviceHeader("Getting track information", clientConfig.Host, clientConfig.Port)
fmt.Println("⚠️ WARNING: /trackInfo endpoint times out on real devices.")
fmt.Println(" Use 'soundtouch-cli now' (playback status) command instead for track information.")
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
PrintError(fmt.Sprintf("Failed to create client: %v", err))
return err
}
trackInfo, err := client.GetTrackInfo()
if err != nil {
PrintError(fmt.Sprintf("Failed to get track info: %v", err))
return err
}
fmt.Println("Track Information:")
fmt.Printf(" Source: %s\n", trackInfo.Source)
if trackInfo.Track != "" {
fmt.Printf(" Track: %s\n", trackInfo.Track)
}
if trackInfo.Artist != "" {
fmt.Printf(" Artist: %s\n", trackInfo.Artist)
}
if trackInfo.Album != "" {
fmt.Printf(" Album: %s\n", trackInfo.Album)
}
if trackInfo.StationName != "" {
fmt.Printf(" Station: %s\n", trackInfo.StationName)
}
fmt.Printf(" Play Status: %s\n", trackInfo.PlayStatus)
return nil
}