mirror of
https://github.com/gesellix/Bose-SoundTouch.git
synced 2026-08-18 00:26:29 +00:00
Decode SCMUDC event details (#97)
This should help understanding events from the SoundTouch app to the speakers and from speakers to the BMX service.
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.html]
|
||||
# HTML-specific formatting
|
||||
# Standardize on tag layout
|
||||
ij_html_do_not_indent_children_of_tags = html,body,thead,tbody,tfoot
|
||||
ij_html_keep_blank_lines = 1
|
||||
ij_html_attribute_wrap = normal
|
||||
ij_html_space_inside_empty_tag = false
|
||||
@@ -0,0 +1,323 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SCMUDCRequest represents the structure of incoming SCMUDC telemetry data
|
||||
type SCMUDCRequest struct {
|
||||
Envelope struct {
|
||||
MonoTime int64 `json:"monoTime"`
|
||||
PayloadProtocolVersion string `json:"payloadProtocolVersion"`
|
||||
PayloadType string `json:"payloadType"`
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Time string `json:"time"`
|
||||
UniqueID string `json:"uniqueId"`
|
||||
} `json:"envelope"`
|
||||
Payload struct {
|
||||
DeviceInfo struct {
|
||||
BoseID string `json:"boseID"`
|
||||
DeviceID string `json:"deviceID"`
|
||||
DeviceType string `json:"deviceType"`
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
SoftwareVersion string `json:"softwareVersion"`
|
||||
SystemSerialNumber string `json:"systemSerialNumber"`
|
||||
} `json:"deviceInfo"`
|
||||
Events []SCMUDCEvent `json:"events"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
|
||||
// SCMUDCEvent represents individual events within SCMUDC payload
|
||||
type SCMUDCEvent struct {
|
||||
Data SCMUDCEventData `json:"data"`
|
||||
MonoTime int64 `json:"monoTime"`
|
||||
Time string `json:"time"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
// SCMUDCEventData contains the event-specific data
|
||||
type SCMUDCEventData struct {
|
||||
ButtonID string `json:"buttonId,omitempty"`
|
||||
ContentItem string `json:"contentItem,omitempty"`
|
||||
Origin string `json:"origin"`
|
||||
Preset string `json:"preset,omitempty"`
|
||||
}
|
||||
|
||||
// EnrichedSCMUDCEvent contains processed and human-readable event information
|
||||
type EnrichedSCMUDCEvent struct {
|
||||
Origin string `json:"origin"`
|
||||
Action string `json:"action"`
|
||||
Command string `json:"command"`
|
||||
Summary string `json:"summary"`
|
||||
DecodedData *DecodedContent `json:"decoded_data,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// DecodedContent represents decoded Base64 content from device events
|
||||
type DecodedContent struct {
|
||||
ContentType string `json:"content_type"`
|
||||
ItemName string `json:"item_name"`
|
||||
SourceAccount string `json:"source_account"`
|
||||
Location string `json:"location"`
|
||||
ArtworkURL string `json:"artwork_url,omitempty"`
|
||||
IsPresetable bool `json:"is_presetable"`
|
||||
XMLContent string `json:"xml_content"`
|
||||
}
|
||||
|
||||
// ContentItemXML represents the XML structure found in Base64-encoded content
|
||||
type ContentItemXML struct {
|
||||
XMLName xml.Name `xml:"ContentItem"`
|
||||
Source string `xml:"source,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
Location string `xml:"location,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr"`
|
||||
IsPresetable string `xml:"isPresetable,attr"`
|
||||
ItemName string `xml:"itemName"`
|
||||
ContainerArt string `xml:"containerArt"`
|
||||
}
|
||||
|
||||
// SCMUDCEnricher provides functionality to enrich SCMUDC event data
|
||||
type SCMUDCEnricher struct{}
|
||||
|
||||
// NewSCMUDCEnricher creates a new SCMUDC enricher instance
|
||||
func NewSCMUDCEnricher() *SCMUDCEnricher {
|
||||
return &SCMUDCEnricher{}
|
||||
}
|
||||
|
||||
// EnrichSCMUDCRequest processes a raw SCMUDC request and returns enriched event data
|
||||
func (e *SCMUDCEnricher) EnrichSCMUDCRequest(body []byte) (*EnrichedSCMUDCEvent, error) {
|
||||
var scmudcReq SCMUDCRequest
|
||||
if err := json.Unmarshal(body, &scmudcReq); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal SCMUDC request: %w", err)
|
||||
}
|
||||
|
||||
// Process the first event (most requests contain single events)
|
||||
if len(scmudcReq.Payload.Events) == 0 {
|
||||
return nil, fmt.Errorf("no events found in SCMUDC request")
|
||||
}
|
||||
|
||||
event := scmudcReq.Payload.Events[0]
|
||||
|
||||
// Parse timestamp
|
||||
timestamp, _ := time.Parse(time.RFC3339, event.Time)
|
||||
|
||||
enriched := &EnrichedSCMUDCEvent{
|
||||
Origin: event.Data.Origin,
|
||||
Action: event.Type,
|
||||
Timestamp: timestamp,
|
||||
}
|
||||
|
||||
switch event.Data.Origin {
|
||||
case "gabbo":
|
||||
e.enrichAppEvent(enriched, &event)
|
||||
case "console":
|
||||
e.enrichConsoleEvent(enriched, &event)
|
||||
case "device":
|
||||
e.enrichDeviceEvent(enriched, &event)
|
||||
default:
|
||||
enriched.Command = "Unknown"
|
||||
enriched.Summary = fmt.Sprintf("Unknown origin: %s", event.Data.Origin)
|
||||
}
|
||||
|
||||
return enriched, nil
|
||||
}
|
||||
|
||||
// enrichAppEvent processes events from the SoundTouch app
|
||||
func (e *SCMUDCEnricher) enrichAppEvent(enriched *EnrichedSCMUDCEvent, event *SCMUDCEvent) {
|
||||
enriched.Command = event.Data.ButtonID
|
||||
enriched.Summary = fmt.Sprintf("App: %s", e.formatButton(event.Data.ButtonID))
|
||||
}
|
||||
|
||||
// enrichConsoleEvent processes events from physical device controls
|
||||
func (e *SCMUDCEnricher) enrichConsoleEvent(enriched *EnrichedSCMUDCEvent, event *SCMUDCEvent) {
|
||||
enriched.Command = event.Data.ButtonID
|
||||
enriched.Summary = fmt.Sprintf("Device: %s", e.formatButton(event.Data.ButtonID))
|
||||
}
|
||||
|
||||
// enrichDeviceEvent processes internal device events with content data
|
||||
func (e *SCMUDCEnricher) enrichDeviceEvent(enriched *EnrichedSCMUDCEvent, event *SCMUDCEvent) {
|
||||
if event.Data.ContentItem != "" {
|
||||
if decoded := e.decodeContentItem(event.Data.ContentItem); decoded != nil {
|
||||
enriched.Command = decoded.ItemName
|
||||
enriched.Summary = fmt.Sprintf("Device: %s", e.summarizeContent(decoded))
|
||||
enriched.DecodedData = decoded
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for device events without content
|
||||
enriched.Command = "System Action"
|
||||
enriched.Summary = fmt.Sprintf("Device: %s", enriched.Action)
|
||||
}
|
||||
|
||||
// decodeContentItem decodes Base64-encoded XML content from device events
|
||||
func (e *SCMUDCEnricher) decodeContentItem(base64Content string) *DecodedContent {
|
||||
data, err := base64.StdEncoding.DecodeString(base64Content)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var contentItem ContentItemXML
|
||||
if err := xml.Unmarshal(data, &contentItem); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &DecodedContent{
|
||||
ContentType: contentItem.Source,
|
||||
ItemName: contentItem.ItemName,
|
||||
SourceAccount: contentItem.SourceAccount,
|
||||
Location: contentItem.Location,
|
||||
ArtworkURL: contentItem.ContainerArt,
|
||||
IsPresetable: strings.EqualFold(contentItem.IsPresetable, "true"),
|
||||
XMLContent: string(data),
|
||||
}
|
||||
}
|
||||
|
||||
// formatButton converts button IDs to human-readable names
|
||||
func (e *SCMUDCEnricher) formatButton(buttonID string) string {
|
||||
buttonNames := map[string]string{
|
||||
"POWER": "Power",
|
||||
"PLAY": "Play",
|
||||
"PAUSE": "Pause",
|
||||
"STOP": "Stop",
|
||||
"NEXT_TRACK": "Skip Forward",
|
||||
"PREV_TRACK": "Skip Backward",
|
||||
"VOLUME_UP": "Volume Up",
|
||||
"VOLUME_DOWN": "Volume Down",
|
||||
"MUTE": "Mute",
|
||||
"PRESET_1": "Preset 1",
|
||||
"PRESET_2": "Preset 2",
|
||||
"PRESET_3": "Preset 3",
|
||||
"PRESET_4": "Preset 4",
|
||||
"PRESET_5": "Preset 5",
|
||||
"PRESET_6": "Preset 6",
|
||||
}
|
||||
|
||||
if name, exists := buttonNames[buttonID]; exists {
|
||||
return name
|
||||
}
|
||||
|
||||
return buttonID
|
||||
}
|
||||
|
||||
// summarizeContent creates a short summary of content for UI display
|
||||
func (e *SCMUDCEnricher) summarizeContent(content *DecodedContent) string {
|
||||
if content.ItemName != "" {
|
||||
return fmt.Sprintf("Playing %s", e.truncateString(content.ItemName, 30))
|
||||
}
|
||||
|
||||
if content.ContentType != "" {
|
||||
return fmt.Sprintf("Playing %s content", content.ContentType)
|
||||
}
|
||||
|
||||
return "Playing content"
|
||||
}
|
||||
|
||||
// truncateString truncates a string to maxLength with ellipsis
|
||||
func (e *SCMUDCEnricher) truncateString(s string, maxLength int) string {
|
||||
if len(s) <= maxLength {
|
||||
return s
|
||||
}
|
||||
|
||||
return s[:maxLength-3] + "..."
|
||||
}
|
||||
|
||||
// GetOriginDescription returns a human-readable description of the event origin
|
||||
func (e *SCMUDCEnricher) GetOriginDescription(origin string) string {
|
||||
descriptions := map[string]string{
|
||||
"gabbo": "SoundTouch App",
|
||||
"console": "Device Hardware",
|
||||
"device": "Device Internal",
|
||||
}
|
||||
|
||||
if desc, exists := descriptions[origin]; exists {
|
||||
return desc
|
||||
}
|
||||
|
||||
return "Unknown Origin"
|
||||
}
|
||||
|
||||
// GenerateEnrichedComments creates comment lines for .http files
|
||||
func (e *SCMUDCEnricher) GenerateEnrichedComments(enriched *EnrichedSCMUDCEvent) []string {
|
||||
comments := []string{
|
||||
fmt.Sprintf("// Origin: %s (%s)", e.GetOriginDescription(enriched.Origin), enriched.Origin),
|
||||
fmt.Sprintf("// Action: %s", enriched.Action),
|
||||
fmt.Sprintf("// Command: %s", enriched.Command),
|
||||
fmt.Sprintf("// Summary: %s", enriched.Summary),
|
||||
}
|
||||
|
||||
if enriched.DecodedData != nil {
|
||||
comments = append(comments,
|
||||
"// Decoded Content:",
|
||||
fmt.Sprintf("// Source: %s", enriched.DecodedData.ContentType),
|
||||
fmt.Sprintf("// Track: %s", enriched.DecodedData.ItemName),
|
||||
fmt.Sprintf("// Account: %s", enriched.DecodedData.SourceAccount),
|
||||
)
|
||||
|
||||
if enriched.DecodedData.ArtworkURL != "" {
|
||||
comments = append(comments, fmt.Sprintf("// Artwork: %s", enriched.DecodedData.ArtworkURL))
|
||||
}
|
||||
|
||||
comments = append(comments,
|
||||
"//",
|
||||
"// Full XML:",
|
||||
)
|
||||
|
||||
// Add XML content as comments, line by line
|
||||
xmlLines := strings.Split(enriched.DecodedData.XMLContent, "\n")
|
||||
for _, line := range xmlLines {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
comments = append(comments, fmt.Sprintf("// %s", strings.TrimSpace(line)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return comments
|
||||
}
|
||||
|
||||
// IsSCMUDCRequest checks if a request path is a SCMUDC endpoint
|
||||
func IsSCMUDCRequest(path string) bool {
|
||||
return strings.Contains(path, "/v1/scmudc/")
|
||||
}
|
||||
|
||||
// GetActionIcon returns an emoji icon for the given action type
|
||||
func GetActionIcon(action string) string {
|
||||
icons := map[string]string{
|
||||
"play-pressed": "▶️",
|
||||
"pause-pressed": "⏸️",
|
||||
"power-pressed": "⚡",
|
||||
"stop-pressed": "⏹️",
|
||||
"skip-forward-pressed": "⏭️",
|
||||
"skip-backward-pressed": "⏪",
|
||||
"preset-pressed": "⭐",
|
||||
"play-item": "🎵",
|
||||
"preset-assigned": "🔖",
|
||||
}
|
||||
|
||||
if icon, exists := icons[action]; exists {
|
||||
return icon
|
||||
}
|
||||
|
||||
return "🔘"
|
||||
}
|
||||
|
||||
// GetOriginIcon returns an emoji icon for the given origin
|
||||
func GetOriginIcon(origin string) string {
|
||||
icons := map[string]string{
|
||||
"gabbo": "📱", // SoundTouch App
|
||||
"console": "🎛️", // Device Console
|
||||
"device": "🔄", // Internal System
|
||||
}
|
||||
|
||||
if icon, exists := icons[origin]; exists {
|
||||
return icon
|
||||
}
|
||||
|
||||
return "❓"
|
||||
}
|
||||
+1408
-531
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+175
-18
@@ -49,15 +49,16 @@ type InteractionStats struct {
|
||||
|
||||
// Interaction represents a single recorded HTTP interaction.
|
||||
type Interaction struct {
|
||||
ID string `json:"id"`
|
||||
Session string `json:"session"`
|
||||
Category string `json:"category"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
File string `json:"file"`
|
||||
Counter int `json:"counter"`
|
||||
Status int `json:"status"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
ID string `json:"id"`
|
||||
Session string `json:"session"`
|
||||
Category string `json:"category"`
|
||||
Method string `json:"method"`
|
||||
Path string `json:"path"`
|
||||
File string `json:"file"`
|
||||
Counter int `json:"counter"`
|
||||
Status int `json:"status"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
SCMUDCData *EnrichedSCMUDCEvent `json:"scmudc_data,omitempty"`
|
||||
}
|
||||
|
||||
// NewRecorder creates a new HTTP interaction recorder.
|
||||
@@ -181,11 +182,25 @@ func (r *Recorder) Record(category string, req *http.Request, res *http.Response
|
||||
}
|
||||
|
||||
func (r *Recorder) save(task recordingTask) {
|
||||
var buf bytes.Buffer
|
||||
r.writeRequest(&buf, task.req, task.replacements)
|
||||
var (
|
||||
buf bytes.Buffer
|
||||
enriched *EnrichedSCMUDCEvent
|
||||
)
|
||||
|
||||
// Check if this is a SCMUDC request and enrich it
|
||||
|
||||
if strings.Contains(task.req.URL.Path, "/v1/scmudc/") && task.req.Body != nil {
|
||||
bodyBytes, err := io.ReadAll(task.req.Body)
|
||||
if err == nil {
|
||||
task.req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
|
||||
enriched = enrichSCMUDCRequest(bodyBytes)
|
||||
}
|
||||
}
|
||||
|
||||
r.writeRequestWithEnrichment(&buf, task.req, task.replacements, enriched)
|
||||
|
||||
if task.res != nil {
|
||||
r.writeResponse(&buf, task.res)
|
||||
r.writeResponseWithEnrichment(&buf, task.res, enriched)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(task.path, buf.Bytes(), 0644); err != nil {
|
||||
@@ -239,7 +254,7 @@ func (r *Recorder) getRecordingPath(dir, method string) string {
|
||||
return filepath.Join(dir, filename)
|
||||
}
|
||||
|
||||
func (r *Recorder) writeRequest(buf *bytes.Buffer, req *http.Request, replacements map[string]string) {
|
||||
func (r *Recorder) writeRequestWithEnrichment(buf *bytes.Buffer, req *http.Request, replacements map[string]string, enriched *EnrichedSCMUDCEvent) {
|
||||
displayURL := req.URL.String()
|
||||
for orig, repl := range replacements {
|
||||
displayURL = strings.ReplaceAll(displayURL, orig, "{{"+strings.Trim(repl, "{}")+"}}")
|
||||
@@ -252,6 +267,14 @@ func (r *Recorder) writeRequest(buf *bytes.Buffer, req *http.Request, replacemen
|
||||
fmt.Fprintf(buf, "// %s: %s\n", key, orig)
|
||||
}
|
||||
|
||||
// Add SCMUDC enriched comments
|
||||
if enriched != nil {
|
||||
enrichedComments := generateSCMUDCComments(enriched)
|
||||
for _, comment := range enrichedComments {
|
||||
fmt.Fprintf(buf, "%s\n", comment)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(buf, "%s %s\n", req.Method, displayURL)
|
||||
fmt.Fprintf(buf, "Host: %s\n", req.Host)
|
||||
|
||||
@@ -283,10 +306,29 @@ func (r *Recorder) writeRequest(buf *bytes.Buffer, req *http.Request, replacemen
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Recorder) writeResponse(buf *bytes.Buffer, res *http.Response) {
|
||||
func (r *Recorder) writeResponseWithEnrichment(buf *bytes.Buffer, res *http.Response, enriched *EnrichedSCMUDCEvent) {
|
||||
buf.WriteString("\n")
|
||||
buf.WriteString("> {% \n")
|
||||
fmt.Fprintf(buf, " // Response: %d %s\n", res.StatusCode, http.StatusText(res.StatusCode))
|
||||
|
||||
// Add SCMUDC enrichment summary in response
|
||||
if enriched != nil {
|
||||
buf.WriteString(" //\n")
|
||||
buf.WriteString(" // SCMUDC Event Analysis:\n")
|
||||
fmt.Fprintf(buf, " // - Origin: %s (%s)\n", getOriginDescription(enriched.Origin), enriched.Origin)
|
||||
fmt.Fprintf(buf, " // - Action: %s\n", enriched.Action)
|
||||
fmt.Fprintf(buf, " // - Summary: %s\n", enriched.Summary)
|
||||
|
||||
if enriched.DecodedData != nil {
|
||||
fmt.Fprintf(buf, " // - Content: %s\n", enriched.DecodedData.ItemName)
|
||||
|
||||
if enriched.DecodedData.SourceAccount != "" {
|
||||
fmt.Fprintf(buf, " // - Account: %s\n", enriched.DecodedData.SourceAccount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buf.WriteString(" //\n")
|
||||
buf.WriteString(" // Headers:\n")
|
||||
|
||||
for k, vv := range res.Header {
|
||||
@@ -312,8 +354,8 @@ func (r *Recorder) writeResponse(buf *bytes.Buffer, res *http.Response) {
|
||||
buf.WriteString("\n/*\n")
|
||||
buf.Write(bodyBytes)
|
||||
buf.WriteString("\n*/\n")
|
||||
} else {
|
||||
fmt.Fprintf(buf, "\n// [Binary response body: %d bytes]\n", len(bodyBytes))
|
||||
} else if len(bodyBytes) > 0 {
|
||||
fmt.Fprintf(buf, "\n[Binary response body: %d bytes]\n", len(bodyBytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -503,7 +545,7 @@ func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Inter
|
||||
requestPath = "/"
|
||||
}
|
||||
|
||||
return Interaction{
|
||||
interaction := Interaction{
|
||||
ID: filename,
|
||||
Session: sessionID,
|
||||
Category: category,
|
||||
@@ -513,7 +555,122 @@ func (r *Recorder) parseInteractionFile(rel, path string, parts []string) (Inter
|
||||
Counter: counter,
|
||||
Status: r.peekStatus(path),
|
||||
Timestamp: timestamp,
|
||||
}, true
|
||||
}
|
||||
|
||||
// Extract SCMUDC enrichment data if this is a SCMUDC request
|
||||
if strings.Contains(requestPath, "/v1/scmudc/") {
|
||||
interaction.SCMUDCData = r.extractSCMUDCFromFile(path)
|
||||
}
|
||||
|
||||
return interaction, true
|
||||
}
|
||||
|
||||
// extractSCMUDCFromFile parses SCMUDC enrichment data from a .http file
|
||||
func (r *Recorder) extractSCMUDCFromFile(path string) *EnrichedSCMUDCEvent {
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
lines := strings.Split(string(content), "\n")
|
||||
|
||||
var (
|
||||
enriched EnrichedSCMUDCEvent
|
||||
foundSCMUDC bool
|
||||
bodyStart int
|
||||
)
|
||||
|
||||
// Look for SCMUDC enrichment comments
|
||||
|
||||
for i, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(line, "// Origin: "):
|
||||
parts := strings.Split(line, " (")
|
||||
if len(parts) >= 2 {
|
||||
enriched.Origin = strings.TrimSuffix(parts[1], ")")
|
||||
foundSCMUDC = true
|
||||
}
|
||||
case strings.HasPrefix(line, "// Action: "):
|
||||
enriched.Action = strings.TrimPrefix(line, "// Action: ")
|
||||
case strings.HasPrefix(line, "// Command: "):
|
||||
enriched.Command = strings.TrimPrefix(line, "// Command: ")
|
||||
case strings.HasPrefix(line, "// Summary: "):
|
||||
enriched.Summary = strings.TrimPrefix(line, "// Summary: ")
|
||||
case strings.HasPrefix(line, "// - Source: "):
|
||||
r.ensureDecodedData(&enriched)
|
||||
enriched.DecodedData.ContentType = strings.TrimPrefix(line, "// - Source: ")
|
||||
case strings.HasPrefix(line, "// - Item: "):
|
||||
r.ensureDecodedData(&enriched)
|
||||
enriched.DecodedData.ItemName = strings.TrimPrefix(line, "// - Item: ")
|
||||
case strings.HasPrefix(line, "// - Account: "):
|
||||
r.ensureDecodedData(&enriched)
|
||||
enriched.DecodedData.SourceAccount = strings.TrimPrefix(line, "// - Account: ")
|
||||
case strings.HasPrefix(line, "// - Artwork: "):
|
||||
r.ensureDecodedData(&enriched)
|
||||
enriched.DecodedData.ArtworkURL = strings.TrimPrefix(line, "// - Artwork: ")
|
||||
case line == "// - Presetable: Yes":
|
||||
r.ensureDecodedData(&enriched)
|
||||
enriched.DecodedData.IsPresetable = true
|
||||
case line == "{" && i > 0:
|
||||
// Found start of JSON body
|
||||
bodyStart = i
|
||||
goto endLoop
|
||||
}
|
||||
}
|
||||
|
||||
endLoop:
|
||||
// If we didn't find enrichment comments but this is a SCMUDC request,
|
||||
// try to parse the JSON body directly
|
||||
if !foundSCMUDC && bodyStart > 0 {
|
||||
if parsed := r.parseSCMUDBody(lines, bodyStart); parsed != nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
|
||||
if !foundSCMUDC {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &enriched
|
||||
}
|
||||
|
||||
func (r *Recorder) ensureDecodedData(enriched *EnrichedSCMUDCEvent) {
|
||||
if enriched.DecodedData == nil {
|
||||
enriched.DecodedData = &DecodedContent{}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Recorder) parseSCMUDBody(lines []string, bodyStart int) *EnrichedSCMUDCEvent {
|
||||
var bodyLines []string
|
||||
|
||||
inBody := false
|
||||
braceCount := 0
|
||||
|
||||
for i := bodyStart; i < len(lines); i++ {
|
||||
line := lines[i]
|
||||
if strings.TrimSpace(line) == "{" && !inBody {
|
||||
inBody = true
|
||||
|
||||
bodyLines = append(bodyLines, line)
|
||||
braceCount = 1
|
||||
} else if inBody {
|
||||
bodyLines = append(bodyLines, line)
|
||||
|
||||
braceCount += strings.Count(line, "{") - strings.Count(line, "}")
|
||||
if braceCount == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(bodyLines) > 0 {
|
||||
bodyJSON := strings.Join(bodyLines, "\n")
|
||||
return enrichSCMUDCRequest([]byte(bodyJSON))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Recorder) getFullTimestamp(sessionID, filename string) string {
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// SCMUDCRequest represents the structure of SCMUDC telemetry requests
|
||||
type SCMUDCRequest struct {
|
||||
Envelope struct {
|
||||
MonoTime int64 `json:"monoTime"`
|
||||
PayloadProtocolVersion string `json:"payloadProtocolVersion"`
|
||||
PayloadType string `json:"payloadType"`
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
Time string `json:"time"`
|
||||
UniqueID string `json:"uniqueId"`
|
||||
} `json:"envelope"`
|
||||
Payload struct {
|
||||
DeviceInfo struct {
|
||||
BoseID string `json:"boseID"`
|
||||
DeviceID string `json:"deviceID"`
|
||||
DeviceType string `json:"deviceType"`
|
||||
SerialNumber string `json:"serialNumber"`
|
||||
SoftwareVersion string `json:"softwareVersion"`
|
||||
SystemSerialNumber string `json:"systemSerialNumber"`
|
||||
} `json:"deviceInfo"`
|
||||
Events []SCMUDCEvent `json:"events"`
|
||||
} `json:"payload"`
|
||||
}
|
||||
|
||||
// SCMUDCEvent represents individual events within SCMUDC requests
|
||||
type SCMUDCEvent struct {
|
||||
Type string `json:"type"`
|
||||
Data struct {
|
||||
ButtonID string `json:"buttonId,omitempty"`
|
||||
Origin string `json:"origin"`
|
||||
ContentItem string `json:"contentItem,omitempty"`
|
||||
Preset string `json:"preset,omitempty"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
// EnrichedSCMUDCEvent contains human-readable analysis of SCMUDC events
|
||||
type EnrichedSCMUDCEvent struct {
|
||||
Origin string `json:"origin"`
|
||||
Action string `json:"action"`
|
||||
Command string `json:"command"`
|
||||
Summary string `json:"summary"`
|
||||
DecodedData *DecodedContent `json:"decoded_data,omitempty"`
|
||||
}
|
||||
|
||||
// DecodedContent represents decoded ContentItem XML data
|
||||
type DecodedContent struct {
|
||||
ContentType string `json:"content_type"`
|
||||
ItemName string `json:"item_name"`
|
||||
SourceAccount string `json:"source_account,omitempty"`
|
||||
Location string `json:"location,omitempty"`
|
||||
ArtworkURL string `json:"artwork_url,omitempty"`
|
||||
IsPresetable bool `json:"is_presetable,omitempty"`
|
||||
XMLContent string `json:"xml_content,omitempty"`
|
||||
}
|
||||
|
||||
// ContentItem represents the XML structure within Base64-encoded content
|
||||
type ContentItem struct {
|
||||
XMLName xml.Name `xml:"ContentItem"`
|
||||
Source string `xml:"source,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
Location string `xml:"location,attr"`
|
||||
SourceAccount string `xml:"sourceAccount,attr"`
|
||||
IsPresetable string `xml:"isPresetable,attr"`
|
||||
ItemName string `xml:"itemName"`
|
||||
ContainerArt string `xml:"containerArt"`
|
||||
}
|
||||
|
||||
// enrichSCMUDCRequest analyzes and enriches SCMUDC request data
|
||||
func enrichSCMUDCRequest(body []byte) *EnrichedSCMUDCEvent {
|
||||
var scmudcReq SCMUDCRequest
|
||||
if err := json.Unmarshal(body, &scmudcReq); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Process the first event (most requests contain single events)
|
||||
if len(scmudcReq.Payload.Events) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
event := scmudcReq.Payload.Events[0]
|
||||
enriched := &EnrichedSCMUDCEvent{
|
||||
Origin: event.Data.Origin,
|
||||
Action: event.Type,
|
||||
}
|
||||
|
||||
switch event.Data.Origin {
|
||||
case "gabbo":
|
||||
enriched.Command = event.Data.ButtonID
|
||||
enriched.Summary = fmt.Sprintf("App: %s", formatButton(event.Data.ButtonID))
|
||||
|
||||
case "console":
|
||||
enriched.Command = event.Data.ButtonID
|
||||
enriched.Summary = fmt.Sprintf("Device: %s", formatButton(event.Data.ButtonID))
|
||||
|
||||
case "device":
|
||||
if event.Data.ContentItem != "" {
|
||||
if decoded := decodeContentItem(event.Data.ContentItem); decoded != nil {
|
||||
enriched.Command = decoded.ItemName
|
||||
enriched.Summary = fmt.Sprintf("Device: %s", summarizeContent(decoded))
|
||||
enriched.DecodedData = decoded
|
||||
} else {
|
||||
enriched.Command = "Content Item"
|
||||
enriched.Summary = "Device: Content Action"
|
||||
}
|
||||
} else {
|
||||
enriched.Command = "System Action"
|
||||
enriched.Summary = "Device: Internal Action"
|
||||
}
|
||||
}
|
||||
|
||||
return enriched
|
||||
}
|
||||
|
||||
// decodeContentItem decodes Base64-encoded XML content
|
||||
func decodeContentItem(base64Content string) *DecodedContent {
|
||||
data, err := base64.StdEncoding.DecodeString(base64Content)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var contentItem ContentItem
|
||||
if err := xml.Unmarshal(data, &contentItem); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
decoded := &DecodedContent{
|
||||
ContentType: contentItem.Source,
|
||||
ItemName: contentItem.ItemName,
|
||||
SourceAccount: contentItem.SourceAccount,
|
||||
Location: contentItem.Location,
|
||||
ArtworkURL: contentItem.ContainerArt,
|
||||
XMLContent: string(data),
|
||||
}
|
||||
|
||||
if contentItem.IsPresetable == "true" {
|
||||
decoded.IsPresetable = true
|
||||
}
|
||||
|
||||
return decoded
|
||||
}
|
||||
|
||||
// formatButton converts button IDs to human-readable names
|
||||
func formatButton(buttonID string) string {
|
||||
switch buttonID {
|
||||
case "POWER":
|
||||
return "Power Button"
|
||||
case "PLAY":
|
||||
return "Play Button"
|
||||
case "PAUSE":
|
||||
return "Pause Button"
|
||||
case "STOP":
|
||||
return "Stop Button"
|
||||
case "NEXT_TRACK":
|
||||
return "Next Track"
|
||||
case "PREV_TRACK":
|
||||
return "Previous Track"
|
||||
case "PRESET_1", "PRESET_2", "PRESET_3", "PRESET_4", "PRESET_5", "PRESET_6":
|
||||
return fmt.Sprintf("Preset %s", strings.TrimPrefix(buttonID, "PRESET_"))
|
||||
default:
|
||||
return buttonID
|
||||
}
|
||||
}
|
||||
|
||||
// summarizeContent creates a brief summary of content items
|
||||
func summarizeContent(decoded *DecodedContent) string {
|
||||
switch decoded.ContentType {
|
||||
case "SPOTIFY":
|
||||
return fmt.Sprintf("Spotify: %s", decoded.ItemName)
|
||||
case "PANDORA":
|
||||
return fmt.Sprintf("Pandora: %s", decoded.ItemName)
|
||||
case "INTERNET_RADIO":
|
||||
return fmt.Sprintf("Radio: %s", decoded.ItemName)
|
||||
case "STORED_MUSIC":
|
||||
return fmt.Sprintf("Library: %s", decoded.ItemName)
|
||||
default:
|
||||
if decoded.ItemName != "" {
|
||||
return fmt.Sprintf("%s: %s", decoded.ContentType, decoded.ItemName)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s Content", decoded.ContentType)
|
||||
}
|
||||
}
|
||||
|
||||
// getOriginDescription returns human-readable origin descriptions
|
||||
func getOriginDescription(origin string) string {
|
||||
switch origin {
|
||||
case "gabbo":
|
||||
return "SoundTouch App"
|
||||
case "console":
|
||||
return "Device Hardware"
|
||||
case "device":
|
||||
return "Internal System"
|
||||
default:
|
||||
return origin
|
||||
}
|
||||
}
|
||||
|
||||
// generateSCMUDCComments creates enriched comments for .http files
|
||||
func generateSCMUDCComments(enriched *EnrichedSCMUDCEvent) []string {
|
||||
if enriched == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
comments := []string{
|
||||
fmt.Sprintf("// Origin: %s (%s)", getOriginDescription(enriched.Origin), enriched.Origin),
|
||||
fmt.Sprintf("// Action: %s", enriched.Action),
|
||||
fmt.Sprintf("// Command: %s", enriched.Command),
|
||||
fmt.Sprintf("// Summary: %s", enriched.Summary),
|
||||
}
|
||||
|
||||
if enriched.DecodedData != nil {
|
||||
comments = append(comments,
|
||||
"//",
|
||||
"// Decoded Content:",
|
||||
fmt.Sprintf("// - Source: %s", enriched.DecodedData.ContentType),
|
||||
fmt.Sprintf("// - Item: %s", enriched.DecodedData.ItemName),
|
||||
)
|
||||
|
||||
if enriched.DecodedData.SourceAccount != "" {
|
||||
comments = append(comments, fmt.Sprintf("// - Account: %s", enriched.DecodedData.SourceAccount))
|
||||
}
|
||||
|
||||
if enriched.DecodedData.ArtworkURL != "" {
|
||||
comments = append(comments, fmt.Sprintf("// - Artwork: %s", enriched.DecodedData.ArtworkURL))
|
||||
}
|
||||
|
||||
if enriched.DecodedData.IsPresetable {
|
||||
comments = append(comments, "// - Presetable: Yes")
|
||||
}
|
||||
|
||||
if enriched.DecodedData.XMLContent != "" {
|
||||
comments = append(comments,
|
||||
"//",
|
||||
"// Full XML Content:",
|
||||
)
|
||||
|
||||
// Add XML content as comments, line by line
|
||||
lines := strings.Split(enriched.DecodedData.XMLContent, "\n")
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
comments = append(comments, fmt.Sprintf("// %s", strings.TrimSpace(line)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return comments
|
||||
}
|
||||
Reference in New Issue
Block a user