From d9d63e769ba3d42ca470d4dd13d52a3d12f5c60c Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 22:10:04 +0200
Subject: [PATCH 001/460] Scaffold workspace and checks
---
.gitattributes | 1 +
.github/dependabot.yml | 17 +++
.github/workflows/ci.yml | 41 ++++++
.gitignore | 1 +
CHANGELOG.md | 1 +
Cargo.lock | 11 ++
Cargo.toml | 20 +++
LICENSE | 21 +++
README.md | 5 +
about.hbs | 282 +++++++++++++++++++++++++++++++++++++++
about.toml | 21 +++
check.ps1 | 106 +++++++++++++++
crates/app/Cargo.toml | 11 ++
crates/app/src/main.rs | 1 +
crates/core/Cargo.toml | 10 ++
crates/core/src/lib.rs | 1 +
16 files changed, 550 insertions(+)
create mode 100644 .gitattributes
create mode 100644 .github/dependabot.yml
create mode 100644 .github/workflows/ci.yml
create mode 100644 .gitignore
create mode 100644 CHANGELOG.md
create mode 100644 Cargo.lock
create mode 100644 Cargo.toml
create mode 100644 LICENSE
create mode 100644 README.md
create mode 100644 about.hbs
create mode 100644 about.toml
create mode 100644 check.ps1
create mode 100644 crates/app/Cargo.toml
create mode 100644 crates/app/src/main.rs
create mode 100644 crates/core/Cargo.toml
create mode 100644 crates/core/src/lib.rs
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..6313b56
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+* text=auto eol=lf
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..62d6f23
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,17 @@
+# Monthly, grouped: one PR per ecosystem. CI on pull_request tests each bump.
+version: 2
+updates:
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "monthly"
+ groups:
+ actions:
+ patterns: ["*"]
+ - package-ecosystem: "cargo"
+ directory: "/"
+ schedule:
+ interval: "monthly"
+ groups:
+ cargo:
+ patterns: ["*"]
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..513f768
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,41 @@
+# One pipeline: CI runs the same check.ps1 as the pre-push hook.
+# `branches` and `branches-ignore` must never both appear on one event.
+name: ci
+
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ check:
+ runs-on: windows-latest
+ # A cold cache once hit 45 minutes.
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v7
+ - uses: dtolnay/rust-toolchain@stable
+ with:
+ components: rustfmt, clippy
+ - uses: Swatinem/rust-cache@v2
+ with:
+ workspaces: .
+ # Prebuilt and checksum-verified: building cargo-audit from source took
+ # about 11 minutes per run and pushed cold-cache runs past the timeout.
+ # Only cargo-audit is unpinned: Dependabot does not update tool
+ # versions here, and an outdated cargo-audit can fail on a newer
+ # advisory database.
+ - uses: taiki-e/install-action@v2
+ with:
+ tool: cargo-about@0.9,cargo-audit
+ - run: ./check.ps1
+ shell: pwsh
+ - run: cargo build --release --locked -p neumann-control-app
+ - uses: actions/upload-artifact@v7
+ with:
+ name: neumann-control-exe
+ path: target/release/neumann-control.exe
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..701cd4c
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1 @@
+## [Unreleased]
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..32dd314
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,11 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "neumann-control-app"
+version = "0.1.0"
+
+[[package]]
+name = "neumann-control-core"
+version = "0.1.0"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..39ce58b
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,20 @@
+[workspace]
+members = ["crates/core", "crates/app"]
+resolver = "2"
+
+[workspace.package]
+version = "0.1.0"
+edition = "2021"
+authors = ["Varitras"]
+license = "MIT"
+rust-version = "1.95"
+
+# Dependencies carry no debug info in dev and test builds: their PDBs made up
+# most of a target directory that grew past 40 GB; our own crate keeps its own.
+[profile.dev.package."*"]
+debug = false
+
+[profile.release]
+lto = true
+codegen-units = 1
+strip = true
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f8fdba0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Varitras
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..ffec228
--- /dev/null
+++ b/README.md
@@ -0,0 +1,5 @@
+# neumann-control
+
+A portable Windows app to find, read and control Neumann KH DSP speakers over Sennheiser SSC.
+
+Status: in development.
diff --git a/about.hbs b/about.hbs
new file mode 100644
index 0000000..1b9fbf0
--- /dev/null
+++ b/about.hbs
@@ -0,0 +1,282 @@
+
+
+
+
+
+ Third-party licenses
+
+
+
+
+ Third-party licenses
+ Rust crates and fonts bundled into this release.
+
+ Overview
+
+ {{#each overview}}
+ {{name}} ({{count}})
+ {{/each}}
+
+
+ Rust crates
+ {{#each licenses}}
+
+ {{#if first_of_kind}}
+
{{name}}
+ {{/if}}
+
Used by:
+
+
{{text}}
+
+ {{/each}}
+
+ Fonts
+
+
+
Inter 4.1 (SIL Open Font License 1.1)
+
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION AND CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
+
+
+
+
JetBrains Mono NL 2.304 (SIL Open Font License 1.1)
+
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
+
+
+
+
+
diff --git a/about.toml b/about.toml
new file mode 100644
index 0000000..a989b18
--- /dev/null
+++ b/about.toml
@@ -0,0 +1,21 @@
+targets = ["x86_64-pc-windows-msvc"]
+ignore-dev-dependencies = true
+ignore-build-dependencies = false
+
+# The set actually present in the release dependency graph of the workspace,
+# found by running `cargo about generate` once with a permissive list and
+# tightening. OFL-1.1 and Ubuntu-font-1.0 come with egui's default fonts
+# (epaint_default_fonts), which stay as the emoji and symbol fallback.
+# GPL/LGPL/AGPL are deliberately not accepted; if one appears, the gate fails.
+accepted = [
+ "MIT",
+ "Apache-2.0",
+ "BSD-3-Clause",
+ "ISC",
+ "Zlib",
+ "Unicode-3.0",
+ "MPL-2.0",
+ "BSL-1.0",
+ "OFL-1.1",
+ "Ubuntu-font-1.0",
+]
diff --git a/check.ps1 b/check.ps1
new file mode 100644
index 0000000..46d10b0
--- /dev/null
+++ b/check.ps1
@@ -0,0 +1,106 @@
+<#
+.SYNOPSIS
+ Definition of done, as one command. CI runs this same script.
+#>
+[CmdletBinding()]
+param()
+
+$ErrorActionPreference = 'Stop'
+Set-Location $PSScriptRoot
+
+function Invoke-Gate {
+ param([string] $Name, [scriptblock] $Command)
+ Write-Host ""
+ Write-Host "=== $Name" -ForegroundColor Cyan
+ # A block without a native command would leave the exit code of an earlier step in place.
+ $global:LASTEXITCODE = 0
+ & $Command
+ if ($LASTEXITCODE -ne 0) {
+ Write-Host ""
+ Write-Host "FAILED: $Name" -ForegroundColor Red
+ exit 1
+ }
+}
+
+# Fails when the search prints anything; git grep exits 1 for "no match", which is the pass.
+function Invoke-Forbid {
+ param([string] $Name, [scriptblock] $Search)
+ Write-Host ""
+ Write-Host "=== $Name" -ForegroundColor Cyan
+ $global:LASTEXITCODE = 0
+ $found = & $Search
+ if ($LASTEXITCODE -gt 1) {
+ Write-Host "FAILED: $Name could not search" -ForegroundColor Red
+ exit 1
+ }
+ if ($found) {
+ $found | Out-Host
+ Write-Host ""
+ Write-Host "FAILED: $Name" -ForegroundColor Red
+ exit 1
+ }
+}
+
+function Assert-Tool {
+ param([string] $Name, [string] $InstallHint)
+ if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
+ Write-Host "FAILED: $Name is not installed. $InstallHint" -ForegroundColor Red
+ exit 1
+ }
+}
+
+# Splits Rust source into comments, raw strings, character literals and normal string literals,
+# leftmost first, so a quote or brace inside one never starts another. Only normal string literals
+# are format strings, so the width guard looks at those alone.
+$rustToken = [regex]('(?//[^\n]*|/\*[\s\S]*?\*/)' +
+ '|(?b?r(?#*)"[\s\S]*?"\k)' +
+ '|(?''(?:\\u\{[0-9a-fA-F]+\}|\\.|[^\\\n''])'')' +
+ '|(?"(?:\\[\s\S]|[^"\\])*")')
+
+# Each normal string literal in tracked and new Rust files that matches $Pattern, with its line.
+function Find-InRustStrings {
+ param([regex] $Pattern)
+ foreach ($path in git ls-files --cached --others --exclude-standard -- '*.rs') {
+ $content = Get-Content -Raw -LiteralPath $path -Encoding utf8
+ if (-not $content) { continue }
+ foreach ($token in $rustToken.Matches($content)) {
+ $literal = $token.Groups['string']
+ if (-not $literal.Success -or -not $Pattern.IsMatch($literal.Value)) { continue }
+ $line = ($content.Substring(0, $literal.Index) -split "`n").Count
+ "${path}:${line}: $($literal.Value)"
+ }
+ }
+}
+
+Assert-Tool 'cargo-audit' 'Install with: cargo install cargo-audit --locked'
+Assert-Tool 'cargo-about' 'Install with: cargo install cargo-about --locked'
+
+# A width or precision taken from a variable panics above u16::MAX (Rust 1.87+); pad by building.
+# The argument name before `:` excludes escaped JSON such as "{\"a\":\"b$\"}".
+Invoke-Forbid 'runtime format widths' {
+ Find-InRustStrings ('\{[A-Za-z0-9_]*:[^{}"\\]*?(?:[a-z_][a-z0-9_]*|[0-9]+)\$[^{}]*\}' +
+ '|\{[A-Za-z0-9_]*:[^{}"\\]*\.\*[^{}]*\}')
+}
+# The index is what gets committed; -text paths (fixtures) skip LF normalisation, so a CR there
+# would reach a blob unchanged.
+Invoke-Forbid 'carriage returns in staged text files' {
+ git grep --cached -nIP '\r'
+}
+# Catches a stray CR in new and unstaged files before they are added. A CR at a line end is a
+# CRLF ending, which git normalises to LF on add outside the -text paths.
+Invoke-Forbid 'carriage returns inside lines of working files' {
+ git grep -nIP --untracked '\r(?!$)'
+}
+
+Invoke-Gate 'cargo fmt (check)' { cargo fmt --all -- --check }
+# A dependency with an unaccepted licence (e.g. a GPL crate) fails generation, so this doubles
+# as the licence gate; build-release.ps1 generates the shipped file from the same config.
+Invoke-Gate 'licence gate (cargo about)' {
+ cargo about generate --workspace --locked --fail -c about.toml about.hbs -o "$env:TEMP\neumann-control-about-check.html"
+}
+Invoke-Gate 'cargo audit' { cargo audit }
+Invoke-Gate 'cargo clippy' { cargo clippy --workspace --all-targets --locked -- -D warnings }
+Invoke-Gate 'cargo test' { cargo test --workspace --locked }
+
+Write-Host ""
+Write-Host "All gates green." -ForegroundColor Green
diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml
new file mode 100644
index 0000000..5043123
--- /dev/null
+++ b/crates/app/Cargo.toml
@@ -0,0 +1,11 @@
+[package]
+name = "neumann-control-app"
+version.workspace = true
+edition.workspace = true
+authors.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+[[bin]]
+name = "neumann-control"
+path = "src/main.rs"
diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs
new file mode 100644
index 0000000..f328e4d
--- /dev/null
+++ b/crates/app/src/main.rs
@@ -0,0 +1 @@
+fn main() {}
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
new file mode 100644
index 0000000..2853f14
--- /dev/null
+++ b/crates/core/Cargo.toml
@@ -0,0 +1,10 @@
+[package]
+name = "neumann-control-core"
+version.workspace = true
+edition.workspace = true
+authors.workspace = true
+license.workspace = true
+rust-version.workspace = true
+
+[features]
+test-support = []
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
new file mode 100644
index 0000000..8b13789
--- /dev/null
+++ b/crates/core/src/lib.rs
@@ -0,0 +1 @@
+
From 76cb4749212b819abbb982b80cdf288d544e69ca Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 22:13:50 +0200
Subject: [PATCH 002/460] Parse SSC replies with nested errors and xid
---
Cargo.lock | 100 +++++++
crates/core/Cargo.toml | 4 +
crates/core/src/lib.rs | 2 +-
crates/core/src/ssc/mod.rs | 1 +
crates/core/src/ssc/wire.rs | 226 ++++++++++++++++
.../core/tests/fixtures/recorded_replies.json | 253 ++++++++++++++++++
crates/core/tests/wire.rs | 166 ++++++++++++
7 files changed, 751 insertions(+), 1 deletion(-)
create mode 100644 crates/core/src/ssc/mod.rs
create mode 100644 crates/core/src/ssc/wire.rs
create mode 100644 crates/core/tests/fixtures/recorded_replies.json
create mode 100644 crates/core/tests/wire.rs
diff --git a/Cargo.lock b/Cargo.lock
index 32dd314..fe6028a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,18 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
[[package]]
name = "neumann-control-app"
version = "0.1.0"
@@ -9,3 +21,91 @@ version = "0.1.0"
[[package]]
name = "neumann-control-core"
version = "0.1.0"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.154"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index 2853f14..20f95b4 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -8,3 +8,7 @@ rust-version.workspace = true
[features]
test-support = []
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index 8b13789..9dfefb4 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -1 +1 @@
-
+pub mod ssc;
diff --git a/crates/core/src/ssc/mod.rs b/crates/core/src/ssc/mod.rs
new file mode 100644
index 0000000..349da7c
--- /dev/null
+++ b/crates/core/src/ssc/mod.rs
@@ -0,0 +1 @@
+pub mod wire;
diff --git a/crates/core/src/ssc/wire.rs b/crates/core/src/ssc/wire.rs
new file mode 100644
index 0000000..d73367d
--- /dev/null
+++ b/crates/core/src/ssc/wire.rs
@@ -0,0 +1,226 @@
+//! SSC v1 wire format: paths, request lines and reply parsing.
+
+use std::fmt;
+
+use serde::de::{self, MapAccess, SeqAccess, Visitor};
+use serde::{Deserialize, Deserializer, Serialize, Serializer};
+use serde_json::{Map, Number, Value};
+
+const PATH_SEPARATOR: char = '/';
+const OSC_KEY: &str = "osc";
+const XID_KEY: &str = "xid";
+const ERROR_KEY: &str = "error";
+const DESCRIPTION_KEY: &str = "desc";
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum WireError {
+ InvalidPath,
+ NonFinite,
+ Malformed,
+ NotAnObject,
+}
+
+impl fmt::Display for WireError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let text = match self {
+ WireError::InvalidPath => "invalid SSC path",
+ WireError::NonFinite => "number is not finite",
+ WireError::Malformed => "malformed JSON line",
+ WireError::NotAnObject => "line is not a JSON object",
+ };
+ formatter.write_str(text)
+ }
+}
+
+impl std::error::Error for WireError {}
+
+/// An address such as `audio/out/level`; never empty, no empty segments.
+#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
+pub struct SscPath(Vec);
+
+impl SscPath {
+ pub fn parse(text: &str) -> Result {
+ let segments: Vec = text.split(PATH_SEPARATOR).map(str::to_owned).collect();
+ if segments.iter().any(String::is_empty) {
+ return Err(WireError::InvalidPath);
+ }
+ Ok(SscPath(segments))
+ }
+
+ pub fn segments(&self) -> &[String] {
+ &self.0
+ }
+}
+
+impl fmt::Display for SscPath {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(&self.0.join(&PATH_SEPARATOR.to_string()))
+ }
+}
+
+impl Serialize for SscPath {
+ fn serialize(&self, serializer: S) -> Result {
+ serializer.collect_str(self)
+ }
+}
+
+impl<'de> Deserialize<'de> for SscPath {
+ fn deserialize>(deserializer: D) -> Result {
+ let text = String::deserialize(deserializer)?;
+ SscPath::parse(&text).map_err(de::Error::custom)
+ }
+}
+
+/// A device error: the `[code, {"desc": text}]` pair of an `osc/error` reply.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct DeviceError {
+ pub code: i64,
+ pub description: String,
+}
+
+/// Merges `incoming` into `target`: objects recursively, anything else is replaced.
+fn merge(target: &mut Value, incoming: Value) {
+ match (target, incoming) {
+ (Value::Object(existing), Value::Object(additions)) => {
+ for (key, value) in additions {
+ match existing.get_mut(&key) {
+ Some(slot) => merge(slot, value),
+ None => {
+ existing.insert(key, value);
+ }
+ }
+ }
+ }
+ (slot, value) => *slot = value,
+ }
+}
+
+/// A JSON value whose repeated object keys are merged instead of dropped: a device can
+/// send two top-level `osc` members in one line, and a standard parser keeps only the last.
+struct MergedValue(Value);
+
+impl<'de> Deserialize<'de> for MergedValue {
+ fn deserialize>(deserializer: D) -> Result {
+ deserializer.deserialize_any(MergedVisitor)
+ }
+}
+
+struct MergedVisitor;
+
+impl<'de> Visitor<'de> for MergedVisitor {
+ type Value = MergedValue;
+
+ fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("any JSON value")
+ }
+
+ fn visit_bool(self, value: bool) -> Result {
+ Ok(MergedValue(Value::Bool(value)))
+ }
+
+ fn visit_i64(self, value: i64) -> Result {
+ Ok(MergedValue(Value::Number(value.into())))
+ }
+
+ fn visit_u64(self, value: u64) -> Result {
+ Ok(MergedValue(Value::Number(value.into())))
+ }
+
+ fn visit_f64(self, value: f64) -> Result {
+ let number = Number::from_f64(value).ok_or_else(|| E::custom("non-finite number"))?;
+ Ok(MergedValue(Value::Number(number)))
+ }
+
+ fn visit_str(self, value: &str) -> Result {
+ Ok(MergedValue(Value::String(value.to_owned())))
+ }
+
+ fn visit_string(self, value: String) -> Result {
+ Ok(MergedValue(Value::String(value)))
+ }
+
+ fn visit_unit(self) -> Result {
+ Ok(MergedValue(Value::Null))
+ }
+
+ fn visit_seq>(self, mut sequence: A) -> Result {
+ let mut items = Vec::new();
+ while let Some(item) = sequence.next_element::()? {
+ items.push(item.0);
+ }
+ Ok(MergedValue(Value::Array(items)))
+ }
+
+ fn visit_map>(self, mut entries: A) -> Result {
+ let mut object = Value::Object(Map::new());
+ while let Some((key, value)) = entries.next_entry::()? {
+ merge(&mut object, Value::Object(Map::from_iter([(key, value.0)])));
+ }
+ Ok(MergedValue(object))
+ }
+}
+
+/// Parses one reply line into a JSON object, merging duplicate keys deeply.
+pub fn parse_line(line: &[u8]) -> Result {
+ let text = std::str::from_utf8(line).map_err(|_| WireError::Malformed)?;
+ let merged: MergedValue = serde_json::from_str(text).map_err(|_| WireError::Malformed)?;
+ match merged.0 {
+ Value::Object(_) => Ok(merged.0),
+ _ => Err(WireError::NotAnObject),
+ }
+}
+
+/// Builds a request line without terminator. The transaction id lives under `osc`; a path
+/// that itself starts with `osc` shares that object.
+pub fn request_line(path: &SscPath, value: Value, xid: u64) -> String {
+ let nested = path.segments().iter().rev().fold(value, |inner, segment| {
+ Value::Object(Map::from_iter([(segment.clone(), inner)]))
+ });
+ let mut request = serde_json::json!({ OSC_KEY: { XID_KEY: xid } });
+ merge(&mut request, nested);
+ request.to_string()
+}
+
+/// A JSON number for a set request; NaN and infinities have no JSON form.
+pub fn number(value: f64) -> Result {
+ Number::from_f64(value)
+ .map(Value::Number)
+ .ok_or(WireError::NonFinite)
+}
+
+/// The value at `path` if present and not `null`.
+pub fn extract<'a>(tree: &'a Value, path: &SscPath) -> Option<&'a Value> {
+ let found = path
+ .segments()
+ .iter()
+ .try_fold(tree, |node, segment| node.get(segment))?;
+ (!found.is_null()).then_some(found)
+}
+
+/// The transaction id echoed in `osc/xid`, if it is a non-negative integer.
+pub fn reply_xid(tree: &Value) -> Option {
+ tree.get(OSC_KEY)?.get(XID_KEY)?.as_u64()
+}
+
+/// The first `[code, {"desc": text}]` pair under `osc/error`, depth-first.
+pub fn device_error(tree: &Value) -> Option {
+ find_pair(tree.get(OSC_KEY)?.get(ERROR_KEY)?)
+}
+
+fn find_pair(node: &Value) -> Option {
+ match node {
+ Value::Array(items) => as_pair(items).or_else(|| items.iter().find_map(find_pair)),
+ Value::Object(members) => members.values().find_map(find_pair),
+ _ => None,
+ }
+}
+
+fn as_pair(items: &[Value]) -> Option {
+ let [code, detail] = items else {
+ return None;
+ };
+ Some(DeviceError {
+ code: code.as_i64()?,
+ description: detail.get(DESCRIPTION_KEY)?.as_str()?.to_owned(),
+ })
+}
diff --git a/crates/core/tests/fixtures/recorded_replies.json b/crates/core/tests/fixtures/recorded_replies.json
new file mode 100644
index 0000000..d341f74
--- /dev/null
+++ b/crates/core/tests/fixtures/recorded_replies.json
@@ -0,0 +1,253 @@
+{
+ "not_found": [
+ {
+ "request": {
+ "audio": {
+ "out": {
+ "dimm": null
+ }
+ }
+ },
+ "reply": "{\"osc\":{\"error\":[{\"audio\":{\"out\":{\"dimm\":[404,{\"desc\":\"address not found\"}]}}}]}}"
+ },
+ {
+ "request": {
+ "audio": {
+ "out5": {
+ "level": null
+ }
+ }
+ },
+ "reply": "{\"osc\":{\"error\":[{\"audio\":{\"out5\":[404,{\"desc\":\"address not found\"}]}}]}}"
+ },
+ {
+ "request": {
+ "device": {
+ "save_settings": null
+ }
+ },
+ "reply": "{\"osc\":{\"error\":[{\"device\":{\"save_settings\":[404,{\"desc\":\"address not found\"}]}}]}}"
+ }
+ ],
+ "kh750_not_found": [
+ {
+ "request": {
+ "audio": {
+ "out": {
+ "phaseinversion": null
+ }
+ }
+ },
+ "reply": "{\"osc\":{\"error\":[{\"audio\":{\"out\":{\"phaseinversion\":[404,{\"desc\":\"address not found\"}]}}}]}}"
+ }
+ ],
+ "container": [
+ {
+ "request": {
+ "device": null
+ },
+ "reply": "{\"osc\":{\"error\":[{\"device\":[404,{\"desc\":\"address not found\"}]}]}}"
+ }
+ ],
+ "bundle": [
+ {
+ "request": {
+ "audio": {
+ "out": {
+ "level": null,
+ "dimm": null
+ }
+ }
+ },
+ "reply": "{\"audio\":{\"out\":{\"level\":100.0}},\"osc\":{\"error\":[{\"audio\":{\"out\":[404,{\"desc\":\"address not found\"}]}}]}}"
+ }
+ ],
+ "set": [
+ {
+ "request": {
+ "audio": {
+ "out": {
+ "mute": false
+ }
+ }
+ },
+ "reply": "{\"audio\":{\"out\":{\"mute\":false}}}"
+ },
+ {
+ "request": {
+ "audio": {
+ "out": {
+ "dimm": 0
+ }
+ }
+ },
+ "reply": "{\"osc\":{\"error\":[{\"audio\":{\"out\":{\"dimm\":[404,{\"desc\":\"address not found\"}]}}}]}}"
+ },
+ {
+ "request": {
+ "audio": {
+ "out": {
+ "mute": "x"
+ }
+ }
+ },
+ "reply": "{\"osc\":{\"error\":[{\"audio\":{\"out\":{\"mute\":[406,{\"desc\":\"not acceptable\"}]}}}]}}"
+ },
+ {
+ "request": {
+ "audio": {
+ "out": {
+ "mute": false,
+ "delay": 0
+ }
+ }
+ },
+ "reply": "{\"audio\":{\"out\":{\"mute\":false,\"delay\":0}}}"
+ },
+ {
+ "request": {
+ "audio": {
+ "out": {
+ "mute": false,
+ "dimm": 0
+ }
+ }
+ },
+ "reply": "{\"audio\":{\"out\":{\"mute\":false}},\"osc\":{\"error\":[{\"audio\":{\"out\":[404,{\"desc\":\"address not found\"}]}}]}}"
+ },
+ {
+ "request": {
+ "audio": {
+ "out": {
+ "delay": null,
+ "mute": false
+ }
+ }
+ },
+ "reply": "{\"audio\":{\"out\":{\"mute\":false,\"delay\":0}}}"
+ },
+ {
+ "request": {
+ "ui": {
+ "input_gain": 0.0
+ }
+ },
+ "reply": "{\"osc\":{\"error\":[{\"ui\":{\"input_gain\":[405,{\"desc\":\"method not allowed\"}]}}]}}"
+ },
+ {
+ "request": {
+ "audio": {
+ "out": {
+ "mute": false
+ }
+ },
+ "ui": {
+ "input_gain": 0.0
+ }
+ },
+ "reply": "{\"audio\":{\"out\":{\"mute\":false}},\"osc\":{\"error\":[{\"ui\":{\"input_gain\":[405,{\"desc\":\"method not allowed\"}]}}]}}"
+ }
+ ],
+ "schema": [
+ {
+ "request": {
+ "osc": {
+ "schema": null
+ }
+ },
+ "reply": "{\"osc\":{\"schema\":[{\"device\":{},\"osc\":{},\"audio\":{},\"m\":{},\"ui\":{},\"warnings\":null}]}}"
+ },
+ {
+ "request": {
+ "osc": {
+ "schema": [
+ {
+ "audio": null
+ }
+ ]
+ }
+ },
+ "reply": "{\"osc\":{\"schema\":[{\"audio\":{\"in\":{},\"out\":{}}}]}}",
+ "model": "KH 120 II"
+ },
+ {
+ "request": {
+ "osc": {
+ "schema": [
+ {
+ "audio": null
+ }
+ ]
+ }
+ },
+ "reply": "{\"osc\":{\"schema\":[{\"audio\":{\"in\":{},\"out\":{},\"out1\":{},\"out2\":{},\"digital_bypass\":null}}]}}",
+ "model": "KH 750"
+ }
+ ],
+ "introspection_errors": [
+ {
+ "request": {
+ "osc": {
+ "schema": [
+ null
+ ]
+ }
+ },
+ "reply": "{\"osc\":{\"error\":[{\"osc\":{\"schema\":[454,{\"desc\":\"parameter address not found\"}]}}]}}"
+ },
+ {
+ "request": {
+ "osc": {
+ "schema": [
+ {
+ "audio": {}
+ }
+ ]
+ }
+ },
+ "reply": "{\"osc\":{\"error\":[{\"osc\":{\"schema\":[406,{\"desc\":\"not acceptable\"}]}}]}}"
+ },
+ {
+ "request": {
+ "osc": {
+ "limits": {
+ "audio": {
+ "out": {
+ "level": null
+ }
+ }
+ }
+ }
+ },
+ "reply": "{\"osc\":{\"error\":[{\"osc\":{\"limits\":[454,{\"desc\":\"parameter address not found\"}]}}]}}"
+ }
+ ],
+ "xid": [
+ {
+ "request": {
+ "osc": {
+ "xid": 4711
+ },
+ "audio": {
+ "out": {
+ "level": null
+ }
+ }
+ },
+ "reply": "{\"audio\":{\"out\":{\"level\":100.0}},\"osc\":{\"xid\":4711}}"
+ },
+ {
+ "request": {
+ "osc": {
+ "xid": "abc"
+ },
+ "audio": {
+ "out": {
+ "mute": null
+ }
+ }
+ },
+ "reply": "{\"audio\":{\"out\":{\"mute\":false}},\"osc\":{\"xid\":\"abc\"}}"
+ }
+ ]
+}
diff --git a/crates/core/tests/wire.rs b/crates/core/tests/wire.rs
new file mode 100644
index 0000000..09bbcce
--- /dev/null
+++ b/crates/core/tests/wire.rs
@@ -0,0 +1,166 @@
+use neumann_control_core::ssc::wire::{
+ device_error, extract, number, parse_line, reply_xid, request_line, SscPath, WireError,
+};
+use serde_json::{json, Value};
+
+const FIXTURES: &str = include_str!("fixtures/recorded_replies.json");
+
+fn group(name: &str) -> Vec<(Value, String)> {
+ let fixtures: Value = serde_json::from_str(FIXTURES).expect("fixture file is valid JSON");
+ fixtures[name]
+ .as_array()
+ .unwrap_or_else(|| panic!("fixture group {name} is missing"))
+ .iter()
+ .map(|entry| {
+ (
+ entry["request"].clone(),
+ entry["reply"]
+ .as_str()
+ .expect("reply is a string")
+ .to_owned(),
+ )
+ })
+ .collect()
+}
+
+fn path(text: &str) -> SscPath {
+ SscPath::parse(text).expect("valid path")
+}
+
+#[test]
+fn nested_404_is_found_at_any_depth() {
+ let replies: Vec<_> = group("not_found")
+ .into_iter()
+ .chain(group("kh750_not_found"))
+ .chain(group("container"))
+ .collect();
+ assert!(replies.len() >= 5);
+ for (_, reply) in replies {
+ let tree = parse_line(reply.as_bytes()).unwrap();
+ let error = device_error(&tree).unwrap_or_else(|| panic!("no error in {reply}"));
+ assert_eq!(error.code, 404, "{reply}");
+ assert_eq!(error.description, "address not found", "{reply}");
+ }
+}
+
+#[test]
+fn bundle_reply_keeps_value_and_error() {
+ let (_, reply) = group("bundle").remove(0);
+ let tree = parse_line(reply.as_bytes()).unwrap();
+ assert_eq!(
+ extract(&tree, &path("audio/out/level")),
+ Some(&json!(100.0))
+ );
+ assert_eq!(device_error(&tree).unwrap().code, 404);
+}
+
+#[test]
+fn duplicate_osc_members_are_merged() {
+ let tree = parse_line(
+ br#"{"osc":{"error":[{"a":[210,{"desc":"partial success"}]}]},"osc":{"xid":7}}"#,
+ )
+ .unwrap();
+ assert_eq!(reply_xid(&tree), Some(7));
+ assert_eq!(device_error(&tree).unwrap().code, 210);
+}
+
+#[test]
+fn duplicate_non_object_member_takes_the_later_value() {
+ let tree = parse_line(br#"{"a":1,"a":2,"b":{"c":1},"b":{"d":2}}"#).unwrap();
+ assert_eq!(tree, json!({"a": 2, "b": {"c": 1, "d": 2}}));
+}
+
+#[test]
+fn set_replies_report_their_errors() {
+ let codes: Vec> = group("set")
+ .into_iter()
+ .map(|(_, reply)| device_error(&parse_line(reply.as_bytes()).unwrap()).map(|e| e.code))
+ .collect();
+ assert!(codes.contains(&None));
+ assert!(codes.contains(&Some(404)));
+ assert!(codes.contains(&Some(405)));
+ assert!(codes.contains(&Some(406)));
+}
+
+#[test]
+fn flat_error_pair_is_found() {
+ let tree = parse_line(br#"{"osc":{"error":[404,{"desc":"address not found"}]}}"#).unwrap();
+ assert_eq!(device_error(&tree).unwrap().code, 404);
+}
+
+#[test]
+fn reply_without_error_has_none() {
+ let tree = parse_line(br#"{"audio":{"out":{"mute":false}}}"#).unwrap();
+ assert!(device_error(&tree).is_none());
+}
+
+#[test]
+fn xid_is_read_from_recorded_echo() {
+ let (_, reply) = group("xid").remove(0);
+ let tree = parse_line(reply.as_bytes()).unwrap();
+ assert_eq!(reply_xid(&tree), Some(4711));
+}
+
+#[test]
+fn string_xid_is_not_a_number() {
+ let (_, reply) = group("xid").remove(1);
+ let tree = parse_line(reply.as_bytes()).unwrap();
+ assert_eq!(reply_xid(&tree), None);
+}
+
+#[test]
+fn extract_ignores_null_and_missing() {
+ let tree = parse_line(br#"{"osc":{"ping":null}}"#).unwrap();
+ assert_eq!(extract(&tree, &path("osc/ping")), None);
+ assert_eq!(extract(&tree, &path("osc/version")), None);
+}
+
+#[test]
+fn request_line_nests_path_and_xid() {
+ let line = request_line(&path("audio/out/level"), Value::Null, 5);
+ assert_eq!(
+ parse_line(line.as_bytes()).unwrap(),
+ json!({"osc": {"xid": 5}, "audio": {"out": {"level": null}}})
+ );
+ let line = request_line(&path("osc/schema"), Value::Null, 6);
+ assert_eq!(
+ parse_line(line.as_bytes()).unwrap(),
+ json!({"osc": {"xid": 6, "schema": null}})
+ );
+ assert!(!line.contains('\n'));
+}
+
+#[test]
+fn non_finite_number_is_refused() {
+ assert_eq!(number(f64::NAN), Err(WireError::NonFinite));
+ assert_eq!(number(f64::INFINITY), Err(WireError::NonFinite));
+ assert_eq!(number(f64::NEG_INFINITY), Err(WireError::NonFinite));
+ assert_eq!(number(1.5), Ok(json!(1.5)));
+}
+
+#[test]
+fn non_object_and_garbage_lines_are_errors() {
+ assert_eq!(parse_line(b"[1]"), Err(WireError::NotAnObject));
+ assert_eq!(parse_line(b"\xff"), Err(WireError::Malformed));
+ assert_eq!(parse_line(b"{"), Err(WireError::Malformed));
+}
+
+#[test]
+fn path_parse_rejects_empty_segments() {
+ assert_eq!(SscPath::parse("audio//level"), Err(WireError::InvalidPath));
+ assert_eq!(SscPath::parse(""), Err(WireError::InvalidPath));
+}
+
+#[test]
+fn path_displays_and_serializes_as_string() {
+ let parsed = path("audio/out/level");
+ assert_eq!(parsed.to_string(), "audio/out/level");
+ assert_eq!(parsed.segments(), ["audio", "out", "level"]);
+ assert_eq!(
+ serde_json::to_string(&parsed).unwrap(),
+ r#""audio/out/level""#
+ );
+ let back: SscPath = serde_json::from_str(r#""audio/out/level""#).unwrap();
+ assert_eq!(back, parsed);
+ assert!(serde_json::from_str::(r#""a//b""#).is_err());
+}
From 0e8239d9f91cd2da23c8b14d50e6d1e0d8e227c9 Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 22:18:46 +0200
Subject: [PATCH 003/460] Parse IPv6 speaker addresses and mask them
---
crates/core/src/address.rs | 64 ++++++++++++++++++++++++++
crates/core/src/lib.rs | 2 +
crates/core/src/mask.rs | 38 ++++++++++++++++
crates/core/tests/address.rs | 88 ++++++++++++++++++++++++++++++++++++
4 files changed, 192 insertions(+)
create mode 100644 crates/core/src/address.rs
create mode 100644 crates/core/src/mask.rs
create mode 100644 crates/core/tests/address.rs
diff --git a/crates/core/src/address.rs b/crates/core/src/address.rs
new file mode 100644
index 0000000..9202cba
--- /dev/null
+++ b/crates/core/src/address.rs
@@ -0,0 +1,64 @@
+//! Parsing of IPv6 speaker addresses, including Windows numeric zone indexes.
+
+use std::fmt;
+use std::net::{Ipv4Addr, Ipv6Addr, SocketAddrV6};
+
+pub const SSC_PORT: u16 = 45;
+
+const SCOPE_SEPARATOR: char = '%';
+const LINK_LOCAL_PREFIX_MASK: u16 = 0xffc0;
+const LINK_LOCAL_PREFIX: u16 = 0xfe80;
+const NO_SCOPE: u32 = 0;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum AddressError {
+ Ipv4NotSupported,
+ MissingScope,
+ ScopeNotNumeric,
+ Invalid,
+}
+
+impl fmt::Display for AddressError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let text = match self {
+ AddressError::Ipv4NotSupported => "IPv4 addresses are not supported",
+ AddressError::MissingScope => "link-local address needs an interface index scope",
+ AddressError::ScopeNotNumeric => "scope must be a numeric interface index",
+ AddressError::Invalid => "not a valid IPv6 address",
+ };
+ formatter.write_str(text)
+ }
+}
+
+impl std::error::Error for AddressError {}
+
+pub fn is_link_local(address: &Ipv6Addr) -> bool {
+ address.segments()[0] & LINK_LOCAL_PREFIX_MASK == LINK_LOCAL_PREFIX
+}
+
+pub fn parse_address(input: &str, port: u16) -> Result {
+ let trimmed = input.trim();
+ let unbracketed = trimmed
+ .strip_prefix('[')
+ .and_then(|rest| rest.strip_suffix(']'))
+ .unwrap_or(trimmed);
+ let (host, scope_text) = match unbracketed.split_once(SCOPE_SEPARATOR) {
+ Some((host, scope_text)) => (host, Some(scope_text)),
+ None => (unbracketed, None),
+ };
+
+ if host.parse::().is_ok() {
+ return Err(AddressError::Ipv4NotSupported);
+ }
+ let ip: Ipv6Addr = host.parse().map_err(|_| AddressError::Invalid)?;
+ let scope = match scope_text {
+ Some(text) => text
+ .parse::()
+ .map_err(|_| AddressError::ScopeNotNumeric)?,
+ None => NO_SCOPE,
+ };
+ if is_link_local(&ip) && scope == NO_SCOPE {
+ return Err(AddressError::MissingScope);
+ }
+ Ok(SocketAddrV6::new(ip, port, 0, scope))
+}
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index 9dfefb4..2e8b5fa 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -1 +1,3 @@
+pub mod address;
+pub mod mask;
pub mod ssc;
diff --git a/crates/core/src/mask.rs b/crates/core/src/mask.rs
new file mode 100644
index 0000000..3c04fd8
--- /dev/null
+++ b/crates/core/src/mask.rs
@@ -0,0 +1,38 @@
+//! Masking of addresses and serial numbers for logs and screenshots.
+
+use std::net::SocketAddrV6;
+
+const MASK_PLACEHOLDER: &str = "xx";
+const KEPT_ADDRESS_CHARACTERS: usize = 2;
+const KEPT_SERIAL_CHARACTERS: usize = 3;
+const ELLIPSIS: char = '…';
+const GROUP_SEPARATOR: char = ':';
+const COMPRESSION_MARKER: &str = "::";
+
+/// Keeps the prefix up to the last `::` and drops the groups after it, keeping
+/// only the final two characters. Without a `::` only the last group is masked.
+pub fn mask_host(address: &SocketAddrV6) -> String {
+ let text = address.ip().to_string();
+ let prefix_length = match text.rfind(COMPRESSION_MARKER) {
+ Some(index) => index + COMPRESSION_MARKER.len(),
+ None => text.rfind(GROUP_SEPARATOR).map_or(0, |index| index + 1),
+ };
+ let (prefix, tail) = text.split_at(prefix_length);
+ let kept_start = tail.len().saturating_sub(KEPT_ADDRESS_CHARACTERS);
+ let scope = match address.scope_id() {
+ 0 => String::new(),
+ scope_id => format!("%{scope_id}"),
+ };
+ format!("{prefix}{MASK_PLACEHOLDER}{}{scope}", &tail[kept_start..])
+}
+
+pub fn mask_serial(serial: &str) -> String {
+ let characters: Vec = serial.chars().collect();
+ if characters.len() <= KEPT_SERIAL_CHARACTERS {
+ return serial.to_owned();
+ }
+ let kept: String = characters[characters.len() - KEPT_SERIAL_CHARACTERS..]
+ .iter()
+ .collect();
+ format!("{ELLIPSIS}{kept}")
+}
diff --git a/crates/core/tests/address.rs b/crates/core/tests/address.rs
new file mode 100644
index 0000000..8bec6f7
--- /dev/null
+++ b/crates/core/tests/address.rs
@@ -0,0 +1,88 @@
+use std::net::{Ipv6Addr, SocketAddrV6};
+
+use neumann_control_core::address::{is_link_local, parse_address, AddressError, SSC_PORT};
+use neumann_control_core::mask::{mask_host, mask_serial};
+
+const SIMULATED_ADDRESS: &str = "fe80::2a36:38ff:fe12:3456";
+
+#[test]
+fn link_local_without_scope_is_refused() {
+ assert_eq!(
+ parse_address(SIMULATED_ADDRESS, SSC_PORT),
+ Err(AddressError::MissingScope)
+ );
+}
+
+#[test]
+fn interface_name_scope_is_refused() {
+ assert_eq!(
+ parse_address("fe80::1%eth0", SSC_PORT),
+ Err(AddressError::ScopeNotNumeric)
+ );
+}
+
+#[test]
+fn ipv4_is_refused() {
+ assert_eq!(
+ parse_address("192.168.1.10", SSC_PORT),
+ Err(AddressError::Ipv4NotSupported)
+ );
+}
+
+#[test]
+fn garbage_is_invalid() {
+ assert_eq!(
+ parse_address("speaker", SSC_PORT),
+ Err(AddressError::Invalid)
+ );
+}
+
+#[test]
+fn bracketed_and_plain_forms_parse() {
+ for input in ["fe80::1%12", "[fe80::1%12]", " fe80::1%12 "] {
+ let address = parse_address(input, SSC_PORT).unwrap();
+ assert_eq!(address.scope_id(), 12);
+ assert_eq!(address.port(), 45);
+ assert_eq!(*address.ip(), "fe80::1".parse::().unwrap());
+ }
+}
+
+#[test]
+fn global_address_needs_no_scope() {
+ let address = parse_address("2001:db8::1", SSC_PORT).unwrap();
+ assert_eq!(address.scope_id(), 0);
+}
+
+#[test]
+fn link_local_range_is_fe80_slash_10() {
+ assert!(is_link_local(&"febf::1".parse().unwrap()));
+ assert!(!is_link_local(&"fec0::1".parse().unwrap()));
+}
+
+#[test]
+fn host_mask_keeps_prefix_and_last_two() {
+ let address = parse_address("fe80::2a36:38ff:fe12:3456%12", SSC_PORT).unwrap();
+ assert_eq!(mask_host(&address), "fe80::xx56%12");
+}
+
+#[test]
+fn host_mask_without_scope_has_no_suffix() {
+ let address = SocketAddrV6::new("2001:db8::abcd".parse().unwrap(), SSC_PORT, 0, 0);
+ assert_eq!(mask_host(&address), "2001:db8::xxcd");
+}
+
+#[test]
+fn host_mask_of_uncompressed_address_masks_final_group() {
+ let address = SocketAddrV6::new("2001:db8:1:2:3:4:5:6789".parse().unwrap(), SSC_PORT, 0, 0);
+ assert_eq!(mask_host(&address), "2001:db8:1:2:3:4:5:xx89");
+}
+
+#[test]
+fn serial_mask_keeps_last_three() {
+ assert_eq!(mask_serial("SIM0001234"), "…234");
+}
+
+#[test]
+fn short_serial_is_unchanged() {
+ assert_eq!(mask_serial("123"), "123");
+}
From 65653a4c27f0d53aebe4ba61641e8187fa8f999f Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 22:20:33 +0200
Subject: [PATCH 004/460] Mask the whole interface identifier
---
crates/core/src/address.rs | 15 ++++++++++++---
crates/core/src/mask.rs | 26 ++++++++++++--------------
crates/core/tests/address.rs | 26 +++++++++++++++++++++++---
3 files changed, 47 insertions(+), 20 deletions(-)
diff --git a/crates/core/src/address.rs b/crates/core/src/address.rs
index 9202cba..439cf3c 100644
--- a/crates/core/src/address.rs
+++ b/crates/core/src/address.rs
@@ -51,10 +51,11 @@ pub fn parse_address(input: &str, port: u16) -> Result text
- .parse::()
- .map_err(|_| AddressError::ScopeNotNumeric)?,
+ Some(text) => parse_scope(text)?,
None => NO_SCOPE,
};
if is_link_local(&ip) && scope == NO_SCOPE {
@@ -62,3 +63,11 @@ pub fn parse_address(input: &str, port: u16) -> Result Result {
+ if text.is_empty() || !text.bytes().all(|byte| byte.is_ascii_digit()) {
+ return Err(AddressError::ScopeNotNumeric);
+ }
+ text.parse().map_err(|_| AddressError::ScopeNotNumeric)
+}
diff --git a/crates/core/src/mask.rs b/crates/core/src/mask.rs
index 3c04fd8..4c2b2f4 100644
--- a/crates/core/src/mask.rs
+++ b/crates/core/src/mask.rs
@@ -1,29 +1,27 @@
//! Masking of addresses and serial numbers for logs and screenshots.
-use std::net::SocketAddrV6;
+use std::net::{Ipv6Addr, SocketAddrV6};
const MASK_PLACEHOLDER: &str = "xx";
-const KEPT_ADDRESS_CHARACTERS: usize = 2;
+const SEGMENT_COUNT: usize = 8;
+const KEPT_PREFIX_SEGMENTS: usize = 4;
+const LOW_BYTE_MASK: u16 = 0x00ff;
const KEPT_SERIAL_CHARACTERS: usize = 3;
const ELLIPSIS: char = '…';
-const GROUP_SEPARATOR: char = ':';
-const COMPRESSION_MARKER: &str = "::";
-/// Keeps the prefix up to the last `::` and drops the groups after it, keeping
-/// only the final two characters. Without a `::` only the last group is masked.
+/// Keeps the first four groups (the network prefix) and only the low byte of
+/// the last group, so the interface identifier never reaches logs.
pub fn mask_host(address: &SocketAddrV6) -> String {
- let text = address.ip().to_string();
- let prefix_length = match text.rfind(COMPRESSION_MARKER) {
- Some(index) => index + COMPRESSION_MARKER.len(),
- None => text.rfind(GROUP_SEPARATOR).map_or(0, |index| index + 1),
- };
- let (prefix, tail) = text.split_at(prefix_length);
- let kept_start = tail.len().saturating_sub(KEPT_ADDRESS_CHARACTERS);
+ let segments = address.ip().segments();
+ let mut prefix_segments = [0u16; SEGMENT_COUNT];
+ prefix_segments[..KEPT_PREFIX_SEGMENTS].copy_from_slice(&segments[..KEPT_PREFIX_SEGMENTS]);
+ let prefix = Ipv6Addr::from(prefix_segments);
+ let last_byte = segments[SEGMENT_COUNT - 1] & LOW_BYTE_MASK;
let scope = match address.scope_id() {
0 => String::new(),
scope_id => format!("%{scope_id}"),
};
- format!("{prefix}{MASK_PLACEHOLDER}{}{scope}", &tail[kept_start..])
+ format!("{prefix}{MASK_PLACEHOLDER}{last_byte:02x}{scope}")
}
pub fn mask_serial(serial: &str) -> String {
diff --git a/crates/core/tests/address.rs b/crates/core/tests/address.rs
index 8bec6f7..74ee922 100644
--- a/crates/core/tests/address.rs
+++ b/crates/core/tests/address.rs
@@ -72,9 +72,29 @@ fn host_mask_without_scope_has_no_suffix() {
}
#[test]
-fn host_mask_of_uncompressed_address_masks_final_group() {
- let address = SocketAddrV6::new("2001:db8:1:2:3:4:5:6789".parse().unwrap(), SSC_PORT, 0, 0);
- assert_eq!(mask_host(&address), "2001:db8:1:2:3:4:5:xx89");
+fn host_mask_hides_interface_identifier_of_uncompressed_address() {
+ let address = parse_address("2001:db8:1:2:2a36:38ff:fe12:3456", SSC_PORT).unwrap();
+ let masked = mask_host(&address);
+ assert_eq!(masked, "2001:db8:1:2::xx56");
+ for hidden in ["2a36", "38ff", "fe12"] {
+ assert!(!masked.contains(hidden));
+ }
+}
+
+#[test]
+fn scope_with_plus_sign_is_not_numeric() {
+ assert_eq!(
+ parse_address("fe80::1%+12", SSC_PORT),
+ Err(AddressError::ScopeNotNumeric)
+ );
+}
+
+#[test]
+fn ipv4_mapped_address_is_refused() {
+ assert_eq!(
+ parse_address("::ffff:192.168.1.10", SSC_PORT),
+ Err(AddressError::Ipv4NotSupported)
+ );
}
#[test]
From b456738dc450f15d643ed4261ea924f24361b5a7 Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 22:34:27 +0200
Subject: [PATCH 005/460] Add SSC simulator built from recorded replies
---
check.ps1 | 4 +-
crates/core/Cargo.toml | 4 +
crates/core/src/lib.rs | 2 +
crates/core/src/simulator/device.rs | 530 ++++++++++++++++++
crates/core/src/simulator/kh120ii.json | 88 +++
crates/core/src/simulator/kh750.json | 201 +++++++
crates/core/src/simulator/mod.rs | 287 ++++++++++
crates/core/src/ssc/wire.rs | 2 +-
.../core/tests/fixtures/recorded_replies.json | 18 +
crates/core/tests/simulator.rs | 426 ++++++++++++++
10 files changed, 1559 insertions(+), 3 deletions(-)
create mode 100644 crates/core/src/simulator/device.rs
create mode 100644 crates/core/src/simulator/kh120ii.json
create mode 100644 crates/core/src/simulator/kh750.json
create mode 100644 crates/core/src/simulator/mod.rs
create mode 100644 crates/core/tests/simulator.rs
diff --git a/check.ps1 b/check.ps1
index 46d10b0..ded2fa2 100644
--- a/check.ps1
+++ b/check.ps1
@@ -99,8 +99,8 @@ Invoke-Gate 'licence gate (cargo about)' {
cargo about generate --workspace --locked --fail -c about.toml about.hbs -o "$env:TEMP\neumann-control-about-check.html"
}
Invoke-Gate 'cargo audit' { cargo audit }
-Invoke-Gate 'cargo clippy' { cargo clippy --workspace --all-targets --locked -- -D warnings }
-Invoke-Gate 'cargo test' { cargo test --workspace --locked }
+Invoke-Gate 'cargo clippy' { cargo clippy --workspace --all-targets --all-features --locked -- -D warnings }
+Invoke-Gate 'cargo test' { cargo test --workspace --all-features --locked }
Write-Host ""
Write-Host "All gates green." -ForegroundColor Green
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index 20f95b4..bb0a1e3 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -12,3 +12,7 @@ test-support = []
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+
+[[test]]
+name = "simulator"
+required-features = ["test-support"]
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index 2e8b5fa..6521557 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -1,3 +1,5 @@
pub mod address;
pub mod mask;
+#[cfg(feature = "test-support")]
+pub mod simulator;
pub mod ssc;
diff --git a/crates/core/src/simulator/device.rs b/crates/core/src/simulator/device.rs
new file mode 100644
index 0000000..e62067f
--- /dev/null
+++ b/crates/core/src/simulator/device.rs
@@ -0,0 +1,530 @@
+//! The simulated speaker: a value tree plus the answering rules recorded from real devices.
+//! No I/O here; the server module moves the lines.
+
+use serde_json::{json, Map, Number, Value};
+
+use super::SimModel;
+use crate::ssc::wire::{merge, SscPath};
+
+const OSC_KEY: &str = "osc";
+const XID_KEY: &str = "xid";
+const ERROR_KEY: &str = "error";
+const SCHEMA_KEY: &str = "schema";
+const LIMITS_KEY: &str = "limits";
+const VERSION_KEY: &str = "version";
+const PING_KEY: &str = "ping";
+const FEATURE_KEY: &str = "feature";
+const PATH_SEPARATOR: &str = "/";
+
+const OSC_VERSION: &str = "1.1";
+const OSC_PATTERN_FEATURE: &str = "*?";
+const FEATURE_PATTERN: &str = "pattern";
+const FEATURE_SUBSCRIPTION: &str = "subscription";
+
+const TYPE_KEY: &str = "type";
+const TYPE_NUMBER: &str = "Number";
+const TYPE_STRING: &str = "String";
+const TYPE_BOOLEAN: &str = "Boolean";
+
+struct ErrorKind {
+ code: u16,
+ description: &'static str,
+}
+
+const BAD_REQUEST: ErrorKind = ErrorKind {
+ code: 400,
+ description: "message not understood",
+};
+const NOT_FOUND: ErrorKind = ErrorKind {
+ code: 404,
+ description: "address not found",
+};
+const NOT_ALLOWED: ErrorKind = ErrorKind {
+ code: 405,
+ description: "method not allowed",
+};
+const NOT_ACCEPTABLE: ErrorKind = ErrorKind {
+ code: 406,
+ description: "not acceptable",
+};
+const PARAMETER_NOT_FOUND: ErrorKind = ErrorKind {
+ code: 454,
+ description: "parameter address not found",
+};
+
+const KH_120_II_TREE: &str = include_str!("kh120ii.json");
+const KH_750_TREE: &str = include_str!("kh750.json");
+
+/// Read-only on every model: identity, live metering, derived values.
+const READ_ONLY_COMMON: &[&str] = &[
+ "device/identity/product",
+ "device/identity/vendor",
+ "device/identity/serial",
+ "device/identity/version",
+ "device/identity/hw_version",
+ "device/standby/countdown",
+ "device/temperature",
+ "audio/in/current_input",
+ "m/in/level",
+ "m/in/clip",
+ "m/out/level",
+ "m/out/clip",
+ "warnings",
+];
+
+/// Refused with 405 on the KH 120 II in the write tests.
+const READ_ONLY_KH_120_II: &[&str] = &[
+ "ui/input_gain",
+ "ui/input_select",
+ "ui/bass_gain",
+ "ui/mid_gain",
+ "ui/treble_gain",
+ "ui/output_level",
+];
+
+/// Refused with 405 on the KH 750 in the write tests, plus the sensors the integration
+/// models as read-only. `device/standby/enabled` is writable on the KH 120 II only.
+const READ_ONLY_KH_750: &[&str] = &[
+ "ui/bass_management",
+ "ui/channel_b_input_mode",
+ "ui/subwoofer_input_gain",
+ "ui/subwoofer_low_cut",
+ "ui/subwoofer_output_level",
+ "ui/subwoofer_phase",
+ "ui/subwoofer_phase_inversion",
+ "audio/digital_bypass",
+ "audio/out/label",
+ "audio/out1/label",
+ "audio/out2/label",
+ "audio/out1/loudspeaker",
+ "audio/out2/loudspeaker",
+ "device/standby/enabled",
+];
+
+/// The reply to one request, before the transaction id is attached.
+pub struct Answer {
+ pub body: Value,
+ pub xid: Option,
+}
+
+pub struct SimulatedDevice {
+ tree: Value,
+ read_only_for_model: &'static [&'static str],
+}
+
+impl SimulatedDevice {
+ pub fn new(model: SimModel) -> SimulatedDevice {
+ let (source, read_only_for_model) = match model {
+ SimModel::Kh120Ii => (KH_120_II_TREE, READ_ONLY_KH_120_II),
+ SimModel::Kh750 => (KH_750_TREE, READ_ONLY_KH_750),
+ };
+ SimulatedDevice {
+ tree: serde_json::from_str(source).expect("embedded value tree is valid JSON"),
+ read_only_for_model,
+ }
+ }
+
+ pub fn value(&self, path: &str) -> Option {
+ let path = SscPath::parse(path).ok()?;
+ lookup(&self.tree, path.segments()).cloned()
+ }
+
+ pub fn set_value(&mut self, path: &str, value: Value) {
+ let path = SscPath::parse(path).expect("a valid SSC path");
+ let mut node = &mut self.tree;
+ for segment in path.segments() {
+ if !node.is_object() {
+ *node = Value::Object(Map::new());
+ }
+ node = node
+ .as_object_mut()
+ .expect("made an object above")
+ .entry(segment.clone())
+ .or_insert(Value::Null);
+ }
+ *node = value;
+ }
+
+ pub fn remove_value(&mut self, path: &str) {
+ let path = SscPath::parse(path).expect("a valid SSC path");
+ let Some((name, parents)) = path.segments().split_last() else {
+ return;
+ };
+ let parent = parents
+ .iter()
+ .try_fold(&mut self.tree, |node, segment| node.get_mut(segment));
+ if let Some(members) = parent.and_then(Value::as_object_mut) {
+ members.remove(name);
+ }
+ }
+
+ pub fn answer(&mut self, request: &Map) -> Answer {
+ let xid = request
+ .get(OSC_KEY)
+ .and_then(|osc| osc.get(XID_KEY))
+ .cloned();
+ let body = self.answer_body(request, xid.is_some());
+ Answer { body, xid }
+ }
+
+ fn answer_body(&mut self, request: &Map, has_xid: bool) -> Value {
+ let Some(osc) = request.get(OSC_KEY) else {
+ return self.answer_values(request);
+ };
+ let Some(osc) = osc.as_object() else {
+ return error_reply(&BAD_REQUEST);
+ };
+ if let Some(argument) = osc.get(SCHEMA_KEY) {
+ return self.schema(argument);
+ }
+ if let Some(argument) = osc.get(LIMITS_KEY) {
+ return self.limits(argument);
+ }
+ let Some(methods) = osc_methods(osc) else {
+ return error_reply(&BAD_REQUEST);
+ };
+ let mut values = request.clone();
+ values.remove(OSC_KEY);
+ if values.is_empty() && methods.is_empty() && !has_xid {
+ return error_reply(&BAD_REQUEST);
+ }
+ let mut body = if values.is_empty() {
+ Value::Object(Map::new())
+ } else {
+ self.answer_values(&values)
+ };
+ if !methods.is_empty() {
+ merge(&mut body, json!({ OSC_KEY: methods }));
+ }
+ body
+ }
+
+ fn answer_values(&mut self, request: &Map) -> Value {
+ let leaves = collect_leaves(request, &[]);
+ if leaves.is_empty() {
+ return error_reply(&BAD_REQUEST);
+ }
+ let bundled = leaves.len() > 1;
+ let mut body = Value::Object(Map::new());
+ let mut errors = Vec::new();
+ for (path, value) in leaves {
+ match self.answer_leaf(&path, value, bundled) {
+ Ok(subtree) => merge(&mut body, subtree),
+ Err(entry) => errors.push(entry),
+ }
+ }
+ if !errors.is_empty() {
+ body[OSC_KEY] = json!({ ERROR_KEY: errors });
+ }
+ body
+ }
+
+ /// One leaf of a request: the answer subtree, or the error entry nested at its node.
+ fn answer_leaf(
+ &mut self,
+ path: &[String],
+ value: Value,
+ bundled: bool,
+ ) -> Result {
+ let current = match lookup(&self.tree, path) {
+ Some(found) if !found.is_object() => found.clone(),
+ found => {
+ let node = if found.is_none() {
+ &path[..self.first_missing_depth(path)]
+ } else {
+ path
+ };
+ // Recorded: inside a bundle the 404 sits at the parent of the failing node.
+ let node = if bundled && node.len() > 1 {
+ &node[..node.len() - 1]
+ } else {
+ node
+ };
+ return Err(error_entry(node, &NOT_FOUND));
+ }
+ };
+ if value.is_null() {
+ return Ok(nest(path, current));
+ }
+ if self.is_read_only(path) {
+ return Err(error_entry(path, &NOT_ALLOWED));
+ }
+ if !same_kind(¤t, &value) {
+ return Err(error_entry(path, &NOT_ACCEPTABLE));
+ }
+ let resulting = resulting_value(¤t, value);
+ self.store(path, resulting.clone());
+ Ok(nest(path, resulting))
+ }
+
+ fn first_missing_depth(&self, path: &[String]) -> usize {
+ let mut node = &self.tree;
+ for (depth, segment) in path.iter().enumerate() {
+ match node.get(segment) {
+ Some(child) => node = child,
+ None => return depth + 1,
+ }
+ }
+ path.len()
+ }
+
+ fn is_read_only(&self, path: &[String]) -> bool {
+ let joined = path.join(PATH_SEPARATOR);
+ READ_ONLY_COMMON.contains(&joined.as_str())
+ || self.read_only_for_model.contains(&joined.as_str())
+ }
+
+ fn store(&mut self, path: &[String], value: Value) {
+ if let Some(slot) = path
+ .iter()
+ .try_fold(&mut self.tree, |node, segment| node.get_mut(segment))
+ {
+ *slot = value;
+ }
+ }
+
+ /// Everything `osc/schema` describes: the device tree plus the `osc` container.
+ fn address_tree(&self) -> Value {
+ let mut tree = self.tree.clone();
+ tree[OSC_KEY] = osc_tree();
+ tree
+ }
+
+ fn schema(&self, argument: &Value) -> Value {
+ let tree = self.address_tree();
+ if argument.is_null() {
+ return osc_reply(SCHEMA_KEY, json!([schema_level(&tree)]));
+ }
+ let path = match introspection_path(argument, SCHEMA_KEY) {
+ Ok(path) => path,
+ Err(refusal) => return refusal,
+ };
+ let Some(node) = lookup(&tree, &path) else {
+ return introspection_error(SCHEMA_KEY, &PARAMETER_NOT_FOUND);
+ };
+ let described = if node.is_object() {
+ schema_level(node)
+ } else {
+ Value::Null
+ };
+ osc_reply(SCHEMA_KEY, json!([nest(&path, described)]))
+ }
+
+ fn limits(&self, argument: &Value) -> Value {
+ let path = match introspection_path(argument, LIMITS_KEY) {
+ Ok(path) => path,
+ Err(refusal) => return refusal,
+ };
+ let tree = self.address_tree();
+ let Some(node) = lookup(&tree, &path).filter(|node| !node.is_object()) else {
+ return introspection_error(LIMITS_KEY, &PARAMETER_NOT_FOUND);
+ };
+ // Only the type is reproduced; the other fields (units, range, options) are not
+ // recorded per path, and none is invented.
+ let description = if path[0] == OSC_KEY {
+ json!({})
+ } else {
+ json!({ TYPE_KEY: limits_type(node) })
+ };
+ osc_reply(LIMITS_KEY, json!([nest(&path, json!([description]))]))
+ }
+}
+
+/// The serialized line: the transaction id rides in `osc/xid`. An error reply already has an
+/// `osc` member, and real devices then send a second one for the id instead of merging.
+pub fn encode_line(body: &Value, xid: Option<&Value>) -> String {
+ let Some(xid) = xid else {
+ return body.to_string();
+ };
+ let has_error = body
+ .get(OSC_KEY)
+ .and_then(|osc| osc.get(ERROR_KEY))
+ .is_some();
+ if has_error {
+ let text = body.to_string();
+ let opened = text.strip_suffix('}').unwrap_or(&text);
+ return format!("{opened},\"{OSC_KEY}\":{{\"{XID_KEY}\":{xid}}}}}");
+ }
+ let mut tagged = body.clone();
+ merge(&mut tagged, json!({ OSC_KEY: { XID_KEY: xid } }));
+ tagged.to_string()
+}
+
+fn lookup<'a>(tree: &'a Value, path: &[String]) -> Option<&'a Value> {
+ path.iter()
+ .try_fold(tree, |node, segment| node.get(segment))
+}
+
+fn nest(path: &[String], value: Value) -> Value {
+ path.iter().rev().fold(value, |inner, segment| {
+ Value::Object(Map::from_iter([(segment.clone(), inner)]))
+ })
+}
+
+/// Flattens a request into (path, value) pairs, stopping at non-object values and `{}`.
+fn collect_leaves(members: &Map, prefix: &[String]) -> Vec<(Vec, Value)> {
+ let mut leaves = Vec::new();
+ for (name, value) in members {
+ let mut path = prefix.to_vec();
+ path.push(name.clone());
+ match value.as_object().filter(|nested| !nested.is_empty()) {
+ Some(nested) => leaves.extend(collect_leaves(nested, &path)),
+ None => leaves.push((path, value.clone())),
+ }
+ }
+ leaves
+}
+
+fn error_pair(kind: &ErrorKind) -> Value {
+ json!([kind.code, { "desc": kind.description }])
+}
+
+fn error_entry(node: &[String], kind: &ErrorKind) -> Value {
+ nest(node, error_pair(kind))
+}
+
+/// A flat error for requests whose real reply was never recorded.
+fn error_reply(kind: &ErrorKind) -> Value {
+ json!({ OSC_KEY: { ERROR_KEY: error_pair(kind) } })
+}
+
+/// A refused introspection request: the error sits at `osc/`, as recorded.
+fn introspection_error(method: &str, kind: &ErrorKind) -> Value {
+ json!({ OSC_KEY: { ERROR_KEY: [nest(&[OSC_KEY.to_owned(), method.to_owned()], error_pair(kind))] } })
+}
+
+fn osc_reply(method: &str, payload: Value) -> Value {
+ json!({ OSC_KEY: { method: payload } })
+}
+
+/// The address an `osc/schema` or `osc/limits` request names. Recorded: the argument is a
+/// list holding one address tree that ends in `null`; `[null]`, `[{}]` or no list is a 454,
+/// a tree ending in `{}` a 406.
+fn introspection_path(argument: &Value, method: &str) -> Result, Value> {
+ let tree = argument
+ .as_array()
+ .and_then(|items| items.first())
+ .and_then(Value::as_object)
+ .filter(|members| !members.is_empty())
+ .ok_or_else(|| introspection_error(method, &PARAMETER_NOT_FOUND))?;
+ let mut leaves = collect_leaves(tree, &[]);
+ let (Some((path, end)), true) = (leaves.pop(), leaves.is_empty()) else {
+ return Err(error_reply(&BAD_REQUEST));
+ };
+ if !end.is_null() {
+ return Err(introspection_error(method, &NOT_ACCEPTABLE));
+ }
+ Ok(path)
+}
+
+/// One level of the address tree as `osc/schema` reports it: containers `{}`, leaves `null`.
+fn schema_level(node: &Value) -> Value {
+ let members = node.as_object().into_iter().flatten();
+ Value::Object(
+ members
+ .map(|(name, child)| {
+ let described = if child.is_object() {
+ json!({})
+ } else {
+ Value::Null
+ };
+ (name.clone(), described)
+ })
+ .collect(),
+ )
+}
+
+/// The `osc` container as the devices describe it; every method is a leaf.
+fn osc_tree() -> Value {
+ json!({
+ "state": {
+ "auth": { "access": null },
+ "prettyprint": null,
+ "subscribe": null,
+ "subscribe_reserve": null
+ },
+ "feature": { "timetag": null, "baseaddr": null, "subscription": null, "pattern": null },
+ "limits": null,
+ "schema": null,
+ "version": null,
+ "xid": null,
+ "ping": null,
+ "error": null
+ })
+}
+
+/// The recorded answers of `osc/version`, `osc/ping` and single `osc/feature` gets; `None`
+/// for anything else (sets, containers), whose reply was never recorded.
+fn osc_methods(osc: &Map) -> Option> {
+ let mut answer = Map::new();
+ for (name, value) in osc.iter().filter(|(name, _)| name.as_str() != XID_KEY) {
+ match (name.as_str(), value) {
+ (VERSION_KEY, Value::Null) => answer.insert(name.clone(), json!(OSC_VERSION)),
+ (PING_KEY, Value::Null) => answer.insert(name.clone(), Value::Null),
+ (FEATURE_KEY, Value::Object(asked)) if !asked.is_empty() => {
+ answer.insert(name.clone(), features(asked)?)
+ }
+ _ => return None,
+ };
+ }
+ Some(answer)
+}
+
+fn features(asked: &Map) -> Option {
+ let mut answer = Map::new();
+ for (name, value) in asked {
+ let known = match name.as_str() {
+ FEATURE_PATTERN => json!(OSC_PATTERN_FEATURE),
+ FEATURE_SUBSCRIPTION => json!(true),
+ _ => return None,
+ };
+ if !value.is_null() {
+ return None;
+ }
+ answer.insert(name.clone(), known);
+ }
+ Some(Value::Object(answer))
+}
+
+fn limits_type(value: &Value) -> &'static str {
+ match value {
+ Value::Array(items) => items.first().map_or(TYPE_STRING, limits_type),
+ Value::Bool(_) => TYPE_BOOLEAN,
+ Value::Number(_) => TYPE_NUMBER,
+ _ => TYPE_STRING,
+ }
+}
+
+/// Whether a set carries the kind of value the leaf holds; lists must keep their length.
+fn same_kind(current: &Value, value: &Value) -> bool {
+ match (current, value) {
+ (Value::Bool(_), Value::Bool(_))
+ | (Value::Number(_), Value::Number(_))
+ | (Value::String(_), Value::String(_)) => true,
+ (Value::Array(held), Value::Array(given)) => {
+ held.len() == given.len()
+ && held
+ .iter()
+ .zip(given)
+ .all(|(left, right)| same_kind(left, right))
+ }
+ _ => false,
+ }
+}
+
+/// A float leaf stays a float: `90` is stored and echoed as `90.0`, as the devices do.
+fn resulting_value(current: &Value, value: Value) -> Value {
+ match (current, &value) {
+ (Value::Number(held), Value::Number(given)) if held.is_f64() => given
+ .as_f64()
+ .and_then(Number::from_f64)
+ .map_or(value, Value::Number),
+ (Value::Array(held), Value::Array(given)) => Value::Array(
+ held.iter()
+ .zip(given)
+ .map(|(left, right)| resulting_value(left, right.clone()))
+ .collect(),
+ ),
+ _ => value,
+ }
+}
diff --git a/crates/core/src/simulator/kh120ii.json b/crates/core/src/simulator/kh120ii.json
new file mode 100644
index 0000000..9a6333f
--- /dev/null
+++ b/crates/core/src/simulator/kh120ii.json
@@ -0,0 +1,88 @@
+{
+ "device": {
+ "auth": {
+ "exclusive": ""
+ },
+ "identification": {
+ "visual": false
+ },
+ "identity": {
+ "hw_version": 1,
+ "product": "KH 120 II",
+ "serial": "SIM0001234",
+ "vendor": "Georg Neumann GmbH",
+ "version": "1_7_3"
+ },
+ "name": "Simulated KH 120 II",
+ "network": {
+ "ether": {
+ "macs": "08:1b:2c:3d:4e:5f"
+ }
+ },
+ "restore": "",
+ "standby": {
+ "auto_standby_time": 60,
+ "countdown": 0,
+ "enabled": true,
+ "level": -65
+ },
+ "system": "",
+ "time": 0,
+ "timeprecision": 0,
+ "update": {
+ "enable": false,
+ "startover": false
+ }
+ },
+ "audio": {
+ "in": {
+ "current_input": "ANALOG INPUT",
+ "interface": "ANALOG ONLY"
+ },
+ "out": {
+ "delay": 0,
+ "level": 100.0,
+ "mixer": {
+ "levels": [0.0, -100.0]
+ },
+ "mute": false,
+ "mute_persist": false,
+ "eq2": {
+ "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "enabled": [false, false, false, false, false, false, false, false, false, false],
+ "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0],
+ "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7],
+ "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"]
+ },
+ "eq3": {
+ "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "enabled": [false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false, false],
+ "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0],
+ "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7],
+ "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"]
+ },
+ "phaseinversion": false
+ }
+ },
+ "ui": {
+ "control_mode": "NETWORK",
+ "bass_gain": "0",
+ "input_gain": 0.0,
+ "input_select": "ANALOG",
+ "logo": {
+ "brightness": 30
+ },
+ "mid_gain": "0",
+ "output_level": "100",
+ "treble_gain": "0"
+ },
+ "m": {
+ "in": {
+ "clip": [false, false],
+ "level": [-40.0, -41.5]
+ }
+ },
+ "warnings": ["NO_WARNING"]
+}
diff --git a/crates/core/src/simulator/kh750.json b/crates/core/src/simulator/kh750.json
new file mode 100644
index 0000000..3449af4
--- /dev/null
+++ b/crates/core/src/simulator/kh750.json
@@ -0,0 +1,201 @@
+{
+ "device": {
+ "auth": {
+ "exclusive": ""
+ },
+ "identification": {
+ "visual": false
+ },
+ "identity": {
+ "hw_version": 1,
+ "product": "KH 750",
+ "serial": "SIM0007500",
+ "vendor": "Georg Neumann GmbH",
+ "version": "2_1_2"
+ },
+ "name": "Simulated KH 750",
+ "network": {
+ "ether": {
+ "macs": "08:1b:2c:3d:4e:5f"
+ }
+ },
+ "restore": "",
+ "standby": {
+ "auto_standby_time": 60,
+ "countdown": 0,
+ "enabled": true,
+ "level": -65
+ },
+ "system": "",
+ "time": 0,
+ "timeprecision": 0,
+ "update": {
+ "enable": false,
+ "startover": false
+ },
+ "temperature": 310
+ },
+ "audio": {
+ "in": {
+ "current_input": "ANALOG INPUT",
+ "interface": "ANALOG ONLY"
+ },
+ "digital_bypass": true,
+ "out": {
+ "delay": 0,
+ "level": 100.0,
+ "mixer": {
+ "levels": [0.0, -100.0]
+ },
+ "mute": false,
+ "mute_persist": false,
+ "eq1": {
+ "in1": {
+ "boost": [0.0, 0.0],
+ "enabled": [true, true],
+ "frequency": [80.0, 80.0],
+ "gain": [0.0, 0.0],
+ "q": [0.7, 0.7],
+ "type": ["LOWPASS", "LOWPASS"]
+ },
+ "in2": {
+ "boost": [0.0, 0.0],
+ "enabled": [true, true],
+ "frequency": [80.0, 80.0],
+ "gain": [0.0, 0.0],
+ "q": [0.7, 0.7],
+ "type": ["LOWPASS", "LOWPASS"]
+ }
+ },
+ "eq2": {
+ "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "enabled": [false, false, false, false, false, false, false, false, false, false],
+ "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0],
+ "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7],
+ "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"]
+ },
+ "eq3": {
+ "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "enabled": [false, false, false, false, false, false, false, false, false, false],
+ "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0],
+ "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7],
+ "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"]
+ },
+ "label": "SUBWOOFER"
+ },
+ "out1": {
+ "delay": 0,
+ "level": 100.0,
+ "mixer": {
+ "levels": [0.0, -100.0]
+ },
+ "mute": false,
+ "mute_persist": false,
+ "eq1": {
+ "in1": {
+ "boost": [0.0, 0.0],
+ "enabled": [true, true],
+ "frequency": [80.0, 80.0],
+ "gain": [0.0, 0.0],
+ "q": [0.7, 0.7],
+ "type": ["HIGHPASS", "HIGHPASS"]
+ },
+ "in2": {
+ "boost": [0.0, 0.0],
+ "enabled": [true, true],
+ "frequency": [80.0, 80.0],
+ "gain": [0.0, 0.0],
+ "q": [0.7, 0.7],
+ "type": ["HIGHPASS", "HIGHPASS"]
+ }
+ },
+ "eq2": {
+ "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "enabled": [false, false, false, false, false, false, false, false, false, false],
+ "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0],
+ "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7],
+ "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"]
+ },
+ "eq3": {
+ "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "enabled": [false, false, false, false, false, false, false, false, false, false],
+ "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0],
+ "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7],
+ "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"]
+ },
+ "label": "OUT1",
+ "loudspeaker": "UNKNOWN"
+ },
+ "out2": {
+ "delay": 0,
+ "level": 100.0,
+ "mixer": {
+ "levels": [0.0, -100.0]
+ },
+ "mute": false,
+ "mute_persist": false,
+ "eq1": {
+ "in1": {
+ "boost": [0.0, 0.0],
+ "enabled": [true, true],
+ "frequency": [80.0, 80.0],
+ "gain": [0.0, 0.0],
+ "q": [0.7, 0.7],
+ "type": ["HIGHPASS", "HIGHPASS"]
+ },
+ "in2": {
+ "boost": [0.0, 0.0],
+ "enabled": [true, true],
+ "frequency": [80.0, 80.0],
+ "gain": [0.0, 0.0],
+ "q": [0.7, 0.7],
+ "type": ["HIGHPASS", "HIGHPASS"]
+ }
+ },
+ "eq2": {
+ "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "enabled": [false, false, false, false, false, false, false, false, false, false],
+ "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0],
+ "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7],
+ "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"]
+ },
+ "eq3": {
+ "boost": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "enabled": [false, false, false, false, false, false, false, false, false, false],
+ "frequency": [100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0, 100.0],
+ "gain": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ "q": [0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7, 0.7],
+ "type": ["PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC", "PARAMETRIC"]
+ },
+ "label": "OUT2",
+ "loudspeaker": "UNKNOWN"
+ }
+ },
+ "ui": {
+ "control_mode": "NETWORK",
+ "auto_standby": "ON",
+ "bass_management": "DISABLED",
+ "channel_b_input_mode": "Ext.BM LFE (fullrange)",
+ "subwoofer_input_gain": 2.0,
+ "subwoofer_low_cut": -0.1,
+ "subwoofer_output_level": "94",
+ "subwoofer_phase": "0",
+ "subwoofer_phase_inversion": "0"
+ },
+ "m": {
+ "in": {
+ "clip": [false, false],
+ "level": [-40.0, -41.5]
+ },
+ "out": {
+ "clip": [false, false, false],
+ "level": [-35.0, -35.0, -35.0]
+ }
+ },
+ "warnings": ["NO_WARNING"]
+}
diff --git a/crates/core/src/simulator/mod.rs b/crates/core/src/simulator/mod.rs
new file mode 100644
index 0000000..d9e0541
--- /dev/null
+++ b/crates/core/src/simulator/mod.rs
@@ -0,0 +1,287 @@
+//! A simulated Neumann KH speaker for tests: it answers over TCP exactly like the recorded
+//! real devices and can be told to misbehave on the next reply.
+
+mod device;
+
+use std::collections::{HashMap, VecDeque};
+use std::io::{self, BufRead, BufReader, Write};
+use std::net::{Ipv6Addr, Shutdown, SocketAddr, SocketAddrV6, TcpListener, TcpStream};
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex, MutexGuard};
+use std::thread::{self, JoinHandle};
+use std::time::Duration;
+
+use serde_json::{json, Map, Value};
+
+use crate::ssc::wire::parse_line;
+use device::{encode_line, SimulatedDevice};
+
+const LINE_TERMINATOR: &str = "\r\n";
+const LINE_LIMIT_BYTES: usize = 1_048_576;
+const FOREIGN_XID_OFFSET: u64 = 1000;
+const FILLER_BYTE: char = 'a';
+// Windows does not wake a blocked read when another handle of the socket is shut down, so
+// connection threads wake up this often to look for the stop flag.
+const READ_POLL_INTERVAL: Duration = Duration::from_millis(50);
+// A refused loopback connect takes about 2 s on Windows; waking a finished listener must not.
+const WAKE_TIMEOUT: Duration = Duration::from_millis(200);
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum SimModel {
+ Kh120Ii,
+ Kh750,
+}
+
+/// A misbehaviour applied to the next reply only.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Fault {
+ /// An earlier reply without the request's id arrives before the real one.
+ StaleLineFirst,
+ /// A reply with a foreign id (request id + 1000) arrives before the real one.
+ ForeignXidFirst,
+ /// The reply leaves out the transaction id.
+ NoXidEcho,
+ /// A line longer than the 1 MiB limit replaces the reply.
+ OverlongLine,
+ /// The connection is closed instead of answering.
+ CloseConnection,
+ /// The request is read and never answered.
+ Silence,
+}
+
+/// What the connection sends back for one request.
+enum Delivery {
+ Lines(Vec),
+ Close,
+ Silent,
+}
+
+struct State {
+ device: SimulatedDevice,
+ faults: VecDeque,
+ requests: Vec,
+}
+
+#[derive(Default)]
+struct Connections {
+ next_id: u64,
+ streams: HashMap,
+ workers: Vec>,
+}
+
+struct Shared {
+ state: Mutex,
+ connections: Mutex,
+ stopping: AtomicBool,
+}
+
+impl Shared {
+ fn state(&self) -> MutexGuard<'_, State> {
+ self.state.lock().expect("simulator state lock")
+ }
+
+ fn connections(&self) -> MutexGuard<'_, Connections> {
+ self.connections.lock().expect("simulator connection lock")
+ }
+
+ /// Answers one request. A fault still applies the request to the tree: the device
+ /// received it, only its reply goes wrong.
+ fn respond(&self, request: Map) -> Delivery {
+ let mut state = self.state();
+ state.requests.push(Value::Object(request.clone()));
+ let fault = state.faults.pop_front();
+ let answer = state.device.answer(&request);
+ let reply = encode_line(&answer.body, answer.xid.as_ref());
+ match fault {
+ None => Delivery::Lines(vec![reply]),
+ Some(Fault::CloseConnection) => Delivery::Close,
+ Some(Fault::Silence) => Delivery::Silent,
+ Some(Fault::OverlongLine) => Delivery::Lines(vec![overlong_line()]),
+ Some(Fault::NoXidEcho) => Delivery::Lines(vec![encode_line(&answer.body, None)]),
+ Some(Fault::ForeignXidFirst) => {
+ let foreign =
+ answer.xid.as_ref().and_then(Value::as_u64).unwrap_or(0) + FOREIGN_XID_OFFSET;
+ let early = encode_line(&answer.body, Some(&json!(foreign)));
+ Delivery::Lines(vec![early, reply])
+ }
+ Some(Fault::StaleLineFirst) => {
+ let earlier = json!({"audio": {"out": {"level": null}}});
+ let stale = state.device.answer(earlier.as_object().expect("an object"));
+ Delivery::Lines(vec![encode_line(&stale.body, None), reply])
+ }
+ }
+ }
+
+ fn converse(&self, stream: TcpStream) {
+ let _ = stream.set_read_timeout(Some(READ_POLL_INTERVAL));
+ let Ok(read_half) = stream.try_clone() else {
+ return;
+ };
+ let mut reader = BufReader::new(read_half);
+ let mut writer = &stream;
+ let mut line = Vec::new();
+ loop {
+ match reader.read_until(b'\n', &mut line) {
+ Ok(0) => break,
+ Ok(_) => {}
+ Err(error) if is_poll_timeout(&error) => {
+ if self.stopping.load(Ordering::SeqCst) {
+ break;
+ }
+ continue;
+ }
+ Err(_) => break,
+ }
+ let parsed = parse_line(line.trim_ascii());
+ line.clear();
+ let Ok(Value::Object(request)) = parsed else {
+ continue;
+ };
+ let lines = match self.respond(request) {
+ Delivery::Lines(lines) => lines,
+ Delivery::Silent => continue,
+ Delivery::Close => break,
+ };
+ let payload: String = lines
+ .iter()
+ .map(|text| format!("{text}{LINE_TERMINATOR}"))
+ .collect();
+ if writer.write_all(payload.as_bytes()).is_err() {
+ break;
+ }
+ }
+ // The registry keeps a duplicate handle, so dropping this one would not close it.
+ let _ = stream.shutdown(Shutdown::Both);
+ }
+
+ fn serve(self: &Arc, stream: TcpStream) {
+ let _ = stream.set_nodelay(true);
+ let Ok(registered) = stream.try_clone() else {
+ return;
+ };
+ let mut connections = self.connections();
+ let id = connections.next_id;
+ connections.next_id += 1;
+ connections.streams.insert(id, registered);
+ let shared = Arc::clone(self);
+ connections.workers.push(thread::spawn(move || {
+ shared.converse(stream);
+ shared.connections().streams.remove(&id);
+ }));
+ }
+}
+
+fn is_poll_timeout(error: &io::Error) -> bool {
+ matches!(
+ error.kind(),
+ io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
+ )
+}
+
+fn overlong_line() -> String {
+ FILLER_BYTE.to_string().repeat(LINE_LIMIT_BYTES + 1)
+}
+
+fn accept_loop(shared: Arc, listener: TcpListener) {
+ for incoming in listener.incoming() {
+ if shared.stopping.load(Ordering::SeqCst) {
+ break;
+ }
+ if let Ok(stream) = incoming {
+ shared.serve(stream);
+ }
+ }
+}
+
+pub struct Simulator {
+ shared: Arc,
+ address: SocketAddrV6,
+ acceptor: Option>,
+}
+
+impl Simulator {
+ /// Starts on a free port of the IPv6 loopback address.
+ pub fn start(model: SimModel) -> Simulator {
+ let any_port = SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0);
+ Simulator::start_on(model, any_port).expect("IPv6 loopback is available")
+ }
+
+ /// Starts on `address`, e.g. the one a stopped simulator had, to model a restart.
+ pub fn start_on(model: SimModel, address: SocketAddrV6) -> io::Result {
+ let listener = TcpListener::bind(address)?;
+ let SocketAddr::V6(bound) = listener.local_addr()? else {
+ return Err(io::Error::other("listener is not IPv6"));
+ };
+ let shared = Arc::new(Shared {
+ state: Mutex::new(State {
+ device: SimulatedDevice::new(model),
+ faults: VecDeque::new(),
+ requests: Vec::new(),
+ }),
+ connections: Mutex::new(Connections::default()),
+ stopping: AtomicBool::new(false),
+ });
+ let acceptor = thread::spawn({
+ let shared = Arc::clone(&shared);
+ move || accept_loop(shared, listener)
+ });
+ Ok(Simulator {
+ shared,
+ address: bound,
+ acceptor: Some(acceptor),
+ })
+ }
+
+ pub fn address(&self) -> SocketAddrV6 {
+ self.address
+ }
+
+ /// Closes the listener and every open connection; the port is free again on return.
+ pub fn stop(self) {
+ drop(self);
+ }
+
+ /// Queues a fault for the next reply; several queued faults apply one per reply.
+ pub fn inject(&self, fault: Fault) {
+ self.shared.state().faults.push_back(fault);
+ }
+
+ /// Every request received so far, parsed.
+ pub fn requests(&self) -> Vec {
+ self.shared.state().requests.clone()
+ }
+
+ pub fn value(&self, path: &str) -> Option {
+ self.shared.state().device.value(path)
+ }
+
+ /// Changes the tree behind the client's back, e.g. to model a knob turned on the speaker.
+ pub fn set_value(&self, path: &str, value: Value) {
+ self.shared.state().device.set_value(path, value);
+ }
+
+ pub fn remove_value(&self, path: &str) {
+ self.shared.state().device.remove_value(path);
+ }
+}
+
+impl Drop for Simulator {
+ fn drop(&mut self) {
+ self.shared.stopping.store(true, Ordering::SeqCst);
+ // accept() blocks until a connection arrives, so one is made to let the loop see the flag.
+ let _ = TcpStream::connect_timeout(&SocketAddr::V6(self.address), WAKE_TIMEOUT);
+ if let Some(acceptor) = self.acceptor.take() {
+ let _ = acceptor.join();
+ }
+ let workers = {
+ let mut connections = self.shared.connections();
+ for stream in connections.streams.values() {
+ let _ = stream.shutdown(Shutdown::Both);
+ }
+ std::mem::take(&mut connections.workers)
+ };
+ for worker in workers {
+ let _ = worker.join();
+ }
+ }
+}
diff --git a/crates/core/src/ssc/wire.rs b/crates/core/src/ssc/wire.rs
index d73367d..e0b09ac 100644
--- a/crates/core/src/ssc/wire.rs
+++ b/crates/core/src/ssc/wire.rs
@@ -79,7 +79,7 @@ pub struct DeviceError {
}
/// Merges `incoming` into `target`: objects recursively, anything else is replaced.
-fn merge(target: &mut Value, incoming: Value) {
+pub fn merge(target: &mut Value, incoming: Value) {
match (target, incoming) {
(Value::Object(existing), Value::Object(additions)) => {
for (key, value) in additions {
diff --git a/crates/core/tests/fixtures/recorded_replies.json b/crates/core/tests/fixtures/recorded_replies.json
index d341f74..5931fef 100644
--- a/crates/core/tests/fixtures/recorded_replies.json
+++ b/crates/core/tests/fixtures/recorded_replies.json
@@ -249,5 +249,23 @@
},
"reply": "{\"audio\":{\"out\":{\"mute\":false}},\"osc\":{\"xid\":\"abc\"}}"
}
+ ],
+ "osc_methods": [
+ {
+ "request": { "osc": { "version": null } },
+ "reply": "{\"osc\":{\"version\":\"1.1\"}}"
+ },
+ {
+ "request": { "osc": { "feature": { "pattern": null } } },
+ "reply": "{\"osc\":{\"feature\":{\"pattern\":\"*?\"}}}"
+ },
+ {
+ "request": { "osc": { "feature": { "subscription": null } } },
+ "reply": "{\"osc\":{\"feature\":{\"subscription\":true}}}"
+ },
+ {
+ "request": { "osc": { "ping": null } },
+ "reply": "{\"osc\":{\"ping\":null}}"
+ }
]
}
diff --git a/crates/core/tests/simulator.rs b/crates/core/tests/simulator.rs
new file mode 100644
index 0000000..6650d25
--- /dev/null
+++ b/crates/core/tests/simulator.rs
@@ -0,0 +1,426 @@
+use std::io::{BufRead, BufReader, Read, Write};
+use std::net::{Ipv6Addr, SocketAddrV6, TcpStream};
+use std::time::Duration;
+
+use neumann_control_core::simulator::{Fault, SimModel, Simulator};
+use neumann_control_core::ssc::wire::{device_error, parse_line, reply_xid};
+use serde_json::{json, Value};
+
+const FIXTURES: &str = include_str!("fixtures/recorded_replies.json");
+const READ_TIMEOUT: Duration = Duration::from_secs(3);
+const SILENCE_TIMEOUT: Duration = Duration::from_millis(400);
+// Longer than the simulator's read poll interval, so the first half is seen on its own.
+const PARTIAL_LINE_PAUSE: Duration = Duration::from_millis(200);
+const LINE_LIMIT_BYTES: usize = 1_048_576;
+const FOREIGN_XID_OFFSET: u64 = 1000;
+const KH_120_II: &str = "KH 120 II";
+const KH_750: &str = "KH 750";
+const KH_120_II_LEAVES: usize = 50;
+const KH_750_LEAVES: usize = 129;
+
+struct Client {
+ reader: BufReader,
+ writer: TcpStream,
+}
+
+impl Client {
+ fn connect(simulator: &Simulator) -> Client {
+ let stream = TcpStream::connect(simulator.address()).expect("simulator accepts");
+ stream.set_read_timeout(Some(READ_TIMEOUT)).unwrap();
+ Client {
+ reader: BufReader::new(stream.try_clone().unwrap()),
+ writer: stream,
+ }
+ }
+
+ fn send(&mut self, request: &Value) {
+ write!(self.writer, "{request}\r\n").expect("request is written");
+ }
+
+ fn read_raw_line(&mut self) -> Option {
+ let mut line = String::new();
+ match self.reader.read_line(&mut line) {
+ Ok(0) | Err(_) => None,
+ Ok(_) => Some(line.trim_end().to_owned()),
+ }
+ }
+
+ fn read_tree(&mut self) -> Value {
+ let line = self.read_raw_line().expect("a reply line");
+ parse_line(line.as_bytes()).expect("reply is a JSON object")
+ }
+
+ fn ask(&mut self, request: &Value) -> Value {
+ self.send(request);
+ self.read_tree()
+ }
+}
+
+fn model_of(name: &str) -> SimModel {
+ match name {
+ KH_120_II => SimModel::Kh120Ii,
+ KH_750 => SimModel::Kh750,
+ other => panic!("unknown model {other}"),
+ }
+}
+
+fn start_kh120() -> Simulator {
+ Simulator::start(SimModel::Kh120Ii)
+}
+
+/// The reply with its transaction id removed, and an `osc` object left empty by that dropped.
+fn without_xid(mut tree: Value) -> Value {
+ let Some(osc) = tree.get_mut("osc").and_then(Value::as_object_mut) else {
+ return tree;
+ };
+ osc.remove("xid");
+ if osc.is_empty() {
+ tree.as_object_mut().unwrap().remove("osc");
+ }
+ tree
+}
+
+fn recorded_entries() -> Vec<(String, Value, String, &'static str)> {
+ let fixtures: Value = serde_json::from_str(FIXTURES).expect("fixture file is valid JSON");
+ let mut entries = Vec::new();
+ for (group, members) in fixtures.as_object().unwrap() {
+ for member in members.as_array().unwrap() {
+ let model = match (group.as_str(), member["model"].as_str()) {
+ (_, Some(KH_750)) | ("kh750_not_found", _) => KH_750,
+ _ => KH_120_II,
+ };
+ entries.push((
+ group.clone(),
+ member["request"].clone(),
+ member["reply"].as_str().unwrap().to_owned(),
+ model,
+ ));
+ }
+ }
+ entries
+}
+
+#[test]
+fn simulator_matches_recorded_replies() {
+ let entries = recorded_entries();
+ let groups: std::collections::BTreeSet<_> =
+ entries.iter().map(|entry| entry.0.as_str()).collect();
+ assert!(groups.contains("osc_methods") && groups.contains("schema"));
+ for (group, request, recorded, model) in &entries {
+ let simulator = Simulator::start(model_of(model));
+ let reply = Client::connect(&simulator).ask(request);
+ let expected = parse_line(recorded.as_bytes()).unwrap();
+ assert_eq!(
+ without_xid(reply),
+ without_xid(expected),
+ "{group} / {model}: {request}"
+ );
+ }
+}
+
+#[test]
+fn simulator_echoes_xid() {
+ let simulator = start_kh120();
+ let mut client = Client::connect(&simulator);
+ let numeric = client.ask(&json!({"osc":{"xid":4711},"audio":{"out":{"level":null}}}));
+ assert_eq!(reply_xid(&numeric), Some(4711));
+ let text = client.ask(&json!({"osc":{"xid":"abc"},"audio":{"out":{"mute":null}}}));
+ assert_eq!(text["osc"]["xid"], json!("abc"));
+ let without = client.ask(&json!({"audio":{"out":{"mute":null}}}));
+ assert!(without.get("osc").is_none(), "no xid was asked for");
+}
+
+#[test]
+fn error_reply_carries_xid_as_second_osc_member() {
+ let simulator = start_kh120();
+ let mut client = Client::connect(&simulator);
+ client.send(&json!({"osc":{"xid":7},"audio":{"out":{"dimm":null}}}));
+ let line = client.read_raw_line().unwrap();
+ assert_eq!(line.matches("\"osc\"").count(), 2, "{line}");
+ let tree = parse_line(line.as_bytes()).unwrap();
+ assert_eq!(reply_xid(&tree), Some(7));
+ assert_eq!(device_error(&tree).unwrap().code, 404);
+}
+
+#[test]
+fn simulator_refuses_read_only_set_with_405() {
+ let simulator = start_kh120();
+ let mut client = Client::connect(&simulator);
+ let reply = client.ask(&json!({"ui":{"input_gain":-3.0}}));
+ let error = device_error(&reply).expect("an error reply");
+ assert_eq!(
+ (error.code, error.description.as_str()),
+ (405, "method not allowed")
+ );
+ assert_eq!(simulator.value("ui/input_gain"), Some(json!(0.0)));
+}
+
+#[test]
+fn simulator_refuses_standby_enabled_set_on_kh750_only() {
+ let kh750 = Simulator::start(SimModel::Kh750);
+ let refused = Client::connect(&kh750).ask(&json!({"device":{"standby":{"enabled":false}}}));
+ assert_eq!(device_error(&refused).unwrap().code, 405);
+ let kh120 = start_kh120();
+ let accepted = Client::connect(&kh120).ask(&json!({"device":{"standby":{"enabled":false}}}));
+ assert!(device_error(&accepted).is_none());
+ assert_eq!(kh120.value("device/standby/enabled"), Some(json!(false)));
+}
+
+#[test]
+fn valid_set_is_stored_and_echoed() {
+ let simulator = start_kh120();
+ let mut client = Client::connect(&simulator);
+ let reply = client.ask(&json!({"audio":{"out":{"level":90}}}));
+ assert_eq!(reply, json!({"audio":{"out":{"level":90.0}}}));
+ assert_eq!(simulator.value("audio/out/level"), Some(json!(90.0)));
+ let wrong_list_length = client.ask(&json!({"audio":{"out":{"eq2":{"enabled":[true]}}}}));
+ assert_eq!(device_error(&wrong_list_length).unwrap().code, 406);
+ let wrong_type = client.ask(&json!({"audio":{"out":{"mute":"x"}}}));
+ assert_eq!(device_error(&wrong_type).unwrap().code, 406);
+ assert_eq!(simulator.value("audio/out/mute"), Some(json!(false)));
+}
+
+#[test]
+fn simulator_schema_root_lists_top_level() {
+ let simulator = start_kh120();
+ let reply = Client::connect(&simulator).ask(&json!({"osc":{"schema":null}}));
+ let level = &reply["osc"]["schema"][0];
+ for container in ["device", "osc", "audio", "m", "ui"] {
+ assert_eq!(level[container], json!({}), "{container}");
+ }
+ assert_eq!(level["warnings"], Value::Null);
+ assert_eq!(level.as_object().unwrap().len(), 6);
+ let unknown = Client::connect(&simulator).ask(&json!({"osc":{"schema":[{"nowhere":null}]}}));
+ assert_eq!(device_error(&unknown).unwrap().code, 454);
+}
+
+fn count_leaves(client: &mut Client, prefix: &[&str], level: &Value) -> usize {
+ level
+ .as_object()
+ .unwrap()
+ .iter()
+ .map(|(name, node)| {
+ if !node.is_object() {
+ return 1;
+ }
+ let mut path = prefix.to_vec();
+ path.push(name);
+ let request = path.iter().rev().fold(
+ Value::Null,
+ |inner, segment| json!({ (segment.to_string()): inner }),
+ );
+ let reply = client.ask(&json!({"osc":{"schema":[request]}}));
+ let mut child = reply["osc"]["schema"][0].clone();
+ for segment in &path {
+ child = child[segment].clone();
+ }
+ count_leaves(client, &path, &child)
+ })
+ .sum()
+}
+
+#[test]
+fn simulator_trees_have_the_measured_leaf_counts() {
+ for (model, expected) in [
+ (SimModel::Kh120Ii, KH_120_II_LEAVES),
+ (SimModel::Kh750, KH_750_LEAVES),
+ ] {
+ let simulator = Simulator::start(model);
+ let mut client = Client::connect(&simulator);
+ let root = client.ask(&json!({"osc":{"schema":null}}))["osc"]["schema"][0].clone();
+ let mut level = root.as_object().unwrap().clone();
+ level.remove("osc");
+ assert_eq!(
+ count_leaves(&mut client, &[], &Value::Object(level)),
+ expected
+ );
+ }
+}
+
+#[test]
+fn simulator_reports_limits_type_and_identity() {
+ let simulator = Simulator::start(SimModel::Kh750);
+ let mut client = Client::connect(&simulator);
+ let number = client.ask(&json!({"osc":{"limits":[{"audio":{"out":{"level":null}}}]}}));
+ assert_eq!(
+ number["osc"]["limits"][0]["audio"]["out"]["level"],
+ json!([{"type":"Number"}])
+ );
+ let list = client.ask(&json!({"osc":{"limits":[{"m":{"in":{"clip":null}}}]}}));
+ assert_eq!(
+ list["osc"]["limits"][0]["m"]["in"]["clip"],
+ json!([{"type":"Boolean"}])
+ );
+ let text = client.ask(&json!({"osc":{"limits":[{"device":{"name":null}}]}}));
+ assert_eq!(
+ text["osc"]["limits"][0]["device"]["name"],
+ json!([{"type":"String"}])
+ );
+ assert_eq!(
+ simulator.value("device/identity/serial"),
+ Some(json!("SIM0007500"))
+ );
+ assert_eq!(
+ simulator.value("device/identity/version"),
+ Some(json!("2_1_2"))
+ );
+ assert_eq!(
+ simulator.value("device/identity/hw_version"),
+ Some(json!(1))
+ );
+}
+
+#[test]
+fn requests_are_recorded_parsed() {
+ let simulator = start_kh120();
+ let mut client = Client::connect(&simulator);
+ let request = json!({"osc":{"xid":1},"audio":{"out":{"mute":null}}});
+ client.ask(&request);
+ assert_eq!(simulator.requests(), vec![request]);
+}
+
+#[test]
+fn set_value_and_remove_value_change_the_tree() {
+ let simulator = start_kh120();
+ let mut client = Client::connect(&simulator);
+ simulator.set_value("audio/out/level", json!(42.5));
+ let changed = client.ask(&json!({"audio":{"out":{"level":null}}}));
+ assert_eq!(changed["audio"]["out"]["level"], json!(42.5));
+ simulator.remove_value("audio/out/level");
+ let removed = client.ask(&json!({"audio":{"out":{"level":null}}}));
+ assert_eq!(device_error(&removed).unwrap().code, 404);
+ assert_eq!(simulator.value("audio/out/level"), None);
+}
+
+#[test]
+fn fault_close_connection_ends_stream() {
+ let simulator = start_kh120();
+ let mut client = Client::connect(&simulator);
+ simulator.inject(Fault::CloseConnection);
+ client.send(&json!({"audio":{"out":{"mute":null}}}));
+ assert_eq!(client.read_raw_line(), None);
+ let mut second = Client::connect(&simulator);
+ let healthy = second.ask(&json!({"audio":{"out":{"mute":null}}}));
+ assert_eq!(
+ healthy["audio"]["out"]["mute"],
+ json!(false),
+ "fault was spent"
+ );
+}
+
+#[test]
+fn fault_stale_line_comes_before_the_real_reply() {
+ let simulator = start_kh120();
+ let mut client = Client::connect(&simulator);
+ simulator.inject(Fault::StaleLineFirst);
+ client.send(&json!({"osc":{"xid":5},"audio":{"out":{"mute":null}}}));
+ let stale = client.read_tree();
+ assert_ne!(reply_xid(&stale), Some(5));
+ let real = client.read_tree();
+ assert_eq!(reply_xid(&real), Some(5));
+ assert_eq!(real["audio"]["out"]["mute"], json!(false));
+}
+
+#[test]
+fn fault_foreign_xid_comes_before_the_real_reply() {
+ let simulator = start_kh120();
+ let mut client = Client::connect(&simulator);
+ simulator.inject(Fault::ForeignXidFirst);
+ client.send(&json!({"osc":{"xid":5},"audio":{"out":{"mute":null}}}));
+ assert_eq!(reply_xid(&client.read_tree()), Some(5 + FOREIGN_XID_OFFSET));
+ assert_eq!(reply_xid(&client.read_tree()), Some(5));
+}
+
+#[test]
+fn fault_no_xid_echo_drops_the_id_once() {
+ let simulator = start_kh120();
+ let mut client = Client::connect(&simulator);
+ simulator.inject(Fault::NoXidEcho);
+ let request = json!({"osc":{"xid":5},"audio":{"out":{"mute":null}}});
+ assert_eq!(reply_xid(&client.ask(&request)), None);
+ assert_eq!(reply_xid(&client.ask(&request)), Some(5));
+}
+
+#[test]
+fn fault_overlong_line_exceeds_the_limit() {
+ let simulator = start_kh120();
+ let mut client = Client::connect(&simulator);
+ simulator.inject(Fault::OverlongLine);
+ client.send(&json!({"audio":{"out":{"mute":null}}}));
+ let mut received = Vec::new();
+ client
+ .reader
+ .by_ref()
+ .take(LINE_LIMIT_BYTES as u64 + 1)
+ .read_to_end(&mut received)
+ .unwrap();
+ assert_eq!(received.len(), LINE_LIMIT_BYTES + 1);
+ assert!(!received.contains(&b'\n'));
+}
+
+#[test]
+fn fault_silence_never_answers_that_request() {
+ let simulator = start_kh120();
+ let mut client = Client::connect(&simulator);
+ simulator.inject(Fault::Silence);
+ client.send(&json!({"audio":{"out":{"mute":null}}}));
+ client
+ .writer
+ .set_read_timeout(Some(SILENCE_TIMEOUT))
+ .unwrap();
+ assert_eq!(client.read_raw_line(), None);
+ assert_eq!(simulator.requests().len(), 1, "the request was read");
+}
+
+#[test]
+fn stop_closes_connections_and_refuses_new_ones() {
+ let simulator = start_kh120();
+ let address = simulator.address();
+ let mut client = Client::connect(&simulator);
+ client.ask(&json!({"osc":{"ping":null}}));
+ simulator.stop();
+ assert_eq!(client.read_raw_line(), None, "open connection is closed");
+ assert!(TcpStream::connect(address).is_err());
+}
+
+#[test]
+fn start_on_rebinds_the_same_address() {
+ let first = start_kh120();
+ let address: SocketAddrV6 = first.address();
+ assert_eq!(*address.ip(), Ipv6Addr::LOCALHOST);
+ first.stop();
+ let second = Simulator::start_on(SimModel::Kh120Ii, address).expect("port is free again");
+ assert_eq!(second.address(), address);
+ let reply = Client::connect(&second).ask(&json!({"osc":{"ping":null}}));
+ assert_eq!(reply, json!({"osc":{"ping":null}}));
+}
+
+#[test]
+fn two_connections_are_served_in_parallel() {
+ let simulator = start_kh120();
+ let mut first = Client::connect(&simulator);
+ let mut second = Client::connect(&simulator);
+ assert_eq!(
+ first.ask(&json!({"osc":{"version":null}}))["osc"]["version"],
+ json!("1.1")
+ );
+ assert_eq!(
+ second.ask(&json!({"osc":{"version":null}}))["osc"]["version"],
+ json!("1.1")
+ );
+}
+
+#[test]
+fn request_split_across_reads_is_reassembled() {
+ let simulator = start_kh120();
+ let mut client = Client::connect(&simulator);
+ let request = json!({"audio":{"out":{"mute":null}}}).to_string();
+ let (first, second) = request.split_at(request.len() / 2);
+ client.writer.write_all(first.as_bytes()).unwrap();
+ std::thread::sleep(PARTIAL_LINE_PAUSE);
+ client
+ .writer
+ .write_all(format!("{second}\r\n").as_bytes())
+ .unwrap();
+ assert_eq!(client.read_tree()["audio"]["out"]["mute"], json!(false));
+}
From bed88343276a16388a3098633c24717bd5f319bc Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 22:40:52 +0200
Subject: [PATCH 006/460] Add blocking SSC client with xid matching
---
crates/core/Cargo.toml | 4 +
crates/core/src/ssc/client.rs | 375 ++++++++++++++++++++++++++++++++++
crates/core/src/ssc/mod.rs | 1 +
crates/core/tests/client.rs | 192 +++++++++++++++++
4 files changed, 572 insertions(+)
create mode 100644 crates/core/src/ssc/client.rs
create mode 100644 crates/core/tests/client.rs
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index bb0a1e3..241b658 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -16,3 +16,7 @@ serde_json = "1"
[[test]]
name = "simulator"
required-features = ["test-support"]
+
+[[test]]
+name = "client"
+required-features = ["test-support"]
diff --git a/crates/core/src/ssc/client.rs b/crates/core/src/ssc/client.rs
new file mode 100644
index 0000000..edc33fd
--- /dev/null
+++ b/crates/core/src/ssc/client.rs
@@ -0,0 +1,375 @@
+//! Blocking SSC v1 client: one TCP connection to one speaker, one request in flight.
+//!
+//! Replies are matched by the transaction id the client sends with every request. The reader
+//! enforces every limit while reading, because the peer is a network device that can send
+//! anything; after any breach the connection is closed and the client is single-use.
+
+use std::fmt;
+use std::io::{self, Read, Write};
+use std::net::{Shutdown, SocketAddr, SocketAddrV6, TcpStream};
+use std::time::{Duration, Instant};
+
+use serde_json::{Map, Value};
+
+use crate::mask::mask_host;
+use crate::ssc::wire::{
+ device_error, extract, number, parse_line, reply_xid, request_line, DeviceError, SscPath,
+};
+
+pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(3);
+pub const RESPONSE_TIMEOUT: Duration = Duration::from_secs(3);
+pub const MAX_LINE_BYTES: usize = 1_048_576;
+pub const MAX_RESPONSE_LINES: usize = 256;
+pub const MAX_RESPONSE_BYTES: usize = 4 * MAX_LINE_BYTES;
+pub const MAX_READ_DURATION: Duration = Duration::from_secs(10);
+
+const READ_CHUNK_BYTES: usize = 8192;
+const LINE_TERMINATOR: &str = "\r\n";
+const FIRST_XID: u64 = 1;
+const SCHEMA_PATH: &str = "osc/schema";
+const LIMITS_PATH: &str = "osc/limits";
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum NodeKind {
+ Container,
+ Leaf,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum SscError {
+ Connection(String),
+ Timeout,
+ Device(DeviceError),
+ Protocol(String),
+}
+
+impl SscError {
+ /// Whether the connection is unusable afterwards and the caller has to reconnect.
+ pub fn is_connection_lost(&self) -> bool {
+ !matches!(self, SscError::Device(_))
+ }
+}
+
+impl fmt::Display for SscError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ SscError::Connection(message) => write!(formatter, "connection error: {message}"),
+ SscError::Timeout => formatter.write_str("the speaker did not answer in time"),
+ SscError::Device(error) => write!(
+ formatter,
+ "the speaker refused the request ({}): {}",
+ error.code, error.description
+ ),
+ SscError::Protocol(message) => write!(formatter, "protocol error: {message}"),
+ }
+ }
+}
+
+impl std::error::Error for SscError {}
+
+/// The socket plus the bytes read from it but not yet handed out as lines.
+struct Connection {
+ stream: TcpStream,
+ host: String,
+ pending: Vec,
+ scanned: usize,
+}
+
+impl Connection {
+ fn send_line(&mut self, line: &str) -> Result<(), SscError> {
+ let message = format!("{line}{LINE_TERMINATOR}");
+ self.stream
+ .set_write_timeout(Some(RESPONSE_TIMEOUT))
+ .and_then(|()| self.stream.write_all(message.as_bytes()))
+ .map_err(|error| self.connection_error("write to", &error))
+ }
+
+ /// The next line without its terminator. Never buffers more than one line limit.
+ fn next_line(&mut self, deadline: Instant) -> Result, SscError> {
+ loop {
+ if let Some(line) = self.take_line()? {
+ return Ok(line);
+ }
+ if self.pending.len() > MAX_LINE_BYTES + 1 {
+ return Err(self.line_too_long());
+ }
+ self.fill(deadline)?;
+ }
+ }
+
+ fn take_line(&mut self) -> Result>, SscError> {
+ let unscanned = &self.pending[self.scanned..];
+ let Some(offset) = unscanned.iter().position(|&byte| byte == b'\n') else {
+ self.scanned = self.pending.len();
+ return Ok(None);
+ };
+ let end = self.scanned + offset;
+ let mut line: Vec = self.pending.drain(..=end).collect();
+ self.scanned = 0;
+ line.pop();
+ if line.last() == Some(&b'\r') {
+ line.pop();
+ }
+ if line.len() > MAX_LINE_BYTES {
+ return Err(self.line_too_long());
+ }
+ Ok(Some(line))
+ }
+
+ fn fill(&mut self, deadline: Instant) -> Result<(), SscError> {
+ let remaining = deadline.saturating_duration_since(Instant::now());
+ if remaining.is_zero() {
+ return Err(self.read_duration_exceeded());
+ }
+ let wait = remaining.min(RESPONSE_TIMEOUT);
+ let mut chunk = [0u8; READ_CHUNK_BYTES];
+ let outcome = self
+ .stream
+ .set_read_timeout(Some(wait))
+ .and_then(|()| self.stream.read(&mut chunk));
+ match outcome {
+ Ok(0) => Err(SscError::Connection(format!(
+ "{} closed the connection",
+ self.host
+ ))),
+ Ok(count) => {
+ self.pending.extend_from_slice(&chunk[..count]);
+ Ok(())
+ }
+ Err(error) if error.kind() == io::ErrorKind::Interrupted => Ok(()),
+ // A wait shortened to the remaining budget is the read-duration limit, not silence.
+ Err(error) if is_timeout(&error) && wait < RESPONSE_TIMEOUT => {
+ Err(self.read_duration_exceeded())
+ }
+ Err(error) if is_timeout(&error) => Err(SscError::Timeout),
+ Err(error) => Err(self.connection_error("read from", &error)),
+ }
+ }
+
+ fn connection_error(&self, action: &str, error: &io::Error) -> SscError {
+ SscError::Connection(format!("could not {action} {}: {error}", self.host))
+ }
+
+ fn line_too_long(&self) -> SscError {
+ SscError::Protocol(format!(
+ "{} sent a line over {MAX_LINE_BYTES} bytes",
+ self.host
+ ))
+ }
+
+ fn read_duration_exceeded(&self) -> SscError {
+ SscError::Protocol(format!(
+ "{} kept sending for more than {} s",
+ self.host,
+ MAX_READ_DURATION.as_secs()
+ ))
+ }
+}
+
+fn is_timeout(error: &io::Error) -> bool {
+ matches!(
+ error.kind(),
+ io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut
+ )
+}
+
+/// What to do with a line that arrived while waiting for the reply to one request.
+enum Verdict {
+ Reply,
+ Skip,
+}
+
+pub struct Client {
+ connection: Connection,
+ next_xid: u64,
+ closed: bool,
+}
+
+impl Client {
+ pub fn connect(address: SocketAddrV6) -> Result {
+ let host = mask_host(&address);
+ let stream = TcpStream::connect_timeout(&SocketAddr::V6(address), CONNECT_TIMEOUT)
+ .map_err(|error| match error.kind() {
+ io::ErrorKind::TimedOut => SscError::Timeout,
+ _ => SscError::Connection(format!("could not connect to {host}: {error}")),
+ })?;
+ // Requests are single small lines; Nagle's delay would add latency to each of them.
+ let _ = stream.set_nodelay(true);
+ Ok(Client {
+ connection: Connection {
+ stream,
+ host,
+ pending: Vec::new(),
+ scanned: 0,
+ },
+ next_xid: FIRST_XID,
+ closed: false,
+ })
+ }
+
+ pub fn is_closed(&self) -> bool {
+ self.closed
+ }
+
+ pub fn get(&mut self, path: &SscPath) -> Result {
+ let reply = self.exchange(path, Value::Null)?;
+ confirmed_value(&reply, path)
+ }
+
+ /// Sets a value and returns the value the device confirms (it may clamp).
+ pub fn set(&mut self, path: &SscPath, value: Value) -> Result {
+ let reply = self.exchange(path, value)?;
+ confirmed_value(&reply, path)
+ }
+
+ /// Like `set` for a number; NaN and infinities have no JSON form and are never sent.
+ pub fn set_number(&mut self, path: &SscPath, value: f64) -> Result {
+ let value = number(value).map_err(|error| SscError::Protocol(error.to_string()))?;
+ self.set(path, value)
+ }
+
+ /// The children of `path` (the root for `None`) and whether each is a container.
+ pub fn schema(&mut self, path: Option<&SscPath>) -> Result, SscError> {
+ let argument = path.map_or(Value::Null, introspection_argument);
+ let reply = self.exchange(&introspection_path(SCHEMA_PATH), argument)?;
+ match introspected_node(&reply, SCHEMA_PATH, path)? {
+ Value::Object(children) => Ok(children
+ .iter()
+ .map(|(name, child)| (name.clone(), node_kind(child)))
+ .collect()),
+ Value::Null => Ok(Vec::new()),
+ _ => Err(protocol("schema level is not an object")),
+ }
+ }
+
+ /// The limits dict of one leaf.
+ pub fn limits(&mut self, path: &SscPath) -> Result {
+ let reply = self.exchange(
+ &introspection_path(LIMITS_PATH),
+ introspection_argument(path),
+ )?;
+ let node = introspected_node(&reply, LIMITS_PATH, Some(path))?;
+ let description = match node {
+ Value::Array(items) => items.first(),
+ other => Some(other),
+ };
+ match description {
+ Some(found @ Value::Object(_)) => Ok(found.clone()),
+ _ => Err(protocol("limits reply holds no description")),
+ }
+ }
+
+ fn close(&mut self) {
+ self.closed = true;
+ let _ = self.connection.stream.shutdown(Shutdown::Both);
+ }
+
+ /// One request and its reply tree; a device error in the reply becomes `Err`.
+ fn exchange(&mut self, path: &SscPath, value: Value) -> Result {
+ if self.closed {
+ let host = &self.connection.host;
+ return Err(SscError::Connection(format!(
+ "connection to {host} is closed"
+ )));
+ }
+ let result = self.transact(path, value);
+ if matches!(&result, Err(error) if error.is_connection_lost()) {
+ self.close();
+ }
+ result
+ }
+
+ fn transact(&mut self, path: &SscPath, value: Value) -> Result {
+ let xid = self.next_xid;
+ self.next_xid += 1;
+ self.connection.send_line(&request_line(path, value, xid))?;
+ let reply = self.read_reply(xid, path)?;
+ match device_error(&reply) {
+ Some(error) => Err(SscError::Device(error)),
+ None => Ok(reply),
+ }
+ }
+
+ fn read_reply(&mut self, xid: u64, path: &SscPath) -> Result {
+ let deadline = Instant::now() + MAX_READ_DURATION;
+ let mut lines = 0usize;
+ let mut bytes = 0usize;
+ loop {
+ let line = self.connection.next_line(deadline)?;
+ lines += 1;
+ bytes += line.len() + LINE_TERMINATOR.len();
+ if lines > MAX_RESPONSE_LINES || bytes > MAX_RESPONSE_BYTES {
+ return Err(protocol("response exceeded the line or byte budget"));
+ }
+ if line.is_empty() {
+ continue;
+ }
+ let tree = parse_line(&line).map_err(|error| SscError::Protocol(error.to_string()))?;
+ if let Verdict::Reply = judge(&tree, xid, path) {
+ return Ok(tree);
+ }
+ }
+ }
+}
+
+/// A line with our id is the reply, one with another id is stale; a line without an id counts
+/// as the reply only when it answers the requested path or carries an error.
+fn judge(tree: &Value, xid: u64, path: &SscPath) -> Verdict {
+ match reply_xid(tree) {
+ Some(received) if received == xid => Verdict::Reply,
+ Some(_) => Verdict::Skip,
+ None if extract(tree, path).is_some() || device_error(tree).is_some() => Verdict::Reply,
+ None => Verdict::Skip,
+ }
+}
+
+fn confirmed_value(reply: &Value, path: &SscPath) -> Result {
+ extract(reply, path)
+ .cloned()
+ .ok_or_else(|| protocol("reply without the requested path"))
+}
+
+fn protocol(message: &str) -> SscError {
+ SscError::Protocol(message.to_owned())
+}
+
+fn introspection_path(text: &str) -> SscPath {
+ SscPath::parse(text).expect("constant introspection path is valid")
+}
+
+/// `[ {a:{b:null}} ]`: the list holding one address tree that ends in `null`.
+fn introspection_argument(path: &SscPath) -> Value {
+ let tree = path
+ .segments()
+ .iter()
+ .rev()
+ .fold(Value::Null, |inner, segment| {
+ Value::Object(Map::from_iter([(segment.clone(), inner)]))
+ });
+ Value::Array(vec![tree])
+}
+
+/// The node an `osc/schema` or `osc/limits` reply describes: the first list entry, followed
+/// down `path` (the entry itself for the root).
+fn introspected_node<'a>(
+ reply: &'a Value,
+ method: &str,
+ path: Option<&SscPath>,
+) -> Result<&'a Value, SscError> {
+ let listed = extract(reply, &introspection_path(method))
+ .and_then(Value::as_array)
+ .and_then(|items| items.first())
+ .ok_or_else(|| protocol("reply without the requested path"))?;
+ let segments = path.map_or(&[][..], SscPath::segments);
+ segments
+ .iter()
+ .try_fold(listed, |node, segment| node.get(segment))
+ .ok_or_else(|| protocol("reply does not describe the requested path"))
+}
+
+fn node_kind(node: &Value) -> NodeKind {
+ match node {
+ Value::Object(_) => NodeKind::Container,
+ _ => NodeKind::Leaf,
+ }
+}
diff --git a/crates/core/src/ssc/mod.rs b/crates/core/src/ssc/mod.rs
index 349da7c..70f7ef1 100644
--- a/crates/core/src/ssc/mod.rs
+++ b/crates/core/src/ssc/mod.rs
@@ -1 +1,2 @@
+pub mod client;
pub mod wire;
diff --git a/crates/core/tests/client.rs b/crates/core/tests/client.rs
new file mode 100644
index 0000000..ccf7595
--- /dev/null
+++ b/crates/core/tests/client.rs
@@ -0,0 +1,192 @@
+use std::time::{Duration, Instant};
+
+use neumann_control_core::simulator::{Fault, SimModel, Simulator};
+use neumann_control_core::ssc::client::{Client, NodeKind, SscError};
+use neumann_control_core::ssc::wire::SscPath;
+use serde_json::{json, Value};
+
+const SILENCE_DEADLINE: Duration = Duration::from_millis(3500);
+const MISSING_PATH_CODE: i64 = 404;
+const READ_ONLY_CODE: i64 = 405;
+const WRONG_TYPE_CODE: i64 = 406;
+
+fn path(text: &str) -> SscPath {
+ SscPath::parse(text).expect("a valid path")
+}
+
+fn start(model: SimModel) -> (Simulator, Client) {
+ let simulator = Simulator::start(model);
+ let client = Client::connect(simulator.address()).expect("simulator accepts");
+ (simulator, client)
+}
+
+fn device_code(error: SscError) -> i64 {
+ match error {
+ SscError::Device(device) => device.code,
+ other => panic!("expected a device error, got {other:?}"),
+ }
+}
+
+#[test]
+fn get_returns_value() {
+ let (_simulator, mut client) = start(SimModel::Kh750);
+ assert_eq!(client.get(&path("audio/out/level")).unwrap(), json!(100.0));
+}
+
+#[test]
+fn set_returns_confirmed_value() {
+ let (simulator, mut client) = start(SimModel::Kh750);
+ let confirmed = client.set(&path("audio/out/mute"), json!(true)).unwrap();
+ assert_eq!(confirmed, json!(true));
+ assert_eq!(simulator.value("audio/out/mute"), Some(json!(true)));
+}
+
+#[test]
+fn missing_path_is_device_error_404() {
+ let (_simulator, mut client) = start(SimModel::Kh750);
+ let error = client.get(&path("audio/out5/level")).unwrap_err();
+ assert_eq!(device_code(error), MISSING_PATH_CODE);
+ assert!(!client.is_closed());
+}
+
+#[test]
+fn read_only_set_is_device_error_405() {
+ let (_simulator, mut client) = start(SimModel::Kh750);
+ let error = client
+ .set(&path("device/identity/serial"), json!("X"))
+ .unwrap_err();
+ assert_eq!(device_code(error), READ_ONLY_CODE);
+}
+
+#[test]
+fn wrong_type_set_is_device_error_406() {
+ let (_simulator, mut client) = start(SimModel::Kh750);
+ let error = client
+ .set(&path("audio/out/level"), json!("loud"))
+ .unwrap_err();
+ assert_eq!(device_code(error), WRONG_TYPE_CODE);
+}
+
+// A stale line without an id is accepted when it carries the requested path, so the stale
+// line of the simulator (audio/out/level) must be skipped because it lacks a different path.
+#[test]
+fn stale_line_is_skipped_by_xid() {
+ let (simulator, mut client) = start(SimModel::Kh750);
+ simulator.inject(Fault::StaleLineFirst);
+ let mute = client.get(&path("audio/out/mute")).unwrap();
+ assert_eq!(mute, json!(false));
+}
+
+#[test]
+fn foreign_xid_line_is_skipped() {
+ let (simulator, mut client) = start(SimModel::Kh750);
+ simulator.inject(Fault::ForeignXidFirst);
+ assert_eq!(client.get(&path("audio/out/level")).unwrap(), json!(100.0));
+ assert_eq!(client.get(&path("audio/out/mute")).unwrap(), json!(false));
+}
+
+#[test]
+fn reply_without_xid_is_accepted_when_it_has_the_path() {
+ let (simulator, mut client) = start(SimModel::Kh750);
+ simulator.inject(Fault::NoXidEcho);
+ assert_eq!(client.get(&path("audio/out/level")).unwrap(), json!(100.0));
+}
+
+#[test]
+fn overlong_line_closes_connection() {
+ let (simulator, mut client) = start(SimModel::Kh750);
+ simulator.inject(Fault::OverlongLine);
+ let error = client.get(&path("audio/out/level")).unwrap_err();
+ assert!(matches!(error, SscError::Protocol(_)), "got {error:?}");
+ assert!(client.is_closed());
+}
+
+#[test]
+fn silent_device_times_out() {
+ let (simulator, mut client) = start(SimModel::Kh750);
+ simulator.inject(Fault::Silence);
+ let started = Instant::now();
+ let error = client.get(&path("audio/out/level")).unwrap_err();
+ assert!(matches!(error, SscError::Timeout), "got {error:?}");
+ assert!(started.elapsed() < SILENCE_DEADLINE);
+ assert!(client.is_closed());
+ assert!(error.is_connection_lost());
+}
+
+#[test]
+fn closed_connection_is_connection_error() {
+ let (simulator, mut client) = start(SimModel::Kh750);
+ simulator.inject(Fault::CloseConnection);
+ let error = client.get(&path("audio/out/level")).unwrap_err();
+ assert!(matches!(error, SscError::Connection(_)), "got {error:?}");
+ assert!(client.is_closed());
+}
+
+#[test]
+fn closed_client_refuses_further_requests() {
+ let (simulator, mut client) = start(SimModel::Kh750);
+ simulator.inject(Fault::CloseConnection);
+ let _ = client.get(&path("audio/out/level"));
+ let error = client.get(&path("audio/out/level")).unwrap_err();
+ assert!(matches!(error, SscError::Connection(_)), "got {error:?}");
+}
+
+#[test]
+fn error_text_never_shows_the_interface_identifier() {
+ let (simulator, mut client) = start(SimModel::Kh750);
+ simulator.inject(Fault::CloseConnection);
+ let error = client.get(&path("audio/out/level")).unwrap_err();
+ let text = error.to_string();
+ assert!(
+ !text.contains(&simulator.address().ip().to_string()),
+ "{text}"
+ );
+}
+
+#[test]
+fn schema_root_and_audio_level() {
+ let (_simulator, mut client) = start(SimModel::Kh750);
+ let root = client.schema(None).unwrap();
+ assert!(root.contains(&("audio".to_owned(), NodeKind::Container)));
+ let audio = client.schema(Some(&path("audio"))).unwrap();
+ assert!(audio.contains(&("out1".to_owned(), NodeKind::Container)));
+ assert!(audio.contains(&("out2".to_owned(), NodeKind::Container)));
+ assert!(audio.contains(&("digital_bypass".to_owned(), NodeKind::Leaf)));
+}
+
+#[test]
+fn limits_returns_the_dict() {
+ let (_simulator, mut client) = start(SimModel::Kh750);
+ let limits = client.limits(&path("audio/out/level")).unwrap();
+ assert_eq!(limits["type"], json!("Number"));
+}
+
+#[test]
+fn limits_of_unknown_path_is_device_error_454() {
+ let (_simulator, mut client) = start(SimModel::Kh750);
+ let error = client.limits(&path("audio/out5/level")).unwrap_err();
+ assert_eq!(device_code(error), 454);
+}
+
+#[test]
+fn non_finite_set_is_refused_before_sending() {
+ let (simulator, mut client) = start(SimModel::Kh750);
+ let before = simulator.requests().len();
+ let error = client
+ .set_number(&path("audio/out/level"), f64::NAN)
+ .unwrap_err();
+ assert!(matches!(error, SscError::Protocol(_)), "got {error:?}");
+ let error = client
+ .set_number(&path("audio/out/level"), f64::INFINITY)
+ .unwrap_err();
+ assert!(matches!(error, SscError::Protocol(_)), "got {error:?}");
+ assert_eq!(simulator.requests().len(), before);
+ assert!(!client.is_closed());
+}
+
+#[test]
+fn set_number_sends_a_finite_number() {
+ let (_simulator, mut client) = start(SimModel::Kh750);
+ let confirmed: Value = client.set_number(&path("audio/out/level"), 90.0).unwrap();
+ assert_eq!(confirmed, json!(90.0));
+}
From 843d2be1d1fdd73da883400ef29d723cb7c7743a Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 22:44:07 +0200
Subject: [PATCH 007/460] Test the client's response budgets
---
crates/core/tests/client.rs | 155 +++++++++++++++++++++++++++++++++++-
1 file changed, 151 insertions(+), 4 deletions(-)
diff --git a/crates/core/tests/client.rs b/crates/core/tests/client.rs
index ccf7595..0c916d8 100644
--- a/crates/core/tests/client.rs
+++ b/crates/core/tests/client.rs
@@ -1,7 +1,12 @@
+use std::io::{BufRead, BufReader, Write};
+use std::net::{Ipv6Addr, SocketAddrV6, TcpListener, TcpStream};
+use std::thread;
use std::time::{Duration, Instant};
use neumann_control_core::simulator::{Fault, SimModel, Simulator};
-use neumann_control_core::ssc::client::{Client, NodeKind, SscError};
+use neumann_control_core::ssc::client::{
+ Client, NodeKind, SscError, MAX_LINE_BYTES, MAX_RESPONSE_BYTES, MAX_RESPONSE_LINES,
+};
use neumann_control_core::ssc::wire::SscPath;
use serde_json::{json, Value};
@@ -67,10 +72,9 @@ fn wrong_type_set_is_device_error_406() {
assert_eq!(device_code(error), WRONG_TYPE_CODE);
}
-// A stale line without an id is accepted when it carries the requested path, so the stale
-// line of the simulator (audio/out/level) must be skipped because it lacks a different path.
+// Rule 3: a line without an id counts only if it has the requested path.
#[test]
-fn stale_line_is_skipped_by_xid() {
+fn xidless_line_without_the_path_is_skipped() {
let (simulator, mut client) = start(SimModel::Kh750);
simulator.inject(Fault::StaleLineFirst);
let mute = client.get(&path("audio/out/mute")).unwrap();
@@ -190,3 +194,146 @@ fn set_number_sends_a_finite_number() {
let confirmed: Value = client.set_number(&path("audio/out/level"), 90.0).unwrap();
assert_eq!(confirmed, json!(90.0));
}
+
+const SHORT_LINE: &str = "{\"x\":1}
+";
+const BIG_LINE_BYTES: usize = 900 * 1024;
+const BIG_LINE_COUNT: usize = 5;
+const _: () = assert!(BIG_LINE_BYTES < MAX_LINE_BYTES);
+const _: () = assert!(BIG_LINE_BYTES * BIG_LINE_COUNT > MAX_RESPONSE_BYTES);
+const FOREIGN_XID: u64 = 999_999;
+const TALKING_INTERVAL: Duration = Duration::from_secs(2);
+const TALKING_LINES: usize = 8;
+const TEN_SECOND_CUT_MIN: Duration = Duration::from_secs(9);
+const TEN_SECOND_CUT_MAX: Duration = Duration::from_secs(13);
+
+/// A one-connection fake speaker: reads the request line, then runs `script` with the
+/// request's transaction id. Write errors in the script are ignored (the client hangs up).
+fn scripted_speaker(script: impl FnOnce(&mut TcpStream, u64) + Send + 'static) -> Client {
+ let listener = TcpListener::bind(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0)).unwrap();
+ let address = match listener.local_addr().unwrap() {
+ std::net::SocketAddr::V6(address) => address,
+ other => panic!("not IPv6: {other}"),
+ };
+ thread::spawn(move || {
+ let (mut stream, _) = listener.accept().unwrap();
+ let mut request = String::new();
+ BufReader::new(stream.try_clone().unwrap())
+ .read_line(&mut request)
+ .unwrap();
+ let tree: Value = serde_json::from_str(request.trim()).unwrap();
+ let xid = tree["osc"]["xid"].as_u64().unwrap();
+ script(&mut stream, xid);
+ });
+ Client::connect(address).expect("fake speaker accepts")
+}
+
+fn level_reply(xid: u64) -> String {
+ format!(
+ "{{\"audio\":{{\"out\":{{\"level\":1.0}}}},\"osc\":{{\"xid\":{xid}}}}}
+"
+ )
+}
+
+fn padded_object(total_content_bytes: usize) -> String {
+ let overhead = "{\"x\":\"\"}".len();
+ format!(
+ "{{\"x\":\"{}\"}}",
+ "a".repeat(total_content_bytes - overhead)
+ )
+}
+
+fn assert_protocol_and_closed(mut client: Client) {
+ let error = client.get(&path("audio/out/level")).unwrap_err();
+ assert!(matches!(error, SscError::Protocol(_)), "got {error:?}");
+ assert!(client.is_closed());
+}
+
+#[test]
+fn too_many_lines_closes_connection() {
+ let client = scripted_speaker(|stream, _| {
+ for _ in 0..=MAX_RESPONSE_LINES {
+ if stream.write_all(SHORT_LINE.as_bytes()).is_err() {
+ break;
+ }
+ }
+ });
+ assert_protocol_and_closed(client);
+}
+
+#[test]
+fn too_many_bytes_closes_connection() {
+ let client = scripted_speaker(|stream, _| {
+ let line = format!(
+ "{}
+",
+ padded_object(BIG_LINE_BYTES)
+ );
+ for _ in 0..BIG_LINE_COUNT {
+ if stream.write_all(line.as_bytes()).is_err() {
+ break;
+ }
+ }
+ });
+ assert_protocol_and_closed(client);
+}
+
+#[test]
+fn unterminated_stream_over_line_limit_closes_connection() {
+ let client = scripted_speaker(|stream, _| {
+ let chunk = vec![b'a'; MAX_LINE_BYTES + 1024];
+ let _ = stream.write_all(&chunk);
+ });
+ assert_protocol_and_closed(client);
+}
+
+// The limit applies to the line without its CR LF: exactly MAX_LINE_BYTES is allowed.
+#[test]
+fn line_of_exactly_the_limit_is_accepted() {
+ let mut client = scripted_speaker(|stream, xid| {
+ let line = format!(
+ "{}
+",
+ padded_object(MAX_LINE_BYTES)
+ );
+ let _ = stream.write_all(line.as_bytes());
+ let _ = stream.write_all(level_reply(xid).as_bytes());
+ });
+ assert_eq!(client.get(&path("audio/out/level")).unwrap(), json!(1.0));
+}
+
+#[test]
+fn line_one_byte_over_the_limit_closes_connection() {
+ let client = scripted_speaker(|stream, _| {
+ let line = format!(
+ "{}
+",
+ padded_object(MAX_LINE_BYTES + 1)
+ );
+ let _ = stream.write_all(line.as_bytes());
+ });
+ assert_protocol_and_closed(client);
+}
+
+#[test]
+fn device_that_keeps_talking_is_cut_after_ten_seconds() {
+ let client = scripted_speaker(|stream, _| {
+ let line = format!(
+ "{{\"osc\":{{\"xid\":{FOREIGN_XID}}}}}
+"
+ );
+ for _ in 0..TALKING_LINES {
+ if stream.write_all(line.as_bytes()).is_err() {
+ break;
+ }
+ thread::sleep(TALKING_INTERVAL);
+ }
+ });
+ let started = Instant::now();
+ assert_protocol_and_closed(client);
+ let elapsed = started.elapsed();
+ assert!(
+ (TEN_SECOND_CUT_MIN..TEN_SECOND_CUT_MAX).contains(&elapsed),
+ "cut after {elapsed:?}"
+ );
+}
From 82124f9ceb847165207f60621990779c0e0c588e Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 22:48:36 +0200
Subject: [PATCH 008/460] Add the path catalog mirrored from neumann-connect
---
Cargo.lock | 77 ++++++
crates/core/Cargo.toml | 1 +
crates/core/src/catalog.rs | 321 +++++++++++++++++++++++
crates/core/src/lib.rs | 1 +
crates/core/src/paths.toml | 487 +++++++++++++++++++++++++++++++++++
crates/core/tests/catalog.rs | 225 ++++++++++++++++
6 files changed, 1112 insertions(+)
create mode 100644 crates/core/src/catalog.rs
create mode 100644 crates/core/src/paths.toml
create mode 100644 crates/core/tests/catalog.rs
diff --git a/Cargo.lock b/Cargo.lock
index fe6028a..cf5d929 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,28 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "equivalent"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
[[package]]
name = "itoa"
version = "1.0.18"
@@ -24,6 +46,7 @@ version = "0.1.0"
dependencies = [
"serde",
"serde_json",
+ "toml",
]
[[package]]
@@ -87,6 +110,15 @@ dependencies = [
"zmij",
]
+[[package]]
+name = "serde_spanned"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
+dependencies = [
+ "serde_core",
+]
+
[[package]]
name = "syn"
version = "3.0.9"
@@ -98,12 +130,57 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "toml"
+version = "1.1.8+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
+
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+
[[package]]
name = "zmij"
version = "1.0.23"
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index 241b658..f67384c 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -12,6 +12,7 @@ test-support = []
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+toml = "1"
[[test]]
name = "simulator"
diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs
new file mode 100644
index 0000000..99c287b
--- /dev/null
+++ b/crates/core/src/catalog.rs
@@ -0,0 +1,321 @@
+//! The path table: which SSC paths exist, on which models, how they are shown, polled and
+//! written. All of it is data in `paths.toml`; this module only loads and applies it.
+
+use std::collections::HashSet;
+use std::fmt;
+use std::sync::OnceLock;
+
+use serde::Deserialize;
+use serde_json::Value;
+
+use crate::ssc::wire::SscPath;
+
+const EMBEDDED_CATALOG: &str = include_str!("paths.toml");
+const PRINTABLE_ASCII: std::ops::RangeInclusive = ' '..='~';
+const KELVIN_OFFSET: f64 = 273.15;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Group {
+ Output,
+ Output1,
+ Output2,
+ Input,
+ Standby,
+ Device,
+ RearPanel,
+ Metering,
+}
+
+impl Group {
+ pub fn title(&self) -> &'static str {
+ match self {
+ Group::Output => "Ausgang",
+ Group::Output1 => "Ausgang 1",
+ Group::Output2 => "Ausgang 2",
+ Group::Input => "Eingang",
+ Group::Standby => "Standby",
+ Group::Device => "Gerät",
+ Group::RearPanel => "Rückseite",
+ Group::Metering => "Messung",
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Kind {
+ Number,
+ Switch,
+ Select,
+ Text,
+ Display,
+ Meter,
+ Clip,
+}
+
+impl Kind {
+ fn can_be_written(&self) -> bool {
+ matches!(
+ self,
+ Kind::Number | Kind::Switch | Kind::Select | Kind::Text
+ )
+ }
+}
+
+/// `Once` marks identity values that are read when connecting.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Poll {
+ Fast,
+ Slow,
+ Meter,
+ Once,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum ModelClass {
+ All,
+ Monitor,
+ Subwoofer,
+ Logo,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Conversion {
+ KelvinToCelsius,
+}
+
+impl Conversion {
+ pub fn apply(&self, value: f64) -> f64 {
+ match self {
+ Conversion::KelvinToCelsius => value - KELVIN_OFFSET,
+ }
+ }
+}
+
+/// Product lists behind the model classes; a monitor is any product that is not a subwoofer.
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
+pub struct ModelClasses {
+ pub subwoofer: Vec,
+ pub logo: Vec,
+}
+
+impl ModelClasses {
+ pub fn is_subwoofer(&self, product: &str) -> bool {
+ self.subwoofer.iter().any(|known| known == product)
+ }
+
+ pub fn contains(&self, class: ModelClass, product: &str) -> bool {
+ match class {
+ ModelClass::All => true,
+ ModelClass::Monitor => !self.is_subwoofer(product),
+ ModelClass::Subwoofer => self.is_subwoofer(product),
+ ModelClass::Logo => self.logo.iter().any(|known| known == product),
+ }
+ }
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum ValueError {
+ OutOfRange { min: f64, max: f64 },
+ NotAnOption,
+ TooLong(usize),
+ NotPrintableAscii,
+ WrongType,
+}
+
+impl fmt::Display for ValueError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ ValueError::OutOfRange { min, max } => write!(formatter, "value outside {min}..{max}"),
+ ValueError::NotAnOption => formatter.write_str("value is not one of the options"),
+ ValueError::TooLong(limit) => write!(formatter, "text longer than {limit} characters"),
+ ValueError::NotPrintableAscii => formatter.write_str("text is not printable ASCII"),
+ ValueError::WrongType => formatter.write_str("value has the wrong type"),
+ }
+ }
+}
+
+impl std::error::Error for ValueError {}
+
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Entry {
+ pub path: SscPath,
+ pub label: String,
+ pub group: Group,
+ pub kind: Kind,
+ pub poll: Poll,
+ pub class: ModelClass,
+ #[serde(default)]
+ pub unit: Option,
+ #[serde(default)]
+ pub min: Option,
+ #[serde(default)]
+ pub max: Option,
+ /// Upper bound on subwoofer models, where it differs from `max`.
+ #[serde(default)]
+ pub max_subwoofer: Option,
+ #[serde(default)]
+ pub step: Option,
+ #[serde(default)]
+ pub max_length: Option,
+ #[serde(default)]
+ pub options: Vec,
+ #[serde(default)]
+ pub writable_on: Vec,
+ #[serde(default)]
+ pub conversion: Option,
+}
+
+impl Entry {
+ pub fn is_writable(&self, product: &str) -> bool {
+ self.writable_on.iter().any(|known| known == product)
+ }
+
+ pub fn max_for(&self, product: &str, classes: &ModelClasses) -> Option {
+ if classes.is_subwoofer(product) && self.max_subwoofer.is_some() {
+ return self.max_subwoofer;
+ }
+ self.max
+ }
+
+ pub fn validate(
+ &self,
+ value: &Value,
+ product: &str,
+ classes: &ModelClasses,
+ ) -> Result<(), ValueError> {
+ match self.kind {
+ Kind::Number => self.validate_number(value, self.max_for(product, classes)),
+ Kind::Switch => value.as_bool().map(|_| ()).ok_or(ValueError::WrongType),
+ Kind::Select => self.validate_option(value),
+ Kind::Text => self.validate_text(value),
+ Kind::Display | Kind::Meter | Kind::Clip => Err(ValueError::WrongType),
+ }
+ }
+
+ fn validate_number(&self, value: &Value, max: Option) -> Result<(), ValueError> {
+ let number = value
+ .as_f64()
+ .filter(|candidate| candidate.is_finite())
+ .ok_or(ValueError::WrongType)?;
+ let below = self.min.is_some_and(|min| number < min);
+ let above = max.is_some_and(|max| number > max);
+ if below || above {
+ return Err(ValueError::OutOfRange {
+ min: self.min.unwrap_or(f64::NEG_INFINITY),
+ max: max.unwrap_or(f64::INFINITY),
+ });
+ }
+ Ok(())
+ }
+
+ fn validate_option(&self, value: &Value) -> Result<(), ValueError> {
+ let text = value.as_str().ok_or(ValueError::WrongType)?;
+ if self.options.iter().any(|option| option == text) {
+ return Ok(());
+ }
+ Err(ValueError::NotAnOption)
+ }
+
+ fn validate_text(&self, value: &Value) -> Result<(), ValueError> {
+ let text = value.as_str().ok_or(ValueError::WrongType)?;
+ if !text
+ .chars()
+ .all(|character| PRINTABLE_ASCII.contains(&character))
+ {
+ return Err(ValueError::NotPrintableAscii);
+ }
+ match self.max_length {
+ Some(limit) if text.len() > limit => Err(ValueError::TooLong(limit)),
+ _ => Ok(()),
+ }
+ }
+}
+
+#[derive(Debug, PartialEq)]
+pub enum CatalogError {
+ Syntax(String),
+ DuplicatePath(SscPath),
+ Invalid { path: SscPath, reason: &'static str },
+}
+
+impl fmt::Display for CatalogError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ CatalogError::Syntax(message) => write!(formatter, "catalog syntax: {message}"),
+ CatalogError::DuplicatePath(path) => write!(formatter, "catalog lists {path} twice"),
+ CatalogError::Invalid { path, reason } => write!(formatter, "catalog {path}: {reason}"),
+ }
+ }
+}
+
+impl std::error::Error for CatalogError {}
+
+#[derive(Debug, Deserialize)]
+struct CatalogFile {
+ models: ModelClasses,
+ entry: Vec,
+}
+
+#[derive(Debug)]
+pub struct Catalog {
+ pub entries: Vec,
+ pub classes: ModelClasses,
+}
+
+impl Catalog {
+ /// Panics only when the embedded file is invalid; a test pins that it is not.
+ pub fn embedded() -> &'static Catalog {
+ static EMBEDDED: OnceLock = OnceLock::new();
+ EMBEDDED.get_or_init(|| Catalog::parse(EMBEDDED_CATALOG).expect("embedded paths.toml"))
+ }
+
+ pub fn parse(text: &str) -> Result {
+ let file: CatalogFile =
+ toml::from_str(text).map_err(|error| CatalogError::Syntax(error.to_string()))?;
+ let mut seen = HashSet::new();
+ for entry in &file.entry {
+ if !seen.insert(entry.path.clone()) {
+ return Err(CatalogError::DuplicatePath(entry.path.clone()));
+ }
+ check_entry(entry)?;
+ }
+ Ok(Catalog {
+ entries: file.entry,
+ classes: file.models,
+ })
+ }
+
+ pub fn entries_for(&self, product: &str) -> Vec<&Entry> {
+ self.entries
+ .iter()
+ .filter(|entry| self.classes.contains(entry.class, product))
+ .collect()
+ }
+}
+
+fn check_entry(entry: &Entry) -> Result<(), CatalogError> {
+ let invalid = |reason| CatalogError::Invalid {
+ path: entry.path.clone(),
+ reason,
+ };
+ if !entry.kind.can_be_written() && !entry.writable_on.is_empty() {
+ return Err(invalid("read-only kind lists writable models"));
+ }
+ if entry.kind == Kind::Select && entry.options.is_empty() {
+ return Err(invalid("select without options"));
+ }
+ if entry.kind == Kind::Text && entry.max_length.is_none() {
+ return Err(invalid("text without max_length"));
+ }
+ let reversed = matches!((entry.min, entry.max), (Some(min), Some(max)) if min > max);
+ if reversed {
+ return Err(invalid("min above max"));
+ }
+ Ok(())
+}
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index 6521557..4fc5910 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -1,4 +1,5 @@
pub mod address;
+pub mod catalog;
pub mod mask;
#[cfg(feature = "test-support")]
pub mod simulator;
diff --git a/crates/core/src/paths.toml b/crates/core/src/paths.toml
new file mode 100644
index 0000000..7c77523
--- /dev/null
+++ b/crates/core/src/paths.toml
@@ -0,0 +1,487 @@
+# Path catalog of neumann-control.
+#
+# This file mirrors custom_components/neumann_kh/const.py of neumann-connect (Home Assistant
+# integration), synced from version 1.19.1. Model knowledge lives here and nowhere in code:
+# which model has a path (`class`), where it may be written (`writable_on`, exact product
+# strings as the device reports them) and how often it is polled (`poll`). A product that is
+# not listed in `writable_on` is read-only, so unmeasured models never get a write.
+#
+# Labels are German UI copy. Out of scope (not listed): EQ, restore, save_settings, out5,
+# mixer, mute_persist, update, auth, system, time, macs.
+
+[models]
+# Models with subwoofer features; every other product counts as a monitor.
+subwoofer = ["KH 750", "KH 750 DSP"]
+# Models with a logo brightness setting.
+logo = ["KH 80", "KH 80 DSP", "KH 150", "KH 150 AES67", "KH 120", "KH 120 II", "KH 120 II AES67"]
+
+
+[[entry]]
+path = "device/identity/product"
+label = "Produkt"
+group = "device"
+kind = "display"
+poll = "once"
+class = "all"
+
+[[entry]]
+path = "device/identity/version"
+label = "Firmware-Version"
+group = "device"
+kind = "display"
+poll = "once"
+class = "all"
+
+[[entry]]
+path = "device/identity/serial"
+label = "Seriennummer"
+group = "device"
+kind = "display"
+poll = "once"
+class = "all"
+
+[[entry]]
+path = "device/identity/vendor"
+label = "Hersteller"
+group = "device"
+kind = "display"
+poll = "once"
+class = "all"
+
+[[entry]]
+path = "device/identity/hw_version"
+label = "Hardware-Version"
+group = "device"
+kind = "display"
+poll = "slow"
+class = "all"
+
+[[entry]]
+path = "audio/out/level"
+label = "Pegel"
+group = "output"
+kind = "number"
+poll = "fast"
+class = "all"
+unit = "dB SPL"
+min = 0
+max = 120
+writable_on = ["KH 120 II", "KH 750"]
+
+[[entry]]
+path = "audio/out/delay"
+label = "Verzögerung"
+group = "output"
+kind = "number"
+poll = "fast"
+class = "all"
+unit = "1/48 kHz"
+min = 0
+max = 5760
+max_subwoofer = 1000
+step = 1
+writable_on = ["KH 120 II", "KH 750"]
+
+[[entry]]
+path = "audio/out/mute"
+label = "Stumm"
+group = "output"
+kind = "switch"
+poll = "fast"
+class = "all"
+writable_on = ["KH 120 II", "KH 750"]
+
+[[entry]]
+path = "audio/out/phaseinversion"
+label = "Phase invertiert"
+group = "output"
+kind = "switch"
+poll = "fast"
+class = "monitor"
+writable_on = ["KH 120 II"]
+
+[[entry]]
+path = "audio/out/label"
+label = "Bezeichnung"
+group = "output"
+kind = "display"
+poll = "slow"
+class = "subwoofer"
+
+[[entry]]
+path = "audio/out1/level"
+label = "Pegel"
+group = "output1"
+kind = "number"
+poll = "fast"
+class = "subwoofer"
+unit = "dB SPL"
+min = 0
+max = 120
+writable_on = ["KH 750"]
+
+[[entry]]
+path = "audio/out1/delay"
+label = "Verzögerung"
+group = "output1"
+kind = "number"
+poll = "fast"
+class = "subwoofer"
+unit = "1/48 kHz"
+min = 0
+max = 1000
+step = 1
+writable_on = ["KH 750"]
+
+[[entry]]
+path = "audio/out1/mute"
+label = "Stumm"
+group = "output1"
+kind = "switch"
+poll = "fast"
+class = "subwoofer"
+writable_on = ["KH 750"]
+
+[[entry]]
+path = "audio/out1/label"
+label = "Bezeichnung"
+group = "output1"
+kind = "display"
+poll = "slow"
+class = "subwoofer"
+
+[[entry]]
+path = "audio/out1/loudspeaker"
+label = "Lautsprecher"
+group = "output1"
+kind = "display"
+poll = "slow"
+class = "subwoofer"
+
+[[entry]]
+path = "audio/out2/level"
+label = "Pegel"
+group = "output2"
+kind = "number"
+poll = "fast"
+class = "subwoofer"
+unit = "dB SPL"
+min = 0
+max = 120
+writable_on = ["KH 750"]
+
+[[entry]]
+path = "audio/out2/delay"
+label = "Verzögerung"
+group = "output2"
+kind = "number"
+poll = "fast"
+class = "subwoofer"
+unit = "1/48 kHz"
+min = 0
+max = 1000
+step = 1
+writable_on = ["KH 750"]
+
+[[entry]]
+path = "audio/out2/mute"
+label = "Stumm"
+group = "output2"
+kind = "switch"
+poll = "fast"
+class = "subwoofer"
+writable_on = ["KH 750"]
+
+[[entry]]
+path = "audio/out2/label"
+label = "Bezeichnung"
+group = "output2"
+kind = "display"
+poll = "slow"
+class = "subwoofer"
+
+[[entry]]
+path = "audio/out2/loudspeaker"
+label = "Lautsprecher"
+group = "output2"
+kind = "display"
+poll = "slow"
+class = "subwoofer"
+
+[[entry]]
+path = "audio/in/interface"
+label = "Eingangs-Interface"
+group = "input"
+kind = "select"
+poll = "slow"
+class = "all"
+options = ["ANALOG ONLY", "DIGITAL ONLY", "DIGITAL DISCARDS ANALOG"]
+writable_on = ["KH 120 II", "KH 750"]
+
+[[entry]]
+path = "audio/in/current_input"
+label = "Aktueller Eingang"
+group = "input"
+kind = "display"
+poll = "slow"
+class = "all"
+
+[[entry]]
+path = "device/standby/enabled"
+label = "Auto-Standby"
+group = "standby"
+kind = "switch"
+poll = "fast"
+class = "all"
+writable_on = ["KH 120 II"]
+
+[[entry]]
+path = "device/standby/auto_standby_time"
+label = "Standby-Zeit"
+group = "standby"
+kind = "number"
+poll = "fast"
+class = "all"
+unit = "min"
+min = 1
+max = 240
+step = 1
+writable_on = ["KH 120 II"]
+
+[[entry]]
+path = "device/standby/level"
+label = "Standby-Pegel"
+group = "standby"
+kind = "number"
+poll = "fast"
+class = "all"
+unit = "dBu"
+min = -80
+max = -55
+step = 1
+writable_on = ["KH 120 II"]
+
+[[entry]]
+path = "device/standby/countdown"
+label = "Countdown"
+group = "standby"
+kind = "display"
+poll = "fast"
+class = "all"
+unit = "min"
+
+[[entry]]
+path = "device/identification/visual"
+label = "Identifizieren"
+group = "device"
+kind = "switch"
+poll = "fast"
+class = "all"
+writable_on = ["KH 120 II", "KH 750"]
+
+[[entry]]
+path = "ui/control_mode"
+label = "Steuermodus"
+group = "device"
+kind = "select"
+poll = "fast"
+class = "all"
+options = ["NETWORK", "LOCAL"]
+writable_on = ["KH 120 II", "KH 750"]
+
+[[entry]]
+path = "ui/logo/brightness"
+label = "Logo-Helligkeit"
+group = "device"
+kind = "number"
+poll = "fast"
+class = "logo"
+unit = "%"
+min = 0
+max = 125
+step = 1
+writable_on = ["KH 120 II"]
+
+[[entry]]
+path = "device/name"
+label = "Name"
+group = "device"
+kind = "text"
+poll = "slow"
+class = "all"
+max_length = 52
+writable_on = ["KH 120 II", "KH 750"]
+
+[[entry]]
+path = "warnings"
+label = "Warnungen"
+group = "device"
+kind = "display"
+poll = "fast"
+class = "all"
+
+[[entry]]
+path = "device/temperature"
+label = "Temperatur"
+group = "device"
+kind = "display"
+poll = "fast"
+class = "subwoofer"
+unit = "K"
+conversion = "kelvin_to_celsius"
+
+[[entry]]
+path = "ui/input_gain"
+label = "Eingangsverstärkung"
+group = "rear_panel"
+kind = "display"
+poll = "slow"
+class = "monitor"
+unit = "dB"
+
+[[entry]]
+path = "ui/output_level"
+label = "Ausgangspegel"
+group = "rear_panel"
+kind = "display"
+poll = "slow"
+class = "monitor"
+unit = "dB SPL"
+
+[[entry]]
+path = "ui/bass_gain"
+label = "Bass"
+group = "rear_panel"
+kind = "display"
+poll = "slow"
+class = "monitor"
+unit = "dB"
+
+[[entry]]
+path = "ui/mid_gain"
+label = "Mitten"
+group = "rear_panel"
+kind = "display"
+poll = "slow"
+class = "monitor"
+unit = "dB"
+
+[[entry]]
+path = "ui/treble_gain"
+label = "Höhen"
+group = "rear_panel"
+kind = "display"
+poll = "slow"
+class = "monitor"
+unit = "dB"
+
+[[entry]]
+path = "ui/input_select"
+label = "Eingangswahl"
+group = "rear_panel"
+kind = "display"
+poll = "slow"
+class = "monitor"
+
+[[entry]]
+path = "audio/digital_bypass"
+label = "Digital-Bypass"
+group = "rear_panel"
+kind = "display"
+poll = "slow"
+class = "subwoofer"
+
+[[entry]]
+path = "ui/bass_management"
+label = "Bassmanagement"
+group = "rear_panel"
+kind = "display"
+poll = "slow"
+class = "subwoofer"
+
+[[entry]]
+path = "ui/channel_b_input_mode"
+label = "Kanal-B-Eingang"
+group = "rear_panel"
+kind = "display"
+poll = "slow"
+class = "subwoofer"
+
+[[entry]]
+path = "ui/subwoofer_input_gain"
+label = "Eingangsverstärkung"
+group = "rear_panel"
+kind = "display"
+poll = "slow"
+class = "subwoofer"
+unit = "dB"
+
+[[entry]]
+path = "ui/subwoofer_low_cut"
+label = "Low-Cut"
+group = "rear_panel"
+kind = "display"
+poll = "slow"
+class = "subwoofer"
+unit = "dB"
+
+[[entry]]
+path = "ui/subwoofer_output_level"
+label = "Ausgangspegel"
+group = "rear_panel"
+kind = "display"
+poll = "slow"
+class = "subwoofer"
+unit = "dB SPL"
+
+[[entry]]
+path = "ui/subwoofer_phase"
+label = "Phase"
+group = "rear_panel"
+kind = "display"
+poll = "slow"
+class = "subwoofer"
+unit = "°"
+
+[[entry]]
+path = "ui/subwoofer_phase_inversion"
+label = "Phasenumkehr"
+group = "rear_panel"
+kind = "display"
+poll = "slow"
+class = "subwoofer"
+unit = "°"
+
+[[entry]]
+path = "m/in/level"
+label = "Eingangspegel"
+group = "metering"
+kind = "meter"
+poll = "meter"
+class = "all"
+unit = "dB"
+
+[[entry]]
+path = "m/in/clip"
+label = "Eingang übersteuert"
+group = "metering"
+kind = "clip"
+poll = "meter"
+class = "all"
+
+[[entry]]
+path = "m/out/level"
+label = "Ausgangspegel"
+group = "metering"
+kind = "meter"
+poll = "meter"
+class = "subwoofer"
+unit = "dB"
+
+[[entry]]
+path = "m/out/clip"
+label = "Ausgang übersteuert"
+group = "metering"
+kind = "clip"
+poll = "meter"
+class = "subwoofer"
diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs
new file mode 100644
index 0000000..0855387
--- /dev/null
+++ b/crates/core/tests/catalog.rs
@@ -0,0 +1,225 @@
+use neumann_control_core::catalog::{Catalog, Entry, ValueError};
+use neumann_control_core::ssc::wire::SscPath;
+use serde_json::json;
+
+const MONITOR: &str = "KH 120 II";
+const SUBWOOFER: &str = "KH 750";
+const UNKNOWN_PRODUCT: &str = "KH 80";
+
+fn path(text: &str) -> SscPath {
+ SscPath::parse(text).unwrap()
+}
+
+fn entry(text: &str) -> &'static Entry {
+ let wanted = path(text);
+ Catalog::embedded()
+ .entries
+ .iter()
+ .find(|candidate| candidate.path == wanted)
+ .unwrap_or_else(|| panic!("{text} is not in the catalog"))
+}
+
+fn has(product: &str, text: &str) -> bool {
+ let wanted = path(text);
+ Catalog::embedded()
+ .entries_for(product)
+ .iter()
+ .any(|candidate| candidate.path == wanted)
+}
+
+#[test]
+fn embedded_catalog_loads() {
+ assert!(!Catalog::embedded().entries.is_empty());
+}
+
+#[test]
+fn paths_are_unique() {
+ let mut seen = std::collections::HashSet::new();
+ for candidate in &Catalog::embedded().entries {
+ assert!(seen.insert(&candidate.path), "{} twice", candidate.path);
+ }
+}
+
+#[test]
+fn writable_models_have_the_path() {
+ let catalog = Catalog::embedded();
+ for candidate in &catalog.entries {
+ for product in &candidate.writable_on {
+ assert!(
+ catalog.entries_for(product).contains(&candidate),
+ "{} is writable on {product} but not offered there",
+ candidate.path
+ );
+ }
+ }
+}
+
+#[test]
+fn kh750_has_no_phase_inversion_and_has_out1() {
+ assert!(!has(SUBWOOFER, "audio/out/phaseinversion"));
+ assert!(has(SUBWOOFER, "audio/out1/level"));
+ assert!(has(MONITOR, "audio/out/phaseinversion"));
+ assert!(!has(MONITOR, "audio/out1/level"));
+}
+
+#[test]
+fn kh120ii_has_logo_but_kh750_not() {
+ assert!(has(MONITOR, "ui/logo/brightness"));
+ assert!(!has(SUBWOOFER, "ui/logo/brightness"));
+}
+
+#[test]
+fn unknown_model_is_read_only() {
+ for candidate in &Catalog::embedded().entries {
+ assert!(
+ !candidate.is_writable(UNKNOWN_PRODUCT),
+ "{}",
+ candidate.path
+ );
+ }
+}
+
+#[test]
+fn auto_standby_switch_is_writable_on_the_monitor_only() {
+ let switch = entry("device/standby/enabled");
+ assert!(switch.is_writable(MONITOR));
+ assert!(!switch.is_writable(SUBWOOFER));
+}
+
+#[test]
+fn level_out_of_range_is_refused() {
+ let catalog = Catalog::embedded();
+ let result = entry("audio/out/level").validate(&json!(120.5), MONITOR, &catalog.classes);
+ assert_eq!(
+ result,
+ Err(ValueError::OutOfRange {
+ min: 0.0,
+ max: 120.0
+ })
+ );
+ assert_eq!(
+ entry("audio/out/level").validate(&json!(120), MONITOR, &catalog.classes),
+ Ok(())
+ );
+}
+
+#[test]
+fn delay_max_depends_on_model() {
+ let catalog = Catalog::embedded();
+ let delay = entry("audio/out/delay");
+ assert_eq!(delay.max_for(MONITOR, &catalog.classes), Some(5760.0));
+ assert_eq!(delay.max_for(SUBWOOFER, &catalog.classes), Some(1000.0));
+ assert_eq!(
+ delay.validate(&json!(5760), MONITOR, &catalog.classes),
+ Ok(())
+ );
+ assert!(delay
+ .validate(&json!(1001), SUBWOOFER, &catalog.classes)
+ .is_err());
+}
+
+#[test]
+fn name_with_umlaut_is_refused() {
+ let catalog = Catalog::embedded();
+ let result =
+ entry("device/name").validate(&json!("Lautsprecher Küche"), MONITOR, &catalog.classes);
+ assert_eq!(result, Err(ValueError::NotPrintableAscii));
+}
+
+#[test]
+fn name_longer_than_52_is_refused() {
+ let catalog = Catalog::embedded();
+ let name = entry("device/name");
+ let longest = "a".repeat(52);
+ assert_eq!(
+ name.validate(&json!(longest), MONITOR, &catalog.classes),
+ Ok(())
+ );
+ let too_long = "a".repeat(53);
+ assert_eq!(
+ name.validate(&json!(too_long), MONITOR, &catalog.classes),
+ Err(ValueError::TooLong(52))
+ );
+}
+
+#[test]
+fn unknown_option_is_refused() {
+ let catalog = Catalog::embedded();
+ let mode = entry("ui/control_mode");
+ assert_eq!(
+ mode.validate(&json!("REMOTE"), MONITOR, &catalog.classes),
+ Err(ValueError::NotAnOption)
+ );
+ assert_eq!(
+ mode.validate(&json!("LOCAL"), MONITOR, &catalog.classes),
+ Ok(())
+ );
+}
+
+#[test]
+fn wrong_json_type_is_refused() {
+ let catalog = Catalog::embedded();
+ assert_eq!(
+ entry("audio/out/mute").validate(&json!(1), MONITOR, &catalog.classes),
+ Err(ValueError::WrongType)
+ );
+ assert_eq!(
+ entry("audio/out/level").validate(&json!("loud"), MONITOR, &catalog.classes),
+ Err(ValueError::WrongType)
+ );
+ assert_eq!(
+ entry("device/temperature").validate(&json!(300), SUBWOOFER, &catalog.classes),
+ Err(ValueError::WrongType)
+ );
+}
+
+#[test]
+fn temperature_is_converted_from_kelvin() {
+ let temperature = entry("device/temperature");
+ assert_eq!(temperature.unit.as_deref(), Some("K"));
+ assert!(temperature.conversion.is_some());
+}
+
+#[test]
+fn duplicate_path_in_text_is_an_error() {
+ let text = r#"
+ [models]
+ subwoofer = ["KH 750"]
+ logo = []
+
+ [[entry]]
+ path = "audio/out/mute"
+ label = "Stumm"
+ group = "output"
+ kind = "switch"
+ poll = "fast"
+ class = "all"
+
+ [[entry]]
+ path = "audio/out/mute"
+ label = "Stumm"
+ group = "output"
+ kind = "switch"
+ poll = "fast"
+ class = "all"
+ "#;
+ assert!(Catalog::parse(text).is_err());
+}
+
+#[test]
+fn select_without_options_is_an_error() {
+ let text = r#"
+ [models]
+ subwoofer = []
+ logo = []
+
+ [[entry]]
+ path = "ui/control_mode"
+ label = "Steuermodus"
+ group = "device"
+ kind = "select"
+ poll = "fast"
+ class = "all"
+ "#;
+ assert!(Catalog::parse(text).is_err());
+}
From 2c38a84b24180ed60cea9b648d9f81bd4186a3a6 Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 22:50:40 +0200
Subject: [PATCH 009/460] Validate catalog steps and tighten tests
---
crates/core/src/catalog.rs | 15 +++++++++++++++
crates/core/tests/catalog.rs | 34 ++++++++++++++++++++++++++++++----
2 files changed, 45 insertions(+), 4 deletions(-)
diff --git a/crates/core/src/catalog.rs b/crates/core/src/catalog.rs
index 99c287b..183b25a 100644
--- a/crates/core/src/catalog.rs
+++ b/crates/core/src/catalog.rs
@@ -12,6 +12,8 @@ use crate::ssc::wire::SscPath;
const EMBEDDED_CATALOG: &str = include_str!("paths.toml");
const PRINTABLE_ASCII: std::ops::RangeInclusive = ' '..='~';
+/// Relative to the step size; absorbs binary rounding of decimal steps.
+const STEP_TOLERANCE: f64 = 1e-9;
const KELVIN_OFFSET: f64 = 273.15;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
@@ -122,6 +124,7 @@ impl ModelClasses {
pub enum ValueError {
OutOfRange { min: f64, max: f64 },
NotAnOption,
+ NotAStep,
TooLong(usize),
NotPrintableAscii,
WrongType,
@@ -132,6 +135,7 @@ impl fmt::Display for ValueError {
match self {
ValueError::OutOfRange { min, max } => write!(formatter, "value outside {min}..{max}"),
ValueError::NotAnOption => formatter.write_str("value is not one of the options"),
+ ValueError::NotAStep => formatter.write_str("value is not a whole number of steps"),
ValueError::TooLong(limit) => write!(formatter, "text longer than {limit} characters"),
ValueError::NotPrintableAscii => formatter.write_str("text is not printable ASCII"),
ValueError::WrongType => formatter.write_str("value has the wrong type"),
@@ -211,6 +215,17 @@ impl Entry {
max: max.unwrap_or(f64::INFINITY),
});
}
+ self.validate_step(number)
+ }
+
+ fn validate_step(&self, number: f64) -> Result<(), ValueError> {
+ let Some(step) = self.step.filter(|step| *step > 0.0) else {
+ return Ok(());
+ };
+ let steps = (number - self.min.unwrap_or(0.0)) / step;
+ if (steps - steps.round()).abs() > STEP_TOLERANCE {
+ return Err(ValueError::NotAStep);
+ }
Ok(())
}
diff --git a/crates/core/tests/catalog.rs b/crates/core/tests/catalog.rs
index 0855387..91a437b 100644
--- a/crates/core/tests/catalog.rs
+++ b/crates/core/tests/catalog.rs
@@ -1,4 +1,4 @@
-use neumann_control_core::catalog::{Catalog, Entry, ValueError};
+use neumann_control_core::catalog::{Catalog, CatalogError, Conversion, Entry, ValueError};
use neumann_control_core::ssc::wire::SscPath;
use serde_json::json;
@@ -177,7 +177,9 @@ fn wrong_json_type_is_refused() {
fn temperature_is_converted_from_kelvin() {
let temperature = entry("device/temperature");
assert_eq!(temperature.unit.as_deref(), Some("K"));
- assert!(temperature.conversion.is_some());
+ assert_eq!(temperature.conversion, Some(Conversion::KelvinToCelsius));
+ let celsius = Conversion::KelvinToCelsius.apply(313.0);
+ assert!((celsius - 39.85).abs() < 1e-9, "{celsius}");
}
#[test]
@@ -203,7 +205,12 @@ fn duplicate_path_in_text_is_an_error() {
poll = "fast"
class = "all"
"#;
- assert!(Catalog::parse(text).is_err());
+ match Catalog::parse(text) {
+ Err(CatalogError::DuplicatePath(duplicate)) => {
+ assert_eq!(duplicate, path("audio/out/mute"))
+ }
+ other => panic!("expected DuplicatePath, got {other:?}"),
+ }
}
#[test]
@@ -221,5 +228,24 @@ fn select_without_options_is_an_error() {
poll = "fast"
class = "all"
"#;
- assert!(Catalog::parse(text).is_err());
+ match Catalog::parse(text) {
+ Err(CatalogError::Invalid { path: invalid, .. }) => {
+ assert_eq!(invalid, path("ui/control_mode"))
+ }
+ other => panic!("expected Invalid, got {other:?}"),
+ }
+}
+
+#[test]
+fn delay_must_be_whole_steps() {
+ let catalog = Catalog::embedded();
+ let delay = entry("audio/out/delay");
+ assert_eq!(
+ delay.validate(&json!(1.5), MONITOR, &catalog.classes),
+ Err(ValueError::NotAStep)
+ );
+ assert_eq!(
+ delay.validate(&json!(2.0), MONITOR, &catalog.classes),
+ Ok(())
+ );
}
From cd80295335247da4ff1dd05d28426c594fb60b05 Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 22:59:03 +0200
Subject: [PATCH 010/460] Add speaker worker and schema walk
---
Cargo.lock | 7 +
crates/core/Cargo.toml | 5 +
crates/core/src/lib.rs | 3 +
crates/core/src/poll_schedule.rs | 107 ++++++
crates/core/src/schema_walk.rs | 97 ++++++
crates/core/src/speaker.rs | 434 ++++++++++++++++++++++++
crates/core/tests/speaker.rs | 559 +++++++++++++++++++++++++++++++
7 files changed, 1212 insertions(+)
create mode 100644 crates/core/src/poll_schedule.rs
create mode 100644 crates/core/src/schema_walk.rs
create mode 100644 crates/core/src/speaker.rs
create mode 100644 crates/core/tests/speaker.rs
diff --git a/Cargo.lock b/Cargo.lock
index cf5d929..f95113a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -30,6 +30,12 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
[[package]]
name = "memchr"
version = "2.8.3"
@@ -44,6 +50,7 @@ version = "0.1.0"
name = "neumann-control-core"
version = "0.1.0"
dependencies = [
+ "log",
"serde",
"serde_json",
"toml",
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index f67384c..6005ca9 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -10,6 +10,7 @@ rust-version.workspace = true
test-support = []
[dependencies]
+log = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1"
@@ -21,3 +22,7 @@ required-features = ["test-support"]
[[test]]
name = "client"
required-features = ["test-support"]
+
+[[test]]
+name = "speaker"
+required-features = ["test-support"]
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index 4fc5910..3d6f95c 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -1,6 +1,9 @@
pub mod address;
pub mod catalog;
pub mod mask;
+mod poll_schedule;
+pub mod schema_walk;
#[cfg(feature = "test-support")]
pub mod simulator;
+pub mod speaker;
pub mod ssc;
diff --git a/crates/core/src/poll_schedule.rs b/crates/core/src/poll_schedule.rs
new file mode 100644
index 0000000..a913f2c
--- /dev/null
+++ b/crates/core/src/poll_schedule.rs
@@ -0,0 +1,107 @@
+//! When the speaker worker reads which path: meters on their own tick, the fast and slow paths
+//! one at a time in turn, so a long round of them never delays the meters.
+
+use std::time::{Duration, Instant};
+
+use crate::catalog::{Entry, Poll};
+use crate::speaker::Timing;
+use crate::ssc::wire::SscPath;
+
+#[derive(Debug, PartialEq)]
+pub enum Due {
+ Meters(Vec),
+ Path(SscPath),
+ Nothing,
+}
+
+struct Slot {
+ path: SscPath,
+ interval: Duration,
+ due: Instant,
+}
+
+pub struct Schedule {
+ meters: Vec,
+ meter_interval: Duration,
+ next_meter: Instant,
+ slots: Vec,
+ cursor: usize,
+}
+
+impl Schedule {
+ /// `Once` entries are left out: they are read when connecting only.
+ pub fn new(entries: &[&Entry], timing: &Timing, now: Instant) -> Schedule {
+ let mut meters = Vec::new();
+ let mut slots = Vec::new();
+ for entry in entries {
+ let interval = match entry.poll {
+ Poll::Meter => {
+ meters.push(entry.path.clone());
+ continue;
+ }
+ Poll::Once => continue,
+ Poll::Fast => timing.fast,
+ Poll::Slow => timing.slow,
+ };
+ slots.push(Slot {
+ path: entry.path.clone(),
+ interval,
+ due: now + interval,
+ });
+ }
+ Schedule {
+ meters,
+ meter_interval: timing.meter,
+ next_meter: now + timing.meter,
+ slots,
+ cursor: 0,
+ }
+ }
+
+ /// How long until something is due; with nothing left to poll, one meter interval.
+ pub fn time_until_due(&self, now: Instant) -> Duration {
+ let meter_due = (!self.meters.is_empty()).then_some(self.next_meter);
+ let earliest = self
+ .slots
+ .iter()
+ .map(|slot| slot.due)
+ .chain(meter_due)
+ .min();
+ earliest.map_or(self.meter_interval, |due| {
+ due.saturating_duration_since(now)
+ })
+ }
+
+ /// The due item, rescheduled from `now`. Meters go first; of the due paths only the next
+ /// one in turn, so every due path gets its read before any path gets a second one.
+ pub fn take_due(&mut self, now: Instant) -> Due {
+ let meters_due = !self.meters.is_empty() && self.next_meter <= now;
+ if meters_due {
+ self.next_meter = now + self.meter_interval;
+ return Due::Meters(self.meters.clone());
+ }
+ let Some(index) = self.next_due_slot(now) else {
+ return Due::Nothing;
+ };
+ self.cursor = (index + 1) % self.slots.len();
+ let slot = &mut self.slots[index];
+ slot.due = now + slot.interval;
+ Due::Path(slot.path.clone())
+ }
+
+ /// Stops polling `path`, e.g. because the speaker does not have it.
+ pub fn forget(&mut self, path: &SscPath) {
+ self.meters.retain(|meter| meter != path);
+ self.slots.retain(|slot| &slot.path != path);
+ if self.cursor >= self.slots.len() {
+ self.cursor = 0;
+ }
+ }
+
+ fn next_due_slot(&self, now: Instant) -> Option {
+ let count = self.slots.len();
+ (0..count)
+ .map(|offset| (self.cursor + offset) % count)
+ .find(|&index| self.slots[index].due <= now)
+ }
+}
diff --git a/crates/core/src/schema_walk.rs b/crates/core/src/schema_walk.rs
new file mode 100644
index 0000000..50d67ad
--- /dev/null
+++ b/crates/core/src/schema_walk.rs
@@ -0,0 +1,97 @@
+//! Reads every value a speaker has by walking `osc/schema`, for the "read everything" view.
+
+use std::collections::VecDeque;
+
+use serde_json::Value;
+
+use crate::ssc::client::{Client, NodeKind, SscError};
+use crate::ssc::wire::SscPath;
+
+/// Bounds the walk, because the tree comes from a network device that can describe anything.
+pub const MAX_SCHEMA_NODES: usize = 500;
+pub const MAX_SCHEMA_DEPTH: usize = 10;
+
+/// Holds the protocol's own methods (`osc/schema`, `osc/limits`, ...), not device values.
+const PROTOCOL_CONTAINER: &str = "osc";
+const PATH_SEPARATOR: char = '/';
+
+#[derive(Clone, Debug, PartialEq)]
+pub struct LeafReport {
+ pub path: SscPath,
+ /// The value, or the device's refusal as text.
+ pub value: Result,
+ /// `None` when the device has no limits for the leaf.
+ pub limits: Option,
+}
+
+/// Every leaf breadth-first from the root with its value and limits. A device error on one
+/// leaf stays in that leaf's report; a lost connection or a tree over the bounds fails it all.
+pub fn read_all(client: &mut Client) -> Result, SscError> {
+ leaf_paths(client)?
+ .into_iter()
+ .map(|path| report(client, path))
+ .collect()
+}
+
+fn leaf_paths(client: &mut Client) -> Result, SscError> {
+ let mut leaves = Vec::new();
+ let mut containers: VecDeque> = VecDeque::from([None]);
+ let mut visited = 0usize;
+ while let Some(container) = containers.pop_front() {
+ for (name, kind) in client.schema(container.as_ref())? {
+ if container.is_none() && name == PROTOCOL_CONTAINER {
+ continue;
+ }
+ visited += 1;
+ if visited > MAX_SCHEMA_NODES {
+ return Err(SscError::Protocol(format!(
+ "schema has more than {MAX_SCHEMA_NODES} nodes"
+ )));
+ }
+ let path = child_path(container.as_ref(), &name)?;
+ let depth = path.segments().len();
+ match kind {
+ NodeKind::Leaf => leaves.push(path),
+ NodeKind::Container if depth >= MAX_SCHEMA_DEPTH => {
+ return Err(SscError::Protocol(format!(
+ "schema is deeper than {MAX_SCHEMA_DEPTH} levels"
+ )));
+ }
+ NodeKind::Container => containers.push_back(Some(path)),
+ }
+ }
+ }
+ Ok(leaves)
+}
+
+fn report(client: &mut Client, path: SscPath) -> Result {
+ let value = match client.get(&path) {
+ Ok(value) => Ok(value),
+ Err(refusal @ SscError::Device(_)) => Err(refusal.to_string()),
+ Err(lost) => return Err(lost),
+ };
+ let limits = match client.limits(&path) {
+ Ok(limits) => Some(limits),
+ Err(SscError::Device(_)) => None,
+ Err(lost) => return Err(lost),
+ };
+ Ok(LeafReport {
+ path,
+ value,
+ limits,
+ })
+}
+
+/// A node name holding the separator would silently become several segments.
+fn child_path(parent: Option<&SscPath>, name: &str) -> Result {
+ if name.contains(PATH_SEPARATOR) {
+ return Err(SscError::Protocol(format!(
+ "schema names a node with a '{PATH_SEPARATOR}'"
+ )));
+ }
+ let text = match parent {
+ Some(parent) => format!("{parent}{PATH_SEPARATOR}{name}"),
+ None => name.to_owned(),
+ };
+ SscPath::parse(&text).map_err(|error| SscError::Protocol(error.to_string()))
+}
diff --git a/crates/core/src/speaker.rs b/crates/core/src/speaker.rs
new file mode 100644
index 0000000..59687c5
--- /dev/null
+++ b/crates/core/src/speaker.rs
@@ -0,0 +1,434 @@
+//! The worker that owns the connection to one speaker: one thread per speaker, commands from
+//! the app before polling, and everything it learns sent back as events.
+
+use std::collections::HashSet;
+use std::net::SocketAddrV6;
+use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
+use std::sync::Arc;
+use std::thread::{self, JoinHandle};
+use std::time::{Duration, Instant};
+
+use serde_json::Value;
+
+use crate::catalog::{Catalog, Entry};
+use crate::mask::{mask_host, mask_serial};
+use crate::poll_schedule::{Due, Schedule};
+use crate::schema_walk::{self, LeafReport};
+use crate::ssc::client::{Client, SscError};
+use crate::ssc::wire::{DeviceError, SscPath};
+
+const DEFAULT_METER_INTERVAL: Duration = Duration::from_millis(100);
+const DEFAULT_FAST_INTERVAL: Duration = Duration::from_secs(2);
+const DEFAULT_SLOW_INTERVAL: Duration = Duration::from_secs(30);
+const DEFAULT_RECONNECT_INTERVAL: Duration = Duration::from_secs(10);
+
+const PRODUCT_PATH: &str = "device/identity/product";
+const VERSION_PATH: &str = "device/identity/version";
+const SERIAL_PATH: &str = "device/identity/serial";
+const NAME_PATH: &str = "device/name";
+
+const NOT_FOUND_CODE: i64 = 404;
+const NOT_CONNECTED_MESSAGE: &str = "the speaker is not connected";
+const THREAD_NAME: &str = "speaker";
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Target {
+ pub address: SocketAddrV6,
+ /// When set, a speaker reporting another serial is not used.
+ pub expected_serial: Option,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct Timing {
+ pub meter: Duration,
+ pub fast: Duration,
+ pub slow: Duration,
+ pub reconnect: Duration,
+}
+
+impl Default for Timing {
+ fn default() -> Timing {
+ Timing {
+ meter: DEFAULT_METER_INTERVAL,
+ fast: DEFAULT_FAST_INTERVAL,
+ slow: DEFAULT_SLOW_INTERVAL,
+ reconnect: DEFAULT_RECONNECT_INTERVAL,
+ }
+ }
+}
+
+/// The value of a `Set` is checked against the catalog by the app before it is sent.
+#[derive(Clone, Debug, PartialEq)]
+pub enum Command {
+ Set { path: SscPath, value: Value },
+ ReadAll,
+ Stop,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Identity {
+ pub product: String,
+ pub version: String,
+ pub serial: String,
+ pub name: String,
+}
+
+#[derive(Clone, Debug, PartialEq)]
+pub enum Event {
+ Connected(Identity),
+ /// Sent once per outage; the text is the masked error.
+ Unreachable(String),
+ Value {
+ path: SscPath,
+ value: Value,
+ },
+ /// The speaker does not have the path; it is not polled again on this connection.
+ Absent(SscPath),
+ /// The speaker refused the write; a `Value` with the unchanged value follows.
+ WriteRejected {
+ path: SscPath,
+ error: DeviceError,
+ },
+ /// The write did not reach the speaker or its answer got lost.
+ WriteFailed {
+ path: SscPath,
+ message: String,
+ },
+ SerialMismatch {
+ expected: String,
+ found: String,
+ },
+ AllValues(Vec),
+ AllValuesFailed(String),
+}
+
+pub struct SpeakerHandle {
+ commands: Sender,
+ thread: Option>,
+}
+
+impl SpeakerHandle {
+ pub fn send(&self, command: Command) {
+ // After Stop or a panic the worker is gone and the command has no one to run it;
+ // Drop logs a panic when joining.
+ let _ = self.commands.send(command);
+ }
+}
+
+impl Drop for SpeakerHandle {
+ fn drop(&mut self) {
+ let _ = self.commands.send(Command::Stop);
+ let Some(thread) = self.thread.take() else {
+ return;
+ };
+ if thread.join().is_err() {
+ log::error!("speaker worker panicked");
+ }
+ }
+}
+
+/// Starts the worker thread. `wake` runs after every event, e.g. to repaint the UI.
+pub fn spawn(
+ target: Target,
+ timing: Timing,
+ wake: Arc,
+) -> (SpeakerHandle, Receiver) {
+ let (command_sender, commands) = mpsc::channel();
+ let (events, event_receiver) = mpsc::channel();
+ let worker = Worker {
+ host: mask_host(&target.address),
+ target,
+ timing,
+ commands,
+ outbox: Outbox { events, wake },
+ reported: None,
+ };
+ let thread = thread::Builder::new()
+ .name(THREAD_NAME.to_owned())
+ .spawn(move || worker.run())
+ .expect("the operating system starts a thread");
+ let handle = SpeakerHandle {
+ commands: command_sender,
+ thread: Some(thread),
+ };
+ (handle, event_receiver)
+}
+
+struct Outbox {
+ events: Sender,
+ wake: Arc,
+}
+
+impl Outbox {
+ fn emit(&self, event: Event) {
+ // A dropped receiver means the app is closing; the handle's Drop stops this thread.
+ let _ = self.events.send(event);
+ (self.wake)();
+ }
+}
+
+/// Why the worker is disconnected, so each reason is reported once and not on every retry.
+#[derive(Clone, Copy, PartialEq, Eq)]
+enum Disconnect {
+ Unreachable,
+ SerialMismatch,
+}
+
+/// Why a connected session ended.
+enum Interruption {
+ Stopped,
+ Lost(SscError),
+}
+
+struct Stopped;
+
+/// The state of one connection; a reconnect starts a new one.
+struct Session {
+ client: Client,
+ schedule: Schedule,
+ absent: HashSet,
+}
+
+struct Worker {
+ target: Target,
+ host: String,
+ timing: Timing,
+ commands: Receiver,
+ outbox: Outbox,
+ reported: Option,
+}
+
+impl Worker {
+ fn run(mut self) {
+ loop {
+ if self.connect_and_serve().is_err() {
+ return;
+ }
+ if self.wait_disconnected(self.timing.reconnect).is_err() {
+ return;
+ }
+ }
+ }
+
+ /// One connection attempt and, if the speaker is the expected one, the session on it.
+ fn connect_and_serve(&mut self) -> Result<(), Stopped> {
+ let connected = Client::connect(self.target.address).and_then(|mut client| {
+ let identity = read_identity(&mut client)?;
+ Ok((client, identity))
+ });
+ let (client, identity) = match connected {
+ Ok(connected) => connected,
+ Err(error) => {
+ self.report_unreachable(&error);
+ return Ok(());
+ }
+ };
+ if let Some(expected) = self.mismatched_serial(&identity) {
+ self.report_mismatch(expected, identity.serial);
+ return Ok(());
+ }
+ log::info!("connected to {}", self.host);
+ self.reported = None;
+ let entries = Catalog::embedded().entries_for(&identity.product);
+ self.outbox.emit(Event::Connected(identity));
+ let mut session = Session {
+ client,
+ schedule: Schedule::new(&entries, &self.timing, Instant::now()),
+ absent: HashSet::new(),
+ };
+ match self.serve(&mut session, &entries) {
+ Interruption::Stopped => Err(Stopped),
+ Interruption::Lost(error) => {
+ self.report_unreachable(&error);
+ Ok(())
+ }
+ }
+ }
+
+ fn serve(&self, session: &mut Session, entries: &[&Entry]) -> Interruption {
+ for entry in entries {
+ if let Err(interruption) = self.read(session, &entry.path) {
+ return interruption;
+ }
+ }
+ loop {
+ if let Err(interruption) = self.step(session) {
+ return interruption;
+ }
+ }
+ }
+
+ /// A pending command, or else what the schedule has due; waits for whichever comes first.
+ fn step(&self, session: &mut Session) -> Result<(), Interruption> {
+ let wait = session.schedule.time_until_due(Instant::now());
+ match self.commands.recv_timeout(wait) {
+ Ok(command) => return self.execute(session, command),
+ Err(RecvTimeoutError::Timeout) => {}
+ Err(RecvTimeoutError::Disconnected) => return Err(Interruption::Stopped),
+ }
+ match session.schedule.take_due(Instant::now()) {
+ Due::Meters(paths) => paths.iter().try_for_each(|path| self.read(session, path)),
+ Due::Path(path) => self.read(session, &path),
+ Due::Nothing => Ok(()),
+ }
+ }
+
+ fn execute(&self, session: &mut Session, command: Command) -> Result<(), Interruption> {
+ match command {
+ Command::Set { path, value } => self.write(session, path, value),
+ Command::ReadAll => self.read_all(session),
+ Command::Stop => Err(Interruption::Stopped),
+ }
+ }
+
+ fn read(&self, session: &mut Session, path: &SscPath) -> Result<(), Interruption> {
+ match session.client.get(path) {
+ Ok(value) => self.outbox.emit(Event::Value {
+ path: path.clone(),
+ value,
+ }),
+ Err(SscError::Device(error)) if error.code == NOT_FOUND_CODE => {
+ self.mark_absent(session, path);
+ }
+ // Nothing the user could act on; the next poll asks again.
+ Err(SscError::Device(error)) => log::warn!(
+ "{} refused to read {path} ({}): {}",
+ self.host,
+ error.code,
+ error.description
+ ),
+ Err(lost) => return Err(Interruption::Lost(lost)),
+ }
+ Ok(())
+ }
+
+ fn write(
+ &self,
+ session: &mut Session,
+ path: SscPath,
+ value: Value,
+ ) -> Result<(), Interruption> {
+ let error = match session.client.set(&path, value) {
+ Ok(confirmed) => {
+ self.outbox.emit(Event::Value {
+ path,
+ value: confirmed,
+ });
+ return Ok(());
+ }
+ Err(error) => error,
+ };
+ match error {
+ SscError::Device(error) if error.code == NOT_FOUND_CODE => {
+ self.mark_absent(session, &path);
+ Ok(())
+ }
+ // The device kept its old value (read-only, wrong type, ...); show that one again.
+ SscError::Device(error) => {
+ self.outbox.emit(Event::WriteRejected {
+ path: path.clone(),
+ error,
+ });
+ self.read(session, &path)
+ }
+ lost => {
+ self.outbox.emit(Event::WriteFailed {
+ path,
+ message: lost.to_string(),
+ });
+ Err(Interruption::Lost(lost))
+ }
+ }
+ }
+
+ fn read_all(&self, session: &mut Session) -> Result<(), Interruption> {
+ let error = match schema_walk::read_all(&mut session.client) {
+ Ok(reports) => {
+ self.outbox.emit(Event::AllValues(reports));
+ return Ok(());
+ }
+ Err(error) => error,
+ };
+ self.outbox.emit(Event::AllValuesFailed(error.to_string()));
+ if error.is_connection_lost() {
+ return Err(Interruption::Lost(error));
+ }
+ Ok(())
+ }
+
+ fn mark_absent(&self, session: &mut Session, path: &SscPath) {
+ if !session.absent.insert(path.clone()) {
+ return;
+ }
+ session.schedule.forget(path);
+ self.outbox.emit(Event::Absent(path.clone()));
+ }
+
+ /// Waits between connection attempts, answering commands at once since none can run.
+ fn wait_disconnected(&self, duration: Duration) -> Result<(), Stopped> {
+ let deadline = Instant::now() + duration;
+ loop {
+ let remaining = deadline.saturating_duration_since(Instant::now());
+ match self.commands.recv_timeout(remaining) {
+ Ok(Command::Set { path, .. }) => self.outbox.emit(Event::WriteFailed {
+ path,
+ message: NOT_CONNECTED_MESSAGE.to_owned(),
+ }),
+ Ok(Command::ReadAll) => self
+ .outbox
+ .emit(Event::AllValuesFailed(NOT_CONNECTED_MESSAGE.to_owned())),
+ Ok(Command::Stop) | Err(RecvTimeoutError::Disconnected) => return Err(Stopped),
+ Err(RecvTimeoutError::Timeout) => return Ok(()),
+ }
+ }
+ }
+
+ fn mismatched_serial(&self, identity: &Identity) -> Option {
+ self.target
+ .expected_serial
+ .clone()
+ .filter(|expected| *expected != identity.serial)
+ }
+
+ fn report_unreachable(&mut self, error: &SscError) {
+ if self.reported == Some(Disconnect::Unreachable) {
+ return;
+ }
+ self.reported = Some(Disconnect::Unreachable);
+ let reason = error.to_string();
+ log::info!("{} is unreachable: {reason}", self.host);
+ self.outbox.emit(Event::Unreachable(reason));
+ }
+
+ fn report_mismatch(&mut self, expected: String, found: String) {
+ if self.reported == Some(Disconnect::SerialMismatch) {
+ return;
+ }
+ self.reported = Some(Disconnect::SerialMismatch);
+ log::info!(
+ "{} has serial {}, expected {}",
+ self.host,
+ mask_serial(&found),
+ mask_serial(&expected)
+ );
+ self.outbox.emit(Event::SerialMismatch { expected, found });
+ }
+}
+
+fn read_identity(client: &mut Client) -> Result {
+ Ok(Identity {
+ product: read_text(client, PRODUCT_PATH)?,
+ version: read_text(client, VERSION_PATH)?,
+ serial: read_text(client, SERIAL_PATH)?,
+ name: read_text(client, NAME_PATH)?,
+ })
+}
+
+/// A string value as it is; any other value in its JSON form.
+fn read_text(client: &mut Client, path: &str) -> Result {
+ let path = SscPath::parse(path).expect("constant identity path is valid");
+ match client.get(&path)? {
+ Value::String(text) => Ok(text),
+ other => Ok(other.to_string()),
+ }
+}
diff --git a/crates/core/tests/speaker.rs b/crates/core/tests/speaker.rs
new file mode 100644
index 0000000..f0ac5f9
--- /dev/null
+++ b/crates/core/tests/speaker.rs
@@ -0,0 +1,559 @@
+use std::net::{Ipv6Addr, SocketAddrV6, TcpListener};
+use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
+use std::sync::mpsc::{Receiver, RecvTimeoutError};
+use std::sync::Arc;
+use std::thread::{self, JoinHandle};
+use std::time::{Duration, Instant};
+
+use neumann_control_core::catalog::Catalog;
+use neumann_control_core::schema_walk::read_all;
+use neumann_control_core::simulator::{Fault, SimModel, Simulator};
+use neumann_control_core::speaker::{
+ spawn, Command, Event, Identity, SpeakerHandle, Target, Timing,
+};
+use neumann_control_core::ssc::client::Client;
+use neumann_control_core::ssc::wire::SscPath;
+use serde_json::{json, Value};
+
+const EVENT_DEADLINE: Duration = Duration::from_secs(5);
+// A refused loopback connect takes about 2 s on Windows, so a restart needs a few of them.
+const RECOVERY_DEADLINE: Duration = Duration::from_secs(15);
+const REQUEST_POLL_INTERVAL: Duration = Duration::from_millis(10);
+const QUIET_PERIOD: Duration = Duration::from_millis(300);
+const STOP_DEADLINE: Duration = Duration::from_secs(1);
+const LONG_INTERVAL: Duration = Duration::from_secs(60);
+const READ_ONLY_CODE: i64 = 405;
+const KH_120_II_LEAVES: usize = 50;
+const KH_120_II_PRODUCT: &str = "KH 120 II";
+const KH_120_II_SERIAL: &str = "SIM0001234";
+
+fn path(text: &str) -> SscPath {
+ SscPath::parse(text).expect("a valid path")
+}
+
+fn short_timing() -> Timing {
+ Timing {
+ meter: Duration::from_millis(20),
+ fast: Duration::from_millis(50),
+ slow: Duration::from_millis(200),
+ reconnect: Duration::from_millis(100),
+ }
+}
+
+fn quiet_timing() -> Timing {
+ Timing {
+ meter: LONG_INTERVAL,
+ fast: LONG_INTERVAL,
+ slow: LONG_INTERVAL,
+ reconnect: Duration::from_millis(100),
+ }
+}
+
+fn no_wake() -> Arc {
+ Arc::new(|| {})
+}
+
+fn start_worker(
+ address: SocketAddrV6,
+ expected_serial: Option<&str>,
+ timing: Timing,
+) -> (SpeakerHandle, Receiver) {
+ let target = Target {
+ address,
+ expected_serial: expected_serial.map(str::to_owned),
+ };
+ spawn(target, timing, no_wake())
+}
+
+/// Every event up to and including the first that matches; panics when none comes in time.
+fn collect_until(
+ events: &Receiver,
+ deadline: Duration,
+ matches: impl Fn(&Event) -> bool,
+) -> Vec {
+ let end = Instant::now() + deadline;
+ let mut seen = Vec::new();
+ loop {
+ let remaining = end.saturating_duration_since(Instant::now());
+ match events.recv_timeout(remaining) {
+ Ok(event) => {
+ let found = matches(&event);
+ seen.push(event);
+ if found {
+ return seen;
+ }
+ }
+ Err(error) => panic!("no matching event ({error:?}); seen: {seen:?}"),
+ }
+ }
+}
+
+fn is_connected(event: &Event) -> bool {
+ matches!(event, Event::Connected(_))
+}
+
+fn is_unreachable(event: &Event) -> bool {
+ matches!(event, Event::Unreachable(_))
+}
+
+fn is_value_of(event: &Event, wanted: &SscPath) -> bool {
+ matches!(event, Event::Value { path, .. } if path == wanted)
+}
+
+/// Whether a parsed request names `wanted`, read or write.
+fn request_names(request: &Value, wanted: &SscPath) -> bool {
+ wanted
+ .segments()
+ .iter()
+ .try_fold(request, |node, segment| node.get(segment))
+ .is_some()
+}
+
+fn count_requests(simulator: &Simulator, wanted: &SscPath) -> usize {
+ simulator
+ .requests()
+ .iter()
+ .filter(|request| request_names(request, wanted))
+ .count()
+}
+
+fn wait_for_requests(simulator: &Simulator, wanted: &SscPath, count: usize, deadline: Duration) {
+ let end = Instant::now() + deadline;
+ while count_requests(simulator, wanted) < count {
+ assert!(
+ Instant::now() < end,
+ "fewer than {count} requests for {wanted}"
+ );
+ thread::sleep(REQUEST_POLL_INTERVAL);
+ }
+}
+
+/// The last path the worker reads when connecting: once its value arrives, polling has begun.
+fn last_initial_path(product: &str) -> SscPath {
+ Catalog::embedded()
+ .entries_for(product)
+ .last()
+ .expect("the catalog lists entries")
+ .path
+ .clone()
+}
+
+/// Accepts connections and closes them at once: a peer that is there but never answers a
+/// request, so every attempt fails fast.
+struct ClosingListener {
+ address: SocketAddrV6,
+ accepted: Arc,
+ stopping: Arc,
+ thread: Option>,
+}
+
+impl ClosingListener {
+ fn start() -> ClosingListener {
+ let listener = TcpListener::bind(SocketAddrV6::new(Ipv6Addr::LOCALHOST, 0, 0, 0))
+ .expect("IPv6 loopback is available");
+ let std::net::SocketAddr::V6(address) = listener.local_addr().expect("a bound address")
+ else {
+ panic!("listener is not IPv6");
+ };
+ let accepted = Arc::new(AtomicUsize::new(0));
+ let stopping = Arc::new(AtomicBool::new(false));
+ let thread = thread::spawn({
+ let accepted = Arc::clone(&accepted);
+ let stopping = Arc::clone(&stopping);
+ move || {
+ for incoming in listener.incoming() {
+ if stopping.load(Ordering::SeqCst) {
+ break;
+ }
+ drop(incoming);
+ accepted.fetch_add(1, Ordering::SeqCst);
+ }
+ }
+ });
+ ClosingListener {
+ address,
+ accepted,
+ stopping,
+ thread: Some(thread),
+ }
+ }
+
+ fn wait_for_accepts(&self, count: usize) {
+ let end = Instant::now() + EVENT_DEADLINE;
+ while self.accepted.load(Ordering::SeqCst) < count {
+ assert!(
+ Instant::now() < end,
+ "fewer than {count} connection attempts"
+ );
+ thread::sleep(REQUEST_POLL_INTERVAL);
+ }
+ }
+}
+
+impl Drop for ClosingListener {
+ fn drop(&mut self) {
+ self.stopping.store(true, Ordering::SeqCst);
+ let _ = std::net::TcpStream::connect(self.address);
+ if let Some(thread) = self.thread.take() {
+ let _ = thread.join();
+ }
+ }
+}
+
+#[test]
+fn worker_reports_identity_then_values() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ let woken = Arc::new(AtomicUsize::new(0));
+ let wake: Arc = Arc::new({
+ let woken = Arc::clone(&woken);
+ move || {
+ woken.fetch_add(1, Ordering::SeqCst);
+ }
+ });
+ let target = Target {
+ address: simulator.address(),
+ expected_serial: Some(KH_120_II_SERIAL.to_owned()),
+ };
+ let (_handle, events) = spawn(target, short_timing(), wake);
+
+ let first = events.recv_timeout(EVENT_DEADLINE).expect("an event");
+ assert_eq!(
+ first,
+ Event::Connected(Identity {
+ product: KH_120_II_PRODUCT.to_owned(),
+ version: "1_7_3".to_owned(),
+ serial: KH_120_II_SERIAL.to_owned(),
+ name: "Simulated KH 120 II".to_owned(),
+ })
+ );
+ let level = path("audio/out/level");
+ let seen = collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &level));
+ let expected = Event::Value {
+ path: level,
+ value: simulator
+ .value("audio/out/level")
+ .expect("the sim has a level"),
+ };
+ assert_eq!(seen.last(), Some(&expected));
+ assert!(woken.load(Ordering::SeqCst) > seen.len());
+}
+
+#[test]
+fn set_command_emits_confirmed_value() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ let (handle, events) = start_worker(simulator.address(), None, short_timing());
+ collect_until(&events, EVENT_DEADLINE, is_connected);
+
+ let mute = path("audio/out/mute");
+ handle.send(Command::Set {
+ path: mute.clone(),
+ value: json!(true),
+ });
+ collect_until(&events, EVENT_DEADLINE, |event| {
+ *event
+ == Event::Value {
+ path: mute.clone(),
+ value: json!(true),
+ }
+ });
+ assert_eq!(simulator.value("audio/out/mute"), Some(json!(true)));
+}
+
+#[test]
+fn read_only_set_emits_rejection_and_reread() {
+ let simulator = Simulator::start(SimModel::Kh750);
+ let (handle, events) = start_worker(simulator.address(), None, quiet_timing());
+ collect_until(&events, EVENT_DEADLINE, is_connected);
+
+ let standby = path("device/standby/enabled");
+ let current = simulator
+ .value("device/standby/enabled")
+ .expect("the sim has the path");
+ let refused = Value::Bool(!current.as_bool().expect("a switch"));
+ handle.send(Command::Set {
+ path: standby.clone(),
+ value: refused,
+ });
+ let seen = collect_until(&events, EVENT_DEADLINE, |event| {
+ matches!(event, Event::WriteRejected { .. })
+ });
+ let Some(Event::WriteRejected { path, error }) = seen.last() else {
+ unreachable!("collect_until returns the match last");
+ };
+ assert_eq!(path, &standby);
+ assert_eq!(error.code, READ_ONLY_CODE);
+
+ let reread = events.recv_timeout(EVENT_DEADLINE).expect("the re-read");
+ assert_eq!(
+ reread,
+ Event::Value {
+ path: standby,
+ value: current,
+ }
+ );
+}
+
+#[test]
+fn absent_path_is_reported_once_and_not_polled_again() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ simulator.remove_value("audio/out/mute");
+ let (_handle, events) = start_worker(simulator.address(), None, short_timing());
+
+ let mute = path("audio/out/mute");
+ let mut seen = collect_until(&events, EVENT_DEADLINE, |event| {
+ *event == Event::Absent(mute.clone())
+ });
+ // Another fast path read three more times proves several fast rounds went by.
+ let level = path("audio/out/level");
+ for _ in 0..3 {
+ seen.extend(collect_until(&events, EVENT_DEADLINE, |event| {
+ is_value_of(event, &level)
+ }));
+ }
+
+ let absent_reports = seen
+ .iter()
+ .filter(|event| **event == Event::Absent(mute.clone()))
+ .count();
+ assert_eq!(absent_reports, 1);
+ assert_eq!(count_requests(&simulator, &mute), 1);
+}
+
+#[test]
+fn commands_run_before_polling() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ let busy = Timing {
+ meter: LONG_INTERVAL,
+ fast: Duration::from_millis(1),
+ slow: Duration::from_millis(1),
+ reconnect: Duration::from_millis(100),
+ };
+ let (handle, events) = start_worker(simulator.address(), None, busy);
+ // A fast path read twice means the connect-time read is over and polling runs nonstop.
+ let level = path("audio/out/level");
+ collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &level));
+ collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &level));
+
+ let before = simulator.requests().len();
+ handle.send(Command::Set {
+ path: level.clone(),
+ value: json!(90.0),
+ });
+ collect_until(&events, EVENT_DEADLINE, |event| {
+ *event
+ == Event::Value {
+ path: level.clone(),
+ value: json!(90.0),
+ }
+ });
+
+ let requests = simulator.requests();
+ let set_index = requests
+ .iter()
+ .position(|request| request["audio"]["out"]["level"] == json!(90.0))
+ .expect("the set reached the simulator");
+ // At most the one poll that was already under way may come first.
+ assert!(
+ set_index <= before + 1,
+ "set was request {set_index}, {before} requests before it was sent"
+ );
+}
+
+#[test]
+fn meters_are_polled_between_slow_paths() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ let busy = Timing {
+ meter: Duration::from_millis(50),
+ fast: Duration::from_millis(1),
+ slow: Duration::from_millis(1),
+ reconnect: Duration::from_millis(100),
+ };
+ let (_handle, events) = start_worker(simulator.address(), None, busy);
+ let last = last_initial_path(KH_120_II_PRODUCT);
+ collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &last));
+
+ let meter = path("m/in/level");
+ let slow = path("device/name");
+ let meters_before = count_requests(&simulator, &meter);
+ let slow_before = count_requests(&simulator, &slow);
+ let end = Instant::now() + Duration::from_secs(1);
+ while Instant::now() < end && count_requests(&simulator, &meter) < meters_before + 10 {
+ thread::sleep(REQUEST_POLL_INTERVAL);
+ }
+
+ assert!(count_requests(&simulator, &meter) >= meters_before + 10);
+ assert!(count_requests(&simulator, &slow) > slow_before);
+}
+
+#[test]
+fn worker_recovers_after_simulator_restart() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ let address = simulator.address();
+ let (_handle, events) = start_worker(address, None, short_timing());
+ collect_until(&events, EVENT_DEADLINE, is_connected);
+
+ simulator.stop();
+ let seen = collect_until(&events, EVENT_DEADLINE, is_unreachable);
+ let Some(Event::Unreachable(reason)) = seen.last() else {
+ unreachable!("collect_until returns the match last");
+ };
+ assert!(!reason.contains(&address.ip().to_string()));
+
+ let _restarted = Simulator::start_on(SimModel::Kh120Ii, address).expect("the port is free");
+ let seen = collect_until(&events, RECOVERY_DEADLINE, is_connected);
+ assert!(!seen.iter().any(is_unreachable), "outage reported twice");
+}
+
+#[test]
+fn unreachable_is_reported_once_per_outage() {
+ let listener = ClosingListener::start();
+ let (_handle, events) = start_worker(listener.address, None, short_timing());
+
+ collect_until(&events, EVENT_DEADLINE, is_unreachable);
+ listener.wait_for_accepts(3);
+
+ let further: Vec = events.try_iter().collect();
+ assert_eq!(further, Vec::new());
+}
+
+#[test]
+fn commands_while_unreachable_fail_at_once() {
+ let listener = ClosingListener::start();
+ let timing = Timing {
+ reconnect: LONG_INTERVAL,
+ ..short_timing()
+ };
+ let (handle, events) = start_worker(listener.address, None, timing);
+ collect_until(&events, EVENT_DEADLINE, is_unreachable);
+
+ let level = path("audio/out/level");
+ handle.send(Command::Set {
+ path: level.clone(),
+ value: json!(90.0),
+ });
+ handle.send(Command::ReadAll);
+ let failed = events
+ .recv_timeout(EVENT_DEADLINE)
+ .expect("a write failure");
+ assert!(matches!(failed, Event::WriteFailed { path, .. } if path == level));
+ let failed = events
+ .recv_timeout(EVENT_DEADLINE)
+ .expect("a read-all failure");
+ assert!(matches!(failed, Event::AllValuesFailed(_)));
+}
+
+#[test]
+fn connection_loss_during_set_reports_write_failed() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ let (handle, events) = start_worker(simulator.address(), None, quiet_timing());
+ let last = last_initial_path(KH_120_II_PRODUCT);
+ collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &last));
+
+ simulator.inject(Fault::CloseConnection);
+ let mute = path("audio/out/mute");
+ handle.send(Command::Set {
+ path: mute.clone(),
+ value: json!(true),
+ });
+ let failed = events
+ .recv_timeout(EVENT_DEADLINE)
+ .expect("a write failure");
+ assert!(matches!(failed, Event::WriteFailed { path, .. } if path == mute));
+ let lost = events.recv_timeout(EVENT_DEADLINE).expect("the outage");
+ assert!(is_unreachable(&lost));
+}
+
+#[test]
+fn serial_mismatch_is_reported() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ let (_handle, events) = start_worker(simulator.address(), Some("OTHER"), short_timing());
+
+ let first = events.recv_timeout(EVENT_DEADLINE).expect("an event");
+ assert_eq!(
+ first,
+ Event::SerialMismatch {
+ expected: "OTHER".to_owned(),
+ found: KH_120_II_SERIAL.to_owned(),
+ }
+ );
+ let serial = path("device/identity/serial");
+ wait_for_requests(&simulator, &serial, 2, EVENT_DEADLINE);
+ match events.recv_timeout(QUIET_PERIOD) {
+ Err(RecvTimeoutError::Timeout) => {}
+ other => panic!("a mismatched speaker must stay silent, got {other:?}"),
+ }
+}
+
+#[test]
+fn read_all_lists_every_leaf() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ let mut client = Client::connect(simulator.address()).expect("simulator accepts");
+
+ let reports = read_all(&mut client).expect("the walk succeeds");
+
+ assert_eq!(reports.len(), KH_120_II_LEAVES);
+ let mut paths: Vec = reports.iter().map(|leaf| leaf.path.to_string()).collect();
+ paths.sort();
+ paths.dedup();
+ assert_eq!(paths.len(), KH_120_II_LEAVES);
+ assert!(paths.iter().all(|text| !text.starts_with("osc/")));
+ let level = reports
+ .iter()
+ .find(|leaf| leaf.path == path("audio/out/level"))
+ .expect("the level is a leaf");
+ assert_eq!(
+ level.value,
+ Ok(simulator
+ .value("audio/out/level")
+ .expect("the sim has a level"))
+ );
+ assert_eq!(level.limits, Some(json!({"type": "Number"})));
+}
+
+#[test]
+fn read_all_command_reports_all_values() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ let (handle, events) = start_worker(simulator.address(), None, short_timing());
+ collect_until(&events, EVENT_DEADLINE, is_connected);
+
+ handle.send(Command::ReadAll);
+ let seen = collect_until(&events, EVENT_DEADLINE, |event| {
+ matches!(event, Event::AllValues(_) | Event::AllValuesFailed(_))
+ });
+ let Some(Event::AllValues(reports)) = seen.last() else {
+ panic!("expected all values, got {:?}", seen.last());
+ };
+ assert_eq!(reports.len(), KH_120_II_LEAVES);
+}
+
+#[test]
+fn drop_stops_the_thread() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ let (handle, events) = start_worker(simulator.address(), None, short_timing());
+ collect_until(&events, EVENT_DEADLINE, is_connected);
+
+ let started = Instant::now();
+ drop(handle);
+ assert!(started.elapsed() < STOP_DEADLINE);
+ // The worker owned the only sender; a finished thread leaves the channel closed.
+ let _: Vec = events.try_iter().collect();
+ assert_eq!(
+ events.recv_timeout(Duration::ZERO),
+ Err(RecvTimeoutError::Disconnected)
+ );
+}
+
+#[test]
+fn drop_stops_the_thread_while_waiting_to_reconnect() {
+ let listener = ClosingListener::start();
+ let timing = Timing {
+ reconnect: LONG_INTERVAL,
+ ..short_timing()
+ };
+ let (handle, events) = start_worker(listener.address, None, timing);
+ collect_until(&events, EVENT_DEADLINE, is_unreachable);
+
+ let started = Instant::now();
+ drop(handle);
+ assert!(started.elapsed() < STOP_DEADLINE);
+}
From 1b2f721b9fbb054e27b6dedf358542fdc232999d Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 23:12:44 +0200
Subject: [PATCH 011/460] Stop the worker promptly and keep it on walk errors
---
crates/core/src/schema_walk.rs | 71 ++++++++++++---
crates/core/src/simulator/mod.rs | 11 ++-
crates/core/src/speaker.rs | 107 +++++++++++++---------
crates/core/src/speaker/inbox.rs | 51 +++++++++++
crates/core/tests/speaker.rs | 151 +++++++++++++++++++++++++++++--
5 files changed, 328 insertions(+), 63 deletions(-)
create mode 100644 crates/core/src/speaker/inbox.rs
diff --git a/crates/core/src/schema_walk.rs b/crates/core/src/schema_walk.rs
index 50d67ad..1597989 100644
--- a/crates/core/src/schema_walk.rs
+++ b/crates/core/src/schema_walk.rs
@@ -24,27 +24,58 @@ pub struct LeafReport {
pub limits: Option,
}
+#[derive(Clone, Debug, PartialEq)]
+pub enum WalkError {
+ /// The connection is gone; the caller has to reconnect.
+ Connection(SscError),
+ /// The device or the shape of its tree ended the walk; the connection is still usable.
+ Refused(String),
+ /// `should_stop` asked to end the walk.
+ Stopped,
+}
+
+impl From for WalkError {
+ fn from(error: SscError) -> WalkError {
+ match error {
+ SscError::Device(_) => WalkError::Refused(error.to_string()),
+ lost => WalkError::Connection(lost),
+ }
+ }
+}
+
/// Every leaf breadth-first from the root with its value and limits. A device error on one
-/// leaf stays in that leaf's report; a lost connection or a tree over the bounds fails it all.
-pub fn read_all(client: &mut Client) -> Result, SscError> {
- leaf_paths(client)?
- .into_iter()
- .map(|path| report(client, path))
- .collect()
+/// leaf stays in that leaf's report. `should_stop` is asked before every request, so a caller
+/// waiting to shut down waits for one request at most.
+pub fn read_all(
+ client: &mut Client,
+ should_stop: &dyn Fn() -> bool,
+) -> Result, WalkError> {
+ let leaves = leaf_paths(client, should_stop)?;
+ let mut reports = Vec::with_capacity(leaves.len());
+ for path in leaves {
+ reports.push(report(client, path, should_stop)?);
+ }
+ Ok(reports)
}
-fn leaf_paths(client: &mut Client) -> Result, SscError> {
+fn leaf_paths(
+ client: &mut Client,
+ should_stop: &dyn Fn() -> bool,
+) -> Result, WalkError> {
let mut leaves = Vec::new();
let mut containers: VecDeque> = VecDeque::from([None]);
let mut visited = 0usize;
while let Some(container) = containers.pop_front() {
+ if should_stop() {
+ return Err(WalkError::Stopped);
+ }
for (name, kind) in client.schema(container.as_ref())? {
if container.is_none() && name == PROTOCOL_CONTAINER {
continue;
}
visited += 1;
if visited > MAX_SCHEMA_NODES {
- return Err(SscError::Protocol(format!(
+ return Err(WalkError::Refused(format!(
"schema has more than {MAX_SCHEMA_NODES} nodes"
)));
}
@@ -53,7 +84,7 @@ fn leaf_paths(client: &mut Client) -> Result, SscError> {
match kind {
NodeKind::Leaf => leaves.push(path),
NodeKind::Container if depth >= MAX_SCHEMA_DEPTH => {
- return Err(SscError::Protocol(format!(
+ return Err(WalkError::Refused(format!(
"schema is deeper than {MAX_SCHEMA_DEPTH} levels"
)));
}
@@ -64,16 +95,26 @@ fn leaf_paths(client: &mut Client) -> Result, SscError> {
Ok(leaves)
}
-fn report(client: &mut Client, path: SscPath) -> Result {
+fn report(
+ client: &mut Client,
+ path: SscPath,
+ should_stop: &dyn Fn() -> bool,
+) -> Result {
+ if should_stop() {
+ return Err(WalkError::Stopped);
+ }
let value = match client.get(&path) {
Ok(value) => Ok(value),
Err(refusal @ SscError::Device(_)) => Err(refusal.to_string()),
- Err(lost) => return Err(lost),
+ Err(lost) => return Err(WalkError::Connection(lost)),
};
+ if should_stop() {
+ return Err(WalkError::Stopped);
+ }
let limits = match client.limits(&path) {
Ok(limits) => Some(limits),
Err(SscError::Device(_)) => None,
- Err(lost) => return Err(lost),
+ Err(lost) => return Err(WalkError::Connection(lost)),
};
Ok(LeafReport {
path,
@@ -83,9 +124,9 @@ fn report(client: &mut Client, path: SscPath) -> Result {
}
/// A node name holding the separator would silently become several segments.
-fn child_path(parent: Option<&SscPath>, name: &str) -> Result {
+fn child_path(parent: Option<&SscPath>, name: &str) -> Result {
if name.contains(PATH_SEPARATOR) {
- return Err(SscError::Protocol(format!(
+ return Err(WalkError::Refused(format!(
"schema names a node with a '{PATH_SEPARATOR}'"
)));
}
@@ -93,5 +134,5 @@ fn child_path(parent: Option<&SscPath>, name: &str) -> Result
Some(parent) => format!("{parent}{PATH_SEPARATOR}{name}"),
None => name.to_owned(),
};
- SscPath::parse(&text).map_err(|error| SscError::Protocol(error.to_string()))
+ SscPath::parse(&text).map_err(|error| WalkError::Refused(error.to_string()))
}
diff --git a/crates/core/src/simulator/mod.rs b/crates/core/src/simulator/mod.rs
index d9e0541..d72cf47 100644
--- a/crates/core/src/simulator/mod.rs
+++ b/crates/core/src/simulator/mod.rs
@@ -60,6 +60,7 @@ struct State {
device: SimulatedDevice,
faults: VecDeque,
requests: Vec,
+ reply_delay: Duration,
}
#[derive(Default)]
@@ -137,7 +138,9 @@ impl Shared {
let Ok(Value::Object(request)) = parsed else {
continue;
};
- let lines = match self.respond(request) {
+ let delivery = self.respond(request);
+ thread::sleep(self.state().reply_delay);
+ let lines = match delivery {
Delivery::Lines(lines) => lines,
Delivery::Silent => continue,
Delivery::Close => break,
@@ -217,6 +220,7 @@ impl Simulator {
device: SimulatedDevice::new(model),
faults: VecDeque::new(),
requests: Vec::new(),
+ reply_delay: Duration::ZERO,
}),
connections: Mutex::new(Connections::default()),
stopping: AtomicBool::new(false),
@@ -251,6 +255,11 @@ impl Simulator {
self.shared.state().requests.clone()
}
+ /// Models a slow device: every later reply waits this long.
+ pub fn set_reply_delay(&self, delay: Duration) {
+ self.shared.state().reply_delay = delay;
+ }
+
pub fn value(&self, path: &str) -> Option {
self.shared.state().device.value(path)
}
diff --git a/crates/core/src/speaker.rs b/crates/core/src/speaker.rs
index 59687c5..3395ccc 100644
--- a/crates/core/src/speaker.rs
+++ b/crates/core/src/speaker.rs
@@ -1,6 +1,8 @@
//! The worker that owns the connection to one speaker: one thread per speaker, commands from
//! the app before polling, and everything it learns sent back as events.
+mod inbox;
+
use std::collections::HashSet;
use std::net::SocketAddrV6;
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender};
@@ -13,9 +15,10 @@ use serde_json::Value;
use crate::catalog::{Catalog, Entry};
use crate::mask::{mask_host, mask_serial};
use crate::poll_schedule::{Due, Schedule};
-use crate::schema_walk::{self, LeafReport};
+use crate::schema_walk::{self, LeafReport, WalkError};
use crate::ssc::client::{Client, SscError};
use crate::ssc::wire::{DeviceError, SscPath};
+use inbox::Inbox;
const DEFAULT_METER_INTERVAL: Duration = Duration::from_millis(100);
const DEFAULT_FAST_INTERVAL: Duration = Duration::from_secs(2);
@@ -82,9 +85,10 @@ pub enum Event {
path: SscPath,
value: Value,
},
- /// The speaker does not have the path; it is not polled again on this connection.
+ /// The speaker does not have the path; it is not polled again on this connection, and
+ /// every `Set` to it is answered with this again.
Absent(SscPath),
- /// The speaker refused the write; a `Value` with the unchanged value follows.
+ /// The speaker refused the write; a re-read follows.
WriteRejected {
path: SscPath,
error: DeviceError,
@@ -139,7 +143,7 @@ pub fn spawn(
host: mask_host(&target.address),
target,
timing,
- commands,
+ inbox: Inbox::new(commands),
outbox: Outbox { events, wake },
reported: None,
};
@@ -177,6 +181,7 @@ enum Disconnect {
/// Why a connected session ended.
enum Interruption {
Stopped,
+ /// The connection is gone, or the speaker cannot be used on it.
Lost(SscError),
}
@@ -193,7 +198,7 @@ struct Worker {
target: Target,
host: String,
timing: Timing,
- commands: Receiver,
+ inbox: Inbox,
outbox: Outbox,
reported: Option,
}
@@ -212,17 +217,21 @@ impl Worker {
/// One connection attempt and, if the speaker is the expected one, the session on it.
fn connect_and_serve(&mut self) -> Result<(), Stopped> {
- let connected = Client::connect(self.target.address).and_then(|mut client| {
- let identity = read_identity(&mut client)?;
- Ok((client, identity))
- });
- let (client, identity) = match connected {
- Ok(connected) => connected,
+ let mut client = match Client::connect(self.target.address) {
+ Ok(client) => client,
Err(error) => {
self.report_unreachable(&error);
return Ok(());
}
};
+ let identity = match self.read_identity(&mut client) {
+ Ok(identity) => identity,
+ Err(Interruption::Stopped) => return Err(Stopped),
+ Err(Interruption::Lost(error)) => {
+ self.report_unreachable(&error);
+ return Ok(());
+ }
+ };
if let Some(expected) = self.mismatched_serial(&identity) {
self.report_mismatch(expected, identity.serial);
return Ok(());
@@ -261,7 +270,7 @@ impl Worker {
/// A pending command, or else what the schedule has due; waits for whichever comes first.
fn step(&self, session: &mut Session) -> Result<(), Interruption> {
let wait = session.schedule.time_until_due(Instant::now());
- match self.commands.recv_timeout(wait) {
+ match self.inbox.next(wait) {
Ok(command) => return self.execute(session, command),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => return Err(Interruption::Stopped),
@@ -282,13 +291,18 @@ impl Worker {
}
fn read(&self, session: &mut Session, path: &SscPath) -> Result<(), Interruption> {
+ if self.inbox.stop_requested() {
+ return Err(Interruption::Stopped);
+ }
match session.client.get(path) {
Ok(value) => self.outbox.emit(Event::Value {
path: path.clone(),
value,
}),
Err(SscError::Device(error)) if error.code == NOT_FOUND_CODE => {
- self.mark_absent(session, path);
+ if self.forget(session, path) {
+ self.outbox.emit(Event::Absent(path.clone()));
+ }
}
// Nothing the user could act on; the next poll asks again.
Err(SscError::Device(error)) => log::warn!(
@@ -319,8 +333,10 @@ impl Worker {
Err(error) => error,
};
match error {
+ // Reported even when already known: the app waits for an answer to its write.
SscError::Device(error) if error.code == NOT_FOUND_CODE => {
- self.mark_absent(session, &path);
+ self.forget(session, &path);
+ self.outbox.emit(Event::Absent(path));
Ok(())
}
// The device kept its old value (read-only, wrong type, ...); show that one again.
@@ -342,26 +358,31 @@ impl Worker {
}
fn read_all(&self, session: &mut Session) -> Result<(), Interruption> {
- let error = match schema_walk::read_all(&mut session.client) {
+ let should_stop = || self.inbox.stop_requested();
+ match schema_walk::read_all(&mut session.client, &should_stop) {
Ok(reports) => {
self.outbox.emit(Event::AllValues(reports));
- return Ok(());
+ Ok(())
}
- Err(error) => error,
- };
- self.outbox.emit(Event::AllValuesFailed(error.to_string()));
- if error.is_connection_lost() {
- return Err(Interruption::Lost(error));
+ Err(WalkError::Refused(reason)) => {
+ self.outbox.emit(Event::AllValuesFailed(reason));
+ Ok(())
+ }
+ Err(WalkError::Connection(error)) => {
+ self.outbox.emit(Event::AllValuesFailed(error.to_string()));
+ Err(Interruption::Lost(error))
+ }
+ Err(WalkError::Stopped) => Err(Interruption::Stopped),
}
- Ok(())
}
- fn mark_absent(&self, session: &mut Session, path: &SscPath) {
+ /// Stops polling `path`; false when it was already known to be absent.
+ fn forget(&self, session: &mut Session, path: &SscPath) -> bool {
if !session.absent.insert(path.clone()) {
- return;
+ return false;
}
session.schedule.forget(path);
- self.outbox.emit(Event::Absent(path.clone()));
+ true
}
/// Waits between connection attempts, answering commands at once since none can run.
@@ -369,7 +390,7 @@ impl Worker {
let deadline = Instant::now() + duration;
loop {
let remaining = deadline.saturating_duration_since(Instant::now());
- match self.commands.recv_timeout(remaining) {
+ match self.inbox.next(remaining) {
Ok(Command::Set { path, .. }) => self.outbox.emit(Event::WriteFailed {
path,
message: NOT_CONNECTED_MESSAGE.to_owned(),
@@ -413,22 +434,26 @@ impl Worker {
);
self.outbox.emit(Event::SerialMismatch { expected, found });
}
-}
-fn read_identity(client: &mut Client) -> Result {
- Ok(Identity {
- product: read_text(client, PRODUCT_PATH)?,
- version: read_text(client, VERSION_PATH)?,
- serial: read_text(client, SERIAL_PATH)?,
- name: read_text(client, NAME_PATH)?,
- })
-}
+ /// A device error counts as lost too: without its identity the speaker cannot be used.
+ fn read_identity(&self, client: &mut Client) -> Result {
+ Ok(Identity {
+ product: self.read_text(client, PRODUCT_PATH)?,
+ version: self.read_text(client, VERSION_PATH)?,
+ serial: self.read_text(client, SERIAL_PATH)?,
+ name: self.read_text(client, NAME_PATH)?,
+ })
+ }
-/// A string value as it is; any other value in its JSON form.
-fn read_text(client: &mut Client, path: &str) -> Result {
- let path = SscPath::parse(path).expect("constant identity path is valid");
- match client.get(&path)? {
- Value::String(text) => Ok(text),
- other => Ok(other.to_string()),
+ /// A string value as it is; any other value in its JSON form.
+ fn read_text(&self, client: &mut Client, path: &str) -> Result {
+ if self.inbox.stop_requested() {
+ return Err(Interruption::Stopped);
+ }
+ let path = SscPath::parse(path).expect("constant identity path is valid");
+ match client.get(&path).map_err(Interruption::Lost)? {
+ Value::String(text) => Ok(text),
+ other => Ok(other.to_string()),
+ }
}
}
diff --git a/crates/core/src/speaker/inbox.rs b/crates/core/src/speaker/inbox.rs
new file mode 100644
index 0000000..7f70829
--- /dev/null
+++ b/crates/core/src/speaker/inbox.rs
@@ -0,0 +1,51 @@
+//! The worker's side of the command channel.
+
+use std::cell::{Cell, RefCell};
+use std::collections::VecDeque;
+use std::sync::mpsc::{Receiver, RecvTimeoutError, TryRecvError};
+use std::time::Duration;
+
+use super::Command;
+
+/// The command channel plus the commands taken off it early while looking for a `Stop`, kept
+/// in arrival order.
+pub(super) struct Inbox {
+ receiver: Receiver,
+ pending: RefCell>,
+ stop_requested: Cell,
+}
+
+impl Inbox {
+ pub(super) fn new(receiver: Receiver) -> Inbox {
+ Inbox {
+ receiver,
+ pending: RefCell::new(VecDeque::new()),
+ stop_requested: Cell::new(false),
+ }
+ }
+
+ /// Looks without waiting, so long work between polls can end early; a closed channel
+ /// counts as `Stop`.
+ pub(super) fn stop_requested(&self) -> bool {
+ while !self.stop_requested.get() {
+ match self.receiver.try_recv() {
+ Ok(Command::Stop) | Err(TryRecvError::Disconnected) => {
+ self.stop_requested.set(true);
+ }
+ Ok(command) => self.pending.borrow_mut().push_back(command),
+ Err(TryRecvError::Empty) => return false,
+ }
+ }
+ true
+ }
+
+ pub(super) fn next(&self, timeout: Duration) -> Result {
+ if self.stop_requested.get() {
+ return Ok(Command::Stop);
+ }
+ if let Some(command) = self.pending.borrow_mut().pop_front() {
+ return Ok(command);
+ }
+ self.receiver.recv_timeout(timeout)
+ }
+}
diff --git a/crates/core/tests/speaker.rs b/crates/core/tests/speaker.rs
index f0ac5f9..00b7bf6 100644
--- a/crates/core/tests/speaker.rs
+++ b/crates/core/tests/speaker.rs
@@ -6,7 +6,7 @@ use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
use neumann_control_core::catalog::Catalog;
-use neumann_control_core::schema_walk::read_all;
+use neumann_control_core::schema_walk::{read_all, WalkError, MAX_SCHEMA_DEPTH, MAX_SCHEMA_NODES};
use neumann_control_core::simulator::{Fault, SimModel, Simulator};
use neumann_control_core::speaker::{
spawn, Command, Event, Identity, SpeakerHandle, Target, Timing,
@@ -21,6 +21,8 @@ const RECOVERY_DEADLINE: Duration = Duration::from_secs(15);
const REQUEST_POLL_INTERVAL: Duration = Duration::from_millis(10);
const QUIET_PERIOD: Duration = Duration::from_millis(300);
const STOP_DEADLINE: Duration = Duration::from_secs(1);
+// Slow enough that a walk or connect-time read takes many seconds.
+const SLOW_REPLY: Duration = Duration::from_millis(200);
const LONG_INTERVAL: Duration = Duration::from_secs(60);
const READ_ONLY_CODE: i64 = 405;
const KH_120_II_LEAVES: usize = 50;
@@ -235,7 +237,12 @@ fn worker_reports_identity_then_values() {
.expect("the sim has a level"),
};
assert_eq!(seen.last(), Some(&expected));
- assert!(woken.load(Ordering::SeqCst) > seen.len());
+ // wake runs after the event is sent, so it may trail the receiver by a moment.
+ let end = Instant::now() + EVENT_DEADLINE;
+ while woken.load(Ordering::SeqCst) < seen.len() + 1 {
+ assert!(Instant::now() < end, "wake was not called for every event");
+ thread::sleep(REQUEST_POLL_INTERVAL);
+ }
}
#[test]
@@ -319,6 +326,25 @@ fn absent_path_is_reported_once_and_not_polled_again() {
assert_eq!(count_requests(&simulator, &mute), 1);
}
+#[test]
+fn set_on_absent_path_reports_absent_again() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ simulator.remove_value("audio/out/mute");
+ let (handle, events) = start_worker(simulator.address(), None, quiet_timing());
+ let mute = path("audio/out/mute");
+ collect_until(&events, EVENT_DEADLINE, |event| {
+ *event == Event::Absent(mute.clone())
+ });
+
+ handle.send(Command::Set {
+ path: mute.clone(),
+ value: json!(true),
+ });
+ collect_until(&events, EVENT_DEADLINE, |event| {
+ *event == Event::Absent(mute.clone())
+ });
+}
+
#[test]
fn commands_run_before_polling() {
let simulator = Simulator::start(SimModel::Kh120Ii);
@@ -334,11 +360,11 @@ fn commands_run_before_polling() {
collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &level));
collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &level));
- let before = simulator.requests().len();
handle.send(Command::Set {
path: level.clone(),
value: json!(90.0),
});
+ let after_send = simulator.requests().len();
collect_until(&events, EVENT_DEADLINE, |event| {
*event
== Event::Value {
@@ -354,8 +380,8 @@ fn commands_run_before_polling() {
.expect("the set reached the simulator");
// At most the one poll that was already under way may come first.
assert!(
- set_index <= before + 1,
- "set was request {set_index}, {before} requests before it was sent"
+ set_index <= after_send + 1,
+ "set was request {set_index}, {after_send} requests when it was sent"
);
}
@@ -489,7 +515,7 @@ fn read_all_lists_every_leaf() {
let simulator = Simulator::start(SimModel::Kh120Ii);
let mut client = Client::connect(simulator.address()).expect("simulator accepts");
- let reports = read_all(&mut client).expect("the walk succeeds");
+ let reports = read_all(&mut client, &|| false).expect("the walk succeeds");
assert_eq!(reports.len(), KH_120_II_LEAVES);
let mut paths: Vec = reports.iter().map(|leaf| leaf.path.to_string()).collect();
@@ -557,3 +583,116 @@ fn drop_stops_the_thread_while_waiting_to_reconnect() {
drop(handle);
assert!(started.elapsed() < STOP_DEADLINE);
}
+
+#[test]
+fn drop_during_read_all_returns_promptly() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ let (handle, events) = start_worker(simulator.address(), None, quiet_timing());
+ let last = last_initial_path(KH_120_II_PRODUCT);
+ collect_until(&events, EVENT_DEADLINE, |event| is_value_of(event, &last));
+
+ simulator.set_reply_delay(SLOW_REPLY);
+ handle.send(Command::ReadAll);
+ wait_for_requests(&simulator, &path("osc/schema"), 1, EVENT_DEADLINE);
+
+ let started = Instant::now();
+ drop(handle);
+ assert!(started.elapsed() < STOP_DEADLINE);
+}
+
+#[test]
+fn drop_during_connect_read_returns_promptly() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ simulator.set_reply_delay(SLOW_REPLY);
+ let (handle, events) = start_worker(simulator.address(), None, quiet_timing());
+ collect_until(&events, EVENT_DEADLINE, is_connected);
+
+ let started = Instant::now();
+ drop(handle);
+ assert!(started.elapsed() < STOP_DEADLINE);
+}
+
+#[test]
+fn oversized_schema_fails_read_all_but_keeps_connection() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ let deep = (0..=MAX_SCHEMA_DEPTH)
+ .map(|level| format!("level{level}"))
+ .collect::>()
+ .join("/");
+ simulator.set_value(&deep, json!(1));
+ let (handle, events) = start_worker(simulator.address(), None, quiet_timing());
+ collect_until(&events, EVENT_DEADLINE, is_connected);
+
+ handle.send(Command::ReadAll);
+ let seen = collect_until(&events, EVENT_DEADLINE, |event| {
+ matches!(event, Event::AllValues(_) | Event::AllValuesFailed(_))
+ });
+ assert!(matches!(seen.last(), Some(Event::AllValuesFailed(_))));
+
+ let mute = path("audio/out/mute");
+ handle.send(Command::Set {
+ path: mute.clone(),
+ value: json!(true),
+ });
+ let seen = collect_until(&events, EVENT_DEADLINE, |event| {
+ *event
+ == Event::Value {
+ path: mute.clone(),
+ value: json!(true),
+ }
+ });
+ assert!(!seen.iter().any(is_unreachable));
+}
+
+#[test]
+fn too_many_schema_nodes_are_refused() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ for index in 0..=MAX_SCHEMA_NODES {
+ simulator.set_value(&format!("wide/node{index}"), json!(index));
+ }
+ let mut client = Client::connect(simulator.address()).expect("simulator accepts");
+
+ let outcome = read_all(&mut client, &|| false);
+
+ assert!(matches!(outcome, Err(WalkError::Refused(_))));
+ assert!(!client.is_closed());
+}
+
+#[test]
+fn read_all_stops_when_asked() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ let mut client = Client::connect(simulator.address()).expect("simulator accepts");
+
+ let outcome = read_all(&mut client, &|| true);
+
+ assert_eq!(outcome, Err(WalkError::Stopped));
+ assert!(simulator.requests().is_empty());
+}
+
+#[test]
+fn commands_queued_during_long_work_keep_their_order() {
+ let simulator = Simulator::start(SimModel::Kh120Ii);
+ simulator.set_reply_delay(Duration::from_millis(20));
+ let (handle, events) = start_worker(simulator.address(), None, quiet_timing());
+ collect_until(&events, EVENT_DEADLINE, is_connected);
+
+ // Sent while the connect-time read is still running, so the worker sets them aside.
+ let level = path("audio/out/level");
+ for value in [80.0, 90.0] {
+ handle.send(Command::Set {
+ path: level.clone(),
+ value: json!(value),
+ });
+ }
+ let last_set = Event::Value {
+ path: level.clone(),
+ value: json!(90.0),
+ };
+ let seen = collect_until(&events, RECOVERY_DEADLINE, |event| *event == last_set);
+ let first_set = Event::Value {
+ path: level,
+ value: json!(80.0),
+ };
+ assert!(seen.contains(&first_set), "the first set did not run first");
+ assert_eq!(simulator.value("audio/out/level"), Some(json!(90.0)));
+}
From 7d85bdd89c3fe0d7b9dc13858f0101a250186da1 Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 23:16:24 +0200
Subject: [PATCH 012/460] Discover speakers via mDNS
---
Cargo.lock | 139 ++++++++++++++++++++++++
crates/core/Cargo.toml | 1 +
crates/core/examples/scan.rs | 28 +++++
crates/core/src/discovery.rs | 186 +++++++++++++++++++++++++++++++++
crates/core/src/lib.rs | 1 +
crates/core/tests/discovery.rs | 81 ++++++++++++++
6 files changed, 436 insertions(+)
create mode 100644 crates/core/examples/scan.rs
create mode 100644 crates/core/src/discovery.rs
create mode 100644 crates/core/tests/discovery.rs
diff --git a/Cargo.lock b/Cargo.lock
index f95113a..e386b0f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -8,12 +8,51 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "flume"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+[[package]]
+name = "if-addrs"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
[[package]]
name = "indexmap"
version = "2.14.2"
@@ -30,18 +69,60 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+[[package]]
+name = "libc"
+version = "0.2.191"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+[[package]]
+name = "mdns-sd"
+version = "0.21.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "127d3355da0615643c88c885e29a1ccb29ca4b1ca1f2e44b3caed2e728a796aa"
+dependencies = [
+ "fastrand",
+ "flume",
+ "if-addrs",
+ "log",
+ "mio",
+ "socket-pktinfo",
+ "socket2",
+]
+
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+[[package]]
+name = "mio"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys",
+]
+
[[package]]
name = "neumann-control-app"
version = "0.1.0"
@@ -51,6 +132,7 @@ name = "neumann-control-core"
version = "0.1.0"
dependencies = [
"log",
+ "mdns-sd",
"serde",
"serde_json",
"toml",
@@ -74,6 +156,12 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
[[package]]
name = "serde"
version = "1.0.229"
@@ -126,6 +214,36 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "socket-pktinfo"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac"
+dependencies = [
+ "libc",
+ "socket2",
+ "windows-sys",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+dependencies = [
+ "lock_api",
+]
+
[[package]]
name = "syn"
version = "3.0.9"
@@ -182,6 +300,27 @@ version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
[[package]]
name = "winnow"
version = "1.0.4"
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index 6005ca9..8db7b32 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -11,6 +11,7 @@ test-support = []
[dependencies]
log = "0.4"
+mdns-sd = "0.21"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1"
diff --git a/crates/core/examples/scan.rs b/crates/core/examples/scan.rs
new file mode 100644
index 0000000..23ff96b
--- /dev/null
+++ b/crates/core/examples/scan.rs
@@ -0,0 +1,28 @@
+//! Prints the speakers found on the LAN, with serial and address masked.
+
+use neumann_control_core::discovery::{scan, SCAN_DURATION};
+use neumann_control_core::mask::{mask_host, mask_serial};
+
+const UNKNOWN: &str = "unknown";
+
+fn main() {
+ match scan(SCAN_DURATION) {
+ Ok(speakers) => {
+ println!("{} speaker(s) found", speakers.len());
+ for speaker in speakers {
+ let serial = speaker
+ .serial
+ .as_deref()
+ .map_or_else(|| UNKNOWN.to_string(), mask_serial);
+ println!(
+ "{} | {} | {} | {}",
+ speaker.name,
+ speaker.product.as_deref().unwrap_or(UNKNOWN),
+ serial,
+ mask_host(&speaker.address)
+ );
+ }
+ }
+ Err(message) => eprintln!("scan failed: {message}"),
+ }
+}
diff --git a/crates/core/src/discovery.rs b/crates/core/src/discovery.rs
new file mode 100644
index 0000000..73e4c7e
--- /dev/null
+++ b/crates/core/src/discovery.rs
@@ -0,0 +1,186 @@
+//! Speaker discovery over mDNS. The speakers announce `_ssc._tcp.local.`, are reachable over IPv6
+//! only, and need a link-local address together with the interface index as scope.
+
+use std::net::{Ipv6Addr, SocketAddrV6};
+use std::time::{Duration, Instant};
+
+use mdns_sd::{IfKind, ScopedIp, ServiceDaemon, ServiceEvent};
+
+use crate::address::is_link_local;
+
+pub const SCAN_DURATION: Duration = Duration::from_secs(4);
+pub const MAX_SERVICES: usize = 32;
+const SERVICE_TYPE: &str = "_ssc._tcp.local.";
+const MAX_TXT_CHARACTERS: usize = 64;
+const TXT_PRODUCT_KEY: &str = "model";
+const TXT_SERIAL_KEY: &str = "id";
+const NO_SCOPE: u32 = 0;
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Found {
+ pub name: String,
+ pub product: Option,
+ pub serial: Option,
+ pub address: SocketAddrV6,
+}
+
+/// Link-local with a scope wins; a link-local address without a scope cannot be connected to on
+/// Windows and is skipped; a global address is the fallback.
+pub fn pick_address(addresses: &[(Ipv6Addr, u32)], port: u16) -> Option {
+ let scoped_link_local = addresses
+ .iter()
+ .find(|(address, scope)| is_link_local(address) && *scope != NO_SCOPE);
+ let global = addresses
+ .iter()
+ .find(|(address, _)| !is_link_local(address));
+ scoped_link_local
+ .or(global)
+ .map(|(address, scope)| SocketAddrV6::new(*address, port, 0, *scope))
+}
+
+/// One entry per serial (a later link-local address replaces an earlier one, a later global
+/// address never replaces a link-local one); entries without serial are deduplicated by address.
+pub fn merge_found(found: Vec) -> Vec {
+ let mut merged: Vec = Vec::new();
+ for candidate in found {
+ let existing = merged
+ .iter_mut()
+ .find(|entry| same_speaker(entry, &candidate));
+ let Some(entry) = existing else {
+ merged.push(candidate);
+ continue;
+ };
+ let keeps_link_local =
+ is_link_local(entry.address.ip()) && !is_link_local(candidate.address.ip());
+ if !keeps_link_local {
+ *entry = candidate;
+ }
+ }
+ merged
+}
+
+fn same_speaker(left: &Found, right: &Found) -> bool {
+ match (&left.serial, &right.serial) {
+ (Some(left_serial), Some(right_serial)) => left_serial == right_serial,
+ (None, None) => left.address == right.address,
+ _ => false,
+ }
+}
+
+fn instance_name<'a>(fullname: &'a str, service_type: &str) -> &'a str {
+ let suffix = format!(".{service_type}");
+ fullname.strip_suffix(&suffix).unwrap_or(fullname)
+}
+
+fn capped(text: &str) -> String {
+ text.chars().take(MAX_TXT_CHARACTERS).collect()
+}
+
+fn found_from_parts(
+ fullname: &str,
+ service_type: &str,
+ port: u16,
+ addresses: &[(Ipv6Addr, u32)],
+ product: Option<&str>,
+ serial: Option<&str>,
+) -> Option {
+ Some(Found {
+ name: capped(instance_name(fullname, service_type)),
+ product: product.map(capped),
+ serial: serial.map(capped),
+ address: pick_address(addresses, port)?,
+ })
+}
+
+/// Browses for `duration` and returns every speaker that resolved with a usable address.
+/// Errors are fixed texts: the underlying messages could carry network addresses.
+pub fn scan(duration: Duration) -> Result, String> {
+ let daemon = ServiceDaemon::new().map_err(|_| "mDNS could not be started".to_string())?;
+ let result = browse(&daemon, duration);
+ // The scan is over either way; a failed shutdown leaves nothing to recover.
+ let _ = daemon.shutdown();
+ result.map(merge_found)
+}
+
+fn browse(daemon: &ServiceDaemon, duration: Duration) -> Result, String> {
+ daemon
+ .disable_interface(IfKind::IPv4)
+ .map_err(|_| "mDNS could not be restricted to IPv6".to_string())?;
+ let events = daemon
+ .browse(SERVICE_TYPE)
+ .map_err(|_| "mDNS browsing could not be started".to_string())?;
+ let deadline = Instant::now() + duration;
+ let mut found = Vec::new();
+ while found.len() < MAX_SERVICES {
+ let remaining = deadline.saturating_duration_since(Instant::now());
+ let Ok(event) = events.recv_timeout(remaining) else {
+ break;
+ };
+ let ServiceEvent::ServiceResolved(service) = event else {
+ continue;
+ };
+ let addresses: Vec<(Ipv6Addr, u32)> = service
+ .get_addresses()
+ .iter()
+ .filter_map(|address| match address {
+ ScopedIp::V6(scoped) => Some((*scoped.addr(), scoped.scope_id().index)),
+ _ => None,
+ })
+ .collect();
+ let entry = found_from_parts(
+ service.get_fullname(),
+ &service.ty_domain,
+ service.get_port(),
+ &addresses,
+ service.get_property_val_str(TXT_PRODUCT_KEY),
+ service.get_property_val_str(TXT_SERIAL_KEY),
+ );
+ found.extend(entry);
+ }
+ Ok(found)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn instance_name_loses_the_service_suffix() {
+ assert_eq!(
+ instance_name("KH 120 II 1._ssc._tcp.local.", SERVICE_TYPE),
+ "KH 120 II 1"
+ );
+ assert_eq!(instance_name("plain", SERVICE_TYPE), "plain");
+ }
+
+ #[test]
+ fn txt_values_are_capped() {
+ let long = "x".repeat(MAX_TXT_CHARACTERS + 10);
+ let address: Ipv6Addr = "2001:db8::1".parse().unwrap();
+ let found = found_from_parts(
+ "a._ssc._tcp.local.",
+ SERVICE_TYPE,
+ 45,
+ &[(address, 0)],
+ Some(&long),
+ Some(&long),
+ )
+ .unwrap();
+ assert_eq!(found.product.unwrap().chars().count(), MAX_TXT_CHARACTERS);
+ assert_eq!(found.serial.unwrap().chars().count(), MAX_TXT_CHARACTERS);
+ }
+
+ #[test]
+ fn service_without_usable_address_is_dropped() {
+ let link_local: Ipv6Addr = "fe80::1".parse().unwrap();
+ let found = found_from_parts(
+ "a._ssc._tcp.local.",
+ SERVICE_TYPE,
+ 45,
+ &[(link_local, 0)],
+ None,
+ None,
+ );
+ assert_eq!(found, None);
+ }
+}
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index 3d6f95c..3bb7bea 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -1,5 +1,6 @@
pub mod address;
pub mod catalog;
+pub mod discovery;
pub mod mask;
mod poll_schedule;
pub mod schema_walk;
diff --git a/crates/core/tests/discovery.rs b/crates/core/tests/discovery.rs
new file mode 100644
index 0000000..474500e
--- /dev/null
+++ b/crates/core/tests/discovery.rs
@@ -0,0 +1,81 @@
+use std::net::{Ipv6Addr, SocketAddrV6};
+
+use neumann_control_core::discovery::{merge_found, pick_address, Found};
+
+const PORT: u16 = 45;
+const SIMULATED_LINK_LOCAL: &str = "fe80::2a36:38ff:fe12:3456";
+const SIMULATED_GLOBAL: &str = "2001:db8::2a36:38ff:fe12:3456";
+
+fn address(text: &str) -> Ipv6Addr {
+ text.parse().unwrap()
+}
+
+fn found(serial: Option<&str>, text: &str, scope: u32) -> Found {
+ Found {
+ name: "Speaker".into(),
+ product: Some("KH 120 II".into()),
+ serial: serial.map(String::from),
+ address: SocketAddrV6::new(address(text), PORT, 0, scope),
+ }
+}
+
+#[test]
+fn link_local_with_scope_is_preferred() {
+ let addresses = [
+ (address(SIMULATED_GLOBAL), 0),
+ (address(SIMULATED_LINK_LOCAL), 7),
+ ];
+ let picked = pick_address(&addresses, PORT).unwrap();
+ assert_eq!(*picked.ip(), address(SIMULATED_LINK_LOCAL));
+ assert_eq!(picked.scope_id(), 7);
+ assert_eq!(picked.port(), PORT);
+}
+
+#[test]
+fn link_local_without_scope_is_ignored() {
+ let addresses = [(address(SIMULATED_LINK_LOCAL), 0)];
+ assert_eq!(pick_address(&addresses, PORT), None);
+}
+
+#[test]
+fn global_is_used_when_no_link_local() {
+ let addresses = [
+ (address(SIMULATED_LINK_LOCAL), 0),
+ (address(SIMULATED_GLOBAL), 0),
+ ];
+ let picked = pick_address(&addresses, PORT).unwrap();
+ assert_eq!(*picked.ip(), address(SIMULATED_GLOBAL));
+ assert_eq!(picked.scope_id(), 0);
+}
+
+#[test]
+fn same_serial_is_merged() {
+ let merged = merge_found(vec![
+ found(Some("SIM0001234"), SIMULATED_GLOBAL, 0),
+ found(Some("SIM0001234"), SIMULATED_LINK_LOCAL, 7),
+ found(Some("SIM0007500"), SIMULATED_GLOBAL, 0),
+ ]);
+ assert_eq!(merged.len(), 2);
+ assert_eq!(*merged[0].address.ip(), address(SIMULATED_LINK_LOCAL));
+ assert_eq!(merged[0].address.scope_id(), 7);
+}
+
+#[test]
+fn link_local_is_kept_when_a_global_comes_later() {
+ let merged = merge_found(vec![
+ found(Some("SIM0001234"), SIMULATED_LINK_LOCAL, 7),
+ found(Some("SIM0001234"), SIMULATED_GLOBAL, 0),
+ ]);
+ assert_eq!(merged.len(), 1);
+ assert_eq!(*merged[0].address.ip(), address(SIMULATED_LINK_LOCAL));
+}
+
+#[test]
+fn entries_without_serial_are_kept_by_address() {
+ let merged = merge_found(vec![
+ found(None, SIMULATED_LINK_LOCAL, 7),
+ found(None, SIMULATED_LINK_LOCAL, 7),
+ found(None, SIMULATED_GLOBAL, 0),
+ ]);
+ assert_eq!(merged.len(), 2);
+}
From 966deb2729fcf2071def3055ce786948142d2338 Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 23:18:38 +0200
Subject: [PATCH 013/460] Harden discovery against repeated and empty records
---
crates/core/src/discovery.rs | 73 ++++++++++++++++++++++++++++++++----
1 file changed, 66 insertions(+), 7 deletions(-)
diff --git a/crates/core/src/discovery.rs b/crates/core/src/discovery.rs
index 73e4c7e..70d0fcd 100644
--- a/crates/core/src/discovery.rs
+++ b/crates/core/src/discovery.rs
@@ -73,7 +73,23 @@ fn instance_name<'a>(fullname: &'a str, service_type: &str) -> &'a str {
}
fn capped(text: &str) -> String {
- text.chars().take(MAX_TXT_CHARACTERS).collect()
+ text.chars()
+ .filter(|character| !character.is_control())
+ .take(MAX_TXT_CHARACTERS)
+ .collect()
+}
+
+/// A TXT value that is empty after cleaning counts as missing, so two malformed speakers are
+/// never merged under the same empty serial.
+fn txt_value(text: Option<&str>) -> Option {
+ text.map(capped).filter(|value| !value.is_empty())
+}
+
+/// Adds one resolved speaker; mdns-sd re-emits resolutions per interface and refresh, so the
+/// speaker cap has to count the merged list, not the events.
+fn add_found(mut found: Vec, candidate: Found) -> Vec {
+ found.push(candidate);
+ merge_found(found)
}
fn found_from_parts(
@@ -86,20 +102,21 @@ fn found_from_parts(
) -> Option {
Some(Found {
name: capped(instance_name(fullname, service_type)),
- product: product.map(capped),
- serial: serial.map(capped),
+ product: txt_value(product),
+ serial: txt_value(serial),
address: pick_address(addresses, port)?,
})
}
-/// Browses for `duration` and returns every speaker that resolved with a usable address.
+/// Browses for `duration` and returns every speaker that resolved with a usable address. It may
+/// return early once `MAX_SERVICES` distinct speakers are found.
/// Errors are fixed texts: the underlying messages could carry network addresses.
pub fn scan(duration: Duration) -> Result, String> {
let daemon = ServiceDaemon::new().map_err(|_| "mDNS could not be started".to_string())?;
let result = browse(&daemon, duration);
// The scan is over either way; a failed shutdown leaves nothing to recover.
let _ = daemon.shutdown();
- result.map(merge_found)
+ result
}
fn browse(daemon: &ServiceDaemon, duration: Duration) -> Result, String> {
@@ -110,7 +127,7 @@ fn browse(daemon: &ServiceDaemon, duration: Duration) -> Result, Stri
.browse(SERVICE_TYPE)
.map_err(|_| "mDNS browsing could not be started".to_string())?;
let deadline = Instant::now() + duration;
- let mut found = Vec::new();
+ let mut found: Vec = Vec::new();
while found.len() < MAX_SERVICES {
let remaining = deadline.saturating_duration_since(Instant::now());
let Ok(event) = events.recv_timeout(remaining) else {
@@ -135,7 +152,9 @@ fn browse(daemon: &ServiceDaemon, duration: Duration) -> Result, Stri
service.get_property_val_str(TXT_PRODUCT_KEY),
service.get_property_val_str(TXT_SERIAL_KEY),
);
- found.extend(entry);
+ if let Some(entry) = entry {
+ found = add_found(found, entry);
+ }
}
Ok(found)
}
@@ -170,6 +189,46 @@ mod tests {
assert_eq!(found.serial.unwrap().chars().count(), MAX_TXT_CHARACTERS);
}
+ fn sample(serial: Option<&str>, last_segment: u16) -> Found {
+ let address = Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, last_segment);
+ Found {
+ name: "Speaker".into(),
+ product: None,
+ serial: serial.map(String::from),
+ address: SocketAddrV6::new(address, 45, 0, 0),
+ }
+ }
+
+ #[test]
+ fn repeated_events_do_not_fill_the_cap() {
+ let mut found = Vec::new();
+ for _ in 0..MAX_SERVICES + 5 {
+ found = add_found(found, sample(Some("SIM0001234"), 1));
+ }
+ assert_eq!(found.len(), 1);
+ }
+
+ #[test]
+ fn empty_serial_is_treated_as_missing() {
+ let address: Ipv6Addr = "2001:db8::1".parse().unwrap();
+ let found = found_from_parts(
+ "a._ssc._tcp.local.",
+ SERVICE_TYPE,
+ 45,
+ &[(address, 0)],
+ Some(""),
+ Some(""),
+ )
+ .unwrap();
+ assert_eq!(found.serial, None);
+ assert_eq!(found.product, None);
+ }
+
+ #[test]
+ fn control_characters_are_removed_from_txt() {
+ assert_eq!(capped("KH\u{0}1\n20\u{1b}"), "KH120");
+ }
+
#[test]
fn service_without_usable_address_is_dropped() {
let link_local: Ipv6Addr = "fe80::1".parse().unwrap();
From d3e9937eafdd4ff327f85a30d82a29bc022777b8 Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 23:26:20 +0200
Subject: [PATCH 014/460] Store manual speakers and window geometry
---
Cargo.lock | 78 +++++++++++++++++++++++++
crates/core/Cargo.toml | 3 +
crates/core/src/lib.rs | 1 +
crates/core/src/settings.rs | 104 +++++++++++++++++++++++++++++++++
crates/core/tests/settings.rs | 107 ++++++++++++++++++++++++++++++++++
5 files changed, 293 insertions(+)
create mode 100644 crates/core/src/settings.rs
create mode 100644 crates/core/tests/settings.rs
diff --git a/Cargo.lock b/Cargo.lock
index e386b0f..aed83c7 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,12 +2,34 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys",
+]
+
[[package]]
name = "fastrand"
version = "2.5.0"
@@ -37,6 +59,17 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+]
+
[[package]]
name = "hashbrown"
version = "0.17.1"
@@ -75,6 +108,12 @@ version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -135,9 +174,16 @@ dependencies = [
"mdns-sd",
"serde",
"serde_json",
+ "tempfile",
"toml",
]
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -156,6 +202,25 @@ dependencies = [
"proc-macro2",
]
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rustix"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys",
+]
+
[[package]]
name = "scopeguard"
version = "1.2.0"
@@ -255,6 +320,19 @@ dependencies = [
"unicode-ident",
]
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom",
+ "once_cell",
+ "rustix",
+ "windows-sys",
+]
+
[[package]]
name = "toml"
version = "1.1.8+spec-1.1.0"
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index 8db7b32..8c9b5bd 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -27,3 +27,6 @@ required-features = ["test-support"]
[[test]]
name = "speaker"
required-features = ["test-support"]
+
+[dev-dependencies]
+tempfile = "3"
diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs
index 3bb7bea..d4b5422 100644
--- a/crates/core/src/lib.rs
+++ b/crates/core/src/lib.rs
@@ -4,6 +4,7 @@ pub mod discovery;
pub mod mask;
mod poll_schedule;
pub mod schema_walk;
+pub mod settings;
#[cfg(feature = "test-support")]
pub mod simulator;
pub mod speaker;
diff --git a/crates/core/src/settings.rs b/crates/core/src/settings.rs
new file mode 100644
index 0000000..70a0bb5
--- /dev/null
+++ b/crates/core/src/settings.rs
@@ -0,0 +1,104 @@
+//! Persistent settings stored next to the executable.
+
+use std::ffi::OsString;
+use std::fs::{self, File};
+use std::io::{self, ErrorKind, Write};
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+
+pub const SETTINGS_FILE: &str = "settings.json";
+pub const LOG_FILE: &str = "neumann-control.log";
+
+const TEMPORARY_SUFFIX: &str = ".tmp";
+const QUARANTINE_SUFFIX: &str = ".bad";
+
+#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Settings {
+ pub manual: Vec,
+ pub window: Option,
+}
+
+#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
+pub struct ManualSpeaker {
+ pub address: String,
+ pub port: u16,
+ #[serde(default)]
+ pub serial: Option,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
+pub struct WindowGeometry {
+ pub x: f32,
+ pub y: f32,
+ pub width: f32,
+ pub height: f32,
+}
+
+/// Loads the settings; a missing file yields the defaults, an unreadable or
+/// malformed one is moved aside (never overwriting an earlier one) and also
+/// yields the defaults.
+pub fn load(path: &Path) -> Settings {
+ let bytes = match fs::read(path) {
+ Ok(bytes) => bytes,
+ Err(error) if error.kind() == ErrorKind::NotFound => return Settings::default(),
+ Err(error) => {
+ log::warn!(
+ "settings file could not be read ({}); using defaults",
+ error.kind()
+ );
+ quarantine(path);
+ return Settings::default();
+ }
+ };
+ match serde_json::from_slice(&bytes) {
+ Ok(settings) => settings,
+ Err(error) => {
+ // Only the error category is logged: the message can quote file contents,
+ // which include speaker addresses.
+ log::warn!(
+ "settings file is malformed ({:?}); using defaults",
+ error.classify()
+ );
+ quarantine(path);
+ Settings::default()
+ }
+ }
+}
+
+/// Writes through a temporary file in the same directory so a crash never
+/// leaves a truncated settings file behind.
+pub fn save(settings: &Settings, path: &Path) -> io::Result<()> {
+ let temporary = with_suffix(path, TEMPORARY_SUFFIX);
+ let serialized = serde_json::to_vec_pretty(settings)?;
+ let mut file = File::create(&temporary)?;
+ file.write_all(&serialized)?;
+ file.sync_all()?;
+ drop(file);
+ fs::rename(&temporary, path)
+}
+
+fn quarantine(path: &Path) {
+ let target = free_quarantine_path(path);
+ if let Err(error) = fs::rename(path, &target) {
+ log::warn!("settings file could not be moved aside ({})", error.kind());
+ }
+}
+
+fn free_quarantine_path(path: &Path) -> PathBuf {
+ let first = with_suffix(path, QUARANTINE_SUFFIX);
+ if !first.exists() {
+ return first;
+ }
+ (1..)
+ .map(|number| with_suffix(&first, &format!(".{number}")))
+ .find(|candidate| !candidate.exists())
+ .expect("an unbounded range always yields a free name")
+}
+
+fn with_suffix(path: &Path, suffix: &str) -> PathBuf {
+ let mut name: OsString = path.as_os_str().to_owned();
+ name.push(suffix);
+ PathBuf::from(name)
+}
diff --git a/crates/core/tests/settings.rs b/crates/core/tests/settings.rs
new file mode 100644
index 0000000..a231c33
--- /dev/null
+++ b/crates/core/tests/settings.rs
@@ -0,0 +1,107 @@
+use neumann_control_core::settings::{
+ load, save, ManualSpeaker, Settings, WindowGeometry, SETTINGS_FILE,
+};
+use std::fs;
+
+fn sample() -> Settings {
+ Settings {
+ manual: vec![
+ ManualSpeaker {
+ address: "fe80::2a36:38ff:fe12:3456".to_string(),
+ port: 45,
+ serial: Some("SIM0001234".to_string()),
+ },
+ ManualSpeaker {
+ address: "192.0.2.10".to_string(),
+ port: 45,
+ serial: None,
+ },
+ ],
+ window: Some(WindowGeometry {
+ x: 10.0,
+ y: 20.0,
+ width: 800.0,
+ height: 600.0,
+ }),
+ }
+}
+
+#[test]
+fn missing_file_gives_default() {
+ let directory = tempfile::tempdir().unwrap();
+ let loaded = load(&directory.path().join(SETTINGS_FILE));
+ assert_eq!(loaded, Settings::default());
+}
+
+#[test]
+fn round_trip_keeps_values() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join(SETTINGS_FILE);
+ save(&sample(), &path).unwrap();
+ assert_eq!(load(&path), sample());
+ assert!(!directory.path().join("settings.json.tmp").exists());
+}
+
+#[test]
+fn save_replaces_existing_file() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join(SETTINGS_FILE);
+ save(&Settings::default(), &path).unwrap();
+ save(&sample(), &path).unwrap();
+ assert_eq!(load(&path), sample());
+}
+
+#[test]
+fn malformed_file_is_moved_aside() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join(SETTINGS_FILE);
+ fs::write(&path, "{ not json").unwrap();
+ assert_eq!(load(&path), Settings::default());
+ assert!(!path.exists());
+ assert_eq!(
+ fs::read_to_string(directory.path().join("settings.json.bad")).unwrap(),
+ "{ not json"
+ );
+}
+
+#[test]
+fn existing_bad_file_is_never_overwritten() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join(SETTINGS_FILE);
+ fs::write(directory.path().join("settings.json.bad"), "first").unwrap();
+ fs::write(&path, "second").unwrap();
+ assert_eq!(load(&path), Settings::default());
+ assert_eq!(
+ fs::read_to_string(directory.path().join("settings.json.bad")).unwrap(),
+ "first"
+ );
+ assert_eq!(
+ fs::read_to_string(directory.path().join("settings.json.bad.1")).unwrap(),
+ "second"
+ );
+}
+
+#[test]
+fn wrong_type_is_malformed() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join(SETTINGS_FILE);
+ fs::write(&path, r#"{"manual": "oops"}"#).unwrap();
+ assert_eq!(load(&path), Settings::default());
+ assert!(directory.path().join("settings.json.bad").exists());
+}
+
+#[test]
+fn unknown_and_missing_fields_are_tolerated() {
+ let directory = tempfile::tempdir().unwrap();
+ let path = directory.path().join(SETTINGS_FILE);
+ fs::write(
+ &path,
+ r#"{"future": 1, "manual": [{"address": "192.0.2.10", "port": 45, "extra": true}]}"#,
+ )
+ .unwrap();
+ let loaded = load(&path);
+ assert_eq!(loaded.manual.len(), 1);
+ assert_eq!(loaded.manual[0].serial, None);
+ assert_eq!(loaded.window, None);
+ assert!(path.exists());
+}
From 255cc8596da854cc7cb125cb87b94c57c661c9f5 Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 23:29:36 +0200
Subject: [PATCH 015/460] Add app state for speaker cards
---
Cargo.lock | 4 +
crates/app/Cargo.toml | 8 +
crates/app/src/lib.rs | 2 +
crates/app/src/state.rs | 459 ++++++++++++++++++++++++++++++++++++++++
crates/app/src/texts.rs | 36 ++++
5 files changed, 509 insertions(+)
create mode 100644 crates/app/src/lib.rs
create mode 100644 crates/app/src/state.rs
create mode 100644 crates/app/src/texts.rs
diff --git a/Cargo.lock b/Cargo.lock
index aed83c7..a0e42ef 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -165,6 +165,10 @@ dependencies = [
[[package]]
name = "neumann-control-app"
version = "0.1.0"
+dependencies = [
+ "neumann-control-core",
+ "serde_json",
+]
[[package]]
name = "neumann-control-core"
diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml
index 5043123..f021dcd 100644
--- a/crates/app/Cargo.toml
+++ b/crates/app/Cargo.toml
@@ -9,3 +9,11 @@ rust-version.workspace = true
[[bin]]
name = "neumann-control"
path = "src/main.rs"
+
+[lib]
+name = "neumann_control_app"
+path = "src/lib.rs"
+
+[dependencies]
+neumann-control-core = { path = "../core" }
+serde_json = "1"
diff --git a/crates/app/src/lib.rs b/crates/app/src/lib.rs
new file mode 100644
index 0000000..fc8450b
--- /dev/null
+++ b/crates/app/src/lib.rs
@@ -0,0 +1,2 @@
+pub mod state;
+pub mod texts;
diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs
new file mode 100644
index 0000000..5c1429e
--- /dev/null
+++ b/crates/app/src/state.rs
@@ -0,0 +1,459 @@
+//! UI-independent state of the speaker cards: what the worker events add up to, which targets
+//! to talk to, and the time-based behaviour of meters and the fader wheel.
+//! All times are seconds of the UI clock passed in by the caller.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::net::SocketAddrV6;
+
+use neumann_control_core::address::parse_address;
+use neumann_control_core::discovery::Found;
+use neumann_control_core::mask::mask_serial;
+use neumann_control_core::settings::ManualSpeaker;
+use neumann_control_core::speaker::{Event, Identity};
+use neumann_control_core::ssc::wire::SscPath;
+use serde_json::Value;
+
+use crate::texts;
+
+pub const PEAK_HOLD_SECONDS: f64 = 1.5;
+pub const METER_FLOOR_DB: f64 = -60.0;
+pub const METER_CEILING_DB: f64 = 6.0;
+pub const WHEEL_STEP_DB: f64 = 0.5;
+pub const WHEEL_IDLE_SECONDS: f64 = 0.3;
+pub const FADER_MIN_DB: f64 = 0.0;
+pub const FADER_MAX_DB: f64 = 120.0;
+
+const METER_PATHS: [&str; 2] = ["m/in/level", "m/out/level"];
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub enum SpeakerKey {
+ Serial(String),
+ Address(SocketAddrV6),
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum Status {
+ Connecting,
+ Connected,
+ Unreachable,
+}
+
+#[derive(Clone, Debug)]
+pub struct SpeakerState {
+ pub key: SpeakerKey,
+ pub address: SocketAddrV6,
+ pub identity: Option,
+ pub values: BTreeMap,
+ pub absent: BTreeSet,
+ pub status: Status,
+ pub message: Option,
+ pub expanded: bool,
+ pub meters: Vec,
+ pub fader: FaderState,
+}
+
+impl SpeakerState {
+ pub fn new(key: SpeakerKey, address: SocketAddrV6) -> SpeakerState {
+ SpeakerState {
+ key,
+ address,
+ identity: None,
+ values: BTreeMap::new(),
+ absent: BTreeSet::new(),
+ status: Status::Connecting,
+ message: None,
+ expanded: false,
+ meters: Vec::new(),
+ fader: FaderState::default(),
+ }
+ }
+
+ pub fn apply(&mut self, event: Event, now: f64) {
+ match event {
+ Event::Connected(identity) => {
+ if !identity.serial.is_empty() {
+ self.key = SpeakerKey::Serial(identity.serial.clone());
+ }
+ self.identity = Some(identity);
+ self.status = Status::Connected;
+ self.message = None;
+ }
+ // The masked reason is for the log; the card only shows the generic text.
+ Event::Unreachable(_) => {
+ self.status = Status::Unreachable;
+ self.message = Some(texts::UNREACHABLE.to_owned());
+ }
+ Event::Value { path, value } => {
+ self.absent.remove(&path);
+ self.feed_meters(&path, &value, now);
+ self.values.insert(path, value);
+ }
+ Event::Absent(path) => {
+ self.values.remove(&path);
+ self.absent.insert(path);
+ }
+ Event::WriteRejected { error, .. } => {
+ self.message = Some(texts::describe_rejection(error.code));
+ }
+ Event::WriteFailed { .. } => {
+ self.message = Some(texts::WRITE_FAILED.to_owned());
+ }
+ Event::SerialMismatch { found, .. } => {
+ self.status = Status::Unreachable;
+ self.message = Some(texts::serial_mismatch(&mask_serial(&found)));
+ }
+ // The full-value dump has no state to land in until the report view exists.
+ Event::AllValues(_) | Event::AllValuesFailed(_) => {}
+ }
+ }
+
+ fn feed_meters(&mut self, path: &SscPath, value: &Value, now: f64) {
+ let path_text = path.to_string();
+ if !METER_PATHS.contains(&path_text.as_str()) {
+ return;
+ }
+ let Some(levels) = value.as_array() else {
+ return;
+ };
+ if self.meters.len() < levels.len() {
+ self.meters.resize_with(levels.len(), MeterState::default);
+ }
+ for (meter, level) in self.meters.iter_mut().zip(levels) {
+ meter.update(level.as_f64().unwrap_or(METER_FLOOR_DB), now);
+ }
+ }
+}
+
+/// Manual entries first (settings order), then discovered speakers not yet listed. A serial
+/// present on both sides is one target at the discovered address; entries without serial are
+/// identified by address. Manual entries whose address does not parse are skipped here and stay
+/// untouched in the settings.
+pub fn merge_targets(manual: &[ManualSpeaker], found: &[Found]) -> Vec<(SpeakerKey, SocketAddrV6)> {
+ let mut targets: Vec<(SpeakerKey, SocketAddrV6)> = Vec::new();
+ for speaker in manual {
+ let Ok(address) = parse_address(&speaker.address, speaker.port) else {
+ continue;
+ };
+ let key = match &speaker.serial {
+ Some(serial) => SpeakerKey::Serial(serial.clone()),
+ None => SpeakerKey::Address(address),
+ };
+ push_or_replace(&mut targets, key, address, false);
+ }
+ for entry in found {
+ let key = match &entry.serial {
+ Some(serial) => SpeakerKey::Serial(serial.clone()),
+ None => SpeakerKey::Address(entry.address),
+ };
+ push_or_replace(&mut targets, key, entry.address, true);
+ }
+ targets
+}
+
+fn push_or_replace(
+ targets: &mut Vec<(SpeakerKey, SocketAddrV6)>,
+ key: SpeakerKey,
+ address: SocketAddrV6,
+ replace_address: bool,
+) {
+ match targets.iter_mut().find(|(existing, _)| *existing == key) {
+ Some(existing) if replace_address => existing.1 = address,
+ Some(_) => {}
+ None => targets.push((key, address)),
+ }
+}
+
+/// Maps a dB value linearly from the floor..ceiling range to 0..1; the device reports silence
+/// as -160, which lands below the floor and gives 0.
+pub fn fraction(db: f64) -> f32 {
+ let position = (db - METER_FLOOR_DB) / (METER_CEILING_DB - METER_FLOOR_DB);
+ position.clamp(0.0, 1.0) as f32
+}
+
+#[derive(Clone, Copy, Debug)]
+pub struct MeterState {
+ level: f64,
+ peak: f64,
+ peak_since: f64,
+}
+
+impl Default for MeterState {
+ fn default() -> MeterState {
+ MeterState {
+ level: METER_FLOOR_DB,
+ peak: METER_FLOOR_DB,
+ peak_since: 0.0,
+ }
+ }
+}
+
+impl MeterState {
+ pub fn update(&mut self, level: f64, now: f64) {
+ self.level = level;
+ let hold_expired = now - self.peak_since >= PEAK_HOLD_SECONDS;
+ if level >= self.peak || hold_expired {
+ self.peak = level;
+ self.peak_since = now;
+ }
+ }
+
+ pub fn level(&self) -> f64 {
+ self.level
+ }
+
+ pub fn peak(&self) -> f64 {
+ self.peak
+ }
+}
+
+/// Collects mouse-wheel steps so one write goes out after the wheel went quiet.
+#[derive(Clone, Copy, Debug, Default)]
+pub struct FaderState {
+ pending: Option,
+ last_step: f64,
+}
+
+impl FaderState {
+ pub fn on_wheel(&mut self, steps: i32, current: f64, now: f64) {
+ let base = self.pending.unwrap_or(current);
+ let target = base + f64::from(steps) * WHEEL_STEP_DB;
+ self.pending = Some(target.clamp(FADER_MIN_DB, FADER_MAX_DB));
+ self.last_step = now;
+ }
+
+ pub fn due(&mut self, now: f64) -> Option {
+ let idle = now - self.last_step >= WHEEL_IDLE_SECONDS;
+ if self.pending.is_some() && idle {
+ return self.pending.take();
+ }
+ None
+ }
+
+ pub fn shown(&self, device: f64) -> f64 {
+ self.pending.unwrap_or(device)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use neumann_control_core::ssc::wire::DeviceError;
+ use serde_json::json;
+
+ const SERIAL: &str = "SIM0001234";
+ const OTHER_SERIAL: &str = "SIM0007500";
+ const ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3456%7";
+ const OTHER_ADDRESS_TEXT: &str = "fe80::2a36:38ff:fe12:3457%7";
+ const PORT: u16 = 45;
+
+ fn address(text: &str) -> SocketAddrV6 {
+ parse_address(text, PORT).unwrap()
+ }
+
+ fn manual(text: &str, serial: Option<&str>) -> ManualSpeaker {
+ ManualSpeaker {
+ address: text.to_owned(),
+ port: PORT,
+ serial: serial.map(str::to_owned),
+ }
+ }
+
+ fn found(text: &str, serial: Option<&str>) -> Found {
+ Found {
+ name: "KH 120".to_owned(),
+ product: None,
+ serial: serial.map(str::to_owned),
+ address: address(text),
+ }
+ }
+
+ fn state() -> SpeakerState {
+ SpeakerState::new(
+ SpeakerKey::Address(address(ADDRESS_TEXT)),
+ address(ADDRESS_TEXT),
+ )
+ }
+
+ fn path(text: &str) -> SscPath {
+ SscPath::parse(text).unwrap()
+ }
+
+ #[test]
+ fn same_serial_merges_into_one_target() {
+ let targets = merge_targets(
+ &[manual(ADDRESS_TEXT, Some(SERIAL))],
+ &[found(OTHER_ADDRESS_TEXT, Some(SERIAL))],
+ );
+ assert_eq!(
+ targets,
+ vec![(
+ SpeakerKey::Serial(SERIAL.to_owned()),
+ address(OTHER_ADDRESS_TEXT)
+ )]
+ );
+ }
+
+ #[test]
+ fn manual_without_serial_stays_by_address() {
+ let targets = merge_targets(
+ &[manual(ADDRESS_TEXT, None), manual("not an address", None)],
+ &[found(OTHER_ADDRESS_TEXT, Some(OTHER_SERIAL))],
+ );
+ assert_eq!(
+ targets,
+ vec![
+ (
+ SpeakerKey::Address(address(ADDRESS_TEXT)),
+ address(ADDRESS_TEXT)
+ ),
+ (
+ SpeakerKey::Serial(OTHER_SERIAL.to_owned()),
+ address(OTHER_ADDRESS_TEXT)
+ ),
+ ]
+ );
+ }
+
+ #[test]
+ fn found_without_serial_dedupes_by_address() {
+ let targets = merge_targets(&[manual(ADDRESS_TEXT, None)], &[found(ADDRESS_TEXT, None)]);
+ assert_eq!(targets.len(), 1);
+ }
+
+ #[test]
+ fn connected_upgrades_key_to_serial_and_keeps_address() {
+ let mut speaker = state();
+ speaker.apply(
+ Event::Connected(Identity {
+ product: "KH 120 II".to_owned(),
+ version: "1".to_owned(),
+ serial: SERIAL.to_owned(),
+ name: "left".to_owned(),
+ }),
+ 0.0,
+ );
+ assert_eq!(speaker.key, SpeakerKey::Serial(SERIAL.to_owned()));
+ assert_eq!(speaker.address, address(ADDRESS_TEXT));
+ assert_eq!(speaker.status, Status::Connected);
+ }
+
+ #[test]
+ fn unreachable_event_greys_card_and_keeps_values() {
+ let mut speaker = state();
+ speaker.apply(
+ Event::Value {
+ path: path("device/name"),
+ value: json!("left"),
+ },
+ 0.0,
+ );
+ speaker.apply(Event::Unreachable("masked".to_owned()), 1.0);
+ assert_eq!(speaker.status, Status::Unreachable);
+ assert_eq!(speaker.message.as_deref(), Some(texts::UNREACHABLE));
+ assert_eq!(speaker.values.len(), 1);
+ }
+
+ #[test]
+ fn absent_event_removes_value() {
+ let mut speaker = state();
+ speaker.apply(
+ Event::Value {
+ path: path("device/name"),
+ value: json!("left"),
+ },
+ 0.0,
+ );
+ speaker.apply(Event::Absent(path("device/name")), 1.0);
+ assert!(speaker.values.is_empty());
+ assert!(speaker.absent.contains(&path("device/name")));
+ }
+
+ #[test]
+ fn write_rejected_sets_german_message() {
+ let mut speaker = state();
+ speaker.apply(
+ Event::WriteRejected {
+ path: path("device/name"),
+ error: DeviceError {
+ code: 405,
+ description: String::new(),
+ },
+ },
+ 0.0,
+ );
+ assert_eq!(
+ speaker.message.as_deref(),
+ Some("Gerät lehnt ab: nur lesbar")
+ );
+ }
+
+ #[test]
+ fn serial_mismatch_masks_found_serial() {
+ let mut speaker = state();
+ speaker.apply(
+ Event::SerialMismatch {
+ expected: SERIAL.to_owned(),
+ found: OTHER_SERIAL.to_owned(),
+ },
+ 0.0,
+ );
+ assert_eq!(speaker.status, Status::Unreachable);
+ let message = speaker.message.unwrap();
+ assert!(message.contains(&mask_serial(OTHER_SERIAL)));
+ assert!(!message.contains(OTHER_SERIAL));
+ }
+
+ #[test]
+ fn meter_value_feeds_one_meter_per_channel() {
+ let mut speaker = state();
+ speaker.apply(
+ Event::Value {
+ path: path("m/out/level"),
+ value: json!([-12.0, -20.5]),
+ },
+ 0.0,
+ );
+ assert_eq!(speaker.meters.len(), 2);
+ assert_eq!(speaker.meters[1].level(), -20.5);
+ }
+
+ #[test]
+ fn peak_holds_then_falls_after_1_5_s() {
+ let mut meter = MeterState::default();
+ meter.update(-6.0, 0.0);
+ meter.update(-30.0, 1.0);
+ assert_eq!(meter.peak(), -6.0);
+ assert_eq!(meter.level(), -30.0);
+ meter.update(-30.0, 1.6);
+ assert_eq!(meter.peak(), -30.0);
+ }
+
+ #[test]
+ fn meter_fraction_clamps_below_floor_and_above_ceiling() {
+ assert_eq!(fraction(-160.0), 0.0);
+ assert_eq!(fraction(10.0), 1.0);
+ assert_eq!(fraction(-27.0), 0.5);
+ }
+
+ #[test]
+ fn wheel_steps_debounce_to_one_value() {
+ let mut fader = FaderState::default();
+ fader.on_wheel(1, 80.0, 1.0);
+ fader.on_wheel(1, 80.0, 1.1);
+ fader.on_wheel(1, 80.0, 1.2);
+ assert_eq!(fader.shown(80.0), 81.5);
+ assert_eq!(fader.due(1.4), None);
+ assert_eq!(fader.due(1.5), Some(81.5));
+ assert_eq!(fader.due(2.0), None);
+ assert_eq!(fader.shown(80.0), 80.0);
+ }
+
+ #[test]
+ fn wheel_clamps_at_120() {
+ let mut fader = FaderState::default();
+ fader.on_wheel(10, 119.0, 0.0);
+ assert_eq!(fader.due(1.0), Some(120.0));
+ fader.on_wheel(-1000, 1.0, 2.0);
+ assert_eq!(fader.due(3.0), Some(0.0));
+ }
+}
diff --git a/crates/app/src/texts.rs b/crates/app/src/texts.rs
new file mode 100644
index 0000000..794a69d
--- /dev/null
+++ b/crates/app/src/texts.rs
@@ -0,0 +1,36 @@
+//! German UI strings; the only place user-visible text lives.
+
+use neumann_control_core::catalog::ValueError;
+
+pub const UNREACHABLE: &str = "nicht erreichbar (aus oder Standby)";
+pub const WRITE_FAILED: &str = "Schreiben fehlgeschlagen";
+const SERIAL_MISMATCH_PREFIX: &str = "Anderes Gerät unter dieser Adresse (Seriennummer ";
+const SERIAL_MISMATCH_SUFFIX: &str = ")";
+
+const REJECTED_READ_ONLY_CODE: i64 = 405;
+const REJECTED_WRONG_VALUE_CODE: i64 = 406;
+
+pub fn serial_mismatch(masked_serial: &str) -> String {
+ format!("{SERIAL_MISMATCH_PREFIX}{masked_serial}{SERIAL_MISMATCH_SUFFIX}")
+}
+
+pub fn describe_value_error(error: &ValueError) -> String {
+ match error {
+ ValueError::OutOfRange { min, max } => format!("Wert außerhalb von {min} bis {max}."),
+ ValueError::NotAnOption => "Wert ist keine der Auswahlmöglichkeiten.".to_owned(),
+ ValueError::NotAStep => "Wert passt nicht zur Schrittweite.".to_owned(),
+ ValueError::TooLong(limit) => format!("Höchstens {limit} Zeichen."),
+ ValueError::NotPrintableAscii => {
+ "Nur Buchstaben A–Z, Ziffern und Satzzeichen ohne Umlaute.".to_owned()
+ }
+ ValueError::WrongType => "Wert hat den falschen Typ.".to_owned(),
+ }
+}
+
+pub fn describe_rejection(code: i64) -> String {
+ match code {
+ REJECTED_READ_ONLY_CODE => "Gerät lehnt ab: nur lesbar".to_owned(),
+ REJECTED_WRONG_VALUE_CODE => "Gerät lehnt ab: falscher Wert".to_owned(),
+ other => format!("Gerät lehnt ab (Code {other})"),
+ }
+}
From d2bae4999f53e8267e411a99127775580018c07b Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sat, 17 Oct 2026 23:36:27 +0200
Subject: [PATCH 016/460] Keep meters apart and merge manual speakers by
address
---
crates/app/src/state.rs | 199 +++++++++++++++++++++++++++++++---------
1 file changed, 157 insertions(+), 42 deletions(-)
diff --git a/crates/app/src/state.rs b/crates/app/src/state.rs
index 5c1429e..39ce893 100644
--- a/crates/app/src/state.rs
+++ b/crates/app/src/state.rs
@@ -4,8 +4,10 @@
use std::collections::{BTreeMap, BTreeSet};
use std::net::SocketAddrV6;
+use std::ops::RangeInclusive;
use neumann_control_core::address::parse_address;
+use neumann_control_core::catalog::Catalog;
use neumann_control_core::discovery::Found;
use neumann_control_core::mask::mask_serial;
use neumann_control_core::settings::ManualSpeaker;
@@ -20,10 +22,10 @@ pub const METER_FLOOR_DB: f64 = -60.0;
pub const METER_CEILING_DB: f64 = 6.0;
pub const WHEEL_STEP_DB: f64 = 0.5;
pub const WHEEL_IDLE_SECONDS: f64 = 0.3;
-pub const FADER_MIN_DB: f64 = 0.0;
-pub const FADER_MAX_DB: f64 = 120.0;
-const METER_PATHS: [&str; 2] = ["m/in/level", "m/out/level"];
+const INPUT_METER_PATH: &str = "m/in/level";
+const OUTPUT_METER_PATH: &str = "m/out/level";
+const OUTPUT_LEVEL_PATH: &str = "audio/out/level";
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SpeakerKey {
@@ -47,8 +49,11 @@ pub struct SpeakerState {
pub absent: BTreeSet,
pub status: Status,
pub message: Option,
+ /// The path a write message is about; a fresh value for it clears the message.
+ message_path: Option,
pub expanded: bool,
- pub meters: Vec,
+ pub input_meters: Vec,
+ pub output_meters: Vec,
pub fader: FaderState,
}
@@ -62,8 +67,10 @@ impl SpeakerState {
absent: BTreeSet::new(),
status: Status::Connecting,
message: None,
+ message_path: None,
expanded: false,
- meters: Vec::new(),
+ input_meters: Vec::new(),
+ output_meters: Vec::new(),
fader: FaderState::default(),
}
}
@@ -76,14 +83,18 @@ impl SpeakerState {
}
self.identity = Some(identity);
self.status = Status::Connected;
- self.message = None;
+ self.set_message(None, None);
}
// The masked reason is for the log; the card only shows the generic text.
Event::Unreachable(_) => {
self.status = Status::Unreachable;
- self.message = Some(texts::UNREACHABLE.to_owned());
+ self.set_message(Some(texts::UNREACHABLE.to_owned()), None);
+ self.reset_meters();
}
Event::Value { path, value } => {
+ if self.message_path.as_ref() == Some(&path) {
+ self.set_message(None, None);
+ }
self.absent.remove(&path);
self.feed_meters(&path, &value, now);
self.values.insert(path, value);
@@ -92,41 +103,56 @@ impl SpeakerState {
self.values.remove(&path);
self.absent.insert(path);
}
- Event::WriteRejected { error, .. } => {
- self.message = Some(texts::describe_rejection(error.code));
+ Event::WriteRejected { path, error } => {
+ let text = texts::describe_rejection(error.code);
+ self.set_message(Some(text), Some(path));
}
- Event::WriteFailed { .. } => {
- self.message = Some(texts::WRITE_FAILED.to_owned());
+ Event::WriteFailed { path, .. } => {
+ self.set_message(Some(texts::WRITE_FAILED.to_owned()), Some(path));
}
Event::SerialMismatch { found, .. } => {
self.status = Status::Unreachable;
- self.message = Some(texts::serial_mismatch(&mask_serial(&found)));
+ let text = texts::serial_mismatch(&mask_serial(&found));
+ self.set_message(Some(text), None);
}
// The full-value dump has no state to land in until the report view exists.
Event::AllValues(_) | Event::AllValuesFailed(_) => {}
}
}
- fn feed_meters(&mut self, path: &SscPath, value: &Value, now: f64) {
- let path_text = path.to_string();
- if !METER_PATHS.contains(&path_text.as_str()) {
- return;
+ fn set_message(&mut self, message: Option, path: Option) {
+ self.message = message;
+ self.message_path = path;
+ }
+
+ fn reset_meters(&mut self) {
+ let meters = self.input_meters.iter_mut().chain(&mut self.output_meters);
+ for meter in meters {
+ meter.reset();
}
+ }
+
+ fn feed_meters(&mut self, path: &SscPath, value: &Value, now: f64) {
+ let meters = match path.to_string().as_str() {
+ INPUT_METER_PATH => &mut self.input_meters,
+ OUTPUT_METER_PATH => &mut self.output_meters,
+ _ => return,
+ };
let Some(levels) = value.as_array() else {
return;
};
- if self.meters.len() < levels.len() {
- self.meters.resize_with(levels.len(), MeterState::default);
+ if meters.len() < levels.len() {
+ meters.resize_with(levels.len(), MeterState::default);
}
- for (meter, level) in self.meters.iter_mut().zip(levels) {
+ for (meter, level) in meters.iter_mut().zip(levels) {
meter.update(level.as_f64().unwrap_or(METER_FLOOR_DB), now);
}
}
}
-/// Manual entries first (settings order), then discovered speakers not yet listed. A serial
-/// present on both sides is one target at the discovered address; entries without serial are
-/// identified by address. Manual entries whose address does not parse are skipped here and stay
+/// Manual entries first (settings order), then discovered speakers not yet listed. A speaker
+/// present on both sides (same serial, or same address) is one target at the discovered
+/// address, keyed by the serial once either side knows it. Manual entries whose address does not parse are skipped here and stay
/// untouched in the settings.
pub fn merge_targets(manual: &[ManualSpeaker], found: &[Found]) -> Vec<(SpeakerKey, SocketAddrV6)> {
let mut targets: Vec<(SpeakerKey, SocketAddrV6)> = Vec::new();
@@ -154,13 +180,35 @@ fn push_or_replace(
targets: &mut Vec<(SpeakerKey, SocketAddrV6)>,
key: SpeakerKey,
address: SocketAddrV6,
- replace_address: bool,
+ from_found: bool,
) {
- match targets.iter_mut().find(|(existing, _)| *existing == key) {
- Some(existing) if replace_address => existing.1 = address,
- Some(_) => {}
- None => targets.push((key, address)),
+ let position = targets
+ .iter()
+ .position(|(existing, existing_address)| *existing == key || *existing_address == address);
+ let Some(position) = position else {
+ targets.push((key, address));
+ return;
+ };
+ if !from_found {
+ return;
}
+ if matches!(key, SpeakerKey::Serial(_)) {
+ targets[position].0 = key;
+ }
+ targets[position].1 = address;
+}
+
+/// The catalog range of the output level, which the wheel fader drives.
+pub fn output_level_range() -> RangeInclusive {
+ let path = SscPath::parse(OUTPUT_LEVEL_PATH).expect("constant path");
+ let entry = Catalog::embedded()
+ .entries
+ .iter()
+ .find(|entry| entry.path == path)
+ .expect("catalog has the output level");
+ let min = entry.min.expect("output level has a minimum");
+ let max = entry.max.expect("output level has a maximum");
+ min..=max
}
/// Maps a dB value linearly from the floor..ceiling range to 0..1; the device reports silence
@@ -201,9 +249,17 @@ impl MeterState {
self.level
}
- pub fn peak(&self) -> f64 {
+ /// The held peak, or the current level once the hold ran out.
+ pub fn peak(&self, now: f64) -> f64 {
+ if now - self.peak_since >= PEAK_HOLD_SECONDS {
+ return self.level;
+ }
self.peak
}
+
+ pub fn reset(&mut self) {
+ *self = MeterState::default();
+ }
}
/// Collects mouse-wheel steps so one write goes out after the wheel went quiet.
@@ -214,10 +270,11 @@ pub struct FaderState {
}
impl FaderState {
- pub fn on_wheel(&mut self, steps: i32, current: f64, now: f64) {
+ /// `range` is the allowed value range of the faded entry.
+ pub fn on_wheel(&mut self, steps: i32, current: f64, range: RangeInclusive, now: f64) {
let base = self.pending.unwrap_or(current);
let target = base + f64::from(steps) * WHEEL_STEP_DB;
- self.pending = Some(target.clamp(FADER_MIN_DB, FADER_MAX_DB));
+ self.pending = Some(target.clamp(*range.start(), *range.end()));
self.last_step = now;
}
@@ -274,6 +331,13 @@ mod tests {
)
}
+ fn value_event(text: &str, value: Value) -> Event {
+ Event::Value {
+ path: path(text),
+ value,
+ }
+ }
+
fn path(text: &str) -> SscPath {
SscPath::parse(text).unwrap()
}
@@ -413,8 +477,8 @@ mod tests {
},
0.0,
);
- assert_eq!(speaker.meters.len(), 2);
- assert_eq!(speaker.meters[1].level(), -20.5);
+ assert_eq!(speaker.output_meters.len(), 2);
+ assert_eq!(speaker.output_meters[1].level(), -20.5);
}
#[test]
@@ -422,10 +486,58 @@ mod tests {
let mut meter = MeterState::default();
meter.update(-6.0, 0.0);
meter.update(-30.0, 1.0);
- assert_eq!(meter.peak(), -6.0);
+ assert_eq!(meter.peak(1.0), -6.0);
assert_eq!(meter.level(), -30.0);
- meter.update(-30.0, 1.6);
- assert_eq!(meter.peak(), -30.0);
+ assert_eq!(meter.peak(1.25), -6.0);
+ assert_eq!(meter.peak(1.5), -30.0);
+ }
+
+ #[test]
+ fn input_and_output_meters_are_kept_apart() {
+ let mut speaker = state();
+ speaker.apply(value_event("m/in/level", json!([-10.0])), 0.0);
+ speaker.apply(value_event("m/out/level", json!([-20.0, -30.0])), 0.0);
+ assert_eq!(speaker.input_meters.len(), 1);
+ assert_eq!(speaker.input_meters[0].level(), -10.0);
+ assert_eq!(speaker.output_meters.len(), 2);
+ }
+
+ #[test]
+ fn unreachable_resets_meters() {
+ let mut speaker = state();
+ speaker.apply(value_event("m/out/level", json!([-3.0])), 0.0);
+ speaker.apply(Event::Unreachable("masked".to_owned()), 0.5);
+ assert_eq!(speaker.output_meters[0].level(), METER_FLOOR_DB);
+ assert_eq!(speaker.output_meters[0].peak(0.5), METER_FLOOR_DB);
+ }
+
+ #[test]
+ fn write_message_clears_when_the_path_reports_a_value() {
+ let mut speaker = state();
+ speaker.apply(
+ Event::WriteFailed {
+ path: path("audio/out/level"),
+ message: "lost".to_owned(),
+ },
+ 0.0,
+ );
+ assert_eq!(speaker.message.as_deref(), Some(texts::WRITE_FAILED));
+ speaker.apply(value_event("device/name", json!("left")), 1.0);
+ assert!(speaker.message.is_some());
+ speaker.apply(value_event("audio/out/level", json!(80.0)), 2.0);
+ assert_eq!(speaker.message, None);
+ }
+
+ #[test]
+ fn manual_address_and_found_serial_merge_into_one_target() {
+ let targets = merge_targets(
+ &[manual(ADDRESS_TEXT, None)],
+ &[found(ADDRESS_TEXT, Some(SERIAL))],
+ );
+ assert_eq!(
+ targets,
+ vec![(SpeakerKey::Serial(SERIAL.to_owned()), address(ADDRESS_TEXT))]
+ );
}
#[test]
@@ -437,23 +549,26 @@ mod tests {
#[test]
fn wheel_steps_debounce_to_one_value() {
+ let range = output_level_range();
let mut fader = FaderState::default();
- fader.on_wheel(1, 80.0, 1.0);
- fader.on_wheel(1, 80.0, 1.1);
- fader.on_wheel(1, 80.0, 1.2);
+ fader.on_wheel(1, 80.0, range.clone(), 1.0);
+ fader.on_wheel(1, 80.0, range.clone(), 1.125);
+ fader.on_wheel(1, 80.0, range, 1.25);
assert_eq!(fader.shown(80.0), 81.5);
- assert_eq!(fader.due(1.4), None);
- assert_eq!(fader.due(1.5), Some(81.5));
+ assert_eq!(fader.due(1.5), None);
+ assert_eq!(fader.due(1.625), Some(81.5));
assert_eq!(fader.due(2.0), None);
assert_eq!(fader.shown(80.0), 80.0);
}
#[test]
fn wheel_clamps_at_120() {
+ let range = output_level_range();
+ assert_eq!(range, 0.0..=120.0);
let mut fader = FaderState::default();
- fader.on_wheel(10, 119.0, 0.0);
+ fader.on_wheel(10, 119.0, range.clone(), 0.0);
assert_eq!(fader.due(1.0), Some(120.0));
- fader.on_wheel(-1000, 1.0, 2.0);
+ fader.on_wheel(-1000, 1.0, range, 2.0);
assert_eq!(fader.due(3.0), Some(0.0));
}
}
From 5aa64c094b353765949ec317508a924bac1f9b90 Mon Sep 17 00:00:00 2001
From: Varitras <86969187+Varitras@users.noreply.github.com>
Date: Sun, 18 Oct 2026 00:02:49 +0200
Subject: [PATCH 017/460] Show speakers as channel-strip cards
---
.gitattributes | 1 +
Cargo.lock | 4542 +++++++++++++++++++-
about.hbs | 100 +-
crates/app/Cargo.toml | 8 +
crates/app/assets/fonts/Inter-Regular.ttf | Bin 0 -> 411640 bytes
crates/app/assets/fonts/Inter-SemiBold.ttf | Bin 0 -> 419744 bytes
crates/app/assets/fonts/OFL-Inter.txt | 92 +
crates/app/assets/fonts/SOURCES.md | 18 +
crates/app/src/app.rs | 323 ++
crates/app/src/lib.rs | 4 +
crates/app/src/main.rs | 89 +-
crates/app/src/spawner.rs | 63 +
crates/app/src/state.rs | 100 +-
crates/app/src/texts.rs | 54 +
crates/app/src/ui_kit/fonts.rs | 90 +
crates/app/src/ui_kit/mod.rs | 2 +
crates/app/src/ui_kit/theme.rs | 172 +
crates/app/src/views/add_address.rs | 126 +
crates/app/src/views/card.rs | 292 ++
crates/app/src/views/mod.rs | 3 +
crates/app/src/views/toolbar.rs | 49 +
crates/app/tests/views.rs | 399 ++
22 files changed, 6204 insertions(+), 323 deletions(-)
create mode 100644 crates/app/assets/fonts/Inter-Regular.ttf
create mode 100644 crates/app/assets/fonts/Inter-SemiBold.ttf
create mode 100644 crates/app/assets/fonts/OFL-Inter.txt
create mode 100644 crates/app/assets/fonts/SOURCES.md
create mode 100644 crates/app/src/app.rs
create mode 100644 crates/app/src/spawner.rs
create mode 100644 crates/app/src/ui_kit/fonts.rs
create mode 100644 crates/app/src/ui_kit/mod.rs
create mode 100644 crates/app/src/ui_kit/theme.rs
create mode 100644 crates/app/src/views/add_address.rs
create mode 100644 crates/app/src/views/card.rs
create mode 100644 crates/app/src/views/mod.rs
create mode 100644 crates/app/src/views/toolbar.rs
create mode 100644 crates/app/tests/views.rs
diff --git a/.gitattributes b/.gitattributes
index 6313b56..3a68bdb 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -1 +1,2 @@
* text=auto eol=lf
+*.ttf binary
diff --git a/Cargo.lock b/Cargo.lock
index a0e42ef..e0d48fb 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -2,6 +2,442 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "ab_glyph"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2"
+dependencies = [
+ "ab_glyph_rasterizer",
+ "owned_ttf_parser",
+]
+
+[[package]]
+name = "ab_glyph_rasterizer"
+version = "0.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618"
+
+[[package]]
+name = "accesskit"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320"
+dependencies = [
+ "uuid",
+]
+
+[[package]]
+name = "accesskit_atspi_common"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer 0.36.0",
+ "atspi-common",
+ "phf",
+ "serde",
+ "zvariant",
+]
+
+[[package]]
+name = "accesskit_consumer"
+version = "0.35.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10"
+dependencies = [
+ "accesskit",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "accesskit_consumer"
+version = "0.36.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60"
+dependencies = [
+ "accesskit",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "accesskit_consumer"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245"
+dependencies = [
+ "accesskit",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "accesskit_macos"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer 0.38.0",
+ "hashbrown 0.16.1",
+ "objc2 0.5.3",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "accesskit_unix"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c"
+dependencies = [
+ "accesskit",
+ "accesskit_atspi_common",
+ "async-channel",
+ "async-executor",
+ "async-task",
+ "atspi",
+ "futures-lite",
+ "futures-util",
+ "serde",
+ "zbus",
+]
+
+[[package]]
+name = "accesskit_windows"
+version = "0.32.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer 0.35.0",
+ "hashbrown 0.16.1",
+ "static_assertions",
+ "windows",
+ "windows-core",
+]
+
+[[package]]
+name = "accesskit_winit"
+version = "0.32.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313"
+dependencies = [
+ "accesskit",
+ "accesskit_macos",
+ "accesskit_unix",
+ "accesskit_windows",
+ "raw-window-handle",
+ "winit",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom 0.3.4",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android-activity"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd"
+dependencies = [
+ "android-properties",
+ "bitflags 2.13.2",
+ "cc",
+ "jni",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-context",
+ "ndk-sys",
+ "num_enum",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "android-properties"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "arboard"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917"
+dependencies = [
+ "clipboard-win",
+ "image",
+ "log",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+ "parking_lot",
+ "percent-encoding",
+ "windows-sys 0.61.2",
+ "x11rb",
+]
+
+[[package]]
+name = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
+
+[[package]]
+name = "as-raw-xcb-connection"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
+
+[[package]]
+name = "ash"
+version = "0.38.0+1.3.281"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "async-broadcast"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-channel"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
+dependencies = [
+ "concurrent-queue",
+ "event-listener-strategy",
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-executor"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
+dependencies = [
+ "async-task",
+ "concurrent-queue",
+ "fastrand",
+ "futures-lite",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "async-io"
+version = "2.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
+dependencies = [
+ "autocfg",
+ "cfg-if",
+ "concurrent-queue",
+ "futures-io",
+ "futures-lite",
+ "parking",
+ "polling",
+ "rustix 1.1.5",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-process"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
+dependencies = [
+ "async-channel",
+ "async-io",
+ "async-lock",
+ "async-signal",
+ "async-task",
+ "blocking",
+ "cfg-if",
+ "event-listener",
+ "futures-lite",
+ "rustix 1.1.5",
+]
+
+[[package]]
+name = "async-recursion"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "async-signal"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
+dependencies = [
+ "async-io",
+ "async-lock",
+ "atomic-waker",
+ "cfg-if",
+ "futures-core",
+ "futures-io",
+ "rustix 1.1.5",
+ "signal-hook-registry",
+ "slab",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "async-task"
+version = "4.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "atspi"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d"
+dependencies = [
+ "atspi-common",
+ "atspi-proxies",
+]
+
+[[package]]
+name = "atspi-common"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d"
+dependencies = [
+ "enumflags2",
+ "serde",
+ "static_assertions",
+ "zbus",
+ "zbus-lockstep",
+ "zbus-lockstep-macros",
+ "zbus_names",
+ "zvariant",
+]
+
+[[package]]
+name = "atspi-proxies"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc"
+dependencies = [
+ "atspi-common",
+ "serde",
+ "zbus",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "bit-set"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
[[package]]
name = "bitflags"
version = "2.13.2"
@@ -9,408 +445,4072 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
-name = "cfg-if"
-version = "1.0.5"
+name = "block2"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
+dependencies = [
+ "objc2 0.5.3",
+]
[[package]]
-name = "equivalent"
-version = "1.0.3"
+name = "block2"
+version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
+dependencies = [
+ "objc2 0.6.5",
+]
[[package]]
-name = "errno"
-version = "0.3.14"
+name = "blocking"
+version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
dependencies = [
- "libc",
- "windows-sys",
+ "async-channel",
+ "async-task",
+ "futures-io",
+ "futures-lite",
+ "piper",
]
[[package]]
-name = "fastrand"
-version = "2.5.0"
+name = "bumpalo"
+version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytemuck"
+version = "1.25.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "calloop"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
+dependencies = [
+ "bitflags 2.13.2",
+ "log",
+ "polling",
+ "rustix 0.38.44",
+ "slab",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "calloop"
+version = "0.14.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e"
+dependencies = [
+ "bitflags 2.13.2",
+ "polling",
+ "rustix 1.1.5",
+ "tracing",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20"
+dependencies = [
+ "calloop 0.13.0",
+ "rustix 0.38.44",
+ "wayland-backend",
+ "wayland-client",
+]
+
+[[package]]
+name = "calloop-wayland-source"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa"
+dependencies = [
+ "calloop 0.14.5",
+ "rustix 1.1.5",
+ "wayland-backend",
+ "wayland-client",
+]
+
+[[package]]
+name = "cc"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "cgl"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "clipboard-win"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
+dependencies = [
+ "error-code",
+]
+
+[[package]]
+name = "codespan-reporting"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681"
+dependencies = [
+ "serde",
+ "termcolor",
+ "unicode-width",
+]
+
+[[package]]
+name = "color"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "combine"
+version = "4.6.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92"
+dependencies = [
+ "bytes",
+ "memchr",
+]
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "core-foundation"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
+
+[[package]]
+name = "core-graphics"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "core-graphics-types",
+ "foreign-types",
+ "libc",
+]
+
+[[package]]
+name = "core-graphics-types"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf"
+dependencies = [
+ "bitflags 1.3.2",
+ "core-foundation",
+ "libc",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
+
+[[package]]
+name = "crunchy"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
+
+[[package]]
+name = "cursor-icon"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f"
+
+[[package]]
+name = "deranged"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
+
+[[package]]
+name = "dispatch"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
+
+[[package]]
+name = "dispatch2"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2 0.6.5",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "dlib"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
+dependencies = [
+ "libloading",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
+]
+
+[[package]]
+name = "downcast-rs"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
+
+[[package]]
+name = "dpi"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
+
+[[package]]
+name = "ecolor"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34"
+dependencies = [
+ "bytemuck",
+ "emath",
+]
+
+[[package]]
+name = "eframe"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "document-features",
+ "egui",
+ "egui-wgpu",
+ "egui-winit",
+ "egui_glow",
+ "glutin",
+ "glutin-winit",
+ "image",
+ "js-sys",
+ "log",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
+ "parking_lot",
+ "percent-encoding",
+ "pollster",
+ "profiling",
+ "raw-window-handle",
+ "static_assertions",
+ "wasm-bindgen",
+ "web-sys",
+ "web-time",
+ "wgpu",
+ "windows-sys 0.61.2",
+ "winit",
+]
+
+[[package]]
+name = "egui"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba"
+dependencies = [
+ "accesskit",
+ "ahash",
+ "bitflags 2.13.2",
+ "emath",
+ "epaint",
+ "itertools",
+ "log",
+ "nohash-hasher",
+ "profiling",
+ "smallvec",
+ "unicode-segmentation",
+ "web-sys",
+]
+
+[[package]]
+name = "egui-wgpu"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "document-features",
+ "egui",
+ "epaint",
+ "log",
+ "profiling",
+ "thiserror 2.0.21",
+ "type-map",
+ "web-time",
+ "wgpu",
+ "winit",
+]
+
+[[package]]
+name = "egui-winit"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc"
+dependencies = [
+ "accesskit_winit",
+ "arboard",
+ "bytemuck",
+ "egui",
+ "log",
+ "objc2 0.6.5",
+ "objc2-foundation 0.3.2",
+ "objc2-ui-kit 0.3.2",
+ "profiling",
+ "raw-window-handle",
+ "smithay-clipboard",
+ "web-time",
+ "webbrowser",
+ "winit",
+]
+
+[[package]]
+name = "egui_glow"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b"
+dependencies = [
+ "bytemuck",
+ "egui",
+ "glow",
+ "log",
+ "profiling",
+ "winit",
+]
+
+[[package]]
+name = "egui_kittest"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb3f3effb2726fee23449293e67acbf6cc6ce35525b1722bd5743bb348114d5"
+dependencies = [
+ "eframe",
+ "egui",
+ "kittest",
+ "log",
+ "serde",
+ "toml",
+]
+
+[[package]]
+name = "either"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
+
+[[package]]
+name = "emath"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "endi"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
+
+[[package]]
+name = "enumflags2"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
+dependencies = [
+ "enumflags2_derive",
+ "serde",
+]
+
+[[package]]
+name = "enumflags2_derive"
+version = "0.7.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "epaint"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "ecolor",
+ "emath",
+ "epaint_default_fonts",
+ "font-types",
+ "harfrust",
+ "log",
+ "nohash-hasher",
+ "parking_lot",
+ "profiling",
+ "self_cell",
+ "skrifa",
+ "smallvec",
+ "unicode-general-category",
+ "unicode-segmentation",
+ "vello_cpu",
+]
+
+[[package]]
+name = "epaint_default_fonts"
+version = "0.36.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13"
+
+[[package]]
+name = "equivalent"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "error-code"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7"
+
+[[package]]
+name = "euclid"
+version = "0.22.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "fax"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
+
+[[package]]
+name = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "fearless_simd"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+
+[[package]]
+name = "flate2"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide 0.9.1",
+ "zlib-rs",
+]
+
+[[package]]
+name = "flume"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[package]]
+name = "font-types"
+version = "0.12.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "foreign-types"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
+dependencies = [
+ "foreign-types-macros",
+ "foreign-types-shared",
+]
+
+[[package]]
+name = "foreign-types-macros"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "foreign-types-shared"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-lite"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
+dependencies = [
+ "fastrand",
+ "futures-core",
+ "futures-io",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-core",
+ "futures-macro",
+ "futures-task",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix 1.1.5",
+ "windows-link",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 6.0.0",
+]
+
+[[package]]
+name = "gl_generator"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
+dependencies = [
+ "khronos_api",
+ "log",
+ "xml-rs",
+]
+
+[[package]]
+name = "glifo"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a"
+dependencies = [
+ "bytemuck",
+ "foldhash",
+ "hashbrown 0.17.1",
+ "log",
+ "peniko",
+ "skrifa",
+ "smallvec",
+ "vello_common",
+]
+
+[[package]]
+name = "glow"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "glutin"
+version = "0.32.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325"
+dependencies = [
+ "bitflags 2.13.2",
+ "cfg_aliases",
+ "cgl",
+ "dispatch2",
+ "glutin_egl_sys",
+ "glutin_glx_sys",
+ "glutin_wgl_sys",
+ "libloading",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "once_cell",
+ "raw-window-handle",
+ "wayland-sys",
+ "windows-sys 0.52.0",
+ "x11-dl",
+]
+
+[[package]]
+name = "glutin-winit"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f"
+dependencies = [
+ "cfg_aliases",
+ "glutin",
+ "raw-window-handle",
+ "winit",
+]
+
+[[package]]
+name = "glutin_egl_sys"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2"
+dependencies = [
+ "gl_generator",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "glutin_glx_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185"
+dependencies = [
+ "gl_generator",
+ "x11-dl",
+]
+
+[[package]]
+name = "glutin_wgl_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
+dependencies = [
+ "gl_generator",
+]
+
+[[package]]
+name = "gpu-allocator"
+version = "0.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795"
+dependencies = [
+ "ash",
+ "hashbrown 0.16.1",
+ "log",
+ "presser",
+ "thiserror 2.0.21",
+ "windows",
+]
+
+[[package]]
+name = "guillotiere"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab"
+dependencies = [
+ "euclid",
+]
+
+[[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+ "num-traits",
+ "zerocopy",
+]
+
+[[package]]
+name = "harfrust"
+version = "0.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytemuck",
+ "read-fonts",
+ "smallvec",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "if-addrs"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "image"
+version = "0.25.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+ "png",
+ "tiff",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "itertools"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jni"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
+dependencies = [
+ "cfg-if",
+ "combine",
+ "jni-macros",
+ "jni-sys 0.4.1",
+ "log",
+ "simd_cesu8",
+ "thiserror 2.0.21",
+ "walkdir",
+ "windows-link",
+]
+
+[[package]]
+name = "jni-macros"
+version = "0.22.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "simd_cesu8",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
+dependencies = [
+ "jni-sys 0.4.1",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
+dependencies = [
+ "jni-sys-macros",
+]
+
+[[package]]
+name = "jni-sys-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
+dependencies = [
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "jobserver"
+version = "0.1.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
+dependencies = [
+ "getrandom 0.4.3",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "khronos-egl"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
+dependencies = [
+ "libc",
+ "libloading",
+ "pkg-config",
+]
+
+[[package]]
+name = "khronos_api"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
+
+[[package]]
+name = "kittest"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90ceaa75eb0036a32b6b9833962eb18137449e9817e2e586006471925b727fd5"
+dependencies = [
+ "accesskit",
+ "accesskit_consumer 0.35.0",
+]
+
+[[package]]
+name = "kurbo"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2"
+dependencies = [
+ "arrayvec",
+ "euclid",
+ "polycool",
+ "smallvec",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.191"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
+
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "libredox"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d"
+dependencies = [
+ "bitflags 2.13.2",
+ "libc",
+ "plain",
+ "redox_syscall 0.9.5",
+]
+
+[[package]]
+name = "linebender_resource_handle"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "litrs"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "mdns-sd"
+version = "0.21.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "127d3355da0615643c88c885e29a1ccb29ca4b1ca1f2e44b3caed2e728a796aa"
+dependencies = [
+ "fastrand",
+ "flume",
+ "if-addrs",
+ "log",
+ "mio",
+ "socket-pktinfo",
+ "socket2",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "memmap2"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memoffset"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "mio"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "moxcms"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
+[[package]]
+name = "naga"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130"
+dependencies = [
+ "arrayvec",
+ "bit-set",
+ "bitflags 2.13.2",
+ "cfg-if",
+ "cfg_aliases",
+ "codespan-reporting",
+ "half",
+ "hashbrown 0.17.1",
+ "indexmap",
+ "libm",
+ "log",
+ "naga-types",
+ "num-traits",
+ "once_cell",
+ "rustc-hash 1.1.0",
+ "spirv",
+ "thiserror 2.0.21",
+ "unicode-ident",
+]
+
+[[package]]
+name = "naga-types"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49"
+dependencies = [
+ "hashbrown 0.17.1",
+ "indexmap",
+ "rustc-hash 1.1.0",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.13.2",
+ "jni-sys 0.3.1",
+ "log",
+ "ndk-sys",
+ "num_enum",
+ "raw-window-handle",
+ "thiserror 1.0.69",
+]
+
+[[package]]
+name = "ndk-context"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b"
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys 0.3.1",
+]
+
+[[package]]
+name = "neumann-control-app"
+version = "0.1.0"
+dependencies = [
+ "eframe",
+ "egui_kittest",
+ "log",
+ "neumann-control-core",
+ "serde_json",
+ "simplelog",
+ "tempfile",
+]
+
+[[package]]
+name = "neumann-control-core"
+version = "0.1.0"
+dependencies = [
+ "log",
+ "mdns-sd",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "toml",
+]
+
+[[package]]
+name = "nohash-hasher"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+ "libm",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "num_threads"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "objc-sys"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
+
+[[package]]
+name = "objc2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b"
+dependencies = [
+ "objc-sys",
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
+dependencies = [
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "libc",
+ "objc2 0.5.3",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-foundation 0.2.2",
+ "objc2-quartz-core 0.2.2",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2 0.6.5",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "objc2 0.5.3",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-contacts"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.3",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "objc2 0.5.3",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
+dependencies = [
+ "bitflags 2.13.2",
+ "dispatch2",
+ "objc2 0.6.5",
+]
+
+[[package]]
+name = "objc2-core-graphics"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
+dependencies = [
+ "bitflags 2.13.2",
+ "dispatch2",
+ "objc2 0.6.5",
+ "objc2-core-foundation",
+ "objc2-io-surface",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.3",
+ "objc2-foundation 0.2.2",
+ "objc2-metal 0.2.2",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.3",
+ "objc2-contacts",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-encode"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
+
+[[package]]
+name = "objc2-foundation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "dispatch",
+ "libc",
+ "objc2 0.5.3",
+]
+
+[[package]]
+name = "objc2-foundation"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.6.2",
+ "objc2 0.6.5",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-io-surface"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2 0.6.5",
+ "objc2-core-foundation",
+]
+
+[[package]]
+name = "objc2-link-presentation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.3",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-metal"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "objc2 0.5.3",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-metal"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.6.2",
+ "objc2 0.6.5",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "objc2 0.5.3",
+ "objc2-foundation 0.2.2",
+ "objc2-metal 0.2.2",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2 0.6.5",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
+]
+
+[[package]]
+name = "objc2-symbols"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc"
+dependencies = [
+ "objc2 0.5.3",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "objc2 0.5.3",
+ "objc2-cloud-kit",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+ "objc2-link-presentation",
+ "objc2-quartz-core 0.2.2",
+ "objc2-symbols",
+ "objc2-uniform-type-identifiers",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22"
+dependencies = [
+ "bitflags 2.13.2",
+ "objc2 0.6.5",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+]
+
+[[package]]
+name = "objc2-uniform-type-identifiers"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
+dependencies = [
+ "block2 0.5.1",
+ "objc2 0.5.3",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
+dependencies = [
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "objc2 0.5.3",
+ "objc2-core-location",
+ "objc2-foundation 0.2.2",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "orbclient"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747"
+dependencies = [
+ "libc",
+ "libredox",
+]
+
+[[package]]
+name = "ordered-float"
+version = "5.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "ordered-stream"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "owned_ttf_parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b"
+dependencies = [
+ "ttf-parser",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "parking_lot"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall 0.5.18",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "peniko"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6"
+dependencies = [
+ "bytemuck",
+ "color",
+ "kurbo",
+ "linebender_resource_handle",
+ "smallvec",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "phf"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
+dependencies = [
+ "phf_macros",
+ "phf_shared",
+ "serde",
+]
+
+[[package]]
+name = "phf_generator"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
+dependencies = [
+ "fastrand",
+ "phf_shared",
+]
+
+[[package]]
+name = "phf_macros"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
+dependencies = [
+ "phf_generator",
+ "phf_shared",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "phf_shared"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
+dependencies = [
+ "siphasher",
+]
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "piper"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
+dependencies = [
+ "atomic-waker",
+ "fastrand",
+ "futures-io",
+]
+
+[[package]]
+name = "pkg-config"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
+
+[[package]]
+name = "plain"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
+
+[[package]]
+name = "png"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
+dependencies = [
+ "bitflags 2.13.2",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide 0.8.9",
+]
+
+[[package]]
+name = "polling"
+version = "3.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
+dependencies = [
+ "cfg-if",
+ "concurrent-queue",
+ "hermit-abi",
+ "pin-project-lite",
+ "rustix 1.1.5",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "pollster"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336"
+
+[[package]]
+name = "polycool"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6"
+dependencies = [
+ "arrayvec",
+]
+
+[[package]]
+name = "portable-atomic"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85"
+
+[[package]]
+name = "portable-atomic-util"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715"
+dependencies = [
+ "portable-atomic",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
+
+[[package]]
+name = "presser"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "profiling"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
+
+[[package]]
+name = "pxfm"
+version = "0.1.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
+
+[[package]]
+name = "quick-error"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
+
+[[package]]
+name = "quick-xml"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "range-alloc"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08"
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "raw-window-metal"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135"
+dependencies = [
+ "objc2 0.6.5",
+ "objc2-core-foundation",
+ "objc2-foundation 0.3.2",
+ "objc2-quartz-core 0.3.2",
+]
+
+[[package]]
+name = "read-fonts"
+version = "0.41.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709"
+dependencies = [
+ "bytemuck",
+ "font-types",
+ "once_cell",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
+dependencies = [
+ "bitflags 1.3.2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "580c875845b41ecb1564ad1ae4703044cef774145e3d7a94dc2b3fa7c97868fd"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "renderdoc-sys"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+
+[[package]]
+name = "rustc-hash"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags 2.13.2",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+dependencies = [
+ "bitflags 2.13.2",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.12.1",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "scoped-tls"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sctk-adwaita"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec"
+dependencies = [
+ "ab_glyph",
+ "log",
+ "memmap2",
+ "smithay-client-toolkit 0.19.2",
+ "tiny-skia",
+]
+
+[[package]]
+name = "self_cell"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813"
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.154"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
+
+[[package]]
+name = "simd_cesu8"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
+dependencies = [
+ "rustc_version",
+ "simdutf8",
+]
+
+[[package]]
+name = "simdutf8"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
+
+[[package]]
+name = "simplelog"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16257adbfaef1ee58b1363bdc0664c9b8e1e30aed86049635fb5f147d065a9c0"
+dependencies = [
+ "log",
+ "termcolor",
+ "time",
+]
+
+[[package]]
+name = "siphasher"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
+
+[[package]]
+name = "skrifa"
+version = "0.44.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68"
+dependencies = [
+ "bytemuck",
+ "read-fonts",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.16.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b"
+
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
+dependencies = [
+ "bitflags 2.13.2",
+ "calloop 0.13.0",
+ "calloop-wayland-source 0.3.0",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2",
+ "rustix 0.38.44",
+ "thiserror 1.0.69",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0"
+dependencies = [
+ "bitflags 2.13.2",
+ "calloop 0.14.5",
+ "calloop-wayland-source 0.4.1",
+ "cursor-icon",
+ "libc",
+ "log",
+ "memmap2",
+ "rustix 1.1.5",
+ "thiserror 2.0.21",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-csd-frame",
+ "wayland-cursor",
+ "wayland-protocols",
+ "wayland-protocols-experimental",
+ "wayland-protocols-misc",
+ "wayland-protocols-wlr",
+ "wayland-scanner",
+ "xkeysym",
+]
+
+[[package]]
+name = "smithay-clipboard"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226"
+dependencies = [
+ "libc",
+ "smithay-client-toolkit 0.20.0",
+ "wayland-backend",
+]
+
+[[package]]
+name = "smol_str"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "socket-pktinfo"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac"
+dependencies = [
+ "libc",
+ "socket2",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "spirv"
+version = "0.4.0+sdk-1.4.341.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "strict-num"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix 1.1.5",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl 2.0.21",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "tiff"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
+dependencies = [
+ "fax",
+ "flate2",
+ "half",
+ "quick-error",
+ "weezl",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "libc",
+ "num-conv",
+ "num_threads",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tiny-skia"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "bytemuck",
+ "cfg-if",
+ "log",
+ "tiny-skia-path",
+]
+
+[[package]]
+name = "tiny-skia-path"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93"
+dependencies = [
+ "arrayref",
+ "bytemuck",
+ "strict-num",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tokio"
+version = "1.53.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e"
+dependencies = [
+ "pin-project-lite",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.8+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.25.17+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "ttf-parser"
+version = "0.25.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
+
+[[package]]
+name = "type-map"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90"
+dependencies = [
+ "rustc-hash 2.1.3",
+]
+
+[[package]]
+name = "uds_windows"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
+dependencies = [
+ "memoffset",
+ "tempfile",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "unicode-general-category"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "uuid"
+version = "1.28.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606"
+dependencies = [
+ "js-sys",
+ "serde_core",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "vello_common"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2"
+dependencies = [
+ "bytemuck",
+ "fearless_simd",
+ "guillotiere",
+ "log",
+ "peniko",
+ "smallvec",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "vello_cpu"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea"
+dependencies = [
+ "bytemuck",
+ "glifo",
+ "hashbrown 0.17.1",
+ "vello_common",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
+dependencies = [
+ "js-sys",
+ "tokio",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wayland-backend"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix 1.1.5",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073"
+dependencies = [
+ "bitflags 2.13.2",
+ "rustix 1.1.5",
+ "wayland-backend",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-csd-frame"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e"
+dependencies = [
+ "bitflags 2.13.2",
+ "cursor-icon",
+ "wayland-backend",
+]
+
+[[package]]
+name = "wayland-cursor"
+version = "0.31.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d"
+dependencies = [
+ "rustix 1.1.5",
+ "wayland-client",
+ "xcursor",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6"
+dependencies = [
+ "bitflags 2.13.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-experimental"
+version = "20250721.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1"
+dependencies = [
+ "bitflags 2.13.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-misc"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8"
+dependencies = [
+ "bitflags 2.13.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-plasma"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91"
+dependencies = [
+ "bitflags 2.13.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-wlr"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
+dependencies = [
+ "bitflags 2.13.2",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
+dependencies = [
+ "dlib",
+ "log",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webbrowser"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94"
+dependencies = [
+ "jni",
+ "log",
+ "ndk-context",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+ "objc2-foundation 0.3.2",
+ "url",
+ "web-sys",
+]
+
+[[package]]
+name = "weezl"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
+
+[[package]]
+name = "wgpu"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e"
+dependencies = [
+ "arrayvec",
+ "bitflags 2.13.2",
+ "bytemuck",
+ "cfg-if",
+ "cfg_aliases",
+ "document-features",
+ "hashbrown 0.17.1",
+ "js-sys",
+ "log",
+ "naga",
+ "parking_lot",
+ "portable-atomic",
+ "profiling",
+ "raw-window-handle",
+ "smallvec",
+ "static_assertions",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "wgpu-core",
+ "wgpu-hal",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-core"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14"
+dependencies = [
+ "arrayvec",
+ "bit-set",
+ "bit-vec",
+ "bitflags 2.13.2",
+ "bytemuck",
+ "cfg_aliases",
+ "document-features",
+ "hashbrown 0.17.1",
+ "indexmap",
+ "log",
+ "naga",
+ "naga-types",
+ "once_cell",
+ "parking_lot",
+ "portable-atomic",
+ "profiling",
+ "raw-window-handle",
+ "rustc-hash 1.1.0",
+ "smallvec",
+ "thiserror 2.0.21",
+ "wgpu-core-deps-apple",
+ "wgpu-core-deps-emscripten",
+ "wgpu-core-deps-wasm",
+ "wgpu-core-deps-windows-linux-android",
+ "wgpu-hal",
+ "wgpu-naga-bridge",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-core-deps-apple"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-emscripten"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-wasm"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-core-deps-windows-linux-android"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f"
+dependencies = [
+ "wgpu-hal",
+]
+
+[[package]]
+name = "wgpu-hal"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58"
+dependencies = [
+ "android_system_properties",
+ "arrayvec",
+ "ash",
+ "bit-set",
+ "bitflags 2.13.2",
+ "block2 0.6.2",
+ "bytemuck",
+ "cfg-if",
+ "cfg_aliases",
+ "glow",
+ "glutin_wgl_sys",
+ "gpu-allocator",
+ "hashbrown 0.17.1",
+ "js-sys",
+ "khronos-egl",
+ "libc",
+ "libloading",
+ "log",
+ "naga",
+ "naga-types",
+ "ndk-sys",
+ "objc2 0.6.5",
+ "objc2-core-foundation",
+ "objc2-core-graphics",
+ "objc2-foundation 0.3.2",
+ "objc2-metal 0.3.2",
+ "objc2-quartz-core 0.3.2",
+ "once_cell",
+ "ordered-float",
+ "parking_lot",
+ "portable-atomic",
+ "portable-atomic-util",
+ "profiling",
+ "range-alloc",
+ "raw-window-handle",
+ "raw-window-metal",
+ "renderdoc-sys",
+ "smallvec",
+ "static_assertions",
+ "thiserror 2.0.21",
+ "wasm-bindgen",
+ "wayland-sys",
+ "web-sys",
+ "wgpu-naga-bridge",
+ "wgpu-types",
+ "windows",
+ "windows-core",
+ "windows-result",
+]
+
+[[package]]
+name = "wgpu-naga-bridge"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081"
+dependencies = [
+ "naga",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-types"
+version = "30.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286"
+dependencies = [
+ "bitflags 2.13.2",
+ "bytemuck",
+ "js-sys",
+ "log",
+ "naga-types",
+ "raw-window-handle",
+ "static_assertions",
+ "web-sys",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
+dependencies = [
+ "windows-collections",
+ "windows-core",
+ "windows-future",
+ "windows-numerics",
+]
+
+[[package]]
+name = "windows-collections"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
+dependencies = [
+ "windows-core",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.62.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-future"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
+dependencies = [
+ "windows-core",
+ "windows-link",
+ "windows-threading",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-numerics"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
+dependencies = [
+ "windows-core",
+ "windows-link",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
[[package]]
-name = "flume"
-version = "0.12.0"
+name = "windows-targets"
+version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
- "futures-core",
- "futures-sink",
- "spin",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
]
[[package]]
-name = "futures-core"
-version = "0.3.34"
+name = "windows-threading"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
+dependencies = [
+ "windows-link",
+]
[[package]]
-name = "futures-sink"
-version = "0.3.34"
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
-name = "getrandom"
-version = "0.4.3"
+name = "windows_aarch64_msvc"
+version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
-dependencies = [
- "cfg-if",
- "libc",
- "r-efi",
-]
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
-name = "hashbrown"
-version = "0.17.1"
+name = "windows_i686_gnu"
+version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
-name = "if-addrs"
-version = "0.15.0"
+name = "windows_i686_gnullvm"
+version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b"
-dependencies = [
- "libc",
- "windows-sys",
-]
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
-name = "indexmap"
-version = "2.14.2"
+name = "windows_i686_msvc"
+version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
-dependencies = [
- "equivalent",
- "hashbrown",
-]
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
-name = "itoa"
-version = "1.0.18"
+name = "windows_x86_64_gnu"
+version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
-name = "libc"
-version = "0.2.191"
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
-name = "linux-raw-sys"
-version = "0.12.1"
+name = "windows_x86_64_msvc"
+version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
-name = "lock_api"
-version = "0.4.14"
+name = "winit"
+version = "0.30.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d"
dependencies = [
- "scopeguard",
+ "ahash",
+ "android-activity",
+ "atomic-waker",
+ "bitflags 2.13.2",
+ "block2 0.5.1",
+ "bytemuck",
+ "calloop 0.13.0",
+ "cfg_aliases",
+ "concurrent-queue",
+ "core-foundation",
+ "core-graphics",
+ "cursor-icon",
+ "dpi",
+ "js-sys",
+ "libc",
+ "memmap2",
+ "ndk",
+ "objc2 0.5.3",
+ "objc2-app-kit 0.2.2",
+ "objc2-foundation 0.2.2",
+ "objc2-ui-kit 0.2.2",
+ "orbclient",
+ "percent-encoding",
+ "pin-project",
+ "raw-window-handle",
+ "redox_syscall 0.4.1",
+ "rustix 0.38.44",
+ "sctk-adwaita",
+ "smithay-client-toolkit 0.19.2",
+ "smol_str",
+ "tracing",
+ "unicode-segmentation",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-protocols-plasma",
+ "web-sys",
+ "web-time",
+ "windows-sys 0.52.0",
+ "x11-dl",
+ "x11rb",
+ "xkbcommon-dl",
]
[[package]]
-name = "log"
-version = "0.4.34"
+name = "winnow"
+version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
[[package]]
-name = "mdns-sd"
-version = "0.21.5"
+name = "wit-bindgen"
+version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "127d3355da0615643c88c885e29a1ccb29ca4b1ca1f2e44b3caed2e728a796aa"
-dependencies = [
- "fastrand",
- "flume",
- "if-addrs",
- "log",
- "mio",
- "socket-pktinfo",
- "socket2",
-]
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]]
-name = "memchr"
-version = "2.8.3"
+name = "writeable"
+version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
[[package]]
-name = "mio"
-version = "1.2.4"
+name = "x11-dl"
+version = "2.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
+checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f"
dependencies = [
"libc",
- "log",
- "wasi",
- "windows-sys",
+ "once_cell",
+ "pkg-config",
]
[[package]]
-name = "neumann-control-app"
-version = "0.1.0"
+name = "x11rb"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
dependencies = [
- "neumann-control-core",
- "serde_json",
+ "as-raw-xcb-connection",
+ "gethostname",
+ "libc",
+ "libloading",
+ "once_cell",
+ "rustix 1.1.5",
+ "x11rb-protocol",
]
[[package]]
-name = "neumann-control-core"
-version = "0.1.0"
-dependencies = [
- "log",
- "mdns-sd",
- "serde",
- "serde_json",
- "tempfile",
- "toml",
-]
+name = "x11rb-protocol"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
-name = "once_cell"
-version = "1.21.4"
+name = "xcursor"
+version = "0.3.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23"
[[package]]
-name = "proc-macro2"
-version = "1.0.107"
+name = "xkbcommon-dl"
+version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5"
dependencies = [
- "unicode-ident",
+ "bitflags 2.13.2",
+ "dlib",
+ "log",
+ "once_cell",
+ "xkeysym",
]
[[package]]
-name = "quote"
-version = "1.0.47"
+name = "xkeysym"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
-dependencies = [
- "proc-macro2",
-]
+checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
[[package]]
-name = "r-efi"
-version = "6.0.0"
+name = "xml-rs"
+version = "0.8.29"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
[[package]]
-name = "rustix"
-version = "1.1.5"
+name = "yoke"
+version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [
- "bitflags",
- "errno",
- "libc",
- "linux-raw-sys",
- "windows-sys",
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
]
[[package]]
-name = "scopeguard"
-version = "1.2.0"
+name = "yoke-derive"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "synstructure",
+]
[[package]]
-name = "serde"
-version = "1.0.229"
+name = "zbus"
+version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
dependencies = [
- "serde_core",
- "serde_derive",
+ "async-broadcast",
+ "async-executor",
+ "async-io",
+ "async-lock",
+ "async-process",
+ "async-recursion",
+ "async-task",
+ "async-trait",
+ "blocking",
+ "enumflags2",
+ "event-listener",
+ "futures-core",
+ "futures-lite",
+ "hex",
+ "libc",
+ "ordered-stream",
+ "rustix 1.1.5",
+ "serde",
+ "serde_repr",
+ "tracing",
+ "uds_windows",
+ "uuid",
+ "windows-sys 0.61.2",
+ "winnow",
+ "zbus_macros",
+ "zbus_names",
+ "zvariant",
]
[[package]]
-name = "serde_core"
-version = "1.0.229"
+name = "zbus-lockstep"
+version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863"
dependencies = [
- "serde_derive",
+ "zbus_xml",
+ "zvariant",
]
[[package]]
-name = "serde_derive"
-version = "1.0.229"
+name = "zbus-lockstep-macros"
+version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0"
dependencies = [
"proc-macro2",
"quote",
- "syn",
+ "syn 2.0.119",
+ "zbus-lockstep",
+ "zbus_xml",
+ "zvariant",
]
[[package]]
-name = "serde_json"
-version = "1.0.154"
+name = "zbus_macros"
+version = "5.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
+checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
dependencies = [
- "itoa",
- "memchr",
- "serde",
- "serde_core",
- "zmij",
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "zbus_names",
+ "zvariant",
+ "zvariant_utils",
]
[[package]]
-name = "serde_spanned"
-version = "1.1.2"
+name = "zbus_names"
+version = "4.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
+checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd"
dependencies = [
- "serde_core",
+ "serde",
+ "winnow",
+ "zvariant",
]
[[package]]
-name = "socket-pktinfo"
-version = "0.4.1"
+name = "zbus_xml"
+version = "5.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac"
+checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a"
dependencies = [
- "libc",
- "socket2",
- "windows-sys",
+ "serde",
+ "winnow",
+ "zbus_names",
+ "zvariant",
]
[[package]]
-name = "socket2"
-version = "0.6.5"
+name = "zcheapstr"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
dependencies = [
- "libc",
- "windows-sys",
+ "serde",
]
[[package]]
-name = "spin"
-version = "0.9.9"
+name = "zerocopy"
+version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [
- "lock_api",
+ "zerocopy-derive",
]
[[package]]
-name = "syn"
-version = "3.0.9"
+name = "zerocopy-derive"
+version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
+checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [
"proc-macro2",
"quote",
- "unicode-ident",
+ "syn 2.0.119",
]
[[package]]
-name = "tempfile"
-version = "3.27.0"
+name = "zerofrom"
+version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
dependencies = [
- "fastrand",
- "getrandom",
- "once_cell",
- "rustix",
- "windows-sys",
+ "zerofrom-derive",
]
[[package]]
-name = "toml"
-version = "1.1.8+spec-1.1.0"
+name = "zerofrom-derive"
+version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
- "indexmap",
- "serde_core",
- "serde_spanned",
- "toml_datetime",
- "toml_parser",
- "toml_writer",
- "winnow",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "synstructure",
]
[[package]]
-name = "toml_datetime"
-version = "1.1.2+spec-1.1.0"
+name = "zerotrie"
+version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
dependencies = [
- "serde_core",
+ "displaydoc",
+ "yoke",
+ "zerofrom",
]
[[package]]
-name = "toml_parser"
-version = "1.1.5+spec-1.1.0"
+name = "zerovec"
+version = "0.11.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
dependencies = [
- "winnow",
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
]
[[package]]
-name = "toml_writer"
-version = "1.1.3+spec-1.1.0"
+name = "zerovec-derive"
+version = "0.11.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
[[package]]
-name = "unicode-ident"
-version = "1.0.27"
+name = "zlib-rs"
+version = "0.6.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112"
[[package]]
-name = "wasi"
-version = "0.11.1+wasi-snapshot-preview1"
+name = "zmij"
+version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
-name = "windows-link"
-version = "0.2.1"
+name = "zune-core"
+version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b"
[[package]]
-name = "windows-sys"
-version = "0.61.2"
+name = "zune-jpeg"
+version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
dependencies = [
- "windows-link",
+ "zune-core",
]
[[package]]
-name = "winnow"
-version = "1.0.4"
+name = "zvariant"
+version = "5.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7"
+dependencies = [
+ "endi",
+ "enumflags2",
+ "serde",
+ "winnow",
+ "zcheapstr",
+ "zvariant_derive",
+ "zvariant_utils",
+]
[[package]]
-name = "zmij"
-version = "1.0.23"
+name = "zvariant_derive"
+version = "5.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+ "zvariant_utils",
+]
+
+[[package]]
+name = "zvariant_utils"
+version = "4.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "serde",
+ "syn 3.0.9",
+ "winnow",
+]
diff --git a/about.hbs b/about.hbs
index 1b9fbf0..8ca5fdc 100644
--- a/about.hbs
+++ b/about.hbs
@@ -83,7 +83,8 @@
{{/each}}
Fonts
-
+
Inter 4.1 (SIL Open Font License 1.1)
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
@@ -168,103 +169,6 @@ TERMINATION
This license becomes null and void if any of the above conditions are
not met.
-DISCLAIMER
-THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
-EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
-MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
-OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
-COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
-INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
-DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
-FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
-OTHER DEALINGS IN THE FONT SOFTWARE.
-
-
-
-
JetBrains Mono NL 2.304 (SIL Open Font License 1.1)
-
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
-
-This Font Software is licensed under the SIL Open Font License, Version 1.1.
-This license is copied below, and is also available with a FAQ at:
-https://scripts.sil.org/OFL
-
-
------------------------------------------------------------
-SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
------------------------------------------------------------
-
-PREAMBLE
-The goals of the Open Font License (OFL) are to stimulate worldwide
-development of collaborative font projects, to support the font creation
-efforts of academic and linguistic communities, and to provide a free and
-open framework in which fonts may be shared and improved in partnership
-with others.
-
-The OFL allows the licensed fonts to be used, studied, modified and
-redistributed freely as long as they are not sold by themselves. The
-fonts, including any derivative works, can be bundled, embedded,
-redistributed and/or sold with any software provided that any reserved
-names are not used by derivative works. The fonts and derivatives,
-however, cannot be released under any other type of license. The
-requirement for fonts to remain under this license does not apply
-to any document created using the fonts or their derivatives.
-
-DEFINITIONS
-"Font Software" refers to the set of files released by the Copyright
-Holder(s) under this license and clearly marked as such. This may
-include source files, build scripts and documentation.
-
-"Reserved Font Name" refers to any names specified as such after the
-copyright statement(s).
-
-"Original Version" refers to the collection of Font Software components as
-distributed by the Copyright Holder(s).
-
-"Modified Version" refers to any derivative made by adding to, deleting,
-or substituting -- in part or in whole -- any of the components of the
-Original Version, by changing formats or by porting the Font Software to a
-new environment.
-
-"Author" refers to any designer, engineer, programmer, technical
-writer or other person who contributed to the Font Software.
-
-PERMISSION & CONDITIONS
-Permission is hereby granted, free of charge, to any person obtaining
-a copy of the Font Software, to use, study, copy, merge, embed, modify,
-redistribute, and sell modified and unmodified copies of the Font
-Software, subject to the following conditions:
-
-1) Neither the Font Software nor any of its individual components,
-in Original or Modified Versions, may be sold by itself.
-
-2) Original or Modified Versions of the Font Software may be bundled,
-redistributed and/or sold with any software, provided that each copy
-contains the above copyright notice and this license. These can be
-included either as stand-alone text files, human-readable headers or
-in the appropriate machine-readable metadata fields within text or
-binary files as long as those fields can be easily viewed by the user.
-
-3) No Modified Version of the Font Software may use the Reserved Font
-Name(s) unless explicit written permission is granted by the corresponding
-Copyright Holder. This restriction only applies to the primary font name as
-presented to the users.
-
-4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
-Software shall not be used to promote, endorse or advertise any
-Modified Version, except to acknowledge the contribution(s) of the
-Copyright Holder(s) and the Author(s) or with their explicit written
-permission.
-
-5) The Font Software, modified or unmodified, in part or in whole,
-must be distributed entirely under this license, and must not be
-distributed under any other license. The requirement for fonts to
-remain under this license does not apply to any document created
-using the Font Software.
-
-TERMINATION
-This license becomes null and void if any of the above conditions are
-not met.
-
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml
index f021dcd..1845c45 100644
--- a/crates/app/Cargo.toml
+++ b/crates/app/Cargo.toml
@@ -17,3 +17,11 @@ path = "src/lib.rs"
[dependencies]
neumann-control-core = { path = "../core" }
serde_json = "1"
+eframe = "0.36"
+log = "0.4"
+simplelog = "0.12"
+
+[dev-dependencies]
+egui_kittest = { version = "0.36", features = ["eframe"] }
+neumann-control-core = { path = "../core", features = ["test-support"] }
+tempfile = "3"
diff --git a/crates/app/assets/fonts/Inter-Regular.ttf b/crates/app/assets/fonts/Inter-Regular.ttf
new file mode 100644
index 0000000000000000000000000000000000000000..b7aaca8de1fe458399e17311d35d543a1fa2f984
GIT binary patch
literal 411640
zcmeFa2bdK__C8!)T~*yZGr*7rM8f2m8vtcSauQ)c7Eu8aMG*tRjEJZ#B8v$X6Cxlg
ztAbep5fMSqMN|Y9K@r26)9zw7&AJOH9?r(
zF66%zmXM-0DE#aVp#P5J8E_X`X~MD^TekqGS@#0(v)%`OX#EWQRrV2<>?`L1ACZp$
zAD3?c*UOE-O>#4EtK0_MF82eAZOGew?IFOS_Ibcj_7%XZ>Pq0%YBF$&Lh96g3aM6$)N{ZW)LP&M^&jAW)lT47YBz9?+5_CH@u0P>
z5g%Pk*8-;MRA4<_AJ|Yg0v@581M_qqFs#GCR{CgQfkr%ad)*$`QFj4$)5ijj*T)0@
zqW=OsQJ)ArSq}i7rcVP7)kA@!HA$c?G!ItOl+zuL0Ma^}x5x
zTfleBd%%y)$H1Ltx3C@Q7-2bsox#9MotuHTI@5r6J9h)`aqa;=?VxNpZ#wIMn;n!5
z=N;!A;Fr!m;Lpy_!gjSgQdsW!F7D-i;(h}B-2E^3U%I9^P@lUS2QY@!s*k6TB0E
zCwj};6QI6@KoGNk2o|Fh~oLE;@=N
zxRb;j@u(OghKtc+jJQfn7IzBOv#8%7G4|Z6$6h4Hj~YATLNWfLv&UT`UJ?4-F{tOJ
zTsUIvCE|9*mo7eg?1f_X#TQxRU9;)+W%T&qog@8-6x(G<0c%cZ4gJ3j4TY2c?UpC!E+1
zIN-z+fRHvN(j{RccqZgBIwE8$a>PmxhfzV@>A7R3KD
zu$6oZU=QgNTTcQ$*%M)*TYS6NKVODA9oi}NbT0*&0qH0o>66p5fraejw0J+>m4>kV
z5ZULk)cKj|87E~<%8(g7;oQvmS(%w#GhDdA8Er~jUq1KEIwNCOM$gRM8GRF6`%IJ3
z6%J=mYv2}V4A1DAc}8jY3rZ={=xeK;H8-OH+&H+t2;CD0T}{oHl+h$(X2$%C^o-n$
ztr=l*nd?e5oRmS1;<|?J$VXSc>l*MVW+>Swok3|kdDkgn(Oq;wE|vZwD@sWak;(B_
z<>|;LWqFaE2sOJL_xuQzu!LXYoyvxaOiK{1;YH+%vVxI$WjPgHM;2AYjps!+#;zjh
z)s+23OgRoSBA=J#$6uGtgLuE1(vgK_B@c(zvO250K2tQW(!UDNnmwPg3d)zunx#4N
zsIMt9EixotXQW?bL;N+-k=cBrykWF<K>bUZgQEV8q>l5#_G2Un}b-=U^-Wu>>K;UU-Y
z0Ax!=qj_1%_|u58HbxPN*0z5}qz=!$Bj+c^{g2IWYT;s*QBe-VzY;4;jQ4?D9ZJ1)
zAorXd$U$IR+_q9F?M4k8CkfPw@}s?_Hi#ssnAp!P#?UcvMI9T+v7*
z>i5T-6Q6V9sPC}HpiNCRMlMaXlE~|cAtU!CULW{XWwm-S{%MK-MJB~xRYynr`qBEO
zI{AwIDRlMibF^$nz9lVw%Ur(HMoLE172m1m+ymF+kKe1l1C6>PSrv^k{czFtF40~q
z)!D03pM&r?@D!KauX=E;}}k@?&SrqoU@d+0N43#!PJV7x@f+H{+lhc5;-Pc=p>s%mM!=kuOST
zSP!uEEBS{Z@(u3!av7avE+b$2jL7aX9%*zP`LGQ4fG#8VACP!C;e)LcNKs^ZIdv5<
zqN$JbYU0y}a_=~8H6*L6J;+h
z%?|G)yuvySm(i#jaru%*Li9X{;vQis)WYdc`Y
ziE#AC`V;-B^NagWccY87H3zifqhU!b?!#$h9_jVZ6w3uA#}b^&rUL&q
zaVoaqj8RaBPmr2AI6ORsWHA%#E*=uEi5cPzu~WQgC0RF%kFcU_o^^)xi1m#%&f0DL
zVy&`&wtuzWRGu1SZO}b+PuUo&eT+Q9YwWd^=~#R7i0tOA^ES%SSogFGV@u08Xn`%~
zRJ7ifz6&;JnY%>;;LZ5g7l;-TE1S^5TRN9~Av2kA1^5nga?w{H-`PTvHnRzHDcK364+k-;-^oN0ytrBR`ng=s($JS>$u`
zB97&_ztYD={-gUu_UN~Zcj|YFrS0ELF>jyC&~+i?}YH3gr^rq7V3|2-%oIS8o2`Z
zobCJ)xx)P?j*U1rBbPi#ya|anA@QcEgzn~(8o)6-4Kct_4^fX960qJRwo`$$BM2II4Vj4m`fe_O~ZM_NU
z+Kl7l(kDbuZysW>!nGswT!rJGppmPH$qFwuGS91v(v*t(OvZhR^ag~41nyO&w<8Z-
zA?MqaPJ})VZ_l`FWJkwRrKOm=i%HM`E?YIqj9vybtfF1
zadbiax<<0}u{ifYYL3IX7mnUI`rtSoM_>5=1?PV7?~n5%(6I<%7e|Kal{l}%^+v=b
z3C|%JM{UrJaBhmS&`k8gion+#OZ0NOAeXx0=mxqwj$?83z;PT7O2IIke}jJ{vM_mo
zz}h%*zCBAN>nyBbJW3y}yXfO|FI}YjV4dO#dWybIU$1Y_Q}r$SR(+ekOW&g((Tnvm
z{i0s3SLl`cRlP>9)9dvH{ZGA7zoobOb(H=>Z`WVzZ}hi%xBgD=)j#T=^sjoq{!JGf
z%h*O4V;tie&m@^-lVWO_+9uW1HH}O&bAq|q%*0B?$IRnqp;=~LG|SB=zvD$T!c*yR9
zRjp6*O4g@%CF^pmVVy2ksy3>P*sKO&?djXo+;#1`EQt3u!9RiW=uzKZvG
zJ?MwL9`s)(U;^SJ6EY$3v1w&miBC)i(@}hCPB*8Ef15MR8RBzumN`rO$6R186x(<$
z=60;Vl;SJrGUqa}%emaSTzu_}cgBlvoWD6!#JA1@XMy<6S>!Addz>ZC67jwBvh#}A
z>+E)RiyxgIvHtQWx0YK={Nkp%^~JAln%h`J+}3WPCEda9V9Rw+bI-6m_bm5ptCl;_
zz0OK?=edtqjopRrLhDHPIrllMsk_2mVKsACx+|^b9v+_6!b|aLTj^Mf8L+Zw<)xMF
zUGLps<#=~{_gHz}-@U(ELGO8QsTJ~;d&{ks-b!zk)yjLtd&N46R#I9=dvAMNtU~Wc
z??-s(`;udFNR3(l&8aRnzK)WpI8g+mI56`ogkLE)If@wkraYFe1!3I@ln
z3(hGRU2sXkxPl1B2RYxG9CLDs-P0R^+x89*SF6_>u1l
zrbnsRxnOqT)XH7K+=BUqnT44JPZE}K`Gw_OAyU=7up?4^d%+@pTCEC}7S1Smsc=rg
z>Vma}4;E}Je6(Oo!G{H(70v*Ed%>=PJq7y;7Z(&4E-qAsNrlS_Q*oq0!&KZaKkoVZ
zPb8Y7lz*R>=8K=gSW5i3`te&;SXj6Yv5IPJg{yuDU08zMp{FBwBvS-lQQ;*T)`
z5v-GMZZ(uy@>r{b93qEUL#QsXPLt2d#n#nwiG1DqE3KKgZl^W#)^t0~?rqJm2igOz
z*X_IQnbsRv5C4v}9_!)vNypxA@0U$z4ZJ*3wNve6Q`JlLlFewnx@=Bs(dALPzOFBi
zrus&q$b*^--lz(xqajud5
zoNJwH8zB4oPRj~kf%7W
zIj_mVSO=aYPo>&Op6MRpHkD_)*=|sdaF21j$cx;bZjrpg?duMdS5eI*r?}_1Bjt_m
zW$u;oR`+^$nw;+5?cO8rb7#AA3^8Mr!l24EypzKY>(HZ6MSkVXN
zZ~)wKVvsmP^h7K6j5q=9*K6W_@dnD`b5