Compare commits

..
2 Commits
Author SHA1 Message Date
Tobias Gesellchen e424ee6546 Extract all icons, add mapping including names 2026-03-28 15:49:06 +01:00
Tobias Gesellchen d9d9a67f0e Add font-icon-extractor tool 2026-03-28 15:49:06 +01:00
571 changed files with 4316 additions and 9494 deletions
-9
View File
@@ -28,15 +28,6 @@
},
{
"pattern": "^\\.\\./images/(dashboard-home|account-creation|account-dashboard|usb-remote-services|device-discovery|device-registration|account-migration|migration-setup|migration-progress|migration-health|migration-complete|backup-setup)\\.png$"
},
{
"pattern": "https://www.contributor-covenant.org/version/2/0/code_of_conduct.html"
},
{
"pattern": "https://www.apkmirror.com/apk/bose-corporation/bose-soundtouch/"
},
{
"pattern": "https://apkpure.com/bose-soundtouch/com.bose.soundtouch"
}
],
"replacementPatterns": [
+5 -3
View File
@@ -147,9 +147,11 @@ jobs:
uses: actions/checkout@v6
- name: Check documentation links
run: |
npm install -g markdown-link-check
find . -name "*.md" -not -path "./tests/*" -not -path "./node_modules/*" -print0 | xargs -0 -n1 markdown-link-check -q -v -c .github/markdown-link-check.json
uses: gaurav-nelson/github-action-markdown-link-check@v1
with:
use-quiet-mode: "yes"
use-verbose-mode: "yes"
config-file: ".github/markdown-link-check.json"
- name: Warn on pending images
run: |
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v6
- name: Setup Pages
uses: actions/configure-pages@v6
uses: actions/configure-pages@v5
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
with:
+14 -30
View File
@@ -106,49 +106,33 @@ test-coverage:
check: fmt vet test test-http-client
test-http-client:
@echo "Starting services with docker compose..."
@docker compose -f docker-compose.yml -f docker-compose.ci.yml up -d --build
@echo "Waiting for services to start..."
@sleep 10
@echo "Running .http tests..."
@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" \
-v $(PWD)/tests/integration/http-client:/workdir \
jetbrains/intellij-http-client:2026.1 \
--env-file /workdir/http-client.env.json \
--env ci \
/workdir/spotify_registration.http \
/workdir/create_account.http \
/workdir/register_device.http \
/workdir/spotify_full_flow.http \
/workdir/customer_support.http \
/workdir/power_on.http \
/workdir/get_bmx_services.http \
/workdir/get_sourceproviders.http \
/workdir/get_software_update.http \
/workdir/get_soundtouch_updates.http \
/workdir/get_streaming_token.http \
/workdir/post_oauth_token.http \
/workdir/get_provider_settings.http \
/workdir/tunein_playback_station.http \
/workdir/set_preset_6.http \
/workdir/get_presets.http \
/workdir/delete_preset_6.http \
/workdir/set_preset_5.http \
/workdir/post_recent.http \
/workdir/get_recents.http \
/workdir/get_account_presets.http \
/workdir/get_account_devices.http \
/workdir/get_account_sources.http \
/workdir/get_api_versions.http \
/workdir/post_musicprovider_is_eligible.http \
/workdir/get_full_account.http \
/workdir/get_group.http \
/workdir/unregister_device.http \
--report; \
EXIT_CODE=$$?; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs soundtouch-service; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml logs spotify-mock; \
docker compose -f docker-compose.yml -f docker-compose.ci.yml down; \
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:
+1 -1
View File
@@ -480,7 +480,7 @@ SoundTouch is a trademark of Bose Corporation.
This Go library will continue to work as it uses the local Web API for direct device control, which is unaffected by the cloud service discontinuation. The local preset management functionality implemented in this library (discovered through the [SoundTouch Plus Wiki](https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/wiki/SoundTouch-WebServices-API)) provides an alternative to the cloud-based preset features that will be discontinued.
**Community Alternatives**: See the [Related Projects & Credits](#related-projects--credits) section below for additional tools like SoundCork that provide cloud service alternatives and the SoundTouch Plus project that offers comprehensive Home Assistant integration.
**Community Alternatives**: See the [Related Projects](#related-projects) section below for additional tools like SoundCork that provide cloud service alternatives and the SoundTouch Plus project that offers comprehensive Home Assistant integration.
## Related Projects & Credits
+1
View File
@@ -0,0 +1 @@
test_output/
+305
View File
@@ -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 | ![%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)}
}
+103
View File
@@ -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)
}
}
}
Binary file not shown.
Binary file not shown.

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

-23
View File
@@ -1,23 +0,0 @@
// Package main provides a mock Spotify server for testing purposes.
package main
import (
"flag"
"fmt"
"log"
"net/http"
"github.com/gesellix/bose-soundtouch/pkg/testutils/spotify"
)
func main() {
port := flag.Int("port", 8080, "Port to listen on")
flag.Parse()
log.Printf("Starting mock Spotify server on port %d", *port)
if err := http.ListenAndServe(fmt.Sprintf(":%d", *port), spotify.NewSpotifyHandler()); err != nil {
log.Fatal(err)
}
}
+44 -159
View File
@@ -55,53 +55,6 @@ func updateBuildInfo() {
}
}
func initializeDefaultSources(ds *datastore.DataStore) {
// Ensure default sources exist for all known devices on startup
allDevices, _ := ds.ListAllDevices()
for i := range allDevices {
dev := &allDevices[i]
if sources, errGet := ds.GetConfiguredSources(dev.AccountID, dev.DeviceID); errGet == nil {
log.Printf("Initializing default Sources.xml for existing device %s", dev.DeviceID)
// Find default sources and merge them if missing or outdated tokens
defaults := ds.GetDefaultSources()
modified := false
for i := range defaults {
def := defaults[i]
found := false
for j := range sources {
if sources[j].SourceKeyType == def.SourceKeyType {
found = true
if sources[j].Secret == "" && def.Secret != "" {
log.Printf("Initializing missing token for source %s on device %s", def.SourceKeyType, dev.DeviceID)
sources[j].Secret = def.Secret
sources[j].SecretType = def.SecretType
modified = true
}
break
}
}
if !found {
log.Printf("Adding missing default source %s to device %s", def.SourceKeyType, dev.DeviceID)
sources = append(sources, def)
modified = true
}
}
if modified {
if errSave := ds.SaveConfiguredSources(dev.AccountID, dev.DeviceID, sources); errSave != nil {
log.Printf("Failed to save updated sources for %s: %v", dev.DeviceID, errSave)
}
}
}
}
}
func main() {
updateBuildInfo()
@@ -209,16 +162,6 @@ func main() {
Value: "ueberboese-login://spotify",
EnvVars: []string{"SPOTIFY_REDIRECT_URI"},
},
&cli.StringFlag{
Name: "spotify-token-url",
Usage: "Spotify OAuth token URL (for testing)",
EnvVars: []string{"SPOTIFY_TOKEN_URL"},
},
&cli.StringFlag{
Name: "spotify-api-base",
Usage: "Spotify API base URL (for testing)",
EnvVars: []string{"SPOTIFY_API_BASE"},
},
&cli.StringFlag{
Name: "mgmt-username",
Usage: "Management API username for HTTP Basic Auth",
@@ -315,14 +258,6 @@ func main() {
config.spotifyRedirectURI,
config.dataDir,
)
if config.spotifyTokenURL != "" || config.spotifyAPIBase != "" {
spotifyService.SetEndpoints(config.spotifyTokenURL, config.spotifyAPIBase)
}
if err := spotifyService.Load(); err != nil {
log.Printf("[Spotify] Failed to load accounts: %v", err)
}
server.SetSpotifyService(spotifyService)
clientIDPrefix := config.spotifyClientID
@@ -386,8 +321,6 @@ func main() {
server.SetRecorder(recorder)
initializeDefaultSources(ds)
tlsConfig, err := cm.GetServerTLSConfig(config.domains)
if err != nil {
log.Printf("Warning: Failed to setup TLS: %v", err)
@@ -453,8 +386,6 @@ type serviceConfig struct {
spotifyClientID string
spotifyClientSecret string
spotifyRedirectURI string
spotifyTokenURL string
spotifyAPIBase string
mgmtUsername string
mgmtPassword string
migrationEnabled bool
@@ -519,8 +450,6 @@ func loadConfig(c *cli.Context) serviceConfig {
spotifyClientID := c.String("spotify-client-id")
spotifyClientSecret := c.String("spotify-client-secret")
spotifyRedirectURI := c.String("spotify-redirect-uri")
spotifyTokenURL := c.String("spotify-token-url")
spotifyAPIBase := c.String("spotify-api-base")
mgmtUsername := c.String("mgmt-username")
mgmtPassword := c.String("mgmt-password")
mirrorEnabled := c.Bool("mirror-enabled")
@@ -554,8 +483,6 @@ func loadConfig(c *cli.Context) serviceConfig {
spotifyClientID: spotifyClientID,
spotifyClientSecret: spotifyClientSecret,
spotifyRedirectURI: spotifyRedirectURI,
spotifyTokenURL: spotifyTokenURL,
spotifyAPIBase: spotifyAPIBase,
mgmtUsername: mgmtUsername,
mgmtPassword: mgmtPassword,
migrationEnabled: migrationEnabled,
@@ -734,109 +661,72 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Route("/bmx", func(r chi.Router) {
r.Get("/registry/v1/services", server.HandleBMXRegistry)
r.Get("/registry/v1/servicesAvailability", server.HandleBMXServicesAvailability)
r.Route("/tunein", func(r chi.Router) {
r.Get("/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
r.Get("/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/v1/token", server.HandleTuneInToken)
r.Post("/v1/report", server.HandleTuneInReport)
r.Get("/v1/navigate", server.HandleTuneInNavigate)
r.Get("/v1/navigate/*", server.HandleTuneInNavigate)
r.Get("/v1/search", server.HandleTuneInSearch)
})
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
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)
})
// Legacy or direct domain calls without /bmx prefix
r.Get("/registry/v1/services", server.HandleBMXRegistry)
r.Get("/tunein/v1/playback/station/{stationID}", server.HandleTuneInPlayback)
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)
r.Route("/streaming", func(r chi.Router) {
streamingRoutes := func(r chi.Router) {
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
r.Post("/account", server.HandleMargeCreateAccount)
r.Post("/account/login", server.HandleMargeLogin)
r.Post("/account/{account}/source", server.HandleMargeAddSource)
r.Route("/account/{account}", func(r chi.Router) {
r.Get("/emailaddress", server.HandleMargeGetEmailAddress)
r.Get("/full", server.HandleMargeAccountFull)
r.Get("/sources", server.HandleMargeAccountSources)
r.Get("/devices", server.HandleMargeAccountDevices)
r.Get("/presets", server.HandleMargeAccountPresets)
r.Get("/presets/all", server.HandleMargeAccountPresets)
r.Get("/provider_settings", server.HandleMargeProviderSettings)
r.Route("/device", func(r chi.Router) {
r.Post("/", server.HandleMargeAddDevice)
r.Post("/{device}", server.HandleMargeAddDevice)
})
r.Route("/device/{device}", func(r chi.Router) {
r.Get("/presets", server.HandleMargePresets)
r.Post("/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Put("/preset/{presetNumber}", server.HandleMargeUpdatePreset)
r.Delete("/preset/{presetNumber}", server.HandleMargeRemovePreset)
r.Get("/recent", server.HandleMargeRecents)
r.Get("/recents", server.HandleMargeRecents)
r.Post("/recent", server.HandleMargeAddRecent)
r.Get("/group", server.HandleMargeDeviceGroup)
r.Get("/group/", server.HandleMargeDeviceGroup)
r.Get("/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/group/member", server.HandleMargeDeviceGroupMember)
})
r.Delete("/device/{device}", server.HandleMargeRemoveDevice)
})
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)
r.Post("/account/{account}/device/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Post("/support/power_on", server.HandleMargePowerOn)
r.Get("/account/{account}/provider_settings", server.HandleMargeProviderSettings)
r.Get("/device/{device}/streaming_token", server.HandleMargeStreamingToken)
r.Post("/support/customersupport", server.HandleMargeCustomerSupport)
r.Get("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeGetDeviceSettings)
r.Get("/account/{account}/device/{device}/group", server.HandleMargeDeviceGroup)
r.Get("/account/{account}/device/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/account/{account}/device/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/account/{account}/device/{device}/group/member", server.HandleMargeDeviceGroupMember)
r.Post("/device_setting/account/{account}/device/{device}/device_settings", server.HandleMargeUpdateDeviceSettings)
r.Get("/account/{account}/emailaddress", server.HandleMargeGetEmailAddress)
r.Get("/account/{account}/full", server.HandleMargeAccountFull)
r.Get("/software/update/account/{account}", server.HandleMargeSoftwareUpdate)
r.Route("/support", func(r chi.Router) {
r.Post("/power_on", server.HandleMargePowerOn)
r.Post("/customersupport", server.HandleMargeCustomerSupport)
})
r.Route("/stats", func(r chi.Router) {
r.Post("/usage", server.HandleUsageStats)
r.Post("/error", server.HandleErrorStats)
})
}
r.Route("/music", func(r chi.Router) {
r.Route("/musicprovider/{providerID}", func(r chi.Router) {
r.Post("/is_eligible", server.HandleMusicProviderIsEligible)
})
})
accountsRoutes := func(r chi.Router) {
r.Get("/{account}/full", server.HandleMargeAccountFull)
r.Get("/{account}/devices/{device}/presets", server.HandleMargePresets)
r.Post("/{account}/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Get("/{account}/devices/{device}/recents", server.HandleMargeRecents)
r.Post("/{account}/devices/{device}/recents", server.HandleMargeAddRecent)
r.Post("/{account}/devices", server.HandleMargeAddDevice)
r.Delete("/{account}/devices/{device}", server.HandleMargeRemoveDevice)
r.Get("/{account}/devices/{device}/group", server.HandleMargeDeviceGroup)
r.Get("/{account}/devices/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/{account}/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/{account}/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
}
r.Get("/resources/api_versions.xml", server.HandleMargeAPIVersions)
})
r.Route("/marge", func(r chi.Router) {
r.Route("/streaming", streamingRoutes)
r.Route("/accounts", accountsRoutes)
r.Route("/accounts", func(r chi.Router) {
r.Route("/{account}", func(r chi.Router) {
r.Get("/full", server.HandleMargeAccountFull)
r.Get("/sources", server.HandleMargeAccountSources)
r.Get("/devices", server.HandleMargeAccountDevices)
r.Post("/devices", server.HandleMargeAddDevice)
r.Delete("/devices/{device}", server.HandleMargeRemoveDevice)
r.Get("/devices/{device}/group", server.HandleMargeDeviceGroup)
r.Get("/devices/{device}/group/", server.HandleMargeDeviceGroup)
r.Get("/devices/{device}/group/server", server.HandleMargeDeviceGroupServer)
r.Get("/devices/{device}/group/member", server.HandleMargeDeviceGroupMember)
r.Get("/devices/{device}/presets", server.HandleMargePresets)
r.Get("/devices/{device}/recents", server.HandleMargeRecents)
r.Post("/devices/{device}/presets/{presetNumber}", server.HandleMargeUpdatePreset)
r.Post("/devices/{device}/recents", server.HandleMargeAddRecent)
})
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
})
// Legacy or direct domain calls without /marge prefix
r.Route("/streaming", streamingRoutes)
r.Route("/accounts", accountsRoutes)
r.Get("/updates/soundtouch", server.HandleMargeSoftwareUpdate)
r.Route("/customer", func(r chi.Router) {
@@ -848,17 +738,12 @@ 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.Post("/account/{account}/music/musicprovider/{sourceID}/token/cs", server.HandleBoseAccountToken)
r.HandleFunc("/*", server.HandleBoseProxy)
})
r.Route("/v1", func(r chi.Router) {
r.Post("/stapp/{deviceId}", server.HandleAppEvents)
r.Post("/scmudc/{deviceId}", server.HandleAppEvents)
// Return 405 Method Not Allowed as the upstream behavior also returns 405
r.Get("/blacklist/{deviceId}", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusMethodNotAllowed)
})
})
r.Route("/mgmt", func(r chi.Router) {
-104
View File
@@ -1,104 +0,0 @@
package main
import (
"fmt"
"net/http"
"os"
"reflect"
"runtime"
"sort"
"strings"
"testing"
"github.com/gesellix/bose-soundtouch/pkg/service/handlers"
"github.com/go-chi/chi/v5"
)
func TestPrintRoutes(t *testing.T) {
// Initialize a minimal server to get the router
server := handlers.NewServer(nil, nil, "http://localhost:8000", true, true, true)
r := setupRouter(server)
var routes []string
walkFunc := func(method string, route string, handler http.Handler, middlewares ...func(http.Handler) http.Handler) error {
route = strings.ReplaceAll(route, "/*/", "/")
handlerName := runtime.FuncForPC(reflect.ValueOf(handler).Pointer()).Name()
// Clean up the handler name (remove package path)
// For example, "github.com/gesellix/bose-soundtouch/cmd/soundtouch-service.setupRouter.func1"
// or "command-line-arguments.setupRouter.func1"
// or "main.setupRouter.func1"
parts := strings.Split(handlerName, "/")
if len(parts) > 0 {
handlerName = parts[len(parts)-1]
}
// Now we might have "soundtouch-service.setupRouter.func1"
// or "command-line-arguments.setupRouter.func1"
// or "main.setupRouter.func1"
// Let's remove the first part if it's a known varying package name
if idx := strings.Index(handlerName, "setupRouter"); idx != -1 {
handlerName = handlerName[idx:]
}
// In case it's not setupRouter but still has a package prefix
for {
dotIdx := strings.Index(handlerName, ".")
if dotIdx == -1 {
break
}
prefix := handlerName[:dotIdx]
if prefix == "main" || prefix == "command-line-arguments" || strings.Contains(prefix, "soundtouch-service") {
handlerName = handlerName[dotIdx+1:]
} else {
break
}
}
// Also remove any ".funcN" suffix if it's an anonymous function
if idx := strings.Index(handlerName, ".func"); idx != -1 {
handlerName = handlerName[:idx]
}
routes = append(routes, fmt.Sprintf("%-8s %-60s %s", method, route, handlerName))
return nil
}
if err := chi.Walk(r, walkFunc); err != nil {
t.Fatalf("Failed to walk routes: %v", err)
}
sort.Strings(routes)
output := strings.Join(routes, "\n") + "\n"
// Define snapshot path
snapshotPath := "testdata/router_routes.txt"
actualPath := "testdata/router_routes.actual.txt"
// Always write the current (actual) routes to a file
if err := os.WriteFile(actualPath, []byte(output), 0644); err != nil {
t.Fatalf("Failed to write actual routes: %v", err)
}
// Check if snapshot exists
if _, err := os.Stat(snapshotPath); os.IsNotExist(err) {
// Create testdata directory if it doesn't exist
if err := os.MkdirAll("testdata", 0755); err != nil {
t.Fatalf("Failed to create testdata directory: %v", err)
}
// Initial snapshot creation
if err := os.WriteFile(snapshotPath, []byte(output), 0644); err != nil {
t.Fatalf("Failed to write snapshot: %v", err)
}
t.Logf("Initial snapshot created at %s", snapshotPath)
return
}
// Read existing snapshot
existingOutput, err := os.ReadFile(snapshotPath)
if err != nil {
t.Fatalf("Failed to read snapshot: %v", err)
}
if string(existingOutput) != output {
t.Errorf("Router routes changed! Diff the snapshot at %s with %s", snapshotPath, actualPath)
}
}
@@ -1 +0,0 @@
*.actual.txt
-134
View File
@@ -1,134 +0,0 @@
CONNECT /oauth/* handlers.(*Server).HandleBoseProxy-fm
DELETE /accounts/{account}/devices/{device} handlers.(*Server).HandleMargeRemoveDevice-fm
DELETE /oauth/* handlers.(*Server).HandleBoseProxy-fm
DELETE /setup/devices/{deviceId} handlers.(*Server).HandleRemoveDevice-fm
DELETE /setup/dns-discoveries handlers.(*Server).HandleClearDNSDiscoveries-fm
DELETE /setup/interactions/sessions handlers.(*Server).HandleCleanupSessions-fm
DELETE /setup/interactions/sessions/{session} handlers.(*Server).HandleDeleteSession-fm
DELETE /setup/parity-mismatches handlers.(*Server).HandleClearParityMismatches-fm
DELETE /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeRemovePreset-fm
GET / handlers.(*Server).HandleRoot-fm
GET /accounts/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
GET /accounts/{account}/devices/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
GET /accounts/{account}/devices/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
GET /accounts/{account}/devices/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
GET /accounts/{account}/devices/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
GET /accounts/{account}/devices/{device}/presets handlers.(*Server).HandleMargePresets-fm
GET /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeRecents-fm
GET /accounts/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
GET /accounts/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
GET /bmx/registry/v1/services handlers.(*Server).HandleBMXRegistry-fm
GET /bmx/registry/v1/servicesAvailability handlers.(*Server).HandleBMXServicesAvailability-fm
GET /bmx/tunein/v1/navigate handlers.(*Server).HandleTuneInNavigate-fm
GET /bmx/tunein/v1/navigate/* handlers.(*Server).HandleTuneInNavigate-fm
GET /bmx/tunein/v1/playback/episode/{podcastID} handlers.(*Server).HandleTuneInPlaybackPodcast-fm
GET /bmx/tunein/v1/playback/episodes/{podcastID} handlers.(*Server).HandleTuneInPodcastInfo-fm
GET /bmx/tunein/v1/playback/station/{stationID} handlers.(*Server).HandleTuneInPlayback-fm
GET /bmx/tunein/v1/search handlers.(*Server).HandleTuneInSearch-fm
GET /custom/v1/playback/{encodedURL} handlers.(*Server).HandleCustomPlayback-fm
GET /customer/account/{account} handlers.(*Server).HandleMargeAccountProfile-fm
GET /docs/* handlers.(*Server).HandleDocs-fm
GET /favicon.ico setupRouter
GET /health handlers.(*Server).HandleHealth-fm
GET /media/* handlers.(*Server).HandleMedia
GET /mgmt/accounts/ handlers.(*Server).HandleMgmtListAccounts-fm
GET /mgmt/accounts/{accountId} handlers.(*Server).HandleMgmtAccountDetails-fm
GET /mgmt/accounts/{accountId}/speakers handlers.(*Server).HandleMgmtListSpeakers-fm
GET /mgmt/devices/{deviceId}/events handlers.(*Server).HandleMgmtDeviceEvents-fm
GET /mgmt/spotify/accounts handlers.(*Server).HandleMgmtSpotifyAccounts-fm
GET /mgmt/spotify/callback handlers.(*Server).HandleMgmtSpotifyCallback-fm
GET /mgmt/spotify/token handlers.(*Server).HandleMgmtSpotifyToken-fm
GET /oauth/* handlers.(*Server).HandleBoseProxy-fm
GET /proxy/* handlers.(*Server).HandleProxyRequest-fm
GET /setup/ca.crt handlers.(*Server).HandleGetCACert-fm
GET /setup/devices handlers.(*Server).HandleListDiscoveredDevices-fm
GET /setup/devices/{deviceId}/events handlers.(*Server).HandleGetDeviceEvents-fm
GET /setup/discovery-status handlers.(*Server).HandleGetDiscoveryStatus-fm
GET /setup/dns-discoveries handlers.(*Server).HandleGetDNSDiscoveries-fm
GET /setup/dns-discoveries/download handlers.(*Server).HandleDownloadDNSDiscoveries-fm
GET /setup/info/{deviceId} handlers.(*Server).HandleGetDeviceInfo-fm
GET /setup/interaction-content handlers.(*Server).HandleGetInteractionContent-fm
GET /setup/interaction-stats handlers.(*Server).HandleGetInteractionStats-fm
GET /setup/interactions handlers.(*Server).HandleListInteractions-fm
GET /setup/interactions/sessions/{session}/download handlers.(*Server).HandleDownloadSession-fm
GET /setup/parity-mismatches handlers.(*Server).HandleListParityMismatches-fm
GET /setup/proxy-settings handlers.(*Server).HandleGetProxySettings-fm
GET /setup/settings handlers.(*Server).HandleGetSettings-fm
GET /setup/summary/{deviceId} handlers.(*Server).HandleGetMigrationSummary-fm
GET /setup/version handlers.(*Server).HandleGetVersionInfo-fm
GET /streaming/account/{account}/device/{device}/group handlers.(*Server).HandleMargeDeviceGroup-fm
GET /streaming/account/{account}/device/{device}/group/ handlers.(*Server).HandleMargeDeviceGroup-fm
GET /streaming/account/{account}/device/{device}/group/member handlers.(*Server).HandleMargeDeviceGroupMember-fm
GET /streaming/account/{account}/device/{device}/group/server handlers.(*Server).HandleMargeDeviceGroupServer-fm
GET /streaming/account/{account}/device/{device}/presets handlers.(*Server).HandleMargePresets-fm
GET /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeRecents-fm
GET /streaming/account/{account}/device/{device}/recents handlers.(*Server).HandleMargeRecents-fm
GET /streaming/account/{account}/devices handlers.(*Server).HandleMargeAccountDevices-fm
GET /streaming/account/{account}/emailaddress handlers.(*Server).HandleMargeGetEmailAddress-fm
GET /streaming/account/{account}/full handlers.(*Server).HandleMargeAccountFull-fm
GET /streaming/account/{account}/presets handlers.(*Server).HandleMargeAccountPresets-fm
GET /streaming/account/{account}/presets/all handlers.(*Server).HandleMargeAccountPresets-fm
GET /streaming/account/{account}/provider_settings handlers.(*Server).HandleMargeProviderSettings-fm
GET /streaming/account/{account}/sources handlers.(*Server).HandleMargeAccountSources-fm
GET /streaming/device/{device}/streaming_token handlers.(*Server).HandleMargeStreamingToken-fm
GET /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeGetDeviceSettings-fm
GET /streaming/resources/api_versions.xml handlers.(*Server).HandleMargeAPIVersions-fm
GET /streaming/software/update/account/{account} handlers.(*Server).HandleMargeSoftwareUpdate-fm
GET /streaming/sourceproviders handlers.(*Server).HandleMargeSourceProviders-fm
GET /updates/soundtouch handlers.(*Server).HandleMargeSoftwareUpdate-fm
GET /v1/blacklist/{deviceId} setupRouter
GET /web/* setupRouter.(*Server).HandleWeb
HEAD /oauth/* handlers.(*Server).HandleBoseProxy-fm
OPTIONS /oauth/* handlers.(*Server).HandleBoseProxy-fm
PATCH /oauth/* handlers.(*Server).HandleBoseProxy-fm
POST /accounts/{account}/devices handlers.(*Server).HandleMargeAddDevice-fm
POST /accounts/{account}/devices/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /accounts/{account}/devices/{device}/recents handlers.(*Server).HandleMargeAddRecent-fm
POST /bmx/orion/v1/playback/station/{data} handlers.(*Server).HandleOrionPlayback-fm
POST /bmx/tunein/v1/report handlers.(*Server).HandleTuneInReport-fm
POST /bmx/tunein/v1/token handlers.(*Server).HandleTuneInToken-fm
POST /customer/account/{account} handlers.(*Server).HandleMargeUpdateAccountProfile-fm
POST /customer/account/{account}/password handlers.(*Server).HandleMargeChangePassword-fm
POST /mgmt/accounts/{accountId}/language handlers.(*Server).HandleMgmtUpdateAccountLanguage-fm
POST /mgmt/accounts/{accountId}/provider-settings handlers.(*Server).HandleMgmtUpdateAccountProviderSetting-fm
POST /mgmt/spotify/confirm handlers.(*Server).HandleMgmtSpotifyConfirm-fm
POST /mgmt/spotify/entity handlers.(*Server).HandleMgmtSpotifyEntity-fm
POST /mgmt/spotify/init handlers.(*Server).HandleMgmtSpotifyInit-fm
POST /mgmt/spotify/prime handlers.(*Server).HandleMgmtPrimeDevice-fm
POST /oauth/* handlers.(*Server).HandleBoseProxy-fm
POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs handlers.(*Server).HandleBoseAccountToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token handlers.(*Server).HandleBoseLegacyToken-fm
POST /oauth/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3 handlers.(*Server).HandleBoseToken-fm
POST /setup/backup/{deviceId} handlers.(*Server).HandleBackupConfig-fm
POST /setup/devices handlers.(*Server).HandleAddManualDevice-fm
POST /setup/discover handlers.(*Server).HandleTriggerDiscovery-fm
POST /setup/ensure-remote-services/{deviceId} handlers.(*Server).HandleEnsureRemoteServices-fm
POST /setup/migrate/{deviceId} handlers.(*Server).HandleMigrateDevice-fm
POST /setup/proxy-settings handlers.(*Server).HandleUpdateProxySettings-fm
POST /setup/reboot/{deviceId} handlers.(*Server).HandleRebootDevice-fm
POST /setup/remove-remote-services/{deviceId} handlers.(*Server).HandleRemoveRemoteServices-fm
POST /setup/revert/{deviceId} handlers.(*Server).HandleRevertMigration-fm
POST /setup/settings handlers.(*Server).HandleUpdateSettings-fm
POST /setup/sync/{deviceId} handlers.(*Server).HandleInitialSync-fm
POST /setup/test-connection/{deviceId} handlers.(*Server).HandleTestConnection-fm
POST /setup/test-dns/{deviceId} handlers.(*Server).HandleTestDNSRedirection-fm
POST /setup/test-hosts/{deviceId} handlers.(*Server).HandleTestHostsRedirection-fm
POST /setup/trust-ca/{deviceId} handlers.(*Server).HandleTrustCACert-fm
POST /streaming/account handlers.(*Server).HandleMargeCreateAccount-fm
POST /streaming/account/login handlers.(*Server).HandleMargeLogin-fm
POST /streaming/account/{account}/device/ handlers.(*Server).HandleMargeAddDevice-fm
POST /streaming/account/{account}/device/{device} handlers.(*Server).HandleMargeAddDevice-fm
POST /streaming/account/{account}/device/{device}/presets/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
POST /streaming/account/{account}/device/{device}/recent handlers.(*Server).HandleMargeAddRecent-fm
POST /streaming/account/{account}/source handlers.(*Server).HandleMargeAddSource-fm
POST /streaming/device_setting/account/{account}/device/{device}/device_settings handlers.(*Server).HandleMargeUpdateDeviceSettings-fm
POST /streaming/music/musicprovider/{providerID}/is_eligible handlers.(*Server).HandleMusicProviderIsEligible-fm
POST /streaming/stats/error handlers.(*Server).HandleErrorStats-fm
POST /streaming/stats/usage handlers.(*Server).HandleUsageStats-fm
POST /streaming/support/customersupport handlers.(*Server).HandleMargeCustomerSupport-fm
POST /streaming/support/power_on handlers.(*Server).HandleMargePowerOn-fm
POST /v1/scmudc/{deviceId} handlers.(*Server).HandleAppEvents-fm
POST /v1/stapp/{deviceId} handlers.(*Server).HandleAppEvents-fm
PUT /oauth/* handlers.(*Server).HandleBoseProxy-fm
PUT /streaming/account/{account}/device/{device}/preset/{presetNumber} handlers.(*Server).HandleMargeUpdatePreset-fm
TRACE /oauth/* handlers.(*Server).HandleBoseProxy-fm
-10
View File
@@ -1,10 +0,0 @@
services:
soundtouch-service:
build: .
volumes:
- ./tests/integration/testdata:/app/data
environment:
- SPOTIFY_CLIENT_ID=mock-id
- SPOTIFY_CLIENT_SECRET=mock-secret
- SPOTIFY_TOKEN_URL=http://spotify-mock:8080/api/token
- SPOTIFY_API_BASE=http://spotify-mock:8080
-18
View File
@@ -8,8 +8,6 @@ services:
ports:
- "8000:8000"
- "8443:8443"
networks:
- soundtouch-test-net
environment:
- PORT=8000
- HTTPS_PORT=8443
@@ -37,22 +35,6 @@ services:
cpus: '0.25'
memory: 128M
spotify-mock:
image: golang:1.26.1-alpine
container_name: spotify-mock
working_dir: /app
volumes:
- .:/app
command: go run ./cmd/mock-spotify/main.go -port 8080
ports:
- "8081:8080"
networks:
- soundtouch-test-net
networks:
soundtouch-test-net:
name: soundtouch-test-net
volumes:
soundtouch-data:
# Named volumes are preferred in Swarm. For multi-node persistence,
-114
View File
@@ -1,114 +0,0 @@
# Bose SoundTouch Device Setup Flow
This document details the multi-step process required to fully set up a Bose SoundTouch device, as derived from the Stockholm firmware (`setup/js/`) analysis.
A complete setup flow involves a sequence of local (WebSocket) and cloud (HTTP) actions that move the device from a factory-reset state to a fully registered, functional system.
## 1. Local Coordination Stage (WebSocket)
Before a device can be controlled, it must be configured on the local network and named. These actions occur via a WebSocket connection to the device on port 8080.
### 1.1 Language Configuration (Optional)
If the device is in a factory-reset state, the UI typically ensures the device language matches the user's choice.
- **WebSocket Action**: `set_language`
- **Internal Logic**: `SetupWizard.js` handles this via `set_device_language`.
### 1.2 Network Configuration (WiFi)
Configures the device to connect to a specific wireless access point.
- **File Reference**: `setup/js/workflow_wifi_setup.js`
- **Logic**: Triggers a site survey, then sends SSID and credentials.
- **WebSocket Command**: `set_WIFI_OLED` or similar internal method calls to configure the network profile.
### 1.3 Device Naming (Rename Step)
Assigns a user-friendly name (e.g., "Living Room") to the device.
- **File Reference**: `setup/js/workflow_rename.js`
- **WebSocket Action**: `name`
- **XML Payload**:
```xml
<name>Living Room</name>
```
- **Implementation**: The `RenameDevices.do_rename_devices()` function sends this to the device. The device then updates its local name and mDNS/SSDP broadcasts.
## 2. Cloud Interaction Stage (HTTP)
The device needs to be linked to a Bose "Marge" account to enable cloud-based features and music services.
### 2.1 Account Creation (Registration)
If a user doesn't have an account, the setup client creates one.
- **File Reference**: `setup/js/workflow_marge.js`
- **Cloud Endpoint**: `POST https://streaming.bose.com/streaming/account`
- **Payload**: XML containing name, email, password, and country.
- **Content-Type**: `application/vnd.bose.customer-v1.0+xml`
### 2.2 Cloud Authentication (Login)
The setup client must obtain a valid `accountId` and `userAuthToken` to pair the device.
- **File Reference**: `setup/js/workflow_marge.js`
- **Cloud Endpoint**: `POST https://streaming.bose.com/streaming/account/login`
- **Payload**: XML containing username and password.
- **Content-Type**: `application/vnd.bose.streaming-v1.2+xml`
- **Result**: Returns a session token in the `Credentials` response header and the user's `account ID` in the XML body.
## 3. Registration Bridge (WebSocket to Cloud)
This is the final "pairing" step where the client tells the device which account it belongs to.
### 3.1 Device Registration (The "Pair" Step)
The client sends the user's credentials to the device, which then registers itself with the cloud.
- **File Reference**: `setup/js/workflow_add_devices.js`
- **WebSocket Action**: `setMargeAccount`
- **XML Payload**:
```xml
<PairDeviceWithAccount>
<accountId>12345</accountId>
<userAuthToken>jGwE... (truncated)</userAuthToken>
</PairDeviceWithAccount>
```
- **Device Reaction**: Upon receiving this, the device makes its own outbound HTTP POST to the Marge service:
`POST https://streaming.bose.com/{accountId}/devices`
## 4. Finalization
Once the registration is complete, the setup application (Stockholm) performs final cleanup. It's important to distinguish between **App State** (the Stockholm UI's persistent settings) and **Device State** (the physical speaker's configuration).
### 4.1 Exiting Setup Mode (App Settings)
The Stockholm app communicates with its "native container" (the WebView bridge on iOS/Android/Windows/macOS) using a `setData` command in **JSON format**. This is an internal message to the application's persistent storage, **not a network command sent to the physical speaker**.
This command tells the Stockholm app which page to load on startup, effectively marking the setup as complete in the UI.
- **Internal Command**: `setData`
- **Parameter**: `startupPage`
- **Normal Value**: `index.html` (Normal mode)
- **Setup Value**: `setup/index.html` (Setup mode)
**JSON Payload (Internal to Stockholm App)**:
```json
{
"method": "setData",
"params": {
"name": "startupPage",
"value": "index.html"
}
}
```
**Other Common Internal Parameters**:
- `changeStartupPage`: Set to `false` after a successful setup or update.
- `tipsEnabled`: Set to `false` to suppress the "Getting Started" tutorials.
- `promptUpdate`: Set to `true` if a firmware update was deferred during setup.
### 4.2 Device Finalization
The physical speaker considers the setup "done" once it successfully processes the `<PairDeviceWithAccount>` XML message and completes its own handshake with the Marge cloud. There is no specific "Finalize" XML command sent to the speaker; the successful registration is the signal.
The `SetupWizard.js` calls `single_device_setup_done()` to trigger the internal `setData` updates described above. If these are not saved in the app's local storage, the Stockholm UI may return to the setup flow on next launch, even if the speaker is already paired.
---
## Summary of Scriptable Requirements
To automate a device setup using a custom tool (like `soundtouch-cli`), you must perform the following:
1. **Configure WiFi**: (Assumed if device is reachable over IP).
2. **Set Name**: Send the `<name>` WebSocket message (XML) to update the device identity.
3. **Obtain Token**: Authenticate against the cloud service (Marge) via HTTP.
4. **Pair Device**: Send the `<PairDeviceWithAccount>` WebSocket message (XML) with the account ID and token.
**Note**: The JSON `setData` commands are only necessary if you are building/controlling a version of the Stockholm UI itself. They are not required to configure the physical hardware.
-66
View File
@@ -1,66 +0,0 @@
# Technical Proposal: External Service Provider Abstraction
This document outlines a strategy to refactor the SoundTouch Service's content handling into a modular provider-based system.
## 1. Problem Statement
Currently, content handling for BMX (Bose Media Exchange) services like TuneIn or RadioBrowser is deeply intertwined with the HTTP handlers and XML models. Adding a new content provider (e.g., Local Media, Podcast RSS) requires modifying several files and duplicating boilerplate code for HTTP requests and error handling.
## 2. Proposed Architecture
### 2.1 The Provider Interface
We define a generic `ContentProvider` interface that abstracts away the source-specific logic (API calls, data parsing).
```go
package provider
import "github.com/gesellix/bose-soundtouch/pkg/models"
type ContentProvider interface {
// ID returns the unique identifier for this provider (e.g. "RADIO_BROWSER")
ID() string
// Resolve returns playback details for a given content identifier
Resolve(id string) (*models.BmxPlaybackResponse, error)
// Search allows finding content within this provider
Search(query string) ([]models.ContentItem, error)
}
```
### 2.2 Provider Registry
A central registry in `soundtouch-service` manages the lifecycle and selection of providers.
```go
type Registry struct {
providers map[string]ContentProvider
}
func (r *Registry) Register(p ContentProvider) { ... }
func (r *Registry) Get(id string) ContentProvider { ... }
```
## 3. Implementation Plan
### 3.1 Phase 1: Modularize RadioBrowser
1. **Extract Logic**: Move current RadioBrowser logic from `bmx.go` into a new package `pkg/service/providers/radiobrowser`.
2. **Add Failover**: Implement the **API Failover** logic inspired by OpenCloudTouch.
- Maintain a list of active RadioBrowser mirrors (e.g., `de1.api.radio-browser.info`, `nl1.api.radio-browser.info`).
- Implement a round-robin or health-based selection strategy.
3. **Implements Interface**: Ensure the new package satisfies the `ContentProvider` interface.
### 3.2 Phase 2: Refactor BMX Handlers
- Update `HandleTuneInPlayback` and `HandleOrionPlayback` to use the registry.
- The handlers will look up the provider based on the request context or URL parameters and delegate the resolution.
### 3.3 Phase 3: Dynamic Service Advertising
- Modify `HandleBMXRegistry` to dynamically generate the `bmx_services.json` content based on the currently registered and enabled providers.
## 4. Benefits
- **Resilience**: Centralized error handling and failover strategies for all external APIs.
- **Extensibility**: New services can be added by simply implementing the interface and registering them at startup.
- **Testability**: Providers can be unit-tested in isolation without mocking the entire HTTP server stack.
- **Unified UI**: A future Web UI can query the registry to show available content sources and their statuses.
## 5. Next Steps
1. Refine the `ContentProvider` interface to include metadata (icons, user-friendly names).
2. Create a prototype for the `radiobrowser` provider with failover support.
-45
View File
@@ -1,45 +0,0 @@
# Parity Analysis: Bose-SoundTouch (Go) vs. OpenCloudTouch (Python)
This document provides a comparative analysis of the current Go implementation and the `scheilch/opencloudtouch` project, identifying functional gaps and potential improvements.
## 1. Core Architecture and Language
- **Bose-SoundTouch (Go)**: A high-performance, strongly typed backend with a CLI and background service. Focuses on full API coverage, parity testing, and robust hardware control (DSP, zones).
- **OpenCloudTouch (OCT)**: A modern full-stack application (FastAPI + React/TypeScript). Prioritizes user experience with a web-based setup wizard and a clean abstraction for internet radio.
## 2. Functional Comparison
| Feature | Bose-SoundTouch (Go) | OpenCloudTouch (Python) |
|:------------------------|:-----------------------------------------------------------|:------------------------------------------------------------------------|
| **Setup Experience** | CLI-driven or manual API calls for migration (SSH, XML). | Web-based **Setup Wizard** guides through SSH, backup, and redirection. |
| **Radio Support** | Static integration of **RadioBrowser** and TuneIn. | Dynamic **RadioBrowserAdapter** with automatic **API Failover**. |
| **Commercial Services** | Deep integration (Spotify priming, Pandora, Deezer, etc.). | Basic support, focus is on local content and radio. |
| **Hardware Control** | Extensive (Bass, Treble, Soundbar levels, Clock display). | Basic playback and zone controls. |
| **Cloud Emulation** | High-fidelity parity (mirroring, discrepancy logging). | Functional emulation for local preset/recent persistence. |
| **Notifications** | Built-in **TTS** and custom URL audio alerts. | Not a primary focus. |
## 3. Key Strengths of OpenCloudTouch
- **Guided Onboarding**: The setup wizard reduces the entry barrier for non-technical users significantly.
- **Resilient Radio**: The API failover for RadioBrowser ensures continuous service even if specific community-hosted API instances go offline.
- **Modern API Stack**: Uses OpenAPI and generated TypeScript types for a seamless frontend integration.
- **Provider Abstraction**: A cleaner internal separation between the "Bose World" (XML/BMX) and external content providers (RadioBrowser).
## 4. Suggested Improvements for Bose-SoundTouch
### A. Web-based Setup Wizard (High Priority)
- Implement a state-driven wizard in the `soundtouch-service` to handle:
- SSH activation (checking `/remote_services` via USB).
- Automated backup of speaker configuration.
- Verification of DNS/Hosts redirection.
- Expose this via a simple embedded Web UI (using Go's `embed` package).
### B. RadioBrowser Failover (Medium Priority)
- Adapt the failover logic from OCT:
- Periodically refresh the list of available RadioBrowser API servers.
- Implement a retry mechanism that switches servers on 5xx errors or timeouts.
### C. External Service Abstraction (Medium Priority)
- Refactor the hardcoded BMX logic into a more modular **Provider System** (see `EXTERNAL-SERVICES-ABSTRACTION.md`).
- This will allow easier addition of new sources (e.g., local DLNA, generic M3U playlists) without touching the core BMX handlers.
## 5. Summary
While our Go project provides the most complete technical coverage of SoundTouch hardware and commercial services, OpenCloudTouch sets a higher standard for **user onboarding** and **service resilience** for community-driven content. Integrating a setup wizard and a more robust radio backend would make our project significantly more accessible and reliable.
+44 -44
View File
@@ -21,7 +21,7 @@ This document describes the most important patterns for the Bose SoundTouch API
**Key Aspects:**
- **Native Builds**: Full API functionality for CLI and server
- **WASM Builds**: Browser-compatible subset functionality
- **WASM Builds**: Browser-compatible subset functionality
- **Cross-Platform**: Linux, macOS, Windows support
- **Embedded Assets**: Web UI directly embedded in binary
@@ -66,7 +66,7 @@ func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
return nil, err
}
defer resp.Body.Close()
var nowPlaying models.NowPlaying
err = xml.NewDecoder(resp.Body).Decode(&nowPlaying)
return &nowPlaying, err
@@ -77,7 +77,7 @@ func (c *Client) GetNowPlaying() (*models.NowPlaying, error) {
```go
func (c *Client) SendKey(key models.Key) error {
keyXML := fmt.Sprintf(`<key state="press" sender="GoClient">%s</key>`, key)
resp, err := c.httpClient.Post(
c.baseURL+"/key",
"application/xml",
@@ -117,14 +117,14 @@ func (d *DiscoveryService) DiscoverDevices() ([]Device, error) {
return nil, err
}
defer conn.Close()
// Send M-SEARCH request
searchRequest := "M-SEARCH * HTTP/1.1\r\n" +
"HOST: 239.255.255.250:1900\r\n" +
"MAN: \"ssdp:discover\"\r\n" +
"ST: urn:schemas-upnp-org:device:MediaRenderer:1\r\n" +
"MX: 3\r\n\r\n"
// Implementation details...
return devices, nil
}
@@ -158,13 +158,13 @@ func (e *EventClient) Subscribe(eventType string, handler EventHandler) {
func (e *EventClient) Start() error {
u := url.URL{Scheme: "ws", Host: e.client.host + ":8090", Path: "/"}
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
return err
}
e.conn = conn
go e.eventLoop()
return nil
}
@@ -184,7 +184,7 @@ func (e *EventClient) eventLoop() {
}
return
}
if handler, exists := e.handlers[event.Type]; exists {
go handler(event)
}
@@ -220,7 +220,7 @@ func wasmDiscoverDevices(this js.Value, args []js.Value) interface{} {
handler := js.FuncOf(func(this js.Value, args []js.Value) interface{} {
go func() {
devices, err := discovery.NewDiscoveryService(5*time.Second).DiscoverDevices()
result := make(map[string]interface{})
if err != nil {
result["error"] = err.Error()
@@ -228,13 +228,13 @@ func wasmDiscoverDevices(this js.Value, args []js.Value) interface{} {
devicesJSON, _ := json.Marshal(devices)
result["devices"] = string(devicesJSON)
}
// Call JavaScript callback
args[0].Invoke(js.ValueOf(result))
}()
return nil
})
return handler
}
```
@@ -280,7 +280,7 @@ func main() {
if err != nil {
return err
}
for i, device := range devices {
fmt.Printf("%d: %s (%s)\n", i+1, device.Name, device.Host)
}
@@ -300,7 +300,7 @@ func main() {
},
},
}
app.Run(os.Args)
}
@@ -311,7 +311,7 @@ func getClientFromContext(c *cli.Context) *client.Client {
devices, _ := discovery.DiscoverDevices()
deviceHost = selectDeviceInteractive(devices)
}
return client.NewClient(deviceHost, 8090)
}
```
@@ -327,34 +327,34 @@ var webAssets embed.FS
func main() {
mux := http.NewServeMux()
// Embedded web assets
webFS, err := fs.Sub(webAssets, "web")
if err != nil {
log.Fatal(err)
}
// SPA routing
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.FileServer(http.FS(webFS)).ServeHTTP(w, r)
return
}
data, err := webAssets.ReadFile("web/index.html")
if err != nil {
http.Error(w, "Not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "text/html")
w.Write(data)
})
// API endpoints
mux.HandleFunc("/api/devices", handleDeviceDiscovery)
mux.HandleFunc("/api/client/", handleClientProxy)
log.Println("SoundTouch Web UI starting on :8080")
log.Fatal(http.ListenAndServe(":8080", mux))
}
@@ -370,36 +370,36 @@ func handleClientProxy(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid path", http.StatusBadRequest)
return
}
deviceIP := pathParts[3]
apiPath := "/" + strings.Join(pathParts[4:], "/")
// Proxy request to SoundTouch device
targetURL := fmt.Sprintf("http://%s:8090%s", deviceIP, apiPath)
proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
// Copy headers
for k, v := range r.Header {
proxyReq.Header[k] = v
}
resp, err := http.DefaultClient.Do(proxyReq)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer resp.Body.Close()
// Enable CORS
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
// Copy response
w.WriteHeader(resp.StatusCode)
io.Copy(w, resp.Body)
@@ -448,7 +448,7 @@ func (p *PlayStatus) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error
if err := d.DecodeElement(&s, &start); err != nil {
return err
}
switch s {
case string(PlayStatusPlaying), string(PlayStatusPaused), string(PlayStatusStopped):
*p = PlayStatus(s)
@@ -469,41 +469,41 @@ type Config struct {
// Server configuration
WebPort int `env:"WEB_PORT" default:"8080"`
APITimeout time.Duration `env:"API_TIMEOUT" default:"10s"`
// Discovery configuration
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
CacheDevices bool `env:"CACHE_DEVICES" default:"true"`
// CORS configuration (for web proxy)
CORSOrigins []string `env:"CORS_ORIGINS" default:"*"`
// Logging
LogLevel string `env:"LOG_LEVEL" default:"info"`
}
func Load() Config {
var cfg Config
// Load from .env file
loadDotEnv()
// Parse environment variables with reflection
parseEnvVars(&cfg)
return cfg
}
func parseEnvVars(cfg interface{}) {
v := reflect.ValueOf(cfg).Elem()
t := v.Type()
for i := 0; i < v.NumField(); i++ {
field := v.Field(i)
fieldType := t.Field(i)
envTag := fieldType.Tag.Get("env")
defaultTag := fieldType.Tag.Get("default")
if envTag != "" {
if envValue := os.Getenv(envTag); envValue != "" {
setFieldValue(field, envValue)
@@ -545,11 +545,11 @@ func (m *MockClient) GetNowPlaying() (*models.NowPlaying, error) {
if err, exists := m.errors["now_playing"]; exists {
return nil, err
}
if resp, exists := m.responses["now_playing"]; exists {
return resp.(*models.NowPlaying), nil
}
return &models.NowPlaying{
Track: "Mock Track",
Artist: "Mock Artist",
@@ -577,8 +577,8 @@ CMD ["go", "test", "-v", "./..."]
```bash
# Makefile test target
test-integration:
docker compose -f test/docker-compose.yml up --build --abort-on-container-exit
docker compose -f test/docker-compose.yml down
docker-compose -f test/docker-compose.yml up --build --abort-on-container-exit
docker-compose -f test/docker-compose.yml down
```
## Recommended Project Structure
@@ -741,7 +741,7 @@ type APIError struct {
Message string `xml:",innerxml"`
}
// pkg/models/device.go
// pkg/models/device.go
type DeviceInfo struct {
XMLResponse
Name string `xml:"name"`
@@ -773,7 +773,7 @@ func main() {
},
},
}
app.Run(os.Args)
}
```
@@ -802,4 +802,4 @@ func main() {
## Conclusion
This pattern collection enables the development of robust API clients for hardware devices that function both as native tools and as web applications. The combination of Go's type safety, WASM support, and a structured build system makes it possible to use a single codebase for various deployment scenarios.
This pattern collection enables the development of robust API clients for hardware devices that function both as native tools and as web applications. The combination of Go's type safety, WASM support, and a structured build system makes it possible to use a single codebase for various deployment scenarios.
+2 -3
View File
@@ -8,7 +8,7 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive
- **[Complete Migration Guide](guides/MIGRATION-GUIDE.md)** - Step-by-step guide from Bose Cloud to local control
- **[Getting Started](guides/GETTING-STARTED.md)** - Quick introduction to the toolkit
### For Existing Users
### For Existing Users
- **[Cloud Shutdown Survival Guide](guides/SURVIVAL-GUIDE.md)** - Prepare for the May 2026 shutdown
- **[SoundTouch Service Guide](guides/SOUNDTOUCH-SERVICE.md)** - Advanced service configuration
@@ -17,7 +17,7 @@ Welcome to the documentation for the Bose SoundTouch Toolkit. This comprehensive
The documentation is organized into three main categories:
### 1. **User Guides** - For everyday users migrating and managing devices
### 2. **Technical Reference** - For developers and advanced configuration
### 2. **Technical Reference** - For developers and advanced configuration
### 3. **Concept Documentation** - For contributors and system architects
## 🗂 Documentation Structure
@@ -47,7 +47,6 @@ The documentation is organized into three main categories:
### API Documentation
- [API Endpoints](reference/API-ENDPOINTS.md) - REST API reference
- [Spotify Account Addition](reference/spotify-account-addition.md) - Technical requests for Spotify
- [WebSocket Events](reference/WEBSOCKET-EVENTS.md) - Real-time events
- [Zone Management](reference/ZONE-MANAGEMENT.md) - Multi-room control
- [Preset Management](reference/PRESET-MANAGEMENT.md) - Preset operations
-6
View File
@@ -9,7 +9,6 @@
* [Getting Started](guides/GETTING-STARTED.md)
* [SoundTouch Service](guides/SOUNDTOUCH-SERVICE.md)
* [Initial Device Setup](guides/DEVICE-INITIAL-SETUP.md)
* [Device Setup Flow](DEVICE-SETUP.md)
* [MAC Address Mapping](guides/MAC-ADDRESS-MAPPING.md)
* [HTTPS Setup](guides/HTTPS-SETUP.md)
* [Deployment](guides/DEPLOYMENT.md)
@@ -29,7 +28,6 @@
## Technical Reference
* [API Cookbook](reference/API-COOKBOOK.md)
* [API Endpoints](reference/API-ENDPOINTS.md)
* [Spotify Account Addition](reference/spotify-account-addition.md)
* [Cloud API Emulation](reference/CLOUD-API.md)
* [System Endpoints](reference/SYSTEM-ENDPOINTS.md)
* [Speaker Endpoint](reference/SPEAKER-ENDPOINT.md)
@@ -58,16 +56,12 @@
* [Wiki API Comparison](analysis/WIKI-COMPARISON.md)
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
* [Bose Lab Runbook](analysis/BOSE-LAB-RUNBOOK.md)
* [Missing Routes Spotify](analysis/MISSING-ROUTES-SPOTIFY.md)
## Parity Analysis
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
* [Parity SoundCork](PARITY-SOUNDCORK.md)
* [Parity OpenCloudTouch](PARITY-OPENCLOUDTOUCH.md)
## Appendix (Other Documents)
* [External Services Abstraction](EXTERNAL-SERVICES-ABSTRACTION.md)
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
* [Claude Instructions](CLAUDE.md)
* [Content Selection Implementation](CONTENT-SELECTION-IMPLEMENTATION.md)
-777
View File
@@ -1,777 +0,0 @@
# Bose SoundTouch Traffic Analysis Runbook
> **Goal:** Set up a Raspberry Pi as a transparent access point to fully observe the traffic of the Bose SoundTouch app specifically the pairing flow with the Bose Cloud. This serves as a basis for later reverse engineering / simulation of the cloud endpoints.
---
## Prerequisites
| Component | Details |
|--------------------|------------------------------------------------------------------|
| Raspberry Pi | Pi 3 or newer, Raspberry Pi OS (Bullseye, Bookworm, Trixie) |
| Network interfaces | `eth0` → LAN cable to FritzBox, `wlan0` → own Access Point |
| FritzBox | Unchanged, assigns an IP to the Pi via DHCP on eth0 |
| Custom DNS Server | Already present (or see Appendix A), incl. custom CA certificate |
| Phone | Android, connects to the Pi's Wi-Fi |
### Network Architecture
```
Internet
FritzBox (existing, unchanged)
↓ LAN cable (eth0)
Raspberry Pi
├── DNS Server → selective logging / redirection
├── hostapd → custom Wi-Fi Access Point ("Bose-Lab")
├── dnsmasq → DHCP for clients, DNS to custom server
├── iptables → NAT, Forwarding eth0 ↔ wlan0
├── tcpdump → full traffic capture
└── (optional) mitmproxy → HTTPS decryption
↓ Wi-Fi ("Bose-Lab")
Android Phone
└── Bose SoundTouch App
```
---
## Step 1 Install Packages
```bash
sudo apt update && sudo apt install -y \
hostapd \ # Wi-Fi Access Point daemon
dnsmasq \ # DHCP + DNS forwarding
nftables \ # Modern NAT / firewall / forwarding
tcpdump \ # Packet capture at all levels
wireshark-common # tshark CLI (optional, for live analysis)
```
---
## Step 2 Enable IP Forwarding
The Pi must forward packets between `wlan0` (phone) and `eth0` (FritzBox).
```bash
# Active immediately (no reboot required)
sudo sysctl -w net.ipv4.ip_forward=1
# Permanent (survives reboots)
# On modern Debian, using a dedicated file in sysctl.d/ is more reliable:
echo "net.ipv4.ip_forward=1" | sudo tee /etc/sysctl.d/99-ip-forward.conf
# Apply changes immediately
sudo sysctl --system
```
**Verify:**
```bash
# After a reboot, ensure it is still '1'
cat /proc/sys/net/ipv4/ip_forward
```
---
## Step 3 Static IP on wlan0 (systemd-networkd)
On modern Debian (Bookworm/Trixie), `dhcpcd` is replaced by `systemd-networkd`.
```bash
# Create network configuration
sudo tee /etc/systemd/network/08-wlan0.network << 'EOF'
[Match]
Name=wlan0
[Network]
Address=192.168.10.1/24
IPForward=yes
ConfigureWithoutCarrier=yes
DHCP=no
IPv6AcceptRA=no
EOF
# Restart service
sudo systemctl enable systemd-networkd
sudo systemctl restart systemd-networkd
# Ensure wpa_supplicant and NetworkManager don't interfere
sudo nmcli device set wlan0 managed no
sudo systemctl stop wpa_supplicant@wlan0
sudo systemctl mask wpa_supplicant@wlan0
```
**Verify:**
```bash
ip addr show wlan0
# Expected: ONLY inet 192.168.10.1/24 (NO second DHCP IP)
```
---
## Step 4 hostapd (Access Point)
```bash
sudo tee /etc/hostapd/hostapd.conf << 'EOF'
interface=wlan0
driver=nl80211
ssid=Bose-Lab
hw_mode=b
#hw_mode=g
channel=1
#channel=6
wmm_enabled=0
auth_algs=1
wpa=2
wpa_passphrase=secret123
wpa_key_mgmt=WPA-PSK
wpa_pairwise=CCMP
EOF
# The modern way is to just use hostapd.service which defaults to /etc/hostapd/hostapd.conf
sudo systemctl unmask hostapd
sudo systemctl enable --now hostapd
```
**Verify:**
```bash
sudo systemctl status hostapd
# Expected: active (running)
```
---
## Step 5 dnsmasq (DHCP + DNS)
dnsmasq gives the phone an IP and forwards DNS queries to the custom DNS server.
```bash
# Back up original config
sudo mv /etc/dnsmasq.conf /etc/dnsmasq.conf.bak
sudo tee /etc/dnsmasq.conf << 'EOF'
interface=wlan0
dhcp-range=192.168.10.100,192.168.10.200,24h
dhcp-option=3,192.168.10.1
dhcp-option=6,192.168.10.1
# DNS Upstream: custom server on localhost (adjust port if necessary)
server=127.0.0.1#5353 # Example: custom server on port 5353
# Alternatively: server=1.1.1.1 if DNS server runs directly on port 53
# Log all DNS queries (for initial analysis)
log-queries
log-facility=/var/log/dnsmasq.log
EOF
sudo systemctl restart dnsmasq
```
**Observe DNS log live:**
```bash
sudo tail -f /var/log/dnsmasq.log
```
---
## Step 6 NAT and Forwarding (nftables)
On modern Debian (Bookworm/Trixie), `nftables` is the default and recommended way to manage NAT and traffic forwarding.
```bash
# Define the NAT and Forwarding rules
sudo tee /etc/nftables.conf << 'EOF'
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
chain forward {
type filter hook forward priority 0; policy drop;
# Allow traffic from phone (wlan0) to internet (eth0)
iifname "wlan0" oifname "eth0" accept
# Allow established/related traffic back to the phone
iifname "eth0" oifname "wlan0" ct state established,related accept
}
}
table ip nat {
chain posterouting {
type nat hook postrouting priority 100; policy accept;
# MASQUERADE outgoing packets on eth0
oifname "eth0" masquerade
}
}
EOF
# Enable and start nftables
sudo systemctl enable nftables
sudo systemctl restart nftables
```
**Verify:**
```bash
sudo nft list ruleset
# Expected: ruleset showing the forward and nat chains
```
### WiFi "Bose-Lab" not visible?
If you cannot see the `Bose-Lab` SSID on your phone:
1. **Check hostapd status:** `sudo systemctl status hostapd`. If it failed with "nl80211: Driver does not support configured mode", try changing `hw_mode=g` to `hw_mode=b`.
2. **Interface blocking:** Ensure `rfkill` hasn't blocked WiFi: `sudo rfkill unblock wlan`.
3. **Country Code:** Some systems require a country code in `hostapd.conf` to enable the radio. Add `country_code=DE` (or your country) to the top of `/etc/hostapd/hostapd.conf` and restart hostapd: `sudo systemctl restart hostapd`.
4. **Local Radio Check:** You can verify that the radio is actually configured as an AP: `iw dev wlan0 info`. Look for `type AP` and your SSID.
> **Note:** Do NOT rely on `iw dev wlan0 scan` for your own SSID; many WiFi drivers cannot "scan" and "broadcast" simultaneously.
5. **Debug Mode:** If the scan still returns nothing, stop the service and run hostapd in the foreground to see real-time errors:
```bash
sudo systemctl stop hostapd
sudo hostapd -dd /etc/hostapd/hostapd.conf
```
Look for messages like `nl80211: Failed to set interface wlan0 into AP mode`. This usually means the hardware is busy or doesn't support the current `hw_mode` / `channel` combination.
6. **Conflicting Services:** Ensure nothing else is managing `wlan0`. NetworkManager is common on modern Debian:
```bash
sudo nmcli device set wlan0 managed no
```
7. **Ghost IP Conflict:** If `ip addr show wlan0` shows both `192.168.10.1` and another IP (like `192.168.178.x`), `hostapd` will fail. This is usually caused by NetworkManager managing the interface. Ensure you've run:
```bash
sudo nmcli device set wlan0 managed no
# If the ghost IP is still there, remove it manually:
sudo ip addr del 192.168.178.X/24 dev wlan0
```
---
## Step 7 Install Custom CA Certificate on the Phone
Since a custom DNS server with a custom CA certificate is used, it must be trusted on the phone otherwise, the app will block HTTPS connections to redirected domains.
### Copy CA Certificate to the Pi (if not already there)
If you haven't created a CA yet, follow **Appendix A** first.
```bash
# Certificate is located e.g. at /etc/my-dns-ca/ca.crt
# Temporarily make reachable via HTTP for easy download:
cd /etc/my-dns-ca/
python3 -m http.server 8080
# → Reachable at http://192.168.10.1:8080/ca.crt
```
### Install on Android
1. Connect phone to `Bose-Lab`
2. Open browser → `http://192.168.10.1:8080/ca.crt`
3. Download certificate
4. **Settings → Security → Credentials → Install CA Certificate**
5. Select certificate and confirm
> **Note:** Android distinguishes between system CAs and user CAs. User-installed CAs are accepted by many apps, but apps with certificate pinning (hardcoded certificate hashes) ignore them. Whether Bose uses pinning will be visible in the capture (Connection Reset after TLS ClientHello).
### Android 14+ Special Case
From Android 14 onwards, apps do not trust user CAs by default unless explicitly declared in the manifest. If the Bose app rejects the CA certificate:
```bash
# Option A: Root + Magisk module "MagiskTrustUserCerts"
# → moves user CAs to the system store
# Option B: Root + manually copy to system CA directory
adb push ca.crt /system/etc/security/cacerts/
adb shell chmod 644 /system/etc/security/cacerts/ca.crt
```
---
## Step 8 Capture Traffic
### All at once (recommended)
```bash
# Full capture of all protocols on wlan0
# Filename with timestamp for multiple sessions
sudo tcpdump -i wlan0 \
-w /tmp/bose-$(date +%Y%m%d-%H%M%S).pcap \
-s 0 # full packet length (no truncation)
# End session: Ctrl+C
```
### Targeted by protocol
```bash
# DNS only (Port 53) shows if app uses standard DNS
sudo tcpdump -i wlan0 -n port 53
# HTTPS only TLS connections to Bose Cloud
sudo tcpdump -i wlan0 -n 'tcp port 443'
# mDNS (ZeroConf) device discovery in LAN
# Multicast group 224.0.0.1, Port 5353
sudo tcpdump -i wlan0 -n 'udp port 5353'
# SSDP/UPnP alternative device discovery
sudo tcpdump -i wlan0 -n 'udp port 1900'
# Everything except DNS (reduces noise)
sudo tcpdump -i wlan0 -n 'not port 53' -w /tmp/bose-nodns.pcap
# Traffic of a specific host only (filter by phone IP)
# Read phone IP from dnsmasq.leases beforehand (see below)
sudo tcpdump -i wlan0 -n host 192.168.10.101
```
### Read SNI from TLS Traffic (without decryption)
```bash
# Extract domains from TLS ClientHello (SNI is unencrypted)
sudo tcpdump -i wlan0 -n 'tcp port 443' -A 2>/dev/null \
| grep -oP '(?<=\x00)([a-zA-Z0-9.-]+\.(?:com|net|io|cloud|bose\.com))'
```
### Readable mDNS Announcements output
```bash
# tshark decodes mDNS directly
sudo tshark -i wlan0 -f 'udp port 5353' -T fields \
-e dns.qry.name \
-e dns.resp.name \
-e dns.a
```
---
## Step 9 Analysis with Wireshark (on PC)
Transfer `.pcap` files from the Pi to the PC:
```bash
# From the PC (scp)
scp pi@192.168.10.1:/tmp/bose-*.pcap ~/Desktop/
```
**Important Wireshark Filters:**
```
# DNS only
dns
# HTTPS only
tcp.port == 443
# WebSocket connections (HTTP Upgrade)
websocket
# mDNS
mdns
# TLS Handshakes (SNI visible)
tls.handshake.extensions_server_name
# Traffic of a specific domain (resolve by IP)
http.host contains "bose"
# WebSocket frames
websocket.payload
```
> **Tip:** Wireshark decodes WebSocket frames automatically if it sees the HTTP Upgrade handshake in the same capture. For the pairing flow: filtering for `tls.handshake.extensions_server_name` shows all domains the app contacts, even without decryption.
---
## Step 10 mitmproxy (optional, for HTTPS content)
Only useful if the CA certificate on the phone is trusted and no certificate pinning is active. `mitmproxy` acts as a Man-in-the-Middle by generating fake, on-the-fly certificates for any domain (e.g., `global.api.bose.io`) using your custom CA.
### 1. Configure mitmproxy to use your Custom CA
By default, `mitmproxy` creates its own CA in `~/.mitmproxy/`. To ensure the phone (which already trusts your `ca.crt`) accepts the traffic, you must tell `mitmproxy` to use your existing CA:
```bash
# mitmproxy expects the CA in a specific PEM format (cert + key in one file)
sudo mkdir -p ~/.mitmproxy
sudo cat /etc/my-dns-ca/ca.crt /etc/my-dns-ca/ca.key | sudo tee ~/.mitmproxy/mitmproxy-ca.pem > /dev/null
```
### 2. Install and Start mitmproxy
```bash
# Install mitmproxy binary (stable version for aarch64)
cd /tmp
wget https://downloads.mitmproxy.org/12.2.1/mitmproxy-12.2.1-linux-aarch64.tar.gz
tar -xzf mitmproxy-12.2.1-linux-aarch64.tar.gz
sudo mv mitmproxy mitmdump mitmweb /usr/local/bin/
rm mitmproxy-12.2.1-linux-aarch64.tar.gz
mitmproxy --version
# Transparent proxy on port 8080
# It will now use the CA from ~/.mitmproxy/mitmproxy-ca.pem
mitmproxy --mode transparent --listen-port 8080
# Alternatively: mitmdump for automatic logging to file
# mitmdump --mode transparent --listen-port 8080 -w /tmp/bose-https.mitm
```
### 3. Troubleshooting: TLS Handshake Failures
If you see `Client TLS handshake failed. The client does not trust the proxy's certificate for www.google.com` (or other domains) in the `mitmproxy` logs:
1. **HSTS and Pre-installed Pinning:** High-security sites like `www.google.com` use **HSTS (HTTP Strict Transport Security)** and have their certificates hardcoded (pinned) into browsers like Chrome and the Android system. **These will always fail with a User-installed CA.**
2. **User vs. System CA Store:** On Android 7.0+, apps **do not trust User-installed CAs by default**. They only trust the "System" store.
* **The Bose app:** If it fails, it's because it only trusts the System store or uses its own certificate pinning.
* **The Fix (Rooted Phone):** Use a Magisk module like `AlwaysTrustUserCerts` or manually move your `ca.crt` to `/system/etc/security/cacerts/` (see Step 7).
3. **The "Golden Rule" - Verify the Proxy is Working:**
To confirm your CA and `mitmproxy` are correctly configured, test with a non-HSTS site on the phone's browser (e.g., `http://neverssl.com`). Once redirected to HTTPS, **inspect the certificate**. It should say it was issued by your "Bose-Lab Root CA" (or "SoundTouch Root CA").
* **If this works:** Your "factory" (mitmproxy + CA) is 100% correct. Any failure in the Bose app is due to its own security policy (ignore User Store or Pinning).
* **If this fails:** Your CA is not trusted by the browser or `mitmproxy` is not using your PEM file.
Alternatively, use `curl` from a terminal emulator on the phone:
```bash
# This should work if the CA is in the user store and curl is told to use it
curl -v --cacert /path/to/ca.crt https://example.com
```
4. **Check mitmproxy CA:** Ensure `mitmproxy` is actually using your CA. When it starts, it should NOT generate a new CA in `~/.mitmproxy/mitmproxy-ca.pem` if you've already placed yours there.
---
**nftables rule: redirect HTTPS traffic to mitmproxy**
```bash
# Create a temporary file for the redirection rule
sudo nft add table ip mitm
sudo nft add chain ip mitm prerouting { type nat hook prerouting priority -100 \; }
sudo nft add rule ip mitm prerouting iifname "wlan0" tcp dport 443 redirect to :8080
```
**Remove rule when no longer needed:**
```bash
sudo nft delete table ip mitm
```
> **Detecting Certificate Pinning:** If the app immediately disconnects after mitmproxy redirection (connection reset directly after TLS ClientHello), pinning is active. In this case, Frida + root is needed to patch the pinning.
---
## Step 11 Bypassing Android Trust Restrictions
If `neverssl.com` works in the browser but the Bose app shows `TLS handshake failed` in `mitmproxy`, the app is either ignoring the **User CA store** (common on Android 7+) or using **Certificate Pinning**.
### Option A: Move CA to System Store (Requires Root/Magisk)
This is the most reliable way to make apps trust your CA without modifying the app itself.
1. **Using Magisk (Recommended):**
Install the **"AlwaysTrustUserCerts"** or **"Move Certificates"** module in Magisk. It automatically mirrors all certificates from the User store to the System store on every boot.
2. **Manual Move (via ADB):**
Android system certificates are stored in `/system/etc/security/cacerts/` and must be named using the hash of the certificate.
```bash
# 1. Get the hash of your certificate
hash=$(openssl x509 -inform PEM -subject_hash_old -in ca.crt | head -1)
# 2. Rename the certificate locally
cp ca.crt ${hash}.0
# 3. Push to the phone (requires remounting /system as read-write)
adb push ${hash}.0 /sdcard/
adb shell
su
mount -o rw,remount /
cp /sdcard/${hash}.0 /system/etc/security/cacerts/
chmod 644 /system/etc/security/cacerts/${hash}.0
chown root:root /system/etc/security/cacerts/${hash}.0
reboot
```
### Option B: Patching the App (No Root Required)
If you cannot root your phone, you can modify the app's APK to trust user-installed certificates. This involves obtaining the APK, decompiling it, adding a network security configuration, and then repackaging and signing it.
#### 0. How to get the .apk file?
You have two main ways to get the official Bose SoundTouch APK:
**Method 1: Extract from your phone (Safest)**
If the app is already installed on your phone, you can pull it using `adb`:
```bash
# 1. Find the package name (usually com.bose.soundtouch)
adb shell pm list packages | grep bose
# 2. Get the full path to the APK on the phone
adb shell pm path com.bose.soundtouch
# Output: package:/data/app/~~...==/com.bose.soundtouch-.../base.apk
# 3. Pull the file to your computer
adb pull /data/app/~~...==/com.bose.soundtouch-.../base.apk Bose-SoundTouch.apk
```
**Method 2: Download from a Mirror (Easiest)**
You can download the APK from reputable third-party sites.
> **Warning:** Always verify the site's reputation.
* [APKMirror](https://www.apkmirror.com/apk/bose-corporation/bose-soundtouch/)
* [APKPure](https://apkpure.com/bose-soundtouch/com.bose.soundtouch)
#### 1. Automated Method: apk-mitm (Recommended)
The easiest way is to use `apk-mitm`, which automates the entire process including fixing common certificate pinning libraries.
```bash
# Requires Node.js installed on your PC
npx apk-mitm Bose-SoundTouch.apk
```
This will produce a `Bose-SoundTouch-patched.apk` which you can install on your phone.
#### 2. Manual Method: Network Security Config
If you prefer to do it manually:
1. **Decompile the APK:**
```bash
apktool d Bose-SoundTouch.apk
```
2. **Create/Modify `res/xml/network_security_config.xml`:**
```xml
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<base-config>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>
```
3. **Update `AndroidManifest.xml`:**
Ensure the `<application>` tag includes: `android:networkSecurityConfig="@xml/network_security_config"`.
4. **Repackage and Sign:**
```bash
apktool b Bose-SoundTouch -o Bose-SoundTouch-patched.apk
# Sign with your own key
# 1. Generate a keystore (if you don't have one)
# Note: You can use ANY name/values here. The phone does not need to "know" or "trust" this key beforehand.
# It only needs the APK to be digitally signed so the Android installer accepts it.
keytool -genkey -v -keystore my-release-key.keystore -alias alias_name -keyalg RSA -keysize 2048 -validity 10000
# 2. Sign the APK
apksigner sign --ks my-release-key.keystore --out Bose-SoundTouch-patched-signed.apk Bose-SoundTouch-patched.apk
# Alternatively, use uber-apk-signer (recommended for simplicity)
# It handles zipalign and signing automatically.
java -jar uber-apk-signer.jar --apk Bose-SoundTouch-patched.apk
```
#### 3. Install the Patched APK
Once you have your `Bose-SoundTouch-patched.apk` (and it is signed), you need to install it on your phone.
**Important:** You must **uninstall the original Bose app first**. Android will not allow you to "update" the official app with your patched version because the digital signatures won't match.
**Method 1: via ADB (Recommended)**
```bash
# 1. Uninstall the original app
adb uninstall com.bose.soundtouch
# 2. Install your patched version
adb install Bose-SoundTouch-patched.apk
```
**Method 2: Manual Transfer**
1. Copy the `Bose-SoundTouch-patched.apk` to your phone's storage (via USB, Google Drive, or the Pi's HTTP server).
2. On the phone, use a File Manager to open the APK.
3. If prompted, allow "Install from Unknown Sources" for your File Manager.
### Option C: Patching the App with Frida (Requires Root)
If the app uses **Certificate Pinning** (hardcoded hashes), even moving the CA to the System store won't work. You must disable the pinning check in the app's code.
1. **Install Frida** on your PC and `frida-server` on the rooted phone.
2. **Use a universal bypass script:**
```bash
frida -U -f com.bose.soundtouch -l https://codeshare.frida.re/@pcipolloni/universal-android-ssl-pinning-bypass-with-frida/ --no-pause
```
*(Replace `com.bose.soundtouch` with the actual package name if different).*
## Step 12 Alternative: Regular HTTP Proxy Mode
If the **Transparent AP** setup (Steps 16) is too complex or you are experiencing routing issues, you can use `mitmproxy` as a **Regular HTTP Proxy**.
### 1. How it works
In this mode, the Pi acts as a simple server on port 8080. You tell your phone's Wi-Fi settings to send all traffic to `192.168.10.1:8080`.
* **Pros:** No complex `nftables` or NAT rules required.
* **Cons:** Many Android apps (and background processes) ignore system-wide proxy settings. **HTTPS still requires a trusted CA for decryption.**
### 2. Start mitmproxy in Regular Mode
```bash
# Stop transparent mode first if it's running
# No special flags needed for regular mode
mitmproxy --listen-port 8080
```
### 3. Configure the Phone
1. Go to **Settings → Wi-Fi → Bose-Lab**.
2. Select **Modify Network** (or the "i" icon).
3. Set **Proxy** to **Manual**.
4. **Proxy hostname:** `192.168.10.1`
5. **Proxy port:** `8080`
6. Save and try to browse a site.
---
## Step 13 Extracting for soundtouch-service
```bash
# Which IPs did the phone receive?
cat /var/lib/misc/dnsmasq.leases
# Is the access point active?
sudo systemctl status hostapd
# Is dnsmasq active?
sudo systemctl status dnsmasq
# Check interfaces and IPs
ip addr show
# Check routing table
ip route show
# Show active nftables rules
sudo nft list ruleset
# All running tcpdump processes
pgrep -a tcpdump
# Test the Pi's own DNS resolution
dig @127.0.0.1 -p 5353 global.api.bose.io
# Check network connectivity from the phone (from the Pi)
ping 192.168.10.101 # Phone IP from dnsmasq.leases
```
---
## Restart Sequence
After a Pi reboot, everything should come up automatically. If not:
```bash
# Restart and enable all core services
sudo systemctl restart systemd-networkd
sudo systemctl enable --now hostapd
sudo systemctl enable --now dnsmasq
sudo systemctl restart nftables
# Verify the unmanaged state of wlan0 (nmcli)
sudo nmcli device set wlan0 managed no
```
---
## What to Expect
| Protocol | Port | Tool | Visibility |
|----------------------|------------|--------------------------|------------------------------------------------|
| DNS (Standard) | UDP 53 | tcpdump, dnsmasq log | Full, plaintext |
| HTTPS / REST | TCP 443 | tcpdump (SNI), mitmproxy | SNI without decryption, content with mitmproxy |
| WebSockets | TCP 443/80 | Wireshark | Frames decoded if TLS is broken |
| mDNS / ZeroConf | UDP 5353 | tcpdump, tshark | Full, plaintext |
| SSDP / UPnP | UDP 1900 | tcpdump | Full, plaintext |
| SoundTouch local API | TCP 8090 | tcpdump | Full, plaintext (no TLS) |
> **Expectation for Bose SoundTouch:** The app likely uses standard DNS (older app generation), REST/HTTPS for the pairing flow with the cloud, WebSockets for push events from the device, and mDNS for local device discovery. The local device API on port 8090 is HTTP without TLS this traffic is always readable.
---
## Next Steps After Analysis
1. Extract domains from DNS log and SNI → List of all Bose endpoints
2. HTTP methods and paths from mitmproxy log → Reconstruct API structure
3. Document auth flow (OAuth2? Proprietary? Token format?)
4. Build a minimal mock server simulating the critical endpoints
5. Testing: App against mock server → does pairing work offline?
---
## Appendix A Generating a Custom CA Certificate
If you don't have a custom DNS server with a CA yet, you can create one directly on the Pi. Alternatively, if you are already using the `soundtouch-service` from this repository, you can reuse its CA certificate located in the `data/certs/` directory.
### 0. (Optional) Copy an Existing CA from another host
If you are already using the `soundtouch-service` on another machine (e.g., your notebook), you can copy the existing CA to the Pi instead of generating a new one:
```bash
# On your Pi:
sudo mkdir -p /etc/my-dns-ca
sudo chown $USER:$USER /etc/my-dns-ca
# Run this on your notebook (replace hostnames and paths):
# Note: This is easiest if your SSH key is added to the Pi and soundtouch-service host.
# If you run into permission issues with sudo, ensure the source user has passwordless sudo for 'cat'.
# Step A: Download from source to your notebook
ssh soundtouch-service "sudo cat /var/lib/soundtouch-service/certs/ca.crt" > ca.crt
ssh soundtouch-service "sudo cat /var/lib/soundtouch-service/certs/ca.key" > ca.key
# Step B: Upload from notebook to the Pi
scp ca.crt ca.key soundtouch-access-point:/tmp/
ssh soundtouch-access-point "sudo mv /tmp/ca.crt /tmp/ca.key /etc/my-dns-ca/ && sudo chown root:root /etc/my-dns-ca/ca.*"
rm ca.crt ca.key
```
### 1. Create CA Key and Certificate
```bash
sudo mkdir -p /etc/my-dns-ca
cd /etc/my-dns-ca
# Generate CA private key
sudo openssl genrsa -out ca.key 4096
# Generate Root CA certificate
# Note: we explicitly add basicConstraints=CA:TRUE for modern TLS clients
sudo openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
-out ca.crt \
-subj "/C=DE/O=Bose-Lab/CN=Bose-Lab Root CA" \
-addext "basicConstraints=critical,CA:TRUE" \
-addext "keyUsage=critical,keyCertSign,cRLSign"
```
### 2. Generate a Certificate for Interception (Example)
To intercept `global.api.bose.io`, you need a certificate for it, signed by your CA:
```bash
# Generate server key
sudo openssl genrsa -out bose.key 2048
# Create CSR (Certificate Signing Request) configuration
sudo tee bose.ext << 'EOF'
authorityKeyIdentifier=keyid,issuer
basicConstraints=CA:FALSE
keyUsage = digitalSignature, nonRepudiation, keyEncipherment, dataEncipherment
subjectAltName = @alt_names
[alt_names]
DNS.1 = global.api.bose.io
DNS.2 = *.bose.io
EOF
# Generate CSR
sudo openssl req -new -key bose.key -out bose.csr \
-subj "/C=DE/O=Bose-Lab/CN=global.api.bose.io"
# Sign the certificate with your CA
sudo openssl x509 -req -in bose.csr -CA ca.crt -CAkey ca.key \
-CAcreateserial -out bose.crt -days 365 -sha256 -extfile bose.ext
```
### 3. Usage in your DNS/HTTPS Server
Your custom server (e.g., a small Go or Python script) would then use `bose.crt` and `bose.key` to serve HTTPS traffic for those domains.
-43
View File
@@ -1,43 +0,0 @@
# Spotify Account Addition Implementation Status
To fully replace Bose cloud services for the Spotify account addition flow in the "Stockholm" SoundTouch application, the following routes have been implemented in the `soundtouch-service`:
## 1. OAuth Token Exchange (Bose Cloud)
The Stockholm background worker (in `worker_common.js` and `spotify_worker.js`) performs a token exchange using an authorization code.
* **Route**: `POST /oauth/account/{account}/music/musicprovider/{sourceID}/token/cs`
* **Purpose**: To exchange the Spotify authorization code for a Bose-mediated token.
* **Implementation**: `HandleBoseAccountToken` in `pkg/service/handlers/handlers_oauth.go`.
* **Registration**: Registered in `cmd/soundtouch-service/main.go` under the `/oauth` route group.
## 2. Cloud Source Registration (Marge Service)
The SoundTouch application registers a new music source (e.g., Spotify) with the Bose cloud profile.
* **Route**: `POST /streaming/account/{account}/source`
* **Purpose**: To add the new source (username, credentials, display name) to the user's emulated cloud profile.
* **Implementation**: `HandleMargeAddSource` in `pkg/service/handlers/handlers_marge.go`.
* **Registration**: Registered in `cmd/soundtouch-service/main.go` under the `/streaming` route group.
* **Payload Format**: XML `application/vnd.bose.streaming-v1.1+xml` containing `<source>` with `<username>`, `<sourceproviderid>`, and `<credential type="token_version_3">`.
## 3. Redirect Handling (Browser to App)
The `soundtouch://` deep link redirect URI is handled by the management interface which provides the OAuth callback.
* **Callback Route**: `GET /mgmt/spotify/callback`
* **Implementation**: `HandleMgmtSpotifyCallback` in `pkg/service/handlers/handlers_mgmt.go`.
* **Confirmation Route**: `POST /mgmt/spotify/confirm` (used by mobile apps for deep-link codes).
* **Implementation**: `HandleMgmtSpotifyConfirm` in `pkg/service/handlers/handlers_mgmt.go`.
## Implementation Details
1. **Marge Add Source**:
* `HandleMargeAddSource` in `pkg/service/handlers/handlers_marge.go` parses the incoming XML and persists the new source to the `DataStore` for the corresponding account.
2. **OAuth Account Token Exchange**:
* `HandleBoseAccountToken` in `pkg/service/handlers/handlers_oauth.go` supports the `/oauth/account/.../token/cs` path.
* It responds with a JSON payload including `access_token` and `token_type` "Bearer" after exchanging the code via `ExchangeCodeAndStore`.
3. **Router Registration**:
* These paths are registered in `cmd/soundtouch-service/main.go` within the `/streaming`, `/oauth`, and `/mgmt` route blocks.
+19 -19
View File
@@ -185,7 +185,7 @@ type NowPlaying struct {
type PlayStatus string
const (
PlayStatusPlaying PlayStatus = "PLAY_STATE"
PlayStatusPaused PlayStatus = "PAUSE_STATE"
PlayStatusPaused PlayStatus = "PAUSE_STATE"
PlayStatusStopped PlayStatus = "STOP_STATE"
)
@@ -277,19 +277,19 @@ type Config struct {
// Server configuration
WebPort int `env:"WEB_PORT" default:"8080"`
APITimeout time.Duration `env:"API_TIMEOUT" default:"10s"`
// Discovery configuration
// Discovery configuration
DiscoveryTimeout time.Duration `env:"DISCOVERY_TIMEOUT" default:"5s"`
CacheDevices bool `env:"CACHE_DEVICES" default:"true"`
CacheTTL time.Duration `env:"CACHE_TTL" default:"5m"`
// CORS configuration (for web proxy)
CORSOrigins []string `env:"CORS_ORIGINS" default:"*"`
// Logging
LogLevel string `env:"LOG_LEVEL" default:"info"`
LogFormat string `env:"LOG_FORMAT" default:"json"`
// Development
DevMode bool `env:"DEV_MODE" default:"false"`
}
@@ -537,7 +537,7 @@ build-all: build-linux build-darwin build-windows
dev-cli:
air -c .air-cli.toml
dev-webapp:
dev-webapp:
air -c .air-webapp.toml
dev-wasm:
@@ -556,7 +556,7 @@ check: fmt vet lint test
# Docker development environment
docker-dev:
docker compose up --build
docker-compose up --build
# Release packaging
release: build-all
@@ -596,7 +596,7 @@ import (
"fmt"
"log"
"time"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -609,36 +609,36 @@ func main() {
if err != nil {
log.Fatal(err)
}
if len(devices) == 0 {
log.Fatal("No SoundTouch devices found")
}
// Create client for first device
client := client.NewClient(client.ClientConfig{
Host: devices[0].Host,
Port: 8090,
Timeout: 10 * time.Second,
})
// Get device info
info, err := client.GetDeviceInfo()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Connected to: %s\n", info.Name)
// Get current playback
nowPlaying, err := client.GetNowPlaying()
if err != nil {
log.Fatal(err)
}
if nowPlaying.PlayStatus == models.PlayStatusPlaying {
fmt.Printf("Playing: %s - %s (%s)\n",
fmt.Printf("Playing: %s - %s (%s)\n",
nowPlaying.Artist, nowPlaying.Track, nowPlaying.Album)
}
// Control playback
if nowPlaying.PlayStatus == models.PlayStatusPlaying {
client.SendKey(models.KeyPause)
@@ -737,11 +737,11 @@ docker run -p 8080:8080 soundtouch-webapp
```bash
# Local development with hot reload
make dev-webapp # Web app development
make dev-wasm # WASM development
make dev-wasm # WASM development
make dev-cli # CLI development
# Full development environment
docker compose up # Mock devices + web app
docker-compose up # Mock devices + web app
```
## Success Criteria
@@ -781,7 +781,7 @@ docker compose up # Mock devices + web app
- [Bose SoundTouch Web API Documentation](https://assets.bosecreative.com/m/496577402d128874/original/SoundTouch-Web-API.pdf)
- [Go WebAssembly](https://github.com/golang/go/wiki/WebAssembly)
- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
- [UPnP Device Architecture](http://upnp.org/specs/arch/UPnP-arch-DeviceArchitecture-v1.0.pdf)
- [Go Embed Directive](https://pkg.go.dev/embed)
- [Gorilla WebSocket](https://github.com/gorilla/websocket)
- [PROJECT-PATTERNS.md](../PROJECT-PATTERNS.md) - Detailed pattern documentation
+4 -16
View File
@@ -816,7 +816,7 @@ func TestAccountManager_CreateAccount(t *testing.T) {
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := manager.CreateAccount(tt.input)
@@ -848,18 +848,6 @@ go test ./... -v -cover
go test -bench=. ./...
```
## Performance Requirements
### Response Time Targets
- Local API requests: < 100ms (95th percentile)
- Mirror requests: < 200ms overhead (asynchronous)
- Discovery time: < 5s for network scan
### Resource Constraints
- Memory usage: < 64MB for small deployments
- CPU usage: < 5% on dual-core ARM systems (idle)
- Storage: < 100MB for interaction logs (rotatable)
### Security Considerations
#### Simple Security Model
@@ -961,12 +949,12 @@ func (s *Server) HandleHealthCheck(w http.ResponseWriter, r *http.Request) {
Version: version,
Uptime: time.Since(startTime).String(),
}
// Simple checks
if !s.canWriteToDataDir() {
health.Status = "error"
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(health)
}
@@ -998,4 +986,4 @@ func (m *SimpleMetrics) Save(dataDir string) error {
}
```
This technical specification provides comprehensive details for implementing the enhanced state management system while maintaining compatibility with existing SoundTouch service functionality and meeting the performance requirements for small hardware deployments.
This technical specification provides comprehensive details for implementing the enhanced state management system while maintaining compatibility with existing SoundTouch service functionality and meeting the performance requirements for small hardware deployments.
+1 -1
View File
@@ -116,7 +116,7 @@ volumes:
And run:
```bash
docker compose up -d
docker-compose up -d
```
## Quick Start
-197
View File
@@ -1,197 +0,0 @@
# Spotify Account Addition Technical Reference
This document details the exact network requests performed by the Bose SoundTouch "Stockholm" application and the SoundTouch speaker when adding a new Spotify account. This information is based on analysis of the Stockholm firmware version `27.0.13-4277-8963611`.
## Flow Overview
1. **User Authorization Initiation**: The app opens the system browser to Spotify's authorization page.
2. **Redirect Handling**: After authorization, Spotify redirects back to the app via a custom URI scheme, delivering an authorization `code`.
3. **OAuth Token Exchange**: The app sends this `code` to the background worker, which exchanges it for a Bose-mediated token.
4. **Cloud Source Registration**: The app registers the Spotify account as a "source" in the user's Bose Cloud (Marge) profile.
5. **Local Device Sync**: The app notifies the local SoundTouch speaker about the new source, which then updates its internal configuration.
---
## 0. User Authorization Initiation
The process begins in the Stockholm UI when the user selects Spotify to add a new account.
### Request Details (App to Browser)
- **Action**: Open System Browser
- **Base URL**: `[SPOTIFY_AUTH_URL]` (e.g., `https://accounts.spotify.com/authorize`)
- **Query Parameters**:
- `client_id`: Bose Spotify Client ID
- `response_type`: `code`
- `redirect_uri`: `http://localhost` (often used as a placeholder or specifically handled by the app's internal webview/proxy)
- `scope`: `user-read-private user-read-email ...`
- `state`: A base64-encoded JSON object containing metadata, e.g., `{"service": "SPOTIFY"}`.
### Redirect (Browser to App)
Upon successful login and authorization, Spotify redirects the browser to a URL that the SoundTouch app intercepts.
- **URL Format**: `soundtouch://bose/musicservice/spotify/login?code=[AUTH_CODE]&state=[STATE]`
- **App Action**: The `UIMain` component (in `ui_main.js`) handles this "deep link". It extracts the `code` from the query parameters and prepares to send it to the background worker.
---
## 1. OAuth Token Exchange (Bose Cloud)
After the UI intercepts the redirect and extracts the `code`, it sends a `createOAuthAccountRequest` to the background `SpotifyWorker`. The worker then performs the exchange for a Bose-mediated token.
### What is a "Bose-mediated token"?
The "Bose-mediated token" is a token issued by the Bose OAuth proxy. When the app (or device) requests a token via `oauth.streaming.bose.com`, Bose's service performs the actual OAuth2 exchange with Spotify.
- **It is not directly a Spotify refresh token**: Instead, it is a Bose-issued token that *represents* the underlying Spotify session.
- **Token Version 3**: Modern firmware uses `token_version_3`, which signifies that the device doesn't store the raw Spotify tokens but instead uses a Bose-specific "secret" that the Bose Cloud uses to fetch fresh Spotify access tokens on the device's behalf.
- **Access vs Refresh**: The initial response from the `.../token/cs` endpoint typically contains an `access_token` (valid for ~1 hour) and a `token_type: "Bearer"`. The Bose cloud service manages the persistent refresh token internally.
### Internal Message (UI to Worker)
- **Message Type**: `createOAuthAccountRequest`
- **Payload**:
```json
{
"source": "SPOTIFY",
"code": "[AUTH_CODE_FROM_REDIRECT]",
"credentialType": "token_version_3"
}
```
### Outgoing Request (Worker to Bose OAuth Proxy)
- **Endpoint**: `https://oauth.streaming.bose.com/oauth/account/[ACCOUNT_ID]/music/musicprovider/15/token/cs`
- **Method**: `POST`
- **Headers**:
- `Content-Type: application/json`
- `Accept: application/json`
- `Authorization: Bearer [SESSION_TOKEN]` (The user's Bose account session token)
### Payload (JSON)
```json
{
"grant_type": "authorization_code",
"code": "[AUTH_CODE_FROM_SPOTIFY]",
"redirect_uri": "http://localhost"
}
```
### curl Example
```bash
curl -X POST "https://oauth.streaming.bose.com/oauth/account/[ACCOUNT_ID]/music/musicprovider/15/token/cs" \
-H "Content-Type: application/json" \
-H "Authorization: Bearer [SESSION_TOKEN]" \
-d '{
"grant_type": "authorization_code",
"code": "[AUTH_CODE_FROM_SPOTIFY]",
"redirect_uri": "http://localhost"
}'
```
---
## 2. Cloud Source Registration (Marge)
The app now registers the Spotify account with the Bose "Marge" service. This makes the source available across all devices linked to the same Bose account.
### Request Details
- **Endpoint**: `https://streaming.bose.com/streaming/account/[ACCOUNT_ID]/source`
- **Method**: `POST`
- **Headers**:
- `Content-Type: application/vnd.bose.streaming-v1.1+xml`
- `Authorization: [MARGE_TOKEN]`
- `GUID: [DEVICE_GUID]`
- `ClientType: Stockholm`
### Payload (XML)
```xml
<?xml version="1.0" encoding="UTF-8"?>
<source>
<username>[SPOTIFY_USER_ID]</username>
<sourceproviderid>15</sourceproviderid>
<credential type="token_version_3">[SECRET_TOKEN_OBTAINED_IN_STEP_1]</credential>
<sourcename>[DISPLAY_NAME_E_G_EMAIL]</sourcename>
</source>
```
### curl Example
```bash
curl -X POST "https://streaming.bose.com/streaming/account/[ACCOUNT_ID]/source" \
-H "Content-Type: application/vnd.bose.streaming-v1.1+xml" \
-H "Authorization: [MARGE_TOKEN]" \
-d '<?xml version="1.0" encoding="UTF-8"?><source><username>[USER]</username><sourceproviderid>15</sourceproviderid><credential type="token_version_3">[TOKEN]</credential><sourcename>[NAME]</sourcename></source>'
```
---
### Local Device Sync (LISA API)
The app notifies the physical SoundTouch speaker about the new source. This is usually done via the device's management API on port 8090.
#### Modern Flow (OAuth)
- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceOAuthAccount`
- **Method**: `POST`
- **Payload**:
```xml
<OAuthCredentials source="SPOTIFY" displayName="[DISPLAY_NAME]">
<user>[SPOTIFY_USER_ID]</user>
<code>[AUTH_CODE_OR_TOKEN]</code>
<version>token_version_3</version>
</OAuthCredentials>
```
#### Marge-Sync Notification (Fall-back)
If the speaker returns `1029 UNKNOWN_ACTION_ERROR`, it signifies the LISA API version is too old for the OAuth flow. Stockholm-based firmware often expects the account to be registered in Marge first, followed by a notification to sync.
- **Endpoint**: `http://[DEVICE_IP]:8090/notification`
- **Method**: `POST`
- **Payload**:
```xml
<updates deviceID="[DEVICE_UID]">
<sourcesUpdated></sourcesUpdated>
</updates>
```
#### Legacy Flow (Fall-back)
For older firmware that doesn't use Marge for Spotify:
- **Endpoint**: `http://[DEVICE_IP]:8090/setMusicServiceAccount`
- **Method**: `POST`
- **Payload**:
```xml
<credentials source="SPOTIFY" displayName="Spotify Premium">
<user>[USER]</user>
<pass>[TOKEN]</pass>
</credentials>
```
---
## Implementation in SoundTouch-Service
This project implements the "Bose-mediated token" flow as follows:
1. **Surrogate Secrets**: When a user links their Spotify account via `soundtouch-service`, the service generates a 32-character hex string (a "Bose Secret").
2. **Marge & LISA registration**: This secret is sent to the speaker and stored in the emulated Marge cloud as the `credential`. The raw Spotify refresh token never leaves the server.
3. **Token Refresh Proxy**: When the speaker needs a fresh Spotify `access_token`, it calls the `soundtouch-service` proxy (`/oauth/device/.../token/cs3`) providing this secret. The server maps the secret back to the actual Spotify account, performs the refresh with Spotify, and returns a fresh short-lived `access_token` to the speaker.
---
## Placeholders and Constants
| Placeholder | Description |
|:------------------|:----------------------------------------------------|
| `[ACCOUNT_ID]` | The internal Bose account ID (UUID). |
| `[SESSION_TOKEN]` | Temporary token from Bose login. |
| `[MARGE_TOKEN]` | Persistent authorization token for Marge services. |
| `[DEVICE_GUID]` | Unique identifier for the controller app instance. |
| `[DEVICE_IP]` | Local IP address of the SoundTouch speaker. |
| `15` | Constant `sourceproviderid` for Spotify. |
| `token_version_3` | Credential type for modern OAuth2 Spotify accounts. |
---
## Resulting Persistence
Once these requests succeed, the device updates its `/mnt/nv/BoseApp-Persistence/1/Sources.xml` file:
```xml
<source displayName="user@example.com" secret="[SECRET_BLOB]" secretType="token_version_3">
<sourceKey type="SPOTIFY" account="user" />
</source>
```
+1 -1
View File
@@ -2,7 +2,7 @@ module navigation-station-demo
go 1.26.1
require github.com/gesellix/bose-soundtouch v0.53.0
require github.com/gesellix/bose-soundtouch v0.43.0
require github.com/gorilla/websocket v1.5.3 // indirect
+1 -1
View File
@@ -2,7 +2,7 @@ module preset-management-example
go 1.26.1
require github.com/gesellix/bose-soundtouch v0.53.0
require github.com/gesellix/bose-soundtouch v0.43.0
require github.com/gorilla/websocket v1.5.3 // indirect
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 930 B

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 936 B

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 902 B

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 967 B

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 989 B

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 888 B

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+5
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

+5
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More