Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e424ee6546 | ||
|
|
d9d9a67f0e | ||
|
|
e0a84d5904 | ||
|
|
5d080cf35f | ||
|
|
bcd383bdff | ||
|
|
7a09a2ddc0 | ||
|
|
b04b0bcc32 | ||
|
|
61b5c71097 | ||
|
|
9f7cb81b45 | ||
|
|
d5d6585517 | ||
|
|
50b694aa08 | ||
|
|
717693e01f | ||
|
|
a0833c113c | ||
|
|
e74d2e0fc3 | ||
|
|
5b642010d4 | ||
|
|
5078d933d5 | ||
|
|
6cf511e7e5 | ||
|
|
ba11394d0f | ||
|
|
37eb23fc36 | ||
|
|
4544486221 | ||
|
|
17bd3ea9ed | ||
|
|
ad5344b309 | ||
|
|
8d95e170f6 |
@@ -43,8 +43,14 @@ jobs:
|
||||
- name: Run tests
|
||||
run: go test -v -race -coverprofile=coverage.out ./...
|
||||
|
||||
- name: Build service
|
||||
run: make build-service
|
||||
|
||||
- name: Run HTTP client integration tests
|
||||
run: make test-http-client
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
uses: codecov/codecov-action@v6
|
||||
with:
|
||||
file: ./coverage.out
|
||||
flags: unittests
|
||||
@@ -276,7 +282,7 @@ jobs:
|
||||
type=ref,event=pr
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
|
||||
@@ -34,4 +34,4 @@ jobs:
|
||||
path: '_site'
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
|
||||
@@ -512,7 +512,7 @@ jobs:
|
||||
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
|
||||
|
||||
@@ -20,6 +20,8 @@ EXAMPLE_UPNP_NAME=example-upnp
|
||||
EXAMPLE_UPNP_PATH=./cmd/$(EXAMPLE_UPNP_NAME)
|
||||
SCANNER_NAME=mdns-scanner
|
||||
SCANNER_PATH=./cmd/$(SCANNER_NAME)
|
||||
FAVICON_GEN_NAME=favicon-gen
|
||||
FAVICON_GEN_PATH=./cmd/$(FAVICON_GEN_NAME)
|
||||
BUILD_DIR=./build
|
||||
|
||||
# Version info
|
||||
@@ -27,7 +29,7 @@ BUILD_DIR=./build
|
||||
|
||||
all: check build
|
||||
|
||||
build: build-cli build-service build-examples
|
||||
build: build-cli build-service build-examples build-favicon-gen
|
||||
|
||||
build-cli:
|
||||
@echo "Building $(BINARY_NAME)..."
|
||||
@@ -48,6 +50,11 @@ build-examples:
|
||||
@echo "Building $(SCANNER_NAME)..."
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
|
||||
|
||||
build-favicon-gen:
|
||||
@echo "Building $(FAVICON_GEN_NAME)..."
|
||||
@mkdir -p $(BUILD_DIR)
|
||||
$(GOBUILD) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
|
||||
|
||||
build-all: build-linux build-darwin build-windows build-examples-all
|
||||
|
||||
build-linux:
|
||||
@@ -96,7 +103,37 @@ test-coverage:
|
||||
$(GOCMD) tool cover -html=coverage.out -o coverage.html
|
||||
@echo "Coverage report generated: coverage.html"
|
||||
|
||||
check: fmt vet test
|
||||
check: fmt vet test test-http-client
|
||||
|
||||
test-http-client:
|
||||
@echo "Running HTTP client integration tests..."
|
||||
@docker network create soundtouch-test-net || true
|
||||
@docker build -t soundtouch-service-test .
|
||||
@docker run -d --name soundtouch-service --network soundtouch-test-net \
|
||||
-e PORT=8000 \
|
||||
soundtouch-service-test
|
||||
@echo "Waiting for service to start..."
|
||||
@sleep 5
|
||||
@docker run --rm --network soundtouch-test-net \
|
||||
-v $(PWD)/tests/integration/http-client:/workdir \
|
||||
jetbrains/intellij-http-client:2026.1 \
|
||||
--env-file /workdir/http-client.env.json \
|
||||
--env ci \
|
||||
/workdir/create_account.http \
|
||||
/workdir/register_device.http \
|
||||
/workdir/power_on.http \
|
||||
/workdir/get_provider_settings.http \
|
||||
/workdir/get_full_account.http \
|
||||
/workdir/get_group.http \
|
||||
/workdir/unregister_device.http \
|
||||
--report; \
|
||||
EXIT_CODE=$$?; \
|
||||
docker logs soundtouch-service; \
|
||||
docker stop soundtouch-service; \
|
||||
docker rm soundtouch-service; \
|
||||
docker rmi soundtouch-service-test; \
|
||||
docker network rm soundtouch-test-net; \
|
||||
exit $$EXIT_CODE
|
||||
|
||||
fmt:
|
||||
@echo "Formatting code..."
|
||||
@@ -226,6 +263,7 @@ help:
|
||||
@echo " build - Build the CLI tool, service, and examples"
|
||||
@echo " build-cli - Build only the CLI tool"
|
||||
@echo " build-service - Build only the service"
|
||||
@echo " build-favicon-gen - Build the favicon generator"
|
||||
@echo " build-examples - Build only the example programs"
|
||||
@echo " build-all - Build for all platforms"
|
||||
@echo " test - Run tests"
|
||||
|
||||
@@ -17,6 +17,7 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
|
||||
- ⚡ **Real-time Events**: WebSocket connection for live device state monitoring
|
||||
- 🔍 **Device Discovery**: Automatic discovery via UPnP/SSDP and mDNS
|
||||
- 📻 **Content Navigation**: Browse and search TuneIn, Pandora, Spotify, local music
|
||||
- 📻 **Custom Radio**: Play any stream URL via [flexible proxying](docs/guides/CLI-REFERENCE.md#custom-radio-selection-via-soundtouch-service)
|
||||
- 📻 **RadioBrowser**: Access thousands of internet radio stations via [radio-browser.info](docs/reference/radio-browser.md)
|
||||
- 🎙️ **Station Management**: Add and play radio stations without presets
|
||||
- 🖥️ **CLI Tool**: Comprehensive command-line interface
|
||||
@@ -106,7 +107,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
)
|
||||
|
||||
@@ -116,20 +117,20 @@ func main() {
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
|
||||
// Get device information
|
||||
info, err := c.GetDeviceInfo()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
fmt.Printf("Device: %s\n", info.Name)
|
||||
|
||||
|
||||
// Control playback
|
||||
err = c.Play()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Set volume
|
||||
err = c.SetVolume(50)
|
||||
if err != nil {
|
||||
@@ -147,7 +148,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/discovery"
|
||||
)
|
||||
|
||||
@@ -158,9 +159,9 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
for _, device := range devices {
|
||||
fmt.Printf("Found: %s at %s:%d\n",
|
||||
fmt.Printf("Found: %s at %s:%d\n",
|
||||
device.Name, device.Host, device.Port)
|
||||
}
|
||||
}
|
||||
@@ -174,7 +175,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
@@ -184,13 +185,13 @@ func main() {
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
|
||||
// Subscribe to device events
|
||||
events, err := c.SubscribeToEvents(context.Background())
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
for event := range events {
|
||||
switch e := event.(type) {
|
||||
case *models.NowPlayingUpdated:
|
||||
@@ -211,7 +212,7 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
@@ -221,21 +222,21 @@ func main() {
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
|
||||
// Get current presets
|
||||
presets, err := c.GetPresets()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
fmt.Printf("Found %d presets\n", len(presets.Preset))
|
||||
|
||||
|
||||
// Store currently playing content as preset 1
|
||||
err = c.StoreCurrentAsPreset(1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Store Spotify playlist as preset 2
|
||||
spotifyContent := &models.ContentItem{
|
||||
Source: "SPOTIFY",
|
||||
@@ -249,7 +250,7 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Store radio station as preset 3
|
||||
radioContent := &models.ContentItem{
|
||||
Source: "TUNEIN",
|
||||
@@ -262,13 +263,13 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Select preset 1
|
||||
err = c.SelectPreset(1)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
fmt.Println("Preset management complete!")
|
||||
}
|
||||
```
|
||||
@@ -279,7 +280,7 @@ package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
)
|
||||
@@ -289,7 +290,7 @@ func main() {
|
||||
Host: "192.168.1.100", // Master speaker
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
|
||||
// Create a multiroom zone
|
||||
zone := &models.Zone{
|
||||
Master: "192.168.1.100",
|
||||
@@ -298,12 +299,12 @@ func main() {
|
||||
{IPAddress: "192.168.1.102"}, // Kitchen
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
err := master.SetZone(zone)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
fmt.Println("Multiroom zone created!")
|
||||
}
|
||||
```
|
||||
@@ -314,7 +315,7 @@ package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/client"
|
||||
)
|
||||
|
||||
@@ -323,13 +324,13 @@ func main() {
|
||||
Host: "192.168.1.100",
|
||||
Port: 8090,
|
||||
})
|
||||
|
||||
|
||||
// Play Text-to-Speech message (language code "EN", "DE", etc.)
|
||||
err := c.PlayTTS("Welcome home!", "your-app-key", "EN", 70)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Play audio content from URL
|
||||
err = c.PlayURL(
|
||||
"https://example.com/doorbell.mp3",
|
||||
@@ -342,13 +343,13 @@ func main() {
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
// Play notification beep
|
||||
err = c.PlayNotificationBeep()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
|
||||
fmt.Println("Notifications sent!")
|
||||
}
|
||||
```
|
||||
@@ -358,7 +359,7 @@ func main() {
|
||||
This library supports all Bose SoundTouch-compatible devices, including:
|
||||
|
||||
- SoundTouch 10, 20, 30 series
|
||||
- SoundTouch Portable
|
||||
- SoundTouch Portable
|
||||
- Wave SoundTouch music system
|
||||
- SoundTouch-enabled Bose speakers
|
||||
|
||||
@@ -519,7 +520,7 @@ This project builds upon the excellent work of several community projects:
|
||||
These projects together form a comprehensive ecosystem for SoundTouch device management:
|
||||
|
||||
- **This Project**: Go library + CLI + service for programmatic control and offline operation
|
||||
- **SoundCork**: Python-based service interception and cloud replacement
|
||||
- **SoundCork**: Python-based service interception and cloud replacement
|
||||
- **SoundTouch Plus**: Home Assistant integration with extensive device support
|
||||
- **ÜberBöse**: API research and advanced endpoint discovery
|
||||
- **SoundTouch Hook**: Advanced reverse engineering and process instrumentation
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
// Package main provides a utility to generate PNG and ICO favicons from SVG source files.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/srwiley/oksvg"
|
||||
"github.com/srwiley/rasterx"
|
||||
)
|
||||
|
||||
func main() {
|
||||
mediaDir := "pkg/service/handlers/web/img"
|
||||
files := []string{"favicon-braille", "favicon-morse"}
|
||||
|
||||
for _, name := range files {
|
||||
svgPath := filepath.Join(mediaDir, name+".svg")
|
||||
pngPath := filepath.Join(mediaDir, name+".png")
|
||||
icoPath := filepath.Join(mediaDir, name+".ico")
|
||||
|
||||
fmt.Printf("Processing %s...\n", name)
|
||||
|
||||
// 1. Render SVG to PNG
|
||||
img, err := renderSVG(svgPath, 32, 32)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to render %s: %v", svgPath, err)
|
||||
}
|
||||
|
||||
f, err := os.Create(pngPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create %s: %v", pngPath, err)
|
||||
}
|
||||
|
||||
if err := png.Encode(f, img); err != nil {
|
||||
f.Close()
|
||||
log.Fatalf("Failed to encode PNG %s: %v", pngPath, err)
|
||||
}
|
||||
|
||||
f.Close()
|
||||
fmt.Printf("Created %s\n", pngPath)
|
||||
|
||||
// 2. Create ICO (containing multiple sizes)
|
||||
sizes := []int{16, 32, 48}
|
||||
|
||||
var images []image.Image
|
||||
|
||||
for _, s := range sizes {
|
||||
m, err := renderSVG(svgPath, s, s)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to render %s at size %d: %v", svgPath, s, err)
|
||||
}
|
||||
|
||||
images = append(images, m)
|
||||
}
|
||||
|
||||
if err := writeICO(icoPath, images); err != nil {
|
||||
log.Fatalf("Failed to write ICO %s: %v", icoPath, err)
|
||||
}
|
||||
|
||||
fmt.Printf("Created %s\n", icoPath)
|
||||
}
|
||||
}
|
||||
|
||||
func renderSVG(path string, w, h int) (image.Image, error) {
|
||||
in, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
icon, err := oksvg.ReadIconStream(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
icon.SetTarget(0, 0, float64(w), float64(h))
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
gv := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())
|
||||
dasher := rasterx.NewDasher(w, h, gv)
|
||||
icon.Draw(dasher, 1.0)
|
||||
|
||||
return rgba, nil
|
||||
}
|
||||
|
||||
// Simple ICO encoder that wraps PNGs
|
||||
func writeICO(path string, images []image.Image) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
bw := bufio.NewWriter(f)
|
||||
defer bw.Flush()
|
||||
|
||||
// ICONDIR header
|
||||
// Reserved (2), Type (2), Count (2)
|
||||
binary.Write(bw, binary.LittleEndian, uint16(0))
|
||||
binary.Write(bw, binary.LittleEndian, uint16(1)) // 1 = ICO
|
||||
binary.Write(bw, binary.LittleEndian, uint16(len(images)))
|
||||
|
||||
var pngData [][]byte
|
||||
|
||||
for _, img := range images {
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pngData = append(pngData, buf.Bytes())
|
||||
}
|
||||
|
||||
offset := uint32(6 + len(images)*16)
|
||||
for i, img := range images {
|
||||
b := img.Bounds()
|
||||
|
||||
width := uint8(b.Dx())
|
||||
if b.Dx() >= 256 {
|
||||
width = 0
|
||||
}
|
||||
|
||||
height := uint8(b.Dy())
|
||||
if b.Dy() >= 256 {
|
||||
height = 0
|
||||
}
|
||||
|
||||
// ICONDIRENTRY
|
||||
bw.WriteByte(width)
|
||||
bw.WriteByte(height)
|
||||
bw.WriteByte(0) // Color count
|
||||
bw.WriteByte(0) // Reserved
|
||||
binary.Write(bw, binary.LittleEndian, uint16(1)) // Planes (1)
|
||||
binary.Write(bw, binary.LittleEndian, uint16(32)) // Bits per pixel (32)
|
||||
binary.Write(bw, binary.LittleEndian, uint32(len(pngData[i])))
|
||||
binary.Write(bw, binary.LittleEndian, offset)
|
||||
|
||||
offset += uint32(len(pngData[i]))
|
||||
}
|
||||
|
||||
for _, data := range pngData {
|
||||
bw.Write(data)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
test_output/
|
||||
@@ -0,0 +1,305 @@
|
||||
// Package main provides a utility to extract icons from Bose-branded TrueType fonts.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/draw"
|
||||
"image/png"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
|
||||
"github.com/srwiley/rasterx"
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/sfnt"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
type IconMapping struct {
|
||||
Hex string `json:"hex"`
|
||||
GlyphName string `json:"glyph_name"`
|
||||
File string `json:"file"`
|
||||
SVGFile string `json:"svg_file,omitempty"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
fontPath := flag.String("font", "/path/to/bose.ttf", "Path to the TTF font file")
|
||||
outputDir := flag.String("output", "extracted_icons", "Output directory for icons")
|
||||
imgSize := flag.Int("size", 256, "Size of the PNG icons")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
if err := os.MkdirAll(*outputDir, 0755); err != nil {
|
||||
log.Fatalf("Failed to create output directory: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(*fontPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read font file: %v", err)
|
||||
}
|
||||
|
||||
f, err := sfnt.Parse(data)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse font: %v", err)
|
||||
}
|
||||
|
||||
var (
|
||||
buffer sfnt.Buffer
|
||||
glyphIndex sfnt.GlyphIndex
|
||||
glyphName string
|
||||
segments sfnt.Segments
|
||||
pngFile *os.File
|
||||
)
|
||||
|
||||
unitsPerEm := f.UnitsPerEm()
|
||||
ppem := fixed.Int26_6(unitsPerEm) << 6
|
||||
|
||||
m, err := f.Metrics(&buffer, ppem, font.HintingNone)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to get metrics: %v", err)
|
||||
}
|
||||
|
||||
mapping := make(map[rune]IconMapping)
|
||||
|
||||
// Iterate through common ranges
|
||||
ranges := []struct{ start, end rune }{
|
||||
{0x20, 0x7E}, // Basic Latin
|
||||
{0xA0, 0xFF}, // Latin-1 Supplement
|
||||
{0xE000, 0xF8FF}, // Private Use Area
|
||||
}
|
||||
|
||||
for _, rg := range ranges {
|
||||
for r := rg.start; r <= rg.end; r++ {
|
||||
glyphIndex, err = f.GlyphIndex(&buffer, r)
|
||||
if err != nil || glyphIndex == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
glyphName, err = f.GlyphName(&buffer, glyphIndex)
|
||||
if err != nil {
|
||||
glyphName = fmt.Sprintf("uni%04X", r)
|
||||
}
|
||||
|
||||
segments, err = f.LoadGlyph(&buffer, glyphIndex, ppem, nil)
|
||||
if err != nil {
|
||||
fmt.Printf("Failed to load glyph 0x%04X: %v\n", r, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(segments) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
charHex := fmt.Sprintf("%04X", r)
|
||||
pngFilename := fmt.Sprintf("icon_%s.png", charHex)
|
||||
svgFilename := fmt.Sprintf("icon_%s.svg", charHex)
|
||||
|
||||
// 1. Extract SVG
|
||||
svgPath := segmentsToSVGPath(segments)
|
||||
totalHeight := float64(m.Ascent+m.Descent) / 64.0
|
||||
svgContent := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 %g %d %g">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="%s" />
|
||||
</g>
|
||||
</svg>`, -float64(m.Ascent)/64.0, int(unitsPerEm), totalHeight, svgPath)
|
||||
|
||||
if err = os.WriteFile(filepath.Join(*outputDir, svgFilename), []byte(svgContent), 0644); err != nil {
|
||||
fmt.Printf("Failed to write SVG 0x%s: %v\n", charHex, err)
|
||||
}
|
||||
|
||||
// 2. Render PNG
|
||||
img := renderGlyphToPNG(segments, int(unitsPerEm), int(m.Ascent), int(m.Descent), *imgSize)
|
||||
|
||||
pngFile, err = os.Create(filepath.Join(*outputDir, pngFilename))
|
||||
if err == nil {
|
||||
if err = png.Encode(pngFile, img); err != nil {
|
||||
fmt.Printf("Failed to encode PNG 0x%s: %v\n", charHex, err)
|
||||
}
|
||||
|
||||
pngFile.Close()
|
||||
} else {
|
||||
fmt.Printf("Failed to create PNG file 0x%s: %v\n", charHex, err)
|
||||
}
|
||||
|
||||
mapping[r] = IconMapping{
|
||||
Hex: fmt.Sprintf("0x%s", charHex),
|
||||
GlyphName: glyphName,
|
||||
File: pngFilename,
|
||||
SVGFile: svgFilename,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Save mapping.json
|
||||
mappingList := make(map[string]IconMapping)
|
||||
|
||||
var keys []int
|
||||
|
||||
for r, m := range mapping {
|
||||
mappingList[fmt.Sprintf("%d", r)] = m
|
||||
keys = append(keys, int(r))
|
||||
}
|
||||
|
||||
sort.Ints(keys)
|
||||
|
||||
jsonData, err := json.MarshalIndent(mappingList, "", " ")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to marshal mapping: %v", err)
|
||||
}
|
||||
|
||||
_ = os.WriteFile(filepath.Join(*outputDir, "mapping.json"), jsonData, 0644)
|
||||
|
||||
// Save mapping.md
|
||||
mdFile, _ := os.Create(filepath.Join(*outputDir, "mapping.md"))
|
||||
fmt.Fprintln(mdFile, "# Bose Icons Mapping")
|
||||
fmt.Fprintln(mdFile, "")
|
||||
fmt.Fprintln(mdFile, "| Char Code | Glyph Name | PNG | SVG |")
|
||||
fmt.Fprintln(mdFile, "| --- | --- | --- | --- |")
|
||||
|
||||
for _, k := range keys {
|
||||
m := mapping[rune(k)]
|
||||
fmt.Fprintf(mdFile, "| %s | %s |  | [SVG](%s) |\n", m.Hex, m.GlyphName, m.GlyphName, m.File, m.SVGFile)
|
||||
}
|
||||
|
||||
mdFile.Close()
|
||||
|
||||
fmt.Printf("Extracted %d icons to %s\n", len(mapping), *outputDir)
|
||||
}
|
||||
|
||||
func segmentsToSVGPath(segments sfnt.Segments) string {
|
||||
var path string
|
||||
|
||||
for _, seg := range segments {
|
||||
switch seg.Op {
|
||||
case sfnt.SegmentOpMoveTo:
|
||||
path += fmt.Sprintf("M%g %g ", float64(seg.Args[0].X)/64.0, -float64(seg.Args[0].Y)/64.0)
|
||||
case sfnt.SegmentOpLineTo:
|
||||
path += fmt.Sprintf("L%g %g ", float64(seg.Args[0].X)/64.0, -float64(seg.Args[0].Y)/64.0)
|
||||
case sfnt.SegmentOpQuadTo:
|
||||
path += fmt.Sprintf("Q%g %g %g %g ", float64(seg.Args[0].X)/64.0, -float64(seg.Args[0].Y)/64.0, float64(seg.Args[1].X)/64.0, -float64(seg.Args[1].Y)/64.0)
|
||||
case sfnt.SegmentOpCubeTo:
|
||||
path += fmt.Sprintf("C%g %g %g %g %g %g ", float64(seg.Args[0].X)/64.0, -float64(seg.Args[0].Y)/64.0, float64(seg.Args[1].X)/64.0, -float64(seg.Args[1].Y)/64.0, float64(seg.Args[2].X)/64.0, -float64(seg.Args[2].Y)/64.0)
|
||||
}
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
func renderGlyphToPNG(segments sfnt.Segments, _, _, _, imgSize int) image.Image {
|
||||
rgba := image.NewRGBA(image.Rect(0, 0, imgSize, imgSize))
|
||||
draw.Draw(rgba, rgba.Bounds(), image.Transparent, image.Point{}, draw.Src)
|
||||
|
||||
// Calculate glyph bounds
|
||||
var xmin, ymin, xmax, ymax float64
|
||||
|
||||
initialized := false
|
||||
|
||||
for _, seg := range segments {
|
||||
for _, arg := range seg.Args {
|
||||
x, y := float64(arg.X)/64.0, float64(arg.Y)/64.0
|
||||
if !initialized {
|
||||
xmin, xmax = x, x
|
||||
ymin, ymax = y, y
|
||||
initialized = true
|
||||
} else {
|
||||
if x < xmin {
|
||||
xmin = x
|
||||
}
|
||||
|
||||
if x > xmax {
|
||||
xmax = x
|
||||
}
|
||||
|
||||
if y < ymin {
|
||||
ymin = y
|
||||
}
|
||||
|
||||
if y > ymax {
|
||||
ymax = y
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w := xmax - xmin
|
||||
h := ymax - ymin
|
||||
|
||||
// If no width/height, return empty image
|
||||
if w <= 0 || h <= 0 {
|
||||
return rgba
|
||||
}
|
||||
|
||||
// Calculate scale to fit in imgSize with padding
|
||||
padding := 20.0
|
||||
available := float64(imgSize) - 2*padding
|
||||
|
||||
scale := available / w
|
||||
if h*scale > available {
|
||||
scale = available / h
|
||||
}
|
||||
|
||||
// Center the glyph
|
||||
// X: center of image (imgSize/2) - (center of glyph (xmin+xmax)/2) * scale
|
||||
offsetX := float64(imgSize)/2.0 - (xmin+xmax)/2.0*scale
|
||||
// Y: center of image (imgSize/2) - (center of glyph (ymin+ymax)/2) * scale
|
||||
offsetY := float64(imgSize)/2.0 - (ymin+ymax)/2.0*scale
|
||||
|
||||
scanner := rasterx.NewScannerGV(imgSize, imgSize, rgba, rgba.Bounds())
|
||||
filler := rasterx.NewFiller(imgSize, imgSize, scanner)
|
||||
filler.SetColor(color.Black)
|
||||
|
||||
for _, seg := range segments {
|
||||
switch seg.Op {
|
||||
case sfnt.SegmentOpMoveTo:
|
||||
filler.Start(fixedP(
|
||||
offsetX+float64(seg.Args[0].X)/64.0*scale,
|
||||
offsetY+float64(seg.Args[0].Y)/64.0*scale,
|
||||
))
|
||||
case sfnt.SegmentOpLineTo:
|
||||
filler.Line(fixedP(
|
||||
offsetX+float64(seg.Args[0].X)/64.0*scale,
|
||||
offsetY+float64(seg.Args[0].Y)/64.0*scale,
|
||||
))
|
||||
case sfnt.SegmentOpQuadTo:
|
||||
filler.QuadBezier(
|
||||
fixedP(
|
||||
offsetX+float64(seg.Args[0].X)/64.0*scale,
|
||||
offsetY+float64(seg.Args[0].Y)/64.0*scale,
|
||||
),
|
||||
fixedP(
|
||||
offsetX+float64(seg.Args[1].X)/64.0*scale,
|
||||
offsetY+float64(seg.Args[1].Y)/64.0*scale,
|
||||
),
|
||||
)
|
||||
case sfnt.SegmentOpCubeTo:
|
||||
filler.CubeBezier(
|
||||
fixedP(
|
||||
offsetX+float64(seg.Args[0].X)/64.0*scale,
|
||||
offsetY+float64(seg.Args[0].Y)/64.0*scale,
|
||||
),
|
||||
fixedP(
|
||||
offsetX+float64(seg.Args[1].X)/64.0*scale,
|
||||
offsetY+float64(seg.Args[1].Y)/64.0*scale,
|
||||
),
|
||||
fixedP(
|
||||
offsetX+float64(seg.Args[2].X)/64.0*scale,
|
||||
offsetY+float64(seg.Args[2].Y)/64.0*scale,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
filler.Stop(true)
|
||||
filler.Draw()
|
||||
|
||||
return rgba
|
||||
}
|
||||
|
||||
func fixedP(x, y float64) fixed.Point26_6 {
|
||||
return fixed.Point26_6{X: fixed.Int26_6(x * 64), Y: fixed.Int26_6(y * 64)}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"golang.org/x/image/font"
|
||||
"golang.org/x/image/font/sfnt"
|
||||
"golang.org/x/image/math/fixed"
|
||||
)
|
||||
|
||||
func TestExtractE115(t *testing.T) {
|
||||
fontPath := "testdata/bose_subset.ttf"
|
||||
outputDir := "test_output"
|
||||
refDir := "testdata/references"
|
||||
imgSize := 256
|
||||
targetRune := rune(0xE115)
|
||||
|
||||
if err := os.MkdirAll(outputDir, 0755); err != nil {
|
||||
t.Fatalf("Failed to create output directory: %v", err)
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(fontPath)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read font file: %v", err)
|
||||
}
|
||||
|
||||
f, err := sfnt.Parse(data)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to parse font: %v", err)
|
||||
}
|
||||
|
||||
var buffer sfnt.Buffer
|
||||
unitsPerEm := f.UnitsPerEm()
|
||||
ppem := fixed.Int26_6(unitsPerEm) << 6
|
||||
|
||||
m, err := f.Metrics(&buffer, ppem, font.HintingNone)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to get metrics: %v", err)
|
||||
}
|
||||
|
||||
glyphIndex, err := f.GlyphIndex(&buffer, targetRune)
|
||||
if err != nil || glyphIndex == 0 {
|
||||
t.Fatalf("Failed to find glyph for 0x%X", targetRune)
|
||||
}
|
||||
|
||||
segments, err := f.LoadGlyph(&buffer, glyphIndex, ppem, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to load glyph 0x%X: %v", targetRune, err)
|
||||
}
|
||||
|
||||
charHex := fmt.Sprintf("%04X", targetRune)
|
||||
pngFilename := fmt.Sprintf("icon_%s.png", charHex)
|
||||
svgFilename := fmt.Sprintf("icon_%s.svg", charHex)
|
||||
|
||||
// 1. Extract SVG
|
||||
svgPath := segmentsToSVGPath(segments)
|
||||
totalHeight := float64(m.Ascent+m.Descent) / 64.0
|
||||
svgContent := fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 %g %d %g">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="%s" />
|
||||
</g>
|
||||
</svg>`, -float64(m.Ascent)/64.0, int(unitsPerEm), totalHeight, svgPath)
|
||||
|
||||
svgFilePath := filepath.Join(outputDir, svgFilename)
|
||||
if err = os.WriteFile(svgFilePath, []byte(svgContent), 0644); err != nil {
|
||||
t.Errorf("Failed to write SVG 0x%s: %v", charHex, err)
|
||||
}
|
||||
|
||||
// 2. Render PNG
|
||||
img := renderGlyphToPNG(segments, int(unitsPerEm), int(m.Ascent), int(m.Descent), imgSize)
|
||||
|
||||
pngFilePath := filepath.Join(outputDir, pngFilename)
|
||||
pngFile, err := os.Create(pngFilePath)
|
||||
if err == nil {
|
||||
if err = png.Encode(pngFile, img); err != nil {
|
||||
t.Errorf("Failed to encode PNG 0x%s: %v", charHex, err)
|
||||
}
|
||||
pngFile.Close()
|
||||
} else {
|
||||
t.Errorf("Failed to create PNG file 0x%s: %v", charHex, err)
|
||||
}
|
||||
|
||||
// 3. Compare with references
|
||||
for _, filename := range []string{svgFilename, pngFilename} {
|
||||
generated, err := os.ReadFile(filepath.Join(outputDir, filename))
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read generated file %s: %v", filename, err)
|
||||
continue
|
||||
}
|
||||
reference, err := os.ReadFile(filepath.Join(refDir, filename))
|
||||
if err != nil {
|
||||
t.Errorf("Failed to read reference file %s: %v", filename, err)
|
||||
continue
|
||||
}
|
||||
if string(generated) != string(reference) {
|
||||
t.Errorf("Mismatch in %s: generated does not match reference", filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M119 -31 L828 678 L871 635 L162 -74 L119 -31 M206 194 L206 405 Q206 426 220 440 Q235 455 256 455 L405 455 L602 654 L668 654 L668 638 L607 572 L430 394 L267 394 L267 204 L269 204 L222 157 Q206 171 206 194 M435 112 L478 155 L607 26 L607 285 L668 345 L668 -56 L602 -56 L435 112 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 404 B |
@@ -652,6 +652,73 @@ func listMusicServiceAccounts(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// pairDevice triggers the Stockholm registration flow via WebSocket
|
||||
func pairDevice(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
accountID := c.String("id")
|
||||
token := c.String("token")
|
||||
|
||||
PrintDeviceHeader("Pairing device with Marge account", clientConfig.Host, clientConfig.Port)
|
||||
fmt.Printf(" Account ID: %s\n", accountID)
|
||||
|
||||
// We need a WebSocket client for this
|
||||
ws := client.NewWebSocketClient(nil)
|
||||
|
||||
err = ws.Connect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = ws.Disconnect() }()
|
||||
|
||||
err = ws.PairWithAccount(accountID, token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send pairing request: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Pairing request sent successfully")
|
||||
fmt.Println("💡 The device will now register itself with the cloud service.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// unpairDevice triggers the Stockholm unregistration flow via WebSocket
|
||||
func unpairDevice(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Unpairing device from Marge account", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
// We need a WebSocket client for this
|
||||
ws := client.NewWebSocketClient(nil)
|
||||
|
||||
err = ws.Connect()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = ws.Disconnect() }()
|
||||
|
||||
err = ws.UnPairFromAccount()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send unpairing request: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Unpairing request sent successfully")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getServiceDisplayName returns a user-friendly display name for a service
|
||||
func getServiceDisplayName(source string) string {
|
||||
switch source {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gesellix/bose-soundtouch/pkg/models"
|
||||
@@ -251,6 +253,61 @@ func selectLocalInternetRadio(c *cli.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectCustomRadio handles selecting custom radio stream via soundtouch-service
|
||||
func selectCustomRadio(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
client, err := CreateSoundTouchClient(clientConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
streamURL := c.String("url")
|
||||
itemName := c.String("name")
|
||||
containerArt := c.String("artwork")
|
||||
serviceURL := c.String("service-url")
|
||||
|
||||
encodedURL := base64.URLEncoding.EncodeToString([]byte(streamURL))
|
||||
location := fmt.Sprintf("%s/custom/v1/playback/%s", serviceURL, encodedURL)
|
||||
|
||||
params := url.Values{}
|
||||
if itemName != "" {
|
||||
params.Add("name", itemName)
|
||||
}
|
||||
|
||||
if containerArt != "" {
|
||||
params.Add("imageUrl", containerArt)
|
||||
}
|
||||
|
||||
if len(params) > 0 {
|
||||
location += "?" + params.Encode()
|
||||
}
|
||||
|
||||
// Check LOCAL_INTERNET_RADIO availability
|
||||
checker := NewServiceAvailabilityChecker(client)
|
||||
if !checker.CheckSourceAvailable("LOCAL_INTERNET_RADIO", "select custom radio") {
|
||||
return fmt.Errorf("LOCAL_INTERNET_RADIO is not available")
|
||||
}
|
||||
|
||||
PrintDeviceHeader("Selecting custom radio stream", clientConfig.Host, clientConfig.Port)
|
||||
|
||||
if itemName != "" {
|
||||
fmt.Printf(" Station: %s\n", itemName)
|
||||
}
|
||||
|
||||
fmt.Printf(" URL: %s\n", streamURL)
|
||||
fmt.Printf(" Proxy: %s\n", location)
|
||||
|
||||
err = client.SelectLocalInternetRadio(location, "", itemName, containerArt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to select custom radio: %w", err)
|
||||
}
|
||||
|
||||
PrintSuccess("Custom radio stream selected")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// selectLocalMusic handles selecting LOCAL_MUSIC source
|
||||
func selectLocalMusic(c *cli.Context) error {
|
||||
clientConfig := GetClientConfig(c)
|
||||
|
||||
@@ -196,7 +196,7 @@ var httpClient = &http.Client{
|
||||
}
|
||||
|
||||
func fetchTuneInMetadata(url string) (*Metadata, error) {
|
||||
if !strings.Contains(url, "tunein.com/radio/") {
|
||||
if !strings.Contains(url, "tunein.com/radio/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
|
||||
return nil, fmt.Errorf("url is not a TuneIn radio URL")
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ func fetchTuneInMetadata(url string) (*Metadata, error) {
|
||||
}
|
||||
|
||||
func fetchSpotifyMetadata(url string) (*Metadata, error) {
|
||||
if !strings.Contains(url, "open.spotify.com/") {
|
||||
if !strings.Contains(url, "open.spotify.com/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
|
||||
return nil, fmt.Errorf("url is not a Spotify URL")
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
func TestFetchTuneInMetadata(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
html := `
|
||||
<!doctype html>
|
||||
<html>
|
||||
@@ -30,7 +30,7 @@ func TestFetchTuneInMetadata(t *testing.T) {
|
||||
|
||||
defer func() { httpClient = oldClient }()
|
||||
|
||||
metadata, err := fetchTuneInMetadata("https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/")
|
||||
metadata, err := fetchTuneInMetadata(ts.URL + "/radio/WDR-2-Rheinland-1004-s213886/")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchTuneInMetadata() error = %v", err)
|
||||
}
|
||||
@@ -162,7 +162,7 @@ func TestResolveLocationSpotify(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestFetchSpotifyMetadata(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
html := `
|
||||
<!doctype html>
|
||||
<html>
|
||||
@@ -185,7 +185,7 @@ func TestFetchSpotifyMetadata(t *testing.T) {
|
||||
|
||||
defer func() { httpClient = oldClient }()
|
||||
|
||||
metadata, err := fetchSpotifyMetadata("https://open.spotify.com/album/7F50uh7oGitmAEScRKV6pD")
|
||||
metadata, err := fetchSpotifyMetadata(ts.URL + "/album/7F50uh7oGitmAEScRKV6pD")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchSpotifyMetadata() error = %v", err)
|
||||
}
|
||||
|
||||
@@ -924,6 +924,34 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "custom-radio",
|
||||
Usage: "Select custom radio stream via soundtouch-service",
|
||||
Action: selectCustomRadio,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "url",
|
||||
Aliases: []string{"u"},
|
||||
Usage: "Stream URL",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "name",
|
||||
Aliases: []string{"n"},
|
||||
Usage: "Station name",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "artwork",
|
||||
Usage: "Station artwork URL",
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "service-url",
|
||||
Usage: "URL of the soundtouch-service (default: http://localhost:8080)",
|
||||
Value: "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "local-music",
|
||||
Usage: "Select local music content (LOCAL_MUSIC)",
|
||||
@@ -2010,6 +2038,30 @@ func main() {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "pair",
|
||||
Usage: "Pair the device with a Marge cloud account (Stockholm registration)",
|
||||
Action: pairDevice,
|
||||
Before: RequireHost,
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{
|
||||
Name: "id",
|
||||
Usage: "Marge account ID (e.g., 1234567)",
|
||||
Required: true,
|
||||
},
|
||||
&cli.StringFlag{
|
||||
Name: "token",
|
||||
Usage: "User authorization token",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "unpair",
|
||||
Usage: "Unpair the device from its Marge cloud account",
|
||||
Action: unpairDevice,
|
||||
Before: RequireHost,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Token commands
|
||||
|
||||
@@ -189,6 +189,11 @@ func main() {
|
||||
Usage: "Endpoints to mirror to Bose Cloud (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"MIRROR_ENDPOINTS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "skip-mirror-endpoints",
|
||||
Usage: "Endpoints to skip mirroring to Bose Cloud (comma-separated or multiple flags)",
|
||||
EnvVars: []string{"SKIP_MIRROR_ENDPOINTS"},
|
||||
},
|
||||
&cli.StringSliceFlag{
|
||||
Name: "internal-paths",
|
||||
Usage: "Paths for internal requests (comma-separated or multiple flags)",
|
||||
@@ -235,13 +240,13 @@ func main() {
|
||||
sm := setup.NewManager(config.serverURL, ds, cm)
|
||||
sm.MgmtUsername = config.mgmtUsername
|
||||
sm.MgmtPassword = config.mgmtPassword
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.migrationEnabled, config.migrationDryRun)
|
||||
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
|
||||
sm.GetDNSRunning = server.GetDNSRunning
|
||||
server.SetHTTPServerURL(config.httpsServerURL)
|
||||
server.SetVersionInfo(version, commit, date)
|
||||
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
|
||||
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
|
||||
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.PreferredSource)
|
||||
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource)
|
||||
server.SetInternalPaths(persisted.InternalPaths)
|
||||
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
|
||||
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
|
||||
@@ -374,6 +379,7 @@ type serviceConfig struct {
|
||||
dnsBind string
|
||||
mirrorEnabled bool
|
||||
mirrorEndpoints []string
|
||||
skipMirrorEndpoints []string
|
||||
internalPaths []string
|
||||
discoveryInterval time.Duration
|
||||
domains []string
|
||||
@@ -448,6 +454,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
mgmtPassword := c.String("mgmt-password")
|
||||
mirrorEnabled := c.Bool("mirror-enabled")
|
||||
mirrorEndpoints := c.StringSlice("mirror-endpoints")
|
||||
skipMirrorEndpoints := c.StringSlice("skip-mirror-endpoints")
|
||||
internalPaths := c.StringSlice("internal-paths")
|
||||
migrationEnabled := c.Bool("migration-enabled")
|
||||
migrationDryRun := c.Bool("migration-dry-run")
|
||||
@@ -469,6 +476,7 @@ func loadConfig(c *cli.Context) serviceConfig {
|
||||
dnsBind: dnsBind,
|
||||
mirrorEnabled: mirrorEnabled,
|
||||
mirrorEndpoints: mirrorEndpoints,
|
||||
skipMirrorEndpoints: skipMirrorEndpoints,
|
||||
internalPaths: internalPaths,
|
||||
discoveryInterval: discoveryInterval,
|
||||
domains: domains,
|
||||
@@ -565,6 +573,7 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
|
||||
config.mirrorEnabled = persisted.MirrorEnabled
|
||||
config.mirrorEndpoints = persisted.MirrorEndpoints
|
||||
config.skipMirrorEndpoints = persisted.SkipMirrorEndpoints
|
||||
config.preferredSource = persisted.PreferredSource
|
||||
config.internalPaths = persisted.InternalPaths
|
||||
|
||||
@@ -573,20 +582,21 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
|
||||
|
||||
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
|
||||
settings := datastore.Settings{
|
||||
ServerURL: config.serverURL,
|
||||
HTTPServerURL: config.httpsServerURL,
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DiscoveryEnabled: true,
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
MirrorEnabled: config.mirrorEnabled,
|
||||
MirrorEndpoints: config.mirrorEndpoints,
|
||||
PreferredSource: config.preferredSource,
|
||||
InternalPaths: config.internalPaths,
|
||||
ServerURL: config.serverURL,
|
||||
HTTPServerURL: config.httpsServerURL,
|
||||
RedactLogs: config.redact,
|
||||
LogBodies: config.logBody,
|
||||
RecordInteractions: config.record,
|
||||
DiscoveryInterval: config.discoveryInterval.String(),
|
||||
DiscoveryEnabled: true,
|
||||
DNSEnabled: config.dnsEnabled,
|
||||
DNSUpstream: strings.Split(config.dnsUpstream, ","),
|
||||
DNSBindAddr: config.dnsBind,
|
||||
MirrorEnabled: config.mirrorEnabled,
|
||||
MirrorEndpoints: config.mirrorEndpoints,
|
||||
SkipMirrorEndpoints: config.skipMirrorEndpoints,
|
||||
PreferredSource: config.preferredSource,
|
||||
InternalPaths: config.internalPaths,
|
||||
Shortcuts: map[string]int{
|
||||
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
|
||||
"/sw.js": http.StatusNotFound,
|
||||
@@ -663,9 +673,12 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
|
||||
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
|
||||
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
|
||||
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
|
||||
|
||||
streamingRoutes := func(r chi.Router) {
|
||||
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
|
||||
r.Post("/account", server.HandleMargeCreateAccount)
|
||||
r.Post("/account/login", server.HandleMargeLogin)
|
||||
r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
|
||||
r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
|
||||
r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
|
||||
@@ -723,6 +736,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
})
|
||||
|
||||
r.Route("/oauth", func(r chi.Router) {
|
||||
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
|
||||
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token", server.HandleBoseLegacyToken)
|
||||
r.HandleFunc("/*", server.HandleBoseProxy)
|
||||
})
|
||||
|
||||
@@ -740,14 +755,25 @@ func setupRouter(server *handlers.Server) *chi.Mux {
|
||||
// All other management endpoints require Basic Auth.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(server.BasicAuthMgmt())
|
||||
r.Get("/accounts/{accountId}/speakers", server.HandleMgmtListSpeakers)
|
||||
|
||||
r.Route("/accounts", func(r chi.Router) {
|
||||
r.Get("/", server.HandleMgmtListAccounts)
|
||||
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
|
||||
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
|
||||
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
|
||||
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
|
||||
})
|
||||
|
||||
r.Route("/spotify", func(r chi.Router) {
|
||||
r.Post("/init", server.HandleMgmtSpotifyInit)
|
||||
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
|
||||
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
|
||||
r.Get("/token", server.HandleMgmtSpotifyToken)
|
||||
r.Post("/entity", server.HandleMgmtSpotifyEntity)
|
||||
r.Post("/prime", server.HandleMgmtPrimeDevice)
|
||||
})
|
||||
|
||||
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
|
||||
r.Post("/spotify/init", server.HandleMgmtSpotifyInit)
|
||||
r.Post("/spotify/confirm", server.HandleMgmtSpotifyConfirm)
|
||||
r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts)
|
||||
r.Get("/spotify/token", server.HandleMgmtSpotifyToken)
|
||||
r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity)
|
||||
r.Post("/spotify/prime", server.HandleMgmtPrimeDevice)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ All content selection features from the [SoundTouch WebServices API Wiki](https:
|
||||
- Automatic defaults for missing parameters
|
||||
- **Use Cases**: Internet radio streams, proxy-based radio services
|
||||
|
||||
#### `SelectLocalInternetRadio(location, ...)` via `soundtouch-service`
|
||||
- **Purpose**: Select custom radio stream via local `soundtouch-service` proxy
|
||||
- **Features**:
|
||||
- Flexible stream URL encoding (Base64 or URL-escaped)
|
||||
- Dynamic generation of Bose-compatible playback JSON
|
||||
- Seamless integration with existing `LOCAL_INTERNET_RADIO` source
|
||||
- **Use Case**: Playing any internet radio URL without external proxy dependencies
|
||||
|
||||
#### `SelectLocalMusic(location, sourceAccount, itemName, containerArt string) error`
|
||||
- **Purpose**: Select LOCAL_MUSIC content from SoundTouch App Media Server
|
||||
- **Requirements**: SoundTouch App Media Server running on a computer
|
||||
@@ -47,6 +55,15 @@ soundtouch-cli --host <device> source internet-radio \
|
||||
--artwork "https://example.com/art.png"
|
||||
```
|
||||
|
||||
#### `soundtouch-cli source custom-radio`
|
||||
```bash
|
||||
soundtouch-cli --host <device> source custom-radio \
|
||||
--url "https://stream.example.com/radio" \
|
||||
--name "My Station" \
|
||||
--artwork "https://example.com/art.png" \
|
||||
--service-url "http://localhost:8080"
|
||||
```
|
||||
|
||||
#### `soundtouch-cli source local-music`
|
||||
```bash
|
||||
soundtouch-cli --host <device> source local-music \
|
||||
@@ -101,7 +118,7 @@ Comprehensive test suites implemented for all new functionality:
|
||||
### Example Code
|
||||
Complete working example demonstrating:
|
||||
- LOCAL_INTERNET_RADIO with streamUrl proxy format
|
||||
- LOCAL_INTERNET_RADIO with direct streams
|
||||
- LOCAL_INTERNET_RADIO with direct streams
|
||||
- LOCAL_MUSIC content selection
|
||||
- STORED_MUSIC content selection
|
||||
- Generic ContentItem usage
|
||||
@@ -152,7 +169,7 @@ All convenience methods create properly structured `ContentItem` objects:
|
||||
Based on the wiki structure, these related features are also supported:
|
||||
|
||||
1. **LOCAL_MUSIC**: ✅ Fully implemented
|
||||
2. **STORED_MUSIC**: ✅ Fully implemented
|
||||
2. **STORED_MUSIC**: ✅ Fully implemented
|
||||
3. **SPOTIFY**: ✅ Previously implemented
|
||||
4. **TUNEIN**: ✅ Previously implemented
|
||||
5. **BLUETOOTH**: ✅ Previously implemented
|
||||
@@ -169,7 +186,7 @@ err := client.SelectLocalInternetRadio(location, "", "My Station", "")
|
||||
// Direct ContentItem
|
||||
contentItem := &models.ContentItem{
|
||||
Source: "LOCAL_INTERNET_RADIO",
|
||||
Type: "stationurl",
|
||||
Type: "stationurl",
|
||||
Location: location,
|
||||
ItemName: "My Station",
|
||||
IsPresetable: true,
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
This document summarizes the improvements made to the **Marge service** to improve parity with the upstream Bose SoundTouch service, along with open issues and proposed next steps.
|
||||
|
||||
#### ✅ Completed Improvements (Marge Service)
|
||||
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` or `ButtonNumber` to the `buttonNumber` XML attribute in the `/full` response and ensured it is persisted in the local datastore.
|
||||
* **High-Fidelity Device Metadata**: Improved the datastore to correctly extract, persist, and report detailed device `<components>` (e.g., `LIGHTSWITCH`, `SMSC`) and their firmware versions from upstream responses.
|
||||
* **Standardized Preferred Language**: Updated the default `preferredLanguage` to `de` in the `/full` response and added synchronization to persist it from upstream responses.
|
||||
* **Persisted Provider Settings**: Added support for persisting and echoing back `providerSettings` (e.g., `STREAMING_QUALITY`, `ELIGIBLE_FOR_TRIAL`) from the `/full` response.
|
||||
* **Populated `contentItemType`**: The `contentItemType` (e.g., `tracklisturl`) is now correctly synchronized from upstream, persisted in the local datastore, and returned in the `/full` response for both presets and recents.
|
||||
* **Standardized Credential Types**: Adjusted the logic for Spotify to use the correct `token_version_3` type when a token is present in the `/full` response, improving parity with the upstream service. The service now respects existing `credential_type` values from `Sources.xml` (e.g., `token_version_3` for Spotify) while providing sensible defaults for new or incomplete sources.
|
||||
* **Structured Sources (Sources.xml)**: Refactored `Sources.xml` to use an attribute-based structure (`sourceid`, `source`, `status`, `sourceAccount`, etc.) matching the real device's output. Removed redundant nested tags like `<sourcename>`, `<username>`, and `<name>`.
|
||||
* **Nested Recents (Recents.xml)**: Implemented a nested `<contentItem>` structure within `<recent>` entries in `Recents.xml`, maintaining exact parity with the device's persistence format while supporting legacy flat formats for backward compatibility.
|
||||
* **Inconsistent `serialNumber` Casing**: Fixed the casing mismatch in the `/full` response where the upstream uses camelCase `<serialNumber>` in the top-level `<device>` and lowercase `<serialnumber>` in the nested `<attachedProduct>`. Local responses now correctly mirror this inconsistency.
|
||||
* **Attribute-level Parity**:
|
||||
* Ensured `sourceAccount=""` is preserved in XML even when empty, matching device behavior for sources like TUNEIN.
|
||||
* Fixed casing for attributes like `deviceID` and `utcTime` in `Recents.xml`.
|
||||
* Correctly mapped and persisted preset and recent `id` attributes during "Initial Data Sync".
|
||||
* **Device Name Consistency**: Fixed an issue where the device `<name>` was empty in some local `/full` responses by ensuring it is correctly populated from the datastore and synchronized from upstream.
|
||||
* **Improved XML Parity**: Empty `<name>` tags in the `/full` response are now self-closing (`<name/>`), matching upstream behavior.
|
||||
* **Timestamp-based ID Generation**: Implemented a 9-digit ID schema (`YYMMDD` + 3-digit counter) for `recent` items, ensuring IDs are large, unique, and stay within the 32-bit integer range.
|
||||
* **Automatic Source Learning**: The service now extracts and persists full metadata (credentials, provider IDs, and custom names) from incoming `POST /recent` requests. This improves parity for subsequent `GET /recents` calls.
|
||||
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
|
||||
@@ -15,6 +30,7 @@ This document summarizes the improvements made to the **Marge service** to impro
|
||||
* Implemented structured XML marshaling with consistent 2-space indentation for recents and source providers.
|
||||
* **Improved TuneIn Parity**: Fixed TuneIn source mapping to use ID `25` and ensuring `sourcename` is empty in responses, matching upstream behavior for station playback.
|
||||
* **High-Fidelity Full Account Sync**: Refactored the `/streaming/account/{accountId}/full` response to match the upstream structure. This includes:
|
||||
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` to the `buttonNumber` XML attribute in the `/full` response.
|
||||
* **Structured XML Marshaling**: Replaced manual string concatenation with structured Go models and `xml.Marshal` for the entire response.
|
||||
* **Specific Response Models**: Introduced `FullResponseSource`, `FullResponsePreset`, and `FullResponseRecent` to accurately reflect the upstream structure where `<source>` is a child element, rather than a set of attributes.
|
||||
* **Correct Nesting**: Ensured that `<presets>` and `<recents>` correctly nest their associated `<source>` details, resolving previous data omissions.
|
||||
@@ -30,19 +46,21 @@ This document summarizes the improvements made to the **Marge service** to impro
|
||||
|
||||
#### 🛠️ Open Issues and Next Steps
|
||||
|
||||
Based on the latest `parity_mismatches`, here are the recommended areas for further work:
|
||||
Based on the latest `parity_mismatches` and the high-fidelity `/full` account response comparison (diff14), here are the recommended areas for further work:
|
||||
|
||||
#### 1. BMX / TuneIn Playback Parity (Medium)
|
||||
Current mismatches in `/bmx/tunein/v1/playback/station/...` show differences in reporting URLs and missing links:
|
||||
* **Mismatched Parameters**: Local reporting URLs use `listen_id=3432432423`, while upstream uses a different session-based ID.
|
||||
* **Mismatched Parameters**: Local reporting URLs use `listen_id=1234567890`, while upstream uses a different session-based ID.
|
||||
* **Missing Links**: Some upstream responses include additional `_links` or metadata that are currently omitted in local responses.
|
||||
* **Action**: Improve the `HandleTuneInPlayback` logic to better mirror the upstream response structure and parameter generation.
|
||||
|
||||
#### 2. Presets and Recents Parity (Medium)
|
||||
Further align the standalone `GET /presets` and `GET /recents` endpoints with the refined structural improvements introduced for the `/full` account response:
|
||||
* **Source Nesting**: Ensure the standalone responses also use the specialized nested `<source>` structure instead of mixed attributes when appropriate.
|
||||
* **Field Completeness**: Verify all metadata fields (e.g., `<contentItemType>`, `<lastplayedat>`) are consistently populated across all access paths.
|
||||
* **Action**: Evaluate if the specialized `FullResponsePreset` and `FullResponseRecent` models should be shared or mirrored in the standalone handlers.
|
||||
#### 2. `/full` Account Response Data Gaps (Medium)
|
||||
While structural parity for the `/full` response is high, several value-level gaps remain as shown in `diff14`:
|
||||
* **Timestamp Formats**: Upstream uses ISO-8601 with milliseconds (e.g., `2024-06-23T07:40:36.000+00:00`), whereas some local fields still use Unix epoch integers (e.g., `1234567890`).
|
||||
* **Provider Settings**: The `providerSettings` block in the local response currently lacks crucial values like `keyName`, `providerId`, and `boseId` (appearing as empty tags).
|
||||
* **Component Metadata**: Local component types are sometimes empty (`type=""`) compared to upstream values like `LIGHTSWITCH` or `SMSC`.
|
||||
* **Source/Preset Identifiers**: Local IDs (e.g., `100004`) differ from upstream IDs (e.g., `1234567`), though this may be expected due to different account/device environments.
|
||||
* **Action**: Update the mapping logic in `marge.go` and `setup.go` to ensure all fields in the `/full` response are correctly populated with high-fidelity values and standard ISO-8601 timestamps.
|
||||
|
||||
#### 3. OAuth / Spotify Token Noise (Low/Medium)
|
||||
The `/oauth/device/.../token` endpoint frequently reports mismatches because tokens are naturally different between local and upstream.
|
||||
@@ -60,3 +78,11 @@ Continue the "learning" approach for other services. For example, if we see a ne
|
||||
Analysis of device reboot logs revealed several data requirements:
|
||||
* **Power-On Details Tracking**: Implemented extraction and persistence of detailed device information (serial numbers, firmware version, product details, and MAC addresses) from the `POST /streaming/support/power_on` request. This data is now stored in the local datastore, improving our ability to respond accurately to subsequent management requests.
|
||||
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
|
||||
|
||||
#### 7. Account Full Response (/full) Structural & Value Parity (Completed)
|
||||
Structural and value gaps in the `/full` account response have been addressed:
|
||||
|
||||
**Key Fixes:**
|
||||
* **Structural**:
|
||||
* **Nested Source Association**: Improved the matching logic in `mapRecentsToFullResponse` to correctly link recents to their specific `ConfiguredSource` (e.g., by matching `sourceid` attribute).
|
||||
* **XML Tag Formatting**: Standardized self-closing tags and element formatting to match upstream's multi-line or empty-element formatting in various contexts.
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Parity Analysis: Bose-SoundTouch (Go) vs. SoundCork (Python)
|
||||
|
||||
This document provides a comparative analysis of the current Go implementation and the `deborahgu/soundcork` project, identifying functional gaps and potential improvements.
|
||||
|
||||
## 1. Core Architecture and Language
|
||||
- **Bose-SoundTouch (Go)**: Uses `chi` for routing and `encoding/xml` for data. High performance, strong typing, and precise MIME type handling (`application/vnd.bose.streaming-v1.2+xml`).
|
||||
- **SoundCork (Python)**: Uses `FastAPI` and `xml.etree.ElementTree`. Prioritizes flexibility and rapid prototyping of streaming service mocks.
|
||||
|
||||
## 2. Functional Comparison
|
||||
|
||||
| Feature | Bose-SoundTouch (Go) | SoundCork (Python) |
|
||||
|:---------------------|:-------------------------------------------------|:----------------------------------------------------------------------------------------|
|
||||
| **Group Management** | Placeholder handlers (return `<group/>` or 404). | Active group management (`groups.py`), supporting `/addGroup` and stereo pairing logic. |
|
||||
| **BMX Services** | Supports TuneIn, Orion, and custom streams. | More modular `bmx_services.json` registry with broader mock support. |
|
||||
| **Persistence** | Mixed JSON/XML datastore. | Pure XML-based persistence per device/account. |
|
||||
| **Admin UI** | CLI-based (`soundtouch-cli`) or API-driven. | Draft Web UI for device discovery and account management (`admin.py`). |
|
||||
| **Discovery** | Integrated setup tools and SSDP/MDNS awareness. | Leverages `bosesoundtouchapi` Python library for active discovery. |
|
||||
|
||||
## 3. Key Strengths of SoundCork
|
||||
- **Group Pairing Logic**: Includes logic to manage master/slave relationships for SoundTouch 10 stereo pairs.
|
||||
- **Service Extensibility**: JSON-based registry for BMX services makes it easier to mock multiple providers (SiriusXM, Spotify) without code changes.
|
||||
- **Mock Coverage**: Better coverage of "dummy" endpoints that respond with plausible XML (e.g., `customerSupport`).
|
||||
|
||||
## 4. Suggested Implementation Steps for Bose-SoundTouch
|
||||
|
||||
### A. Implement Full Group Support (High Priority)
|
||||
- Add logic to `pkg/service/marge` to handle `/addGroup` and `/updateGroup`.
|
||||
- Persist group memberships in the datastore to allow speakers to function as stereo pairs or multi-room zones.
|
||||
|
||||
### B. Modularize BMX Registry (Medium Priority)
|
||||
- Extract the hardcoded service list in `HandleBMXRegistry` into an external `bmx-services.json` file.
|
||||
- Allow users to customize which mocked services are advertised to the speaker.
|
||||
|
||||
### C. Enhanced Source Management (Medium Priority)
|
||||
- Refine source learning logic to ensure all `sourceAccount` and `sourceName` metadata is correctly captured during synchronization, using patterns from `soundcork`'s `learnSource`.
|
||||
|
||||
### D. Basic Admin Web UI (Low Priority)
|
||||
- Develop a minimal internal status page to list active accounts and connected devices, improving usability over raw API calls.
|
||||
|
||||
## 5. Summary
|
||||
While our Go implementation is structurally more consistent with recent reference recordings (e.g., `buttonNumber`, detailed `components`), SoundCork provides better coverage of multi-device coordination (Groups) and service emulation (BMX) that we should adopt for a more complete offline experience.
|
||||
@@ -57,6 +57,10 @@
|
||||
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
|
||||
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
|
||||
|
||||
## Parity Analysis
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
|
||||
* [Parity SoundCork](PARITY-SOUNDCORK.md)
|
||||
|
||||
## Appendix (Other Documents)
|
||||
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
|
||||
* [Claude Instructions](CLAUDE.md)
|
||||
@@ -81,3 +85,4 @@
|
||||
* [Power On Implementation Guide](power-on-implementation-guide.md)
|
||||
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
|
||||
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
|
||||
* [Parity SoundCork](PARITY-SOUNDCORK.md)
|
||||
|
||||
@@ -394,6 +394,9 @@ soundtouch-cli --host <device> source spotify
|
||||
soundtouch-cli --host <device> source bluetooth
|
||||
soundtouch-cli --host <device> source aux
|
||||
|
||||
# Custom radio selection (via soundtouch-service)
|
||||
soundtouch-cli --host <device> source custom-radio --url <STREAM_URL> [--name <NAME>] [--artwork <ARTWORK>] [--service-url <SERVICE_URL>]
|
||||
|
||||
# Advanced content selection
|
||||
soundtouch-cli --host <device> source internet-radio --location <URL> [--name <NAME>]
|
||||
soundtouch-cli --host <device> source local-music --location <LOCATION> --account <ACCOUNT>
|
||||
@@ -482,6 +485,7 @@ soundtouch-cli --host 192.168.1.10 source compare
|
||||
| Command | Description | Requirements |
|
||||
|---------|-------------|--------------|
|
||||
| `internet-radio` | Select internet radio stream (LOCAL_INTERNET_RADIO) | Stream URL |
|
||||
| `custom-radio` | Select custom radio stream via soundtouch-service | Stream URL and service URL |
|
||||
| `local-music` | Select local music content (LOCAL_MUSIC) | SoundTouch App Media Server |
|
||||
| `stored-music` | Select stored music content (STORED_MUSIC) | UPnP/DLNA media server |
|
||||
| `content` | Generic content selection (advanced) | Source and location |
|
||||
@@ -495,6 +499,12 @@ The `internet-radio` command supports the streamUrl proxy format from the [Sound
|
||||
soundtouch-cli --host 192.168.1.10 source internet-radio \
|
||||
--location "http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp" \
|
||||
--name "Antenne Chillout"
|
||||
|
||||
# Using local soundtouch-service for custom streams
|
||||
soundtouch-cli --host 192.168.1.10 source custom-radio \
|
||||
--url "https://stream.antenne.de/chillout/stream/aacp" \
|
||||
--name "Antenne Chillout" \
|
||||
--service-url "http://localhost:8080"
|
||||
```
|
||||
|
||||
#### Service Introspection
|
||||
@@ -1044,7 +1054,7 @@ soundtouch-cli --host 192.168.1.10 speaker beep
|
||||
|
||||
**Supported Languages for TTS:**
|
||||
- `EN` - English (default)
|
||||
- `DE` - German
|
||||
- `DE` - German
|
||||
- `ES` - Spanish
|
||||
- `FR` - French
|
||||
- `IT` - Italian
|
||||
@@ -1085,7 +1095,7 @@ soundtouch-cli --host <device> events subscribe [flags]
|
||||
|
||||
**Event Types:**
|
||||
- `nowPlaying` - Track changes, playback status
|
||||
- `volume` - Volume and mute changes
|
||||
- `volume` - Volume and mute changes
|
||||
- `connection` - Network connectivity status
|
||||
- `preset` - Preset configuration changes
|
||||
- `zone` - Multiroom zone changes
|
||||
|
||||
@@ -26,10 +26,11 @@ The service consists of several key components:
|
||||
|
||||
### BMX Services (Bose Media eXchange)
|
||||
- **TuneIn Integration**: Direct playback of radio stations and podcasts
|
||||
- **Custom Streams**: Flexible playback of any internet radio URL via dynamic proxy
|
||||
- **Service Registry**: Media service discovery and configuration
|
||||
- **Playback Control**: Stream URL resolution and audio metadata
|
||||
|
||||
### Marge Services (Account & Device Management)
|
||||
### Marge Services (Account & Device Management)
|
||||
- **Account Management**: User account simulation and device association
|
||||
- **Preset Synchronization**: Cross-device preset storage and sync
|
||||
- **Recent Items**: Playback history tracking and management
|
||||
@@ -57,7 +58,7 @@ go build -o soundtouch-service ./cmd/soundtouch-service
|
||||
|
||||
### Docker Support
|
||||
|
||||
You can run the SoundTouch service using Docker or Docker Compose.
|
||||
You can run the SoundTouch service using Docker or Docker Compose.
|
||||
|
||||
> **Note for macOS and Windows users**: The `--net host` option is only supported on Linux. On macOS and Windows, service discovery (mDNS, UPnP) will not work automatically within the container. You will need to manually enter your device's IP address in the management UI, and the service will communicate with it directly.
|
||||
|
||||
@@ -393,7 +394,7 @@ Migrates device to use local services.
|
||||
|
||||
**Query Parameters:**
|
||||
- `target_url`: Custom service URL (optional)
|
||||
- `proxy_url`: Proxy URL for fallback (optional)
|
||||
- `proxy_url`: Proxy URL for fallback (optional)
|
||||
- `marge`: Set to "original" to proxy Marge requests (optional)
|
||||
- `stats`: Set to "original" to proxy stats requests (optional)
|
||||
- `sw_update`: Set to "original" to proxy update requests (optional)
|
||||
@@ -764,7 +765,7 @@ ls -la data/events/
|
||||
This service implementation is based on and inspired by several excellent community projects:
|
||||
|
||||
### SoundCork
|
||||
- **Project**: [SoundCork](https://github.com/deborahgu/soundcork)
|
||||
- **Project**: [SoundCork](https://github.com/deborahgu/soundcork)
|
||||
- **Authors**: Deborah Gu and contributors
|
||||
- **Contribution**: The architecture and service emulation approach in this Go implementation is heavily based on SoundCork's pioneering Python implementation. SoundCork provided the foundation for understanding Bose's service architecture and migration strategies.
|
||||
|
||||
@@ -796,7 +797,7 @@ func customBMXHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func main() {
|
||||
r := chi.NewRouter()
|
||||
r.Get("/bmx/custom/endpoint", customBMXHandler)
|
||||
r.Get("/custom/endpoint", customBMXHandler)
|
||||
http.ListenAndServe(":8000", r)
|
||||
}
|
||||
```
|
||||
@@ -809,7 +810,7 @@ soundtouch:
|
||||
- host: 192.168.1.100
|
||||
port: 8090
|
||||
name: "Living Room Speaker"
|
||||
|
||||
|
||||
rest:
|
||||
- resource: "http://localhost:8000/setup/devices"
|
||||
scan_interval: 60
|
||||
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M101 600 L528 300 L101 1 L101 600 M610 600 L713 600 L713 1 L610 1 L610 600 M795 600 L897 600 L897 1 L795 1 L795 600 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 245 B |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M328 598 L770 299 L328 -1 L328 598 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 164 B |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M338 595 L766 295 L338 -4 L338 595 M659 295 L399 478 L399 113 L659 295 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 200 B |
|
After Width: | Height: | Size: 930 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M305 595 L407 595 L407 -3 L305 -3 L305 595 M590 595 L693 595 L693 -3 L590 -3 L590 595 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 215 B |
|
After Width: | Height: | Size: 936 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M358 600 L419 600 L419 2 L358 2 L358 600 M584 600 L645 600 L645 2 L584 2 L584 600 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 211 B |
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M46 302 L487 601 L487 352 L853 601 L853 2 L487 251 L487 2 L46 302 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 195 B |
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M59 295 L486 594 L486 337 L853 594 L853 -5 L486 253 L486 -5 L59 295 M425 477 L165 295 L425 112 L425 477 M792 477 L532 295 L792 112 L792 477 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 269 B |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M260 598 L363 598 L363 347 L731 598 L731 -1 L363 250 L363 -1 L260 -1 L260 598 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M284 597 L345 597 L345 340 L713 597 L713 -1 L345 257 L345 -1 L284 -1 L284 597 M652 480 L392 299 L652 116 L652 480 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M80 598 L182 598 L182 348 L551 598 L551 349 L918 598 L918 -0 L551 249 L551 -0 L182 250 L182 -0 L80 -0 L80 598 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M98 600 L159 600 L159 343 L527 600 L527 343 L895 600 L895 1 L527 259 L527 1 L159 260 L159 1 L98 1 L98 600 M466 483 L206 301 L466 119 L466 483 M834 483 L573 301 L834 119 L834 483 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M147 600 L514 351 L514 600 L956 301 L514 2 L514 251 L147 2 L147 600 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 197 B |
|
After Width: | Height: | Size: 3.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M145 594 L512 336 L512 594 L939 294 L512 -5 L512 252 L145 -5 L145 594 M466 294 L206 476 L206 112 L466 294 M833 294 L573 476 L573 112 L833 294 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 271 B |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M260 598 L629 347 L629 598 L731 598 L731 -1 L629 -1 L629 250 L260 -1 L260 598 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 207 B |
|
After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M284 597 L652 339 L652 597 L713 597 L713 -1 L652 -1 L652 256 L284 -1 L284 597 M605 298 L345 480 L345 116 L605 298 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M80 598 L447 349 L447 598 L815 348 L815 598 L918 598 L918 -0 L815 -0 L815 250 L447 -0 L447 248 L80 -0 L80 598 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 239 B |
|
After Width: | Height: | Size: 4.6 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M98 599 L465 342 L465 599 L833 341 L833 599 L894 599 L894 1 L833 1 L833 258 L465 1 L465 257 L98 1 L98 599 M419 300 L159 482 L159 118 L419 300 M786 300 L526 482 L526 118 L786 300 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 307 B |
|
After Width: | Height: | Size: 902 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M200 597 L798 597 L798 -2 L200 -2 L200 597 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 172 B |
|
After Width: | Height: | Size: 967 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M200 597 L798 597 L798 -2 L200 -2 L200 597 M737 59 L737 536 L260 536 L260 59 L737 59 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 214 B |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M155 178 L334 357 L377 314 L271 209 L614 209 Q658 209 695 231 Q732 253 754 290 Q776 327 776 371 Q776 415 754 452 Q732 489 695 511 Q658 533 614 533 L431 533 L431 594 L614 594 Q674 594 725 564 Q777 534 807 482 Q837 431 837 371 Q837 311 807 259 Q777 208 725 178 Q674 148 614 148 L272 148 L379 41 L336 -2 L155 178 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
|
After Width: | Height: | Size: 2.3 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M466 140 L318 140 Q297 140 282 155 Q268 170 268 191 L268 402 Q268 423 282 437 Q297 452 317 452 L467 452 L664 651 L730 651 L730 -59 L664 -59 L466 140 M669 570 L510 410 L492 391 L329 391 L329 201 L492 201 L509 183 L669 23 L669 570 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 358 B |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M119 -31 L828 678 L871 635 L162 -74 L119 -31 M206 194 L206 405 Q206 426 220 440 Q235 455 256 455 L405 455 L602 654 L668 654 L668 638 L607 572 L430 394 L267 394 L267 204 L269 204 L222 157 Q206 171 206 194 M435 112 L478 155 L607 26 L607 285 L668 345 L668 -56 L602 -56 L435 112 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 404 B |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M238 141 L90 141 Q69 141 54 156 Q39 171 39 191 L39 403 Q39 423 53 438 Q68 453 89 453 L238 453 L435 652 L501 652 L501 -58 L435 -58 L238 141 M440 570 L281 410 L264 392 L100 392 L100 202 L263 202 L440 24 L440 570 M784 272 L665 272 L665 333 L784 333 L784 446 L845 446 L845 333 L964 333 L964 272 L845 272 L845 147 L784 147 L784 272 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 456 B |
|
After Width: | Height: | Size: 2.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M238 141 L90 141 Q69 141 54 156 Q39 171 39 191 L39 403 Q39 423 53 438 Q68 453 89 453 L238 453 L435 652 L501 652 L501 -58 L435 -58 L238 141 M440 570 L281 410 L264 392 L100 392 L100 202 L263 202 L440 24 L440 570 M665 333 L964 333 L964 272 L665 272 L665 333 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 384 B |
|
After Width: | Height: | Size: 3.6 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M284 189 L170 189 Q154 189 142 200 Q130 212 130 228 L130 392 Q130 408 141 419 Q153 431 169 431 L285 431 L437 585 L488 585 L488 35 L437 35 L284 189 M711 91 Q739 117 770 173 Q802 229 802 309 Q802 388 771 442 Q740 496 711 525 L759 574 Q794 539 831 472 Q869 405 869 308 Q869 211 831 143 Q794 75 760 41 L711 91 M598 211 Q610 221 624 246 Q638 272 638 307 Q638 342 625 365 Q613 388 598 405 L646 453 Q665 434 685 397 Q706 361 706 308 Q706 255 685 218 Q665 181 646 162 L598 211 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 598 B |
|
After Width: | Height: | Size: 989 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M467 271 L142 271 L142 332 L467 332 L467 656 L528 656 L528 332 L852 332 L852 271 L528 271 L528 -53 L467 -53 L467 271 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 246 B |
|
After Width: | Height: | Size: 888 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M142 332 L852 332 L852 271 L142 271 L142 332 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 174 B |
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M121 100 Q65 133 32 189 Q0 245 0 311 Q0 377 32 433 Q65 489 121 522 Q177 555 243 555 L406 555 L406 652 L588 523 L406 395 L406 494 L243 494 Q194 494 152 469 Q110 445 85 403 Q61 361 61 311 Q61 261 85 219 Q110 177 152 152 Q194 128 244 128 L339 128 Q342 93 352 67 L243 67 Q177 67 121 100 M871 150 Q871 165 870 172 Q901 198 918 234 Q935 270 935 311 Q935 361 910 403 Q885 445 843 469 Q801 494 751 494 L695 494 L695 555 L751 555 Q817 555 873 522 Q929 489 962 433 Q995 377 995 311 Q995 242 960 184 Q925 127 866 96 Q871 124 871 150 M501 -28 Q454 -1 426 46 Q399 94 399 150 Q399 206 426 253 Q454 301 501 328 Q549 356 605 356 Q661 356 708 328 Q755 301 782 253 Q810 206 810 150 Q810 94 782 46 Q755 -1 708 -28 Q661 -56 605 -56 Q549 -56 501 -28 M637 30 L637 270 L602 270 L534 250 L545 209 L587 219 L587 30 L637 30 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 927 B |
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M122 93 Q66 126 33 182 Q0 238 0 304 Q0 370 33 426 Q66 483 122 516 Q179 549 245 549 L420 549 L420 630 L578 518 L420 406 L420 488 L245 488 Q195 488 152 463 Q110 439 85 396 Q61 354 61 304 Q61 255 85 213 Q110 171 152 146 Q195 121 245 121 L303 121 L303 60 L245 60 Q179 60 122 93 M420 91 L578 202 L578 121 L755 121 Q805 121 847 146 Q889 171 914 213 Q939 255 939 304 Q939 354 914 396 Q889 439 847 464 Q805 489 755 489 L698 489 L698 549 L755 549 Q821 549 877 516 Q934 483 967 426 Q1000 370 1000 304 Q1000 239 967 183 Q934 127 877 93 Q821 60 755 60 L578 60 L578 -21 L420 91 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 694 B |
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M798 82 Q664 84 571 146 Q514 183 454 252 Q421 214 392 187 Q364 160 331 138 Q232 73 94 73 L60 73 L60 134 L94 134 Q163 134 217 151 Q269 168 309 197 Q349 226 389 270 Q415 299 414 298 Q368 351 328 385 Q288 419 230 440 Q173 462 94 462 L60 462 L60 523 L94 523 Q230 523 331 458 Q364 436 392 409 Q421 382 454 344 Q479 373 517 409 L521 412 Q539 428 571 450 Q666 513 798 514 L798 595 L956 484 L798 372 L798 453 Q724 452 669 431 Q615 411 576 379 Q537 347 495 298 Q538 249 576 216 Q615 184 669 163 Q724 143 798 143 L798 224 L956 112 L798 -0 L798 82 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 666 B |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M156 310 L501 655 L845 310 L802 267 L501 569 L199 267 L156 310 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 192 B |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M156 292 L199 335 L501 33 L802 335 L845 292 L501 -53 L156 292 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 191 B |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M145 300 L490 645 L533 601 L231 300 L533 -2 L490 -45 L145 300 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 191 B |
|
After Width: | Height: | Size: 1.4 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M467 -2 L768 300 L467 601 L510 645 L855 300 L510 -45 L467 -2 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 190 B |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M457 452 L200 452 L200 513 L798 513 L798 452 L539 452 L798 84 L200 84 L457 452 M681 145 L498 405 L317 145 L681 145 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M200 145 L458 145 L200 513 L798 513 L541 145 L798 145 L798 84 L200 84 L200 145 M681 452 L317 452 L499 192 L681 452 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 244 B |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M143 223 L186 266 L346 106 L819 579 L862 536 L346 20 L143 223 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 191 B |
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 -765 1000 924">
|
||||
<g transform="scale(1, -1)">
|
||||
<path d="M500 513 L799 85 L201 85 L500 513 " />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 163 B |