Compare commits

...
23 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
Tobias Gesellchen e0a84d5904 Split register and unregister device tests (#133) 2026-03-27 22:03:21 +01:00
dependabot[bot]andlnx01 5d080cf35f ci(deps): bump codecov/codecov-action from 5 to 6 in the security-actions group (#132)
Bumps the security-actions group with 1 update:
[codecov/codecov-action](https://github.com/codecov/codecov-action).

Updates `codecov/codecov-action` from 5 to 6
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/codecov/codecov-action/releases">codecov/codecov-action's
releases</a>.</em></p>
<blockquote>
<h2>v6.0.0</h2>
<h2>⚠️ This version introduces support for node24 which make cause
breaking changes for systems that do not currently support node24.
⚠️</h2>
<h2>What's Changed</h2>
<ul>
<li>Revert &quot;Revert &quot;build(deps): bump actions/github-script
from 7.0.1 to 8.0.0&quot;&quot; by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1929">codecov/codecov-action#1929</a></li>
<li>Th/6.0.0 by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1928">codecov/codecov-action#1928</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.5.4...v6.0.0">https://github.com/codecov/codecov-action/compare/v5.5.4...v6.0.0</a></p>
<h2>v5.5.4</h2>
<p>This is a mirror of <code>v5.5.2</code>. <code>v6</code> will be
released which requires <code>node24</code></p>
<h2>What's Changed</h2>
<ul>
<li>Revert &quot;build(deps): bump actions/github-script from 7.0.1 to
8.0.0&quot; by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1926">codecov/codecov-action#1926</a></li>
<li>chore(release): 5.5.4 by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1927">codecov/codecov-action#1927</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.5.3...v5.5.4">https://github.com/codecov/codecov-action/compare/v5.5.3...v5.5.4</a></p>
<h2>v5.5.3</h2>
<h2>What's Changed</h2>
<ul>
<li>build(deps): bump actions/github-script from 7.0.1 to 8.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1874">codecov/codecov-action#1874</a></li>
<li>chore(release): bump to 5.5.3 by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1922">codecov/codecov-action#1922</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.5.2...v5.5.3">https://github.com/codecov/codecov-action/compare/v5.5.2...v5.5.3</a></p>
<h2>v5.5.2</h2>
<h2>What's Changed</h2>
<ul>
<li>check gpg only when skip-validation = false by <a
href="https://github.com/maxweng-sentry"><code>@​maxweng-sentry</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1894">codecov/codecov-action#1894</a></li>
<li>chore: <code>disable_search</code> alignment by <a
href="https://github.com/freemanzMrojo"><code>@​freemanzMrojo</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1881">codecov/codecov-action#1881</a></li>
<li>chore(release): 5.5.2 by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1902">codecov/codecov-action#1902</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/maxweng-sentry"><code>@​maxweng-sentry</code></a>
made their first contribution in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1894">codecov/codecov-action#1894</a></li>
<li><a
href="https://github.com/freemanzMrojo"><code>@​freemanzMrojo</code></a>
made their first contribution in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1881">codecov/codecov-action#1881</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.5.1...v5.5.2">https://github.com/codecov/codecov-action/compare/v5.5.1...v5.5.2</a></p>
<h2>v5.5.1</h2>
<h2>What's Changed</h2>
<ul>
<li>build(deps): bump ossf/scorecard-action from 2.4.1 to 2.4.2 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1833">codecov/codecov-action#1833</a></li>
<li>build(deps): bump github/codeql-action from 3.28.18 to 3.29.9 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1861">codecov/codecov-action#1861</a></li>
<li>Document a <code>codecov-cli</code> version reference example by <a
href="https://github.com/webknjaz"><code>@​webknjaz</code></a> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1774">codecov/codecov-action#1774</a></li>
<li>docs: fix typo in README by <a
href="https://github.com/datalater"><code>@​datalater</code></a> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1866">codecov/codecov-action#1866</a></li>
<li>fix: update to use local app/ dir by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1872">codecov/codecov-action#1872</a></li>
<li>build(deps): bump github/codeql-action from 3.29.9 to 3.29.11 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1867">codecov/codecov-action#1867</a></li>
<li>build(deps): bump actions/checkout from 4.2.2 to 5.0.0 by <a
href="https://github.com/dependabot"><code>@​dependabot</code></a>[bot]
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1868">codecov/codecov-action#1868</a></li>
<li>fix: overwrite pr number on fork by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1871">codecov/codecov-action#1871</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md">codecov/codecov-action's
changelog</a>.</em></p>
<blockquote>
<h2>v5.5.2</h2>
<h3>What's Changed</h3>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.5.1..v5.5.2">https://github.com/codecov/codecov-action/compare/v5.5.1..v5.5.2</a></p>
<h2>v5.5.1</h2>
<h3>What's Changed</h3>
<ul>
<li>fix: overwrite pr number on fork by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1871">codecov/codecov-action#1871</a></li>
<li>build(deps): bump actions/checkout from 4.2.2 to 5.0.0 by
<code>@​app/dependabot</code> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1868">codecov/codecov-action#1868</a></li>
<li>build(deps): bump github/codeql-action from 3.29.9 to 3.29.11 by
<code>@​app/dependabot</code> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1867">codecov/codecov-action#1867</a></li>
<li>fix: update to use local app/ dir by <a
href="https://github.com/thomasrockhu-codecov"><code>@​thomasrockhu-codecov</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1872">codecov/codecov-action#1872</a></li>
<li>docs: fix typo in README by <a
href="https://github.com/datalater"><code>@​datalater</code></a> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1866">codecov/codecov-action#1866</a></li>
<li>Document a <code>codecov-cli</code> version reference example by <a
href="https://github.com/webknjaz"><code>@​webknjaz</code></a> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1774">codecov/codecov-action#1774</a></li>
<li>build(deps): bump github/codeql-action from 3.28.18 to 3.29.9 by
<code>@​app/dependabot</code> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1861">codecov/codecov-action#1861</a></li>
<li>build(deps): bump ossf/scorecard-action from 2.4.1 to 2.4.2 by
<code>@​app/dependabot</code> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1833">codecov/codecov-action#1833</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.5.0..v5.5.1">https://github.com/codecov/codecov-action/compare/v5.5.0..v5.5.1</a></p>
<h2>v5.5.0</h2>
<h3>What's Changed</h3>
<ul>
<li>feat: upgrade wrapper to 0.2.4 by <a
href="https://github.com/jviall"><code>@​jviall</code></a> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1864">codecov/codecov-action#1864</a></li>
<li>Pin actions/github-script by Git SHA by <a
href="https://github.com/martincostello"><code>@​martincostello</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1859">codecov/codecov-action#1859</a></li>
<li>fix: check reqs exist by <a
href="https://github.com/joseph-sentry"><code>@​joseph-sentry</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1835">codecov/codecov-action#1835</a></li>
<li>fix: Typo in README by <a
href="https://github.com/spalmurray"><code>@​spalmurray</code></a> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1838">codecov/codecov-action#1838</a></li>
<li>docs: Refine OIDC docs by <a
href="https://github.com/spalmurray"><code>@​spalmurray</code></a> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1837">codecov/codecov-action#1837</a></li>
<li>build(deps): bump github/codeql-action from 3.28.17 to 3.28.18 by
<code>@​app/dependabot</code> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1829">codecov/codecov-action#1829</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.4.3..v5.5.0">https://github.com/codecov/codecov-action/compare/v5.4.3..v5.5.0</a></p>
<h2>v5.4.3</h2>
<h3>What's Changed</h3>
<ul>
<li>build(deps): bump github/codeql-action from 3.28.13 to 3.28.17 by
<code>@​app/dependabot</code> in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1822">codecov/codecov-action#1822</a></li>
<li>fix: OIDC on forks by <a
href="https://github.com/joseph-sentry"><code>@​joseph-sentry</code></a>
in <a
href="https://redirect.github.com/codecov/codecov-action/pull/1823">codecov/codecov-action#1823</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/codecov/codecov-action/compare/v5.4.2..v5.4.3">https://github.com/codecov/codecov-action/compare/v5.4.2..v5.4.3</a></p>
<h2>v5.4.2</h2>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/codecov/codecov-action/commit/57e3a136b779b570ffcdbf80b3bdc90e7fab3de2"><code>57e3a13</code></a>
Th/6.0.0 (<a
href="https://redirect.github.com/codecov/codecov-action/issues/1928">#1928</a>)</li>
<li><a
href="https://github.com/codecov/codecov-action/commit/f67d33dda8a42b51c42a8318a1f66468119e898b"><code>f67d33d</code></a>
Revert &quot;Revert &quot;build(deps): bump actions/github-script from
7.0.1 to 8.0.0&quot;&quot;...</li>
<li>See full diff in <a
href="https://github.com/codecov/codecov-action/compare/v5...v6">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=codecov/codecov-action&package-manager=github_actions&previous-version=5&new-version=6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 08:47:00 +01:00
dependabot[bot]andlnx01 bcd383bdff ci(deps): bump actions/deploy-pages from 4 to 5 in the actions-core group (#131)
Bumps the actions-core group with 1 update:
[actions/deploy-pages](https://github.com/actions/deploy-pages).

Updates `actions/deploy-pages` from 4 to 5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/actions/deploy-pages/releases">actions/deploy-pages's
releases</a>.</em></p>
<blockquote>
<h2>v5.0.0</h2>
<h1>Changelog</h1>
<ul>
<li>Update Node.js version to 24.x <a
href="https://github.com/salmanmkc"><code>@​salmanmkc</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/404">#404</a>)</li>
<li>Add workflow file for publishing releases to immutable action
package <a
href="https://github.com/Jcambass"><code>@​Jcambass</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/374">#374</a>)</li>
<li>Bump braces from 3.0.2 to 3.0.3 in the npm_and_yarn group across 1
directory <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/360">#360</a>)</li>
<li>Make the rebuild dist workflow work nicer with Dependabot <a
href="https://github.com/yoannchaudet"><code>@​yoannchaudet</code></a>
(<a
href="https://redirect.github.com/actions/deploy-pages/issues/361">#361</a>)</li>
<li>Bump the non-breaking-changes group across 1 directory with 3
updates <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/358">#358</a>)</li>
<li>Delete repeated sentence <a
href="https://github.com/garethsb"><code>@​garethsb</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/359">#359</a>)</li>
<li>Update README.md <a
href="https://github.com/tsusdere"><code>@​tsusdere</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/348">#348</a>)</li>
<li>Bump the non-breaking-changes group with 4 updates <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/341">#341</a>)</li>
<li>Remove error message for file permissions <a
href="https://github.com/TooManyBees"><code>@​TooManyBees</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/340">#340</a>)</li>
</ul>
<hr />
<p>See details of <a
href="https://github.com/actions/deploy-pages/compare/v4.0.5...v4.0.6">all
code changes</a> since previous release.</p>
<p>⚠️ For use with products other than GitHub.com, such as GitHub
Enterprise Server, please consult the <a
href="https://github.com/actions/deploy-pages/#compatibility">compatibility
table</a>.</p>
<h2>v4.0.5</h2>
<h1>Changelog</h1>
<ul>
<li>On API error, the error message will surface the API request ID <a
href="https://github.com/TooManyBees"><code>@​TooManyBees</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/324">#324</a>)</li>
<li>Bump the non-breaking-changes group with 2 updates <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/318">#318</a>)</li>
<li>Bump the non-breaking-changes group with 1 update <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/316">#316</a>)</li>
<li>Bump the non-breaking-changes group with 3 updates <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/314">#314</a>)</li>
<li>Bump release-drafter/release-drafter from 5.25.0 to 6.0.0 <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/311">#311</a>)</li>
</ul>
<hr />
<p>See details of <a
href="https://github.com/actions/deploy-pages/compare/v4.0.4...v4.0.5">all
code changes</a> since previous release.</p>
<p>⚠️ For use with products other than GitHub.com, such as GitHub
Enterprise Server, please consult the <a
href="https://github.com/actions/deploy-pages/#compatibility">compatibility
table</a>.</p>
<h2>v4.0.4</h2>
<h1>Changelog</h1>
<ul>
<li>Update api-client.js <a
href="https://github.com/lmammino"><code>@​lmammino</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/295">#295</a>)</li>
<li>fix typo: compatibilty -&gt; compatibility <a
href="https://github.com/SimonSiefke"><code>@​SimonSiefke</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/298">#298</a>)</li>
<li>Bump <code>@​actions/artifact</code> from 2.0.1 to 2.1.1 <a
href="https://github.com/dependabot"><code>@​dependabot</code></a> (<a
href="https://redirect.github.com/actions/deploy-pages/issues/310">#310</a>)</li>
<li>Update Dependabot config to group non-breaking changes <a
href="https://github.com/JamesMGreene"><code>@​JamesMGreene</code></a>
(<a
href="https://redirect.github.com/actions/deploy-pages/issues/307">#307</a>)</li>
</ul>
<hr />
<p>See details of <a
href="https://github.com/actions/deploy-pages/compare/v4.0.3...v4.0.4">all
code changes</a> since previous release.</p>
<p>⚠️ For use with products other than GitHub.com, such as GitHub
Enterprise Server, please consult the <a
href="https://github.com/actions/deploy-pages/#compatibility">compatibility
table</a>.</p>
<h2>v4.0.3</h2>
<h1>Changelog</h1>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/actions/deploy-pages/commit/cd2ce8fcbc39b97be8ca5fce6e763baed58fa128"><code>cd2ce8f</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/deploy-pages/issues/404">#404</a>
from salmanmkc/node24</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/bbe2a950ee52d4f5cbe74e6d9d6a8803676e91d5"><code>bbe2a95</code></a>
Update Node.js version to 24.x</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/854d7aa1b99e4509c4d1b53d69b7ba4eaf39215a"><code>854d7aa</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/deploy-pages/issues/374">#374</a>
from actions/Jcambass-patch-1</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/306bb814f29679fd12f0e4b0014bc1f3a7e7f4bc"><code>306bb81</code></a>
Add workflow file for publishing releases to immutable action
package</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/b74272834adc04f971da4b0b055c49fa8d7f90c9"><code>b742728</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/deploy-pages/issues/360">#360</a>
from actions/dependabot/npm_and_yarn/npm_and_yarn-513...</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/72732942c639e67ea3f70165fd2e012dd6d95027"><code>7273294</code></a>
Bump braces in the npm_and_yarn group across 1 directory</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/963791f01c40ef3eff219c255dbfb97a6f2c9f87"><code>963791f</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/deploy-pages/issues/361">#361</a>
from actions/dependabot-friendly</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/51bb29d9d7bfe15d731c4957ce1887b5ae8c6727"><code>51bb29d</code></a>
Make the rebuild dist workflow safer for Dependabot</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/89f3d10406f57ee86e6517a982b3fb0438bd6dc5"><code>89f3d10</code></a>
Merge pull request <a
href="https://redirect.github.com/actions/deploy-pages/issues/358">#358</a>
from actions/dependabot/npm_and_yarn/non-breaking-cha...</li>
<li><a
href="https://github.com/actions/deploy-pages/commit/bce735589bbbfa569f1d2ac003277b590d743e4c"><code>bce7355</code></a>
Merge branch 'main' into
dependabot/npm_and_yarn/non-breaking-changes-99c12deb21</li>
<li>Additional commits viewable in <a
href="https://github.com/actions/deploy-pages/compare/v4...v5">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/deploy-pages&package-manager=github_actions&previous-version=4&new-version=5)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 08:46:51 +01:00
dependabot[bot]andlnx01 7a09a2ddc0 deps(deps): bump golang.org/x/image from 0.37.0 to 0.38.0 in the golang group (#130)
Bumps the golang group with 1 update:
[golang.org/x/image](https://github.com/golang/image).

Updates `golang.org/x/image` from 0.37.0 to 0.38.0
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/golang/image/commit/23ae9ed61c1d3343fb95015810f62dcbf444976e"><code>23ae9ed</code></a>
tiff: cap buffer growth to prevent OOM from malicious IFD offset</li>
<li><a
href="https://github.com/golang/image/commit/e589e60f29d0bbbf6400e250e024f93cbc4961ee"><code>e589e60</code></a>
webp: allow VP8L + VP8X(with alpha)</li>
<li>See full diff in <a
href="https://github.com/golang/image/compare/v0.37.0...v0.38.0">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=golang.org/x/image&package-manager=go_modules&previous-version=0.37.0&new-version=0.38.0)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 08:46:41 +01:00
Tobias Gesellchen b04b0bcc32 Add account registration/login (#129) 2026-03-27 08:37:50 +01:00
Tobias GesellchenandJunie 61b5c71097 Enhance account overview UI and make fields editable (#127)
- Added detailed provider settings display to account overview
- Made 'Language' field editable with auto-save functionality (currently
only `en` and `de` available without actual effect on any UI or speaker
config)
- Made 'SPOTIFY - STREAMING_QUALITY' editable with descriptive quality
options
- ⚠️ this currently only writes the account config, but does not update
the actual speaker setting
- Improved account data persistence and error handling
- Added tests for new management API endpoints and data store changes

---------

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 11:42:58 +01:00
Tobias GesellchenandJunie 9f7cb81b45 Implement skip mirror endpoints to reduce false positives in parity checks (#126)
Added 'Skip Mirror Endpoints' setting to allow specific requests like
`/oauth/device/*/music/musicprovider/15/token/cs3` to be handled
exclusively locally, even when mirroring is enabled. Updated
MirrorMiddleware to check against the skip list before performing
mirroring or parity logic. Exposed the setting via the Web UI Settings
tab and the CLI. Updated relevant tests to accommodate the configuration
changes.

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 10:53:36 +01:00
Tobias GesellchenandJunie d5d6585517 Refactor hardcoded source provider IDs to use lookup from constants (#125)
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 10:25:06 +01:00
Tobias GesellchenandJunie 50b694aa08 Fix generic source names in Local Account UI by falling back to account name (#124)
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 09:48:07 +01:00
Tobias GesellchenandJunie 717693e01f feat(sync): improve parity with upstream during data sync (#123)
- Enhance initial and full data synchronization to better align with
upstream services.
- Update data structures in 'pkg/models' to support missing fields
(e.g., SecretType for Spotify).
- Improve 'datastore' persistence logic for presets, recents, and
sources.
- Add comprehensive regression tests for sync and datastore operations.
- Update documentation on parity status and improvements.

Co-authored-by: Junie <junie@jetbrains.com>

Co-authored-by: Junie <junie@jetbrains.com>
2026-03-22 00:01:41 +01:00
Tobias Gesellchen a0833c113c Add favicon-gen tool to generate PNG and ICO favicons from SVG sources (#22) 2026-03-21 13:18:16 +01:00
Tobias GesellchenandJunie e74d2e0fc3 refactor(web): reorganize media assets and add logo to web UI
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-21 13:08:27 +01:00
Tobias GesellchenandJunie 5b642010d4 fix(bmx): use official Bose URL in registry when DNS is enabled
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-21 13:08:27 +01:00
Tobias GesellchenandJunie 5078d933d5 fix(mirror): prevent infinite loop in MirrorMiddleware
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-21 13:08:27 +01:00
Tobias GesellchenandJunie 6cf511e7e5 Implement local Bose Spotify OAuth token handling and fix linting issues in tests (#119)
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-18 23:22:32 +01:00
Tobias Gesellchenandlnx01 ba11394d0f Potential fix for code scanning alert no. 80: Uncontrolled data used in path expression
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-17 22:59:20 +01:00
Tobias GesellchenandJunie 37eb23fc36 Merge existing device info in SaveDeviceInfo to preserve name on power-on
Co-authored-by: Junie <junie@jetbrains.com>
2026-03-17 22:59:20 +01:00
dependabot[bot]andlnx01 4544486221 ci(deps): bump docker/build-push-action from 6 to 7 (#117)
Bumps
[docker/build-push-action](https://github.com/docker/build-push-action)
from 6 to 7.
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/docker/build-push-action/releases">docker/build-push-action's
releases</a>.</em></p>
<blockquote>
<h2>v7.0.0</h2>
<ul>
<li>Node 24 as default runtime (requires <a
href="https://github.com/actions/runner/releases/tag/v2.327.1">Actions
Runner v2.327.1</a> or later) by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1470">docker/build-push-action#1470</a></li>
<li>Remove deprecated <code>DOCKER_BUILD_NO_SUMMARY</code> and
<code>DOCKER_BUILD_EXPORT_RETENTION_DAYS</code> envs by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1473">docker/build-push-action#1473</a></li>
<li>Remove legacy export-build tool support for build summary by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1474">docker/build-push-action#1474</a></li>
<li>Switch to ESM and update config/test wiring by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1466">docker/build-push-action#1466</a></li>
<li>Bump <code>@​actions/core</code> from 1.11.1 to 3.0.0 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1454">docker/build-push-action#1454</a></li>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.62.1 to 0.79.0 in
<a
href="https://redirect.github.com/docker/build-push-action/pull/1453">docker/build-push-action#1453</a>
<a
href="https://redirect.github.com/docker/build-push-action/pull/1472">docker/build-push-action#1472</a>
<a
href="https://redirect.github.com/docker/build-push-action/pull/1479">docker/build-push-action#1479</a></li>
<li>Bump minimatch from 3.1.2 to 3.1.5 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1463">docker/build-push-action#1463</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v6.19.2...v7.0.0">https://github.com/docker/build-push-action/compare/v6.19.2...v7.0.0</a></p>
<h2>v6.19.2</h2>
<ul>
<li>Preserve port in <code>GIT_AUTH_TOKEN</code> host by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1458">docker/build-push-action#1458</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v6.19.1...v6.19.2">https://github.com/docker/build-push-action/compare/v6.19.1...v6.19.2</a></p>
<h2>v6.19.1</h2>
<ul>
<li>Derive <code>GIT_AUTH_TOKEN</code> host from GitHub server URL by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1456">docker/build-push-action#1456</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v6.19.0...v6.19.1">https://github.com/docker/build-push-action/compare/v6.19.0...v6.19.1</a></p>
<h2>v6.19.0</h2>
<ul>
<li>Scope default git auth token to <code>github.com</code> by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1451">docker/build-push-action#1451</a></li>
<li>Bump brace-expansion from 1.1.11 to 1.1.12 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1396">docker/build-push-action#1396</a></li>
<li>Bump form-data from 2.5.1 to 2.5.5 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1391">docker/build-push-action#1391</a></li>
<li>Bump js-yaml from 3.14.1 to 3.14.2 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1429">docker/build-push-action#1429</a></li>
<li>Bump lodash from 4.17.21 to 4.17.23 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1446">docker/build-push-action#1446</a></li>
<li>Bump tmp from 0.2.3 to 0.2.4 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1398">docker/build-push-action#1398</a></li>
<li>Bump undici from 5.28.4 to 5.29.0 in <a
href="https://redirect.github.com/docker/build-push-action/pull/1397">docker/build-push-action#1397</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v6.18.0...v6.19.0">https://github.com/docker/build-push-action/compare/v6.18.0...v6.19.0</a></p>
<h2>v6.18.0</h2>
<ul>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.61.0 to 0.62.1 in
<a
href="https://redirect.github.com/docker/build-push-action/pull/1381">docker/build-push-action#1381</a></li>
</ul>
<blockquote>
<p>[!NOTE]
<a
href="https://docs.docker.com/build/ci/github-actions/build-summary/">Build
summary</a> is now supported with <a
href="https://docs.docker.com/build-cloud/">Docker Build Cloud</a>.</p>
</blockquote>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v6.17.0...v6.18.0">https://github.com/docker/build-push-action/compare/v6.17.0...v6.18.0</a></p>
<h2>v6.17.0</h2>
<ul>
<li>Bump <code>@​docker/actions-toolkit</code> from 0.59.0 to 0.61.0 by
<a href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in
<a
href="https://redirect.github.com/docker/build-push-action/pull/1364">docker/build-push-action#1364</a></li>
</ul>
<blockquote>
<p>[!NOTE]
Build record is now exported using the <a
href="https://docs.docker.com/reference/cli/docker/buildx/history/export/"><code>buildx
history export</code></a> command instead of the legacy export-build
tool.</p>
</blockquote>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/docker/build-push-action/compare/v6.16.0...v6.17.0">https://github.com/docker/build-push-action/compare/v6.16.0...v6.17.0</a></p>
<h2>v6.16.0</h2>
<ul>
<li>Handle no default attestations env var by <a
href="https://github.com/crazy-max"><code>@​crazy-max</code></a> in <a
href="https://redirect.github.com/docker/build-push-action/pull/1343">docker/build-push-action#1343</a></li>
</ul>
<!-- raw HTML omitted -->
</blockquote>
<p>... (truncated)</p>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/docker/build-push-action/commit/d08e5c354a6adb9ed34480a06d141179aa583294"><code>d08e5c3</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1479">#1479</a>
from docker/dependabot/npm_and_yarn/docker/actions-t...</li>
<li><a
href="https://github.com/docker/build-push-action/commit/cbd2dff9a0f0ef650dcce9c635bb2f877ab37be5"><code>cbd2dff</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/build-push-action/commit/f76f51f12900bb84aa9d1a498f35870ef1f76675"><code>f76f51f</code></a>
chore(deps): Bump <code>@​docker/actions-toolkit</code> from 0.78.0 to
0.79.0</li>
<li><a
href="https://github.com/docker/build-push-action/commit/7d03e66b5f24d6b390ab64b132795fd3ef4152c8"><code>7d03e66</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1473">#1473</a>
from crazy-max/rm-deprecated-envs</li>
<li><a
href="https://github.com/docker/build-push-action/commit/98f853d923dd281a3bcbbb98a0712a91aa913322"><code>98f853d</code></a>
chore: update generated content</li>
<li><a
href="https://github.com/docker/build-push-action/commit/cadccf6e8c7385c86d9cb0800cf07672645cc238"><code>cadccf6</code></a>
remove deprecated envs</li>
<li><a
href="https://github.com/docker/build-push-action/commit/03fe8775e325e34fffbda44c73316f8287aea372"><code>03fe877</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1478">#1478</a>
from docker/dependabot/github_actions/docker/setup-b...</li>
<li><a
href="https://github.com/docker/build-push-action/commit/827e36650e1fa7386d09422b5ba3c068fdbe0a1d"><code>827e366</code></a>
chore(deps): Bump docker/setup-buildx-action from 3 to 4</li>
<li><a
href="https://github.com/docker/build-push-action/commit/e25db879d025485a4eebd64fea9bb88a43632da6"><code>e25db87</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1474">#1474</a>
from crazy-max/rm-export-build-tool</li>
<li><a
href="https://github.com/docker/build-push-action/commit/1ac2573b5c8b4e4621d5453ab2a99e83725242bd"><code>1ac2573</code></a>
Merge pull request <a
href="https://redirect.github.com/docker/build-push-action/issues/1470">#1470</a>
from crazy-max/node24</li>
<li>Additional commits viewable in <a
href="https://github.com/docker/build-push-action/compare/v6...v7">compare
view</a></li>
</ul>
</details>
<br />


[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=docker/build-push-action&package-manager=github_actions&previous-version=6&new-version=7)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-17 11:14:03 +01:00
Tobias Gesellchen 17bd3ea9ed Fix: encode IP addresses as raw octets in Subject Alternative Names (#116)
- Update `GenerateCertificate` to correctly identify IP addresses and
add them to `IPAddresses` instead of `DNSNames`.
- Update `GetServerTLSConfig` to verify both `DNSNames` and
`IPAddresses` when checking certificate validity.
- Add `TestCertificateManagerIPAddress` to `certmanager_test.go` to
ensure correct encoding and prevent regressions.
- Ensure compliance with RFC 5280 by using binary encoding for IP
addresses in certificates.
2026-03-17 09:33:51 +01:00
Tobias Gesellchen ad5344b309 Do not use /bmx for our custom endpoint (#115)
Follow-up for https://github.com/gesellix/Bose-SoundTouch/pull/114
2026-03-16 23:36:36 +01:00
Tobias Gesellchen 8d95e170f6 Add a custom-radio url stream source (#114)
Based on the descriptions at

- https://gist.github.com/rody64/98a59990ff60ea962cac72cbe93edf56
-
https://github.com/thlucas1/homeassistantcomponent_soundtouchplus/discussions/37

Example usage:

```
go run ./cmd/soundtouch-cli --host 192.... source custom-radio --url https://stream.antenne.de/chillout/stream/aacp --service-url http://soundtouch.local:8000
Selecting custom radio stream from 192....:8090...
  URL: https://stream.antenne.de/chillout/stream/aacp
  Proxy: http://soundtouch.local:8000/bmx/custom/v1/playback/aHR0cHM6Ly9zdHJlYW0uYW50ZW5uZS5kZS9jaGlsbG91dC9zdHJlYW0vYWFjcA==
✓ Custom radio stream selected
```

Relates to https://github.com/gesellix/Bose-SoundTouch/issues/94
2026-03-16 23:17:50 +01:00
573 changed files with 9776 additions and 2839 deletions
+8 -2
View File
@@ -43,8 +43,14 @@ jobs:
- name: Run tests
run: go test -v -race -coverprofile=coverage.out ./...
- name: Build service
run: make build-service
- name: Run HTTP client integration tests
run: make test-http-client
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
uses: codecov/codecov-action@v6
with:
file: ./coverage.out
flags: unittests
@@ -276,7 +282,7 @@ jobs:
type=ref,event=pr
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
+1 -1
View File
@@ -34,4 +34,4 @@ jobs:
path: '_site'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
uses: actions/deploy-pages@v5
+1 -1
View File
@@ -512,7 +512,7 @@ jobs:
type=raw,value=latest,enable=${{ needs.validate.outputs.is_prerelease == 'false' }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm64/v8,linux/arm/v7
+40 -2
View File
@@ -20,6 +20,8 @@ EXAMPLE_UPNP_NAME=example-upnp
EXAMPLE_UPNP_PATH=./cmd/$(EXAMPLE_UPNP_NAME)
SCANNER_NAME=mdns-scanner
SCANNER_PATH=./cmd/$(SCANNER_NAME)
FAVICON_GEN_NAME=favicon-gen
FAVICON_GEN_PATH=./cmd/$(FAVICON_GEN_NAME)
BUILD_DIR=./build
# Version info
@@ -27,7 +29,7 @@ BUILD_DIR=./build
all: check build
build: build-cli build-service build-examples
build: build-cli build-service build-examples build-favicon-gen
build-cli:
@echo "Building $(BINARY_NAME)..."
@@ -48,6 +50,11 @@ build-examples:
@echo "Building $(SCANNER_NAME)..."
$(GOBUILD) -o $(BUILD_DIR)/$(SCANNER_NAME) $(SCANNER_PATH)
build-favicon-gen:
@echo "Building $(FAVICON_GEN_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) -o $(BUILD_DIR)/$(FAVICON_GEN_NAME) $(FAVICON_GEN_PATH)
build-all: build-linux build-darwin build-windows build-examples-all
build-linux:
@@ -96,7 +103,37 @@ test-coverage:
$(GOCMD) tool cover -html=coverage.out -o coverage.html
@echo "Coverage report generated: coverage.html"
check: fmt vet test
check: fmt vet test test-http-client
test-http-client:
@echo "Running HTTP client integration tests..."
@docker network create soundtouch-test-net || true
@docker build -t soundtouch-service-test .
@docker run -d --name soundtouch-service --network soundtouch-test-net \
-e PORT=8000 \
soundtouch-service-test
@echo "Waiting for service to start..."
@sleep 5
@docker run --rm --network soundtouch-test-net \
-v $(PWD)/tests/integration/http-client:/workdir \
jetbrains/intellij-http-client:2026.1 \
--env-file /workdir/http-client.env.json \
--env ci \
/workdir/create_account.http \
/workdir/register_device.http \
/workdir/power_on.http \
/workdir/get_provider_settings.http \
/workdir/get_full_account.http \
/workdir/get_group.http \
/workdir/unregister_device.http \
--report; \
EXIT_CODE=$$?; \
docker logs soundtouch-service; \
docker stop soundtouch-service; \
docker rm soundtouch-service; \
docker rmi soundtouch-service-test; \
docker network rm soundtouch-test-net; \
exit $$EXIT_CODE
fmt:
@echo "Formatting code..."
@@ -226,6 +263,7 @@ help:
@echo " build - Build the CLI tool, service, and examples"
@echo " build-cli - Build only the CLI tool"
@echo " build-service - Build only the service"
@echo " build-favicon-gen - Build the favicon generator"
@echo " build-examples - Build only the example programs"
@echo " build-all - Build for all platforms"
@echo " test - Run tests"
+30 -29
View File
@@ -17,6 +17,7 @@ A comprehensive solution for controlling and preserving Bose SoundTouch devices,
-**Real-time Events**: WebSocket connection for live device state monitoring
- 🔍 **Device Discovery**: Automatic discovery via UPnP/SSDP and mDNS
- 📻 **Content Navigation**: Browse and search TuneIn, Pandora, Spotify, local music
- 📻 **Custom Radio**: Play any stream URL via [flexible proxying](docs/guides/CLI-REFERENCE.md#custom-radio-selection-via-soundtouch-service)
- 📻 **RadioBrowser**: Access thousands of internet radio stations via [radio-browser.info](docs/reference/radio-browser.md)
- 🎙️ **Station Management**: Add and play radio stations without presets
- 🖥️ **CLI Tool**: Comprehensive command-line interface
@@ -106,7 +107,7 @@ package main
import (
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
)
@@ -116,20 +117,20 @@ func main() {
Host: "192.168.1.100",
Port: 8090,
})
// Get device information
info, err := c.GetDeviceInfo()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Device: %s\n", info.Name)
// Control playback
err = c.Play()
if err != nil {
log.Fatal(err)
}
// Set volume
err = c.SetVolume(50)
if err != nil {
@@ -147,7 +148,7 @@ import (
"fmt"
"log"
"time"
"github.com/gesellix/bose-soundtouch/pkg/discovery"
)
@@ -158,9 +159,9 @@ func main() {
if err != nil {
log.Fatal(err)
}
for _, device := range devices {
fmt.Printf("Found: %s at %s:%d\n",
fmt.Printf("Found: %s at %s:%d\n",
device.Name, device.Host, device.Port)
}
}
@@ -174,7 +175,7 @@ import (
"context"
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
@@ -184,13 +185,13 @@ func main() {
Host: "192.168.1.100",
Port: 8090,
})
// Subscribe to device events
events, err := c.SubscribeToEvents(context.Background())
if err != nil {
log.Fatal(err)
}
for event := range events {
switch e := event.(type) {
case *models.NowPlayingUpdated:
@@ -211,7 +212,7 @@ package main
import (
"fmt"
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
@@ -221,21 +222,21 @@ func main() {
Host: "192.168.1.100",
Port: 8090,
})
// Get current presets
presets, err := c.GetPresets()
if err != nil {
log.Fatal(err)
}
fmt.Printf("Found %d presets\n", len(presets.Preset))
// Store currently playing content as preset 1
err = c.StoreCurrentAsPreset(1)
if err != nil {
log.Fatal(err)
}
// Store Spotify playlist as preset 2
spotifyContent := &models.ContentItem{
Source: "SPOTIFY",
@@ -249,7 +250,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
// Store radio station as preset 3
radioContent := &models.ContentItem{
Source: "TUNEIN",
@@ -262,13 +263,13 @@ func main() {
if err != nil {
log.Fatal(err)
}
// Select preset 1
err = c.SelectPreset(1)
if err != nil {
log.Fatal(err)
}
fmt.Println("Preset management complete!")
}
```
@@ -279,7 +280,7 @@ package main
import (
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
"github.com/gesellix/bose-soundtouch/pkg/models"
)
@@ -289,7 +290,7 @@ func main() {
Host: "192.168.1.100", // Master speaker
Port: 8090,
})
// Create a multiroom zone
zone := &models.Zone{
Master: "192.168.1.100",
@@ -298,12 +299,12 @@ func main() {
{IPAddress: "192.168.1.102"}, // Kitchen
},
}
err := master.SetZone(zone)
if err != nil {
log.Fatal(err)
}
fmt.Println("Multiroom zone created!")
}
```
@@ -314,7 +315,7 @@ package main
import (
"log"
"github.com/gesellix/bose-soundtouch/pkg/client"
)
@@ -323,13 +324,13 @@ func main() {
Host: "192.168.1.100",
Port: 8090,
})
// Play Text-to-Speech message (language code "EN", "DE", etc.)
err := c.PlayTTS("Welcome home!", "your-app-key", "EN", 70)
if err != nil {
log.Fatal(err)
}
// Play audio content from URL
err = c.PlayURL(
"https://example.com/doorbell.mp3",
@@ -342,13 +343,13 @@ func main() {
if err != nil {
log.Fatal(err)
}
// Play notification beep
err = c.PlayNotificationBeep()
if err != nil {
log.Fatal(err)
}
fmt.Println("Notifications sent!")
}
```
@@ -358,7 +359,7 @@ func main() {
This library supports all Bose SoundTouch-compatible devices, including:
- SoundTouch 10, 20, 30 series
- SoundTouch Portable
- SoundTouch Portable
- Wave SoundTouch music system
- SoundTouch-enabled Bose speakers
@@ -519,7 +520,7 @@ This project builds upon the excellent work of several community projects:
These projects together form a comprehensive ecosystem for SoundTouch device management:
- **This Project**: Go library + CLI + service for programmatic control and offline operation
- **SoundCork**: Python-based service interception and cloud replacement
- **SoundCork**: Python-based service interception and cloud replacement
- **SoundTouch Plus**: Home Assistant integration with extensive device support
- **ÜberBöse**: API research and advanced endpoint discovery
- **SoundTouch Hook**: Advanced reverse engineering and process instrumentation
+152
View File
@@ -0,0 +1,152 @@
// Package main provides a utility to generate PNG and ICO favicons from SVG source files.
package main
import (
"bufio"
"bytes"
"encoding/binary"
"fmt"
"image"
"image/png"
"log"
"os"
"path/filepath"
"github.com/srwiley/oksvg"
"github.com/srwiley/rasterx"
)
func main() {
mediaDir := "pkg/service/handlers/web/img"
files := []string{"favicon-braille", "favicon-morse"}
for _, name := range files {
svgPath := filepath.Join(mediaDir, name+".svg")
pngPath := filepath.Join(mediaDir, name+".png")
icoPath := filepath.Join(mediaDir, name+".ico")
fmt.Printf("Processing %s...\n", name)
// 1. Render SVG to PNG
img, err := renderSVG(svgPath, 32, 32)
if err != nil {
log.Fatalf("Failed to render %s: %v", svgPath, err)
}
f, err := os.Create(pngPath)
if err != nil {
log.Fatalf("Failed to create %s: %v", pngPath, err)
}
if err := png.Encode(f, img); err != nil {
f.Close()
log.Fatalf("Failed to encode PNG %s: %v", pngPath, err)
}
f.Close()
fmt.Printf("Created %s\n", pngPath)
// 2. Create ICO (containing multiple sizes)
sizes := []int{16, 32, 48}
var images []image.Image
for _, s := range sizes {
m, err := renderSVG(svgPath, s, s)
if err != nil {
log.Fatalf("Failed to render %s at size %d: %v", svgPath, s, err)
}
images = append(images, m)
}
if err := writeICO(icoPath, images); err != nil {
log.Fatalf("Failed to write ICO %s: %v", icoPath, err)
}
fmt.Printf("Created %s\n", icoPath)
}
}
func renderSVG(path string, w, h int) (image.Image, error) {
in, err := os.Open(path)
if err != nil {
return nil, err
}
defer in.Close()
icon, err := oksvg.ReadIconStream(in)
if err != nil {
return nil, err
}
icon.SetTarget(0, 0, float64(w), float64(h))
rgba := image.NewRGBA(image.Rect(0, 0, w, h))
gv := rasterx.NewScannerGV(w, h, rgba, rgba.Bounds())
dasher := rasterx.NewDasher(w, h, gv)
icon.Draw(dasher, 1.0)
return rgba, nil
}
// Simple ICO encoder that wraps PNGs
func writeICO(path string, images []image.Image) error {
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
bw := bufio.NewWriter(f)
defer bw.Flush()
// ICONDIR header
// Reserved (2), Type (2), Count (2)
binary.Write(bw, binary.LittleEndian, uint16(0))
binary.Write(bw, binary.LittleEndian, uint16(1)) // 1 = ICO
binary.Write(bw, binary.LittleEndian, uint16(len(images)))
var pngData [][]byte
for _, img := range images {
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return err
}
pngData = append(pngData, buf.Bytes())
}
offset := uint32(6 + len(images)*16)
for i, img := range images {
b := img.Bounds()
width := uint8(b.Dx())
if b.Dx() >= 256 {
width = 0
}
height := uint8(b.Dy())
if b.Dy() >= 256 {
height = 0
}
// ICONDIRENTRY
bw.WriteByte(width)
bw.WriteByte(height)
bw.WriteByte(0) // Color count
bw.WriteByte(0) // Reserved
binary.Write(bw, binary.LittleEndian, uint16(1)) // Planes (1)
binary.Write(bw, binary.LittleEndian, uint16(32)) // Bits per pixel (32)
binary.Write(bw, binary.LittleEndian, uint32(len(pngData[i])))
binary.Write(bw, binary.LittleEndian, offset)
offset += uint32(len(pngData[i]))
}
for _, data := range pngData {
bw.Write(data)
}
return nil
}
+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

+67
View File
@@ -652,6 +652,73 @@ func listMusicServiceAccounts(c *cli.Context) error {
return nil
}
// pairDevice triggers the Stockholm registration flow via WebSocket
func pairDevice(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
accountID := c.String("id")
token := c.String("token")
PrintDeviceHeader("Pairing device with Marge account", clientConfig.Host, clientConfig.Port)
fmt.Printf(" Account ID: %s\n", accountID)
// We need a WebSocket client for this
ws := client.NewWebSocketClient(nil)
err = ws.Connect()
if err != nil {
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
}
defer func() { _ = ws.Disconnect() }()
err = ws.PairWithAccount(accountID, token)
if err != nil {
return fmt.Errorf("failed to send pairing request: %w", err)
}
PrintSuccess("Pairing request sent successfully")
fmt.Println("💡 The device will now register itself with the cloud service.")
return nil
}
// unpairDevice triggers the Stockholm unregistration flow via WebSocket
func unpairDevice(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
PrintDeviceHeader("Unpairing device from Marge account", clientConfig.Host, clientConfig.Port)
// We need a WebSocket client for this
ws := client.NewWebSocketClient(nil)
err = ws.Connect()
if err != nil {
return fmt.Errorf("failed to connect to device WebSocket: %w", err)
}
defer func() { _ = ws.Disconnect() }()
err = ws.UnPairFromAccount()
if err != nil {
return fmt.Errorf("failed to send unpairing request: %w", err)
}
PrintSuccess("Unpairing request sent successfully")
return nil
}
// getServiceDisplayName returns a user-friendly display name for a service
func getServiceDisplayName(source string) string {
switch source {
+57
View File
@@ -1,7 +1,9 @@
package main
import (
"encoding/base64"
"fmt"
"net/url"
"strings"
"github.com/gesellix/bose-soundtouch/pkg/models"
@@ -251,6 +253,61 @@ func selectLocalInternetRadio(c *cli.Context) error {
return nil
}
// selectCustomRadio handles selecting custom radio stream via soundtouch-service
func selectCustomRadio(c *cli.Context) error {
clientConfig := GetClientConfig(c)
client, err := CreateSoundTouchClient(clientConfig)
if err != nil {
return err
}
streamURL := c.String("url")
itemName := c.String("name")
containerArt := c.String("artwork")
serviceURL := c.String("service-url")
encodedURL := base64.URLEncoding.EncodeToString([]byte(streamURL))
location := fmt.Sprintf("%s/custom/v1/playback/%s", serviceURL, encodedURL)
params := url.Values{}
if itemName != "" {
params.Add("name", itemName)
}
if containerArt != "" {
params.Add("imageUrl", containerArt)
}
if len(params) > 0 {
location += "?" + params.Encode()
}
// Check LOCAL_INTERNET_RADIO availability
checker := NewServiceAvailabilityChecker(client)
if !checker.CheckSourceAvailable("LOCAL_INTERNET_RADIO", "select custom radio") {
return fmt.Errorf("LOCAL_INTERNET_RADIO is not available")
}
PrintDeviceHeader("Selecting custom radio stream", clientConfig.Host, clientConfig.Port)
if itemName != "" {
fmt.Printf(" Station: %s\n", itemName)
}
fmt.Printf(" URL: %s\n", streamURL)
fmt.Printf(" Proxy: %s\n", location)
err = client.SelectLocalInternetRadio(location, "", itemName, containerArt)
if err != nil {
return fmt.Errorf("failed to select custom radio: %w", err)
}
PrintSuccess("Custom radio stream selected")
return nil
}
// selectLocalMusic handles selecting LOCAL_MUSIC source
func selectLocalMusic(c *cli.Context) error {
clientConfig := GetClientConfig(c)
+2 -2
View File
@@ -196,7 +196,7 @@ var httpClient = &http.Client{
}
func fetchTuneInMetadata(url string) (*Metadata, error) {
if !strings.Contains(url, "tunein.com/radio/") {
if !strings.Contains(url, "tunein.com/radio/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
return nil, fmt.Errorf("url is not a TuneIn radio URL")
}
@@ -256,7 +256,7 @@ func fetchTuneInMetadata(url string) (*Metadata, error) {
}
func fetchSpotifyMetadata(url string) (*Metadata, error) {
if !strings.Contains(url, "open.spotify.com/") {
if !strings.Contains(url, "open.spotify.com/") && !strings.Contains(url, "127.0.0.1") && !strings.Contains(url, "localhost") {
return nil, fmt.Errorf("url is not a Spotify URL")
}
+4 -4
View File
@@ -7,7 +7,7 @@ import (
)
func TestFetchTuneInMetadata(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
html := `
<!doctype html>
<html>
@@ -30,7 +30,7 @@ func TestFetchTuneInMetadata(t *testing.T) {
defer func() { httpClient = oldClient }()
metadata, err := fetchTuneInMetadata("https://tunein.com/radio/WDR-2-Rheinland-1004-s213886/")
metadata, err := fetchTuneInMetadata(ts.URL + "/radio/WDR-2-Rheinland-1004-s213886/")
if err != nil {
t.Fatalf("fetchTuneInMetadata() error = %v", err)
}
@@ -162,7 +162,7 @@ func TestResolveLocationSpotify(t *testing.T) {
}
func TestFetchSpotifyMetadata(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
html := `
<!doctype html>
<html>
@@ -185,7 +185,7 @@ func TestFetchSpotifyMetadata(t *testing.T) {
defer func() { httpClient = oldClient }()
metadata, err := fetchSpotifyMetadata("https://open.spotify.com/album/7F50uh7oGitmAEScRKV6pD")
metadata, err := fetchSpotifyMetadata(ts.URL + "/album/7F50uh7oGitmAEScRKV6pD")
if err != nil {
t.Fatalf("fetchSpotifyMetadata() error = %v", err)
}
+52
View File
@@ -924,6 +924,34 @@ func main() {
},
},
},
{
Name: "custom-radio",
Usage: "Select custom radio stream via soundtouch-service",
Action: selectCustomRadio,
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "url",
Aliases: []string{"u"},
Usage: "Stream URL",
Required: true,
},
&cli.StringFlag{
Name: "name",
Aliases: []string{"n"},
Usage: "Station name",
},
&cli.StringFlag{
Name: "artwork",
Usage: "Station artwork URL",
},
&cli.StringFlag{
Name: "service-url",
Usage: "URL of the soundtouch-service (default: http://localhost:8080)",
Value: "http://localhost:8080",
},
},
},
{
Name: "local-music",
Usage: "Select local music content (LOCAL_MUSIC)",
@@ -2010,6 +2038,30 @@ func main() {
},
},
},
{
Name: "pair",
Usage: "Pair the device with a Marge cloud account (Stockholm registration)",
Action: pairDevice,
Before: RequireHost,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "id",
Usage: "Marge account ID (e.g., 1234567)",
Required: true,
},
&cli.StringFlag{
Name: "token",
Usage: "User authorization token",
Required: true,
},
},
},
{
Name: "unpair",
Usage: "Unpair the device from its Marge cloud account",
Action: unpairDevice,
Before: RequireHost,
},
},
},
// Token commands
+49 -23
View File
@@ -189,6 +189,11 @@ func main() {
Usage: "Endpoints to mirror to Bose Cloud (comma-separated or multiple flags)",
EnvVars: []string{"MIRROR_ENDPOINTS"},
},
&cli.StringSliceFlag{
Name: "skip-mirror-endpoints",
Usage: "Endpoints to skip mirroring to Bose Cloud (comma-separated or multiple flags)",
EnvVars: []string{"SKIP_MIRROR_ENDPOINTS"},
},
&cli.StringSliceFlag{
Name: "internal-paths",
Usage: "Paths for internal requests (comma-separated or multiple flags)",
@@ -235,13 +240,13 @@ func main() {
sm := setup.NewManager(config.serverURL, ds, cm)
sm.MgmtUsername = config.mgmtUsername
sm.MgmtPassword = config.mgmtPassword
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record, config.migrationEnabled, config.migrationDryRun)
server := handlers.NewServer(ds, sm, config.serverURL, config.redact, config.logBody, config.record)
sm.GetDNSRunning = server.GetDNSRunning
server.SetHTTPServerURL(config.httpsServerURL)
server.SetVersionInfo(version, commit, date)
server.SetDiscoverySettings(config.discoveryInterval, persisted.DiscoveryEnabled)
server.SetDNSSettings(persisted.DNSEnabled, strings.Join(persisted.DNSUpstream, ","), persisted.DNSBindAddr)
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.PreferredSource)
server.SetMirrorSettings(persisted.MirrorEnabled, persisted.MirrorEndpoints, persisted.SkipMirrorEndpoints, persisted.PreferredSource)
server.SetInternalPaths(persisted.InternalPaths)
server.SetSpotifyConfig(config.spotifyClientID, config.spotifyClientSecret, config.spotifyRedirectURI)
server.SetMgmtConfig(config.mgmtUsername, config.mgmtPassword)
@@ -374,6 +379,7 @@ type serviceConfig struct {
dnsBind string
mirrorEnabled bool
mirrorEndpoints []string
skipMirrorEndpoints []string
internalPaths []string
discoveryInterval time.Duration
domains []string
@@ -448,6 +454,7 @@ func loadConfig(c *cli.Context) serviceConfig {
mgmtPassword := c.String("mgmt-password")
mirrorEnabled := c.Bool("mirror-enabled")
mirrorEndpoints := c.StringSlice("mirror-endpoints")
skipMirrorEndpoints := c.StringSlice("skip-mirror-endpoints")
internalPaths := c.StringSlice("internal-paths")
migrationEnabled := c.Bool("migration-enabled")
migrationDryRun := c.Bool("migration-dry-run")
@@ -469,6 +476,7 @@ func loadConfig(c *cli.Context) serviceConfig {
dnsBind: dnsBind,
mirrorEnabled: mirrorEnabled,
mirrorEndpoints: mirrorEndpoints,
skipMirrorEndpoints: skipMirrorEndpoints,
internalPaths: internalPaths,
discoveryInterval: discoveryInterval,
domains: domains,
@@ -565,6 +573,7 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
config.mirrorEnabled = persisted.MirrorEnabled
config.mirrorEndpoints = persisted.MirrorEndpoints
config.skipMirrorEndpoints = persisted.SkipMirrorEndpoints
config.preferredSource = persisted.PreferredSource
config.internalPaths = persisted.InternalPaths
@@ -573,20 +582,21 @@ func applyPersistedSettings(ds *datastore.DataStore, config *serviceConfig) data
func createDefaultSettings(ds *datastore.DataStore, config serviceConfig) datastore.Settings {
settings := datastore.Settings{
ServerURL: config.serverURL,
HTTPServerURL: config.httpsServerURL,
RedactLogs: config.redact,
LogBodies: config.logBody,
RecordInteractions: config.record,
DiscoveryInterval: config.discoveryInterval.String(),
DiscoveryEnabled: true,
DNSEnabled: config.dnsEnabled,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
MirrorEnabled: config.mirrorEnabled,
MirrorEndpoints: config.mirrorEndpoints,
PreferredSource: config.preferredSource,
InternalPaths: config.internalPaths,
ServerURL: config.serverURL,
HTTPServerURL: config.httpsServerURL,
RedactLogs: config.redact,
LogBodies: config.logBody,
RecordInteractions: config.record,
DiscoveryInterval: config.discoveryInterval.String(),
DiscoveryEnabled: true,
DNSEnabled: config.dnsEnabled,
DNSUpstream: strings.Split(config.dnsUpstream, ","),
DNSBindAddr: config.dnsBind,
MirrorEnabled: config.mirrorEnabled,
MirrorEndpoints: config.mirrorEndpoints,
SkipMirrorEndpoints: config.skipMirrorEndpoints,
PreferredSource: config.preferredSource,
InternalPaths: config.internalPaths,
Shortcuts: map[string]int{
"/.well-known/appspecific/com.chrome.devtools.json": http.StatusNotFound,
"/sw.js": http.StatusNotFound,
@@ -663,9 +673,12 @@ func setupRouter(server *handlers.Server) *chi.Mux {
r.Get("/tunein/v1/playback/episodes/{podcastID}", server.HandleTuneInPodcastInfo)
r.Get("/tunein/v1/playback/episode/{podcastID}", server.HandleTuneInPlaybackPodcast)
r.Post("/orion/v1/playback/station/{data}", server.HandleOrionPlayback)
r.Get("/custom/v1/playback/{encodedURL}", server.HandleCustomPlayback)
streamingRoutes := func(r chi.Router) {
r.Get("/sourceproviders", server.HandleMargeSourceProviders)
r.Post("/account", server.HandleMargeCreateAccount)
r.Post("/account/login", server.HandleMargeLogin)
r.Get("/account/{account}/device/{device}/recent", server.HandleMargeRecents)
r.Post("/account/{account}/device/{device}/recent", server.HandleMargeAddRecent)
r.Get("/account/{account}/device/{device}/presets", server.HandleMargePresets)
@@ -723,6 +736,8 @@ func setupRouter(server *handlers.Server) *chi.Mux {
})
r.Route("/oauth", func(r chi.Router) {
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token/cs3", server.HandleBoseToken)
r.Post("/device/{deviceID}/music/musicprovider/{sourceID}/token", server.HandleBoseLegacyToken)
r.HandleFunc("/*", server.HandleBoseProxy)
})
@@ -740,14 +755,25 @@ func setupRouter(server *handlers.Server) *chi.Mux {
// All other management endpoints require Basic Auth.
r.Group(func(r chi.Router) {
r.Use(server.BasicAuthMgmt())
r.Get("/accounts/{accountId}/speakers", server.HandleMgmtListSpeakers)
r.Route("/accounts", func(r chi.Router) {
r.Get("/", server.HandleMgmtListAccounts)
r.Get("/{accountId}", server.HandleMgmtAccountDetails)
r.Post("/{accountId}/language", server.HandleMgmtUpdateAccountLanguage)
r.Post("/{accountId}/provider-settings", server.HandleMgmtUpdateAccountProviderSetting)
r.Get("/{accountId}/speakers", server.HandleMgmtListSpeakers)
})
r.Route("/spotify", func(r chi.Router) {
r.Post("/init", server.HandleMgmtSpotifyInit)
r.Post("/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/token", server.HandleMgmtSpotifyToken)
r.Post("/entity", server.HandleMgmtSpotifyEntity)
r.Post("/prime", server.HandleMgmtPrimeDevice)
})
r.Get("/devices/{deviceId}/events", server.HandleMgmtDeviceEvents)
r.Post("/spotify/init", server.HandleMgmtSpotifyInit)
r.Post("/spotify/confirm", server.HandleMgmtSpotifyConfirm)
r.Get("/spotify/accounts", server.HandleMgmtSpotifyAccounts)
r.Get("/spotify/token", server.HandleMgmtSpotifyToken)
r.Post("/spotify/entity", server.HandleMgmtSpotifyEntity)
r.Post("/spotify/prime", server.HandleMgmtPrimeDevice)
})
})
+20 -3
View File
@@ -23,6 +23,14 @@ All content selection features from the [SoundTouch WebServices API Wiki](https:
- Automatic defaults for missing parameters
- **Use Cases**: Internet radio streams, proxy-based radio services
#### `SelectLocalInternetRadio(location, ...)` via `soundtouch-service`
- **Purpose**: Select custom radio stream via local `soundtouch-service` proxy
- **Features**:
- Flexible stream URL encoding (Base64 or URL-escaped)
- Dynamic generation of Bose-compatible playback JSON
- Seamless integration with existing `LOCAL_INTERNET_RADIO` source
- **Use Case**: Playing any internet radio URL without external proxy dependencies
#### `SelectLocalMusic(location, sourceAccount, itemName, containerArt string) error`
- **Purpose**: Select LOCAL_MUSIC content from SoundTouch App Media Server
- **Requirements**: SoundTouch App Media Server running on a computer
@@ -47,6 +55,15 @@ soundtouch-cli --host <device> source internet-radio \
--artwork "https://example.com/art.png"
```
#### `soundtouch-cli source custom-radio`
```bash
soundtouch-cli --host <device> source custom-radio \
--url "https://stream.example.com/radio" \
--name "My Station" \
--artwork "https://example.com/art.png" \
--service-url "http://localhost:8080"
```
#### `soundtouch-cli source local-music`
```bash
soundtouch-cli --host <device> source local-music \
@@ -101,7 +118,7 @@ Comprehensive test suites implemented for all new functionality:
### Example Code
Complete working example demonstrating:
- LOCAL_INTERNET_RADIO with streamUrl proxy format
- LOCAL_INTERNET_RADIO with direct streams
- LOCAL_INTERNET_RADIO with direct streams
- LOCAL_MUSIC content selection
- STORED_MUSIC content selection
- Generic ContentItem usage
@@ -152,7 +169,7 @@ All convenience methods create properly structured `ContentItem` objects:
Based on the wiki structure, these related features are also supported:
1. **LOCAL_MUSIC**: ✅ Fully implemented
2. **STORED_MUSIC**: ✅ Fully implemented
2. **STORED_MUSIC**: ✅ Fully implemented
3. **SPOTIFY**: ✅ Previously implemented
4. **TUNEIN**: ✅ Previously implemented
5. **BLUETOOTH**: ✅ Previously implemented
@@ -169,7 +186,7 @@ err := client.SelectLocalInternetRadio(location, "", "My Station", "")
// Direct ContentItem
contentItem := &models.ContentItem{
Source: "LOCAL_INTERNET_RADIO",
Type: "stationurl",
Type: "stationurl",
Location: location,
ItemName: "My Station",
IsPresetable: true,
+33 -7
View File
@@ -3,6 +3,21 @@
This document summarizes the improvements made to the **Marge service** to improve parity with the upstream Bose SoundTouch service, along with open issues and proposed next steps.
#### ✅ Completed Improvements (Marge Service)
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` or `ButtonNumber` to the `buttonNumber` XML attribute in the `/full` response and ensured it is persisted in the local datastore.
* **High-Fidelity Device Metadata**: Improved the datastore to correctly extract, persist, and report detailed device `<components>` (e.g., `LIGHTSWITCH`, `SMSC`) and their firmware versions from upstream responses.
* **Standardized Preferred Language**: Updated the default `preferredLanguage` to `de` in the `/full` response and added synchronization to persist it from upstream responses.
* **Persisted Provider Settings**: Added support for persisting and echoing back `providerSettings` (e.g., `STREAMING_QUALITY`, `ELIGIBLE_FOR_TRIAL`) from the `/full` response.
* **Populated `contentItemType`**: The `contentItemType` (e.g., `tracklisturl`) is now correctly synchronized from upstream, persisted in the local datastore, and returned in the `/full` response for both presets and recents.
* **Standardized Credential Types**: Adjusted the logic for Spotify to use the correct `token_version_3` type when a token is present in the `/full` response, improving parity with the upstream service. The service now respects existing `credential_type` values from `Sources.xml` (e.g., `token_version_3` for Spotify) while providing sensible defaults for new or incomplete sources.
* **Structured Sources (Sources.xml)**: Refactored `Sources.xml` to use an attribute-based structure (`sourceid`, `source`, `status`, `sourceAccount`, etc.) matching the real device's output. Removed redundant nested tags like `<sourcename>`, `<username>`, and `<name>`.
* **Nested Recents (Recents.xml)**: Implemented a nested `<contentItem>` structure within `<recent>` entries in `Recents.xml`, maintaining exact parity with the device's persistence format while supporting legacy flat formats for backward compatibility.
* **Inconsistent `serialNumber` Casing**: Fixed the casing mismatch in the `/full` response where the upstream uses camelCase `<serialNumber>` in the top-level `<device>` and lowercase `<serialnumber>` in the nested `<attachedProduct>`. Local responses now correctly mirror this inconsistency.
* **Attribute-level Parity**:
* Ensured `sourceAccount=""` is preserved in XML even when empty, matching device behavior for sources like TUNEIN.
* Fixed casing for attributes like `deviceID` and `utcTime` in `Recents.xml`.
* Correctly mapped and persisted preset and recent `id` attributes during "Initial Data Sync".
* **Device Name Consistency**: Fixed an issue where the device `<name>` was empty in some local `/full` responses by ensuring it is correctly populated from the datastore and synchronized from upstream.
* **Improved XML Parity**: Empty `<name>` tags in the `/full` response are now self-closing (`<name/>`), matching upstream behavior.
* **Timestamp-based ID Generation**: Implemented a 9-digit ID schema (`YYMMDD` + 3-digit counter) for `recent` items, ensuring IDs are large, unique, and stay within the 32-bit integer range.
* **Automatic Source Learning**: The service now extracts and persists full metadata (credentials, provider IDs, and custom names) from incoming `POST /recent` requests. This improves parity for subsequent `GET /recents` calls.
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
@@ -15,6 +30,7 @@ This document summarizes the improvements made to the **Marge service** to impro
* Implemented structured XML marshaling with consistent 2-space indentation for recents and source providers.
* **Improved TuneIn Parity**: Fixed TuneIn source mapping to use ID `25` and ensuring `sourcename` is empty in responses, matching upstream behavior for station playback.
* **High-Fidelity Full Account Sync**: Refactored the `/streaming/account/{accountId}/full` response to match the upstream structure. This includes:
* **Mapped Preset `buttonNumber`**: Correctly mapped the internal `ServicePreset.ID` to the `buttonNumber` XML attribute in the `/full` response.
* **Structured XML Marshaling**: Replaced manual string concatenation with structured Go models and `xml.Marshal` for the entire response.
* **Specific Response Models**: Introduced `FullResponseSource`, `FullResponsePreset`, and `FullResponseRecent` to accurately reflect the upstream structure where `<source>` is a child element, rather than a set of attributes.
* **Correct Nesting**: Ensured that `<presets>` and `<recents>` correctly nest their associated `<source>` details, resolving previous data omissions.
@@ -30,19 +46,21 @@ This document summarizes the improvements made to the **Marge service** to impro
#### 🛠️ Open Issues and Next Steps
Based on the latest `parity_mismatches`, here are the recommended areas for further work:
Based on the latest `parity_mismatches` and the high-fidelity `/full` account response comparison (diff14), here are the recommended areas for further work:
#### 1. BMX / TuneIn Playback Parity (Medium)
Current mismatches in `/bmx/tunein/v1/playback/station/...` show differences in reporting URLs and missing links:
* **Mismatched Parameters**: Local reporting URLs use `listen_id=3432432423`, while upstream uses a different session-based ID.
* **Mismatched Parameters**: Local reporting URLs use `listen_id=1234567890`, while upstream uses a different session-based ID.
* **Missing Links**: Some upstream responses include additional `_links` or metadata that are currently omitted in local responses.
* **Action**: Improve the `HandleTuneInPlayback` logic to better mirror the upstream response structure and parameter generation.
#### 2. Presets and Recents Parity (Medium)
Further align the standalone `GET /presets` and `GET /recents` endpoints with the refined structural improvements introduced for the `/full` account response:
* **Source Nesting**: Ensure the standalone responses also use the specialized nested `<source>` structure instead of mixed attributes when appropriate.
* **Field Completeness**: Verify all metadata fields (e.g., `<contentItemType>`, `<lastplayedat>`) are consistently populated across all access paths.
* **Action**: Evaluate if the specialized `FullResponsePreset` and `FullResponseRecent` models should be shared or mirrored in the standalone handlers.
#### 2. `/full` Account Response Data Gaps (Medium)
While structural parity for the `/full` response is high, several value-level gaps remain as shown in `diff14`:
* **Timestamp Formats**: Upstream uses ISO-8601 with milliseconds (e.g., `2024-06-23T07:40:36.000+00:00`), whereas some local fields still use Unix epoch integers (e.g., `1234567890`).
* **Provider Settings**: The `providerSettings` block in the local response currently lacks crucial values like `keyName`, `providerId`, and `boseId` (appearing as empty tags).
* **Component Metadata**: Local component types are sometimes empty (`type=""`) compared to upstream values like `LIGHTSWITCH` or `SMSC`.
* **Source/Preset Identifiers**: Local IDs (e.g., `100004`) differ from upstream IDs (e.g., `1234567`), though this may be expected due to different account/device environments.
* **Action**: Update the mapping logic in `marge.go` and `setup.go` to ensure all fields in the `/full` response are correctly populated with high-fidelity values and standard ISO-8601 timestamps.
#### 3. OAuth / Spotify Token Noise (Low/Medium)
The `/oauth/device/.../token` endpoint frequently reports mismatches because tokens are naturally different between local and upstream.
@@ -60,3 +78,11 @@ Continue the "learning" approach for other services. For example, if we see a ne
Analysis of device reboot logs revealed several data requirements:
* **Power-On Details Tracking**: Implemented extraction and persistence of detailed device information (serial numbers, firmware version, product details, and MAC addresses) from the `POST /streaming/support/power_on` request. This data is now stored in the local datastore, improving our ability to respond accurately to subsequent management requests.
* **Source Provider Mapping**: Synchronized local source provider IDs and timestamps with upstream data. The `RADIO_BROWSER` provider is included in the public `/streaming/sourceproviders` list to maintain internal functionality while acknowledging it as a parity gap.
#### 7. Account Full Response (/full) Structural & Value Parity (Completed)
Structural and value gaps in the `/full` account response have been addressed:
**Key Fixes:**
* **Structural**:
* **Nested Source Association**: Improved the matching logic in `mapRecentsToFullResponse` to correctly link recents to their specific `ConfiguredSource` (e.g., by matching `sourceid` attribute).
* **XML Tag Formatting**: Standardized self-closing tags and element formatting to match upstream's multi-line or empty-element formatting in various contexts.
+41
View File
@@ -0,0 +1,41 @@
# Parity Analysis: Bose-SoundTouch (Go) vs. SoundCork (Python)
This document provides a comparative analysis of the current Go implementation and the `deborahgu/soundcork` project, identifying functional gaps and potential improvements.
## 1. Core Architecture and Language
- **Bose-SoundTouch (Go)**: Uses `chi` for routing and `encoding/xml` for data. High performance, strong typing, and precise MIME type handling (`application/vnd.bose.streaming-v1.2+xml`).
- **SoundCork (Python)**: Uses `FastAPI` and `xml.etree.ElementTree`. Prioritizes flexibility and rapid prototyping of streaming service mocks.
## 2. Functional Comparison
| Feature | Bose-SoundTouch (Go) | SoundCork (Python) |
|:---------------------|:-------------------------------------------------|:----------------------------------------------------------------------------------------|
| **Group Management** | Placeholder handlers (return `<group/>` or 404). | Active group management (`groups.py`), supporting `/addGroup` and stereo pairing logic. |
| **BMX Services** | Supports TuneIn, Orion, and custom streams. | More modular `bmx_services.json` registry with broader mock support. |
| **Persistence** | Mixed JSON/XML datastore. | Pure XML-based persistence per device/account. |
| **Admin UI** | CLI-based (`soundtouch-cli`) or API-driven. | Draft Web UI for device discovery and account management (`admin.py`). |
| **Discovery** | Integrated setup tools and SSDP/MDNS awareness. | Leverages `bosesoundtouchapi` Python library for active discovery. |
## 3. Key Strengths of SoundCork
- **Group Pairing Logic**: Includes logic to manage master/slave relationships for SoundTouch 10 stereo pairs.
- **Service Extensibility**: JSON-based registry for BMX services makes it easier to mock multiple providers (SiriusXM, Spotify) without code changes.
- **Mock Coverage**: Better coverage of "dummy" endpoints that respond with plausible XML (e.g., `customerSupport`).
## 4. Suggested Implementation Steps for Bose-SoundTouch
### A. Implement Full Group Support (High Priority)
- Add logic to `pkg/service/marge` to handle `/addGroup` and `/updateGroup`.
- Persist group memberships in the datastore to allow speakers to function as stereo pairs or multi-room zones.
### B. Modularize BMX Registry (Medium Priority)
- Extract the hardcoded service list in `HandleBMXRegistry` into an external `bmx-services.json` file.
- Allow users to customize which mocked services are advertised to the speaker.
### C. Enhanced Source Management (Medium Priority)
- Refine source learning logic to ensure all `sourceAccount` and `sourceName` metadata is correctly captured during synchronization, using patterns from `soundcork`'s `learnSource`.
### D. Basic Admin Web UI (Low Priority)
- Develop a minimal internal status page to list active accounts and connected devices, improving usability over raw API calls.
## 5. Summary
While our Go implementation is structurally more consistent with recent reference recordings (e.g., `buttonNumber`, detailed `components`), SoundCork provides better coverage of multi-device coordination (Groups) and service emulation (BMX) that we should adopt for a more complete offline experience.
+5
View File
@@ -57,6 +57,10 @@
* [IoT Config Summary](analysis/IOT-CONFIG-SUMMARY.md)
* [IoT Configuration Analysis](analysis/IOT-CONFIGURATION-ANALYSIS.md)
## Parity Analysis
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
* [Parity SoundCork](PARITY-SOUNDCORK.md)
## Appendix (Other Documents)
* [API Navigation Reference](API-NAVIGATION-REFERENCE.md)
* [Claude Instructions](CLAUDE.md)
@@ -81,3 +85,4 @@
* [Power On Implementation Guide](power-on-implementation-guide.md)
* [SCMUDC Events Analysis](scmudc-events-analysis.md)
* [Parity Improvements](PARITY-IMPROVEMENTS.md)
* [Parity SoundCork](PARITY-SOUNDCORK.md)
+12 -2
View File
@@ -394,6 +394,9 @@ soundtouch-cli --host <device> source spotify
soundtouch-cli --host <device> source bluetooth
soundtouch-cli --host <device> source aux
# Custom radio selection (via soundtouch-service)
soundtouch-cli --host <device> source custom-radio --url <STREAM_URL> [--name <NAME>] [--artwork <ARTWORK>] [--service-url <SERVICE_URL>]
# Advanced content selection
soundtouch-cli --host <device> source internet-radio --location <URL> [--name <NAME>]
soundtouch-cli --host <device> source local-music --location <LOCATION> --account <ACCOUNT>
@@ -482,6 +485,7 @@ soundtouch-cli --host 192.168.1.10 source compare
| Command | Description | Requirements |
|---------|-------------|--------------|
| `internet-radio` | Select internet radio stream (LOCAL_INTERNET_RADIO) | Stream URL |
| `custom-radio` | Select custom radio stream via soundtouch-service | Stream URL and service URL |
| `local-music` | Select local music content (LOCAL_MUSIC) | SoundTouch App Media Server |
| `stored-music` | Select stored music content (STORED_MUSIC) | UPnP/DLNA media server |
| `content` | Generic content selection (advanced) | Source and location |
@@ -495,6 +499,12 @@ The `internet-radio` command supports the streamUrl proxy format from the [Sound
soundtouch-cli --host 192.168.1.10 source internet-radio \
--location "http://contentapi.gmuth.de/station.php?name=Antenne%20Chillout&streamUrl=https://stream.antenne.de/chillout/stream/aacp" \
--name "Antenne Chillout"
# Using local soundtouch-service for custom streams
soundtouch-cli --host 192.168.1.10 source custom-radio \
--url "https://stream.antenne.de/chillout/stream/aacp" \
--name "Antenne Chillout" \
--service-url "http://localhost:8080"
```
#### Service Introspection
@@ -1044,7 +1054,7 @@ soundtouch-cli --host 192.168.1.10 speaker beep
**Supported Languages for TTS:**
- `EN` - English (default)
- `DE` - German
- `DE` - German
- `ES` - Spanish
- `FR` - French
- `IT` - Italian
@@ -1085,7 +1095,7 @@ soundtouch-cli --host <device> events subscribe [flags]
**Event Types:**
- `nowPlaying` - Track changes, playback status
- `volume` - Volume and mute changes
- `volume` - Volume and mute changes
- `connection` - Network connectivity status
- `preset` - Preset configuration changes
- `zone` - Multiroom zone changes
+7 -6
View File
@@ -26,10 +26,11 @@ The service consists of several key components:
### BMX Services (Bose Media eXchange)
- **TuneIn Integration**: Direct playback of radio stations and podcasts
- **Custom Streams**: Flexible playback of any internet radio URL via dynamic proxy
- **Service Registry**: Media service discovery and configuration
- **Playback Control**: Stream URL resolution and audio metadata
### Marge Services (Account & Device Management)
### Marge Services (Account & Device Management)
- **Account Management**: User account simulation and device association
- **Preset Synchronization**: Cross-device preset storage and sync
- **Recent Items**: Playback history tracking and management
@@ -57,7 +58,7 @@ go build -o soundtouch-service ./cmd/soundtouch-service
### Docker Support
You can run the SoundTouch service using Docker or Docker Compose.
You can run the SoundTouch service using Docker or Docker Compose.
> **Note for macOS and Windows users**: The `--net host` option is only supported on Linux. On macOS and Windows, service discovery (mDNS, UPnP) will not work automatically within the container. You will need to manually enter your device's IP address in the management UI, and the service will communicate with it directly.
@@ -393,7 +394,7 @@ Migrates device to use local services.
**Query Parameters:**
- `target_url`: Custom service URL (optional)
- `proxy_url`: Proxy URL for fallback (optional)
- `proxy_url`: Proxy URL for fallback (optional)
- `marge`: Set to "original" to proxy Marge requests (optional)
- `stats`: Set to "original" to proxy stats requests (optional)
- `sw_update`: Set to "original" to proxy update requests (optional)
@@ -764,7 +765,7 @@ ls -la data/events/
This service implementation is based on and inspired by several excellent community projects:
### SoundCork
- **Project**: [SoundCork](https://github.com/deborahgu/soundcork)
- **Project**: [SoundCork](https://github.com/deborahgu/soundcork)
- **Authors**: Deborah Gu and contributors
- **Contribution**: The architecture and service emulation approach in this Go implementation is heavily based on SoundCork's pioneering Python implementation. SoundCork provided the foundation for understanding Bose's service architecture and migration strategies.
@@ -796,7 +797,7 @@ func customBMXHandler(w http.ResponseWriter, r *http.Request) {
func main() {
r := chi.NewRouter()
r.Get("/bmx/custom/endpoint", customBMXHandler)
r.Get("/custom/endpoint", customBMXHandler)
http.ListenAndServe(":8000", r)
}
```
@@ -809,7 +810,7 @@ soundtouch:
- host: 192.168.1.100
port: 8090
name: "Living Room Speaker"
rest:
- resource: "http://localhost:8000/setup/devices"
scan_interval: 60
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

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="M457 452 L200 452 L200 513 L798 513 L798 452 L539 452 L798 84 L200 84 L457 452 M681 145 L498 405 L317 145 L681 145 " />
</g>
</svg>

After

Width:  |  Height:  |  Size: 244 B

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="M200 145 L458 145 L200 513 L798 513 L541 145 L798 145 L798 84 L200 84 L200 145 M681 452 L317 452 L499 192 L681 452 " />
</g>
</svg>

After

Width:  |  Height:  |  Size: 244 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.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="M143 223 L186 266 L346 106 L819 579 L862 536 L346 20 L143 223 " />
</g>
</svg>

After

Width:  |  Height:  |  Size: 191 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="M500 513 L799 85 L201 85 L500 513 " />
</g>
</svg>

After

Width:  |  Height:  |  Size: 163 B

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