diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..1513cd2
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,29 @@
+name: CI
+
+on:
+ push:
+ branches: [main, dev]
+ pull_request:
+
+jobs:
+ build-and-test:
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: |
+ 8.0.x
+ 10.0.x
+
+ - name: Restore
+ run: dotnet restore
+
+ - name: Build
+ run: dotnet build --no-restore --configuration Release
+
+ - name: Test
+ run: dotnet test --no-build --configuration Release --verbosity normal
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 70d1ba3..7a2e8d9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,485 @@
-*/bin/
-*/obj/
+## Ignore Visual Studio temporary files, build results, and
+## files generated by popular Visual Studio add-ons.
+##
+## Get latest from `dotnet new gitignore`
-SteelSeriesRestScanner/
+# dotenv files
+.env
+
+# User-specific files
+*.rsuser
+*.suo
+*.user
+*.userosscache
+*.sln.docstates
+
+# User-specific files (MonoDevelop/Xamarin Studio)
+*.userprefs
+
+# Mono auto generated files
+mono_crash.*
+
+# Build results
+[Dd]ebug/
+[Dd]ebugPublic/
+[Rr]elease/
+[Rr]eleases/
+x64/
+x86/
+[Ww][Ii][Nn]32/
+[Aa][Rr][Mm]/
+[Aa][Rr][Mm]64/
+bld/
+[Bb]in/
+[Oo]bj/
+[Ll]og/
+[Ll]ogs/
+
+# Visual Studio 2015/2017 cache/options directory
+.vs/
+# Uncomment if you have tasks that create the project's static files in wwwroot
+#wwwroot/
+
+# Visual Studio 2017 auto generated files
+Generated\ Files/
+
+# MSTest test Results
+[Tt]est[Rr]esult*/
+[Bb]uild[Ll]og.*
+
+# NUnit
+*.VisualState.xml
+TestResult.xml
+nunit-*.xml
+
+# Build Results of an ATL Project
+[Dd]ebugPS/
+[Rr]eleasePS/
+dlldata.c
+
+# Benchmark Results
+BenchmarkDotNet.Artifacts/
+
+# .NET
+project.lock.json
+project.fragment.lock.json
+artifacts/
+
+# Tye
+.tye/
+
+# ASP.NET Scaffolding
+ScaffoldingReadMe.txt
+
+# StyleCop
+StyleCopReport.xml
+
+# Files built by Visual Studio
+*_i.c
+*_p.c
+*_h.h
+*.ilk
+*.meta
+*.obj
+*.iobj
+*.pch
+*.pdb
+*.ipdb
+*.pgc
+*.pgd
+*.rsp
+# but not Directory.Build.rsp, as it configures directory-level build defaults
+!Directory.Build.rsp
+*.sbr
+*.tlb
+*.tli
+*.tlh
+*.tmp
+*.tmp_proj
+*_wpftmp.csproj
+*.log
+*.tlog
+*.vspscc
+*.vssscc
+.builds
+*.pidb
+*.svclog
+*.scc
+
+# Chutzpah Test files
+_Chutzpah*
+
+# Visual C++ cache files
+ipch/
+*.aps
+*.ncb
+*.opendb
+*.opensdf
+*.sdf
+*.cachefile
+*.VC.db
+*.VC.VC.opendb
+
+# Visual Studio profiler
+*.psess
+*.vsp
+*.vspx
+*.sap
+
+# Visual Studio Trace Files
+*.e2e
+
+# TFS 2012 Local Workspace
+$tf/
+
+# Guidance Automation Toolkit
+*.gpState
+
+# ReSharper is a .NET coding add-in
+_ReSharper*/
+*.[Rr]e[Ss]harper
+*.DotSettings.user
+
+# TeamCity is a build add-in
+_TeamCity*
+
+# DotCover is a Code Coverage Tool
+*.dotCover
+
+# AxoCover is a Code Coverage Tool
+.axoCover/*
+!.axoCover/settings.json
+
+# Coverlet is a free, cross platform Code Coverage Tool
+coverage*.json
+coverage*.xml
+coverage*.info
+
+# Visual Studio code coverage results
+*.coverage
+*.coveragexml
+
+# NCrunch
+_NCrunch_*
+.*crunch*.local.xml
+nCrunchTemp_*
+
+# MightyMoose
+*.mm.*
+AutoTest.Net/
+
+# Web workbench (sass)
+.sass-cache/
+
+# Installshield output folder
+[Ee]xpress/
+
+# DocProject is a documentation generator add-in
+DocProject/buildhelp/
+DocProject/Help/*.HxT
+DocProject/Help/*.HxC
+DocProject/Help/*.hhc
+DocProject/Help/*.hhk
+DocProject/Help/*.hhp
+DocProject/Help/Html2
+DocProject/Help/html
+
+# Click-Once directory
+publish/
+
+# Publish Web Output
+*.[Pp]ublish.xml
+*.azurePubxml
+# Note: Comment the next line if you want to checkin your web deploy settings,
+# but database connection strings (with potential passwords) will be unencrypted
+*.pubxml
+*.publishproj
+
+# Microsoft Azure Web App publish settings. Comment the next line if you want to
+# checkin your Azure Web App publish settings, but sensitive information contained
+# in these scripts will be unencrypted
+PublishScripts/
+
+# NuGet Packages
+*.nupkg
+# NuGet Symbol Packages
+*.snupkg
+# The packages folder can be ignored because of Package Restore
+**/[Pp]ackages/*
+# except build/, which is used as an MSBuild target.
+!**/[Pp]ackages/build/
+# Uncomment if necessary however generally it will be regenerated when needed
+#!**/[Pp]ackages/repositories.config
+# NuGet v3's project.json files produces more ignorable files
+*.nuget.props
+*.nuget.targets
+
+# Microsoft Azure Build Output
+csx/
+*.build.csdef
+
+# Microsoft Azure Emulator
+ecf/
+rcf/
+
+# Windows Store app package directories and files
+AppPackages/
+BundleArtifacts/
+Package.StoreAssociation.xml
+_pkginfo.txt
+*.appx
+*.appxbundle
+*.appxupload
+
+# Visual Studio cache files
+# files ending in .cache can be ignored
+*.[Cc]ache
+# but keep track of directories ending in .cache
+!?*.[Cc]ache/
+
+# Others
+ClientBin/
+~$*
+*~
+*.dbmdl
+*.dbproj.schemaview
+*.jfm
+*.pfx
+*.publishsettings
+orleans.codegen.cs
+
+# Including strong name files can present a security risk
+# (https://github.com/github/gitignore/pull/2483#issue-259490424)
+#*.snk
+
+# Since there are multiple workflows, uncomment next line to ignore bower_components
+# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
+#bower_components/
+
+# RIA/Silverlight projects
+Generated_Code/
+
+# Backup & report files from converting an old project file
+# to a newer Visual Studio version. Backup files are not needed,
+# because we have git ;-)
+_UpgradeReport_Files/
+Backup*/
+UpgradeLog*.XML
+UpgradeLog*.htm
+ServiceFabricBackup/
+*.rptproj.bak
+
+# SQL Server files
+*.mdf
+*.ldf
+*.ndf
+
+# Business Intelligence projects
+*.rdl.data
+*.bim.layout
+*.bim_*.settings
+*.rptproj.rsuser
+*- [Bb]ackup.rdl
+*- [Bb]ackup ([0-9]).rdl
+*- [Bb]ackup ([0-9][0-9]).rdl
+
+# Microsoft Fakes
+FakesAssemblies/
+
+# GhostDoc plugin setting file
+*.GhostDoc.xml
+
+# Node.js Tools for Visual Studio
+.ntvs_analysis.dat
+node_modules/
+
+# Visual Studio 6 build log
+*.plg
+
+# Visual Studio 6 workspace options file
+*.opt
+
+# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
+*.vbw
+
+# Visual Studio 6 auto-generated project file (contains which files were open etc.)
+*.vbp
+
+# Visual Studio 6 workspace and project file (working project files containing files to include in project)
+*.dsw
+*.dsp
+
+# Visual Studio 6 technical files
+*.ncb
+*.aps
+
+# Visual Studio LightSwitch build output
+**/*.HTMLClient/GeneratedArtifacts
+**/*.DesktopClient/GeneratedArtifacts
+**/*.DesktopClient/ModelManifest.xml
+**/*.Server/GeneratedArtifacts
+**/*.Server/ModelManifest.xml
+_Pvt_Extensions
+
+# Paket dependency manager
+.paket/paket.exe
+paket-files/
+
+# FAKE - F# Make
+.fake/
+
+# CodeRush personal settings
+.cr/personal
+
+# Python Tools for Visual Studio (PTVS)
+__pycache__/
+*.pyc
+
+# Cake - Uncomment if you are using it
+# tools/**
+# !tools/packages.config
+
+# Tabs Studio
+*.tss
+
+# Telerik's JustMock configuration file
+*.jmconfig
+
+# BizTalk build output
+*.btp.cs
+*.btm.cs
+*.odx.cs
+*.xsd.cs
+
+# OpenCover UI analysis results
+OpenCover/
+
+# Azure Stream Analytics local run output
+ASALocalRun/
+
+# MSBuild Binary and Structured Log
+*.binlog
+
+# NVidia Nsight GPU debugger configuration file
+*.nvuser
+
+# MFractors (Xamarin productivity tool) working folder
+.mfractor/
+
+# Local History for Visual Studio
+.localhistory/
+
+# Visual Studio History (VSHistory) files
+.vshistory/
+
+# BeatPulse healthcheck temp database
+healthchecksdb
+
+# Backup folder for Package Reference Convert tool in Visual Studio 2017
+MigrationBackup/
+
+# Ionide (cross platform F# VS Code tools) working folder
+.ionide/
+
+# Fody - auto-generated XML schema
+FodyWeavers.xsd
+
+# VS Code files for those working on multiple tools
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+*.code-workspace
+
+# Local History for Visual Studio Code
+.history/
+
+# Windows Installer files from build outputs
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# JetBrains Rider
+*.sln.iml
.idea/
-rests.txt
\ No newline at end of file
+
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# Vim temporary swap files
+*.swp
+
+# API Check References
+*.shape.json.actual
\ No newline at end of file
diff --git a/README.md b/README.md
index 116524c..60af3af 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,9 @@
# SteelSeries-NET-API
-[](https://github.com/DataNext27/SteelSeries-NET-API/releases/)
[](https://www.nuget.org/packages/Steelseries-NET-API)
-[](https://github.com/DataNext27/SteelSeries-NET-API/releases/latest/)
+[](https://www.nuget.org/packages/Steelseries-NET-API)
[](https://github.com/DataNext27/SteelSeries-NET-API/blob/main/LICENSE)
-[](https://dotnet.microsoft.com/fr-fr/download/dotnet/9.0)
-[](https://dotnet.microsoft.com/fr-fr/download/dotnet/8.0)
-[](https://dotnet.microsoft.com/fr-fr/download/dotnet/7.0)
+[](https://dotnet.microsoft.com/download)
[](https://ko-fi.com/M4M2VL6WW)
> This library is **NOT** affiliated in any way with **SteelSeries**
> I've made it because it was interesting and funny to do, also I wanted to share this project for people to use it for their own projects
@@ -17,38 +14,110 @@ The library is available via a [nuget package](https://www.nuget.org/packages/St
It is also available in the [Releases](https://github.com/mpaperno/SteelSeries-NET-API/releases) tab as a .zip archive for each supported .NET version.
## Features
- - Full Sonar control
- - Mode
- - Volume
- - Mute
- - ChatMix
- - Configs (Can't edit a config)
- - Playback Devices
- - Streamer mode Personal & Stream Mixes
- - Streamer mode Audience Monitoring
+- **Full Sonar Control**:
+ - **Mixer mode** - read and switch between Classic and Streamer, with confirmation
+ - **Volumes & mutes** - per channel (Master, Game, Chat, Media, Aux, Mic), per mix
+ (Personal/Stream) in streamer mode
+ - **Chat mix** - read and set the game/chat balance
+ - **Audio configs** - list presets and custom configs, read and change the selected
+ config of each channel
+ - **Redirections** - route each channel to a device, toggle channels on the
+ streamer mixes, control stream monitoring ("hear what the audience hears")
+ - **Audio devices** - list physical and Sonar virtual devices
+ - **App routing** - see which application plays on which channel, and move them
+ - **Events** - typed .NET events for all of the above, including changes made from
+ the Sonar UI, hardware wheels, or Windows volume keys. Automatic reconnection
+ when GG restarts.
## Getting Started
-To get started, you only need to create a Sonar Object.
-`````csharp
-// Create Sonar object
-SonarBridge sonarManager = new SonarBridge();
+Requires Windows with [SteelSeries GG](https://steelseries.com/gg) installed and
+Sonar enabled. Targets .NET 8 and .NET 10.
+```csharp
+using SteelSeriesAPI.Sonar;
+using SteelSeriesAPI.Sonar.Enums;
-// Wait for GG to start before continuing
-sonarManager.WaitUntilSteelSeriesStarted();
+using var sonar = new SonarClient();
-// Wait for sonar to start before continuing
-sonarManager.WaitUntilSonarStarted();
+// Read and control the mixer
+Mode mode = await sonar.Mode.GetAsync();
+await sonar.VolumeSettings.SetVolumeAsync(Channel.Game, 0.5);
+await sonar.VolumeSettings.SetMuteAsync(Channel.Chat, true);
+await sonar.ChatMix.SetAsync(-0.3); // towards game
-// Start listening to Sonar Events (optional and require admin rights)
-sonarManager.StartListener();
-sonarManager.SonarEventManager.OnSonarModeChange += OnModeChangeHandler; // Register event
+// List configs and select one
+var configs = await sonar.Configs.GetAllAsync(Channel.Game);
+await sonar.Configs.SelectAsync(configs[0].Id);
-Mode currentMode = sonarManager.Mode.Get(); // Returns the current mode
-sonarManager.VolumeSettings.SetVolume(0.5, Device.Game); // Set the Game Device volume
-...
-`````
-For more example, you can check the [Sample](SteelSeriesAPI.Sample/Program.cs) and the [Tests](SteelSeriesAPI.Tests/Program.cs) folders.
-If you need any sort of Documentation, go check the [Repo's Wiki](https://github.com/DataNext27/SteelSeries-NET-API/wiki) for more information.
+// Route an application to another channel
+await sonar.AppRouting.RouteAppAsync(processId: 7244, Channel.Media);
+```
+
+### Listening to changes
+
+```csharp
+sonar.Events.PollingInterval = TimeSpan.FromMilliseconds(500); // enables full detection
+sonar.Events.VolumeChanged += (_, e) =>
+ Console.WriteLine($"{e.Channel}: {e.PreviousVolume:P0} -> {e.NewVolume:P0}");
+sonar.Events.ChatMixChanged += (_, e) =>
+ Console.WriteLine($"ChatMix balance: {e.Balance}");
+sonar.Events.ModeChanged += (_, e) =>
+ Console.WriteLine($"Mode: {e.PreviousMode} -> {e.NewMode}");
+
+sonar.Events.Start(); // no admin rights needed
+// ...
+await sonar.Events.StopAsync();
+```
+Events cover connection lifecycle, volumes, mode, chat mix, redirections, configs,
+and application audio sessions. See
+[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the full event table and how
+detection works under the hood.
+
+The [Sample project](SteelSeriesAPI.Sample/Program.cs) is a complete demo: it dumps
+the current Sonar state, then prints every event live.
+
+### Error handling
+
+Everything the library throws derives from `SteelSeriesException`:
+
+```csharp
+try
+{
+ await sonar.Mode.SetAsync(Mode.Streamer);
+}
+catch (SonarWrongModeException) { /* operation not available in this mode */ }
+catch (SteelSeriesException e) { /* GG not running, Sonar disabled, API changed... */ }
+```
+
+The client self-heals across GG restarts (the Sonar server changes port every
+time): addresses are rediscovered and the event stream reconnects automatically.
+
+## Migrating from 1.x
+
+Version 2.0 is a full rewrite; the API surface changed. The essentials:
+
+| V1 | V2 |
+|---|---|
+| `new SonarBridge()` | `new SonarClient()` |
+| `WaitUntilSteelSeriesStarted()` / `WaitUntilSonarStarted()` | Not needed - discovery is automatic, failures throw typed exceptions |
+| Synchronous calls (`Mode.Get()`) | Async end to end (`await Mode.GetAsync()`) |
+| `Device` enum | `Channel` enum |
+| `StartListener()` **(admin rights)** | `Events.Start()` **(no admin)** |
+| `SonarEventManager.OnSonarModeChange` | `Events.ModeChanged` (typed args with previous/new values) |
+
+Why the rewrite? V1 detected changes by sniffing network packets (hence admin
+rights) and crashed whenever GG updates changed a field. V2 uses Sonar's own
+WebSocket plus light polling, parses tolerantly (unknown fields and channels are
+skipped, never fatal), and ships tooling to catch SteelSeries-side changes early.
+
+## For contributors
+
+- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - layers, event mechanisms, and the
+ hard-earned lessons about the Sonar API
+- [CONTRIBUTING.md](docs/CONTRIBUTING.md) - setup, the manager mold, and the
+ post-GG-update checklist
+- `SteelSeriesAPI.Explorer` - the exploration tool this library was built with:
+ probe routes, dump payloads, `check` API structures against references, `verify`
+ write contracts live
## Todo
(Actually not planned as not possible, maybe one day I guess :/ )
@@ -58,5 +127,5 @@ If you need any sort of Documentation, go check the [Repo's Wiki](https://github
If anyone find a way to control these above, feel free to create a pull request or an issue
-## Projects Using This API
-- [TouchPortal SteelSeries GG Plugin](https://github.com/DataNext27/TouchPortal_SteelSeriesGG/tree/v2-api-rework) made by DataNext
+## Projects using this library
+- [TouchPortal SteelSeries GG Plugin](https://github.com/DataNext27/TouchPortal_SteelSeriesGG) by DataNext
diff --git a/SteelSeries-NET-API.sln b/SteelSeries-NET-API.sln
index c070797..f099c3d 100644
--- a/SteelSeries-NET-API.sln
+++ b/SteelSeries-NET-API.sln
@@ -1,10 +1,12 @@
Microsoft Visual Studio Solution File, Format Version 12.00
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteelSeriesAPI", "SteelSeriesAPI\SteelSeriesAPI.csproj", "{76F55E01-2CAF-4CDC-BA4D-38C249346F78}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteelSeriesAPI", "SteelSeriesAPI\SteelSeriesAPI.csproj", "{40C964DB-ABF5-482E-9264-B4C2DD3890E6}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteelSeriesAPI.Tests", "SteelSeriesAPI.Tests\SteelSeriesAPI.Tests.csproj", "{924050E0-342D-4F20-BAB7-81B01549AC46}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteelSeriesAPI.Sample", "SteelSeriesAPI.Sample\SteelSeriesAPI.Sample.csproj", "{8B4F53F6-5762-492D-87AE-EE73099805C2}"
EndProject
-Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteelSeriesAPI.Sample", "SteelSeriesAPI.Sample\SteelSeriesAPI.Sample.csproj", "{47A9BD76-FB15-494A-A279-14A3C7166318}"
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteelSeriesAPI.Tests", "SteelSeriesAPI.Tests\SteelSeriesAPI.Tests.csproj", "{C36D2792-3E44-488C-9E35-2233F369AFAB}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteelSeriesAPI.Explorer", "SteelSeriesAPI.Explorer\SteelSeriesAPI.Explorer.csproj", "{85E30B3F-AA66-486F-967C-13868A093D2D}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -12,17 +14,21 @@ Global
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
- {76F55E01-2CAF-4CDC-BA4D-38C249346F78}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {76F55E01-2CAF-4CDC-BA4D-38C249346F78}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {76F55E01-2CAF-4CDC-BA4D-38C249346F78}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {76F55E01-2CAF-4CDC-BA4D-38C249346F78}.Release|Any CPU.Build.0 = Release|Any CPU
- {924050E0-342D-4F20-BAB7-81B01549AC46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {924050E0-342D-4F20-BAB7-81B01549AC46}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {924050E0-342D-4F20-BAB7-81B01549AC46}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {924050E0-342D-4F20-BAB7-81B01549AC46}.Release|Any CPU.Build.0 = Release|Any CPU
- {47A9BD76-FB15-494A-A279-14A3C7166318}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {47A9BD76-FB15-494A-A279-14A3C7166318}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {47A9BD76-FB15-494A-A279-14A3C7166318}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {47A9BD76-FB15-494A-A279-14A3C7166318}.Release|Any CPU.Build.0 = Release|Any CPU
+ {40C964DB-ABF5-482E-9264-B4C2DD3890E6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {40C964DB-ABF5-482E-9264-B4C2DD3890E6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {40C964DB-ABF5-482E-9264-B4C2DD3890E6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {40C964DB-ABF5-482E-9264-B4C2DD3890E6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8B4F53F6-5762-492D-87AE-EE73099805C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8B4F53F6-5762-492D-87AE-EE73099805C2}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8B4F53F6-5762-492D-87AE-EE73099805C2}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8B4F53F6-5762-492D-87AE-EE73099805C2}.Release|Any CPU.Build.0 = Release|Any CPU
+ {C36D2792-3E44-488C-9E35-2233F369AFAB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {C36D2792-3E44-488C-9E35-2233F369AFAB}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {C36D2792-3E44-488C-9E35-2233F369AFAB}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {C36D2792-3E44-488C-9E35-2233F369AFAB}.Release|Any CPU.Build.0 = Release|Any CPU
+ {85E30B3F-AA66-486F-967C-13868A093D2D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {85E30B3F-AA66-486F-967C-13868A093D2D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {85E30B3F-AA66-486F-967C-13868A093D2D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {85E30B3F-AA66-486F-967C-13868A093D2D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/SteelSeriesAPI.Explorer/Program.cs b/SteelSeriesAPI.Explorer/Program.cs
new file mode 100644
index 0000000..2f5f922
--- /dev/null
+++ b/SteelSeriesAPI.Explorer/Program.cs
@@ -0,0 +1,465 @@
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar;
+using SteelSeriesAPI.Sonar.Enums;
+
+namespace SteelSeriesAPI.Explorer;
+
+///
+/// Interactive exploration and verification tool for the Sonar API. Not shipped with the library.
+/// Run without arguments for the REPL, or with one command for scripting (e.g. `dotnet run -- check`).
+///
+internal static class Program
+{
+ private static readonly string[] KnownGetRoutes =
+ [
+ "mode",
+ "volumeSettings/classic/",
+ "volumeSettings/streamer/",
+ "v1/chatMix",
+ "audioDevices",
+ "classicRedirections",
+ "streamRedirections",
+ "streamRedirections/isStreamMonitoringEnabled",
+ "AudioDeviceRouting",
+ "configs"
+ ];
+
+ private static async Task Main(string[] args)
+ {
+ using var sonar = new SonarClient();
+
+ if (args.Length > 0)
+ {
+ // Non-interactive mode: run one command and exit.
+ await ExecuteAsync(sonar, args);
+ return;
+ }
+
+ Console.WriteLine($"Sonar server: {await sonar.GetServerAddressAsync()}");
+ Console.WriteLine("Commands: get | put | probe ... | dump | check [update] | verify | ws [path] [message] | quit");
+
+ while (true)
+ {
+ Console.Write("\n> ");
+ string[] input = (Console.ReadLine() ?? "").Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (input.Length == 0) continue;
+ if (input[0].ToLowerInvariant() is "quit" or "q") return;
+
+ await ExecuteAsync(sonar, input);
+ }
+ }
+
+ private static async Task ExecuteAsync(SonarClient sonar, string[] input)
+ {
+ try
+ {
+ switch (input[0].ToLowerInvariant())
+ {
+ case "get" when input.Length == 2:
+ using (var doc = await sonar.GetRawAsync(input[1]))
+ Console.WriteLine(Pretty(doc.RootElement));
+ break;
+
+ case "put" when input.Length == 2:
+ await sonar.PutRawAsync(input[1]);
+ Console.WriteLine("OK");
+ break;
+
+ case "probe" when input.Length > 1:
+ foreach (string route in input[1..])
+ Console.WriteLine($" {route,-45} -> {await Probe(sonar, route)}");
+ break;
+
+ case "dump":
+ await Dump(sonar);
+ break;
+
+ case "check":
+ await Check(sonar, update: input.Length > 1 && input[1].Equals("update", StringComparison.OrdinalIgnoreCase));
+ break;
+
+ case "verify":
+ await Verify(sonar);
+ break;
+
+ case "ws":
+ await ListenWebSocketAsync(
+ sonar,
+ input.Length >= 2 ? input[1] : "/",
+ input.Length >= 3 ? string.Join(' ', input[2..]) : null);
+ break;
+
+ default:
+ Console.WriteLine("Unknown command.");
+ break;
+ }
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine($"{e.GetType().Name}: {e.Message}");
+ }
+ }
+
+ // ----------------------------------------------------------------
+ // check: structural regression detection across GG updates
+ // ----------------------------------------------------------------
+
+ ///
+ /// Compares the structure of every known GET route against committed reference shapes.
+ /// Values are replaced by their types, so volatile content (GUIDs, volumes, dates,
+ /// new presets) is invisible: only renamed/removed/retyped fields report a change.
+ /// Runs in streamer mode (the superset: classic responses have empty "stream" sections)
+ /// and restores the initial mode afterwards.
+ /// Run 'check update' once to create or refresh the references.
+ ///
+ private static async Task Check(SonarClient sonar, bool update)
+ {
+ Directory.CreateDirectory(ReferenceDir);
+
+ // Reference shapes are captured in streamer mode; checking in classic mode would
+ // report false "STRUCTURE CHANGED" on the mode-dependent routes (empty stream sections).
+ Mode initialMode = await sonar.Mode.GetAsync();
+ if (initialMode != Mode.Streamer)
+ {
+ Console.WriteLine("Switching to streamer mode for the check (will restore afterwards)...");
+ await sonar.Mode.SetAsync(Mode.Streamer);
+ }
+
+ int ok = 0, changed = 0, failed = 0;
+
+ try
+ {
+ foreach (string route in KnownGetRoutes)
+ {
+ string file = Path.Combine(ReferenceDir, SafeName(route) + ".shape.json");
+ try
+ {
+ using var doc = await sonar.GetRawAsync(route);
+ string shape = PrettyShape(NormalizeShape(doc.RootElement));
+
+ if (update)
+ {
+ await File.WriteAllTextAsync(file, shape);
+ Console.WriteLine($" {route,-45} UPDATED");
+ continue;
+ }
+
+ if (!File.Exists(file))
+ {
+ Console.WriteLine($" {route,-45} NO REFERENCE (run 'check update' once)");
+ changed++;
+ }
+ else if (await File.ReadAllTextAsync(file) == shape)
+ {
+ Console.WriteLine($" {route,-45} OK");
+ ok++;
+ }
+ else
+ {
+ string actualFile = file + ".actual";
+ await File.WriteAllTextAsync(actualFile, shape);
+ Console.WriteLine($" {route,-45} STRUCTURE CHANGED -> diff {Path.GetFileName(file)} vs {Path.GetFileName(actualFile)}");
+ changed++;
+ }
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine($" {route,-45} FAILED: {e.GetType().Name}: {e.Message}");
+ failed++;
+ }
+ }
+ }
+ finally
+ {
+ if (initialMode != Mode.Streamer)
+ {
+ Console.WriteLine($"Restoring initial mode ({initialMode})...");
+ await sonar.Mode.SetAsync(initialMode);
+ }
+ }
+
+ Console.WriteLine(update
+ ? $"\nReference shapes written to {ReferenceDir} - commit them."
+ : $"\n{ok} OK, {changed} changed, {failed} failed." +
+ (changed + failed == 0 ? " The API structure is compatible with this library." : ""));
+ }
+
+ ///
+ /// Reduces a JSON payload to its structure: values become type names, object keys
+ /// are sorted, and array elements are collapsed to their distinct shapes.
+ /// The result is itself valid JSON, so it can be pretty-printed for diffing.
+ ///
+ internal static string NormalizeShape(JsonElement element) => element.ValueKind switch
+ {
+ JsonValueKind.Object => "{" + string.Join(",", element.EnumerateObject()
+ .OrderBy(p => p.Name, StringComparer.Ordinal)
+ .Select(p => $"{JsonSerializer.Serialize(p.Name)}:{NormalizeShape(p.Value)}")) + "}",
+
+ JsonValueKind.Array => "[" + string.Join(",", element.EnumerateArray()
+ .Select(NormalizeShape)
+ .Distinct()
+ .OrderBy(s => s, StringComparer.Ordinal)) + "]",
+
+ JsonValueKind.String => "\"string\"",
+ JsonValueKind.Number => "\"number\"",
+ JsonValueKind.True or JsonValueKind.False => "\"boolean\"",
+ JsonValueKind.Null => "\"null\"",
+ _ => "\"unknown\""
+ };
+
+ private static string PrettyShape(string shapeJson)
+ {
+ using var doc = JsonDocument.Parse(shapeJson);
+ return Pretty(doc.RootElement);
+ }
+
+ // ----------------------------------------------------------------
+ // verify: live write round-trips against the real API
+ // ----------------------------------------------------------------
+
+ /// Marks a verify step as skipped rather than failed.
+ private sealed class SkipException(string message) : Exception(message);
+
+ ///
+ /// Exercises every write route with a reversible round-trip (set, read back, restore).
+ /// Run after a GG update to confirm the write contracts still hold.
+ ///
+ private static async Task Verify(SonarClient sonar)
+ {
+ int pass = 0, fail = 0, skip = 0;
+
+ async Task Step(string name, Func action)
+ {
+ try
+ {
+ await action();
+ Console.WriteLine($" PASS {name}");
+ pass++;
+ }
+ catch (SkipException e)
+ {
+ Console.WriteLine($" SKIP {name} ({e.Message})");
+ skip++;
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine($" FAIL {name}: {e.GetType().Name}: {e.Message}");
+ fail++;
+ }
+ }
+
+ Console.WriteLine("Running live write round-trips (state is restored after each step)...\n");
+
+ Mode initialMode = await sonar.Mode.GetAsync();
+
+ // ---- classic-mode steps ----
+ await Step("Switch to Classic mode", () => sonar.Mode.SetAsync(Mode.Classic));
+
+ await Step("Classic volume round-trip", async () =>
+ {
+ var before = await sonar.VolumeSettings.GetAsync(Channel.Game);
+ double target = Math.Abs(before.Volume - 0.42) < 0.01 ? 0.50 : 0.42;
+
+ await sonar.VolumeSettings.SetVolumeAsync(Channel.Game, target);
+ var after = await sonar.VolumeSettings.GetAsync(Channel.Game);
+ if (Math.Abs(after.Volume - target) > 0.005)
+ throw new Exception($"read back {after.Volume}, expected {target}");
+
+ await sonar.VolumeSettings.SetVolumeAsync(Channel.Game, before.Volume);
+ });
+
+ await Step("Classic mute round-trip", async () =>
+ {
+ var before = await sonar.VolumeSettings.GetAsync(Channel.Game);
+
+ await sonar.VolumeSettings.SetMuteAsync(Channel.Game, !before.Muted);
+ var after = await sonar.VolumeSettings.GetAsync(Channel.Game);
+ if (after.Muted == before.Muted)
+ throw new Exception("mute state did not change");
+
+ await sonar.VolumeSettings.SetMuteAsync(Channel.Game, before.Muted);
+ });
+
+ await Step("ChatMix round-trip", async () =>
+ {
+ var before = await sonar.ChatMix.GetAsync();
+ if (!string.Equals(before.State, "enabled", StringComparison.OrdinalIgnoreCase))
+ throw new SkipException($"chat mix state is '{before.State}'");
+
+ double target = Math.Abs(before.Balance) < 0.01 ? 0.20 : 0.00;
+ await sonar.ChatMix.SetAsync(target);
+ var after = await sonar.ChatMix.GetAsync();
+ if (Math.Abs(after.Balance - target) > 0.005)
+ throw new Exception($"read back {after.Balance}, expected {target}");
+
+ await sonar.ChatMix.SetAsync(before.Balance);
+ });
+
+ await Step("Config re-select", async () =>
+ {
+ // Selecting the already-selected config exercises the route without changing anything.
+ var selected = await sonar.Configs.GetSelectedAsync(Channel.Game)
+ ?? throw new SkipException("no selected config reported for Game");
+ await sonar.Configs.SelectAsync(selected.Id);
+ });
+
+ // ---- streamer-mode steps ----
+ await Step("Switch to Streamer mode", () => sonar.Mode.SetAsync(Mode.Streamer));
+
+ await Step("Streamer volume round-trip", async () =>
+ {
+ var before = await sonar.VolumeSettings.GetAsync(Channel.Game, Mix.Personal);
+ double target = Math.Abs(before.Volume - 0.42) < 0.01 ? 0.50 : 0.42;
+
+ await sonar.VolumeSettings.SetVolumeAsync(Channel.Game, Mix.Personal, target);
+ var after = await sonar.VolumeSettings.GetAsync(Channel.Game, Mix.Personal);
+ if (Math.Abs(after.Volume - target) > 0.005)
+ throw new Exception($"read back {after.Volume}, expected {target}");
+
+ await sonar.VolumeSettings.SetVolumeAsync(Channel.Game, Mix.Personal, before.Volume);
+ });
+
+ await Step("Stream monitoring round-trip", async () =>
+ {
+ bool before = await sonar.Redirections.GetStreamMonitoringEnabledAsync();
+
+ await sonar.Redirections.SetStreamMonitoringEnabledAsync(!before);
+ await Task.Delay(150); // give Sonar a beat before reading back
+ if (await sonar.Redirections.GetStreamMonitoringEnabledAsync() == before)
+ throw new Exception("monitoring state did not change");
+
+ await sonar.Redirections.SetStreamMonitoringEnabledAsync(before);
+ });
+
+ await Step("Mix channel toggle round-trip", async () =>
+ {
+ var state = await sonar.Redirections.GetStreamRedirectionsAsync();
+ var personal = state.Personal ?? throw new SkipException("personal mix absent from response");
+ if (!personal.EnabledChannels.TryGetValue(Channel.Media, out bool before))
+ throw new SkipException("Media channel absent from personal mix");
+
+ await sonar.Redirections.SetMixChannelEnabledAsync(Mix.Personal, Channel.Media, !before);
+ await Task.Delay(150);
+ var after = await sonar.Redirections.GetStreamRedirectionsAsync();
+ if (after.Personal?.EnabledChannels.GetValueOrDefault(Channel.Media) == before)
+ throw new Exception("mix toggle did not change");
+
+ await sonar.Redirections.SetMixChannelEnabledAsync(Mix.Personal, Channel.Media, before);
+ });
+
+ await Step($"Restore initial mode ({initialMode})", () => sonar.Mode.SetAsync(initialMode));
+
+ Console.WriteLine($"\n{pass} passed, {fail} failed, {skip} skipped." +
+ (fail == 0 ? " All write contracts hold." : " Investigate the failures before trusting the library."));
+ }
+
+ // ----------------------------------------------------------------
+ // Existing commands
+ // ----------------------------------------------------------------
+
+ /// Tries a GET on a route and describes the outcome without throwing.
+ private static async Task Probe(SonarClient sonar, string route)
+ {
+ try
+ {
+ using var doc = await sonar.GetRawAsync(route);
+ string preview = doc.RootElement.GetRawText();
+ return $"200 OK ({preview[..Math.Min(60, preview.Length)]}...)";
+ }
+ catch (SonarWrongModeException) { return "500 WRONG MODE (route exists!)"; }
+ catch (SonarRequestException e) { return $"{e.StatusCode}"; }
+ }
+
+ /// Snapshots every known GET route into dated JSON files, for diffing across GG updates.
+ private static async Task Dump(SonarClient sonar)
+ {
+ string dir = Path.Combine(ProjectDir, "dumps", DateTime.Now.ToString("yyyy-MM-dd_HHmm"));
+ Directory.CreateDirectory(dir);
+
+ foreach (string route in KnownGetRoutes)
+ {
+ string file = Path.Combine(dir, SafeName(route) + ".json");
+ try
+ {
+ using var doc = await sonar.GetRawAsync(route);
+ await File.WriteAllTextAsync(file, Pretty(doc.RootElement));
+ Console.WriteLine($" {route,-45} -> {file}");
+ }
+ catch (Exception e)
+ {
+ Console.WriteLine($" {route,-45} -> FAILED: {e.Message}");
+ }
+ }
+ }
+
+ /// Connects to a WebSocket path on the Sonar server and prints every incoming message.
+ private static async Task ListenWebSocketAsync(SonarClient sonar, string path, string? initialMessage)
+ {
+ Uri http = await sonar.GetServerAddressAsync();
+ Uri wsUri = new UriBuilder(http) { Scheme = "ws", Path = path }.Uri;
+ Console.WriteLine($"Connecting to {wsUri} ... (press Enter to stop)");
+
+ using var ws = new ClientWebSocket();
+ await ws.ConnectAsync(wsUri, CancellationToken.None);
+ Console.WriteLine("Connected! Now interact with the Sonar UI (sliders, mute, mode...)");
+
+ using var cts = new CancellationTokenSource();
+
+ if (initialMessage is not null)
+ {
+ await ws.SendAsync(Encoding.UTF8.GetBytes(initialMessage),
+ WebSocketMessageType.Text, endOfMessage: true, cts.Token);
+ Console.WriteLine($"Sent: {initialMessage}");
+ }
+
+ _ = Task.Run(() => { Console.ReadLine(); cts.Cancel(); });
+
+ var buffer = new byte[64 * 1024];
+ using var message = new MemoryStream();
+
+ try
+ {
+ while (ws.State == WebSocketState.Open)
+ {
+ var result = await ws.ReceiveAsync(buffer, cts.Token);
+ if (result.MessageType == WebSocketMessageType.Close) break;
+
+ message.Write(buffer, 0, result.Count);
+ if (!result.EndOfMessage) continue;
+
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss.fff}] ({result.MessageType}) {Encoding.UTF8.GetString(message.ToArray())}");
+ message.SetLength(0);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ Console.WriteLine("Stopped listening.");
+ }
+ }
+
+ // ----------------------------------------------------------------
+ // Helpers
+ // ----------------------------------------------------------------
+
+ /// The Explorer project directory, so dumps and references survive `dotnet clean`.
+ private static string ProjectDir { get; } = FindProjectDir();
+
+ private static string ReferenceDir => Path.Combine(ProjectDir, "reference-shapes");
+
+ private static string FindProjectDir()
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ while (dir is not null && dir.GetFiles("*.csproj").Length == 0)
+ dir = dir.Parent;
+ return dir?.FullName ?? Directory.GetCurrentDirectory();
+ }
+
+ private static string SafeName(string route) =>
+ route.Trim('/').Replace('/', '_').Replace('?', '_');
+
+ private static string Pretty(JsonElement element) =>
+ JsonSerializer.Serialize(element, new JsonSerializerOptions { WriteIndented = true });
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Explorer/SteelSeriesAPI.Explorer.csproj b/SteelSeriesAPI.Explorer/SteelSeriesAPI.Explorer.csproj
new file mode 100644
index 0000000..81ea59d
--- /dev/null
+++ b/SteelSeriesAPI.Explorer/SteelSeriesAPI.Explorer.csproj
@@ -0,0 +1,14 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+
+
+
+
+
+
+
diff --git a/SteelSeriesAPI.Explorer/reference-shapes/AudioDeviceRouting.shape.json b/SteelSeriesAPI.Explorer/reference-shapes/AudioDeviceRouting.shape.json
new file mode 100644
index 0000000..e5e21b0
--- /dev/null
+++ b/SteelSeriesAPI.Explorer/reference-shapes/AudioDeviceRouting.shape.json
@@ -0,0 +1,19 @@
+[
+ {
+ "audioSessions": [
+ {
+ "displayName": "string",
+ "id": "string",
+ "isRoutingErrorProne": "boolean",
+ "isSystemSound": "boolean",
+ "processId": "number",
+ "processName": "string",
+ "routingErrorDetected": "boolean",
+ "state": "string"
+ }
+ ],
+ "dataFlow": "string",
+ "deviceId": "string",
+ "role": "string"
+ }
+]
\ No newline at end of file
diff --git a/SteelSeriesAPI.Explorer/reference-shapes/audioDevices.shape.json b/SteelSeriesAPI.Explorer/reference-shapes/audioDevices.shape.json
new file mode 100644
index 0000000..443bfd5
--- /dev/null
+++ b/SteelSeriesAPI.Explorer/reference-shapes/audioDevices.shape.json
@@ -0,0 +1,13 @@
+[
+ {
+ "channels": "number",
+ "dataFlow": "string",
+ "defaultRole": "string",
+ "friendlyName": "string",
+ "fwUpdateRequired": "boolean",
+ "id": "string",
+ "isVad": "boolean",
+ "role": "string",
+ "state": "string"
+ }
+]
\ No newline at end of file
diff --git a/SteelSeriesAPI.Explorer/reference-shapes/classicRedirections.shape.json b/SteelSeriesAPI.Explorer/reference-shapes/classicRedirections.shape.json
new file mode 100644
index 0000000..3365472
--- /dev/null
+++ b/SteelSeriesAPI.Explorer/reference-shapes/classicRedirections.shape.json
@@ -0,0 +1,7 @@
+[
+ {
+ "deviceId": "string",
+ "id": "string",
+ "isRunning": "boolean"
+ }
+]
\ No newline at end of file
diff --git a/SteelSeriesAPI.Explorer/reference-shapes/configs.shape.json b/SteelSeriesAPI.Explorer/reference-shapes/configs.shape.json
new file mode 100644
index 0000000..db79970
--- /dev/null
+++ b/SteelSeriesAPI.Explorer/reference-shapes/configs.shape.json
@@ -0,0 +1,767 @@
+[
+ {
+ "createdAt": "string",
+ "data": {
+ "acousticEchoCancelingState": "boolean",
+ "automaticNoiseGateState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "globalEnableState": "boolean",
+ "impactNoiseReductionState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "noiseCancelingState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "noiseGateState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "noiseReductionState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "parametricEQ": {
+ "enabled": "boolean",
+ "filter1": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter10": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter2": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter3": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter4": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter5": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter6": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter7": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter8": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter9": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ }
+ },
+ "volumeStabilizerState": {
+ "enabled": "boolean",
+ "value": "number"
+ }
+ },
+ "defaultData": {
+ "acousticEchoCancelingState": "boolean",
+ "automaticNoiseGateState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "globalEnableState": "boolean",
+ "impactNoiseReductionState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "noiseCancelingState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "noiseGateState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "noiseReductionState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "parametricEQ": {
+ "enabled": "boolean",
+ "filter1": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter10": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter2": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter3": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter4": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter5": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter6": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter7": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter8": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter9": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ }
+ },
+ "volumeStabilizerState": {
+ "enabled": "boolean",
+ "value": "number"
+ }
+ },
+ "favoritePosition": "number",
+ "id": "string",
+ "image": "string",
+ "isFavorite": "boolean",
+ "isPreset": "boolean",
+ "name": "string",
+ "releaseVersion": "null",
+ "schemaVersion": "number",
+ "updatedAt": "string",
+ "virtualAudioDevice": "string"
+ },
+ {
+ "createdAt": "string",
+ "data": {
+ "bassBoostState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "formFactor": "string",
+ "generalGain": "number",
+ "globalEnableState": "boolean",
+ "parametricEQ": {
+ "enabled": "boolean",
+ "filter1": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter10": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter2": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter3": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter4": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter5": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter6": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter7": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter8": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter9": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ }
+ },
+ "reverbGainDB": "number",
+ "smartVolume": {
+ "enabled": "boolean",
+ "loudness": "string",
+ "volumeLevel": "number"
+ },
+ "trebleBoostState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "virtualSurroundChannels": {
+ "center": {
+ "gain": "number",
+ "position": "number"
+ },
+ "frontLeft": {
+ "gain": "number",
+ "position": "number"
+ },
+ "frontRight": {
+ "gain": "number",
+ "position": "number"
+ },
+ "rearLeft": {
+ "gain": "number",
+ "position": "number"
+ },
+ "rearRight": {
+ "gain": "number",
+ "position": "number"
+ },
+ "sideLeft": {
+ "gain": "number",
+ "position": "number"
+ },
+ "sideRight": {
+ "gain": "number",
+ "position": "number"
+ },
+ "subWoofer": {
+ "gain": "number",
+ "position": "number"
+ }
+ },
+ "virtualSurroundState": "boolean",
+ "voiceClarityState": {
+ "enabled": "boolean",
+ "value": "number"
+ }
+ },
+ "defaultData": {
+ "bassBoostState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "formFactor": "string",
+ "generalGain": "number",
+ "globalEnableState": "boolean",
+ "parametricEQ": {
+ "enabled": "boolean",
+ "filter1": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter10": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter2": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter3": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter4": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter5": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter6": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter7": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter8": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter9": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ }
+ },
+ "reverbGainDB": "number",
+ "smartVolume": {
+ "enabled": "boolean",
+ "loudness": "string",
+ "volumeLevel": "number"
+ },
+ "trebleBoostState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "virtualSurroundChannels": {
+ "center": {
+ "gain": "number",
+ "position": "number"
+ },
+ "frontLeft": {
+ "gain": "number",
+ "position": "number"
+ },
+ "frontRight": {
+ "gain": "number",
+ "position": "number"
+ },
+ "rearLeft": {
+ "gain": "number",
+ "position": "number"
+ },
+ "rearRight": {
+ "gain": "number",
+ "position": "number"
+ },
+ "sideLeft": {
+ "gain": "number",
+ "position": "number"
+ },
+ "sideRight": {
+ "gain": "number",
+ "position": "number"
+ },
+ "subWoofer": {
+ "gain": "number",
+ "position": "number"
+ }
+ },
+ "virtualSurroundState": "boolean",
+ "voiceClarityState": {
+ "enabled": "boolean",
+ "value": "number"
+ }
+ },
+ "favoritePosition": "number",
+ "id": "string",
+ "image": "string",
+ "isFavorite": "boolean",
+ "isPreset": "boolean",
+ "name": "string",
+ "releaseVersion": "null",
+ "schemaVersion": "number",
+ "updatedAt": "string",
+ "virtualAudioDevice": "string"
+ },
+ {
+ "createdAt": "string",
+ "data": {
+ "bassBoostState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "formFactor": "string",
+ "generalGain": "number",
+ "globalEnableState": "boolean",
+ "parametricEQ": {
+ "enabled": "boolean",
+ "filter1": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter10": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter2": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter3": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter4": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter5": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter6": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter7": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter8": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter9": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ }
+ },
+ "reverbGainDB": "number",
+ "smartVolume": {
+ "enabled": "boolean",
+ "loudness": "string",
+ "volumeLevel": "number"
+ },
+ "trebleBoostState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "virtualSurroundChannels": {
+ "center": {
+ "gain": "number",
+ "position": "number"
+ },
+ "frontLeft": {
+ "gain": "number",
+ "position": "number"
+ },
+ "frontRight": {
+ "gain": "number",
+ "position": "number"
+ },
+ "rearLeft": {
+ "gain": "number",
+ "position": "number"
+ },
+ "rearRight": {
+ "gain": "number",
+ "position": "number"
+ },
+ "sideLeft": {
+ "gain": "number",
+ "position": "number"
+ },
+ "sideRight": {
+ "gain": "number",
+ "position": "number"
+ },
+ "subWoofer": {
+ "gain": "number",
+ "position": "number"
+ }
+ },
+ "virtualSurroundState": "boolean",
+ "voiceClarityState": {
+ "enabled": "boolean",
+ "value": "number"
+ }
+ },
+ "defaultData": {
+ "bassBoostState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "formFactor": "string",
+ "generalGain": "number",
+ "globalEnableState": "boolean",
+ "parametricEQ": {
+ "enabled": "boolean",
+ "filter1": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter10": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter2": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter3": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter4": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter5": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter6": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter7": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter8": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ },
+ "filter9": {
+ "enabled": "boolean",
+ "frequency": "number",
+ "gain": "number",
+ "qFactor": "number",
+ "type": "string"
+ }
+ },
+ "reverbGainDB": "number",
+ "smartVolume": {
+ "enabled": "boolean",
+ "loudness": "string",
+ "volumeLevel": "number"
+ },
+ "trebleBoostState": {
+ "enabled": "boolean",
+ "value": "number"
+ },
+ "virtualSurroundChannels": {
+ "center": {
+ "gain": "number",
+ "position": "number"
+ },
+ "frontLeft": {
+ "gain": "number",
+ "position": "number"
+ },
+ "frontRight": {
+ "gain": "number",
+ "position": "number"
+ },
+ "rearLeft": {
+ "gain": "number",
+ "position": "number"
+ },
+ "rearRight": {
+ "gain": "number",
+ "position": "number"
+ },
+ "sideLeft": {
+ "gain": "number",
+ "position": "number"
+ },
+ "sideRight": {
+ "gain": "number",
+ "position": "number"
+ },
+ "subWoofer": {
+ "gain": "number",
+ "position": "number"
+ }
+ },
+ "virtualSurroundState": "boolean",
+ "voiceClarityState": {
+ "enabled": "boolean",
+ "value": "number"
+ }
+ },
+ "favoritePosition": "number",
+ "id": "string",
+ "image": "string",
+ "isFavorite": "boolean",
+ "isPreset": "boolean",
+ "name": "string",
+ "releaseVersion": "string",
+ "schemaVersion": "number",
+ "updatedAt": "string",
+ "virtualAudioDevice": "string"
+ }
+]
\ No newline at end of file
diff --git a/SteelSeriesAPI.Explorer/reference-shapes/mode.shape.json b/SteelSeriesAPI.Explorer/reference-shapes/mode.shape.json
new file mode 100644
index 0000000..1f13d5d
--- /dev/null
+++ b/SteelSeriesAPI.Explorer/reference-shapes/mode.shape.json
@@ -0,0 +1 @@
+"string"
\ No newline at end of file
diff --git a/SteelSeriesAPI.Explorer/reference-shapes/streamRedirections.shape.json b/SteelSeriesAPI.Explorer/reference-shapes/streamRedirections.shape.json
new file mode 100644
index 0000000..60eebfc
--- /dev/null
+++ b/SteelSeriesAPI.Explorer/reference-shapes/streamRedirections.shape.json
@@ -0,0 +1,19 @@
+[
+ {
+ "deviceId": "string",
+ "isRunning": "boolean",
+ "status": [],
+ "streamRedirectionId": "string"
+ },
+ {
+ "deviceId": "string",
+ "isRunning": "boolean",
+ "status": [
+ {
+ "isEnabled": "boolean",
+ "role": "string"
+ }
+ ],
+ "streamRedirectionId": "string"
+ }
+]
\ No newline at end of file
diff --git a/SteelSeriesAPI.Explorer/reference-shapes/streamRedirections_isStreamMonitoringEnabled.shape.json b/SteelSeriesAPI.Explorer/reference-shapes/streamRedirections_isStreamMonitoringEnabled.shape.json
new file mode 100644
index 0000000..ccaf553
--- /dev/null
+++ b/SteelSeriesAPI.Explorer/reference-shapes/streamRedirections_isStreamMonitoringEnabled.shape.json
@@ -0,0 +1 @@
+"boolean"
\ No newline at end of file
diff --git a/SteelSeriesAPI.Explorer/reference-shapes/v1_chatMix.shape.json b/SteelSeriesAPI.Explorer/reference-shapes/v1_chatMix.shape.json
new file mode 100644
index 0000000..7c4c011
--- /dev/null
+++ b/SteelSeriesAPI.Explorer/reference-shapes/v1_chatMix.shape.json
@@ -0,0 +1,5 @@
+{
+ "balance": "number",
+ "id": "string",
+ "state": "string"
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Explorer/reference-shapes/volumeSettings_classic.shape.json b/SteelSeriesAPI.Explorer/reference-shapes/volumeSettings_classic.shape.json
new file mode 100644
index 0000000..5be8913
--- /dev/null
+++ b/SteelSeriesAPI.Explorer/reference-shapes/volumeSettings_classic.shape.json
@@ -0,0 +1,46 @@
+{
+ "devices": {
+ "aux": {
+ "classic": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "stream": {}
+ },
+ "chatCapture": {
+ "classic": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "stream": {}
+ },
+ "chatRender": {
+ "classic": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "stream": {}
+ },
+ "game": {
+ "classic": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "stream": {}
+ },
+ "media": {
+ "classic": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "stream": {}
+ }
+ },
+ "masters": {
+ "classic": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "stream": {}
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Explorer/reference-shapes/volumeSettings_streamer.shape.json b/SteelSeriesAPI.Explorer/reference-shapes/volumeSettings_streamer.shape.json
new file mode 100644
index 0000000..90b7a70
--- /dev/null
+++ b/SteelSeriesAPI.Explorer/reference-shapes/volumeSettings_streamer.shape.json
@@ -0,0 +1,100 @@
+{
+ "devices": {
+ "aux": {
+ "classic": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "stream": {
+ "monitoring": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "streaming": {
+ "muted": "boolean",
+ "volume": "number"
+ }
+ }
+ },
+ "chatCapture": {
+ "classic": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "stream": {
+ "monitoring": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "streaming": {
+ "muted": "boolean",
+ "volume": "number"
+ }
+ }
+ },
+ "chatRender": {
+ "classic": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "stream": {
+ "monitoring": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "streaming": {
+ "muted": "boolean",
+ "volume": "number"
+ }
+ }
+ },
+ "game": {
+ "classic": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "stream": {
+ "monitoring": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "streaming": {
+ "muted": "boolean",
+ "volume": "number"
+ }
+ }
+ },
+ "media": {
+ "classic": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "stream": {
+ "monitoring": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "streaming": {
+ "muted": "boolean",
+ "volume": "number"
+ }
+ }
+ }
+ },
+ "masters": {
+ "classic": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "stream": {
+ "monitoring": {
+ "muted": "boolean",
+ "volume": "number"
+ },
+ "streaming": {
+ "muted": "boolean",
+ "volume": "number"
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Sample/Program.cs b/SteelSeriesAPI.Sample/Program.cs
index 22f5b93..bfd0fcc 100644
--- a/SteelSeriesAPI.Sample/Program.cs
+++ b/SteelSeriesAPI.Sample/Program.cs
@@ -1,152 +1,211 @@
-using SteelSeriesAPI.Sonar;
-using SteelSeriesAPI.Sonar.Events;
+using Microsoft.Extensions.Logging;
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar;
using SteelSeriesAPI.Sonar.Enums;
using SteelSeriesAPI.Sonar.Models;
namespace SteelSeriesAPI.Sample;
-class Program
+///
+/// Demo and manual test bench for the SteelSeries-NET-API library.
+/// Reads the full current Sonar state, then listens to all events until Enter is pressed.
+///
+internal static class Program
{
- static void Main(string[] args)
+ private static async Task Main()
{
- // Create a Sonar Object to control Sonar
- SonarBridge sonarManager = new SonarBridge();
-
- // Wait until GG is started before continuing
- sonarManager.WaitUntilSteelSeriesStarted();
- // Wait until GG and Sonar are both started before continuing
- sonarManager.WaitUntilSonarStarted();
-
- // If you want to detect changes made on GG, you can use the listener (require admin rights)
- sonarManager.StartListener();
- // Then you register the events you need (I've put them all to demonstrate)
- sonarManager.Events.OnSonarModeChange += OnModeChangeHandler; // When the mode gets changed
- sonarManager.Events.OnSonarVolumeChange += OnVolumeChangeHandler; // When the volume of a Sonar Channel or Mix gets changed
- sonarManager.Events.OnSonarMuteChange += OnMuteChangeHandler; // When a Sonar Channel or Mix gets muted or unmuted
- sonarManager.Events.OnSonarConfigChange += OnConfigChangeHandler; // When a new config is set to a Sonar Channel
- sonarManager.Events.OnSonarChatMixChange += OnChatMixChangeHandler; // When the ChatMix value gets changed
- sonarManager.Events.OnSonarPlaybackDeviceChange += OnPlaybackDeviceChangeHandler; // When the Redirection Channel of a Sonar Channel is changed
- sonarManager.Events.OnSonarRoutedProcessChange += OnRoutedProcessChangeHandler; // When a routed process gets routed to a new Sonar Channel
- sonarManager.Events.OnSonarMixChange += OnMixChangeHandler; // When the Mix of a Sonar Channem gets activated or deactivated
- sonarManager.Events.OnSonarAudienceMonitoringChange += OnAudienceMonitoringChangeHandler; // When the Audience Monitoring gets muted or unmuted
-
- // Get current sonar mode
- Mode mode = sonarManager.Mode.Get();
- // Change sonar mode to Streamer
- sonarManager.Mode.Set(Mode.STREAMER);
-
- // Get current volume of a Sonar Channel
- double vol = sonarManager.VolumeSettings.GetVolume(Channel.MEDIA);
- // Get current volume of a Sonar Mix
- double vol2 = sonarManager.VolumeSettings.GetVolume(Channel.CHAT, Mix.STREAM);
- // Set the volume of a Sonar Channel
- sonarManager.VolumeSettings.SetVolume(0.75, Channel.GAME);
- // Set the volume of a Sonar Mix
- sonarManager.VolumeSettings.SetVolume(0.1, Channel.MEDIA, Mix.PERSONAL);
-
- // Get the current mute state of a Sonar Channel
- bool state = sonarManager.VolumeSettings.GetMute(Channel.CHAT);
- bool state2 = sonarManager.VolumeSettings.GetMute(Channel.MASTER, Mix.PERSONAL);
- // Set the current mute state of a Sonar Channel
- sonarManager.VolumeSettings.SetMute(true, Channel.CHAT); // Mute chat
-
- // Get audio configs
- List allConfigs = sonarManager.Configurations.GetAllAudioConfigurations().ToList(); // Return all configs (A SonarAudioConfiguration contains an Id, a Name and an AssociatedChannel)
- List mediaConfigs = sonarManager.Configurations.GetAudioConfigurations(Channel.MEDIA).ToList(); // Return all configs of a Sonar Channel
- SonarAudioConfiguration currentConfig = sonarManager.Configurations.GetSelectedAudioConfiguration(Channel.MEDIA); // Return the currently used config of a Sonar Channel
- // Set the config of a Sonar Channel
- sonarManager.Configurations.SetConfigByName(Channel.MEDIA, "Podcast"); // Using its name
- sonarManager.Configurations.SetConfig(currentConfig); // Using directly the config object
- sonarManager.Configurations.SetConfig(currentConfig.Id); // Or Using its id (no need to precise which Sonar Channel, one id = one config = one Sonar Channel)
-
- // Get ChatMix info
- double chatMixBalance = sonarManager.ChatMix.GetBalance(); // The ChatMix value between -1 and 1
- bool chatMixState = sonarManager.ChatMix.GetState(); // If ChatMix is usable or not
- // Change ChatMix value
- sonarManager.ChatMix.SetBalance(0.5); // 0.5 is halfway to Chat
-
- // Get playback devices (Windows devices)
- List playbackDevices = sonarManager.PlaybackDevices.GetAllPlaybackDevices().ToList(); // All playback devices
- List inputDevices = sonarManager.PlaybackDevices.GetInputPlaybackDevices().ToList(); // Input devices (Mics...)
- List outputDevices = sonarManager.PlaybackDevices.GetOutputPlaybackDevices().ToList(); // Output devices (headset, speakers...)
- PlaybackDevice gameDevice = sonarManager.PlaybackDevices.GetPlaybackDevice(Channel.GAME); // Get the currently used Playback device of a Channel
- sonarManager.PlaybackDevices.GetPlaybackDevice(Mix.STREAM); // Get the currently used Playback device of a Mix
- sonarManager.PlaybackDevices.GetPlaybackDevice(Channel.MIC, Mode.STREAMER); // Get the currently used Playback device of the streamer mode Mic
- sonarManager.PlaybackDevices.GetPlaybackDevice("{0.0.0.00000000}.{192b4f5b-9cc1-4eb2-b752-c5e15b99d548}"); // Get a playback device from its id
- // Change playback devices
- sonarManager.PlaybackDevices.SetPlaybackDevice(gameDevice, Channel.GAME); // Using the playback device object
- sonarManager.PlaybackDevices.SetPlaybackDevice("{0.0.0.00000000}.{192b4f5b-9cc1-4eb2-b752-c5e15b99d548}", Channel.AUX); // Using the playback device ID
-
- // Get the mixes states
- sonarManager.Mix.GetState(Channel.MEDIA, Mix.PERSONAL);
- // Change the mixes states
- sonarManager.Mix.Activate(Channel.MEDIA, Mix.PERSONAL);
- sonarManager.Mix.Deactivate(Channel.CHAT, Mix.STREAM);
- sonarManager.Mix.SetState(false, Channel.MEDIA, Mix.PERSONAL); // Same as deactivating here
-
- // Get Audience Monitoring state
- sonarManager.AudienceMonitoring.GetState();
- // Change Audience Monitoring state
- sonarManager.AudienceMonitoring.SetState(false);
-
- // Get all routed processes whether they are active, inactive or expired
- List allProcesses = sonarManager.RoutedProcesses.GetAllRoutedProcesses().ToList();
- // Get all active routed processes (currently in use)
- List allActiveProcesses = sonarManager.RoutedProcesses.GetAllActiveRoutedProcesses().ToList();
- // Same but for a specific channel
- List gameProcesses = sonarManager.RoutedProcesses.GetRoutedProcesses(Channel.GAME).ToList(); // Will surely return apps like Minecraft...
- List mediaActiveProcesses = sonarManager.RoutedProcesses.GetActiveRoutedProcesses(Channel.MEDIA).ToList(); // Will surely return apps like Google Chrome or Spotify
- // Same idea but by giving the ID of an audio process
- sonarManager.RoutedProcesses.GetRoutedProcessesById(2063);
- sonarManager.RoutedProcesses.GetActiveRoutedProcessesById(10548);
- // Route a process to a Sonar Channel using the RoutedProcess object
- sonarManager.RoutedProcesses.RouteProcessToChannel(mediaActiveProcesses[0], Channel.AUX);
- // Route a process to a Sonar Channel using its process ID (pid)
- sonarManager.RoutedProcesses.RouteProcessToChannel(15482, Channel.MEDIA);
- }
-
- static void OnModeChangeHandler(object? sender, SonarModeEvent eventArgs)
- {
- Console.WriteLine("Received Mode Event : " + eventArgs.NewMode);
- }
+ // Set to LogLevel.Debug to see discovery, reconnections and refresh internals
+ using var loggerFactory = LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Information));
+ var logger = loggerFactory.CreateLogger("Sample");
- static void OnVolumeChangeHandler(object? sender, SonarVolumeEvent eventArgs)
- {
- Console.WriteLine("Received Volume Event : " + eventArgs.Volume + ", " + eventArgs.Mode + ", " + eventArgs.Channel + ", " + eventArgs.Mix);
- }
+ using var sonar = new SonarClient(logger);
- static void OnMuteChangeHandler(object? sender, SonarMuteEvent eventArgs)
- {
- Console.WriteLine("Received Mute Event : " + eventArgs.Muted + ", " + eventArgs.Mode + ", " + eventArgs.Channel + ", " + eventArgs.Mix);
- }
+ try
+ {
+ await PrintCurrentStateAsync(sonar);
+ }
+ catch (SteelSeriesException e)
+ {
+ Console.WriteLine($"Could not read Sonar state: {e.Message}");
+ Console.WriteLine("Make sure SteelSeries GG is running, then restart the sample.");
+ return;
+ }
- static void OnConfigChangeHandler(object? sender, SonarConfigEvent eventArgs)
- {
- Console.WriteLine("Received Config Event : " + eventArgs.ConfigId);
- }
+ SubscribeToEvents(sonar);
- static void OnChatMixChangeHandler(object? sender, SonarChatMixEvent eventArgs)
- {
- Console.WriteLine("Received ChatMix Event : " + eventArgs.Balance);
- }
+ sonar.Events.PollingInterval = TimeSpan.FromMilliseconds(500);
+ sonar.Events.Start();
- static void OnPlaybackDeviceChangeHandler(object? sender, SonarPlaybackDeviceEvent eventArgs)
- {
- Console.WriteLine("Received Redirection Channel Event : " + eventArgs.PlaybackDeviceId + ", " + eventArgs.Mode + ", " + eventArgs.Channel + ", " + eventArgs.Mix);
- }
-
- static void OnRoutedProcessChangeHandler(object? sender, SonarRoutedProcessEvent eventArgs)
- {
- Console.WriteLine("Received Routed Process Event : " + eventArgs.ProcessId + ", " + eventArgs.NewChannel);
+ Console.WriteLine();
+ Console.WriteLine("=== Listening to Sonar events - interact with the Sonar UI, press Enter to stop ===");
+ Console.WriteLine();
+ Console.ReadLine();
+
+ await sonar.Events.StopAsync();
+ Console.WriteLine("Stopped. Bye!");
}
- static void OnMixChangeHandler(object? sender, SonarMixEvent eventArgs)
+ /// Reads and prints the current Sonar state using every typed manager.
+ private static async Task PrintCurrentStateAsync(SonarClient sonar)
{
- Console.WriteLine("Received Redirection State Event : " + eventArgs.NewState + ", " + eventArgs.Channel + ", " + eventArgs.Mix);
+ Console.WriteLine("=== Current Sonar state ===");
+
+ // --- Mode ---
+ Mode mode = await sonar.Mode.GetAsync();
+ Console.WriteLine($"Mode: {mode}");
+
+ // --- Audio devices (also used to resolve ids to names below) ---
+ var devices = await sonar.Devices.GetAllAsync();
+ var deviceNames = devices.ToDictionary(d => d.Id, d => d.Name);
+ string NameOf(string deviceId) => deviceNames.GetValueOrDefault(deviceId, deviceId);
+
+ // --- Volumes (query what is reliable in the current mode) ---
+ Channel[] channels = [Channel.Master, Channel.Game, Channel.Chat, Channel.Media, Channel.Aux, Channel.Mic];
+
+ if (mode == Mode.Classic)
+ {
+ foreach (Channel channel in channels)
+ {
+ var setting = await sonar.VolumeSettings.GetAsync(channel);
+ Console.WriteLine($" {channel,-6} volume: {setting.Volume,6:P0} muted: {setting.Muted}");
+ }
+ }
+ else
+ {
+ foreach (Channel channel in channels)
+ {
+ var personal = await sonar.VolumeSettings.GetAsync(channel, Mix.Personal);
+ var stream = await sonar.VolumeSettings.GetAsync(channel, Mix.Stream);
+ Console.WriteLine(
+ $" {channel,-6} personal: {personal.Volume,6:P0} (muted: {personal.Muted}) " +
+ $"stream: {stream.Volume,6:P0} (muted: {stream.Muted})");
+ }
+ }
+
+ // --- Chat mix ---
+ var chatMix = await sonar.ChatMix.GetAsync();
+ Console.WriteLine($"ChatMix: balance {chatMix.Balance:+0.00;-0.00;0.00} (state: {chatMix.State})");
+
+ // --- Selected configs ---
+ var selected = await sonar.Configs.GetSelectedAsync();
+ Console.WriteLine("Selected configs:");
+ foreach ((Channel channel, var config) in selected.OrderBy(p => p.Key))
+ Console.WriteLine($" {channel,-6} -> {config.Name}{(config.IsPreset ? " (preset)" : "")}");
+
+ // --- Classic redirections ---
+ var classicRedirections = await sonar.Redirections.GetClassicRedirectionsAsync();
+ Console.WriteLine("Classic redirections:");
+ foreach (var redirection in classicRedirections)
+ Console.WriteLine($" {redirection.Channel,-6} -> {NameOf(redirection.DeviceId)} (running: {redirection.IsRunning})");
+
+ // --- Streamer-mode redirections (meaningful values in streamer mode only) ---
+ if (mode == Mode.Streamer)
+ {
+ var streamRedirections = await sonar.Redirections.GetStreamRedirectionsAsync();
+ Console.WriteLine("Stream redirections:");
+ PrintMix(streamRedirections.Personal);
+ PrintMix(streamRedirections.Stream);
+ if (streamRedirections.Mic is { } mic)
+ Console.WriteLine($" Mic passthrough -> {NameOf(mic.DeviceId)} (running: {mic.IsRunning})");
+
+ bool monitoring = await sonar.Redirections.GetStreamMonitoringEnabledAsync();
+ Console.WriteLine($"Stream monitoring (hear the audience mix): {monitoring}");
+
+ void PrintMix(MixRedirection? mix)
+ {
+ if (mix is null) return;
+ string enabledChannels = string.Join(", ",
+ mix.EnabledChannels.Where(p => p.Value).Select(p => p.Key));
+ Console.WriteLine($" {mix.Mix,-8} mix -> {NameOf(mix.DeviceId)} (running: {mix.IsRunning}, enabled: [{enabledChannels}])");
+ }
+ }
+
+ // --- App routing: which applications play on which channel ---
+ var routings = await sonar.AppRouting.GetRoutingsAsync();
+ Console.WriteLine("App routing (active sessions):");
+ bool anySession = false;
+ foreach (var routing in routings.Where(r => r.Channel is not null && r.DataFlow == AudioDataFlow.Render))
+ {
+ foreach (var session in routing.Sessions.Where(s => !s.IsSystemSound && s.IsActive))
+ {
+ Console.WriteLine($" {routing.Channel,-6} -> {session.DisplayName} (pid {session.ProcessId})");
+ anySession = true;
+ }
+ }
+ if (!anySession)
+ Console.WriteLine(" (no application is currently playing audio)");
}
- static void OnAudienceMonitoringChangeHandler(object? sender, SonarAudienceMonitoringEvent eventArgs)
+ /// Subscribes to every event the library exposes, printing each occurrence.
+ private static void SubscribeToEvents(SonarClient sonar)
{
- Console.WriteLine("Received Audience Monitoring Event : " + eventArgs.NewState);
+ // --- Connection lifecycle (from the WebSocket loop) ---
+ sonar.Events.Connected += (_, _) =>
+ Console.WriteLine(">>> Connected to Sonar event stream");
+
+ sonar.Events.Disconnected += (_, _) =>
+ Console.WriteLine(">>> Disconnected from Sonar (GG closed? will keep retrying)");
+
+ // --- Granular, data-carrying events (most consumers should use these) ---
+ sonar.Events.VolumeChanged += (_, e) =>
+ {
+ string mix = e.Mix?.ToString() ?? "Classic";
+ if (e.MuteToggled)
+ Console.WriteLine($"[Volume] {e.Channel} ({mix}) is now {(e.IsMuted ? "MUTED" : "unmuted")}");
+ else
+ Console.WriteLine($"[Volume] {e.Channel} ({mix}): {e.PreviousVolume:P0} -> {e.NewVolume:P0}");
+ };
+
+ sonar.Events.ModeChanged += (_, e) =>
+ Console.WriteLine($"[Mode] {e.PreviousMode} -> {e.NewMode}");
+
+ sonar.Events.ChatMixChanged += (_, e) =>
+ Console.WriteLine($"[ChatMix] balance {e.Balance:+0.00;-0.00;0.00} (state: {e.State})");
+
+ sonar.Events.ClassicDeviceChanged += (_, e) =>
+ Console.WriteLine($"[Redirections] {e.Channel} routed to {e.NewDeviceId}");
+
+ sonar.Events.MixDeviceChanged += (_, e) =>
+ Console.WriteLine($"[Redirections] {e.Mix} mix routed to {e.NewDeviceId}");
+
+ sonar.Events.MixChannelToggled += (_, e) =>
+ Console.WriteLine($"[Redirections] {e.Channel} on {e.Mix} mix: {(e.IsEnabled ? "enabled" : "disabled")}");
+
+ sonar.Events.StreamMonitoringChanged += (_, e) =>
+ Console.WriteLine($"[Monitoring] {(e.IsEnabled ? "hearing what the audience hears" : "back to personal mix")}");
+
+ sonar.Events.ConfigSelectionChanged += (_, e) =>
+ Console.WriteLine($"[Config] {e.Channel}: {e.PreviousConfig?.Name ?? "?"} -> {e.NewConfigName}");
+
+ sonar.Events.AudioSessionOpened += (_, e) =>
+ {
+ var app = e.Sessions.FirstOrDefault(s => !s.IsSystemSound);
+ if (app is not null)
+ Console.WriteLine($"[Session] {app.DisplayName} (pid {app.ProcessId}) opened on {e.Channel?.ToString() ?? e.Role}");
+ };
+
+ sonar.Events.AudioSessionClosed += (_, e) =>
+ {
+ var app = e.Sessions.FirstOrDefault(s => !s.IsSystemSound);
+ if (app is not null)
+ Console.WriteLine($"[Session] {app.DisplayName} closed on {e.Channel?.ToString() ?? e.Role}");
+ };
+
+ // --- Raw invalidation signals (diagnostics; prefer the granular events above) ---
+ sonar.Events.RedirectionsInvalidated += (_, _) =>
+ Console.WriteLine(" (raw: redirections invalidated)");
+
+ sonar.Events.ConfigsInvalidated += (_, _) =>
+ Console.WriteLine(" (raw: configs invalidated)");
+
+ sonar.Events.RoutingInvalidated += (_, _) =>
+ Console.WriteLine(" (raw: app routing invalidated)");
+
+ // --- Low-level / diagnostics ---
+ sonar.Events.VolumeDataReceived += (_, e) =>
+ Console.WriteLine($"[Snapshot] full volume state received ({e.Channels.Count} channels)");
+
+ sonar.Events.UnknownEventReceived += (_, e) =>
+ Console.WriteLine($"[Unknown] {e.EventName}");
}
}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Sample/SteelSeriesAPI.Sample.csproj b/SteelSeriesAPI.Sample/SteelSeriesAPI.Sample.csproj
index 161886a..a270f6f 100644
--- a/SteelSeriesAPI.Sample/SteelSeriesAPI.Sample.csproj
+++ b/SteelSeriesAPI.Sample/SteelSeriesAPI.Sample.csproj
@@ -2,13 +2,17 @@
Exe
- net9.0
+ net10.0
enable
enable
-
+
+
+
+
+
diff --git a/SteelSeriesAPI.Tests/AppRoutingManagerTests.cs b/SteelSeriesAPI.Tests/AppRoutingManagerTests.cs
new file mode 100644
index 0000000..ba71cbe
--- /dev/null
+++ b/SteelSeriesAPI.Tests/AppRoutingManagerTests.cs
@@ -0,0 +1,153 @@
+using System.Text.Json;
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar;
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Managers;
+using Xunit;
+
+namespace SteelSeriesAPI.Tests;
+
+public class AppRoutingManagerTests
+{
+ // Trimmed from the real GET AudioDeviceRouting dump (2026-08-25):
+ // the Game VAD with an active Brave session, the Media VAD with its ghost, a physical device.
+ private const string RoutingDump =
+ """
+ [
+ {
+ "deviceId": "{0.0.0.00000000}.{5baa93cb-fefe-420e-b470-17d24a6f0719}",
+ "role": "game",
+ "dataFlow": "render",
+ "audioSessions": [
+ { "id": "x|#%b{A9EF3FD9}|1%b#", "processName": "Idle", "processId": 0,
+ "isSystemSound": true, "state": "inactive", "displayName": "Idle",
+ "isRoutingErrorProne": false, "routingErrorDetected": false },
+ { "id": "x|brave.exe%b|1%b7244", "processName": "brave", "processId": 7244,
+ "isSystemSound": false, "state": "active", "displayName": "Brave",
+ "isRoutingErrorProne": false, "routingErrorDetected": false }
+ ]
+ },
+ {
+ "deviceId": "{0.0.0.00000000}.{6587c8e4-f610-4fb7-8075-667cefcb5c05}",
+ "role": "media",
+ "dataFlow": "render",
+ "audioSessions": [
+ { "id": "y|brave.exe%b|1%b7244", "processName": "brave", "processId": 7244,
+ "isSystemSound": false, "state": "inactive", "displayName": "Brave",
+ "isRoutingErrorProne": false, "routingErrorDetected": false }
+ ]
+ },
+ {
+ "deviceId": "{0.0.1.00000000}.{28da6e04-ea4b-4391-b05c-e505e5c60d50}",
+ "role": "none",
+ "dataFlow": "capture",
+ "audioSessions": []
+ }
+ ]
+ """;
+
+ // Real WebSocket payload captured on 2026-08-26 (the "data" part of AUDIO_SESSION_OPENED_DATA)
+ private const string SessionOpenedData =
+ """
+ {"deviceId":"{0.0.0.00000000}.{6587c8e4-f610-4fb7-8075-667cefcb5c05}","role":"media","dataFlow":"render",
+ "audioSessions":[{"id":"z","processName":"brave","processId":7244,"isSystemSound":false,
+ "state":"active","displayName":"Brave","isRoutingErrorProne":false,"routingErrorDetected":true}]}
+ """;
+
+ // ---------------- Parsing ----------------
+
+ [Fact]
+ public async Task GetRoutingsAsync_RealDump_ParsesDevicesAndSessions()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.AudioDeviceRouting, RoutingDump);
+ var manager = new AppRoutingManager(transport);
+
+ var routings = await manager.GetRoutingsAsync();
+
+ Assert.Equal(3, routings.Count);
+
+ var game = Assert.Single(routings, r => r.Channel == Channel.Game);
+ Assert.Equal(2, game.Sessions.Count);
+ var brave = Assert.Single(game.Sessions, s => !s.IsSystemSound);
+ Assert.Equal("Brave", brave.DisplayName);
+ Assert.Equal(7244, brave.ProcessId);
+ Assert.True(brave.IsActive);
+
+ // Ghost sessions from past routings must be parsed too, as inactive
+ var media = Assert.Single(routings, r => r.Channel == Channel.Media);
+ Assert.False(Assert.Single(media.Sessions).IsActive);
+
+ // "none" role resolves to a null channel, not a crash
+ Assert.Contains(routings, r => r.Channel is null && r.DataFlow == AudioDataFlow.Capture);
+ }
+
+ [Fact]
+ public void ParseRouting_WebSocketSessionPayload_SharesTheShape()
+ {
+ // The AUDIO_SESSION_OPENED/CLOSED event payload is one AudioDeviceRouting entry
+ var routing = AppRoutingManager.ParseRouting(Json(SessionOpenedData));
+
+ Assert.NotNull(routing);
+ Assert.Equal(Channel.Media, routing!.Channel);
+ Assert.Equal("brave", Assert.Single(routing.Sessions).ProcessName);
+ }
+
+ // ---------------- Writes ----------------
+
+ [Fact]
+ public async Task RouteAppAsync_LowLevel_BuildsVerifiedEscapedRoute()
+ {
+ var transport = new FakeTransport();
+ var manager = new AppRoutingManager(transport);
+
+ await manager.RouteAppAsync(7244, "{0.0.0.00000000}.{6587c8e4}", AudioDataFlow.Render);
+
+ // Route shape verified live on 2026-08-26; braces must be URL-escaped like the official UI does
+ string route = Assert.Single(transport.PutRoutes);
+ Assert.StartsWith("AudioDeviceRouting/render/", route);
+ Assert.EndsWith("/7244", route);
+ Assert.DoesNotContain("{", route);
+ }
+
+ [Fact]
+ public async Task RouteAppAsync_ByChannel_ResolvesDeviceAtCallTime()
+ {
+ // The high-level overload must GET the routing state and target the channel's device
+ var transport = new FakeTransport().With(SonarRoutes.AudioDeviceRouting, RoutingDump);
+ var manager = new AppRoutingManager(transport);
+
+ await manager.RouteAppAsync(7244, Channel.Media);
+
+ string route = Assert.Single(transport.PutRoutes);
+ Assert.Contains("6587c8e4", route); // the Media VAD from the fixture
+ }
+
+ [Fact]
+ public async Task RouteAppAsync_UnknownChannelDevice_ThrowsSonarResponse()
+ {
+ // Aux has no device in this fixture: the resolution must fail loudly, not silently no-op
+ var transport = new FakeTransport().With(SonarRoutes.AudioDeviceRouting, RoutingDump);
+ var manager = new AppRoutingManager(transport);
+
+ await Assert.ThrowsAsync(
+ () => manager.RouteAppAsync(7244, Channel.Aux));
+ Assert.Empty(transport.PutRoutes);
+ }
+
+ [Theory]
+ [InlineData(0)]
+ [InlineData(-42)]
+ public async Task RouteAppAsync_InvalidProcessId_ThrowsWithoutSending(int invalidPid)
+ {
+ var manager = new AppRoutingManager(new FakeTransport());
+
+ await Assert.ThrowsAsync(
+ () => manager.RouteAppAsync(invalidPid, "device", AudioDataFlow.Render));
+ }
+
+ private static JsonElement Json(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.Clone();
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Tests/AudioDeviceManagerTests.cs b/SteelSeriesAPI.Tests/AudioDeviceManagerTests.cs
new file mode 100644
index 0000000..a120d8f
--- /dev/null
+++ b/SteelSeriesAPI.Tests/AudioDeviceManagerTests.cs
@@ -0,0 +1,145 @@
+using System.Text.Json;
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar;
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Managers;
+using Xunit;
+
+namespace SteelSeriesAPI.Tests;
+
+public class AudioDeviceManagerTests
+{
+ // Entries taken from the real audioDevices dump (2026-08-25, GG 118)
+ private const string DevicesDump =
+ """
+ [
+ {
+ "friendlyName": "SteelSeries Sonar - Chat (SteelSeries Sonar Virtual Audio Device)",
+ "id": "{0.0.0.00000000}.{06e6c961-3d64-40f2-a617-72bdcbac9265}",
+ "dataFlow": "render",
+ "role": "chatRender",
+ "channels": 2,
+ "defaultRole": "communications",
+ "fwUpdateRequired": false,
+ "state": "active",
+ "isVad": true
+ },
+ {
+ "friendlyName": "Haut-parleurs (High Definition Audio Device)",
+ "id": "{0.0.0.00000000}.{584e246f-4ae6-417c-acbb-c9f90fee29f1}",
+ "dataFlow": "render",
+ "role": "none",
+ "channels": 2,
+ "defaultRole": "all",
+ "fwUpdateRequired": false,
+ "state": "active",
+ "isVad": false
+ },
+ {
+ "friendlyName": "Line input (High Definition Audio Device)",
+ "id": "{0.0.1.00000000}.{28da6e04-ea4b-4391-b05c-e505e5c60d50}",
+ "dataFlow": "capture",
+ "role": "none",
+ "channels": 2,
+ "defaultRole": "console",
+ "fwUpdateRequired": false,
+ "state": "active",
+ "isVad": false
+ },
+ {
+ "friendlyName": "SteelSeries Sonar - Microphone (SteelSeries Sonar Virtual Audio Device)",
+ "id": "{0.0.1.00000000}.{dd3c92e1-f4e3-4171-a5b6-6995e5de70b3}",
+ "dataFlow": "capture",
+ "role": "chatCapture",
+ "channels": 2,
+ "defaultRole": "all",
+ "fwUpdateRequired": false,
+ "state": "active",
+ "isVad": true
+ }
+ ]
+ """;
+
+ [Fact]
+ public async Task GetAllAsync_RealDump_ParsesAllDevices()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.AudioDevices, DevicesDump);
+ var manager = new AudioDeviceManager(transport);
+
+ var devices = await manager.GetAllAsync();
+
+ Assert.Equal(4, devices.Count);
+
+ var sonarChat = Assert.Single(devices, d => d.Name.Contains("Sonar - Chat"));
+ Assert.True(sonarChat.IsSonarVirtual);
+ Assert.Equal(Channel.Chat, sonarChat.SonarChannel); // role "chatRender" -> Chat
+ Assert.Equal(AudioDataFlow.Render, sonarChat.DataFlow);
+
+ var speakers = Assert.Single(devices, d => d.Name.StartsWith("Haut-parleurs"));
+ Assert.False(speakers.IsSonarVirtual);
+ Assert.Null(speakers.SonarChannel); // physical device: no Sonar channel
+
+ var sonarMic = Assert.Single(devices, d => d.SonarChannel == Channel.Mic);
+ Assert.Equal(AudioDataFlow.Capture, sonarMic.DataFlow); // role "chatCapture" -> Mic
+ }
+
+ [Fact]
+ public async Task GetAllAsync_RenderWithoutVirtual_ReturnsRedirectionCandidates()
+ {
+ // The exact list to offer when picking a classic redirection target
+ var transport = new FakeTransport().With(SonarRoutes.AudioDevices, DevicesDump);
+ var manager = new AudioDeviceManager(transport);
+
+ var candidates = await manager.GetAllAsync(AudioDataFlow.Render);
+
+ var speakers = Assert.Single(candidates);
+ Assert.Equal("Haut-parleurs (High Definition Audio Device)", speakers.Name);
+ }
+
+ [Fact]
+ public async Task GetAllAsync_IncludeSonarVirtual_KeepsVirtualDevices()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.AudioDevices, DevicesDump);
+ var manager = new AudioDeviceManager(transport);
+
+ var all = await manager.GetAllAsync(AudioDataFlow.Capture, includeSonarVirtual: true);
+
+ Assert.Equal(2, all.Count); // Entrée de ligne + Sonar Microphone
+ Assert.Contains(all, d => d.IsSonarVirtual);
+ }
+
+ [Fact]
+ public void ParseDevices_UnknownDataFlowOrMissingId_IsSkipped()
+ {
+ // A future GG update adding a new flow, or a malformed entry, must never break the listing
+ const string withOddities =
+ """
+ [
+ { "friendlyName": "Weird future device", "id": "x", "dataFlow": "loopback", "isVad": false },
+ { "friendlyName": "No id at all", "dataFlow": "render", "isVad": false },
+ { "friendlyName": "Valid one", "id": "y", "dataFlow": "render", "isVad": false }
+ ]
+ """;
+
+ var devices = AudioDeviceManager.ParseDevices(Json(withOddities));
+
+ var valid = Assert.Single(devices);
+ Assert.Equal("Valid one", valid.Name);
+ }
+
+ [Fact]
+ public void ParseDevices_MissingFriendlyName_FallsBackToId()
+ {
+ const string nameless = """[{ "id": "some-id", "dataFlow": "render", "isVad": false }]""";
+
+ var devices = AudioDeviceManager.ParseDevices(Json(nameless));
+
+ Assert.Equal("some-id", Assert.Single(devices).Name);
+ }
+
+ private static JsonElement Json(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.Clone();
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Tests/ChatMixManagerTests.cs b/SteelSeriesAPI.Tests/ChatMixManagerTests.cs
new file mode 100644
index 0000000..739e14d
--- /dev/null
+++ b/SteelSeriesAPI.Tests/ChatMixManagerTests.cs
@@ -0,0 +1,78 @@
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar;
+using SteelSeriesAPI.Sonar.Managers;
+using SteelSeriesAPI.Sonar.Models;
+using Xunit;
+
+namespace SteelSeriesAPI.Tests;
+
+public class ChatMixManagerTests
+{
+ // Real responses captured on 2026-08-08
+ private const string EnabledDump =
+ """{"balance":0.45999998,"state":"enabled","id":"00000000-0000-0000-0000-000000000000"}""";
+
+ private const string DifferentDeviceDump =
+ """{"balance":0.0,"state":"differentDeviceSelected","id":"00000000-0000-0000-0000-000000000000"}""";
+
+ [Fact]
+ public async Task GetAsync_EnabledState_ParsesBalanceAndState()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.GetChatMix, EnabledDump);
+ var manager = new ChatMixManager(transport);
+
+ var setting = await manager.GetAsync();
+
+ Assert.Equal(new ChatMixSetting(0.45999998, "enabled"), setting);
+ }
+
+ [Fact]
+ public async Task GetAsync_DifferentDeviceSelected_ParsesState()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.GetChatMix, DifferentDeviceDump);
+ var manager = new ChatMixManager(transport);
+
+ var setting = await manager.GetAsync();
+
+ Assert.Equal(new ChatMixSetting(0.0, "differentDeviceSelected"), setting);
+ }
+
+ [Fact]
+ public async Task GetAsync_UnknownFieldsAndMissingState_StillWorks()
+ {
+ // A future GG update may drop/rename fields around the ones we read:
+ // parsing must degrade gracefully, never crash.
+ var transport = new FakeTransport().With(SonarRoutes.GetChatMix,
+ """{"balance":0.5,"someNewField":true}""");
+ var manager = new ChatMixManager(transport);
+
+ var setting = await manager.GetAsync();
+
+ Assert.Equal(new ChatMixSetting(0.5, null), setting);
+ }
+
+ [Fact]
+ public async Task SetAsync_NegativeBalance_BuildsInvariantCultureRoute()
+ {
+ // Guards both the invariant decimal separator and the negative sign handling
+ var transport = new FakeTransport();
+ var manager = new ChatMixManager(transport);
+
+ await manager.SetAsync(-0.5);
+
+ Assert.Equal("v1/chatMix?balance=-0.50", Assert.Single(transport.PutRoutes));
+ }
+
+ [Theory]
+ [InlineData(-1.5)]
+ [InlineData(1.5)]
+ [InlineData(double.NaN)]
+ public async Task SetAsync_OutOfRange_ThrowsWithoutSendingAnything(double invalid)
+ {
+ var transport = new FakeTransport();
+ var manager = new ChatMixManager(transport);
+
+ await Assert.ThrowsAsync(() => manager.SetAsync(invalid));
+ Assert.Empty(transport.PutRoutes); // validation must happen BEFORE any HTTP call
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Tests/ConfigManagerTests.cs b/SteelSeriesAPI.Tests/ConfigManagerTests.cs
new file mode 100644
index 0000000..6131fc8
--- /dev/null
+++ b/SteelSeriesAPI.Tests/ConfigManagerTests.cs
@@ -0,0 +1,172 @@
+using System.Text.Json;
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar;
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Managers;
+using Xunit;
+
+namespace SteelSeriesAPI.Tests;
+
+public class ConfigManagerTests
+{
+ // Shape from real dumps (2026-08-25, GG 118 / Sonar schema v5), EQ payloads trimmed:
+ // the parser must ignore data/defaultData whatever they contain.
+ private const string ConfigsDump =
+ """
+ [
+ {
+ "id": "e6979db3-3e00-4399-b58c-6f026c9ef6ba",
+ "name": "Custom",
+ "createdAt": "2026-08-04T02:45:38",
+ "updatedAt": "2026-08-10T19:34:39.4703773",
+ "virtualAudioDevice": "game",
+ "data": { "parametricEQ": { "filter1": { "gain": 4.0 } } },
+ "schemaVersion": 5,
+ "isPreset": false,
+ "defaultData": { "whatever": true },
+ "image": "sonar.svg",
+ "isFavorite": true,
+ "favoritePosition": 0,
+ "releaseVersion": null
+ },
+ {
+ "id": "ed310e25-2f10-49bb-bb74-5978a44bb9be",
+ "name": "Halo: Campaign Evolved",
+ "virtualAudioDevice": "game",
+ "data": {},
+ "isPreset": true,
+ "isFavorite": false,
+ "releaseVersion": "1.100.0"
+ },
+ {
+ "id": "aaaa1111-2222-3333-4444-555566667777",
+ "name": "Mic Custom",
+ "virtualAudioDevice": "chatCapture",
+ "data": {},
+ "isPreset": false,
+ "isFavorite": false
+ }
+ ]
+ """;
+
+ private const string SelectedDump =
+ """
+ [
+ { "id": "e6979db3-3e00-4399-b58c-6f026c9ef6ba", "name": "Custom", "virtualAudioDevice": "game", "isPreset": false, "isFavorite": true },
+ { "id": "aaaa1111-2222-3333-4444-555566667777", "name": "Mic Custom", "virtualAudioDevice": "chatCapture", "isPreset": false, "isFavorite": false },
+ { "id": "bbbb1111-2222-3333-4444-555566667777", "name": "Flat", "virtualAudioDevice": "media", "isPreset": true, "isFavorite": false }
+ ]
+ """;
+
+ // ---------------- Listing ----------------
+
+ [Fact]
+ public async Task GetAllAsync_RealShape_ParsesHeadersAndIgnoresPayloads()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.Configs, ConfigsDump);
+ var manager = new ConfigManager(transport);
+
+ var configs = await manager.GetAllAsync();
+
+ Assert.Equal(3, configs.Count);
+
+ var custom = Assert.Single(configs, c => c.Name == "Custom");
+ Assert.Equal("e6979db3-3e00-4399-b58c-6f026c9ef6ba", custom.Id);
+ Assert.Equal(Channel.Game, custom.Channel);
+ Assert.False(custom.IsPreset);
+ Assert.True(custom.IsFavorite);
+
+ // "chatCapture" virtualAudioDevice must map to the Mic channel
+ Assert.Single(configs, c => c.Channel == Channel.Mic);
+ }
+
+ [Fact]
+ public async Task GetAllAsync_ByChannel_Filters()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.Configs, ConfigsDump);
+ var manager = new ConfigManager(transport);
+
+ var gameConfigs = await manager.GetAllAsync(Channel.Game);
+
+ Assert.Equal(2, gameConfigs.Count);
+ Assert.All(gameConfigs, c => Assert.Equal(Channel.Game, c.Channel));
+ }
+
+ [Fact]
+ public void ParseConfigList_UnknownDeviceOrMissingId_IsSkipped()
+ {
+ // A future GG update adding a new virtualAudioDevice, or a malformed entry,
+ // must never break the listing (the V1 lesson).
+ const string withOddities =
+ """
+ [
+ { "id": "x", "name": "Subwoofer thing", "virtualAudioDevice": "subwoofer" },
+ { "name": "No id at all", "virtualAudioDevice": "game" },
+ { "id": "y", "name": "Valid", "virtualAudioDevice": "aux" }
+ ]
+ """;
+
+ var configs = ConfigManager.ParseConfigList(Json(withOddities));
+
+ var valid = Assert.Single(configs);
+ Assert.Equal(Channel.Aux, valid.Channel);
+ }
+
+ // ---------------- Selection ----------------
+
+ [Fact]
+ public async Task GetSelectedAsync_ReturnsOneConfigPerChannel()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.SelectedConfigs, SelectedDump);
+ var manager = new ConfigManager(transport);
+
+ var selected = await manager.GetSelectedAsync();
+
+ Assert.Equal(3, selected.Count);
+ Assert.Equal("Custom", selected[Channel.Game].Name);
+ Assert.Equal("Mic Custom", selected[Channel.Mic].Name);
+ Assert.Equal("Flat", selected[Channel.Media].Name);
+ }
+
+ [Fact]
+ public async Task GetSelectedAsync_ChannelWithoutSelection_ReturnsNull()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.SelectedConfigs, SelectedDump);
+ var manager = new ConfigManager(transport);
+
+ Assert.Null(await manager.GetSelectedAsync(Channel.Aux));
+ }
+
+ // ---------------- Select (write) ----------------
+
+ [Fact]
+ public async Task SelectAsync_BuildsVerifiedRoute()
+ {
+ var transport = new FakeTransport();
+ var manager = new ConfigManager(transport);
+
+ await manager.SelectAsync("ed310e25-2f10-49bb-bb74-5978a44bb9be");
+
+ // Route verified against the live API on 2026-08-25
+ Assert.Equal("configs/ed310e25-2f10-49bb-bb74-5978a44bb9be/select",
+ Assert.Single(transport.PutRoutes));
+ }
+
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ public async Task SelectAsync_EmptyId_ThrowsWithoutSendingAnything(string invalid)
+ {
+ var transport = new FakeTransport();
+ var manager = new ConfigManager(transport);
+
+ await Assert.ThrowsAsync(() => manager.SelectAsync(invalid));
+ Assert.Empty(transport.PutRoutes);
+ }
+
+ private static JsonElement Json(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.Clone();
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Tests/FakeTransport.cs b/SteelSeriesAPI.Tests/FakeTransport.cs
new file mode 100644
index 0000000..ee0a580
--- /dev/null
+++ b/SteelSeriesAPI.Tests/FakeTransport.cs
@@ -0,0 +1,28 @@
+using System.Text.Json;
+using SteelSeriesAPI.Core;
+
+namespace SteelSeriesAPI.Tests;
+
+/// Fake transport serving canned JSON responses and recording PUT routes.
+internal sealed class FakeTransport : ISonarTransport
+{
+ private readonly Dictionary _responses = new();
+
+ /// Routes received by , in call order.
+ public List PutRoutes { get; } = [];
+
+ public FakeTransport With(string route, string json)
+ {
+ _responses[route] = json;
+ return this;
+ }
+
+ public Task GetAsync(string route, CancellationToken ct = default) =>
+ Task.FromResult(JsonDocument.Parse(_responses[route]));
+
+ public Task PutAsync(string route, CancellationToken ct = default)
+ {
+ PutRoutes.Add(route);
+ return Task.CompletedTask;
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Tests/ModeManagerTests.cs b/SteelSeriesAPI.Tests/ModeManagerTests.cs
new file mode 100644
index 0000000..749de2c
--- /dev/null
+++ b/SteelSeriesAPI.Tests/ModeManagerTests.cs
@@ -0,0 +1,41 @@
+using SteelSeriesAPI.Sonar;
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Managers;
+using SteelSeriesAPI.Core;
+using Xunit;
+
+namespace SteelSeriesAPI.Tests;
+
+public class ModeManagerTests
+{
+ [Fact]
+ public async Task GetAsync_BareJsonString_ParsesMode()
+ {
+ // The mode route returns a bare JSON string, not an object (observed 2026-08-07)
+ var transport = new FakeTransport().With(SonarRoutes.GetMode, "\"stream\"");
+ var manager = new ModeManager(transport);
+
+ Assert.Equal(Mode.Streamer, await manager.GetAsync());
+ }
+
+ [Fact]
+ public async Task GetAsync_UnknownValue_ThrowsSonarResponse()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.GetMode, "\"quantum\"");
+ var manager = new ModeManager(transport);
+
+ await Assert.ThrowsAsync(() => manager.GetAsync());
+ }
+
+ [Fact]
+ public async Task SetAsync_ConfirmedByReadback_Succeeds()
+ {
+ // The fake always reports "stream": switching to Streamer confirms on first poll
+ var transport = new FakeTransport().With(SonarRoutes.GetMode, "\"stream\"");
+ var manager = new ModeManager(transport);
+
+ await manager.SetAsync(Mode.Streamer);
+
+ Assert.Equal("mode/stream", Assert.Single(transport.PutRoutes));
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Tests/Program.cs b/SteelSeriesAPI.Tests/Program.cs
deleted file mode 100644
index cb3c195..0000000
--- a/SteelSeriesAPI.Tests/Program.cs
+++ /dev/null
@@ -1,267 +0,0 @@
-using SteelSeriesAPI.Sonar;
-using SteelSeriesAPI.Sonar.Events;
-using SteelSeriesAPI.Sonar.Enums;
-using SteelSeriesAPI.Sonar.Exceptions;
-using SteelSeriesAPI.Sonar.Models;
-
-namespace SteelSeriesAPI.Tests;
-
-class Program
-{
- static void Main(string[] args)
- {
- SonarBridge sonarManager = new SonarBridge();
- sonarManager.WaitUntilSteelSeriesStarted();
- sonarManager.WaitUntilSonarStarted();
- Console.WriteLine(SonarRetriever.Instance.WebServerAddress());
-
- sonarManager.StartListener();
-
- // Thread.Sleep(1000);
- // sonarManager.StopListener();
-
- sonarManager.Events.OnSonarModeChange += OnModeChangeHandler;
- sonarManager.Events.OnSonarVolumeChange += OnVolumeChangeHandler;
- sonarManager.Events.OnSonarMuteChange += OnMuteChangeHandler;
- sonarManager.Events.OnSonarConfigChange += OnConfigChangeHandler;
- sonarManager.Events.OnSonarChatMixChange += OnChatMixChangeHandler;
- sonarManager.Events.OnSonarPlaybackDeviceChange += OnPlaybackDeviceChangeHandler;
- sonarManager.Events.OnSonarRoutedProcessChange += OnRoutedProcessChangeHandler;
- sonarManager.Events.OnSonarMixChange += OnMixChangeHandler;
- sonarManager.Events.OnSonarAudienceMonitoringChange += OnAudienceMonitoringChangeHandler;
-
- // Save current settings
- var mode = sonarManager.Mode.Get();
- var chatmix = sonarManager.ChatMix.GetBalance();
- var audienceMonitoring = sonarManager.AudienceMonitoring.GetState();
-
- sonarManager.Mode.Set(Mode.CLASSIC);
-
- foreach (Channel channel in (Channel[])Enum.GetValues(typeof(Channel)))
- {
- Console.WriteLine("------ " + channel + " ------");
- if (channel == Channel.MASTER)
- {
- var volume = sonarManager.VolumeSettings.GetVolume(channel);
- var mute = sonarManager.VolumeSettings.GetMute(channel);
-
- Console.WriteLine("Volume test...");
- sonarManager.VolumeSettings.SetVolume(volume + 0.2, channel);
- Thread.Sleep(1000);
- sonarManager.VolumeSettings.SetVolume(volume, channel);
-
- Thread.Sleep(500);
-
- Console.WriteLine("Mute test...");
- sonarManager.VolumeSettings.SetMute(!mute, channel);
- Thread.Sleep(1000);
- sonarManager.VolumeSettings.SetMute(mute, channel);
- }
- else
- {
- var volume = sonarManager.VolumeSettings.GetVolume(channel);
- var mute = sonarManager.VolumeSettings.GetMute(channel);
- var config = sonarManager.Configurations.GetSelectedAudioConfiguration(channel);
- var playbackDevice = sonarManager.PlaybackDevices.GetPlaybackDevice(channel);
- var routedProcesses = new List(sonarManager.RoutedProcesses.GetActiveRoutedProcesses(channel));
-
- Console.WriteLine("Volume test...");
- sonarManager.VolumeSettings.SetVolume(volume + 0.2, channel);
- Thread.Sleep(1000);
- sonarManager.VolumeSettings.SetVolume(volume, channel);
-
- Thread.Sleep(500);
-
- Console.WriteLine("Mute test...");
- sonarManager.VolumeSettings.SetMute(!mute, channel);
- Thread.Sleep(1000);
- sonarManager.VolumeSettings.SetMute(mute, channel);
-
- Thread.Sleep(500);
-
- Console.WriteLine("Config test...");
- sonarManager.Configurations.SetConfig(sonarManager.Configurations.GetAudioConfigurations(channel).ToList()[4]);
- Thread.Sleep(1000);
- sonarManager.Configurations.SetConfig(config);
-
- Thread.Sleep(500);
-
- Console.WriteLine("Playback Device test...");
- if (channel == Channel.MIC)
- {
- sonarManager.PlaybackDevices.SetPlaybackDevice(sonarManager.PlaybackDevices.GetInputPlaybackDevices().First(), channel);
- }
- else
- {
- sonarManager.PlaybackDevices.SetPlaybackDevice(sonarManager.PlaybackDevices.GetOutputPlaybackDevices().First(), channel);
- }
- Thread.Sleep(1000);
- sonarManager.PlaybackDevices.SetPlaybackDevice(playbackDevice, channel);
-
- Console.WriteLine("Routed Processes test...");
- foreach (var r in routedProcesses)
- {
- if (channel == Channel.GAME)
- {
- sonarManager.RoutedProcesses.RouteProcessToChannel(r, Channel.AUX);
- }
- else
- {
- sonarManager.RoutedProcesses.RouteProcessToChannel(r, Channel.GAME);
- }
- }
- Thread.Sleep(1000);
- foreach (var r in routedProcesses)
- {
- sonarManager.RoutedProcesses.RouteProcessToChannel(r, channel);
- }
- }
- }
-
- if (sonarManager.ChatMix.GetState())
- {
- Console.WriteLine("Chat Mix Test...");
- sonarManager.ChatMix.SetBalance(chatmix + 0.2);
- Thread.Sleep(1000);
- sonarManager.ChatMix.SetBalance(chatmix);
- }
- else
- {
- Console.WriteLine("Chat Mix disabled. Can't do test");
- }
-
- sonarManager.Mode.Set(Mode.STREAMER);
-
- foreach (Channel channel in (Channel[])Enum.GetValues(typeof(Channel)))
- {
- foreach (Mix mix in (Mix[])Enum.GetValues(typeof(Mix)))
- {
- Console.WriteLine("------ " + channel + " - " + mix + " ------");
- if (channel == Channel.MASTER)
- {
- var volume = sonarManager.VolumeSettings.GetVolume(channel, mix);
- var mute = sonarManager.VolumeSettings.GetMute(channel, mix);
-
- Console.WriteLine("Volume test...");
- sonarManager.VolumeSettings.SetVolume(volume - 0.2, channel, mix);
- Thread.Sleep(1000);
- sonarManager.VolumeSettings.SetVolume(volume, channel, mix);
-
- Thread.Sleep(500);
-
- Console.WriteLine("Mute test...");
- sonarManager.VolumeSettings.SetMute(!mute, channel, mix);
- Thread.Sleep(1000);
- sonarManager.VolumeSettings.SetMute(mute, channel, mix);
- }
- else
- {
- var volume = sonarManager.VolumeSettings.GetVolume(channel, mix);
- var mute = sonarManager.VolumeSettings.GetMute(channel, mix);
- var redirection = sonarManager.Mix.GetState(channel, mix);
-
- Console.WriteLine("Volume test...");
- sonarManager.VolumeSettings.SetVolume(volume - 0.2, channel, mix);
- Thread.Sleep(1000);
- sonarManager.VolumeSettings.SetVolume(volume, channel, mix);
-
- Thread.Sleep(500);
-
- Console.WriteLine("Mute test...");
- sonarManager.VolumeSettings.SetMute(!mute, channel, mix);
- Thread.Sleep(1000);
- sonarManager.VolumeSettings.SetMute(mute, channel, mix);
-
- Console.WriteLine("Mix test...");
- sonarManager.Mix.SetState(!redirection, channel, mix);
- Thread.Sleep(1000);
- sonarManager.Mix.SetState(redirection, channel, mix);
- }
- }
- }
-
- Console.WriteLine("Playback Device test...");
- foreach (Mix mix in (Mix[])Enum.GetValues(typeof(Mix)))
- {
- try
- {
- Console.WriteLine(mix);
- var playbackDevice = sonarManager.PlaybackDevices.GetPlaybackDevice(mix);
- sonarManager.PlaybackDevices.SetPlaybackDevice(sonarManager.PlaybackDevices.GetOutputPlaybackDevices().First(), mix);
- Thread.Sleep(1000);
- sonarManager.PlaybackDevices.SetPlaybackDevice(playbackDevice, mix);
- }
- catch (PlaybackDeviceNotFoundException e)
- {
- Console.WriteLine(e);
- Console.WriteLine("No playback device set for : " + mix);
- }
- }
-
- try
- {
- Console.WriteLine(Channel.MIC + " " + Mode.STREAMER);
- var micPlaybackDevice = sonarManager.PlaybackDevices.GetPlaybackDevice(Channel.MIC, Mode.STREAMER);
- sonarManager.PlaybackDevices.SetPlaybackDevice(sonarManager.PlaybackDevices.GetOutputPlaybackDevices().First(), Channel.MIC, Mode.STREAMER);
- Thread.Sleep(1000);
- sonarManager.PlaybackDevices.SetPlaybackDevice(micPlaybackDevice, Channel.MIC, Mode.STREAMER);
- }
- catch (PlaybackDeviceNotFoundException e)
- {
- Console.WriteLine(e);
- Console.WriteLine("No playback device set for : " + Channel.MIC + Mode.STREAMER);
- }
-
- Console.WriteLine("Audience Monitoring Test...");
- sonarManager.AudienceMonitoring.SetState(!audienceMonitoring);
- Thread.Sleep(1000);
- sonarManager.AudienceMonitoring.SetState(audienceMonitoring);
-
- sonarManager.Mode.Set(mode);
- }
-
- static void OnModeChangeHandler(object? sender, SonarModeEvent eventArgs)
- {
- Console.WriteLine("Received Mode Event : " + eventArgs.NewMode);
- }
-
- static void OnVolumeChangeHandler(object? sender, SonarVolumeEvent eventArgs)
- {
- Console.WriteLine("Received Volume Event : " + eventArgs.Volume + ", " + eventArgs.Mode + ", " + eventArgs.Channel + ", " + eventArgs.Mix);
- }
-
- static void OnMuteChangeHandler(object? sender, SonarMuteEvent eventArgs)
- {
- Console.WriteLine("Received Mute Event : " + eventArgs.Muted + ", " + eventArgs.Mode + ", " + eventArgs.Channel + ", " + eventArgs.Mix);
- }
-
- static void OnConfigChangeHandler(object? sender, SonarConfigEvent eventArgs)
- {
- Console.WriteLine("Received Config Event : " + eventArgs.ConfigId);
- }
-
- static void OnChatMixChangeHandler(object? sender, SonarChatMixEvent eventArgs)
- {
- Console.WriteLine("Received ChatMix Event : " + eventArgs.Balance);
- }
-
- static void OnPlaybackDeviceChangeHandler(object? sender, SonarPlaybackDeviceEvent eventArgs)
- {
- Console.WriteLine("Received Redirection Channel Event : " + eventArgs.PlaybackDeviceId + ", " + eventArgs.Mode + ", " + eventArgs.Channel + ", " + eventArgs.Mix);
- }
-
- static void OnRoutedProcessChangeHandler(object? sender, SonarRoutedProcessEvent eventArgs)
- {
- Console.WriteLine("Received Routed Process Event : " + eventArgs.ProcessId + ", " + eventArgs.NewChannel);
- }
-
- static void OnMixChangeHandler(object? sender, SonarMixEvent eventArgs)
- {
- Console.WriteLine("Received Redirection State Event : " + eventArgs.NewState + ", " + eventArgs.Channel + ", " + eventArgs.Mix);
- }
-
- static void OnAudienceMonitoringChangeHandler(object? sender, SonarAudienceMonitoringEvent eventArgs)
- {
- Console.WriteLine("Received Audience Monitoring Event : " + eventArgs.NewState);
- }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Tests/RedirectionsManagerTests.cs b/SteelSeriesAPI.Tests/RedirectionsManagerTests.cs
new file mode 100644
index 0000000..6a913df
--- /dev/null
+++ b/SteelSeriesAPI.Tests/RedirectionsManagerTests.cs
@@ -0,0 +1,153 @@
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar;
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Managers;
+using SteelSeriesAPI.Sonar.Models;
+using Xunit;
+
+namespace SteelSeriesAPI.Tests;
+
+public class RedirectionsManagerTests
+{
+ // Real dumps captured on 2026-08-14 (streamer mode active)
+ private const string ClassicDump =
+ """
+ [
+ {"id":"aux","deviceId":"{0.0.0.00000000}.{683a5c4e-d3ff-46b0-ab5a-96b5b8d4b11e}","isRunning":true},
+ {"id":"chat","deviceId":"{0.0.0.00000000}.{584e246f-4ae6-417c-acbb-c9f90fee29f1}","isRunning":true},
+ {"id":"game","deviceId":"{0.0.0.00000000}.{584e246f-4ae6-417c-acbb-c9f90fee29f1}","isRunning":true},
+ {"id":"media","deviceId":"{0.0.0.00000000}.{683a5c4e-d3ff-46b0-ab5a-96b5b8d4b11e}","isRunning":true},
+ {"id":"mic","deviceId":"{0.0.1.00000000}.{28da6e04-ea4b-4391-b05c-e505e5c60d50}","isRunning":true}
+ ]
+ """;
+
+ private const string StreamDump =
+ """
+ [
+ {"streamRedirectionId":"streaming","deviceId":"{0.0.0.00000000}.{bd318855-e5cc-48cf-b878-3b5dc40d81a9}",
+ "status":[{"role":"chatCapture","isEnabled":true},{"role":"chatRender","isEnabled":false},
+ {"role":"game","isEnabled":false},{"role":"media","isEnabled":false},{"role":"aux","isEnabled":true}],
+ "isRunning":false},
+ {"streamRedirectionId":"monitoring","deviceId":"{0.0.0.00000000}.{584e246f-4ae6-417c-acbb-c9f90fee29f1}",
+ "status":[{"role":"chatCapture","isEnabled":false},{"role":"chatRender","isEnabled":true},
+ {"role":"game","isEnabled":true},{"role":"media","isEnabled":true},{"role":"aux","isEnabled":true}],
+ "isRunning":true},
+ {"streamRedirectionId":"mic","deviceId":"{0.0.1.00000000}.{28da6e04-ea4b-4391-b05c-e505e5c60d50}",
+ "status":[],"isRunning":true}
+ ]
+ """;
+
+ // ---------------- Parsing ----------------
+
+ [Fact]
+ public async Task GetClassicRedirectionsAsync_RealDump_ParsesAllChannels()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.ClassicRedirections, ClassicDump);
+ var manager = new RedirectionsManager(transport);
+
+ var redirections = await manager.GetClassicRedirectionsAsync();
+
+ Assert.Equal(5, redirections.Count);
+
+ // "chat" and "mic" short ids must map to the Chat and Mic channels
+ var chat = Assert.Single(redirections, r => r.Channel == Channel.Chat);
+ Assert.Equal("{0.0.0.00000000}.{584e246f-4ae6-417c-acbb-c9f90fee29f1}", chat.DeviceId);
+ Assert.True(chat.IsRunning);
+
+ Assert.Single(redirections, r => r.Channel == Channel.Mic);
+ Assert.DoesNotContain(redirections, r => r.Channel == Channel.Master);
+ }
+
+ [Fact]
+ public async Task GetStreamRedirectionsAsync_RealDump_ParsesMixesAndMic()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.StreamRedirections, StreamDump);
+ var manager = new RedirectionsManager(transport);
+
+ var state = await manager.GetStreamRedirectionsAsync();
+
+ // "monitoring" -> Personal, "streaming" -> Stream
+ Assert.NotNull(state.Personal);
+ Assert.True(state.Personal!.IsRunning);
+ Assert.True(state.Personal.EnabledChannels[Channel.Game]);
+ Assert.False(state.Personal.EnabledChannels[Channel.Mic]); // chatCapture: false
+
+ Assert.NotNull(state.Stream);
+ Assert.False(state.Stream!.IsRunning);
+ Assert.False(state.Stream.EnabledChannels[Channel.Chat]); // chatRender: false
+ Assert.True(state.Stream.EnabledChannels[Channel.Aux]);
+
+ Assert.NotNull(state.Mic);
+ Assert.True(state.Mic!.IsRunning);
+ }
+
+ [Fact]
+ public void ParseStreamRedirections_UnknownRedirectionId_IsSkipped()
+ {
+ const string withUnknown =
+ """
+ [{"streamRedirectionId":"holographic","deviceId":"x","status":[],"isRunning":true},
+ {"streamRedirectionId":"monitoring","deviceId":"y","status":[],"isRunning":true}]
+ """;
+
+ var state = RedirectionsManager.ParseStreamRedirections(Json(withUnknown));
+
+ Assert.NotNull(state.Personal);
+ Assert.Null(state.Stream);
+ }
+
+ // ---------------- Write routes ----------------
+
+ [Fact]
+ public async Task SetStreamMonitoringEnabledAsync_BuildsVerifiedRoute()
+ {
+ var transport = new FakeTransport();
+ var manager = new RedirectionsManager(transport);
+
+ await manager.SetStreamMonitoringEnabledAsync(true);
+
+ // Route verified against the live API on 2026-08-14
+ Assert.Equal("streamRedirections/isStreamMonitoringEnabled/true", Assert.Single(transport.PutRoutes));
+ }
+
+ [Fact]
+ public async Task SetMixChannelEnabledAsync_UsesJsonVocabulary()
+ {
+ var transport = new FakeTransport();
+ var manager = new RedirectionsManager(transport);
+
+ await manager.SetMixChannelEnabledAsync(Mix.Personal, Channel.Mic, false);
+
+ // Stream redirection routes use "chatCapture", not "mic" (verified 2026-08-14)
+ Assert.Equal("streamRedirections/monitoring/redirections/chatCapture/isEnabled/false",
+ Assert.Single(transport.PutRoutes));
+ }
+
+ [Fact]
+ public async Task SetClassicDeviceAsync_UsesShortVocabularyAndEscapesDeviceId()
+ {
+ var transport = new FakeTransport();
+ var manager = new RedirectionsManager(transport);
+
+ await manager.SetClassicDeviceAsync(Channel.Mic, "{0.0.1}.{abc}");
+
+ string route = Assert.Single(transport.PutRoutes);
+ Assert.StartsWith("classicRedirections/mic/deviceId/", route); // short id "mic"
+ Assert.DoesNotContain("{", route); // braces escaped
+ }
+
+ [Fact]
+ public async Task SetMixChannelEnabledAsync_Master_Throws()
+ {
+ var manager = new RedirectionsManager(new FakeTransport());
+
+ await Assert.ThrowsAsync(
+ () => manager.SetMixChannelEnabledAsync(Mix.Personal, Channel.Master, true));
+ }
+
+ private static System.Text.Json.JsonElement Json(string json)
+ {
+ using var doc = System.Text.Json.JsonDocument.Parse(json);
+ return doc.RootElement.Clone();
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Tests/ServerDiscoveryTests.cs b/SteelSeriesAPI.Tests/ServerDiscoveryTests.cs
new file mode 100644
index 0000000..97647bd
--- /dev/null
+++ b/SteelSeriesAPI.Tests/ServerDiscoveryTests.cs
@@ -0,0 +1,73 @@
+using System.Text.Json;
+using SteelSeriesAPI.Core;
+using Xunit;
+
+namespace SteelSeriesAPI.Tests;
+
+public class ServerDiscoveryTests
+{
+ // Reduced fixture from the real /subApps dump (2026-08-04)
+ private const string ValidSubApps =
+ """
+ {"subApps":{"sonar":{"name":"sonar","isEnabled":true,"toggleViaSettings":true,
+ "autoStart":true,"isReady":true,"isRunning":true,"exitCode":0,
+ "metadata":{"encryptedWebServerAddress":"","webServerAddress":"http://127.0.0.1:53784",
+ "offlineFrontendAddress":"","onlineFrontendAddress":""},"secretMetadata":null}}}
+ """;
+
+ private const string SonarStopped =
+ """{"subApps":{"sonar":{"isRunning":false,"metadata":null}}}""";
+
+ // Regression: transient state observed on 2026-08-04 when GG restarts
+ private const string SonarStartingUp =
+ """{"subApps":{"sonar":{"isRunning":true,"metadata":null}}}""";
+
+ private const string NoSonarEntry =
+ """{"subApps":{"engine":{"isRunning":true}}}""";
+
+ [Fact]
+ public void ParseSonarAddress_WithValidPayload_ReturnsAddress()
+ {
+ var result = ServerDiscovery.ParseSonarAddress(ValidSubApps);
+
+ Assert.Equal(new Uri("http://127.0.0.1:53784"), result);
+ }
+
+ [Fact]
+ public void ParseSonarAddress_WhenSonarStopped_ThrowsSonarNotRunning()
+ {
+ Assert.Throws(
+ () => ServerDiscovery.ParseSonarAddress(SonarStopped));
+ }
+
+ [Fact]
+ public void ParseSonarAddress_WhenSonarStartingUp_ThrowsSonarNotRunning()
+ {
+ // This case crashed with InvalidOperationException before the fix:
+ // metadata is null for ~3s while Sonar is starting up.
+ Assert.Throws(
+ () => ServerDiscovery.ParseSonarAddress(SonarStartingUp));
+ }
+
+ [Fact]
+ public void ParseSonarAddress_WhenSonarEntryMissing_ThrowsDiscovery()
+ {
+ Assert.Throws(
+ () => ServerDiscovery.ParseSonarAddress(NoSonarEntry));
+ }
+
+ [Fact]
+ public void ParseSonarAddress_WithUnknownExtraFields_StillWorks()
+ {
+ // Simulates a future GG update adding/renaming fields around the ones we read
+ const string futureVersion =
+ """
+ {"subApps":{"sonar":{"isRunning":true,"someNewField":42,"renamedThing":"x",
+ "metadata":{"webServerAddress":"http://127.0.0.1:9999","newMetaField":true}}}}
+ """;
+
+ var result = ServerDiscovery.ParseSonarAddress(futureVersion);
+
+ Assert.Equal(new Uri("http://127.0.0.1:9999"), result);
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Tests/SonarEventListenerTests.cs b/SteelSeriesAPI.Tests/SonarEventListenerTests.cs
new file mode 100644
index 0000000..95fc6c0
--- /dev/null
+++ b/SteelSeriesAPI.Tests/SonarEventListenerTests.cs
@@ -0,0 +1,234 @@
+using System.Text.Json;
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Events;
+using SteelSeriesAPI.Sonar.Models;
+using Xunit;
+
+namespace SteelSeriesAPI.Tests;
+
+public class SonarEventListenerTests
+{
+ // ---------------------------------------------------------------
+ // Fixtures: real payloads captured from ws://.../sock on 2026-08-08
+ // (the "data" part of each event message, trimmed to 3 channels
+ // where the full capture had 6 - the parser does not require all)
+ // ---------------------------------------------------------------
+
+ private const string ChatMixData =
+ """{"balance":-0.53,"state":"enabled","id":"00000000-0000-0000-0000-000000000000"}""";
+
+ // Streamer mode active: "stream" sections filled, "classic" sections stale
+ private const string StreamerVolumeData =
+ """
+ {"masters":{"stream":{"streaming":{"volume":1.0,"muted":false},"monitoring":{"volume":1.0,"muted":false}},"classic":{"volume":0.0,"muted":false}},
+ "devices":{
+ "game":{"stream":{"streaming":{"volume":1.0,"muted":false},"monitoring":{"volume":0.42,"muted":false}},"classic":{"volume":0.0,"muted":false}},
+ "chatRender":{"stream":{"streaming":{"volume":0.33,"muted":true},"monitoring":{"volume":1.0,"muted":false}},"classic":{"volume":0.0,"muted":false}}}}
+ """;
+
+ // Classic mode active: "stream" sections are empty objects
+ private const string ClassicVolumeData =
+ """
+ {"masters":{"stream":{},"classic":{"volume":1.0,"muted":false}},
+ "devices":{
+ "game":{"stream":{},"classic":{"volume":0.5,"muted":false}},
+ "media":{"stream":{},"classic":{"volume":1.0,"muted":true}}}}
+ """;
+
+ /// Parses a JSON string and returns a JsonElement detached from the document lifetime.
+ private static JsonElement Json(string json)
+ {
+ using var doc = JsonDocument.Parse(json);
+ return doc.RootElement.Clone();
+ }
+
+ /// Shortcut to build a ChannelVolumes with optional per-mix settings.
+ private static ChannelVolumes Volumes(
+ (double vol, bool muted)? classic = null,
+ (double vol, bool muted)? personal = null,
+ (double vol, bool muted)? stream = null)
+ {
+ return new ChannelVolumes(
+ classic is { } c ? new VolumeSetting(c.vol, c.muted) : null,
+ personal is { } p ? new VolumeSetting(p.vol, p.muted) : null,
+ stream is { } s ? new VolumeSetting(s.vol, s.muted) : null);
+ }
+
+ // ---------------------------------------------------------------
+ // ParseChatMix
+ // ---------------------------------------------------------------
+
+ [Fact]
+ public void ParseChatMix_RealPayload_ParsesBalanceAndState()
+ {
+ var setting = SonarEventListener.ParseChatMix(Json(ChatMixData));
+
+ Assert.Equal(new ChatMixSetting(-0.53, "enabled"), setting);
+ }
+
+ [Fact]
+ public void ParseChatMix_NullData_ReturnsNeutralDefaults()
+ {
+ // Some Sonar events arrive with "data": null - parsing must never crash
+ var setting = SonarEventListener.ParseChatMix(Json("null"));
+
+ Assert.Equal(new ChatMixSetting(0.0, null), setting);
+ }
+
+ // ---------------------------------------------------------------
+ // ParseVolumeSnapshot
+ // ---------------------------------------------------------------
+
+ [Fact]
+ public void ParseVolumeSnapshot_StreamerCapture_ParsesAllMixes()
+ {
+ var snapshot = SonarEventListener.ParseVolumeSnapshot(Json(StreamerVolumeData));
+
+ Assert.Equal(3, snapshot.Channels.Count); // Master + game + chatRender
+
+ var game = snapshot.Channels[Channel.Game];
+ Assert.Equal(new VolumeSetting(0.42, false), game.Personal);
+ Assert.Equal(new VolumeSetting(1.0, false), game.Stream);
+ Assert.Equal(new VolumeSetting(0.0, false), game.Classic);
+
+ var chat = snapshot.Channels[Channel.Chat];
+ Assert.Equal(new VolumeSetting(0.33, true), chat.Stream);
+ }
+
+ [Fact]
+ public void ParseVolumeSnapshot_ClassicCapture_MixesAreNull()
+ {
+ // In classic mode the "stream" nodes are empty objects: mixes must be null, not zeroed
+ var snapshot = SonarEventListener.ParseVolumeSnapshot(Json(ClassicVolumeData));
+
+ var game = snapshot.Channels[Channel.Game];
+ Assert.Equal(new VolumeSetting(0.5, false), game.Classic);
+ Assert.Null(game.Personal);
+ Assert.Null(game.Stream);
+
+ Assert.Equal(new VolumeSetting(1.0, true), snapshot.Channels[Channel.Media].Classic);
+ }
+
+ [Fact]
+ public void ParseVolumeSnapshot_UnknownChannel_IsSkippedWithoutCrashing()
+ {
+ // A future GG update adding a new channel must not break parsing (the V1 lesson)
+ const string withUnknown =
+ """
+ {"masters":{"stream":{},"classic":{"volume":1.0,"muted":false}},
+ "devices":{
+ "subwoofer":{"stream":{},"classic":{"volume":0.8,"muted":false}},
+ "game":{"stream":{},"classic":{"volume":0.5,"muted":false}}}}
+ """;
+
+ var snapshot = SonarEventListener.ParseVolumeSnapshot(Json(withUnknown));
+
+ Assert.Equal(2, snapshot.Channels.Count); // Master + game, subwoofer skipped
+ Assert.True(snapshot.Channels.ContainsKey(Channel.Game));
+ }
+
+ // ---------------------------------------------------------------
+ // Diff
+ // ---------------------------------------------------------------
+
+ [Fact]
+ public void Diff_ClassicVolumeChange_IsDetected()
+ {
+ var previous = new VolumeSnapshot(new Dictionary
+ {
+ [Channel.Game] = Volumes(classic: (0.5, false))
+ });
+ var current = new VolumeSnapshot(new Dictionary
+ {
+ [Channel.Game] = Volumes(classic: (0.8, false))
+ });
+
+ var change = Assert.Single(SonarEventListener.Diff(previous, current, Mode.Classic));
+
+ Assert.Equal(Channel.Game, change.Channel);
+ Assert.Null(change.Mix);
+ Assert.Equal(0.8, change.NewVolume);
+ Assert.Equal(0.5, change.PreviousVolume);
+ Assert.False(change.MuteToggled);
+ }
+
+ [Fact]
+ public void Diff_MuteToggle_SetsMuteToggled()
+ {
+ var previous = new VolumeSnapshot(new Dictionary
+ {
+ [Channel.Chat] = Volumes(classic: (0.4, false))
+ });
+ var current = new VolumeSnapshot(new Dictionary
+ {
+ [Channel.Chat] = Volumes(classic: (0.4, true))
+ });
+
+ var change = Assert.Single(SonarEventListener.Diff(previous, current, Mode.Classic));
+
+ Assert.True(change.MuteToggled);
+ Assert.True(change.IsMuted);
+ Assert.False(change.WasMuted);
+ }
+
+ [Fact]
+ public void Diff_InClassicMode_IgnoresStreamerSections()
+ {
+ // The other mode's sections return stale data (observed 2026-08-08):
+ // a Personal change must NOT produce an event while diffing in classic mode.
+ var previous = new VolumeSnapshot(new Dictionary
+ {
+ [Channel.Game] = Volumes(classic: (0.5, false), personal: (1.0, false))
+ });
+ var current = new VolumeSnapshot(new Dictionary
+ {
+ [Channel.Game] = Volumes(classic: (0.5, false), personal: (0.2, false))
+ });
+
+ Assert.Empty(SonarEventListener.Diff(previous, current, Mode.Classic));
+ }
+
+ [Fact]
+ public void Diff_InStreamerMode_DetectsBothMixesIndependently()
+ {
+ var previous = new VolumeSnapshot(new Dictionary
+ {
+ [Channel.Game] = Volumes(personal: (1.0, false), stream: (1.0, false))
+ });
+ var current = new VolumeSnapshot(new Dictionary
+ {
+ [Channel.Game] = Volumes(personal: (0.7, false), stream: (0.3, false))
+ });
+
+ var changes = SonarEventListener.Diff(previous, current, Mode.Streamer).ToList();
+
+ Assert.Equal(2, changes.Count);
+ Assert.Contains(changes, c => c.Mix == Mix.Personal && c.NewVolume == 0.7);
+ Assert.Contains(changes, c => c.Mix == Mix.Stream && c.NewVolume == 0.3);
+ }
+
+ [Fact]
+ public void Diff_IdenticalSnapshots_ReturnsNothing()
+ {
+ var snapshot = new VolumeSnapshot(new Dictionary
+ {
+ [Channel.Game] = Volumes(classic: (0.5, false)),
+ [Channel.Media] = Volumes(classic: (1.0, true))
+ });
+
+ Assert.Empty(SonarEventListener.Diff(snapshot, snapshot, Mode.Classic));
+ }
+
+ [Fact]
+ public void Diff_ChannelAbsentFromPrevious_IsSkipped()
+ {
+ // First sighting of a channel: nothing to compare against, no event
+ var previous = new VolumeSnapshot(new Dictionary());
+ var current = new VolumeSnapshot(new Dictionary
+ {
+ [Channel.Game] = Volumes(classic: (0.5, false))
+ });
+
+ Assert.Empty(SonarEventListener.Diff(previous, current, Mode.Classic));
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj b/SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj
index fc7bbf8..78fdb46 100644
--- a/SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj
+++ b/SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj
@@ -1,14 +1,23 @@
- Exe
+ net8.0;net10.0
enable
enable
- net7.0;net8.0;net9.0
- 7.0.0
- LatestMajor
+ false
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SteelSeriesAPI.Tests/VolumeSettingsManagerTests.cs b/SteelSeriesAPI.Tests/VolumeSettingsManagerTests.cs
new file mode 100644
index 0000000..87d551e
--- /dev/null
+++ b/SteelSeriesAPI.Tests/VolumeSettingsManagerTests.cs
@@ -0,0 +1,102 @@
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar;
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Managers;
+using SteelSeriesAPI.Sonar.Models;
+using Xunit;
+
+namespace SteelSeriesAPI.Tests;
+
+public class VolumeSettingsManagerTests
+{
+ // Real dump captured on 2026-08-07 (streamer mode active, classic route)
+ private const string ClassicDump =
+ """
+ {"masters":{"stream":{},"classic":{"volume":0.0,"muted":false}},"devices":{"game":{"stream":{},"classic":{"volume":1.0,"muted":false}},"chatRender":{"stream":{},"classic":{"volume":1.0,"muted":false}},"chatCapture":{"stream":{},"classic":{"volume":1.0,"muted":false}},"media":{"stream":{},"classic":{"volume":1.0,"muted":false}},"aux":{"stream":{},"classic":{"volume":1.0,"muted":false}}}}
+ """;
+
+ // Real dump captured on 2026-08-07 (streamer mode active, streamer route)
+ private const string StreamerDump =
+ """
+ {"masters":{"stream":{"streaming":{"volume":1.0,"muted":false},"monitoring":{"volume":1.0,"muted":false}},"classic":{"volume":0.0,"muted":false}},"devices":{"game":{"stream":{"streaming":{"volume":1.0,"muted":false},"monitoring":{"volume":0.42,"muted":false}},"classic":{"volume":0.0,"muted":false}},"chatRender":{"stream":{"streaming":{"volume":1.0,"muted":false},"monitoring":{"volume":1.0,"muted":false}},"classic":{"volume":0.0,"muted":false}},"chatCapture":{"stream":{"streaming":{"volume":1.0,"muted":false},"monitoring":{"volume":1.0,"muted":false}},"classic":{"volume":0.0,"muted":false}},"media":{"stream":{"streaming":{"volume":1.0,"muted":false},"monitoring":{"volume":1.0,"muted":false}},"classic":{"volume":0.0,"muted":false}},"aux":{"stream":{"streaming":{"volume":1.0,"muted":false},"monitoring":{"volume":1.0,"muted":false}},"classic":{"volume":0.0,"muted":false}}}}
+ """;
+
+ [Fact]
+ public async Task GetAsync_ClassicMaster_ParsesVolumeAndMute()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.ClassicVolumes, ClassicDump);
+ var manager = new VolumeSettingsManager(transport);
+
+ var setting = await manager.GetAsync(Channel.Master);
+
+ Assert.Equal(new VolumeSetting(0.0, false), setting);
+ }
+
+ [Fact]
+ public async Task GetAsync_ClassicGame_ParsesDeviceChannel()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.ClassicVolumes, ClassicDump);
+ var manager = new VolumeSettingsManager(transport);
+
+ var setting = await manager.GetAsync(Channel.Game);
+
+ Assert.Equal(new VolumeSetting(1.0, false), setting);
+ }
+
+ [Fact]
+ public async Task GetAsync_StreamerPersonalMix_ReadsMonitoringVolume()
+ {
+ var transport = new FakeTransport().With(SonarRoutes.StreamerVolumes, StreamerDump);
+ var manager = new VolumeSettingsManager(transport);
+
+ var setting = await manager.GetAsync(Channel.Game, Mix.Personal);
+
+ Assert.Equal(new VolumeSetting(0.42, false), setting);
+ }
+
+ [Fact]
+ public async Task SetVolumeAsync_BuildsInvariantCultureRoute()
+ {
+ // Guards against "0,37" appearing in URLs on French/German machines
+ var transport = new FakeTransport();
+ var manager = new VolumeSettingsManager(transport);
+
+ await manager.SetVolumeAsync(Channel.Game, 0.37);
+
+ Assert.Equal("volumeSettings/classic/game/Volume/0.37", Assert.Single(transport.PutRoutes));
+ }
+
+ [Fact]
+ public async Task SetMuteAsync_BuildsLowercaseBooleanRoute()
+ {
+ var transport = new FakeTransport();
+ var manager = new VolumeSettingsManager(transport);
+
+ await manager.SetMuteAsync(Channel.Chat, true);
+
+ Assert.Equal("volumeSettings/classic/chatRender/Mute/true", Assert.Single(transport.PutRoutes));
+ }
+
+ [Theory]
+ [InlineData(1.5)]
+ [InlineData(double.NaN)]
+ public async Task SetVolumeAsync_OutOfRange_Throws(double invalid)
+ {
+ var transport = new FakeTransport();
+ var manager = new VolumeSettingsManager(transport);
+
+ await Assert.ThrowsAsync(() => manager.SetVolumeAsync(Channel.Game, invalid));
+ Assert.Empty(transport.PutRoutes); // validation must happen BEFORE any HTTP call
+ }
+
+ [Fact]
+ public async Task GetAsync_EmptyStreamNode_ThrowsSonarResponse()
+ {
+ // In the classic dump, "stream" nodes are empty objects: digging into a mix must fail loudly
+ var transport = new FakeTransport().With(SonarRoutes.StreamerVolumes, ClassicDump);
+ var manager = new VolumeSettingsManager(transport);
+
+ await Assert.ThrowsAsync(
+ () => manager.GetAsync(Channel.Game, Mix.Personal));
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Core/ISonarTransport.cs b/SteelSeriesAPI/Core/ISonarTransport.cs
new file mode 100644
index 0000000..576717b
--- /dev/null
+++ b/SteelSeriesAPI/Core/ISonarTransport.cs
@@ -0,0 +1,17 @@
+using System.Text.Json;
+
+namespace SteelSeriesAPI.Core;
+
+/// Low-level transport to the Sonar web server.
+public interface ISonarTransport
+{
+ /// Sends a GET request and returns the parsed JSON response.
+ /// The route, relative to the Sonar server base address.
+ /// A token to cancel the operation.
+ Task GetAsync(string route, CancellationToken ct = default);
+
+ /// Sends a PUT request.
+ /// The route, relative to the Sonar server base address.
+ /// A token to cancel the operation.
+ Task PutAsync(string route, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Core/JsonExtensions.cs b/SteelSeriesAPI/Core/JsonExtensions.cs
new file mode 100644
index 0000000..d09b360
--- /dev/null
+++ b/SteelSeriesAPI/Core/JsonExtensions.cs
@@ -0,0 +1,46 @@
+using System.Text.Json;
+
+namespace SteelSeriesAPI.Core;
+
+/// Tolerant navigation helpers for .
+internal static class JsonExtensions
+{
+ ///
+ /// Walks down a chain of JSON object properties, tolerating missing or null nodes.
+ ///
+ /// A node in the path is missing, null, or not an object.
+ internal static JsonElement Dig(this JsonElement element, params string[] path)
+ {
+ JsonElement current = element;
+ foreach (string key in path)
+ {
+ if (current.ValueKind != JsonValueKind.Object ||
+ !current.TryGetProperty(key, out current))
+ {
+ throw new SonarResponseException(
+ $"Expected JSON path '{string.Join('/', path)}' not found in Sonar response (missing at '{key}').");
+ }
+ }
+ return current;
+ }
+
+ ///
+ /// Reads a string property, tolerating a missing property, a null/non-string value,
+ /// or a parent that is not a JSON object. Returns null in all those cases.
+ ///
+ internal static string? GetStringOrNull(this JsonElement element, string propertyName) =>
+ element.ValueKind == JsonValueKind.Object &&
+ element.TryGetProperty(propertyName, out var p) &&
+ p.ValueKind == JsonValueKind.String
+ ? p.GetString()
+ : null;
+
+ ///
+ /// Reads a boolean property, tolerating a missing property, a null/non-boolean value,
+ /// or a parent that is not a JSON object. Returns false in all those cases.
+ ///
+ internal static bool GetBoolOrFalse(this JsonElement element, string propertyName) =>
+ element.ValueKind == JsonValueKind.Object &&
+ element.TryGetProperty(propertyName, out var p) &&
+ p.ValueKind == JsonValueKind.True;
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Core/ServerDiscovery.cs b/SteelSeriesAPI/Core/ServerDiscovery.cs
new file mode 100644
index 0000000..85c6083
--- /dev/null
+++ b/SteelSeriesAPI/Core/ServerDiscovery.cs
@@ -0,0 +1,107 @@
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace SteelSeriesAPI.Core;
+
+///
+/// Discovers the Sonar web server address by reading coreProps.json
+/// and querying the GG /subApps endpoint.
+///
+public sealed class ServerDiscovery
+{
+ private readonly HttpClient _ggClient;
+ private readonly string _corePropsPath;
+ private readonly ILogger _logger;
+
+ /// Creates a new discovery service.
+ /// Optional logger for diagnostics. When null, the library stays silent.
+ /// Overrides the default coreProps.json location. Mainly useful for testing.
+ public ServerDiscovery(ILogger? logger = null, string? corePropsPath = null)
+ {
+ _logger = logger ?? NullLogger.Instance;
+ _corePropsPath = corePropsPath ?? Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
+ "SteelSeries", "GG", "coreProps.json");
+
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (message, _, _, _) =>
+ message.RequestUri?.IsLoopback ?? false
+ };
+ _ggClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(5) };
+ }
+
+ /// Reads coreProps.json and returns the GG encrypted server address.
+ public string GetGGAddress()
+ {
+ if (!File.Exists(_corePropsPath))
+ throw new SteelSeriesNotFoundException(
+ $"coreProps.json not found at '{_corePropsPath}'. Is SteelSeries GG installed and running?");
+
+ using var coreProps = JsonDocument.Parse(File.ReadAllText(_corePropsPath));
+
+ if (!coreProps.RootElement.TryGetProperty("ggEncryptedAddress", out var address))
+ throw new DiscoveryException("Field 'ggEncryptedAddress' missing from coreProps.json.");
+
+ return address.GetString()
+ ?? throw new DiscoveryException("Field 'ggEncryptedAddress' is null in coreProps.json.");
+ }
+
+ /// Queries /subApps and returns the Sonar web server base address.
+ public async Task DiscoverSonarAddressAsync(CancellationToken ct = default)
+ {
+ string ggAddress = GetGGAddress();
+ _logger.LogDebug("Querying subApps at {Address}", ggAddress);
+
+ string json;
+ try
+ {
+ json = await _ggClient.GetStringAsync($"https://{ggAddress}/subApps", ct);
+ }
+ catch (HttpRequestException ex)
+ {
+ throw new DiscoveryException(
+ $"Could not reach the GG server at '{ggAddress}'. Is SteelSeries GG running?", ex);
+ }
+ catch (TaskCanceledException ex) when (!ct.IsCancellationRequested)
+ {
+ throw new DiscoveryException(
+ $"The GG server at '{ggAddress}' did not respond within {_ggClient.Timeout.TotalSeconds:0}s.", ex);
+ }
+
+ return ParseSonarAddress(json);
+ }
+
+ /// Extracts the Sonar address from a /subApps JSON payload.
+ internal static Uri ParseSonarAddress(string subAppsJson)
+ {
+ using var doc = JsonDocument.Parse(subAppsJson);
+
+ if (!doc.RootElement.TryGetProperty("subApps", out var subApps) ||
+ subApps.ValueKind != JsonValueKind.Object ||
+ !subApps.TryGetProperty("sonar", out var sonar) ||
+ sonar.ValueKind != JsonValueKind.Object)
+ throw new DiscoveryException("Sonar entry not found in /subApps response.");
+
+ // Only the 2 fields we actually need. Everything else may change freely.
+ bool isRunning = sonar.TryGetProperty("isRunning", out var running) &&
+ running.ValueKind == JsonValueKind.True;
+ if (!isRunning)
+ throw new SonarNotRunningException();
+
+ string? address = null;
+ if (sonar.TryGetProperty("metadata", out var meta) &&
+ meta.ValueKind == JsonValueKind.Object &&
+ meta.TryGetProperty("webServerAddress", out var addr) &&
+ addr.ValueKind == JsonValueKind.String)
+ {
+ address = addr.GetString();
+ }
+
+ if (string.IsNullOrEmpty(address))
+ throw new SonarNotRunningException(); // startup in progress: transient, resolves on its own
+
+ return new Uri(address);
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Core/SonarExceptions.cs b/SteelSeriesAPI/Core/SonarExceptions.cs
new file mode 100644
index 0000000..243e511
--- /dev/null
+++ b/SteelSeriesAPI/Core/SonarExceptions.cs
@@ -0,0 +1,80 @@
+namespace SteelSeriesAPI.Core;
+
+/// Base class for all exceptions thrown by this library.
+public class SteelSeriesException : Exception
+{
+ /// Creates the exception with a message describing the failure.
+ /// A description of what went wrong.
+ /// The underlying exception, if any.
+ public SteelSeriesException(string message, Exception? inner = null)
+ : base(message, inner) { }
+}
+
+/// SteelSeries GG is not installed or coreProps.json cannot be found.
+public class SteelSeriesNotFoundException : SteelSeriesException
+{
+ /// Creates the exception with a message describing where the lookup failed.
+ /// A description of what went wrong.
+ public SteelSeriesNotFoundException(string message) : base(message) { }
+}
+
+/// Sonar is not enabled or not running inside GG.
+public class SonarNotRunningException : SteelSeriesException
+{
+ /// Creates the exception with a default message.
+ public SonarNotRunningException()
+ : base("Sonar is not running. Enable it in SteelSeries GG.") { }
+}
+
+/// The GG/Sonar API responded with an unexpected structure.
+public class DiscoveryException : SteelSeriesException
+{
+ /// Creates the exception with a message describing where the discovery failed.
+ /// A description of what went wrong.
+ /// The underlying exception, if any.
+ public DiscoveryException(string message, Exception? inner = null)
+ : base(message, inner) { }
+}
+
+/// The Sonar API responded with an unexpected JSON structure.
+public class SonarResponseException : SteelSeriesException
+{
+ /// Creates the exception with a message describing where the response failed.
+ /// A description of what went wrong.
+ public SonarResponseException(string message) : base(message) { }
+}
+
+/// The Sonar server received the request but rejected it with an HTTP error status.
+public class SonarRequestException : SteelSeriesException
+{
+ /// The HTTP status code returned by the server.
+ public int StatusCode { get; }
+
+ /// The raw response body, which may contain details about the rejection.
+ public string? ResponseBody { get; }
+
+ /// Creates the exception from the rejected route and the server response.
+ /// The route that was rejected.
+ /// The HTTP status code returned by the server.
+ /// The raw response body, if any.
+ public SonarRequestException(string route, int statusCode, string? responseBody)
+ : base($"Sonar rejected '{route}' with HTTP {statusCode}." +
+ (string.IsNullOrWhiteSpace(responseBody) ? "" : $" Response: {responseBody}"))
+ {
+ StatusCode = statusCode;
+ ResponseBody = responseBody;
+ }
+}
+
+///
+/// The requested operation is not available in the current mixer mode.
+/// For example, classic volume routes cannot be written while streamer mode is active.
+/// Check to read or switch the mode.
+///
+public class SonarWrongModeException : SteelSeriesException
+{
+ /// Creates the exception from the rejected route.
+ /// The route that was rejected.
+ public SonarWrongModeException(string route)
+ : base($"Sonar rejected '{route}': this operation is not available in the current mixer mode.") { }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Core/SonarHttpClient.cs b/SteelSeriesAPI/Core/SonarHttpClient.cs
new file mode 100644
index 0000000..d0747d0
--- /dev/null
+++ b/SteelSeriesAPI/Core/SonarHttpClient.cs
@@ -0,0 +1,121 @@
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace SteelSeriesAPI.Core;
+
+///
+/// Resilient HTTP client for the Sonar web server.
+/// Caches the server address and transparently rediscovers it when GG restarts.
+///
+public sealed class SonarHttpClient : IDisposable, ISonarTransport
+{
+ private readonly HttpClient _http;
+ private readonly ServerDiscovery _discovery;
+ private readonly SemaphoreSlim _discoveryLock = new(1, 1);
+ private readonly ILogger _logger;
+
+ private Uri? _baseAddress;
+
+ /// Creates a new Sonar HTTP client.
+ /// The discovery service used to locate the Sonar web server.
+ /// Optional logger for diagnostics. When null, the library stays silent.
+ public SonarHttpClient(ServerDiscovery discovery, ILogger? logger = null)
+ {
+ _discovery = discovery;
+ _logger = logger ?? NullLogger.Instance;
+ _http = new HttpClient { Timeout = TimeSpan.FromSeconds(5) };
+ }
+
+ /// Sends a GET request to the Sonar server and returns the parsed JSON response.
+ /// The route, relative to the Sonar server base address.
+ /// A token to cancel the operation.
+ public async Task GetAsync(string route, CancellationToken ct = default)
+ {
+ using var response = await SendAsync(HttpMethod.Get, route, ct);
+ await using var stream = await response.Content.ReadAsStreamAsync(ct);
+ return await JsonDocument.ParseAsync(stream, cancellationToken: ct);
+ }
+
+
+ /// Sends a PUT request to the Sonar server.
+ /// The route, relative to the Sonar server base address.
+ /// A token to cancel the operation.
+ public async Task PutAsync(string route, CancellationToken ct = default)
+ {
+ using var _ = await SendAsync(HttpMethod.Put, route, ct);
+ }
+
+ private async Task SendAsync(
+ HttpMethod method, string route, CancellationToken ct, bool isRetry = false)
+ {
+ Uri baseAddress = await GetBaseAddressAsync(ct);
+ var request = new HttpRequestMessage(method, new Uri(baseAddress, route));
+
+ HttpResponseMessage response;
+ try
+ {
+ response = await _http.SendAsync(request, ct);
+ }
+ catch (HttpRequestException ex) when (!isRetry)
+ {
+ // Transport-level failure (connection refused, reset...):
+ // GG may have restarted on a new port. Rediscover once, retry once.
+ _logger.LogInformation(ex, "Request to {Route} failed, rediscovering Sonar address", route);
+ InvalidateAddress();
+ return await SendAsync(method, route, ct, isRetry: true);
+ }
+ catch (TaskCanceledException ex) when (!ct.IsCancellationRequested && !isRetry)
+ {
+ // Timeout (not a caller cancellation): treat as a transport failure.
+ _logger.LogInformation(ex, "Request to {Route} timed out, rediscovering Sonar address", route);
+ InvalidateAddress();
+ return await SendAsync(method, route, ct, isRetry: true);
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ string body = await response.Content.ReadAsStringAsync(ct);
+
+ if (body.Contains("Cannot be called in current mode", StringComparison.OrdinalIgnoreCase))
+ throw new SonarWrongModeException(route);
+
+ throw new SonarRequestException(route, (int)response.StatusCode, body);
+ }
+
+ return response;
+ }
+
+ private async Task GetBaseAddressAsync(CancellationToken ct)
+ {
+ if (_baseAddress is not null) return _baseAddress;
+
+ await _discoveryLock.WaitAsync(ct);
+ try
+ {
+ // Another caller may have resolved it while we waited.
+ return _baseAddress ??= await _discovery.DiscoverSonarAddressAsync(ct);
+ }
+ finally
+ {
+ _discoveryLock.Release();
+ }
+ }
+
+ ///
+ /// Resolves and returns the current Sonar web server address,
+ /// discovering it if not already cached.
+ ///
+ /// A token to cancel the operation.
+ public Task GetServerAddressAsync(CancellationToken ct = default) =>
+ GetBaseAddressAsync(ct);
+
+ internal void InvalidateAddress() => _baseAddress = null;
+
+ /// Releases the underlying HTTP resources.
+ public void Dispose()
+ {
+ _http.Dispose();
+ _discoveryLock.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Exceptions/SteelSeriesNotRunningException.cs b/SteelSeriesAPI/Exceptions/SteelSeriesNotRunningException.cs
deleted file mode 100644
index a2de28a..0000000
--- a/SteelSeriesAPI/Exceptions/SteelSeriesNotRunningException.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace SteelSeriesAPI.Exceptions;
-
-public class SteelSeriesNotRunningException : Exception
-{
- public SteelSeriesNotRunningException() : base("SteelSeries is not running.") { }
- public SteelSeriesNotRunningException(string message) : base(message) { }
- public SteelSeriesNotRunningException(string message, Exception innerException) : base(message, innerException) { }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Interfaces/IAppRetriever.cs b/SteelSeriesAPI/Interfaces/IAppRetriever.cs
deleted file mode 100644
index 948219d..0000000
--- a/SteelSeriesAPI/Interfaces/IAppRetriever.cs
+++ /dev/null
@@ -1,18 +0,0 @@
-namespace SteelSeriesAPI.Interfaces;
-
-public interface IAppRetriever
-{
- string Name { get; }
- bool IsEnabled { get; }
- bool IsReady { get; }
- bool IsRunning { get; }
- bool ShouldAutoStart { get; }
- bool IsWindowsSupported { get; }
- bool IsMacSupported { get; }
- bool ToggleViaSettings { get; }
- bool IsBrowserViewSupported { get; }
-
- string WebServerAddress();
-
- void WaitUntilAppStarted();
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Interfaces/ISteelSeriesRetriever.cs b/SteelSeriesAPI/Interfaces/ISteelSeriesRetriever.cs
deleted file mode 100644
index 5af67ce..0000000
--- a/SteelSeriesAPI/Interfaces/ISteelSeriesRetriever.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-namespace SteelSeriesAPI.Interfaces;
-
-public interface ISteelSeriesRetriever
-{
- bool Running { get; }
-
- string GetggEncryptedAddress();
-
- void WaitUntilSteelSeriesStarted();
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Enums/AudioDataFlow.cs b/SteelSeriesAPI/Sonar/Enums/AudioDataFlow.cs
new file mode 100644
index 0000000..0551c6c
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Enums/AudioDataFlow.cs
@@ -0,0 +1,10 @@
+namespace SteelSeriesAPI.Sonar.Enums;
+
+/// The direction of an audio device.
+public enum AudioDataFlow
+{
+ /// An output device (speakers, headphones...).
+ Render,
+ /// An input device (microphones, line-in...).
+ Capture
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Enums/Channel.cs b/SteelSeriesAPI/Sonar/Enums/Channel.cs
index 4e82dec..1c25ed7 100644
--- a/SteelSeriesAPI/Sonar/Enums/Channel.cs
+++ b/SteelSeriesAPI/Sonar/Enums/Channel.cs
@@ -1,86 +1,86 @@
-namespace SteelSeriesAPI.Sonar.Enums;
+namespace SteelSeriesAPI.Sonar.Enums;
+/// A Sonar virtual audio channel.
public enum Channel
{
- MASTER,
- GAME,
- CHAT,
- MEDIA,
- AUX,
- MIC
-}
-
-public enum ChannelMapChoice
-{
- JsonDict,
- HttpDict,
- ChannelDict
+ /// The master channel, controlling the overall output.
+ Master,
+ /// The game audio channel.
+ Game,
+ /// The chat audio channel (incoming voice).
+ Chat,
+ /// The media audio channel.
+ Media,
+ /// The auxiliary audio channel.
+ Aux,
+ /// The microphone channel (outgoing voice).
+ Mic
}
+/// Mapping helpers between values and Sonar API identifiers.
public static class ChannelExtensions
{
- private static readonly Dictionary ChannelJsonMap = new Dictionary
- {
- { Channel.MASTER, "masters" },
- { Channel.GAME, "game" },
- { Channel.CHAT, "chatRender" },
- { Channel.MEDIA, "media" },
- { Channel.AUX, "aux" },
- { Channel.MIC, "chatCapture" }
- };
-
- private static readonly Dictionary ChannelHttpMap = new Dictionary
+ private static readonly Dictionary JsonKeys = new()
{
- { Channel.MASTER, "Master" },
- { Channel.GAME, "game" },
- { Channel.CHAT, "chatRender" },
- { Channel.MEDIA, "media" },
- { Channel.AUX, "aux" },
- { Channel.MIC, "chatCapture" }
+ { Channel.Master, "masters" },
+ { Channel.Game, "game" },
+ { Channel.Chat, "chatRender" },
+ { Channel.Media, "media" },
+ { Channel.Aux, "aux" },
+ { Channel.Mic, "chatCapture" }
};
- private static readonly Dictionary ChannelMap = new Dictionary
+ private static readonly Dictionary RouteKeys = new()
{
- { Channel.MASTER, "master" },
- { Channel.GAME, "game" },
- { Channel.CHAT, "chat" },
- { Channel.MEDIA, "media" },
- { Channel.AUX, "aux" },
- { Channel.MIC, "mic" }
+ { Channel.Master, "Master" },
+ { Channel.Game, "game" },
+ { Channel.Chat, "chatRender" },
+ { Channel.Media, "media" },
+ { Channel.Aux, "aux" },
+ { Channel.Mic, "chatCapture" }
};
- public static string ToDictKey(this Channel channel, ChannelMapChoice context = ChannelMapChoice.JsonDict)
+ // Classic redirections use a third naming scheme: "chat"/"mic" instead of
+// "chatRender"/"chatCapture" (observed 2026-08-14). Stream redirection statuses,
+// in the same route family, use the volumeSettings vocabulary. Yes, really.
+ private static readonly Dictionary ClassicRedirectionKeys = new()
{
- return context switch
- {
- ChannelMapChoice.JsonDict => ChannelJsonMap.ContainsKey(channel) ? ChannelJsonMap[channel] : null,
- ChannelMapChoice.HttpDict => ChannelHttpMap.ContainsKey(channel) ? ChannelHttpMap[channel] : null,
- ChannelMapChoice.ChannelDict => ChannelMap.ContainsKey(channel) ? ChannelMap[channel] : null,
- _ => null
- };
- }
+ { Channel.Game, "game" },
+ { Channel.Chat, "chat" },
+ { Channel.Media, "media" },
+ { Channel.Aux, "aux" },
+ { Channel.Mic, "mic" }
+ // No Master: the master channel is not a routable device.
+ };
+
+ /// Gets the key used for this channel in Sonar JSON responses.
+ public static string ToJsonKey(this Channel channel) => JsonKeys[channel];
+
+ /// Gets the key used for this channel in Sonar HTTP routes.
+ public static string ToRouteKey(this Channel channel) => RouteKeys[channel];
- public static Channel? FromDictKey(string jsonKey, ChannelMapChoice context = ChannelMapChoice.JsonDict)
- {
- var map = context switch
- {
- ChannelMapChoice.JsonDict => ChannelJsonMap,
- ChannelMapChoice.HttpDict => ChannelHttpMap,
- ChannelMapChoice.ChannelDict => ChannelMap,
- _ => null
- };
+ /// Gets the key used for this channel in classic redirection routes and payloads.
+ /// The channel has no redirection (Master).
+ public static string ToClassicRedirectionKey(this Channel channel) =>
+ ClassicRedirectionKeys.TryGetValue(channel, out var key)
+ ? key
+ : throw new ArgumentException($"Channel '{channel}' has no classic redirection.", nameof(channel));
- if (map != null)
- {
- foreach (var pair in map)
- {
- if (pair.Value.ToLower() == jsonKey.ToLower())
- {
- return pair.Key;
- }
- }
- }
+ /// Resolves a Sonar JSON key back to a , or null if unknown.
+ public static Channel? FromJsonKey(string key)
+ {
+ foreach (var pair in JsonKeys)
+ if (string.Equals(pair.Value, key, StringComparison.OrdinalIgnoreCase))
+ return pair.Key;
+ return null;
+ }
+ /// Resolves a classic redirection key back to a , or null if unknown.
+ public static Channel? FromClassicRedirectionKey(string key)
+ {
+ foreach (var pair in ClassicRedirectionKeys)
+ if (string.Equals(pair.Value, key, StringComparison.OrdinalIgnoreCase))
+ return pair.Key;
return null;
}
}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Enums/DataFlow.cs b/SteelSeriesAPI/Sonar/Enums/DataFlow.cs
deleted file mode 100644
index 082ff07..0000000
--- a/SteelSeriesAPI/Sonar/Enums/DataFlow.cs
+++ /dev/null
@@ -1,34 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Enums;
-
-public enum DataFlow
-{
- INPUT,
- OUTPUT
-}
-
-public static class DataFlowExtensions
-{
- private static readonly Dictionary DataFlowMap = new Dictionary
- {
- { DataFlow.INPUT, "capture" },
- { DataFlow.OUTPUT, "render" }
- };
-
- public static string ToDictKey(this DataFlow dataFlow)
- {
- return DataFlowMap[dataFlow];
- }
-
- public static DataFlow? FromDictKey(string jsonKey)
- {
- foreach (var pair in DataFlowMap)
- {
- if (pair.Value == jsonKey)
- {
- return pair.Key;
- }
- }
-
- return null;
- }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Enums/Mix.cs b/SteelSeriesAPI/Sonar/Enums/Mix.cs
index 6203d9d..6205ca7 100644
--- a/SteelSeriesAPI/Sonar/Enums/Mix.cs
+++ b/SteelSeriesAPI/Sonar/Enums/Mix.cs
@@ -1,34 +1,42 @@
-namespace SteelSeriesAPI.Sonar.Enums;
+namespace SteelSeriesAPI.Sonar.Enums;
+///
+/// A streamer-mode output mix.
+/// When streamer mode is enabled, Sonar splits the audio into two
+/// independent mixes that can be balanced separately.
+///
public enum Mix
{
- PERSONAL,
- STREAM
+ /// The personal mix: what the streamer hears in their own headset.
+ Personal,
+
+ /// The stream mix: what the audience hears through the streaming software.
+ Stream
}
+/// Mapping helpers between values and Sonar API identifiers.
public static class MixExtensions
{
- private static readonly Dictionary MixMap = new Dictionary
+ // Internal API ids differ from the UI names:
+ // the personal mix is "monitoring", the stream mix is "streaming".
+ private static readonly Dictionary ApiKeys = new()
{
- { Mix.PERSONAL, "monitoring" },
- { Mix.STREAM, "streaming" }
+ { Mix.Personal, "monitoring" },
+ { Mix.Stream, "streaming" }
};
- public static string ToDictKey(this Mix mix)
- {
- return MixMap[mix];
- }
-
- public static Mix? FromDictKey(string jsonKey)
+ /// Gets the identifier used for this mix in Sonar JSON responses.
+ public static string ToJsonKey(this Mix mix) => ApiKeys[mix];
+
+ /// Gets the identifier used for this mix in Sonar HTTP routes.
+ public static string ToRouteKey(this Mix mix) => ApiKeys[mix];
+
+ /// Resolves a Sonar API identifier back to a , or null if unknown.
+ public static Mix? FromJsonKey(string key)
{
- foreach (var pair in MixMap)
- {
- if (pair.Value == jsonKey)
- {
+ foreach (var pair in ApiKeys)
+ if (string.Equals(pair.Value, key, StringComparison.OrdinalIgnoreCase))
return pair.Key;
- }
- }
-
return null;
}
}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Enums/Mode.cs b/SteelSeriesAPI/Sonar/Enums/Mode.cs
index de0a76a..d7888d0 100644
--- a/SteelSeriesAPI/Sonar/Enums/Mode.cs
+++ b/SteelSeriesAPI/Sonar/Enums/Mode.cs
@@ -1,61 +1,27 @@
-namespace SteelSeriesAPI.Sonar.Enums;
+namespace SteelSeriesAPI.Sonar.Enums;
+/// The operating mode of the Sonar mixer.
public enum Mode
{
- CLASSIC,
- STREAMER
-}
+ /// Classic mode: a single output mix with one volume per channel.
+ Classic,
-public enum ModeMapChoice
-{
- StreamerDict,
- StreamDict
+ /// Streamer mode: two independent output mixes (personal and stream).
+ Streamer
}
+/// Mapping helpers between values and Sonar API identifiers.
public static class ModeExtensions
{
- private static readonly Dictionary PrimaryModeMap = new Dictionary
- {
- { Mode.CLASSIC, "classic" },
- { Mode.STREAMER, "streamer" }
- };
-
- private static readonly Dictionary SecondaryModeMap = new Dictionary
- {
- { Mode.CLASSIC, "classic" },
- { Mode.STREAMER, "stream" }
- };
+ /// Gets the identifier used for this mode by the Sonar API.
+ public static string ToApiValue(this Mode mode) =>
+ mode == Mode.Streamer ? "stream" : "classic";
- public static string ToDictKey(this Mode mode, ModeMapChoice context = ModeMapChoice.StreamerDict)
- {
- return context switch
- {
- ModeMapChoice.StreamerDict => PrimaryModeMap.ContainsKey(mode) ? PrimaryModeMap[mode] : null,
- ModeMapChoice.StreamDict => SecondaryModeMap.ContainsKey(mode) ? SecondaryModeMap[mode] : null,
- _ => null
- };
- }
-
- public static Mode? FromDictKey(string jsonKey, ModeMapChoice context = ModeMapChoice.StreamerDict)
+ /// Resolves a Sonar API identifier back to a , or null if unknown.
+ public static Mode? FromApiValue(string value) => value switch
{
- var map = context switch
- {
- ModeMapChoice.StreamerDict => PrimaryModeMap,
- ModeMapChoice.StreamDict => SecondaryModeMap,
- _ => null
- };
-
- if (map != null)
- {
- foreach (var pair in map)
- {
- if (pair.Value == jsonKey)
- {
- return pair.Key;
- }
- }
- }
-
- return null;
- }
+ "classic" => Mode.Classic,
+ "stream" => Mode.Streamer,
+ _ => null
+ };
}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Enums/RoutedProcessState.cs b/SteelSeriesAPI/Sonar/Enums/RoutedProcessState.cs
deleted file mode 100644
index 5e0a91a..0000000
--- a/SteelSeriesAPI/Sonar/Enums/RoutedProcessState.cs
+++ /dev/null
@@ -1,36 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Enums;
-
-public enum RoutedProcessState
-{
- ACTIVE,
- INACTIVE,
- EXPIRED
-}
-
-public static class RoutedProcessStateExtensions
-{
- private static readonly Dictionary RoutedProcessStateMap = new Dictionary
- {
- { RoutedProcessState.ACTIVE, "active" },
- { RoutedProcessState.INACTIVE, "inactive" },
- { RoutedProcessState.EXPIRED, "expired" },
- };
-
- public static string ToDictKey(this RoutedProcessState state)
- {
- return RoutedProcessStateMap[state];
- }
-
- public static RoutedProcessState? FromDictKey(string jsonKey)
- {
- foreach (var pair in RoutedProcessStateMap)
- {
- if (pair.Value == jsonKey)
- {
- return pair.Key;
- }
- }
-
- return null;
- }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/ConfigSelectionChange.cs b/SteelSeriesAPI/Sonar/Events/ConfigSelectionChange.cs
new file mode 100644
index 0000000..e919499
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Events/ConfigSelectionChange.cs
@@ -0,0 +1,14 @@
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Events;
+
+/// The selected config of a channel changed.
+/// The channel whose selection changed.
+/// The config that was selected before, or null if unknown.
+/// The config that is selected now.
+public sealed record ConfigSelectionChange(Channel Channel, SonarConfig? PreviousConfig, SonarConfig NewConfig)
+{
+ /// The display name of the newly selected config. Shortcut for NewConfig.Name.
+ public string NewConfigName => NewConfig.Name;
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/Listener/DebouncedRefresher.cs b/SteelSeriesAPI/Sonar/Events/Listener/DebouncedRefresher.cs
new file mode 100644
index 0000000..9ccfa69
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Events/Listener/DebouncedRefresher.cs
@@ -0,0 +1,75 @@
+using Microsoft.Extensions.Logging;
+
+namespace SteelSeriesAPI.Sonar.Events;
+
+///
+/// Runs a refresh callback with two entry points: a debounced schedule (invalidation
+/// bursts collapse into a single refresh) and an immediate, awaited run (polling tick).
+/// Both paths are serialized by an internal lock.
+///
+internal sealed class DebouncedRefresher : IDisposable
+{
+ private static readonly TimeSpan DebounceDelay = TimeSpan.FromMilliseconds(250);
+
+ private readonly string _name;
+ private readonly Func _refresh;
+ private readonly ILogger _logger;
+ private readonly SemaphoreSlim _lock = new(1, 1);
+ private int _version;
+
+ /// Creates a refresher.
+ /// A short name used in log messages (e.g. "redirections").
+ /// The refresh work. Must not manage its own locking.
+ /// The listener's logger.
+ internal DebouncedRefresher(string name, Func refresh, ILogger logger)
+ {
+ _name = name;
+ _refresh = refresh;
+ _logger = logger;
+ }
+
+ ///
+ /// Schedules a refresh after the debounce delay. Each call supersedes the previous
+ /// one, so only the last invalidation of a burst actually refreshes.
+ ///
+ internal void Schedule(CancellationToken ct)
+ {
+ int version = Interlocked.Increment(ref _version);
+ _logger.LogDebug("{Name} refresh #{Version} scheduled", _name, version);
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await Task.Delay(DebounceDelay, ct);
+ if (version != _version)
+ {
+ _logger.LogDebug("{Name} refresh #{Version} superseded", _name, version);
+ return;
+ }
+
+ await RunNowAsync(ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ // Listener stopping: expected, stay silent.
+ }
+ catch (Exception ex)
+ {
+ // Includes HTTP timeouts (TaskCanceledException with a non-cancelled token).
+ _logger.LogWarning(ex, "{Name} refresh #{Version} failed", _name, version);
+ }
+ }, ct);
+ }
+
+ /// Runs the refresh immediately, serialized with any scheduled refresh.
+ internal async Task RunNowAsync(CancellationToken ct)
+ {
+ await _lock.WaitAsync(ct);
+ try { await _refresh(ct); }
+ finally { _lock.Release(); }
+ }
+
+ ///
+ public void Dispose() => _lock.Dispose();
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Configs.cs b/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Configs.cs
new file mode 100644
index 0000000..48a9447
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Configs.cs
@@ -0,0 +1,42 @@
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Managers;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Events;
+
+// Config selection change detection: invalidation/polling-triggered fetch + diff + granular events.
+public sealed partial class SonarEventListener
+{
+ private readonly ConfigManager _configs;
+ private readonly DebouncedRefresher _configsRefresher;
+ private IReadOnlyDictionary? _selectedConfigsBaseline;
+
+ ///
+ /// Raised when Sonar broadcasts a config invalidation, without details.
+ /// Most consumers should prefer , which carries
+ /// the affected channel and both configs.
+ ///
+ public event EventHandler? ConfigsInvalidated;
+
+ /// Raised when the selected config of a channel changes.
+ public event EventHandler? ConfigSelectionChanged;
+
+ /// Fetches the selected configs, diffs them against the baseline, and raises granular events.
+ private async Task RefreshSelectedConfigsAsync(CancellationToken ct)
+ {
+ var selected = await _configs.GetSelectedAsync(ct);
+
+ if (_selectedConfigsBaseline is { } baseline)
+ {
+ foreach ((Channel channel, SonarConfig config) in selected)
+ {
+ SonarConfig? previous = baseline.GetValueOrDefault(channel);
+ if (previous?.Id != config.Id)
+ RaiseSafely(() => ConfigSelectionChanged?.Invoke(this,
+ new ConfigSelectionChange(channel, previous, config)));
+ }
+ }
+
+ _selectedConfigsBaseline = selected;
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Redirections.cs b/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Redirections.cs
new file mode 100644
index 0000000..eca5647
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Redirections.cs
@@ -0,0 +1,113 @@
+using Microsoft.Extensions.Logging;
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Managers;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Events;
+
+// Redirection change detection: invalidation/polling-triggered fetch + diff + granular events.
+public sealed partial class SonarEventListener
+{
+ private readonly RedirectionsManager _redirections;
+ private readonly DebouncedRefresher _redirectionsRefresher;
+ private RedirectionsSnapshot? _redirectionsBaseline;
+
+ ///
+ /// Raised when Sonar broadcasts a redirection invalidation, without details.
+ /// Most consumers should prefer the granular events: ,
+ /// , and .
+ ///
+ public event EventHandler? RedirectionsInvalidated;
+
+ /// Raised when a channel is routed to a different device in classic mode.
+ public event EventHandler? ClassicDeviceChanged;
+
+ /// Raised when a streamer-mode mix is routed to a different output device.
+ public event EventHandler? MixDeviceChanged;
+
+ /// Raised when a channel is enabled or disabled on a streamer-mode mix.
+ public event EventHandler? MixChannelToggled;
+
+ /// Raised when stream monitoring ("hear what the audience hears") is toggled.
+ public event EventHandler? StreamMonitoringChanged;
+
+ /// The full redirection state used as a diffing baseline.
+ internal sealed record RedirectionsSnapshot(
+ IReadOnlyList Classic,
+ StreamRedirections Stream,
+ bool MonitoringEnabled);
+
+ /// Fetches the full redirection state, diffs it against the baseline, and raises granular events.
+ private async Task RefreshRedirectionsAsync(CancellationToken ct)
+ {
+ var snapshot = new RedirectionsSnapshot(
+ await _redirections.GetClassicRedirectionsAsync(ct),
+ await _redirections.GetStreamRedirectionsAsync(ct),
+ await _redirections.GetStreamMonitoringEnabledAsync(ct));
+
+ if (_redirectionsBaseline is { } baseline)
+ {
+ RedirectionDiff diff = DiffRedirections(baseline, snapshot);
+
+ if (!diff.IsEmpty)
+ {
+ _logger.LogDebug(
+ "Redirection changes detected: {Classic} classic, {MixDev} mix devices, {Toggles} toggles, monitoring changed: {Mon}",
+ diff.ClassicDeviceChanges.Count, diff.MixDeviceChanges.Count,
+ diff.MixChannelToggles.Count, diff.MonitoringChange is not null);
+ }
+
+ foreach (var change in diff.ClassicDeviceChanges)
+ RaiseSafely(() => ClassicDeviceChanged?.Invoke(this, change));
+ foreach (var change in diff.MixDeviceChanges)
+ RaiseSafely(() => MixDeviceChanged?.Invoke(this, change));
+ foreach (var change in diff.MixChannelToggles)
+ RaiseSafely(() => MixChannelToggled?.Invoke(this, change));
+ if (diff.MonitoringChange is { } monitoring)
+ RaiseSafely(() => StreamMonitoringChanged?.Invoke(this, monitoring));
+ }
+ else
+ {
+ _logger.LogDebug("Redirection baseline seeded");
+ }
+
+ _redirectionsBaseline = snapshot;
+ }
+
+ /// Computes what changed between two redirection snapshots.
+ internal static RedirectionDiff DiffRedirections(RedirectionsSnapshot previous, RedirectionsSnapshot current)
+ {
+ var classicChanges = new List();
+ foreach (var cur in current.Classic)
+ {
+ var prev = previous.Classic.FirstOrDefault(r => r.Channel == cur.Channel);
+ if (prev is not null && prev.DeviceId != cur.DeviceId)
+ classicChanges.Add(new ClassicDeviceChange(cur.Channel, prev.DeviceId, cur.DeviceId));
+ }
+
+ var mixDeviceChanges = new List();
+ var mixToggles = new List();
+ DiffMix(previous.Stream.Personal, current.Stream.Personal);
+ DiffMix(previous.Stream.Stream, current.Stream.Stream);
+
+ void DiffMix(MixRedirection? prev, MixRedirection? cur)
+ {
+ if (prev is null || cur is null) return;
+
+ if (prev.DeviceId != cur.DeviceId)
+ mixDeviceChanges.Add(new MixDeviceChange(cur.Mix, prev.DeviceId, cur.DeviceId));
+
+ foreach ((Channel channel, bool enabled) in cur.EnabledChannels)
+ {
+ if (prev.EnabledChannels.TryGetValue(channel, out bool wasEnabled) && wasEnabled != enabled)
+ mixToggles.Add(new MixChannelToggle(cur.Mix, channel, enabled));
+ }
+ }
+
+ StreamMonitoringChange? monitoring = previous.MonitoringEnabled != current.MonitoringEnabled
+ ? new StreamMonitoringChange(current.MonitoringEnabled)
+ : null;
+
+ return new RedirectionDiff(classicChanges, mixDeviceChanges, mixToggles, monitoring);
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Volumes.cs b/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Volumes.cs
new file mode 100644
index 0000000..ad77496
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Volumes.cs
@@ -0,0 +1,166 @@
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Managers;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Events;
+
+// Volume and mode detection: the polling loop, the snapshot parsers, and the mode-aware diff.
+public sealed partial class SonarEventListener
+{
+ ///
+ /// Raised when Sonar pushes a full volume snapshot (on connection, after major changes
+ /// such as a mode switch, and on OS/hardware-initiated volume changes).
+ /// Most consumers should prefer , which carries granular diffs.
+ ///
+ public event EventHandler? VolumeDataReceived;
+
+ /// Raised when polling detects a volume or mute change. Requires .
+ public event EventHandler? VolumeChanged;
+
+ /// Raised when polling detects a mixer mode change. Requires .
+ public event EventHandler? ModeChanged;
+
+ ///
+ /// Polls the mode and the matching volume route, raising granular events on differences.
+ /// Each volumeSettings route only reliably reflects its own mode's values (observed
+ /// 2026-08-08: the other mode's section returns stale data), hence the mode-aware routing.
+ /// Also runs the redirection and config refreshes on the same cadence, because Sonar
+ /// does not broadcast changes received through its own HTTP API.
+ ///
+ private async Task RunPollingAsync(TimeSpan interval, CancellationToken ct)
+ {
+ var modeManager = new ModeManager(_httpClient);
+ VolumeSnapshot? baseline = null;
+ Mode? baselineMode = null;
+
+ while (!ct.IsCancellationRequested)
+ {
+ try { await Task.Delay(interval, ct); }
+ catch (OperationCanceledException) { break; }
+
+ try
+ {
+ Mode mode = await modeManager.GetAsync(ct);
+
+ string route = mode == Mode.Streamer
+ ? SonarRoutes.StreamerVolumes
+ : SonarRoutes.ClassicVolumes;
+
+ using var doc = await _httpClient.GetAsync(route, ct);
+ var snapshot = ParseVolumeSnapshot(doc.RootElement);
+
+ if (baselineMode is not null && baselineMode != mode)
+ {
+ Mode previous = baselineMode.Value;
+ RaiseSafely(() => ModeChanged?.Invoke(this, new ModeChange(previous, mode)));
+ }
+
+ // Only diff against a baseline captured in the same mode: comparing across
+ // modes would produce spurious events from the stale sections.
+ if (baseline is not null && baselineMode == mode)
+ {
+ foreach (VolumeChange change in Diff(baseline, snapshot, mode))
+ RaiseSafely(() => VolumeChanged?.Invoke(this, change));
+ }
+
+ baseline = snapshot;
+ baselineMode = mode;
+
+ await _redirectionsRefresher.RunNowAsync(ct);
+ await _configsRefresher.RunNowAsync(ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Polling tick failed");
+ }
+ }
+ }
+
+ ///
+ /// Computes the volume changes between two snapshots, comparing only the values
+ /// that are reliable in the given mode.
+ ///
+ internal static IEnumerable Diff(VolumeSnapshot previous, VolumeSnapshot current, Mode mode)
+ {
+ foreach ((Channel channel, ChannelVolumes cur) in current.Channels)
+ {
+ if (!previous.Channels.TryGetValue(channel, out var prev)) continue;
+
+ if (mode == Mode.Classic)
+ {
+ if (Changed(prev.Classic, cur.Classic))
+ yield return new VolumeChange(channel, null, prev.Classic!, cur.Classic!);
+ }
+ else
+ {
+ if (Changed(prev.Personal, cur.Personal))
+ yield return new VolumeChange(channel, Mix.Personal, prev.Personal!, cur.Personal!);
+ if (Changed(prev.Stream, cur.Stream))
+ yield return new VolumeChange(channel, Mix.Stream, prev.Stream!, cur.Stream!);
+ }
+ }
+
+ static bool Changed(VolumeSetting? previous, VolumeSetting? current) =>
+ previous is not null && current is not null && previous != current;
+ }
+
+ /// Parses a SONAR_EVENT_VOLUME_DATA payload. Same shape as GET volumeSettings/streamer/.
+ internal static VolumeSnapshot ParseVolumeSnapshot(JsonElement data)
+ {
+ var channels = new Dictionary();
+
+ if (data.ValueKind == JsonValueKind.Object &&
+ data.TryGetProperty("masters", out var masters))
+ {
+ channels[Channel.Master] = ParseChannelVolumes(masters);
+ }
+
+ if (data.ValueKind == JsonValueKind.Object &&
+ data.TryGetProperty("devices", out var devices) &&
+ devices.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var device in devices.EnumerateObject())
+ {
+ Channel? channel = ChannelExtensions.FromJsonKey(device.Name);
+ if (channel is null) continue; // unknown channel added by a future GG update: skip, don't crash
+
+ channels[channel.Value] = ParseChannelVolumes(device.Value);
+ }
+ }
+
+ return new VolumeSnapshot(channels);
+ }
+
+ private static ChannelVolumes ParseChannelVolumes(JsonElement node)
+ {
+ VolumeSetting? classic = null, personal = null, stream = null;
+
+ if (node.ValueKind == JsonValueKind.Object)
+ {
+ if (node.TryGetProperty("classic", out var c) && c.ValueKind == JsonValueKind.Object)
+ classic = ParseSetting(c);
+
+ if (node.TryGetProperty("stream", out var st) && st.ValueKind == JsonValueKind.Object)
+ {
+ if (st.TryGetProperty(Mix.Personal.ToJsonKey(), out var p) && p.ValueKind == JsonValueKind.Object)
+ personal = ParseSetting(p);
+ if (st.TryGetProperty(Mix.Stream.ToJsonKey(), out var sm) && sm.ValueKind == JsonValueKind.Object)
+ stream = ParseSetting(sm);
+ }
+ }
+
+ return new ChannelVolumes(classic, personal, stream);
+ }
+
+ private static VolumeSetting ParseSetting(JsonElement node)
+ {
+ double volume = node.TryGetProperty("volume", out var v) &&
+ v.ValueKind == JsonValueKind.Number ? v.GetDouble() : 0.0;
+ bool muted = node.GetBoolOrFalse("muted");
+ return new VolumeSetting(volume, muted);
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/ModeChange.cs b/SteelSeriesAPI/Sonar/Events/ModeChange.cs
new file mode 100644
index 0000000..a39d799
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Events/ModeChange.cs
@@ -0,0 +1,8 @@
+using SteelSeriesAPI.Sonar.Enums;
+
+namespace SteelSeriesAPI.Sonar.Events;
+
+/// A detected change of the Sonar mixer mode.
+/// The mode before the change.
+/// The mode after the change.
+public sealed record ModeChange(Mode PreviousMode, Mode NewMode);
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/RedirectionChanges.cs b/SteelSeriesAPI/Sonar/Events/RedirectionChanges.cs
new file mode 100644
index 0000000..2cc1e3b
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Events/RedirectionChanges.cs
@@ -0,0 +1,39 @@
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Events;
+
+/// A channel was routed to a different device in classic mode.
+/// The channel that was rerouted.
+/// The device the channel was routed to before.
+/// The device the channel is routed to now.
+public sealed record ClassicDeviceChange(Channel Channel, string PreviousDeviceId, string NewDeviceId);
+
+/// A streamer-mode mix was routed to a different output device.
+/// The mix that was rerouted.
+/// The device the mix was routed to before.
+/// The device the mix is routed to now.
+public sealed record MixDeviceChange(Mix Mix, string PreviousDeviceId, string NewDeviceId);
+
+/// A channel was enabled or disabled on a streamer-mode mix.
+/// The affected mix.
+/// The toggled channel.
+/// Whether the channel is enabled on the mix now.
+public sealed record MixChannelToggle(Mix Mix, Channel Channel, bool IsEnabled);
+
+/// Stream monitoring ("hear what the audience hears") was toggled.
+/// Whether stream monitoring is enabled now.
+public sealed record StreamMonitoringChange(bool IsEnabled);
+
+/// Everything that changed between two redirection snapshots.
+public sealed record RedirectionDiff(
+ IReadOnlyList ClassicDeviceChanges,
+ IReadOnlyList MixDeviceChanges,
+ IReadOnlyList MixChannelToggles,
+ StreamMonitoringChange? MonitoringChange)
+{
+ /// True when nothing actually changed between the two snapshots.
+ public bool IsEmpty =>
+ ClassicDeviceChanges.Count == 0 && MixDeviceChanges.Count == 0 &&
+ MixChannelToggles.Count == 0 && MonitoringChange is null;
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/SonarAudienceMonitoringEvent.cs b/SteelSeriesAPI/Sonar/Events/SonarAudienceMonitoringEvent.cs
deleted file mode 100644
index 279839d..0000000
--- a/SteelSeriesAPI/Sonar/Events/SonarAudienceMonitoringEvent.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Events;
-
-public class SonarAudienceMonitoringEvent : EventArgs
-{
- // /streamRedirections/isStreamMonitoringEnabled/true
- public bool NewState { get; set; }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/SonarChatMixEvent.cs b/SteelSeriesAPI/Sonar/Events/SonarChatMixEvent.cs
deleted file mode 100644
index 352ab66..0000000
--- a/SteelSeriesAPI/Sonar/Events/SonarChatMixEvent.cs
+++ /dev/null
@@ -1,7 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Events;
-
-public class SonarChatMixEvent : EventArgs
-{
- // /chatMix?balance=0
- public double Balance { get; set; }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/SonarConfigEvent.cs b/SteelSeriesAPI/Sonar/Events/SonarConfigEvent.cs
deleted file mode 100644
index 5777090..0000000
--- a/SteelSeriesAPI/Sonar/Events/SonarConfigEvent.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Events;
-
-public class SonarConfigEvent : EventArgs
-{
- // /configs/e6979db3-3e00-4399-b58c-6f026c9ef6ba/select
- // /configs <--- Error
- public string ConfigId { get; set; }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs
new file mode 100644
index 0000000..62e5ec5
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs
@@ -0,0 +1,296 @@
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar.Managers;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Events;
+
+///
+/// Listens to the Sonar WebSocket event stream (/sock) and raises typed .NET events.
+/// Automatically reconnects (with backoff) when the connection drops, e.g. when GG restarts.
+/// On every (re)connection, Sonar pushes its full current state, so subscribers resynchronize for free.
+///
+///
+/// Events are raised from background threads. Each event is fed by one of three mechanisms,
+/// invisible to subscribers:
+///
+/// - WebSocket broadcast (real time): , ,
+/// , , the *Invalidated signals,
+/// and / (the connection itself).
+/// - Polling (requires ): and
+/// . Sonar does not broadcast changes received through its own HTTP API,
+/// such as UI slider moves (observed 2026-08-08), hence the polling.
+/// - Hybrid (WebSocket invalidation and polling, both feeding a fetch+diff):
+/// , , ,
+/// and .
+///
+///
+public sealed partial class SonarEventListener : IDisposable
+{
+ private const string SocketPath = "/sock";
+
+ private readonly SonarHttpClient _httpClient;
+ private readonly ILogger _logger;
+ private CancellationTokenSource? _cts;
+ private CancellationToken _lifetime;
+ private Task? _runLoop;
+ private Task? _pollLoop;
+
+ ///
+ /// When set before , the listener periodically polls Sonar at this
+ /// interval to detect changes that Sonar does not broadcast over its WebSocket:
+ /// volume and mute levels, the mixer mode, redirection states (device routing,
+ /// mix toggles, stream monitoring) and config selections. Smaller values reduce
+ /// detection latency but increase local HTTP traffic; 300-500ms is a good balance
+ /// for interactive use. Null (the default) disables polling: only WebSocket-broadcast
+ /// events (chat mix, audio sessions, snapshots...) will be raised.
+ ///
+ public TimeSpan? PollingInterval { get; set; }
+
+ /// Raised when the connection to Sonar is established or re-established.
+ public event EventHandler? Connected;
+
+ /// Raised when the connection to Sonar is lost. The listener will keep trying to reconnect.
+ public event EventHandler? Disconnected;
+
+ /// Raised when Sonar broadcasts a chat mix change (slider, hardware wheel...).
+ public event EventHandler? ChatMixChanged;
+
+ ///
+ /// Raised when an application audio session appears on a device
+ /// (app started playing, or was routed to this channel).
+ ///
+ public event EventHandler? AudioSessionOpened;
+
+ ///
+ /// Raised when an application audio session leaves a device
+ /// (app stopped playing, or was routed away from this channel).
+ ///
+ public event EventHandler? AudioSessionClosed;
+
+ ///
+ /// Raised when Sonar signals that app routing changed, without details.
+ /// Query for the new state,
+ /// or rely on / which carry the data.
+ ///
+ public event EventHandler? RoutingInvalidated;
+
+ /// Raised for any Sonar event not yet mapped to a typed event.
+ public event EventHandler? UnknownEventReceived;
+
+ internal SonarEventListener(SonarHttpClient httpClient, ILogger? logger = null)
+ {
+ _httpClient = httpClient;
+ _logger = logger ?? NullLogger.Instance;
+
+ _redirections = new RedirectionsManager(httpClient);
+ _configs = new ConfigManager(httpClient);
+
+ _redirectionsRefresher = new DebouncedRefresher("Redirections", RefreshRedirectionsAsync, _logger);
+ _configsRefresher = new DebouncedRefresher("Configs", RefreshSelectedConfigsAsync, _logger);
+ }
+
+ ///
+ /// Starts listening in the background. Call to stop;
+ /// the listener can then be started again.
+ ///
+ public void Start()
+ {
+ if (_runLoop is not null)
+ throw new InvalidOperationException("The event listener is already running.");
+
+ // Reset the diffing baselines: after a stop/start cycle, the world may have changed.
+ _redirectionsBaseline = null;
+ _selectedConfigsBaseline = null;
+
+ _cts = new CancellationTokenSource();
+ _lifetime = _cts.Token;
+ _runLoop = Task.Run(() => RunAsync(_cts.Token));
+
+ if (PollingInterval is { } interval)
+ _pollLoop = Task.Run(() => RunPollingAsync(interval, _cts.Token));
+ }
+
+ /// Stops listening and waits for the background loops to complete.
+ public async Task StopAsync()
+ {
+ if (_cts is null || _runLoop is null) return;
+
+ await _cts.CancelAsync();
+ try { await Task.WhenAll(_runLoop, _pollLoop ?? Task.CompletedTask); }
+ catch (OperationCanceledException) { /* expected */ }
+
+ _cts.Dispose();
+ _cts = null;
+ _runLoop = null;
+ _pollLoop = null;
+ }
+
+ /// Connection lifecycle loop: connect, receive, and reconnect with backoff on failure.
+ private async Task RunAsync(CancellationToken ct)
+ {
+ TimeSpan backoff = TimeSpan.FromSeconds(1);
+ bool wasConnected = false;
+
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ Uri http = await _httpClient.GetServerAddressAsync(ct);
+ Uri wsUri = new UriBuilder(http) { Scheme = "ws", Path = SocketPath }.Uri;
+
+ using var ws = new ClientWebSocket();
+ await ws.ConnectAsync(wsUri, ct);
+
+ _logger.LogDebug("Connected to Sonar event stream at {Uri}", wsUri);
+ backoff = TimeSpan.FromSeconds(1); // reset on success
+
+ wasConnected = true;
+ RaiseSafely(() => Connected?.Invoke(this, EventArgs.Empty));
+
+ // Seed the diffing baselines right away, so the very first user change
+ // after startup produces granular events instead of just creating the baseline.
+ _redirectionsRefresher.Schedule(ct);
+ _configsRefresher.Schedule(ct);
+
+ await ReceiveLoopAsync(ws, ct);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ break;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "Sonar event stream connection failed or dropped");
+ }
+
+ if (wasConnected)
+ {
+ wasConnected = false;
+ RaiseSafely(() => Disconnected?.Invoke(this, EventArgs.Empty));
+ }
+
+ // GG may have restarted on a new port: force a fresh discovery on next attempt.
+ _httpClient.InvalidateAddress();
+
+ try { await Task.Delay(backoff, ct); }
+ catch (OperationCanceledException) { break; }
+
+ backoff = TimeSpan.FromSeconds(Math.Min(backoff.TotalSeconds * 2, 30));
+ }
+ }
+
+ /// Receives messages until the socket closes, reassembling fragmented frames.
+ private async Task ReceiveLoopAsync(ClientWebSocket ws, CancellationToken ct)
+ {
+ var buffer = new byte[64 * 1024];
+ using var message = new MemoryStream();
+
+ while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
+ {
+ var result = await ws.ReceiveAsync(buffer, ct);
+ if (result.MessageType == WebSocketMessageType.Close) return;
+
+ message.Write(buffer, 0, result.Count);
+ if (!result.EndOfMessage) continue;
+
+ Dispatch(Encoding.UTF8.GetString(message.ToArray()));
+ message.SetLength(0);
+ }
+ }
+
+ /// Parses one raw message and raises the matching typed event.
+ private void Dispatch(string json)
+ {
+ string? eventName = null;
+ try
+ {
+ using var doc = JsonDocument.Parse(json);
+
+ eventName = doc.RootElement.TryGetProperty("event", out var e) &&
+ e.ValueKind == JsonValueKind.String
+ ? e.GetString()
+ : null;
+
+ JsonElement data = doc.RootElement.TryGetProperty("data", out var d) ? d : default;
+
+ switch (eventName)
+ {
+ case SonarEventNames.ChatMixData:
+ RaiseSafely(() => ChatMixChanged?.Invoke(this, ParseChatMix(data)));
+ break;
+
+ case SonarEventNames.VolumeData:
+ RaiseSafely(() => VolumeDataReceived?.Invoke(this, ParseVolumeSnapshot(data)));
+ break;
+
+ case SonarEventNames.RedirectionStatusUpdate:
+ case SonarEventNames.StreamMonitoringLockStatusUpdate:
+ RaiseSafely(() => RedirectionsInvalidated?.Invoke(this, EventArgs.Empty));
+ _redirectionsRefresher.Schedule(_lifetime);
+ break;
+
+ case SonarEventNames.SelectedConfigUpdated:
+ RaiseSafely(() => ConfigsInvalidated?.Invoke(this, EventArgs.Empty));
+ _configsRefresher.Schedule(_lifetime);
+ break;
+
+ case SonarEventNames.AudioSessionOpened:
+ if (AppRoutingManager.ParseRouting(data) is { } opened)
+ RaiseSafely(() => AudioSessionOpened?.Invoke(this, opened));
+ break;
+
+ case SonarEventNames.AudioSessionClosed:
+ if (AppRoutingManager.ParseRouting(data) is { } closed)
+ RaiseSafely(() => AudioSessionClosed?.Invoke(this, closed));
+ break;
+
+ case SonarEventNames.RoutingData:
+ RaiseSafely(() => RoutingInvalidated?.Invoke(this, EventArgs.Empty));
+ break;
+
+ default:
+ RaiseSafely(() => UnknownEventReceived?.Invoke(this,
+ new SonarUnknownEvent(eventName ?? "", json)));
+ break;
+ }
+ }
+ catch (JsonException ex)
+ {
+ _logger.LogWarning(ex, "Could not parse Sonar event message (event: {Event})", eventName);
+ }
+ }
+
+ /// A subscriber throwing must never kill the receive loop.
+ private void RaiseSafely(Action raise)
+ {
+ try { raise(); }
+ catch (Exception ex) { _logger.LogWarning(ex, "A Sonar event subscriber threw an exception"); }
+ }
+
+ /// Parses an EVENT_SONAR_CHATMIX_DATA payload. Same shape as GET v1/chatMix.
+ internal static ChatMixSetting ParseChatMix(JsonElement data)
+ {
+ double balance = data.ValueKind == JsonValueKind.Object &&
+ data.TryGetProperty("balance", out var b) &&
+ b.ValueKind == JsonValueKind.Number
+ ? b.GetDouble() : 0.0;
+
+ string? state = data.GetStringOrNull("state");
+
+ return new ChatMixSetting(balance, state);
+ }
+
+ /// Stops the listener without waiting. Prefer for a graceful stop.
+ public void Dispose()
+ {
+ _cts?.Cancel();
+ _cts?.Dispose();
+ _redirectionsRefresher.Dispose();
+ _configsRefresher.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs b/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs
new file mode 100644
index 0000000..4f21fb5
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs
@@ -0,0 +1,25 @@
+namespace SteelSeriesAPI.Sonar.Events;
+
+///
+/// Central registry of every event name broadcast on the Sonar WebSocket (/sock).
+/// Full catalog extracted from the GG UI bundle on 2026-08-08.
+/// Note the inconsistent EVENT_SONAR_ vs SONAR_EVENT_ prefixes: that is SteelSeries' doing.
+///
+internal static class SonarEventNames
+{
+ internal const string ChatMixData = "EVENT_SONAR_CHATMIX_DATA";
+ internal const string VolumeData = "SONAR_EVENT_VOLUME_DATA";
+ internal const string RedirectionStatusUpdate = "SONAR_EVENT_REDIRECTION_STATUS_UPDATE";
+ internal const string DeviceStatusUpdate = "SONAR_EVENT_DEVICE_STATUS_UPDATE";
+ internal const string DeviceDefaultUpdate = "SONAR_EVENT_DEVICE_DEFAULT_UPDATE";
+ internal const string AudioSessionOpened = "SONAR_EVENT_AUDIO_SESSION_OPENED_DATA";
+ internal const string AudioSessionClosed = "SONAR_EVENT_AUDIO_SESSION_CLOSED_DATA";
+ internal const string SelectedConfigUpdated = "SONAR_EVENT_SELECTED_CONFIG_UPDATED";
+ internal const string StreamMonitoringLockStatusUpdate = "SONAR_EVENT_STREAM_MONITORING_LOCK_STATUS_UPDATE";
+ internal const string RoutingData = "SONAR_EVENT_ROUTING_DATA";
+
+ // Known to exist (UI bundle catalog) but not yet wired to typed events:
+ // EVENT_SONAR_STATUS, SONAR_EVENT_DEVICE_OUT_VOLUME_DATA, SONAR_EVENT_DEVICE_VOLUMES_UPDATE,
+ // SONAR_EVENT_FALLBACK_UPDATED, SONAR_EVENT_FAVORITE_CONFIGS_UPDATED, SONAR_EVENT_FEATURE_UPDATED,
+ // SONAR_EVENT_PLAYER_STOPPED, SONAR_EVENT_QUICKSET_*, SONAR_EVENT_RECORDING_STOPPED
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/SonarMixEvent.cs b/SteelSeriesAPI/Sonar/Events/SonarMixEvent.cs
deleted file mode 100644
index d4fae6c..0000000
--- a/SteelSeriesAPI/Sonar/Events/SonarMixEvent.cs
+++ /dev/null
@@ -1,14 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-
-namespace SteelSeriesAPI.Sonar.Events;
-
-public class SonarMixEvent : EventArgs
-{
- // /streamRedirections/monitoring/redirections/chatRender/isEnabled/true
-
- public bool NewState { get; set; }
-
- public Channel Channel { get; set; }
-
- public Mix Mix { get; set; }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/SonarModeEvent.cs b/SteelSeriesAPI/Sonar/Events/SonarModeEvent.cs
deleted file mode 100644
index 965313c..0000000
--- a/SteelSeriesAPI/Sonar/Events/SonarModeEvent.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-
-namespace SteelSeriesAPI.Sonar.Events;
-
-public class SonarModeEvent : EventArgs
-{
- // /mode/stream
-
- public Mode NewMode { get; set; }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/SonarMuteEvent.cs b/SteelSeriesAPI/Sonar/Events/SonarMuteEvent.cs
deleted file mode 100644
index 1cce0a9..0000000
--- a/SteelSeriesAPI/Sonar/Events/SonarMuteEvent.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-
-namespace SteelSeriesAPI.Sonar.Events;
-
-public class SonarMuteEvent : EventArgs
-{
- // /volumeSettings/classic/game/Mute/bool
- // /volumeSettings/streamer/monitoring/game/isMuted/bool
-
- public bool Muted { get; set; }
-
- public Mode Mode { get; set; }
-
- public Channel Channel { get; set; }
-
- public Mix? Mix { get; set; }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/SonarPlaybackDeviceEvent.cs b/SteelSeriesAPI/Sonar/Events/SonarPlaybackDeviceEvent.cs
deleted file mode 100644
index 28692bc..0000000
--- a/SteelSeriesAPI/Sonar/Events/SonarPlaybackDeviceEvent.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-
-namespace SteelSeriesAPI.Sonar.Events;
-
-public class SonarPlaybackDeviceEvent : EventArgs
-{
- // /classicRedirections/game/deviceId/%7B0.0.0.00000000%7D.%7B1e1ebefc-2c51-4675-aebe-085a06efd255%7D
- // /streamRedirections/monitoring/deviceId/%7B0.0.0.00000000%7D.%7B1e1ebefc-2c51-4675-aebe-085a06efd255%7D
-
- public string PlaybackDeviceId { get; set; }
-
- public Mode Mode { get; set; }
-
- public Channel? Channel { get; set; }
-
- public Mix? Mix { get; set; }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/SonarRoutedProcessEvent.cs b/SteelSeriesAPI/Sonar/Events/SonarRoutedProcessEvent.cs
deleted file mode 100644
index 86271e7..0000000
--- a/SteelSeriesAPI/Sonar/Events/SonarRoutedProcessEvent.cs
+++ /dev/null
@@ -1,40 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-using SteelSeriesAPI.Sonar.Http;
-using SteelSeriesAPI.Sonar.Exceptions;
-
-using System.Text.Json;
-
-namespace SteelSeriesAPI.Sonar.Events;
-
-public class SonarRoutedProcessEvent : EventArgs
-{
- // /AudioDeviceRouting/render/%7B0.0.0.00000000%7D.%7Beb78557a-9882-4205-8014-ad9384173901%7D/4476
- // /AudioDeviceRouting/capture/%7B0.0.1.00000000%7D.%7B989ad130-4b1f-4828-a85a-7aef7fd362b7%7D/4476
-
- public int ProcessId { get; init; }
-
- public Channel NewChannel { get; init; }
-
- internal SonarRoutedProcessEvent(string deviceId)
- {
- NewChannel = DeviceIdToChannel(deviceId);
- }
-
- private Channel DeviceIdToChannel(string deviceId)
- {
- JsonElement audioDeviceRouting = new Fetcher().Provide("AudioDeviceRouting").RootElement;
-
- foreach (JsonElement device in audioDeviceRouting.EnumerateArray())
- {
- if (device.GetProperty("role").GetString() != "none")
- {
- if (device.GetProperty("deviceId").GetString() == deviceId)
- {
- return (Channel)ChannelExtensions.FromDictKey(device.GetProperty("role").GetString()!)!;
- }
- }
- }
-
- throw new RoutedProcessNotFoundException("Event error: Could not find the channel");
- }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/SonarVolumeEvent.cs b/SteelSeriesAPI/Sonar/Events/SonarVolumeEvent.cs
deleted file mode 100644
index e00607e..0000000
--- a/SteelSeriesAPI/Sonar/Events/SonarVolumeEvent.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-
-namespace SteelSeriesAPI.Sonar.Events;
-
-public class SonarVolumeEvent : EventArgs
-{
- // /volumeSettings/classic/game/Volume/0.27
- // /volumeSettings/streamer/monitoring/game/volume/0.99
-
- public double Volume { get; set; }
-
- public Mode Mode { get; set; }
-
- public Channel Channel { get; set; }
-
- public Mix? Mix { get; set; }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/VolumeChange.cs b/SteelSeriesAPI/Sonar/Events/VolumeChange.cs
new file mode 100644
index 0000000..a3432fc
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Events/VolumeChange.cs
@@ -0,0 +1,36 @@
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Events;
+
+///
+/// A detected change in the volume or mute state of one channel on one mix.
+///
+/// The channel that changed (Game, Chat, Master...).
+///
+/// The affected streamer-mode mix (Personal or Stream), or null when the change
+/// happened on the classic-mode value.
+///
+/// The full volume/mute state before the change.
+/// The full volume/mute state after the change.
+public sealed record VolumeChange(
+ Channel Channel,
+ Mix? Mix,
+ VolumeSetting PreviousState,
+ VolumeSetting NewState)
+{
+ /// The volume level after the change, from 0.0 to 1.0.
+ public double NewVolume => NewState.Volume;
+
+ /// The volume level before the change, from 0.0 to 1.0.
+ public double PreviousVolume => PreviousState.Volume;
+
+ /// Whether the channel is muted after the change.
+ public bool IsMuted => NewState.Muted;
+
+ /// Whether the channel was muted before the change.
+ public bool WasMuted => PreviousState.Muted;
+
+ /// True when the mute state itself changed (the user pressed mute/unmute).
+ public bool MuteToggled => WasMuted != IsMuted;
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Events/VolumeSnapshot.cs b/SteelSeriesAPI/Sonar/Events/VolumeSnapshot.cs
new file mode 100644
index 0000000..07854da
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Events/VolumeSnapshot.cs
@@ -0,0 +1,19 @@
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Events;
+
+/// The volume state of one channel across all mixes, as pushed by a Sonar event.
+/// The classic-mode state, or null if absent from the event.
+/// The streamer-mode personal mix state, or null if streamer mode is off.
+/// The streamer-mode stream mix state, or null if streamer mode is off.
+public sealed record ChannelVolumes(VolumeSetting? Classic, VolumeSetting? Personal, VolumeSetting? Stream);
+
+/// A full mixer state snapshot, pushed by Sonar on connection and after major changes.
+/// The state of every channel present in the event, including .
+public sealed record VolumeSnapshot(IReadOnlyDictionary Channels);
+
+/// An event broadcast by Sonar that this library does not yet map to a typed event.
+/// The raw event name (for example "SONAR_EVENT_FEATURE_UPDATED").
+/// The complete raw JSON message, for manual inspection.
+public sealed record SonarUnknownEvent(string EventName, string RawJson);
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Exceptions/ChannelNoStreamerSupportException.cs b/SteelSeriesAPI/Sonar/Exceptions/ChannelNoStreamerSupportException.cs
deleted file mode 100644
index db31ce5..0000000
--- a/SteelSeriesAPI/Sonar/Exceptions/ChannelNoStreamerSupportException.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Exceptions;
-
-public class ChannelNoStreamerSupportException : Exception
-{
- public ChannelNoStreamerSupportException() : base("Only the Mic Channel is supported in this case.") { }
- public ChannelNoStreamerSupportException(string message) : base(message) { }
- public ChannelNoStreamerSupportException(string message, Exception innerException) : base(message, innerException) { }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Exceptions/ChannelNotFoundException.cs b/SteelSeriesAPI/Sonar/Exceptions/ChannelNotFoundException.cs
deleted file mode 100644
index 73a5baf..0000000
--- a/SteelSeriesAPI/Sonar/Exceptions/ChannelNotFoundException.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Exceptions;
-
-public class ChannelNotFoundException : Exception
-{
- public ChannelNotFoundException() : base("Channel could not be found.") { }
- public ChannelNotFoundException(string message) : base(message) { }
- public ChannelNotFoundException(string message, Exception innerException) : base(message, innerException) { }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Exceptions/ChatMixBalanceException.cs b/SteelSeriesAPI/Sonar/Exceptions/ChatMixBalanceException.cs
deleted file mode 100644
index b00a8dc..0000000
--- a/SteelSeriesAPI/Sonar/Exceptions/ChatMixBalanceException.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Exceptions;
-
-public class ChatMixBalanceException : Exception
-{
- public ChatMixBalanceException() : base("ChatMix balance out of range.") { }
- public ChatMixBalanceException(string message) : base(message) { }
- public ChatMixBalanceException(string message, Exception innerException) : base(message, innerException) { }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Exceptions/ChatMixDisabledException.cs b/SteelSeriesAPI/Sonar/Exceptions/ChatMixDisabledException.cs
deleted file mode 100644
index be52eff..0000000
--- a/SteelSeriesAPI/Sonar/Exceptions/ChatMixDisabledException.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Exceptions;
-
-public class ChatMixDisabledException : Exception
-{
- public ChatMixDisabledException() : base("ChatMix is not enabled.") { }
- public ChatMixDisabledException(string message) : base(message) { }
- public ChatMixDisabledException(string message, Exception innerException) : base(message, innerException) { }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Exceptions/ConfigNotFoundException.cs b/SteelSeriesAPI/Sonar/Exceptions/ConfigNotFoundException.cs
deleted file mode 100644
index 1084652..0000000
--- a/SteelSeriesAPI/Sonar/Exceptions/ConfigNotFoundException.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Exceptions;
-
-public class ConfigNotFoundException : Exception
-{
- public ConfigNotFoundException() : base("No audio configuration found.") { }
- public ConfigNotFoundException(string message) : base(message) { }
- public ConfigNotFoundException(string message, Exception innerException) : base(message, innerException) { }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Exceptions/MasterChannelNotSupportedException.cs b/SteelSeriesAPI/Sonar/Exceptions/MasterChannelNotSupportedException.cs
deleted file mode 100644
index 91b8755..0000000
--- a/SteelSeriesAPI/Sonar/Exceptions/MasterChannelNotSupportedException.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Exceptions;
-
-public class MasterChannelNotSupportedException : Exception
-{
- public MasterChannelNotSupportedException() : base("Master Channel is not supported in this case.") { }
- public MasterChannelNotSupportedException(string message) : base(message) { }
- public MasterChannelNotSupportedException(string message, Exception innerException) : base(message, innerException) { }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Exceptions/MixNotFoundException.cs b/SteelSeriesAPI/Sonar/Exceptions/MixNotFoundException.cs
deleted file mode 100644
index 65b339f..0000000
--- a/SteelSeriesAPI/Sonar/Exceptions/MixNotFoundException.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Exceptions;
-
-public class MixNotFoundException : Exception
-{
- public MixNotFoundException() : base("Mix could not be found.") { }
- public MixNotFoundException(string message) : base(message) { }
- public MixNotFoundException(string message, Exception innerException) : base(message, innerException) { }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Exceptions/PlaybackDeviceDataFlowException.cs b/SteelSeriesAPI/Sonar/Exceptions/PlaybackDeviceDataFlowException.cs
deleted file mode 100644
index 3fb0027..0000000
--- a/SteelSeriesAPI/Sonar/Exceptions/PlaybackDeviceDataFlowException.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Exceptions;
-
-public class PlaybackDeviceDataFlowException : Exception
-{
- public PlaybackDeviceDataFlowException() : base("DataFlows do not match.") { }
- public PlaybackDeviceDataFlowException(string message) : base(message) { }
- public PlaybackDeviceDataFlowException(string message, Exception innerException) : base(message, innerException) { }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Exceptions/PlaybackDeviceNotFoundException.cs b/SteelSeriesAPI/Sonar/Exceptions/PlaybackDeviceNotFoundException.cs
deleted file mode 100644
index 25296e7..0000000
--- a/SteelSeriesAPI/Sonar/Exceptions/PlaybackDeviceNotFoundException.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Exceptions;
-
-public class PlaybackDeviceNotFoundException : Exception
-{
- public PlaybackDeviceNotFoundException() : base("Could not find corresponding playback device") { }
- public PlaybackDeviceNotFoundException(string message) : base(message) { }
- public PlaybackDeviceNotFoundException(string message, Exception innerException) : base(message, innerException) { }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Exceptions/RoutedProcessNotFoundException.cs b/SteelSeriesAPI/Sonar/Exceptions/RoutedProcessNotFoundException.cs
deleted file mode 100644
index 604deaa..0000000
--- a/SteelSeriesAPI/Sonar/Exceptions/RoutedProcessNotFoundException.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Exceptions;
-
-public class RoutedProcessNotFoundException : Exception
-{
- public RoutedProcessNotFoundException() : base("Could not find any routed process") { }
- public RoutedProcessNotFoundException(string message) : base(message) { }
- public RoutedProcessNotFoundException(string message, Exception innerException) : base(message, innerException) { }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Exceptions/SonarListenerNotConnectedException.cs b/SteelSeriesAPI/Sonar/Exceptions/SonarListenerNotConnectedException.cs
deleted file mode 100644
index 03294b7..0000000
--- a/SteelSeriesAPI/Sonar/Exceptions/SonarListenerNotConnectedException.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Exceptions;
-
-public class SonarListenerNotConnectedException : Exception
-{
- public SonarListenerNotConnectedException() : base("Listener need to be connected before listening") { }
- public SonarListenerNotConnectedException(string message) : base(message) { }
- public SonarListenerNotConnectedException(string message, Exception innerException) : base(message, innerException) { }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Exceptions/SonarNotRunningException.cs b/SteelSeriesAPI/Sonar/Exceptions/SonarNotRunningException.cs
deleted file mode 100644
index 96b60e8..0000000
--- a/SteelSeriesAPI/Sonar/Exceptions/SonarNotRunningException.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Exceptions;
-
-public class SonarNotRunningException : Exception
-{
- public SonarNotRunningException() : base("Sonar is not running.") { }
- public SonarNotRunningException(string message) : base(message) { }
- public SonarNotRunningException(string message, Exception innerException) : base(message, innerException) { }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Http/Fetcher.cs b/SteelSeriesAPI/Sonar/Http/Fetcher.cs
deleted file mode 100644
index d405f4e..0000000
--- a/SteelSeriesAPI/Sonar/Http/Fetcher.cs
+++ /dev/null
@@ -1,63 +0,0 @@
-using SteelSeriesAPI.Sonar.Exceptions;
-using SteelSeriesAPI.Interfaces;
-
-using System.Text.Json;
-
-namespace SteelSeriesAPI.Sonar.Http;
-
-public class Fetcher
-{
- private readonly HttpClient _httpClient;
- private readonly IAppRetriever _sonarRetriever;
-
- public Fetcher()
- {
- _sonarRetriever = SonarRetriever.Instance;
-
- HttpClientHandler clientHandler = new HttpClientHandler();
- clientHandler.ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true;
- _httpClient = new(clientHandler);
- }
-
- public JsonDocument Provide(string targetHttp)
- {
- if (_sonarRetriever is { IsEnabled: false, IsReady: false, IsRunning: false })
- {
- throw new SonarNotRunningException();
- }
-
- try
- {
- JsonDocument response = JsonDocument.Parse(_httpClient.GetStringAsync(_sonarRetriever.WebServerAddress() + targetHttp).Result);
- return response;
- }
- catch (Exception e)
- {
- Console.WriteLine(e);
- Console.WriteLine("Sonar may not be running.");
- throw;
- }
- }
-
- public void Put(string targetHttp)
- {
- if (_sonarRetriever is { IsEnabled: false, IsReady: false, IsRunning: false })
- {
- throw new SonarNotRunningException();
- }
-
- try
- {
- HttpResponseMessage httpResponseMessage = _httpClient
- .PutAsync(_sonarRetriever.WebServerAddress() + targetHttp, null)
- .GetAwaiter().GetResult();
- httpResponseMessage.EnsureSuccessStatusCode();
- }
- catch (Exception e)
- {
- Console.WriteLine(e);
- Console.WriteLine("Sonar may not be running.");
- throw;
- }
- }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Interfaces/ISonarBridge.cs b/SteelSeriesAPI/Sonar/Interfaces/ISonarBridge.cs
deleted file mode 100644
index 4091bbc..0000000
--- a/SteelSeriesAPI/Sonar/Interfaces/ISonarBridge.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Interfaces;
-
-///
-/// Manage Sonar
-///
-public interface ISonarBridge
-{
- ///
- /// The running state of Sonar
- ///
- bool IsRunning { get; }
-
- ///
- /// Start listening to events happening on Sonar, such as changing volume...
- ///
- /// The state of the listener (false if it didn't start)
- bool StartListener();
-
- ///
- /// Stop the listener
- ///
- void StopListener();
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Interfaces/ISonarSocket.cs b/SteelSeriesAPI/Sonar/Interfaces/ISonarSocket.cs
deleted file mode 100644
index 0ec5e69..0000000
--- a/SteelSeriesAPI/Sonar/Interfaces/ISonarSocket.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Interfaces;
-
-public interface ISonarSocket
-{
- bool IsConnected { get; }
-
- bool Connect();
-
- bool Listen();
-
- void CloseSocket();
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Interfaces/Managers/IAudienceMonitoringManager.cs b/SteelSeriesAPI/Sonar/Interfaces/Managers/IAudienceMonitoringManager.cs
deleted file mode 100644
index 69c20d9..0000000
--- a/SteelSeriesAPI/Sonar/Interfaces/Managers/IAudienceMonitoringManager.cs
+++ /dev/null
@@ -1,20 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Interfaces.Managers;
-
-///
-/// Manage the Audience Monitoring feature of the streamer mode
-///
-public interface IAudienceMonitoringManager
-{
- ///
- /// Get the current state of the Audience Monitoring
- ///
- /// The current state, un/muted
- bool GetState();
-
- ///
- /// Activate or deactivate Audience Monitoring
- /// Listen to what your audience hear
- ///
- /// The new state, un/muted
- void SetState(bool newState);
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Interfaces/Managers/IChatMixManager.cs b/SteelSeriesAPI/Sonar/Interfaces/Managers/IChatMixManager.cs
deleted file mode 100644
index 8ee2b79..0000000
--- a/SteelSeriesAPI/Sonar/Interfaces/Managers/IChatMixManager.cs
+++ /dev/null
@@ -1,26 +0,0 @@
-namespace SteelSeriesAPI.Sonar.Interfaces.Managers;
-
-///
-/// Manage the balance of ChatMix
-///
-public interface IChatMixManager
-{
- ///
- /// Get the actual ChatMix balance value
- ///
- /// A double between -1 and 1
- double GetBalance();
-
- ///
- /// Get the actual state of the ChatMix
- ///
- /// True if ChatMix is enabled
False if ChatMix is disabled
- bool GetState();
-
- ///
- /// Set the balance of the ChatMix
- ///
- /// -1 to balance to Game channel
1 to balance to Chat channel
- /// A between -1 and 1
- void SetBalance(double balance);
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Interfaces/Managers/IConfigurationManager.cs b/SteelSeriesAPI/Sonar/Interfaces/Managers/IConfigurationManager.cs
deleted file mode 100644
index 3b865e4..0000000
--- a/SteelSeriesAPI/Sonar/Interfaces/Managers/IConfigurationManager.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-using SteelSeriesAPI.Sonar.Models;
-
-namespace SteelSeriesAPI.Sonar.Interfaces.Managers;
-
-///
-/// Manage audio configurations for each
-///
-public interface IConfigurationManager
-{
-
- ///
- /// Get all audio configurations of Sonar
- ///
- /// An IEnumerable of
- IEnumerable GetAllAudioConfigurations();
-
- ///
- /// Get all audio configurations of a
- ///
- /// The channel you want the configs of
- /// An IEnumerable of
- IEnumerable GetAudioConfigurations(Channel channel);
-
- ///
- /// Get the current audio configuration of a
- ///
- /// The channel you want the current config
- /// A
- SonarAudioConfiguration GetSelectedAudioConfiguration(Channel channel);
-
- ///
- /// Get a specific audio configuration from Sonar
- ///
- /// The id of the config
- /// A
- SonarAudioConfiguration GetAudioConfiguration(string configId);
-
- ///
- /// Set the config of a Sonar by giving its id
- ///
- /// For more explanation, go on the GitHub wiki
- /// The id of the config
- void SetConfig(string configId);
-
- ///
- /// Set the config of a Sonar by giving a
- ///
- /// For more explanation, go on the GitHub wiki
- /// The id
- void SetConfig(SonarAudioConfiguration config);
-
- ///
- /// Set the config of a Sonar by giving its name
- ///
- /// For more explanation, go on the GitHub wiki
- /// The you want to change the config
- /// The name of the config
- void SetConfigByName(Channel channel, string name);
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Interfaces/Managers/IMixManager.cs b/SteelSeriesAPI/Sonar/Interfaces/Managers/IMixManager.cs
deleted file mode 100644
index d867d74..0000000
--- a/SteelSeriesAPI/Sonar/Interfaces/Managers/IMixManager.cs
+++ /dev/null
@@ -1,39 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-
-namespace SteelSeriesAPI.Sonar.Interfaces.Managers;
-
-///
-/// Manage the personal and stream mix for each channel
-///
-public interface IMixManager
-{
- ///
- /// Get the state of the chosen Sonar of the chosen Sonar
- ///
- /// The Sonar of the
- /// The Sonar you want the state of
- /// The current state, activated/deactivated
- bool GetState(Channel channel, Mix mix);
-
- ///
- /// Activate or deactivate a Sonar of a Sonar
- ///
- /// The new state of the Mix
- /// The Sonar of the
- /// The Sonar you want to activate/deactivate
- void SetState(bool newState, Channel channel, Mix mix);
-
- ///
- /// Activate a Sonar of a Sonar
- ///
- /// The Sonar of the
- /// The Sonar you want to activate
- void Activate(Channel channel, Mix mix);
-
- ///
- /// Deactivate a Sonar of a Sonar
- ///
- /// The Sonar of the
- /// The Sonar you want to deactivate
- void Deactivate(Channel channel, Mix mix);
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Interfaces/Managers/IModeManager.cs b/SteelSeriesAPI/Sonar/Interfaces/Managers/IModeManager.cs
deleted file mode 100644
index d827430..0000000
--- a/SteelSeriesAPI/Sonar/Interfaces/Managers/IModeManager.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-
-namespace SteelSeriesAPI.Sonar.Interfaces.Managers;
-
-///
-/// Manage the Sonar
-///
-public interface IModeManager
-{
- ///
- /// Get the current used by Sonar
- ///
- ///
- Mode Get();
-
- ///
- /// Set the Sonar will be using
- ///
- ///
- void Set(Mode mode);
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Interfaces/Managers/IPlaybackDeviceManager.cs b/SteelSeriesAPI/Sonar/Interfaces/Managers/IPlaybackDeviceManager.cs
deleted file mode 100644
index b761e5e..0000000
--- a/SteelSeriesAPI/Sonar/Interfaces/Managers/IPlaybackDeviceManager.cs
+++ /dev/null
@@ -1,106 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-using SteelSeriesAPI.Sonar.Models;
-
-namespace SteelSeriesAPI.Sonar.Interfaces.Managers;
-
-///
-/// Manage the playback device of each
-///
-public interface IPlaybackDeviceManager
-{
-
-
- ///
- /// Get all the Playback Devices (Windows devices)
- ///
- /// A list of
- IEnumerable GetAllPlaybackDevices();
-
- ///
- /// Get all output playback devices
- ///
- /// A list of
- IEnumerable GetOutputPlaybackDevices();
-
- ///
- /// Get all input playback devices
- ///
- /// A list of
- IEnumerable GetInputPlaybackDevices();
-
- ///
- /// Get the playback device of a
- ///
- ///
- ///
- PlaybackDevice GetPlaybackDevice(Channel channel);
-
- ///
- /// Get the playback device of a depending on the mode
- /// Mainly used to get the Streamer Mode Mic
- ///
- ///
- ///
- ///
- PlaybackDevice GetPlaybackDevice(Channel channel, Mode mode);
-
- ///
- /// Get the playback device of a
- ///
- ///
- ///
- PlaybackDevice GetPlaybackDevice(Mix mix);
-
- ///
- /// Get a playback device using its id
- ///
- /// The id of the device
- ///
- PlaybackDevice GetPlaybackDevice(string deviceId);
-
- ///
- /// Set the playback device of a
- ///
- /// The id of the device
- ///
- void SetPlaybackDevice(string deviceId, Channel channel);
-
- ///
- /// Set the playback device of a depending on the mode
- /// Mainly used to change the playback device of the Streamer Mode
- ///
- /// The id of the device
- ///
- ///
- void SetPlaybackDevice(string deviceId, Channel channel, Mode mode);
-
- ///
- /// Set the playback device of a
- ///
- /// The id of the device
- ///
- void SetPlaybackDevice(string deviceId, Mix mix);
-
- ///
- /// Set the playback device of a
- ///
- ///
- ///
- void SetPlaybackDevice(PlaybackDevice device, Channel channel);
-
- ///
- /// Set the playback device of a depending on the mode
- /// Mainly used to change the playback device of the Streamer Mode
- ///
- ///
- ///
- ///
- void SetPlaybackDevice(PlaybackDevice device, Channel channel, Mode mode);
-
- ///
- /// Set the playback device of a
- ///
- ///
- ///
- void SetPlaybackDevice(PlaybackDevice device, Mix mix);
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Interfaces/Managers/IRoutedProcessManager.cs b/SteelSeriesAPI/Sonar/Interfaces/Managers/IRoutedProcessManager.cs
deleted file mode 100644
index af010e0..0000000
--- a/SteelSeriesAPI/Sonar/Interfaces/Managers/IRoutedProcessManager.cs
+++ /dev/null
@@ -1,64 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-using SteelSeriesAPI.Sonar.Models;
-
-namespace SteelSeriesAPI.Sonar.Interfaces.Managers;
-
-///
-/// Manage routed audio processes
-///
-public interface IRoutedProcessManager
-{
- ///
- /// Get all audio processes that are routed to Sonar
- ///
- /// A list of
- IEnumerable GetAllRoutedProcesses();
-
- ///
- /// Get all audio processes that are routed to Sonar and currently active
- ///
- /// A list of
- IEnumerable GetAllActiveRoutedProcesses();
-
- ///
- /// Get all audio processes that are routed to a
- ///
- ///
- /// A list of
- IEnumerable GetRoutedProcesses(Channel channel);
-
- ///
- /// Get all audio processes that are routed to a and currently active
- ///
- ///
- /// A list of
- IEnumerable GetActiveRoutedProcesses(Channel channel);
-
- ///
- /// Get an audio process that is routed to Sonar whatever its state
- ///
- /// The id of the process
- /// A list of
- IEnumerable GetRoutedProcessesById(int processId);
-
- ///
- /// Get an audio process that is routed to Sonar and is active
- ///
- /// The id of the process
- ///
- IEnumerable GetActiveRoutedProcessesById(int processId);
-
- ///
- /// Route an audio process to a specific
- ///
- /// The id of the process
- ///
- void RouteProcessToChannel(int processId, Channel channel);
-
- ///
- /// Route an audio process to a specific
- ///
- ///
- ///
- void RouteProcessToChannel(RoutedProcess process, Channel channel);
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Interfaces/Managers/IVolumeSettingsManager.cs b/SteelSeriesAPI/Sonar/Interfaces/Managers/IVolumeSettingsManager.cs
deleted file mode 100644
index 99f6d38..0000000
--- a/SteelSeriesAPI/Sonar/Interfaces/Managers/IVolumeSettingsManager.cs
+++ /dev/null
@@ -1,69 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-
-namespace SteelSeriesAPI.Sonar.Interfaces.Managers;
-
-///
-/// Manage the volumes and muted state of each
-///
-public interface IVolumeSettingsManager
-{
- ///
- /// Get the volume of a Sonar
- ///
- /// The Sonar you want the volume of
- /// The volume of the channel in double, value between 0 and 1
- double GetVolume(Channel channel);
-
- ///
- /// Get the volume of a Steamer mode Sonar
- ///
- /// The Sonar of the you want the volume of
- /// The Sonar you want the volume of
- /// The volume of the mix in double, value between 0 and 1
- double GetVolume(Channel channel, Mix mix);
-
- ///
- /// Get the mute state of a Sonar
- ///
- /// The Sonar you want the mute state of
- /// The mute state, a boolean
- bool GetMute(Channel channel);
-
- ///
- /// Get the mute state of a Streamer mode Sonar
- ///
- /// The Sonar of the you want the mute state of
- /// The Sonar you want the mute state of
- /// The mute state, a boolean
- bool GetMute(Channel channel, Mix mix);
-
- ///
- /// Set the volume of a Sonar
- ///
- /// The volume you want to set, between 1 and 0
- /// The you want to change the volume of
- void SetVolume(double volume, Channel channel);
-
- ///
- /// Set the volume of a Streamer mode Sonar
- ///
- /// The volume you want to set, between 1 and 0
- /// The of the you want to change the volume of
- /// The you want to change the volume of
- void SetVolume(double volume, Channel channel, Mix mix);
-
- ///
- /// Mute or unmute a Sonar
- ///
- /// The new muted state
- /// The you want to un/mute
- void SetMute(bool mute, Channel channel);
-
- ///
- /// Mute or unmute a Streamer mode Sonar
- ///
- /// The new muted state
- /// The of the you want to un/mute
- /// The you want to un/mute
- void SetMute(bool mute, Channel channel, Mix mix);
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/AppRoutingManager.cs b/SteelSeriesAPI/Sonar/Managers/AppRoutingManager.cs
new file mode 100644
index 0000000..26f9c0c
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Managers/AppRoutingManager.cs
@@ -0,0 +1,102 @@
+using System.Text.Json;
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Managers;
+
+///
+internal sealed class AppRoutingManager : IAppRoutingManager
+{
+ private readonly ISonarTransport _transport;
+
+ internal AppRoutingManager(ISonarTransport transport) => _transport = transport;
+
+ ///
+ public async Task> GetRoutingsAsync(CancellationToken ct = default)
+ {
+ using var doc = await _transport.GetAsync(SonarRoutes.AudioDeviceRouting, ct);
+ return ParseRoutings(doc.RootElement);
+ }
+
+ ///
+ public async Task RouteAppAsync(int processId, Channel channel, CancellationToken ct = default)
+ {
+ // Device ids are regenerated by GG updates: resolve the channel's device at call time.
+ var routings = await GetRoutingsAsync(ct);
+
+ var target = routings.FirstOrDefault(r =>
+ r.Channel == channel && r.DataFlow == AudioDataFlow.Render);
+
+ if (target is null)
+ throw new SonarResponseException(
+ $"No render device found for channel '{channel}' in the routing state.");
+
+ await RouteAppAsync(processId, target.DeviceId, AudioDataFlow.Render, ct);
+ }
+
+ ///
+ public Task RouteAppAsync(int processId, string deviceId, AudioDataFlow dataFlow, CancellationToken ct = default)
+ {
+ if (processId <= 0)
+ throw new ArgumentOutOfRangeException(nameof(processId), processId,
+ "Process id must be a positive number.");
+ if (string.IsNullOrWhiteSpace(deviceId))
+ throw new ArgumentException("Device id must not be empty.", nameof(deviceId));
+
+ return _transport.PutAsync(SonarRoutes.SetAppRouting(dataFlow, deviceId, processId), ct);
+ }
+
+ /// Parses the AudioDeviceRouting array.
+ internal static IReadOnlyList ParseRoutings(JsonElement root)
+ {
+ var result = new List();
+ if (root.ValueKind != JsonValueKind.Array) return result;
+
+ foreach (var entry in root.EnumerateArray())
+ {
+ if (ParseRouting(entry) is { } routing) result.Add(routing);
+ }
+
+ return result;
+ }
+
+ internal static DeviceRouting? ParseRouting(JsonElement entry)
+ {
+ string? deviceId = entry.GetStringOrNull("deviceId");
+ if (deviceId is null) return null;
+
+ AudioDataFlow? dataFlow = entry.GetStringOrNull("dataFlow") switch
+ {
+ "render" => AudioDataFlow.Render,
+ "capture" => AudioDataFlow.Capture,
+ _ => null
+ };
+ if (dataFlow is null) return null;
+
+ string role = entry.GetStringOrNull("role") ?? "none";
+
+ var sessions = new List();
+ if (entry.TryGetProperty("audioSessions", out var sessionArray) &&
+ sessionArray.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var session in sessionArray.EnumerateArray())
+ {
+ sessions.Add(new AudioSessionInfo(
+ session.GetStringOrNull("processName") ?? "",
+ session.TryGetProperty("processId", out var pid) &&
+ pid.ValueKind == JsonValueKind.Number ? pid.GetInt32() : 0,
+ session.GetStringOrNull("displayName") ?? "",
+ session.GetBoolOrFalse("isSystemSound"),
+ session.GetStringOrNull("state")));
+ }
+ }
+
+ return new DeviceRouting(
+ deviceId,
+ role,
+ ChannelExtensions.FromJsonKey(role), // "stream"/"none" resolve to null, by design
+ dataFlow.Value,
+ sessions);
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/AudienceMonitoringManager.cs b/SteelSeriesAPI/Sonar/Managers/AudienceMonitoringManager.cs
deleted file mode 100644
index 4378dcf..0000000
--- a/SteelSeriesAPI/Sonar/Managers/AudienceMonitoringManager.cs
+++ /dev/null
@@ -1,21 +0,0 @@
-using SteelSeriesAPI.Sonar.Http;
-using SteelSeriesAPI.Sonar.Interfaces.Managers;
-
-using System.Text.Json;
-
-namespace SteelSeriesAPI.Sonar.Managers;
-
-internal class AudienceMonitoringManager : IAudienceMonitoringManager
-{
- public bool GetState()
- {
- JsonDocument streamMonitoring = new Fetcher().Provide("streamRedirections/isStreamMonitoringEnabled");
-
- return streamMonitoring.RootElement.GetBoolean();
- }
-
- public void SetState(bool newState)
- {
- new Fetcher().Put("streamRedirections/isStreamMonitoringEnabled/" + newState);
- }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/AudioDeviceManager.cs b/SteelSeriesAPI/Sonar/Managers/AudioDeviceManager.cs
new file mode 100644
index 0000000..6a41090
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Managers/AudioDeviceManager.cs
@@ -0,0 +1,66 @@
+using System.Text.Json;
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Managers;
+
+///
+internal sealed class AudioDeviceManager : IAudioDeviceManager
+{
+ private readonly ISonarTransport _transport;
+
+ internal AudioDeviceManager(ISonarTransport transport) => _transport = transport;
+
+ ///
+ public async Task> GetAllAsync(CancellationToken ct = default)
+ {
+ using var doc = await _transport.GetAsync(SonarRoutes.AudioDevices, ct);
+ return ParseDevices(doc.RootElement);
+ }
+
+ ///
+ public async Task> GetAllAsync(
+ AudioDataFlow dataFlow, bool includeSonarVirtual = false, CancellationToken ct = default)
+ {
+ var all = await GetAllAsync(ct);
+ return all
+ .Where(d => d.DataFlow == dataFlow && (includeSonarVirtual || !d.IsSonarVirtual))
+ .ToList();
+ }
+
+ /// Parses the audioDevices array. Entries with unknown data flows are skipped.
+ internal static IReadOnlyList ParseDevices(JsonElement root)
+ {
+ var result = new List();
+ if (root.ValueKind != JsonValueKind.Array) return result;
+
+ foreach (var entry in root.EnumerateArray())
+ {
+ string? id = entry.GetStringOrNull("id");
+ if (id is null) continue;
+
+ AudioDataFlow? dataFlow = entry.GetStringOrNull("dataFlow") switch
+ {
+ "render" => AudioDataFlow.Render,
+ "capture" => AudioDataFlow.Capture,
+ _ => null // unknown flow from a future GG update: skip
+ };
+ if (dataFlow is null) continue;
+
+ bool isVirtual = entry.GetBoolOrFalse("isVad");
+ Channel? sonarChannel = isVirtual && entry.GetStringOrNull("role") is { } role
+ ? ChannelExtensions.FromJsonKey(role)
+ : null;
+
+ result.Add(new AudioDevice(
+ id,
+ entry.GetStringOrNull("friendlyName") ?? id,
+ dataFlow.Value,
+ isVirtual,
+ sonarChannel));
+ }
+
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs b/SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs
index 06c0a71..cfad07a 100644
--- a/SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs
+++ b/SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs
@@ -1,46 +1,40 @@
-using SteelSeriesAPI.Sonar.Exceptions;
-using SteelSeriesAPI.Sonar.Interfaces.Managers;
-using SteelSeriesAPI.Sonar.Http;
-
-using System.Text.Json;
-using System.Globalization;
+using System.Text.Json;
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar.Models;
namespace SteelSeriesAPI.Sonar.Managers;
-internal class ChatMixManager : IChatMixManager
+///
+internal sealed class ChatMixManager : IChatMixManager
{
- public double GetBalance()
- {
- JsonDocument chatMix = new Fetcher().Provide("chatMix");
+ private readonly ISonarTransport _transport;
- return chatMix.RootElement.GetProperty("balance").GetDouble();
- }
+ internal ChatMixManager(ISonarTransport transport) => _transport = transport;
- public bool GetState()
+ ///
+ public async Task GetAsync(CancellationToken ct = default)
{
- JsonDocument chatMix = new Fetcher().Provide("chatMix");
- string cState = chatMix.RootElement.GetProperty("state").ToString();
-
- if (cState == "enabled")
- {
- return true;
- }
-
- return false;
+ using var doc = await _transport.GetAsync(SonarRoutes.GetChatMix, ct);
+ var root = doc.RootElement;
+
+ double balance = root.TryGetProperty("balance", out var b) &&
+ b.ValueKind == JsonValueKind.Number
+ ? b.GetDouble() : 0.0;
+
+ string? state = root.TryGetProperty("state", out var s) &&
+ s.ValueKind == JsonValueKind.String
+ ? s.GetString() : null;
+
+ return new ChatMixSetting(balance, state);
}
-
- public void SetBalance(double balance)
- {
- if (!GetState())
- {
- throw new ChatMixDisabledException();
- }
- if (balance > 1 || balance < -1)
- {
- throw new ChatMixBalanceException();
- }
+ ///
+ public Task SetAsync(double balance, CancellationToken ct = default)
+ {
+ if (double.IsNaN(balance) || balance is < -1.0 or > 1.0)
+ throw new ArgumentOutOfRangeException(nameof(balance), balance,
+ "Chat mix balance must be between -1.0 and 1.0.");
- new Fetcher().Put("chatMix?balance=" + balance.ToString("0.00", CultureInfo.InvariantCulture));
+ return _transport.PutAsync(SonarRoutes.SetChatMix(balance), ct);
}
}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/ConfigManager.cs b/SteelSeriesAPI/Sonar/Managers/ConfigManager.cs
new file mode 100644
index 0000000..37b44a3
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Managers/ConfigManager.cs
@@ -0,0 +1,81 @@
+using System.Text.Json;
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Managers;
+
+///
+internal sealed class ConfigManager : IConfigManager
+{
+ private readonly ISonarTransport _transport;
+
+ internal ConfigManager(ISonarTransport transport) => _transport = transport;
+
+ ///
+ public async Task> GetAllAsync(CancellationToken ct = default)
+ {
+ using var doc = await _transport.GetAsync(SonarRoutes.Configs, ct);
+ return ParseConfigList(doc.RootElement);
+ }
+
+ ///
+ public async Task> GetAllAsync(Channel channel, CancellationToken ct = default)
+ {
+ var all = await GetAllAsync(ct);
+ return all.Where(c => c.Channel == channel).ToList();
+ }
+
+ ///
+ public async Task> GetSelectedAsync(CancellationToken ct = default)
+ {
+ using var doc = await _transport.GetAsync(SonarRoutes.SelectedConfigs, ct);
+ return ParseConfigList(doc.RootElement).ToDictionary(c => c.Channel);
+ }
+
+ ///
+ public async Task GetSelectedAsync(Channel channel, CancellationToken ct = default)
+ {
+ var selected = await GetSelectedAsync(ct);
+ return selected.GetValueOrDefault(channel);
+ }
+
+ ///
+ public Task SelectAsync(string configId, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(configId))
+ throw new ArgumentException("Config id must not be empty.", nameof(configId));
+
+ return _transport.PutAsync(SonarRoutes.SelectConfig(configId), ct);
+ }
+
+ ///
+ /// Parses a config array (both /configs and /configs/selected share the shape).
+ /// Only headers are read; the EQ payloads (data/defaultData) are deliberately ignored.
+ ///
+ internal static IReadOnlyList ParseConfigList(JsonElement root)
+ {
+ var result = new List();
+ if (root.ValueKind != JsonValueKind.Array) return result;
+
+ foreach (var entry in root.EnumerateArray())
+ {
+ if (entry.ValueKind != JsonValueKind.Object) continue;
+
+ string? id = entry.GetStringOrNull("id");
+ string? device = entry.GetStringOrNull("virtualAudioDevice");
+ Channel? channel = device is null ? null : ChannelExtensions.FromJsonKey(device);
+
+ if (id is null || channel is null) continue; // unknown/new device kind: skip, don't crash
+
+ result.Add(new SonarConfig(
+ id,
+ entry.GetStringOrNull("name") ?? "",
+ channel.Value,
+ entry.GetBoolOrFalse("isPreset"),
+ entry.GetBoolOrFalse("isFavorite")));
+ }
+
+ return result;
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/ConfigurationManager.cs b/SteelSeriesAPI/Sonar/Managers/ConfigurationManager.cs
deleted file mode 100644
index 1cf0cd5..0000000
--- a/SteelSeriesAPI/Sonar/Managers/ConfigurationManager.cs
+++ /dev/null
@@ -1,128 +0,0 @@
-using SteelSeriesAPI.Sonar.Interfaces.Managers;
-using SteelSeriesAPI.Sonar.Enums;
-using SteelSeriesAPI.Sonar.Http;
-using SteelSeriesAPI.Sonar.Models;
-
-using System.Text.Json;
-using SteelSeriesAPI.Sonar.Exceptions;
-
-namespace SteelSeriesAPI.Sonar.Managers;
-
-internal class ConfigurationManager : IConfigurationManager
-{
- public IEnumerable GetAllAudioConfigurations()
- {
- JsonElement configs = new Fetcher().Provide("configs").RootElement;
-
- foreach (JsonElement config in configs.EnumerateArray())
- {
- string device = config.GetProperty("virtualAudioDevice").GetString()!;
- string id = config.GetProperty("id").GetString()!;
- string name = config.GetProperty("name").GetString()!;
-
- yield return new SonarAudioConfiguration(id, name, (Channel)ChannelExtensions.FromDictKey(device)!);
- }
- }
-
- public IEnumerable GetAudioConfigurations(Channel channel)
- {
- if (channel == Channel.MASTER)
- {
- throw new MasterChannelNotSupportedException();
- }
-
- JsonElement configs = new Fetcher().Provide("configs").RootElement;
-
- foreach (JsonElement config in configs.EnumerateArray())
- {
- string device = config.GetProperty("virtualAudioDevice").GetString()!;
- if (device == channel.ToDictKey())
- {
- string id = config.GetProperty("id").GetString()!;
- string name = config.GetProperty("name").GetString()!;
-
- yield return new SonarAudioConfiguration(id, name, (Channel)ChannelExtensions.FromDictKey(device)!);
- }
- }
- }
-
- public SonarAudioConfiguration GetSelectedAudioConfiguration(Channel channel)
- {
- if (channel == Channel.MASTER)
- {
- throw new MasterChannelNotSupportedException();
- }
-
- JsonElement selectedConfigs = new Fetcher().Provide("configs/selected").RootElement;
-
- foreach (JsonElement config in selectedConfigs.EnumerateArray())
- {
- var device = config.GetProperty("virtualAudioDevice").GetString()!;
- if (device == channel.ToDictKey())
- {
- string id = config.GetProperty("id").GetString()!;
- string name = config.GetProperty("name").GetString()!;
-
- return new SonarAudioConfiguration(id, name, (Channel)ChannelExtensions.FromDictKey(device)!);
- }
- }
-
- throw new ChannelNotFoundException();
- }
-
- public SonarAudioConfiguration GetAudioConfiguration(string configId)
- {
- JsonElement configs = new Fetcher().Provide("configs").RootElement;
-
- foreach (JsonElement config in configs.EnumerateArray())
- {
- string id = config.GetProperty("id").GetString()!;
- if (id == configId)
- {
- string device = config.GetProperty("virtualAudioDevice").GetString()!;
- string name = config.GetProperty("name").GetString()!;
-
- return new SonarAudioConfiguration(id, name, (Channel)ChannelExtensions.FromDictKey(device)!);
- }
- }
-
- throw new ConfigNotFoundException($"No audio configuration found with this id: {configId}");
- }
-
- public void SetConfig(string configId)
- {
- if (string.IsNullOrEmpty(configId)) throw new ConfigNotFoundException("Id can't be null or empty");
-
- JsonElement configs = new Fetcher().Provide("configs").RootElement;
-
- foreach (JsonElement config in configs.EnumerateArray())
- {
- string id = config.GetProperty("id").GetString()!;
- if (id == configId)
- {
- new Fetcher().Put("configs/" + configId + "/select");
- return;
- }
- }
-
- throw new ConfigNotFoundException($"No audio configuration found with this id: {configId}");
- }
-
- public void SetConfig(SonarAudioConfiguration config)
- {
- SetConfig(config.Id);
- }
-
- public void SetConfigByName(Channel channel, string name)
- {
- var configs = GetAudioConfigurations(channel).ToList();
- foreach (var config in configs)
- {
- if (config.Name == name)
- {
- SetConfig(config.Id);
- break;
- }
- }
- }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/EventManager.cs b/SteelSeriesAPI/Sonar/Managers/EventManager.cs
deleted file mode 100644
index 4ab738d..0000000
--- a/SteelSeriesAPI/Sonar/Managers/EventManager.cs
+++ /dev/null
@@ -1,220 +0,0 @@
-using System.Globalization;
-using SteelSeriesAPI.Sonar.Events;
-using SteelSeriesAPI.Sonar.Enums;
-
-namespace SteelSeriesAPI.Sonar.Managers;
-
-///
-/// Manage the different Sonar Events
-///
-public class EventManager
-{
- ///
- /// Notify when the current changed
- ///
- public event EventHandler OnSonarModeChange = delegate{ };
-
- ///
- /// Notify when the volume of a has changed
- ///
- public event EventHandler OnSonarVolumeChange = delegate{ };
-
- ///
- /// Notify when a gets un/muted
- ///
- public event EventHandler OnSonarMuteChange = delegate{ };
-
- ///
- /// Notify when the config of a is changed
- ///
- public event EventHandler OnSonarConfigChange = delegate{ };
-
- ///
- /// Notify when ChatMix value was changed
- ///
- public event EventHandler OnSonarChatMixChange = delegate{ };
-
- ///
- /// Notify when the playback device of a is changed
- ///
- public event EventHandler OnSonarPlaybackDeviceChange = delegate{ };
-
- ///
- /// Notify when an audio process is routed to a new
- ///
- public event EventHandler OnSonarRoutedProcessChange = delegate{ };
-
- ///
- /// Notify when a gets de/activated
- ///
- public event EventHandler OnSonarMixChange = delegate{ };
-
- ///
- /// Notify when the audience monitoring state is changed
- ///
- public event EventHandler OnSonarAudienceMonitoringChange = delegate{ };
-
- internal void HandleEvent(string path)
- {
- var eventMessage = PathResolver(path);
- switch (eventMessage)
- {
- case SonarModeEvent sonarModeEvent:
- OnSonarModeChange(this, sonarModeEvent);
- break;
- case SonarVolumeEvent sonarVolumeEvent:
- OnSonarVolumeChange(this, sonarVolumeEvent);
- break;
- case SonarMuteEvent sonarMuteEvent:
- OnSonarMuteChange(this, sonarMuteEvent);
- break;
- case SonarConfigEvent sonarConfigEvent:
- OnSonarConfigChange(this, sonarConfigEvent);
- break;
- case SonarChatMixEvent sonarChatMixEvent:
- OnSonarChatMixChange(this, sonarChatMixEvent);
- break;
- case SonarPlaybackDeviceEvent sonarPlaybackDeviceEvent:
- OnSonarPlaybackDeviceChange(this, sonarPlaybackDeviceEvent);
- break;
- case SonarRoutedProcessEvent sonarRoutedProcessEvent:
- OnSonarRoutedProcessChange(this, sonarRoutedProcessEvent);
- break;
- case SonarMixEvent sonarMixEvent:
- OnSonarMixChange(this, sonarMixEvent);
- break;
- case SonarAudienceMonitoringEvent sonarAudienceMonitoringEvent:
- OnSonarAudienceMonitoringChange(this, sonarAudienceMonitoringEvent);
- break;
- }
- }
-
- private EventArgs PathResolver(string path)
- {
- string[] subs = path.Split("/");
- EventArgs eventArgs = null!;
-
- switch (subs[1])
- {
- case "mode":
- eventArgs = new SonarModeEvent
- { NewMode = (Mode)ModeExtensions.FromDictKey(subs[2], ModeMapChoice.StreamDict)! };
- break;
- case "volumeSettings":
- switch (subs[2])
- {
- case "classic":
- switch (subs[4])
- {
- case "Volume":
- eventArgs = new SonarVolumeEvent
- {
- Volume = double.Parse(subs[5], CultureInfo.InvariantCulture.NumberFormat),
- Mode = Mode.CLASSIC,
- Channel = (Channel)ChannelExtensions.FromDictKey(subs[3], ChannelMapChoice.HttpDict)!
- };
- break;
- case"Mute":
- eventArgs = new SonarMuteEvent
- {
- Muted = Convert.ToBoolean(subs[5]),
- Mode = Mode.CLASSIC,
- Channel = (Channel)ChannelExtensions.FromDictKey(subs[3], ChannelMapChoice.HttpDict)!
- };
- break;
- }
- break;
- case "streamer":
- switch (subs[5])
- {
- case "volume":
- eventArgs = new SonarVolumeEvent
- {
- Volume = double.Parse(subs[6], CultureInfo.InvariantCulture.NumberFormat),
- Mode = Mode.STREAMER,
- Channel = (Channel)ChannelExtensions.FromDictKey(subs[4], ChannelMapChoice.HttpDict)!,
- Mix = (Mix)MixExtensions.FromDictKey(subs[3])!
- };
- break;
- case "isMuted":
- eventArgs = new SonarMuteEvent
- {
- Muted = Convert.ToBoolean(subs[6]),
- Mode = Mode.STREAMER,
- Channel = (Channel)ChannelExtensions.FromDictKey(subs[4], ChannelMapChoice.HttpDict)!,
- Mix = (Mix)MixExtensions.FromDictKey(subs[3])!
- };
- break;
- }
- break;
- }
- break;
- case "configs":
- if (!(subs.Length < 3))
- {
- eventArgs = new SonarConfigEvent { ConfigId = subs[2] };
- }
- break;
- case "classicRedirections":
- eventArgs = new SonarPlaybackDeviceEvent
- {
- PlaybackDeviceId = subs[4].Replace("%7B", "{").Replace("%7D", "}"),
- Mode = Mode.CLASSIC,
- Channel = (Channel)ChannelExtensions.FromDictKey(subs[2], ChannelMapChoice.ChannelDict)!
- };
- break;
- case "streamRedirections":
- if (subs[2] == "isStreamMonitoringEnabled")
- {
- eventArgs = new SonarAudienceMonitoringEvent { NewState = Convert.ToBoolean(subs[3]) };
- break;
- }
-
- switch (subs[3])
- {
- case "deviceId":
- if (subs[2] == "mic")
- {
- eventArgs = new SonarPlaybackDeviceEvent
- {
- PlaybackDeviceId = subs[4].Replace("%7B", "{").Replace("%7D", "}"),
- Mode = Mode.STREAMER,
- Channel = Channel.MIC
- };
- break;
- }
-
- eventArgs = new SonarPlaybackDeviceEvent
- {
- PlaybackDeviceId = subs[4].Replace("%7B", "{").Replace("%7D", "}"),
- Mode = Mode.STREAMER,
- Mix = (Mix)MixExtensions.FromDictKey(subs[2])!
- };
- break;
- case "redirections":
- eventArgs = new SonarMixEvent
- {
- NewState = Convert.ToBoolean(subs[6]),
- Channel = (Channel)ChannelExtensions.FromDictKey(subs[4])!,
- Mix = (Mix)MixExtensions.FromDictKey(subs[2])!
- };
- break;
- }
- break;
- case "AudioDeviceRouting":
- eventArgs = new SonarRoutedProcessEvent(subs[3].Replace("%7B", "{").Replace("%7D", "}"))
- {
- ProcessId = Convert.ToInt32(subs[4])
- };
- break;
- default:
- if (subs[1].StartsWith("chatMix"))
- {
- eventArgs = new SonarChatMixEvent { Balance = Convert.ToDouble(subs[1].Split("=")[1], CultureInfo.InvariantCulture) };
- }
- break;
- }
-
- return eventArgs;
- }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/IAppRoutingManager.cs b/SteelSeriesAPI/Sonar/Managers/IAppRoutingManager.cs
new file mode 100644
index 0000000..6122aa0
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Managers/IAppRoutingManager.cs
@@ -0,0 +1,30 @@
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Managers;
+
+/// Reads and controls which Sonar channel each application's audio is routed to.
+public interface IAppRoutingManager
+{
+ /// Gets the routing state of every device, with their attached audio sessions.
+ /// A token to cancel the operation.
+ Task> GetRoutingsAsync(CancellationToken ct = default);
+
+ ///
+ /// Routes an application's output audio to a Sonar channel.
+ /// Resolves the channel's virtual device, then applies the routing.
+ ///
+ /// The application's process id (volatile: resolve it at call time).
+ /// The channel to route the application to.
+ /// A token to cancel the operation.
+ /// No virtual device found for the channel.
+ Task RouteAppAsync(int processId, Channel channel, CancellationToken ct = default);
+
+ /// Routes an application to an explicit device. Low-level variant.
+ /// The application's process id.
+ /// The target device identifier.
+ /// The direction of the routing (render for app output).
+ /// A token to cancel the operation.
+ Task RouteAppAsync(int processId, string deviceId, AudioDataFlow dataFlow, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/IAudioDeviceManager.cs b/SteelSeriesAPI/Sonar/Managers/IAudioDeviceManager.cs
new file mode 100644
index 0000000..c73d988
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Managers/IAudioDeviceManager.cs
@@ -0,0 +1,22 @@
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Managers;
+
+/// Lists the audio devices known to Sonar.
+public interface IAudioDeviceManager
+{
+ /// Gets all devices, physical and Sonar virtual.
+ /// A token to cancel the operation.
+ Task> GetAllAsync(CancellationToken ct = default);
+
+ ///
+ /// Gets the devices of one direction, excluding Sonar virtual devices by default.
+ /// This is the list to offer when picking a redirection target.
+ ///
+ /// The device direction to list.
+ /// Whether to include Sonar's own virtual devices.
+ /// A token to cancel the operation.
+ Task> GetAllAsync(
+ AudioDataFlow dataFlow, bool includeSonarVirtual = false, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/IChatMixManager.cs b/SteelSeriesAPI/Sonar/Managers/IChatMixManager.cs
new file mode 100644
index 0000000..eaedab6
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Managers/IChatMixManager.cs
@@ -0,0 +1,17 @@
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Managers;
+
+/// Reads and controls the Sonar chat mix (game/chat balance).
+public interface IChatMixManager
+{
+ /// Gets the current chat mix balance and availability state.
+ /// A token to cancel the operation.
+ Task GetAsync(CancellationToken ct = default);
+
+ /// Sets the chat mix balance.
+ /// The balance, from -1.0 (game only) to +1.0 (chat only).
+ /// A token to cancel the operation.
+ /// Balance is outside the -1.0–1.0 range.
+ Task SetAsync(double balance, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/IConfigManager.cs b/SteelSeriesAPI/Sonar/Managers/IConfigManager.cs
new file mode 100644
index 0000000..0f77a77
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Managers/IConfigManager.cs
@@ -0,0 +1,31 @@
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Managers;
+
+/// Lists and selects Sonar audio configs (presets).
+public interface IConfigManager
+{
+ /// Gets all configs, user-created and presets, across all channels.
+ /// A token to cancel the operation.
+ Task> GetAllAsync(CancellationToken ct = default);
+
+ /// Gets all configs applicable to one channel.
+ /// The channel to list configs for.
+ /// A token to cancel the operation.
+ Task> GetAllAsync(Channel channel, CancellationToken ct = default);
+
+ /// Gets the currently selected config of each channel.
+ /// A token to cancel the operation.
+ Task> GetSelectedAsync(CancellationToken ct = default);
+
+ /// Gets the currently selected config of one channel, or null if none is reported.
+ /// The channel to query.
+ /// A token to cancel the operation.
+ Task GetSelectedAsync(Channel channel, CancellationToken ct = default);
+
+ /// Selects a config. The affected channel is determined by the config itself.
+ /// The id of the config to select, as returned by the listing methods.
+ /// A token to cancel the operation.
+ Task SelectAsync(string configId, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/IModeManager.cs b/SteelSeriesAPI/Sonar/Managers/IModeManager.cs
new file mode 100644
index 0000000..9739fda
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Managers/IModeManager.cs
@@ -0,0 +1,20 @@
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar.Enums;
+
+namespace SteelSeriesAPI.Sonar.Managers;
+
+/// Reads and switches the Sonar mixer mode.
+public interface IModeManager
+{
+ /// Gets the current mixer mode.
+ /// A token to cancel the operation.
+ Task GetAsync(CancellationToken ct = default);
+
+ ///
+ /// Switches the mixer mode and waits until Sonar reports the change as effective.
+ ///
+ /// The mode to switch to.
+ /// A token to cancel the operation.
+ /// Sonar did not confirm the switch in time.
+ Task SetAsync(Mode mode, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/IRedirectionsManager.cs b/SteelSeriesAPI/Sonar/Managers/IRedirectionsManager.cs
new file mode 100644
index 0000000..fdcfcdc
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Managers/IRedirectionsManager.cs
@@ -0,0 +1,34 @@
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Managers;
+
+/// Reads and controls Sonar audio redirections: device routing, mix toggles, and stream monitoring.
+public interface IRedirectionsManager
+{
+ /// Gets the device assigned to each channel in classic mode.
+ /// A token to cancel the operation.
+ Task> GetClassicRedirectionsAsync(CancellationToken ct = default);
+
+ /// Routes a channel to a different device in classic mode.
+ /// The channel to reroute (Master is not routable).
+ /// The Windows device identifier, as listed by the audio devices route.
+ /// A token to cancel the operation.
+ Task SetClassicDeviceAsync(Channel channel, string deviceId, CancellationToken ct = default);
+
+ /// Gets the complete streamer-mode redirection state (both mixes and the mic passthrough).
+ /// A token to cancel the operation.
+ Task GetStreamRedirectionsAsync(CancellationToken ct = default);
+
+ /// Routes a streamer-mode mix to a different output device.
+ Task SetMixDeviceAsync(Mix mix, string deviceId, CancellationToken ct = default);
+
+ /// Enables or disables a channel on a streamer-mode mix (the per-channel mix toggles).
+ Task SetMixChannelEnabledAsync(Mix mix, Channel channel, bool enabled, CancellationToken ct = default);
+
+ /// Gets whether stream monitoring ("hear what the audience hears") is enabled.
+ Task GetStreamMonitoringEnabledAsync(CancellationToken ct = default);
+
+ /// Enables or disables stream monitoring.
+ Task SetStreamMonitoringEnabledAsync(bool enabled, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/IVolumeSettingsManager.cs b/SteelSeriesAPI/Sonar/Managers/IVolumeSettingsManager.cs
new file mode 100644
index 0000000..4d4f486
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Managers/IVolumeSettingsManager.cs
@@ -0,0 +1,32 @@
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Managers;
+
+/// Reads and controls the volume and mute state of Sonar channels.
+public interface IVolumeSettingsManager
+{
+ /// Gets the volume and mute state of a channel in classic mode.
+ /// The channel to read.
+ /// A token to cancel the operation.
+ Task GetAsync(Channel channel, CancellationToken ct = default);
+
+ /// Gets the volume and mute state of a channel for a specific streamer-mode mix.
+ Task GetAsync(Channel channel, Mix mix, CancellationToken ct = default);
+
+ /// Sets the volume of a channel in classic mode.
+ /// The channel to modify.
+ /// The volume level, from 0.0 to 1.0.
+ /// A token to cancel the operation.
+ /// Volume is outside the 0.0–1.0 range.
+ Task SetVolumeAsync(Channel channel, double volume, CancellationToken ct = default);
+
+ /// Sets the volume of a channel for a specific streamer-mode mix.
+ Task SetVolumeAsync(Channel channel, Mix mix, double volume, CancellationToken ct = default);
+
+ /// Mutes or unmutes a channel in classic mode.
+ Task SetMuteAsync(Channel channel, bool muted, CancellationToken ct = default);
+
+ /// Mutes or unmutes a channel for a specific streamer-mode mix.
+ Task SetMuteAsync(Channel channel, Mix mix, bool muted, CancellationToken ct = default);
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/MixManager.cs b/SteelSeriesAPI/Sonar/Managers/MixManager.cs
deleted file mode 100644
index 8c8c555..0000000
--- a/SteelSeriesAPI/Sonar/Managers/MixManager.cs
+++ /dev/null
@@ -1,49 +0,0 @@
-using SteelSeriesAPI.Sonar.Exceptions;
-using SteelSeriesAPI.Sonar.Enums;
-using SteelSeriesAPI.Sonar.Http;
-
-using System.Text.Json;
-using SteelSeriesAPI.Sonar.Interfaces.Managers;
-
-namespace SteelSeriesAPI.Sonar.Managers;
-
-internal class MixManager : IMixManager
-{
- public bool GetState(Channel channel, Mix mix)
- {
- JsonElement streamRedirections = new Fetcher().Provide("streamRedirections").RootElement;
-
- foreach (JsonElement element in streamRedirections.EnumerateArray())
- {
- if (element.GetProperty("streamRedirectionId").GetString() == mix.ToDictKey())
- {
- foreach (JsonElement status in element.GetProperty("status").EnumerateArray())
- {
- if (status.GetProperty("role").GetString() == channel.ToDictKey())
- {
- return status.GetProperty("isEnabled").GetBoolean();
- }
- }
-
- throw new ChannelNotFoundException();
- }
- }
-
- throw new MixNotFoundException();
- }
-
- public void SetState(bool newState,Channel channel, Mix mix)
- {
- new Fetcher().Put("streamRedirections/" + mix.ToDictKey() + "/redirections/" + channel.ToDictKey() + "/isEnabled/" + newState);
- }
-
- public void Activate(Channel channel, Mix mix)
- {
- new Fetcher().Put("streamRedirections/" + mix.ToDictKey() + "/redirections/" + channel.ToDictKey() + "/isEnabled/true");
- }
-
- public void Deactivate(Channel channel, Mix mix)
- {
- new Fetcher().Put("streamRedirections/" + mix.ToDictKey() + "/redirections/" + channel.ToDictKey() + "/isEnabled/false");
- }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/ModeManager.cs b/SteelSeriesAPI/Sonar/Managers/ModeManager.cs
index f8ce852..4ecef34 100644
--- a/SteelSeriesAPI/Sonar/Managers/ModeManager.cs
+++ b/SteelSeriesAPI/Sonar/Managers/ModeManager.cs
@@ -1,21 +1,43 @@
-using SteelSeriesAPI.Sonar.Enums;
-using SteelSeriesAPI.Sonar.Http;
-using SteelSeriesAPI.Sonar.Interfaces.Managers;
+using System.Text.Json;
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar.Enums;
namespace SteelSeriesAPI.Sonar.Managers;
-internal class ModeManager : IModeManager
+///
+internal sealed class ModeManager : IModeManager
{
- public Mode Get()
+ private readonly ISonarTransport _transport;
+
+ internal ModeManager(ISonarTransport transport) => _transport = transport;
+
+ ///
+ public async Task GetAsync(CancellationToken ct = default)
{
- string mode = new Fetcher().Provide("mode").RootElement.ToString();
-
- return (Mode)ModeExtensions.FromDictKey(mode, ModeMapChoice.StreamDict)!;
+ using var doc = await _transport.GetAsync(SonarRoutes.GetMode, ct);
+
+ string? raw = doc.RootElement.ValueKind == JsonValueKind.String
+ ? doc.RootElement.GetString()
+ : null;
+
+ return ModeExtensions.FromApiValue(raw ?? "")
+ ?? throw new SonarResponseException($"Unknown mode value '{raw}' returned by Sonar.");
}
- public void Set(Mode mode)
+ ///
+ public async Task SetAsync(Mode mode, CancellationToken ct = default)
{
- new Fetcher().Put("mode/" + mode.ToDictKey(ModeMapChoice.StreamDict));
- Thread.Sleep(100); // Prevent bugs/freezes/crashes
+ await _transport.PutAsync(SonarRoutes.SetMode(mode), ct);
+
+ // Mode switching takes ~400-600ms in practice (measured 2026-08-07).
+ // Poll every 100ms with a generous 5s budget: succeeds as soon as confirmed.
+ for (int attempt = 0; attempt < 50; attempt++)
+ {
+ if (await GetAsync(ct) == mode) return;
+ await Task.Delay(100, ct);
+ }
+
+ throw new SonarResponseException(
+ $"Sonar did not confirm the switch to mode '{mode}' within 5 seconds.");
}
}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/PlaybackDeviceManager.cs b/SteelSeriesAPI/Sonar/Managers/PlaybackDeviceManager.cs
deleted file mode 100644
index 02d93dd..0000000
--- a/SteelSeriesAPI/Sonar/Managers/PlaybackDeviceManager.cs
+++ /dev/null
@@ -1,351 +0,0 @@
-using System.Collections;
-using SteelSeriesAPI.Sonar.Exceptions;
-using SteelSeriesAPI.Sonar.Enums;
-using SteelSeriesAPI.Sonar.Http;
-using SteelSeriesAPI.Sonar.Interfaces.Managers;
-using SteelSeriesAPI.Sonar.Models;
-
-using System.Text.Json;
-
-namespace SteelSeriesAPI.Sonar.Managers;
-
-internal class PlaybackDeviceManager : IPlaybackDeviceManager
-{
- public IEnumerable GetAllPlaybackDevices()
- {
- JsonElement audioDevices = new Fetcher().Provide("audioDevices").RootElement;
-
- foreach (JsonElement device in audioDevices.EnumerateArray())
- {
- if (device.GetProperty("role").GetString() == "none")
- {
- string id = device.GetProperty("id").GetString()!;
- string name = device.GetProperty("friendlyName").GetString()!;
- DataFlow dataFlow = (DataFlow)DataFlowExtensions.FromDictKey(device.GetProperty("dataFlow").GetString()!)!;
- List> channels = new List>();
- List mixes = new List();
-
- GetChannelsAndMixes(id, channels, mixes);
-
- yield return new PlaybackDevice(id, name, dataFlow, channels, mixes);
- }
- }
- }
-
- public IEnumerable GetOutputPlaybackDevices()
- {
- JsonElement audioDevices = new Fetcher().Provide("audioDevices").RootElement;
-
- foreach (JsonElement device in audioDevices.EnumerateArray())
- {
- if (device.GetProperty("role").GetString() == "none")
- {
- string dataFlow = device.GetProperty("dataFlow").GetString()!;
- if (dataFlow == "render")
- {
- string id = device.GetProperty("id").GetString()!;
- string name = device.GetProperty("friendlyName").GetString()!;
- List> channels = new List>();
- List mixes = new List();
-
- GetChannelsAndMixes(id, channels, mixes);
-
- yield return new PlaybackDevice(id, name, DataFlow.OUTPUT, channels, mixes);
- }
- }
- }
- }
-
- public IEnumerable GetInputPlaybackDevices()
- {
- JsonElement audioDevices = new Fetcher().Provide("audioDevices").RootElement;
-
- foreach (JsonElement device in audioDevices.EnumerateArray())
- {
- if (device.GetProperty("role").GetString() == "none")
- {
- string dataFlow = device.GetProperty("dataFlow").GetString()!;
- if (dataFlow == "capture")
- {
- string id = device.GetProperty("id").GetString()!;
- string name = device.GetProperty("friendlyName").GetString()!;
- List> channels = new List>();
- List mixes = new List();
-
- GetChannelsAndMixes(id, channels, mixes);
-
- yield return new PlaybackDevice(id, name, DataFlow.INPUT, channels, mixes);
- }
- }
- }
- }
-
- public PlaybackDevice GetPlaybackDevice(Channel channel)
- {
- JsonElement classicRedirections = new Fetcher().Provide("classicRedirections").RootElement;
- JsonElement audioDevices = new Fetcher().Provide("audioDevices").RootElement;
-
- foreach (JsonElement redirection in classicRedirections.EnumerateArray())
- {
- if (redirection.GetProperty("id").GetString() == channel.ToDictKey(ChannelMapChoice.ChannelDict))
- {
- string deviceId = redirection.GetProperty("deviceId").GetString()!;
-
- if (string.IsNullOrEmpty(deviceId))
- {
- throw new PlaybackDeviceNotFoundException("No device set on this channel");
- }
-
- foreach (JsonElement device in audioDevices.EnumerateArray())
- {
- if (device.GetProperty("id").GetString() == deviceId)
- {
- string name = device.GetProperty("friendlyName").GetString()!;
- DataFlow dataFlow = (DataFlow)DataFlowExtensions.FromDictKey(device.GetProperty("dataFlow").GetString()!)!;
- List> channels = new List>();
- List mixes = new List();
-
- GetChannelsAndMixes(deviceId, channels, mixes);
-
- return new PlaybackDevice(deviceId, name, dataFlow, channels, mixes);
- }
- }
-
- throw new PlaybackDeviceNotFoundException("Could not find the device");
- }
- }
-
- throw new ChannelNotFoundException("Could not find the Channel");
- }
-
- public PlaybackDevice GetPlaybackDevice(Channel channel, Mode mode)
- {
- if (mode == Mode.CLASSIC)
- {
- return GetPlaybackDevice(channel);
- }
-
- if (mode == Mode.STREAMER && channel != Channel.MIC)
- {
- throw new ChannelNoStreamerSupportException();
- }
-
- JsonElement streamRedirections = new Fetcher().Provide("streamRedirections").RootElement;
- JsonElement audioDevices = new Fetcher().Provide("audioDevices").RootElement;
-
- foreach (JsonElement redirection in streamRedirections.EnumerateArray())
- {
- if (redirection.GetProperty("streamRedirectionId").GetString() == channel.ToDictKey(ChannelMapChoice.ChannelDict))
- {
- string deviceId = redirection.GetProperty("deviceId").GetString()!;
-
- if (string.IsNullOrEmpty(deviceId))
- {
- throw new PlaybackDeviceNotFoundException("No device set on this channel");
- }
-
- foreach (JsonElement device in audioDevices.EnumerateArray())
- {
- if (device.GetProperty("id").GetString() == deviceId)
- {
- string name = device.GetProperty("friendlyName").GetString()!;
- DataFlow dataFlow = (DataFlow)DataFlowExtensions.FromDictKey(device.GetProperty("dataFlow").GetString()!)!;
- List> channels = new List>();
- List mixes = new List();
-
- GetChannelsAndMixes(deviceId, channels, mixes);
-
- return new PlaybackDevice(deviceId, name, dataFlow, channels, mixes);
- }
- }
-
- throw new PlaybackDeviceNotFoundException("Could not find the device");
- }
- }
-
- throw new ChannelNotFoundException("Could not find the Channel");
- }
-
- public PlaybackDevice GetPlaybackDevice(Mix mix)
- {
- JsonElement streamRedirections = new Fetcher().Provide("streamRedirections").RootElement;
- JsonElement audioDevices = new Fetcher().Provide("audioDevices").RootElement;
-
- foreach (JsonElement redirection in streamRedirections.EnumerateArray())
- {
- if (redirection.GetProperty("streamRedirectionId").GetString() == mix.ToDictKey())
- {
- string deviceId = redirection.GetProperty("deviceId").GetString()!;
-
- if (string.IsNullOrEmpty(deviceId))
- {
- throw new PlaybackDeviceNotFoundException("No device set on this channel");
- }
-
- foreach (JsonElement device in audioDevices.EnumerateArray())
- {
- if (device.GetProperty("id").GetString() == deviceId)
- {
- string name = device.GetProperty("friendlyName").GetString()!;
- DataFlow dataFlow = (DataFlow)DataFlowExtensions.FromDictKey(device.GetProperty("dataFlow").GetString()!)!;
- List> channels = new List>();
- List mixes = new List();
-
- GetChannelsAndMixes(deviceId, channels, mixes);
-
- return new PlaybackDevice(deviceId, name, dataFlow, channels, mixes);
- }
- }
-
- throw new PlaybackDeviceNotFoundException("Could not find the device");
- }
- }
-
- throw new ChannelNotFoundException("Could not find the Channel");
- }
-
- public PlaybackDevice GetPlaybackDevice(string deviceId)
- {
- JsonElement audioDevices = new Fetcher().Provide("audioDevices").RootElement;
-
- foreach (JsonElement device in audioDevices.EnumerateArray())
- {
- if (device.GetProperty("id").GetString() == deviceId)
- {
- string name = device.GetProperty("friendlyName").GetString()!;
- DataFlow dataFlow = (DataFlow)DataFlowExtensions.FromDictKey(device.GetProperty("dataFlow").GetString()!)!;
- List> channels = new List>();
- List mixes = new List();
-
- GetChannelsAndMixes(deviceId, channels, mixes);
-
- return new PlaybackDevice(deviceId, name, dataFlow, channels, mixes);
- }
- }
-
- throw new PlaybackDeviceNotFoundException("Could not find the device");
- }
-
- private void GetChannelsAndMixes(string deviceId, List> channels, List mixes)
- {
- JsonElement classicRedirections = new Fetcher().Provide("classicRedirections").RootElement;
-
- foreach (JsonElement redirection in classicRedirections.EnumerateArray())
- {
- if (redirection.GetProperty("deviceId").GetString() == deviceId)
- {
- channels.Add(new Tuple((Channel)ChannelExtensions.FromDictKey(redirection.GetProperty("id").GetString()!, ChannelMapChoice.ChannelDict)!, Mode.CLASSIC));
- }
- }
-
- JsonElement streamRedirections = new Fetcher().Provide("streamRedirections").RootElement;
-
- foreach (JsonElement redirection in streamRedirections.EnumerateArray())
- {
- if (redirection.GetProperty("deviceId").GetString() == deviceId)
- {
- var id = redirection.GetProperty("streamRedirectionId").GetString()!;
- if (id == "mic")
- {
- channels.Add(new Tuple(Channel.MIC, Mode.STREAMER));
- }
- else
- {
- mixes.Add((Mix)MixExtensions.FromDictKey(id)!);
- }
- }
- }
- }
-
- public void SetPlaybackDevice(string deviceId, Channel channel)
- {
- JsonElement audioDevices = new Fetcher().Provide("audioDevices").RootElement;
-
- foreach (JsonElement device in audioDevices.EnumerateArray())
- {
- if (device.GetProperty("id").GetString() == deviceId)
- {
- string dataFlow = device.GetProperty("dataFlow").GetString()!;
- if ((dataFlow == "render" && channel == Channel.MIC)
- || (dataFlow == "capture" && channel != Channel.MIC))
- {
- throw new PlaybackDeviceDataFlowException();
- }
-
- new Fetcher().Put("classicRedirections/" + channel.ToDictKey(ChannelMapChoice.ChannelDict) +"/deviceId/" + deviceId);
- return;
- }
- }
-
- throw new PlaybackDeviceNotFoundException("Could not find the device");
- }
-
- public void SetPlaybackDevice(string deviceId, Channel channel, Mode mode)
- {
- if (mode == Mode.CLASSIC)
- {
- SetPlaybackDevice(deviceId, channel);
- }
-
- if (mode == Mode.STREAMER && channel != Channel.MIC)
- {
- throw new ChannelNoStreamerSupportException();
- }
-
- JsonElement audioDevices = new Fetcher().Provide("audioDevices").RootElement;
-
- foreach (JsonElement device in audioDevices.EnumerateArray())
- {
- if (device.GetProperty("id").GetString() == deviceId)
- {
- string dataFlow = device.GetProperty("dataFlow").GetString()!;
- if (dataFlow == "capture" && channel != Channel.MIC)
- {
- throw new PlaybackDeviceDataFlowException();
- }
-
- new Fetcher().Put("streamRedirections/" + channel.ToDictKey(ChannelMapChoice.ChannelDict) +"/deviceId/" + deviceId);
- return;
- }
- }
-
- throw new PlaybackDeviceNotFoundException("Could not find the device");
- }
-
- public void SetPlaybackDevice(string deviceId, Mix mix)
- {
- JsonElement audioDevices = new Fetcher().Provide("audioDevices").RootElement;
-
- foreach (JsonElement device in audioDevices.EnumerateArray())
- {
- if (device.GetProperty("id").GetString() == deviceId)
- {
- string dataFlow = device.GetProperty("dataFlow").GetString()!;
- if (dataFlow == "capture")
- {
- throw new PlaybackDeviceDataFlowException();
- }
-
- new Fetcher().Put("streamRedirections/" + mix.ToDictKey() +"/deviceId/" + deviceId);
- return;
- }
- }
-
- throw new PlaybackDeviceNotFoundException("Could not find the device");
- }
-
- public void SetPlaybackDevice(PlaybackDevice device, Channel channel)
- {
- SetPlaybackDevice(device.Id, channel);
- }
-
- public void SetPlaybackDevice(PlaybackDevice device, Channel channel, Mode mode)
- {
- SetPlaybackDevice(device.Id, channel, mode);
- }
-
- public void SetPlaybackDevice(PlaybackDevice device, Mix mix)
- {
- SetPlaybackDevice(device.Id, mix);
- }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/RedirectionsManager.cs b/SteelSeriesAPI/Sonar/Managers/RedirectionsManager.cs
new file mode 100644
index 0000000..f398f0d
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Managers/RedirectionsManager.cs
@@ -0,0 +1,140 @@
+using System.Text.Json;
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar.Enums;
+using SteelSeriesAPI.Sonar.Models;
+
+namespace SteelSeriesAPI.Sonar.Managers;
+
+///
+internal sealed class RedirectionsManager : IRedirectionsManager
+{
+ private readonly ISonarTransport _transport;
+
+ internal RedirectionsManager(ISonarTransport transport) => _transport = transport;
+
+ ///
+ public async Task> GetClassicRedirectionsAsync(CancellationToken ct = default)
+ {
+ using var doc = await _transport.GetAsync(SonarRoutes.ClassicRedirections, ct);
+ return ParseClassicRedirections(doc.RootElement);
+ }
+
+ ///
+ public Task SetClassicDeviceAsync(Channel channel, string deviceId, CancellationToken ct = default)
+ {
+ ValidateDeviceId(deviceId);
+ return _transport.PutAsync(SonarRoutes.SetClassicRedirectionDevice(channel, deviceId), ct);
+ }
+
+ ///
+ public async Task GetStreamRedirectionsAsync(CancellationToken ct = default)
+ {
+ using var doc = await _transport.GetAsync(SonarRoutes.StreamRedirections, ct);
+ return ParseStreamRedirections(doc.RootElement);
+ }
+
+ ///
+ public Task SetMixDeviceAsync(Mix mix, string deviceId, CancellationToken ct = default)
+ {
+ ValidateDeviceId(deviceId);
+ return _transport.PutAsync(SonarRoutes.SetStreamRedirectionDevice(mix, deviceId), ct);
+ }
+
+ ///
+ public Task SetMixChannelEnabledAsync(Mix mix, Channel channel, bool enabled, CancellationToken ct = default)
+ {
+ if (channel == Channel.Master)
+ throw new ArgumentException("The Master channel cannot be toggled on a mix.", nameof(channel));
+
+ return _transport.PutAsync(SonarRoutes.SetMixChannelEnabled(mix, channel, enabled), ct);
+ }
+
+ ///
+ public async Task GetStreamMonitoringEnabledAsync(CancellationToken ct = default)
+ {
+ using var doc = await _transport.GetAsync(SonarRoutes.StreamMonitoringEnabled, ct);
+ return doc.RootElement.ValueKind == JsonValueKind.True;
+ }
+
+ ///
+ public Task SetStreamMonitoringEnabledAsync(bool enabled, CancellationToken ct = default) =>
+ _transport.PutAsync(SonarRoutes.SetStreamMonitoringEnabled(enabled), ct);
+
+ private static void ValidateDeviceId(string deviceId)
+ {
+ if (string.IsNullOrWhiteSpace(deviceId))
+ throw new ArgumentException("Device id must not be empty.", nameof(deviceId));
+ }
+
+ /// Parses the classicRedirections array. Unknown channel ids are skipped.
+ internal static IReadOnlyList ParseClassicRedirections(JsonElement root)
+ {
+ var result = new List();
+ if (root.ValueKind != JsonValueKind.Array) return result;
+
+ foreach (var entry in root.EnumerateArray())
+ {
+ if (entry.ValueKind != JsonValueKind.Object) continue;
+
+ string? id = entry.TryGetProperty("id", out var i) && i.ValueKind == JsonValueKind.String
+ ? i.GetString() : null;
+ Channel? channel = id is null ? null : ChannelExtensions.FromClassicRedirectionKey(id);
+ if (channel is null) continue; // unknown channel from a future GG update: skip
+
+ result.Add(new ClassicRedirection(
+ channel.Value,
+ entry.GetStringOrNull("deviceId") ?? "",
+ entry.GetBoolOrFalse("isRunning")));
+ }
+
+ return result;
+ }
+
+ /// Parses the streamRedirections array (both mixes and the mic passthrough).
+ internal static StreamRedirections ParseStreamRedirections(JsonElement root)
+ {
+ MixRedirection? personal = null, stream = null;
+ MicRedirection? mic = null;
+
+ if (root.ValueKind != JsonValueKind.Array)
+ return new StreamRedirections(null, null, null);
+
+ foreach (var entry in root.EnumerateArray())
+ {
+ if (entry.ValueKind != JsonValueKind.Object) continue;
+
+ string? id = entry.GetStringOrNull("streamRedirectionId");
+ string deviceId = entry.GetStringOrNull("deviceId") ?? "";
+ bool isRunning = entry.GetBoolOrFalse("isRunning");
+
+ if (string.Equals(id, "mic", StringComparison.OrdinalIgnoreCase))
+ {
+ mic = new MicRedirection(deviceId, isRunning);
+ continue;
+ }
+
+ Mix? mix = id is null ? null : MixExtensions.FromJsonKey(id);
+ if (mix is null) continue;
+
+ var enabled = new Dictionary();
+ if (entry.TryGetProperty("status", out var status) && status.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var role in status.EnumerateArray())
+ {
+ Channel? channel = role.GetStringOrNull("role") is { } r
+ ? ChannelExtensions.FromJsonKey(r)
+ : null;
+ if (channel is null) continue;
+
+ enabled[channel.Value] = role.GetBoolOrFalse("isEnabled");
+ }
+ }
+
+ var redirection = new MixRedirection(mix.Value, deviceId, isRunning, enabled);
+ if (mix == Mix.Personal) personal = redirection;
+ else stream = redirection;
+ }
+
+ return new StreamRedirections(personal, stream, mic);
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/RoutedProcessManager.cs b/SteelSeriesAPI/Sonar/Managers/RoutedProcessManager.cs
deleted file mode 100644
index 1ca7017..0000000
--- a/SteelSeriesAPI/Sonar/Managers/RoutedProcessManager.cs
+++ /dev/null
@@ -1,225 +0,0 @@
-using SteelSeriesAPI.Sonar.Exceptions;
-using SteelSeriesAPI.Sonar.Enums;
-using SteelSeriesAPI.Sonar.Http;
-using SteelSeriesAPI.Sonar.Interfaces.Managers;
-using SteelSeriesAPI.Sonar.Models;
-
-using System.Text.Json;
-
-namespace SteelSeriesAPI.Sonar.Managers;
-
-internal class RoutedProcessManager : IRoutedProcessManager
-{
- public IEnumerable GetAllRoutedProcesses()
- {
- JsonElement audioDeviceRouting = new Fetcher().Provide("AudioDeviceRouting").RootElement;
-
- foreach (JsonElement device in audioDeviceRouting.EnumerateArray())
- {
- string role = device.GetProperty("role").GetString()!;
- if (role != "none")
- {
- foreach (JsonElement session in device.GetProperty("audioSessions").EnumerateArray())
- {
- int processId = session.GetProperty("processId").GetInt32();
- string processName = session.GetProperty("processName").GetString()!;
- string displayName = session.GetProperty("displayName").GetString()!;
-
- if (processId == 0 && processName == "Idle" && displayName == "Idle") continue;
-
- RoutedProcessState state = (RoutedProcessState)RoutedProcessStateExtensions.FromDictKey(device.GetProperty("state").GetString()!)!;
- Channel channel = (Channel)ChannelExtensions.FromDictKey(role)!;
- string processPath = session.GetProperty("id").GetString()!.Split("|")[1].Replace('\\', '/');
-
- yield return new RoutedProcess(processId, processName, displayName, state, channel, processPath);
- }
- }
- }
- }
-
- public IEnumerable GetAllActiveRoutedProcesses()
- {
- JsonElement audioDeviceRouting = new Fetcher().Provide("AudioDeviceRouting").RootElement;
-
- foreach (JsonElement device in audioDeviceRouting.EnumerateArray())
- {
- string role = device.GetProperty("role").GetString()!;
- if (role != "none")
- {
- foreach (JsonElement session in device.GetProperty("audioSessions").EnumerateArray())
- {
- if (session.GetProperty("state").GetString() == "active")
- {
- int processId = session.GetProperty("processId").GetInt32();
- string processName = session.GetProperty("processName").GetString()!;
- string displayName = session.GetProperty("displayName").GetString()!;
-
- if (processId == 0 && processName == "Idle" && displayName == "Idle") continue;
-
- RoutedProcessState state = RoutedProcessState.ACTIVE;
- Channel channel = (Channel)ChannelExtensions.FromDictKey(role)!;
- string processPath = session.GetProperty("id").GetString()!.Split("|")[1].Replace('\\', '/');
-
- yield return new RoutedProcess(processId, processName, displayName, state, channel, processPath);
- }
- }
- }
- }
- }
-
- public IEnumerable GetRoutedProcesses(Channel channel)
- {
- if (channel == Channel.MASTER)
- {
- throw new MasterChannelNotSupportedException();
- }
-
- JsonElement audioDeviceRouting = new Fetcher().Provide("AudioDeviceRouting").RootElement;
-
- foreach (JsonElement device in audioDeviceRouting.EnumerateArray())
- {
- if (device.GetProperty("role").GetString() == channel.ToDictKey())
- {
- foreach (JsonElement session in device.GetProperty("audioSessions").EnumerateArray())
- {
- int processId = session.GetProperty("processId").GetInt32();
- string processName = session.GetProperty("processName").GetString()!;
- string displayName = session.GetProperty("displayName").GetString()!;
-
- if (processId == 0 && processName == "Idle" && displayName == "Idle") continue;
-
- RoutedProcessState state = (RoutedProcessState)RoutedProcessStateExtensions.FromDictKey(device.GetProperty("state").GetString()!)!;
- string processPath = session.GetProperty("id").GetString()!.Split("|")[1].Replace('\\', '/');
-
- yield return new RoutedProcess(processId, processName, displayName, state, channel, processPath);
- }
- }
- }
- }
-
- public IEnumerable GetActiveRoutedProcesses(Channel channel)
- {
- if (channel == Channel.MASTER)
- {
- throw new MasterChannelNotSupportedException();
- }
-
- JsonElement audioDeviceRouting = new Fetcher().Provide("AudioDeviceRouting").RootElement;
-
- foreach (JsonElement device in audioDeviceRouting.EnumerateArray())
- {
- if (device.GetProperty("role").GetString() == channel.ToDictKey())
- {
- foreach (JsonElement session in device.GetProperty("audioSessions").EnumerateArray())
- {
- if (session.GetProperty("state").GetString() == "active")
- {
- int processId = session.GetProperty("processId").GetInt32();
- string processName = session.GetProperty("processName").GetString()!;
- string displayName = session.GetProperty("displayName").GetString()!;
-
- if (processId == 0 && processName == "Idle" && displayName == "Idle") continue;
-
- RoutedProcessState state = RoutedProcessState.ACTIVE;
- string processPath = session.GetProperty("id").GetString()!.Split("|")[1].Replace('\\', '/');
-
- yield return new RoutedProcess(processId, processName, displayName, state, channel, processPath);
- }
- }
- }
- }
- }
-
- public IEnumerable GetRoutedProcessesById(int processId)
- {
- JsonElement audioDeviceRouting = new Fetcher().Provide("AudioDeviceRouting").RootElement;
-
- foreach (JsonElement device in audioDeviceRouting.EnumerateArray())
- {
- string role = device.GetProperty("role").GetString()!;
- if (role != "none")
- {
- foreach (JsonElement session in device.GetProperty("audioSessions").EnumerateArray())
- {
- if (session.GetProperty("processId").GetInt32() == processId)
- {
- string processName = session.GetProperty("processName").GetString()!;
- string displayName = session.GetProperty("displayName").GetString()!;
- RoutedProcessState state = (RoutedProcessState)RoutedProcessStateExtensions.FromDictKey(device.GetProperty("state").GetString()!)!;
- Channel channel = (Channel)ChannelExtensions.FromDictKey(role)!;
- string processPath = session.GetProperty("id").GetString()!.Split("|")[1].Replace('\\', '/');
-
- yield return new RoutedProcess(processId, processName, displayName, state, channel, processPath);
- }
- }
- }
- }
- }
-
- public IEnumerable GetActiveRoutedProcessesById(int processId)
- {
- JsonElement audioDeviceRouting = new Fetcher().Provide("AudioDeviceRouting").RootElement;
-
- foreach (JsonElement device in audioDeviceRouting.EnumerateArray())
- {
- string role = device.GetProperty("role").GetString()!;
- if (role != "none")
- {
- foreach (JsonElement session in device.GetProperty("audioSessions").EnumerateArray())
- {
- if (session.GetProperty("state").GetString() == "active" && session.GetProperty("processId").GetInt32() == processId)
- {
- string processName = session.GetProperty("processName").GetString()!;
- string displayName = session.GetProperty("displayName").GetString()!;
- RoutedProcessState state = RoutedProcessState.ACTIVE;
- Channel channel = (Channel)ChannelExtensions.FromDictKey(role)!;
- string processPath = session.GetProperty("id").GetString()!.Split("|")[1].Replace('\\', '/');
-
- yield return new RoutedProcess(processId, processName, displayName, state, channel, processPath);
- }
- }
- }
- }
-
- throw new RoutedProcessNotFoundException("No active processes with id " + processId + " found");
- }
-
- public void RouteProcessToChannel(int processId, Channel channel)
- {
- if (channel == Channel.MASTER)
- {
- throw new MasterChannelNotSupportedException();
- }
-
- JsonElement audioDeviceRouting = new Fetcher().Provide("AudioDeviceRouting").RootElement;
-
- foreach (JsonElement device in audioDeviceRouting.EnumerateArray())
- {
- if (device.GetProperty("role").GetString() == channel.ToDictKey())
- {
- if (channel == Channel.MIC)
- {
- new Fetcher().Put("AudioDeviceRouting/capture/" + device.GetProperty("deviceId").GetString() + "/" + processId);
- break;
- }
-
- new Fetcher().Put("AudioDeviceRouting/render/" + device.GetProperty("deviceId").GetString() + "/" + processId);
- break;
- }
- }
- }
-
- public void RouteProcessToChannel(RoutedProcess process, Channel channel)
- {
- RouteProcessToChannel(process.ProcessId, channel);
-
- if (process.Channel == channel)
- {
- process.State = RoutedProcessState.ACTIVE;
- }
- else
- {
- process.State = RoutedProcessState.INACTIVE;
- }
- }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs b/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs
index 0b4d394..5448069 100644
--- a/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs
+++ b/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs
@@ -1,70 +1,80 @@
-using SteelSeriesAPI.Sonar.Interfaces.Managers;
+using System.Text.Json;
+using SteelSeriesAPI.Core;
using SteelSeriesAPI.Sonar.Enums;
-using SteelSeriesAPI.Sonar.Http;
-
-using System.Globalization;
-using System.Text.Json;
+using SteelSeriesAPI.Sonar.Models;
namespace SteelSeriesAPI.Sonar.Managers;
-internal class VolumeSettingsManager : IVolumeSettingsManager
+///
+internal sealed class VolumeSettingsManager : IVolumeSettingsManager
{
- // volume = 0,00000000 <-- 8 decimal max
- public double GetVolume(Channel channel)
- {
- JsonDocument volumeSettings = new Fetcher().Provide("volumeSettings/classic/");
+ private readonly ISonarTransport _transport;
- if (channel == Channel.MASTER)
- return volumeSettings.RootElement.GetProperty("masters").GetProperty("classic").GetProperty("volume").GetDouble();
- return volumeSettings.RootElement.GetProperty("devices").GetProperty(channel.ToDictKey()).GetProperty("classic").GetProperty("volume").GetDouble();
- }
+ internal VolumeSettingsManager(ISonarTransport transport) => _transport = transport;
- public double GetVolume(Channel channel, Mix mix)
+ ///
+ public async Task GetAsync(Channel channel, CancellationToken ct = default)
{
- JsonDocument volumeSettings = new Fetcher().Provide("volumeSettings/streamer/");
+ using var doc = await _transport.GetAsync(SonarRoutes.ClassicVolumes, ct);
- if (channel == Channel.MASTER)
- return volumeSettings.RootElement.GetProperty("masters").GetProperty("stream").GetProperty(mix.ToDictKey()).GetProperty("volume").GetDouble();
- return volumeSettings.RootElement.GetProperty("devices").GetProperty(channel.ToDictKey()).GetProperty("stream").GetProperty(mix.ToDictKey()).GetProperty("volume").GetDouble();
- }
-
- public bool GetMute(Channel channel)
- {
- JsonDocument volumeSettings = new Fetcher().Provide("volumeSettings/classic/");
+ // Master lives under "masters", other channels under "devices/{key}".
+ JsonElement node = channel == Channel.Master
+ ? doc.RootElement.Dig("masters", "classic")
+ : doc.RootElement.Dig("devices", channel.ToJsonKey(), "classic");
- if (channel == Channel.MASTER)
- return volumeSettings.RootElement.GetProperty("masters").GetProperty("classic").GetProperty("muted").GetBoolean();
- return volumeSettings.RootElement.GetProperty("devices").GetProperty(channel.ToDictKey()).GetProperty("classic").GetProperty("muted").GetBoolean();
+ return ParseSetting(node);
}
- public bool GetMute(Channel channel, Mix mix)
+ ///
+ public async Task GetAsync(Channel channel, Mix mix, CancellationToken ct = default)
{
- JsonDocument volumeSettings = new Fetcher().Provide("volumeSettings/streamer/");
+ using var doc = await _transport.GetAsync(SonarRoutes.StreamerVolumes, ct);
- if (channel == Channel.MASTER)
- return volumeSettings.RootElement.GetProperty("masters").GetProperty("stream").GetProperty(mix.ToDictKey()).GetProperty("muted").GetBoolean();
- return volumeSettings.RootElement.GetProperty("devices").GetProperty(channel.ToDictKey()).GetProperty("stream").GetProperty(mix.ToDictKey()).GetProperty("muted").GetBoolean();
+ JsonElement node = channel == Channel.Master
+ ? doc.RootElement.Dig("masters", "stream", mix.ToJsonKey())
+ : doc.RootElement.Dig("devices", channel.ToJsonKey(), "stream", mix.ToJsonKey());
+
+ return ParseSetting(node);
}
- public void SetVolume(double volume, Channel channel)
+ ///
+ public Task SetVolumeAsync(Channel channel, double volume, CancellationToken ct = default)
{
- string vol = volume.ToString("0.00", CultureInfo.InvariantCulture);
- new Fetcher().Put("volumeSettings/classic/" + channel.ToDictKey(ChannelMapChoice.HttpDict) + "/Volume/" + vol);
+ ValidateVolume(volume);
+ return _transport.PutAsync(SonarRoutes.SetClassicVolume(channel, volume), ct);
}
- public void SetVolume(double volume, Channel channel, Mix mix)
+ ///
+ public Task SetVolumeAsync(Channel channel, Mix mix, double volume, CancellationToken ct = default)
{
- string vol = volume.ToString("0.00", CultureInfo.InvariantCulture);
- new Fetcher().Put("volumeSettings/streamer/" + mix.ToDictKey() + "/" + channel.ToDictKey(ChannelMapChoice.HttpDict) + "/volume/" + vol);
+ ValidateVolume(volume);
+ return _transport.PutAsync(SonarRoutes.SetStreamerVolume(mix, channel, volume), ct);
}
- public void SetMute(bool mute, Channel channel)
+ ///
+ public Task SetMuteAsync(Channel channel, bool muted, CancellationToken ct = default) =>
+ _transport.PutAsync(SonarRoutes.SetClassicMute(channel, muted), ct);
+
+ ///
+ public Task SetMuteAsync(Channel channel, Mix mix, bool muted, CancellationToken ct = default) =>
+ _transport.PutAsync(SonarRoutes.SetStreamerMute(mix, channel, muted), ct);
+
+ private static VolumeSetting ParseSetting(JsonElement node)
{
- new Fetcher().Put("volumeSettings/classic/" + channel.ToDictKey(ChannelMapChoice.HttpDict) + "/Mute/" + mute);
+ double volume = node.TryGetProperty("volume", out var v) &&
+ v.ValueKind == JsonValueKind.Number
+ ? v.GetDouble() : 0.0;
+
+ bool muted = node.TryGetProperty("muted", out var m) &&
+ m.ValueKind == JsonValueKind.True;
+
+ return new VolumeSetting(volume, muted);
}
- public void SetMute(bool mute, Channel channel, Mix mix)
+ private static void ValidateVolume(double volume)
{
- new Fetcher().Put("volumeSettings/streamer/" + mix.ToDictKey() + "/" + channel.ToDictKey(ChannelMapChoice.HttpDict) + "/isMuted/" + mute);
+ if (double.IsNaN(volume) || volume is < 0.0 or > 1.0)
+ throw new ArgumentOutOfRangeException(nameof(volume), volume,
+ "Volume must be between 0.0 and 1.0.");
}
}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Models/AppRouting.cs b/SteelSeriesAPI/Sonar/Models/AppRouting.cs
new file mode 100644
index 0000000..4878ab9
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Models/AppRouting.cs
@@ -0,0 +1,33 @@
+using SteelSeriesAPI.Sonar.Enums;
+
+namespace SteelSeriesAPI.Sonar.Models;
+
+/// An application audio session known to Sonar.
+/// The process name (e.g. "brave").
+/// The Windows process id. WARNING: changes on every launch - never persist it.
+/// The human-readable name (e.g. "Brave").
+/// True for the Windows system-sounds session.
+/// The raw session state as reported by Sonar ("active", "inactive"...).
+public sealed record AudioSessionInfo(
+ string ProcessName,
+ int ProcessId,
+ string DisplayName,
+ bool IsSystemSound,
+ string? State)
+{
+ /// True when the session is currently playing/capturing audio.
+ public bool IsActive => string.Equals(State, "active", StringComparison.OrdinalIgnoreCase);
+}
+
+/// The audio sessions currently attached to one device.
+/// The Windows device identifier.
+/// The raw Sonar role of the device ("game", "stream", "none"...).
+/// The Sonar channel this device carries, or null (physical devices, stream mixes).
+/// Whether this is an output or input device.
+/// The sessions attached to this device, ghosts of past routings included.
+public sealed record DeviceRouting(
+ string DeviceId,
+ string Role,
+ Channel? Channel,
+ AudioDataFlow DataFlow,
+ IReadOnlyList Sessions);
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Models/AudioDevice.cs b/SteelSeriesAPI/Sonar/Models/AudioDevice.cs
new file mode 100644
index 0000000..713f994
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Models/AudioDevice.cs
@@ -0,0 +1,19 @@
+using SteelSeriesAPI.Sonar.Enums;
+
+namespace SteelSeriesAPI.Sonar.Models;
+
+/// An audio device known to Sonar.
+///
+/// The Windows device identifier, used by redirection routes.
+/// WARNING: Sonar virtual device ids are regenerated by GG updates - never persist them.
+///
+/// The human-readable device name (e.g. "Haut-parleurs (High Definition Audio Device)").
+/// Whether this is an output (render) or input (capture) device.
+/// True for the virtual devices created by Sonar itself (Gaming, Chat, Media...).
+/// For Sonar virtual devices, the channel they carry; null for physical devices.
+public sealed record AudioDevice(
+ string Id,
+ string Name,
+ AudioDataFlow DataFlow,
+ bool IsSonarVirtual,
+ Channel? SonarChannel);
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Models/ChatMixSetting.cs b/SteelSeriesAPI/Sonar/Models/ChatMixSetting.cs
new file mode 100644
index 0000000..f2032b8
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Models/ChatMixSetting.cs
@@ -0,0 +1,11 @@
+namespace SteelSeriesAPI.Sonar.Models;
+
+/// The chat mix state of the Sonar mixer.
+///
+/// The game/chat balance, from -1.0 (game only) to +1.0 (chat only). 0.0 is neutral.
+///
+///
+/// The availability state as reported by Sonar (for example whether a compatible
+/// device is selected). Raw API value, not yet mapped to an enum.
+///
+public record ChatMixSetting(double Balance, string? State);
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Models/PlaybackDevice.cs b/SteelSeriesAPI/Sonar/Models/PlaybackDevice.cs
deleted file mode 100644
index 9104b24..0000000
--- a/SteelSeriesAPI/Sonar/Models/PlaybackDevice.cs
+++ /dev/null
@@ -1,6 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-using System.Collections;
-
-namespace SteelSeriesAPI.Sonar.Models;
-
-public record PlaybackDevice(string Id, string Name, DataFlow DataFlow, List> Channels, List Mixes);
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Models/Redirections.cs b/SteelSeriesAPI/Sonar/Models/Redirections.cs
new file mode 100644
index 0000000..f832c69
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Models/Redirections.cs
@@ -0,0 +1,31 @@
+using SteelSeriesAPI.Sonar.Enums;
+
+namespace SteelSeriesAPI.Sonar.Models;
+
+/// The output (or input, for Mic) device assigned to a channel in classic mode.
+/// The channel this redirection belongs to.
+/// The Windows device identifier the channel is routed to.
+/// Whether the redirection is currently active.
+public sealed record ClassicRedirection(Channel Channel, string DeviceId, bool IsRunning);
+
+/// The state of one streamer-mode mix: its output device and which channels feed it.
+/// The mix this redirection belongs to.
+/// The Windows device identifier the mix is routed to.
+/// Whether the mix redirection is currently active.
+/// Which channels are enabled (toggled on) for this mix.
+public sealed record MixRedirection(
+ Mix Mix,
+ string DeviceId,
+ bool IsRunning,
+ IReadOnlyDictionary EnabledChannels);
+
+/// The microphone passthrough state in streamer mode.
+/// The Windows device identifier of the physical microphone.
+/// Whether the passthrough is currently active.
+public sealed record MicRedirection(string DeviceId, bool IsRunning);
+
+/// The complete streamer-mode redirection state.
+/// The personal (monitoring) mix, or null if absent from the response.
+/// The stream (streaming) mix, or null if absent from the response.
+/// The microphone passthrough, or null if absent from the response.
+public sealed record StreamRedirections(MixRedirection? Personal, MixRedirection? Stream, MicRedirection? Mic);
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Models/RoutedProcess.cs b/SteelSeriesAPI/Sonar/Models/RoutedProcess.cs
deleted file mode 100644
index beae57f..0000000
--- a/SteelSeriesAPI/Sonar/Models/RoutedProcess.cs
+++ /dev/null
@@ -1,23 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-
-namespace SteelSeriesAPI.Sonar.Models;
-
-public record RoutedProcess
-{
- public int ProcessId { get; init; }
- public string ProcessName { get; init; }
- public string DisplayName { get; init; }
- public RoutedProcessState State { get; internal set; }
- public Channel Channel { get; init; }
- public string ProcessPath { get; init; }
-
- public RoutedProcess(int processId, string processName, string displayName, RoutedProcessState state, Channel channel, string processPath)
- {
- ProcessId = processId;
- ProcessName = processName;
- DisplayName = displayName;
- State = state;
- Channel = channel;
- ProcessPath = processPath;
- }
-};
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Models/SonarAudioConfiguration.cs b/SteelSeriesAPI/Sonar/Models/SonarAudioConfiguration.cs
deleted file mode 100644
index c76b2fe..0000000
--- a/SteelSeriesAPI/Sonar/Models/SonarAudioConfiguration.cs
+++ /dev/null
@@ -1,5 +0,0 @@
-using SteelSeriesAPI.Sonar.Enums;
-
-namespace SteelSeriesAPI.Sonar.Models;
-
-public record SonarAudioConfiguration(string Id, string Name, Channel AssociatedChannel);
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Models/SonarConfig.cs b/SteelSeriesAPI/Sonar/Models/SonarConfig.cs
new file mode 100644
index 0000000..dd32cb5
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Models/SonarConfig.cs
@@ -0,0 +1,14 @@
+using SteelSeriesAPI.Sonar.Enums;
+
+namespace SteelSeriesAPI.Sonar.Models;
+
+///
+/// An audio configuration (preset) header, without its EQ/effects payload.
+/// Sonar keeps one selected config per channel.
+///
+/// The unique identifier of the config.
+/// The display name (e.g. "Custom", "FPS Footsteps").
+/// The channel this config applies to. Mic configs use the chatCapture device.
+/// True for built-in SteelSeries presets, false for user-created configs.
+/// Whether the user marked this config as favorite.
+public sealed record SonarConfig(string Id, string Name, Channel Channel, bool IsPreset, bool IsFavorite);
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/Models/VolumeSettings.cs b/SteelSeriesAPI/Sonar/Models/VolumeSettings.cs
new file mode 100644
index 0000000..af76139
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/Models/VolumeSettings.cs
@@ -0,0 +1,6 @@
+namespace SteelSeriesAPI.Sonar.Models;
+
+/// The volume state of a single Sonar channel.
+/// The volume level, from 0.0 (silent) to 1.0 (full).
+/// Whether the channel is currently muted.
+public record VolumeSetting(double Volume, bool Muted);
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/SonarBridge.cs b/SteelSeriesAPI/Sonar/SonarBridge.cs
deleted file mode 100644
index c92c9b5..0000000
--- a/SteelSeriesAPI/Sonar/SonarBridge.cs
+++ /dev/null
@@ -1,155 +0,0 @@
-using SteelSeriesAPI.Sonar.Interfaces;
-using SteelSeriesAPI.Sonar.Interfaces.Managers;
-using SteelSeriesAPI.Sonar.Managers;
-using SteelSeriesAPI.Sonar.Enums;
-
-using System.Security.Principal;
-using System.Runtime.Versioning;
-
-namespace SteelSeriesAPI.Sonar;
-
-///
-/// The Sonar object, to control Sonar
Allow you to listen for event, get or set volumes, muted states, ...
-///
-public class SonarBridge : ISonarBridge
-{
- ///
- /// The running state of Sonar
- ///
- public bool IsRunning => SonarRetriever.Instance is { IsEnabled: true, IsReady: true, IsRunning: true };
-
- private readonly ISonarSocket _sonarSocket;
-
- ///
- /// Manage the Sonar
- ///
- public readonly IModeManager Mode;
-
- ///
- /// Manage the volumes and muted state of each
- ///
- public readonly IVolumeSettingsManager VolumeSettings;
-
- ///
- /// Manage the balance of ChatMix
- ///
- public readonly IChatMixManager ChatMix;
-
- ///
- /// Manage audio configurations for each
- ///
- public readonly IConfigurationManager Configurations;
-
- ///
- /// Manage the playback device of each
- ///
- public readonly IPlaybackDeviceManager PlaybackDevices;
-
- ///
- /// Manage routed audio processes
- ///
- public readonly IRoutedProcessManager RoutedProcesses;
-
- ///
- /// Manage the personal and stream mix for each channel
- ///
- public readonly IMixManager Mix;
-
- ///
- /// Manage the Audience Monitoring feature of the streamer mode
- ///
- public readonly IAudienceMonitoringManager AudienceMonitoring;
-
- ///
- /// Manage the different Sonar Events
- ///
- public readonly EventManager Events;
-
- ///
- /// The Sonar object, to control Sonar
Allow you to listen for event, get or set volumes, muted states, ...
- ///
- public SonarBridge()
- {
- Mode = new ModeManager();
- VolumeSettings = new VolumeSettingsManager();
- ChatMix = new ChatMixManager();
- Configurations = new ConfigurationManager();
- PlaybackDevices = new PlaybackDeviceManager();
- RoutedProcesses = new RoutedProcessManager();
- Mix = new MixManager();
- AudienceMonitoring = new AudienceMonitoringManager();
- Events = new EventManager();
-
- _sonarSocket = new SonarSocket(Events);
- }
-
- #region Listener
-
- ///
- /// Start listening to events happening on Sonar, such as changing volume...
- ///
- /// The state of the listener (false if it didn't start)
- [SupportedOSPlatform("windows")]
- public bool StartListener()
- {
- if (!IsRunAsAdmin())
- {
- throw new ApplicationException("Listener requires Administrator rights to be used");
- }
-
- if (_sonarSocket.IsConnected)
- {
- throw new Exception("Listener already started");
- }
-
- var connected = _sonarSocket.Connect();
- if (!connected)
- {
- return false;
- }
-
- var listening = _sonarSocket.Listen();
- if (!listening)
- {
- return false;
- }
-
- return true;
- }
-
- ///
- /// Stop the listener
- ///
- [SupportedOSPlatform("windows")]
- public void StopListener()
- {
- _sonarSocket.CloseSocket();
- }
-
- [SupportedOSPlatform("windows")]
- private bool IsRunAsAdmin()
- {
- WindowsIdentity id = WindowsIdentity.GetCurrent();
- WindowsPrincipal principal = new WindowsPrincipal(id);
-
- return principal.IsInRole(WindowsBuiltInRole.Administrator);
- }
-
- #endregion
-
- ///
- /// Wait until SteelSeries GG is started and running before running your code below
- ///
- public void WaitUntilSteelSeriesStarted()
- {
- SteelSeriesRetriever.Instance.WaitUntilSteelSeriesStarted();
- }
-
- ///
- /// Wait until Sonar is started and running before running your code below
- ///
- public void WaitUntilSonarStarted()
- {
- SonarRetriever.Instance.WaitUntilAppStarted();
- }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/SonarClient.cs b/SteelSeriesAPI/Sonar/SonarClient.cs
new file mode 100644
index 0000000..74a7bed
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/SonarClient.cs
@@ -0,0 +1,88 @@
+using System.Text.Json;
+using Microsoft.Extensions.Logging;
+using SteelSeriesAPI.Core;
+using SteelSeriesAPI.Sonar.Events;
+using SteelSeriesAPI.Sonar.Managers;
+
+namespace SteelSeriesAPI.Sonar;
+
+///
+/// Entry point for controlling SteelSeries Sonar.
+/// Create one instance and reuse it for the lifetime of your application.
+///
+public sealed class SonarClient : IDisposable
+{
+ private readonly SonarHttpClient _httpClient;
+
+ /// Real-time event stream from Sonar. Call to begin listening.
+ public SonarEventListener Events { get; }
+
+ /// Reads and switches the Sonar mixer mode.
+ public IModeManager Mode { get; }
+
+ /// Controls the volume and mute state of Sonar channels.
+ public IVolumeSettingsManager VolumeSettings { get; }
+
+ /// Reads and controls the chat mix (game/chat balance).
+ public IChatMixManager ChatMix { get; }
+
+ /// Controls audio redirections: device routing, mix toggles, and stream monitoring.
+ public IRedirectionsManager Redirections { get; }
+
+ /// Lists the audio devices known to Sonar, physical and virtual.
+ public IAudioDeviceManager Devices { get; }
+
+ /// Lists and selects Sonar audio configs (presets).
+ public IConfigManager Configs { get; }
+
+ /// Reads and controls which Sonar channel each application's audio is routed to.
+ public IAppRoutingManager AppRouting { get; }
+
+ /// Creates a new Sonar client.
+ /// Optional logger for diagnostics. When null, the library stays silent.
+ public SonarClient(ILogger? logger = null)
+ {
+ var discovery = new ServerDiscovery(logger);
+ _httpClient = new SonarHttpClient(discovery, logger);
+
+ Events = new SonarEventListener(_httpClient, logger);
+
+ Mode = new ModeManager(_httpClient);
+ VolumeSettings = new VolumeSettingsManager(_httpClient);
+ ChatMix = new ChatMixManager(_httpClient);
+ Redirections = new RedirectionsManager(_httpClient);
+ Devices = new AudioDeviceManager(_httpClient);
+ Configs = new ConfigManager(_httpClient);
+ AppRouting = new AppRoutingManager(_httpClient);
+ }
+
+ ///
+ /// Sends a GET request to an arbitrary Sonar route and returns the raw JSON response.
+ /// Intended for exploration and debugging; prefer the typed managers for normal use.
+ ///
+ /// The route, relative to the Sonar server base address.
+ /// A token to cancel the operation.
+ public Task GetRawAsync(string route, CancellationToken ct = default) =>
+ _httpClient.GetAsync(route, ct);
+
+ ///
+ /// Sends a PUT request to an arbitrary Sonar route.
+ /// Intended for exploration and debugging; prefer the typed managers for normal use.
+ ///
+ /// The route, relative to the Sonar server base address.
+ /// A token to cancel the operation.
+ public Task PutRawAsync(string route, CancellationToken ct = default) =>
+ _httpClient.PutAsync(route, ct);
+
+ /// Resolves and returns the current Sonar web server address.
+ /// A token to cancel the operation.
+ public Task GetServerAddressAsync(CancellationToken ct = default) =>
+ _httpClient.GetServerAddressAsync(ct);
+
+ ///
+ public void Dispose()
+ {
+ Events.Dispose();
+ _httpClient.Dispose();
+ }
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/SonarRetriever.cs b/SteelSeriesAPI/Sonar/SonarRetriever.cs
deleted file mode 100644
index 14dd3a5..0000000
--- a/SteelSeriesAPI/Sonar/SonarRetriever.cs
+++ /dev/null
@@ -1,106 +0,0 @@
-using System.Diagnostics;
-using System.Text.Json;
-using SteelSeriesAPI.Exceptions;
-using SteelSeriesAPI.Interfaces;
-using SteelSeriesAPI.Sonar.Exceptions;
-
-namespace SteelSeriesAPI.Sonar;
-
-public class SonarRetriever : IAppRetriever
-{
- private static readonly Lazy _instance = new(() => new SonarRetriever());
- public static SonarRetriever Instance => _instance.Value;
-
- public string Name => "sonar";
-
- public bool IsEnabled => GetMetaDatas()[0];
- public bool IsReady => GetMetaDatas()[1];
- public bool IsRunning => GetMetaDatas()[2];
- public bool ShouldAutoStart => GetMetaDatas()[3];
- public bool IsWindowsSupported => GetMetaDatas()[4];
- public bool IsMacSupported => GetMetaDatas()[5];
- public bool ToggleViaSettings => GetMetaDatas()[6];
- public bool IsBrowserViewSupported => GetMetaDatas()[7];
-
- private readonly HttpClient _httpClient;
-
- ///
- /// Get information about Sonar such as meta datas
- ///
- public SonarRetriever()
- {
- HttpClientHandler clientHandler = new HttpClientHandler();
- clientHandler.ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => { return true; };
- _httpClient = new(clientHandler);
- }
-
- public bool[] GetMetaDatas()
- {
- if (!SteelSeriesRetriever.Instance.Running)
- {
- throw new SteelSeriesNotRunningException();
- }
-
- try
- {
- JsonDocument subApps = JsonDocument.Parse(_httpClient.GetStringAsync("https://" + SteelSeriesRetriever.Instance.GetggEncryptedAddress() + "/subApps").Result);
- JsonElement appElement = subApps.RootElement.GetProperty("subApps").GetProperty(Name);
-
- bool isEnabled = appElement.GetProperty("isEnabled").GetBoolean();
- bool isReady = appElement.GetProperty("isReady").GetBoolean();
- bool isRunning = appElement.GetProperty("isRunning").GetBoolean();
- bool shouldAutoStart = appElement.GetProperty("shouldAutoStart").GetBoolean();
- bool isWindowsSupported = appElement.GetProperty("isWindowsSupported").GetBoolean();
- bool isMacSupported = appElement.GetProperty("isMacSupported").GetBoolean();
- bool toggleViaSettings = appElement.GetProperty("toggleViaSettings").GetBoolean();
- bool isBrowserViewSupported = appElement.GetProperty("isBrowserViewSupported").GetBoolean();
-
- return new bool[8] { isEnabled, isReady, isRunning, shouldAutoStart, isWindowsSupported,
- isMacSupported, toggleViaSettings, isBrowserViewSupported };
- }
- catch (Exception e)
- {
- Console.WriteLine(e);
- throw;
- }
- }
-
- ///
- /// Get the ip address of the Soanr rest server
- ///
- /// The ip address op the Sonar rest server
- ///
- public string WebServerAddress()
- {
- if (!IsEnabled || !IsReady || !IsRunning)
- {
- throw new SonarNotRunningException();
- }
-
- JsonDocument subApps = JsonDocument.Parse(_httpClient.GetStringAsync("https://" + SteelSeriesRetriever.Instance.GetggEncryptedAddress() + "/subApps").Result);
- JsonElement appElement = subApps.RootElement.GetProperty("subApps").GetProperty(Name);
-
- return appElement.GetProperty("metadata").GetProperty("webServerAddress") + "/";
- }
-
- ///
- /// Wait until Sonar is started and running before running your code below
- ///
- public void WaitUntilAppStarted()
- {
- if (!SteelSeriesRetriever.Instance.Running)
- {
- SteelSeriesRetriever.Instance.WaitUntilSteelSeriesStarted();
- }
-
- if (!IsEnabled || !IsReady || !IsRunning)
- {
- Console.WriteLine("Waiting for Sonar to start");
- while (!IsEnabled || !IsReady || !IsRunning)
- {
- Thread.Sleep(500);
- }
- Console.WriteLine("Sonar started, continuing");
- }
- }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/SonarRoutes.cs b/SteelSeriesAPI/Sonar/SonarRoutes.cs
new file mode 100644
index 0000000..4f7ed21
--- /dev/null
+++ b/SteelSeriesAPI/Sonar/SonarRoutes.cs
@@ -0,0 +1,96 @@
+using System.Globalization;
+using SteelSeriesAPI.Sonar.Enums;
+
+namespace SteelSeriesAPI.Sonar;
+
+///
+/// Central registry of every Sonar HTTP route used by this library.
+/// If a SteelSeries GG update changes a route, this is the only file to update.
+///
+internal static class SonarRoutes
+{
+ /// Current mixer mode, returned as a bare JSON string ("classic" or "stream").
+ internal const string GetMode = "mode";
+
+ /// Volume/mute state of all channels in classic mode.
+ internal const string ClassicVolumes = "volumeSettings/classic/";
+
+ ///
+ /// Volume/mute state in streamer mode. WARNING: only the "stream" sections are live;
+ /// the "classic" sections return stale data (and vice versa for the classic route).
+ /// Poll the route matching the current mode. Observed 2026-08-08.
+ ///
+ internal const string StreamerVolumes = "volumeSettings/streamer/";
+
+ /// Current chat mix state (balance and availability).
+ /// Moved to the /v1/ prefix by a 2026 GG update; the unprefixed route now returns 404.
+ internal const string GetChatMix = "v1/chatMix";
+
+ /// Classic-mode redirections: which device each channel is routed to.
+ /// Uses the short channel ids ("chat", "mic") - see ToClassicRedirectionKey.
+ internal const string ClassicRedirections = "classicRedirections";
+
+ /// Streamer-mode redirections: devices and per-channel enablement of each mix.
+ internal const string StreamRedirections = "streamRedirections";
+
+ /// Whether stream monitoring ("hear what the audience hears") is enabled. Bare JSON boolean.
+ internal const string StreamMonitoringEnabled = "streamRedirections/isStreamMonitoringEnabled";
+
+ /// All audio devices known to Sonar (physical and Sonar virtual devices).
+ internal const string AudioDevices = "audioDevices";
+
+ /// All audio configs (user + presets). WARNING: very large payload (>1MB with EQ data). Never poll this route.
+ internal const string Configs = "configs";
+
+ /// The selected config of each channel (one entry per virtualAudioDevice).
+ internal const string SelectedConfigs = "configs/selected";
+
+ /// App-to-device routing state, including the audio sessions of each device.
+ internal const string AudioDeviceRouting = "AudioDeviceRouting";
+
+ internal static string SetMode(Mode mode) => $"mode/{mode.ToApiValue()}";
+
+ // Note: the Sonar API is inconsistent by design ("Volume"/"Mute" capitalized
+ // in classic routes, "volume"/"isMuted" lowercase in streamer routes).
+ // Verified against GG on 2026-08-04.
+
+ internal static string SetClassicVolume(Channel channel, double volume) =>
+ $"volumeSettings/classic/{channel.ToRouteKey()}/Volume/{Format(volume)}";
+
+ internal static string SetClassicMute(Channel channel, bool muted) =>
+ $"volumeSettings/classic/{channel.ToRouteKey()}/Mute/{Bool(muted)}";
+
+ internal static string SetStreamerVolume(Mix mix, Channel channel, double volume) =>
+ $"volumeSettings/streamer/{mix.ToRouteKey()}/{channel.ToRouteKey()}/volume/{Format(volume)}";
+
+ internal static string SetStreamerMute(Mix mix, Channel channel, bool muted) =>
+ $"volumeSettings/streamer/{mix.ToRouteKey()}/{channel.ToRouteKey()}/isMuted/{Bool(muted)}";
+
+ internal static string SetChatMix(double balance) =>
+ $"v1/chatMix?balance={Format(balance)}";
+
+ internal static string SetClassicRedirectionDevice(Channel channel, string deviceId) =>
+ $"classicRedirections/{channel.ToClassicRedirectionKey()}/deviceId/{Uri.EscapeDataString(deviceId)}";
+
+ internal static string SetStreamRedirectionDevice(Mix mix, string deviceId) =>
+ $"streamRedirections/{mix.ToRouteKey()}/deviceId/{Uri.EscapeDataString(deviceId)}";
+
+ internal static string SetMixChannelEnabled(Mix mix, Channel channel, bool enabled) =>
+ $"streamRedirections/{mix.ToRouteKey()}/redirections/{channel.ToJsonKey()}/isEnabled/{Bool(enabled)}";
+
+ internal static string SetStreamMonitoringEnabled(bool enabled) =>
+ $"streamRedirections/isStreamMonitoringEnabled/{Bool(enabled)}";
+
+ /// Selects a config by id. Verified live on 2026-08-25.
+ internal static string SelectConfig(string configId) =>
+ $"configs/{Uri.EscapeDataString(configId)}/select";
+
+ /// Routes an application (by process id) to a device. Verified live on 2026-08-25.
+ internal static string SetAppRouting(AudioDataFlow dataFlow, string deviceId, int processId) =>
+ $"AudioDeviceRouting/{(dataFlow == AudioDataFlow.Render ? "render" : "capture")}/{Uri.EscapeDataString(deviceId)}/{processId}";
+
+ private static string Format(double value) =>
+ value.ToString("0.00", CultureInfo.InvariantCulture);
+
+ private static string Bool(bool value) => value ? "true" : "false";
+}
\ No newline at end of file
diff --git a/SteelSeriesAPI/Sonar/SonarSocket.cs b/SteelSeriesAPI/Sonar/SonarSocket.cs
deleted file mode 100644
index 5c8d5f2..0000000
--- a/SteelSeriesAPI/Sonar/SonarSocket.cs
+++ /dev/null
@@ -1,119 +0,0 @@
-using SteelSeriesAPI.Sonar.Exceptions;
-using SteelSeriesAPI.Sonar.Interfaces;
-using SteelSeriesAPI.Sonar.Managers;
-
-using System.Net;
-using System.Net.Sockets;
-using System.Text;
-
-namespace SteelSeriesAPI.Sonar;
-
-public class SonarSocket : ISonarSocket
-{
- public bool IsConnected => _socket?.IsBound ?? false;
-
- private readonly Thread _listenerThread;
- private readonly EventManager _eventManager;
- private Uri _sonarWebServerAddress;
- private Socket _socket;
-
- private bool _isClosing;
-
- public SonarSocket(EventManager eventManager)
- {
- _listenerThread = new Thread(ListenerThreadSync) { IsBackground = false };
- _eventManager = eventManager;
- }
-
- public bool Connect()
- {
- _sonarWebServerAddress = new Uri(SonarRetriever.Instance.WebServerAddress());
- _socket = new Socket(AddressFamily.InterNetwork, SocketType.Raw, ProtocolType.IP);
- try
- {
- var ip = _sonarWebServerAddress.Host;
- var port = _sonarWebServerAddress.Port;
- _socket.Bind(new IPEndPoint(IPAddress.Parse(ip), port));
-
- return _socket.IsBound;
- }
- catch (Exception e)
- {
- Console.WriteLine(e);
- throw;
- }
- }
-
- public bool Listen()
- {
- if (!IsConnected)
- {
- throw new SonarListenerNotConnectedException();
- }
-
- _listenerThread.Start();
-
- return _listenerThread.IsAlive;
- }
-
- public void CloseSocket()
- {
- _isClosing = true;
-
- _socket.Shutdown(SocketShutdown.Both);
- _socket.Close();
- }
-
- private void ListenerThreadSync()
- {
- _socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.HeaderIncluded, true);
- byte[] optionIn = new byte[4] { 1, 0, 0, 0 };
- byte[] optionOut = new byte[4];
- _socket.IOControl(IOControlCode.ReceiveAll, optionIn, optionOut);
-
- byte[] buffer = new byte[4096];
- EndPoint remoteEndPoint = new IPEndPoint(IPAddress.Parse(_sonarWebServerAddress.Host), _sonarWebServerAddress.Port);
-
- try
- {
- while (_socket.IsBound && !_isClosing)
- {
- int bytesRead = 0;
- try
- {
- bytesRead = _socket.ReceiveFrom(buffer, ref remoteEndPoint);
- }
- catch (SocketException e)
- {
- continue;
- }
-
- string data = Encoding.UTF8.GetString(buffer, 0, bytesRead);
- if (data.Contains("PUT "))
- {
- string putData = "";
- List httpData = new List(data.Split("\n"));
- foreach (string line in httpData)
- {
- if (line.Contains("PUT "))
- {
- putData = line;
- break;
- }
- }
- if (!string.IsNullOrEmpty(putData))
- {
- string path = putData.Split("PUT ")[1].Split(" HTTP")[0];
- // Console.WriteLine(path); // For debugging
- _eventManager.HandleEvent(path); // Invoke events
- }
- }
- }
- }
- catch (Exception e)
- {
- Console.WriteLine(e);
- throw;
- }
- }
-}
\ No newline at end of file
diff --git a/SteelSeriesAPI/SteelSeriesAPI.csproj b/SteelSeriesAPI/SteelSeriesAPI.csproj
index 05b5235..a247f46 100644
--- a/SteelSeriesAPI/SteelSeriesAPI.csproj
+++ b/SteelSeriesAPI/SteelSeriesAPI.csproj
@@ -1,40 +1,49 @@
+ net8.0;net10.0
enable
enable
- net8.0;net9.0;net7.0
- 1.1.0
+ SteelSeriesAPI
+ true
+ $(WarningsAsErrors);CS1573;CS1574;CS1591
+
+
+
Steelseries-NET-API
- Steelseries-NET-API
- SteelSeries .NET API Client
+ 2.0.0-alpha.1
DataNext
- Unofficial .NET SteelSeries Sonar API (Since it's acutally impossible for Moments)
+ SteelSeries-NET-API
+ Unofficial .NET library to control SteelSeries GG Sonar: volumes, mutes, mixer mode, chat mix, audio configs, device redirections, app routing, and real-time change events. No admin rights required. Not affiliated with SteelSeries.
+ steelseries;sonar;gg;audio;mixer;chatmix;volume;streaming;api
+ MIT
https://github.com/DataNext27/SteelSeries-NET-API
- https://github.com/DataNext27/SteelSeries-NET-API.git
+ README.md
+ icon-24.png
+ See https://github.com/DataNext27/SteelSeries-NET-API/releases
+ Copyright (c) DataNext
+
+
+
+ https://github.com/DataNext27/SteelSeries-NET-API
git
- Copyright DataNext; all rights reserved
- MIT
true
- True
- true
- icon-24.png
- README.md
- steelseries; sonar; gg; api; lib; library
+ true
+ true
+ snupkg
+
+
+
+
+
-
- True
- \
-
+
-
+
-
- True
- \
-
+
diff --git a/SteelSeriesAPI/SteelSeriesRetriever.cs b/SteelSeriesAPI/SteelSeriesRetriever.cs
deleted file mode 100644
index ccc98be..0000000
--- a/SteelSeriesAPI/SteelSeriesRetriever.cs
+++ /dev/null
@@ -1,60 +0,0 @@
-using System.Diagnostics;
-using System.Text.Json;
-using SteelSeriesAPI.Exceptions;
-using SteelSeriesAPI.Interfaces;
-
-namespace SteelSeriesAPI;
-
-public class SteelSeriesRetriever : ISteelSeriesRetriever
-{
- private static readonly Lazy _instance = new(() => new SteelSeriesRetriever());
-
- public static SteelSeriesRetriever Instance => _instance.Value;
-
- public bool Running => SteelSeriesProcessesChecker();
-
- private Process[] _steelSeriesProcesses;
-
- public SteelSeriesRetriever()
- {
- _steelSeriesProcesses = Process.GetProcessesByName("SteelSeriesGG");
- }
-
- public string GetggEncryptedAddress()
- {
- if (!Running)
- {
- throw new SteelSeriesNotRunningException();
- }
-
- try
- {
- JsonDocument coreProps = JsonDocument.Parse(File.ReadAllText(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), @"SteelSeries\GG\coreProps.json")));
- string ggEncryptedAddress = coreProps.RootElement.GetProperty("ggEncryptedAddress").ToString();
- return ggEncryptedAddress;
- }
- catch (Exception e)
- {
- throw new Exception("Could not find coreProps.json\nIs SteelSeries installed?", e);
- }
- }
-
- public void WaitUntilSteelSeriesStarted()
- {
- if (!Running)
- {
- Console.WriteLine("Waiting for SteelSeries to start");
- while (!Running)
- {
- Thread.Sleep(500);
- }
- Console.WriteLine("SteelSeries started, continuing");
- }
- }
-
- private bool SteelSeriesProcessesChecker()
- {
- _steelSeriesProcesses = Process.GetProcessesByName("SteelSeriesGG");
- return _steelSeriesProcesses.Length > 0;
- }
-}
\ No newline at end of file
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..0b30e19
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,132 @@
+# Architecture
+
+This document explains how the library is structured, how change detection works,
+and the hard-earned lessons about the Sonar API that shaped the design. Read this
+before touching the internals: most "weird" choices in the code are direct
+consequences of how the Sonar API actually behaves.
+
+## The big picture
+
+The Sonar API is an undocumented, local HTTP + WebSocket API embedded in SteelSeries GG.
+This library wraps it in three layers:
+
+SteelSeriesAPI/
+├── Core/ "How to talk to Sonar" - discovery, HTTP, exceptions, JSON helpers
+├── Sonar/
+│ ├── Managers/ "Controlling Sonar" - typed read/write operations, on demand
+│ ├── Events/ "Being told of changes" - WebSocket + polling, typed .NET events
+│ ├── Models/ - immutable records returned by managers and events
+│ ├── Enums/ - Channel, Mix, Mode, AudioDataFlow + API vocabulary mappings
+│ ├── SonarClient.cs - the single entry point, wires everything together
+│ └── SonarRoutes.cs - every HTTP route, centralized
+└── (tooling: SteelSeriesAPI.Explorer, SteelSeriesAPI.Sample, SteelSeriesAPI.Tests)
+
+
+**Dependency rule:** Managers and Events depend on `Core` through the
+`ISonarTransport` interface, never on each other's internals. `SonarClient` is the
+only place that knows the concrete types and wires them together.
+
+### Core
+
+- `ServerDiscovery` resolves the Sonar web server address:
+ `coreProps.json` → GG `/subApps` endpoint (HTTPS, self-signed cert) → Sonar base URL.
+ The port changes on every GG restart, so the address is never assumed stable.
+- `SonarHttpClient` owns the single `HttpClient`, caches the resolved address, and
+ self-heals: on a transport failure it invalidates the cache, rediscovers, and
+ retries once. It implements `ISonarTransport`, which is what everything else
+ consumes - and what tests fake.
+- `SonarExceptions` is the exception taxonomy. Everything the library throws derives
+ from `SteelSeriesException`, so consumers can catch one type. `SonarWrongModeException`
+ and `SonarRequestException` (with `StatusCode`/`ResponseBody`) carry diagnostics.
+
+### Managers
+
+One manager per functional domain (volumes, mode, chat mix, redirections, devices,
+configs, app routing). The pattern is always the same:
+
+- a public interface (`IVolumeSettingsManager`) next to an `internal sealed` class,
+- the class receives `ISonarTransport`, nothing else,
+- routes live in `SonarRoutes`, never inline,
+- parsing is a pure `internal static` function, testable against real payloads,
+- validation happens before any HTTP call.
+
+### Events
+
+`SonarEventListener` raises typed .NET events fed by three mechanisms. Subscribers
+never know (or care) which mechanism produced an event.
+
+| Event | Mechanism |
+|---|---|
+| `Connected` / `Disconnected` | WebSocket connection lifecycle |
+| `ChatMixChanged` | WebSocket broadcast (real time) |
+| `VolumeDataReceived` | WebSocket broadcast (connection, mode switch, OS/hardware changes) |
+| `AudioSessionOpened` / `AudioSessionClosed` | WebSocket broadcast |
+| `*Invalidated` (redirections, configs, routing) | WebSocket broadcast (raw, no data) |
+| `UnknownEventReceived` | WebSocket broadcast (catch-all) |
+| `VolumeChanged` | Polling (mode-aware diff) |
+| `ModeChanged` | Polling |
+| `ClassicDeviceChanged`, `MixDeviceChanged`, `MixChannelToggled`, `StreamMonitoringChanged` | Hybrid: invalidation + polling → fetch + diff |
+| `ConfigSelectionChanged` | Hybrid: invalidation + polling → fetch + diff |
+
+**Why three mechanisms?** Because Sonar's WebSocket (`/sock`) only broadcasts what
+does *not* come through its own HTTP API. UI slider moves, mix toggles, and config
+selections are HTTP writes from the GG UI, so the server never re-broadcasts them.
+Polling fills that gap. The hybrid pattern (invalidation → debounced fetch → diff →
+granular events) turns Sonar's empty `data: null` signals into rich events.
+
+Key internals:
+
+- The WebSocket loop reconnects automatically with exponential backoff (1s→30s).
+ A dead connection *is* the "GG closed" detector; on reconnection Sonar re-pushes
+ its full state, so subscribers resynchronize for free.
+- `DebouncedRefresher` collapses invalidation bursts into a single fetch (250ms
+ debounce) and serializes refreshes with the polling tick.
+- Diff baselines are only compared within the same mode (see stale sections below).
+- `RaiseSafely` guarantees a throwing subscriber never kills a background loop.
+
+## Lessons about the Sonar API
+
+These are empirical findings, each one shaped the code. Dates refer to when they
+were observed; the Explorer's `check`/`verify` commands guard them.
+
+1. **The port changes on every GG restart.** Never cache the address beyond a
+ connection failure. (`SonarHttpClient` invalidation + rediscovery)
+2. **Writes are rejected in the wrong mode** with HTTP 500
+ `"Cannot be called in current mode"`. (`SonarWrongModeException`)
+3. **Stale sections:** each `volumeSettings/{mode}` route only reliably reflects
+ its *own* mode's values. The other mode's sections return stale data. Poll the
+ route matching the current mode; never diff across modes. (2026-08-08)
+4. **The server does not re-broadcast its own HTTP writes.** UI slider moves,
+ mix toggles, config selections: invisible on the WebSocket. Hence polling.
+5. **Three channel vocabularies** coexist: JSON keys (`chatRender`/`chatCapture`),
+ route keys (capitalized `Volume`/`Mute` in classic, lowercase in streamer), and
+ classic redirection ids (`chat`/`mic`). All mappings live in `ChannelExtensions`.
+6. **Device GUIDs are regenerated by GG updates** and **process ids change on every
+ launch**. Never persist either; resolve at call time. (`RouteAppAsync` does.)
+7. **`configs` weighs >1MB** (every preset embeds its full EQ twice). Never poll it;
+ parse headers lazily. `configs/selected` is the cheap alternative.
+8. **Routes are being migrated to a `/v1/` prefix** (chatMix moved in a 2026 update,
+ breaking the unprefixed route). Expect more; `SonarRoutes` centralizes the fix.
+9. **Event names are inconsistent** (`EVENT_SONAR_*` vs `SONAR_EVENT_*`): SteelSeries'
+ doing, centralized in `SonarEventNames`.
+10. **Float noise** (`0.45999998`): compare records by value, avoid strict equality
+ on raw doubles in new code.
+
+## Design invariants
+
+When contributing, preserve these:
+
+- **Minimal tolerant parsing.** `TryGetProperty` + `ValueKind` checks everywhere.
+ Unknown fields, channels, or entries are *skipped*, never fatal. A GG update must
+ degrade features, not crash consumers. (This killed V1.)
+- **Only parse what the library exposes.** Don't materialize fields "in case".
+- **Routes and event names are centralized** (`SonarRoutes`, `SonarEventNames`),
+ commented and dated.
+- **Wrap at the boundary:** everything thrown to consumers derives from
+ `SteelSeriesException`, with the underlying exception as `InnerException`.
+- **Async end to end** with `CancellationToken` on every public method.
+- **Records for models:** value equality is what makes the diffing cheap.
+- **`InvariantCulture` for every number formatted into a route** (French machines
+ write `0,5` otherwise).
+- **Test fixtures are real captured payloads**, dated in a comment, trimmed but
+ never hand-idealized.
\ No newline at end of file
diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md
new file mode 100644
index 0000000..4f9b49a
--- /dev/null
+++ b/docs/CONTRIBUTING.md
@@ -0,0 +1,90 @@
+# Contributing
+
+Thanks for your interest! This project reverse-engineers the local SteelSeries
+Sonar API, so contributing has two sides: regular C# work, and careful verification
+against the real API. This guide covers both.
+
+Start by reading [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) - most design
+questions are answered there.
+
+## Setup
+
+- .NET SDK 10 (the library multi-targets `net8.0;net10.0`)
+- Windows with SteelSeries GG installed and Sonar enabled - *only needed for live
+ testing*: the unit test suite runs anywhere, without GG
+- Solution layout:
+ - `SteelSeriesAPI` - the library (the only shipped project)
+ - `SteelSeriesAPI.Tests` - xUnit tests, no network, run everywhere
+ - `SteelSeriesAPI.Sample` - manual test bench: dumps the full state, then prints every event live
+ - `SteelSeriesAPI.Explorer` - API exploration & verification tool (see below)
+
+```bash
+dotnet build
+dotnet test # must stay green on net8.0 and net10.0
+dotnet run --project SteelSeriesAPI.Sample # requires GG running
+dotnet run --project SteelSeriesAPI.Explorer # requires GG running
+```
+
+## The Explorer
+
+The Explorer is how this library was built and how it stays alive across GG updates.
+
+| Command | Purpose |
+|---|---|
+| `get ` / `put ` | Play with any route by hand |
+| `probe ...` | Test route candidates (distinguishes 404 from wrong-mode 500) |
+| `ws [path]` | Listen to a WebSocket and print every message |
+| `dump` | Snapshot every known GET route into `dumps//` |
+| `check` | Compare response *structures* against committed references (`reference-shapes/`) |
+| `check update` | Re-capture the references (do this only on a version you trust) |
+| `verify` | Live write round-trips: set, read back, restore, for every write route |
+
+`check` and `verify` handle the mixer mode themselves and restore your state.
+
+## After a GG update (the checklist)
+
+GG updates are what break this kind of library. When one lands:
+
+1. `dotnet run --project SteelSeriesAPI.Explorer -- check`
+ - `STRUCTURE CHANGED`? Diff the `.shape.json` vs `.shape.json.actual` files:
+ that's exactly what SteelSeries changed.
+2. `dotnet run --project SteelSeriesAPI.Explorer -- verify`
+ - a `FAIL` means a write contract broke (renamed route, new vocabulary...).
+3. `dump`, and diff against the previous dated folder for the human-readable view.
+4. If something broke: fix `SonarRoutes`/parsers, update the test fixtures with
+ freshly captured payloads (keep the capture date comment), run `check update`,
+ and commit the new references with the fix.
+5. Nothing broke? Enjoy, you just spent 30 seconds.
+
+## Adding a manager (the mold)
+
+Every manager follows the same shape - copy an existing one (e.g. `ChatMixManager`
+for a simple one, `RedirectionsManager` for a rich one):
+
+1. **Explore first.** Find the routes (Explorer `probe`, Wireshark on loopback, or
+ grepping GG's `app.asar`), capture real payloads, verify writes by hand.
+2. `SonarRoutes`: add the routes, commented and dated ("Verified live on ...").
+3. `Models/`: immutable records, only the fields the library exposes.
+4. `Managers/`: public interface + `internal sealed` class taking `ISonarTransport`.
+ Parsing goes in a pure `internal static` method. Validate before sending.
+5. `SonarClient`: expose the interface, instantiate in the constructor.
+6. **Tests**: fixtures = your captured payloads (dated), plus the standard cases:
+ real payload parses, unknown entries are skipped without crashing, write routes
+ build exactly the verified strings, validation throws *before* any HTTP call.
+7. Sample: add the state dump and event subscriptions if relevant.
+8. If the domain needs change detection, follow the hybrid pattern in
+ `SonarEventListener.Redirections.cs` (it reuses `DebouncedRefresher`).
+
+## Tests
+
+- Unit tests run against `FakeTransport` with real captured payloads as fixtures.
+ They protect *this library's code*; they cannot detect SteelSeries-side changes -
+ that's the Explorer's `check`/`verify` job.
+- Keep the suite green on both target frameworks; CI runs it on every push/PR.
+- New fixtures: paste the real payload (trim huge blobs like EQ data), date it.
+
+## Style
+
+- C# 12, nullable enabled, XML docs on every public member (English).
+- Comments explain *why*, and carry dates when they encode an empirical finding.
+- No new public API without XML docs; no route strings outside `SonarRoutes`.
\ No newline at end of file
diff --git a/global.json b/global.json
deleted file mode 100644
index 87932f4..0000000
--- a/global.json
+++ /dev/null
@@ -1,6 +0,0 @@
-{
- "sdk": {
- "rollForward": "latestMajor",
- "allowPrerelease": true
- }
-}
\ No newline at end of file