From 3acd7cd9f8275eb23ff4e87a0604aba3bc680ade Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Tue, 4 Aug 2026 06:22:30 +0200 Subject: [PATCH 01/26] From zero --- .gitignore | 484 +++++++++++++++++- SteelSeries-NET-API.sln | 30 +- SteelSeriesAPI.Sample/Program.cs | 147 +----- .../SteelSeriesAPI.Sample.csproj | 4 +- SteelSeriesAPI.Tests/Program.cs | 267 ---------- .../SteelSeriesAPI.Tests.csproj | 19 +- SteelSeriesAPI.Tests/UnitTest1.cs | 15 + .../SteelSeriesNotRunningException.cs | 8 - SteelSeriesAPI/Interfaces/IAppRetriever.cs | 18 - .../Interfaces/ISteelSeriesRetriever.cs | 10 - SteelSeriesAPI/Sonar/Enums/Channel.cs | 86 ---- SteelSeriesAPI/Sonar/Enums/DataFlow.cs | 34 -- SteelSeriesAPI/Sonar/Enums/Mix.cs | 34 -- SteelSeriesAPI/Sonar/Enums/Mode.cs | 61 --- .../Sonar/Enums/RoutedProcessState.cs | 36 -- .../Events/SonarAudienceMonitoringEvent.cs | 7 - .../Sonar/Events/SonarChatMixEvent.cs | 7 - .../Sonar/Events/SonarConfigEvent.cs | 8 - SteelSeriesAPI/Sonar/Events/SonarMixEvent.cs | 14 - SteelSeriesAPI/Sonar/Events/SonarModeEvent.cs | 10 - SteelSeriesAPI/Sonar/Events/SonarMuteEvent.cs | 17 - .../Sonar/Events/SonarPlaybackDeviceEvent.cs | 17 - .../Sonar/Events/SonarRoutedProcessEvent.cs | 40 -- .../Sonar/Events/SonarVolumeEvent.cs | 17 - .../ChannelNoStreamerSupportException.cs | 8 - .../Exceptions/ChannelNotFoundException.cs | 8 - .../Exceptions/ChatMixBalanceException.cs | 8 - .../Exceptions/ChatMixDisabledException.cs | 8 - .../Exceptions/ConfigNotFoundException.cs | 8 - .../MasterChannelNotSupportedException.cs | 8 - .../Sonar/Exceptions/MixNotFoundException.cs | 8 - .../PlaybackDeviceDataFlowException.cs | 8 - .../PlaybackDeviceNotFoundException.cs | 8 - .../RoutedProcessNotFoundException.cs | 8 - .../SonarListenerNotConnectedException.cs | 8 - .../Exceptions/SonarNotRunningException.cs | 8 - SteelSeriesAPI/Sonar/Http/Fetcher.cs | 63 --- .../Sonar/Interfaces/ISonarBridge.cs | 23 - .../Sonar/Interfaces/ISonarSocket.cs | 12 - .../Managers/IAudienceMonitoringManager.cs | 20 - .../Interfaces/Managers/IChatMixManager.cs | 26 - .../Managers/IConfigurationManager.cs | 60 --- .../Sonar/Interfaces/Managers/IMixManager.cs | 39 -- .../Sonar/Interfaces/Managers/IModeManager.cs | 21 - .../Managers/IPlaybackDeviceManager.cs | 106 ---- .../Managers/IRoutedProcessManager.cs | 64 --- .../Managers/IVolumeSettingsManager.cs | 69 --- .../Managers/AudienceMonitoringManager.cs | 21 - .../Sonar/Managers/ChatMixManager.cs | 46 -- .../Sonar/Managers/ConfigurationManager.cs | 128 ----- SteelSeriesAPI/Sonar/Managers/EventManager.cs | 220 -------- SteelSeriesAPI/Sonar/Managers/MixManager.cs | 49 -- SteelSeriesAPI/Sonar/Managers/ModeManager.cs | 21 - .../Sonar/Managers/PlaybackDeviceManager.cs | 351 ------------- .../Sonar/Managers/RoutedProcessManager.cs | 225 -------- .../Sonar/Managers/VolumeSettingsManager.cs | 70 --- SteelSeriesAPI/Sonar/Models/PlaybackDevice.cs | 6 - SteelSeriesAPI/Sonar/Models/RoutedProcess.cs | 23 - .../Sonar/Models/SonarAudioConfiguration.cs | 5 - SteelSeriesAPI/Sonar/SonarBridge.cs | 155 ------ SteelSeriesAPI/Sonar/SonarRetriever.cs | 106 ---- SteelSeriesAPI/Sonar/SonarSocket.cs | 119 ----- SteelSeriesAPI/SteelSeriesAPI.csproj | 38 +- SteelSeriesAPI/SteelSeriesRetriever.cs | 60 --- global.json | 6 - 65 files changed, 538 insertions(+), 3100 deletions(-) delete mode 100644 SteelSeriesAPI.Tests/Program.cs create mode 100644 SteelSeriesAPI.Tests/UnitTest1.cs delete mode 100644 SteelSeriesAPI/Exceptions/SteelSeriesNotRunningException.cs delete mode 100644 SteelSeriesAPI/Interfaces/IAppRetriever.cs delete mode 100644 SteelSeriesAPI/Interfaces/ISteelSeriesRetriever.cs delete mode 100644 SteelSeriesAPI/Sonar/Enums/Channel.cs delete mode 100644 SteelSeriesAPI/Sonar/Enums/DataFlow.cs delete mode 100644 SteelSeriesAPI/Sonar/Enums/Mix.cs delete mode 100644 SteelSeriesAPI/Sonar/Enums/Mode.cs delete mode 100644 SteelSeriesAPI/Sonar/Enums/RoutedProcessState.cs delete mode 100644 SteelSeriesAPI/Sonar/Events/SonarAudienceMonitoringEvent.cs delete mode 100644 SteelSeriesAPI/Sonar/Events/SonarChatMixEvent.cs delete mode 100644 SteelSeriesAPI/Sonar/Events/SonarConfigEvent.cs delete mode 100644 SteelSeriesAPI/Sonar/Events/SonarMixEvent.cs delete mode 100644 SteelSeriesAPI/Sonar/Events/SonarModeEvent.cs delete mode 100644 SteelSeriesAPI/Sonar/Events/SonarMuteEvent.cs delete mode 100644 SteelSeriesAPI/Sonar/Events/SonarPlaybackDeviceEvent.cs delete mode 100644 SteelSeriesAPI/Sonar/Events/SonarRoutedProcessEvent.cs delete mode 100644 SteelSeriesAPI/Sonar/Events/SonarVolumeEvent.cs delete mode 100644 SteelSeriesAPI/Sonar/Exceptions/ChannelNoStreamerSupportException.cs delete mode 100644 SteelSeriesAPI/Sonar/Exceptions/ChannelNotFoundException.cs delete mode 100644 SteelSeriesAPI/Sonar/Exceptions/ChatMixBalanceException.cs delete mode 100644 SteelSeriesAPI/Sonar/Exceptions/ChatMixDisabledException.cs delete mode 100644 SteelSeriesAPI/Sonar/Exceptions/ConfigNotFoundException.cs delete mode 100644 SteelSeriesAPI/Sonar/Exceptions/MasterChannelNotSupportedException.cs delete mode 100644 SteelSeriesAPI/Sonar/Exceptions/MixNotFoundException.cs delete mode 100644 SteelSeriesAPI/Sonar/Exceptions/PlaybackDeviceDataFlowException.cs delete mode 100644 SteelSeriesAPI/Sonar/Exceptions/PlaybackDeviceNotFoundException.cs delete mode 100644 SteelSeriesAPI/Sonar/Exceptions/RoutedProcessNotFoundException.cs delete mode 100644 SteelSeriesAPI/Sonar/Exceptions/SonarListenerNotConnectedException.cs delete mode 100644 SteelSeriesAPI/Sonar/Exceptions/SonarNotRunningException.cs delete mode 100644 SteelSeriesAPI/Sonar/Http/Fetcher.cs delete mode 100644 SteelSeriesAPI/Sonar/Interfaces/ISonarBridge.cs delete mode 100644 SteelSeriesAPI/Sonar/Interfaces/ISonarSocket.cs delete mode 100644 SteelSeriesAPI/Sonar/Interfaces/Managers/IAudienceMonitoringManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Interfaces/Managers/IChatMixManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Interfaces/Managers/IConfigurationManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Interfaces/Managers/IMixManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Interfaces/Managers/IModeManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Interfaces/Managers/IPlaybackDeviceManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Interfaces/Managers/IRoutedProcessManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Interfaces/Managers/IVolumeSettingsManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Managers/AudienceMonitoringManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Managers/ConfigurationManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Managers/EventManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Managers/MixManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Managers/ModeManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Managers/PlaybackDeviceManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Managers/RoutedProcessManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs delete mode 100644 SteelSeriesAPI/Sonar/Models/PlaybackDevice.cs delete mode 100644 SteelSeriesAPI/Sonar/Models/RoutedProcess.cs delete mode 100644 SteelSeriesAPI/Sonar/Models/SonarAudioConfiguration.cs delete mode 100644 SteelSeriesAPI/Sonar/SonarBridge.cs delete mode 100644 SteelSeriesAPI/Sonar/SonarRetriever.cs delete mode 100644 SteelSeriesAPI/Sonar/SonarSocket.cs delete mode 100644 SteelSeriesAPI/SteelSeriesRetriever.cs delete mode 100644 global.json diff --git a/.gitignore b/.gitignore index 70d1ba3..0808c4a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,482 @@ -*/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 diff --git a/SteelSeries-NET-API.sln b/SteelSeries-NET-API.sln index c070797..f0374bb 100644 --- a/SteelSeries-NET-API.sln +++ b/SteelSeries-NET-API.sln @@ -1,10 +1,10 @@  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 Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -12,17 +12,17 @@ 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 EndGlobalSection EndGlobal diff --git a/SteelSeriesAPI.Sample/Program.cs b/SteelSeriesAPI.Sample/Program.cs index 22f5b93..d7463e9 100644 --- a/SteelSeriesAPI.Sample/Program.cs +++ b/SteelSeriesAPI.Sample/Program.cs @@ -1,152 +1,9 @@ -using SteelSeriesAPI.Sonar; -using SteelSeriesAPI.Sonar.Events; -using SteelSeriesAPI.Sonar.Enums; -using SteelSeriesAPI.Sonar.Models; - -namespace SteelSeriesAPI.Sample; +namespace SteelSeriesAPI.Sample; class Program { static void Main(string[] args) { - // 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); - } - - 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); + Console.WriteLine("Hello, World!"); } } \ No newline at end of file diff --git a/SteelSeriesAPI.Sample/SteelSeriesAPI.Sample.csproj b/SteelSeriesAPI.Sample/SteelSeriesAPI.Sample.csproj index 161886a..50f9bba 100644 --- a/SteelSeriesAPI.Sample/SteelSeriesAPI.Sample.csproj +++ b/SteelSeriesAPI.Sample/SteelSeriesAPI.Sample.csproj @@ -2,13 +2,13 @@ Exe - net9.0 + net10.0 enable enable - + 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/SteelSeriesAPI.Tests.csproj b/SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj index fc7bbf8..f6dc532 100644 --- a/SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj +++ b/SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj @@ -1,14 +1,25 @@  - Exe + net10.0 + latest enable enable - net7.0;net8.0;net9.0 - 7.0.0 - LatestMajor + false + + + + + + + + + + + + diff --git a/SteelSeriesAPI.Tests/UnitTest1.cs b/SteelSeriesAPI.Tests/UnitTest1.cs new file mode 100644 index 0000000..3ab3b9f --- /dev/null +++ b/SteelSeriesAPI.Tests/UnitTest1.cs @@ -0,0 +1,15 @@ +namespace SteelSeriesAPI.Tests; + +public class Tests +{ + [SetUp] + public void Setup() + { + } + + [Test] + public void Test1() + { + Assert.Pass(); + } +} \ 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/Channel.cs b/SteelSeriesAPI/Sonar/Enums/Channel.cs deleted file mode 100644 index 4e82dec..0000000 --- a/SteelSeriesAPI/Sonar/Enums/Channel.cs +++ /dev/null @@ -1,86 +0,0 @@ -namespace SteelSeriesAPI.Sonar.Enums; - -public enum Channel -{ - MASTER, - GAME, - CHAT, - MEDIA, - AUX, - MIC -} - -public enum ChannelMapChoice -{ - JsonDict, - HttpDict, - ChannelDict -} - -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 - { - { Channel.MASTER, "Master" }, - { Channel.GAME, "game" }, - { Channel.CHAT, "chatRender" }, - { Channel.MEDIA, "media" }, - { Channel.AUX, "aux" }, - { Channel.MIC, "chatCapture" } - }; - - private static readonly Dictionary ChannelMap = new Dictionary - { - { Channel.MASTER, "master" }, - { Channel.GAME, "game" }, - { Channel.CHAT, "chat" }, - { Channel.MEDIA, "media" }, - { Channel.AUX, "aux" }, - { Channel.MIC, "mic" } - }; - - public static string ToDictKey(this Channel channel, ChannelMapChoice context = ChannelMapChoice.JsonDict) - { - 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 - }; - } - - public static Channel? FromDictKey(string jsonKey, ChannelMapChoice context = ChannelMapChoice.JsonDict) - { - var map = context switch - { - ChannelMapChoice.JsonDict => ChannelJsonMap, - ChannelMapChoice.HttpDict => ChannelHttpMap, - ChannelMapChoice.ChannelDict => ChannelMap, - _ => null - }; - - if (map != null) - { - foreach (var pair in map) - { - if (pair.Value.ToLower() == jsonKey.ToLower()) - { - 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 deleted file mode 100644 index 6203d9d..0000000 --- a/SteelSeriesAPI/Sonar/Enums/Mix.cs +++ /dev/null @@ -1,34 +0,0 @@ -namespace SteelSeriesAPI.Sonar.Enums; - -public enum Mix -{ - PERSONAL, - STREAM -} - -public static class MixExtensions -{ - private static readonly Dictionary MixMap = new Dictionary - { - { Mix.PERSONAL, "monitoring" }, - { Mix.STREAM, "streaming" } - }; - - public static string ToDictKey(this Mix mix) - { - return MixMap[mix]; - } - - public static Mix? FromDictKey(string jsonKey) - { - foreach (var pair in MixMap) - { - if (pair.Value == jsonKey) - { - 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 deleted file mode 100644 index de0a76a..0000000 --- a/SteelSeriesAPI/Sonar/Enums/Mode.cs +++ /dev/null @@ -1,61 +0,0 @@ -namespace SteelSeriesAPI.Sonar.Enums; - -public enum Mode -{ - CLASSIC, - STREAMER -} - -public enum ModeMapChoice -{ - StreamerDict, - StreamDict -} - -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" } - }; - - 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) - { - 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; - } -} \ 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/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/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/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/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/ChatMixManager.cs b/SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs deleted file mode 100644 index 06c0a71..0000000 --- a/SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs +++ /dev/null @@ -1,46 +0,0 @@ -using SteelSeriesAPI.Sonar.Exceptions; -using SteelSeriesAPI.Sonar.Interfaces.Managers; -using SteelSeriesAPI.Sonar.Http; - -using System.Text.Json; -using System.Globalization; - -namespace SteelSeriesAPI.Sonar.Managers; - -internal class ChatMixManager : IChatMixManager -{ - public double GetBalance() - { - JsonDocument chatMix = new Fetcher().Provide("chatMix"); - - return chatMix.RootElement.GetProperty("balance").GetDouble(); - } - - public bool GetState() - { - JsonDocument chatMix = new Fetcher().Provide("chatMix"); - string cState = chatMix.RootElement.GetProperty("state").ToString(); - - if (cState == "enabled") - { - return true; - } - - return false; - } - - public void SetBalance(double balance) - { - if (!GetState()) - { - throw new ChatMixDisabledException(); - } - - if (balance > 1 || balance < -1) - { - throw new ChatMixBalanceException(); - } - - new Fetcher().Put("chatMix?balance=" + balance.ToString("0.00", CultureInfo.InvariantCulture)); - } -} \ 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/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 deleted file mode 100644 index f8ce852..0000000 --- a/SteelSeriesAPI/Sonar/Managers/ModeManager.cs +++ /dev/null @@ -1,21 +0,0 @@ -using SteelSeriesAPI.Sonar.Enums; -using SteelSeriesAPI.Sonar.Http; -using SteelSeriesAPI.Sonar.Interfaces.Managers; - -namespace SteelSeriesAPI.Sonar.Managers; - -internal class ModeManager : IModeManager -{ - public Mode Get() - { - string mode = new Fetcher().Provide("mode").RootElement.ToString(); - - return (Mode)ModeExtensions.FromDictKey(mode, ModeMapChoice.StreamDict)!; - } - - public void Set(Mode mode) - { - new Fetcher().Put("mode/" + mode.ToDictKey(ModeMapChoice.StreamDict)); - Thread.Sleep(100); // Prevent bugs/freezes/crashes - } -} \ 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/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 deleted file mode 100644 index 0b4d394..0000000 --- a/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs +++ /dev/null @@ -1,70 +0,0 @@ -using SteelSeriesAPI.Sonar.Interfaces.Managers; -using SteelSeriesAPI.Sonar.Enums; -using SteelSeriesAPI.Sonar.Http; - -using System.Globalization; -using System.Text.Json; - -namespace SteelSeriesAPI.Sonar.Managers; - -internal class VolumeSettingsManager : IVolumeSettingsManager -{ - // volume = 0,00000000 <-- 8 decimal max - public double GetVolume(Channel channel) - { - JsonDocument volumeSettings = new Fetcher().Provide("volumeSettings/classic/"); - - 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(); - } - - public double GetVolume(Channel channel, Mix mix) - { - JsonDocument volumeSettings = new Fetcher().Provide("volumeSettings/streamer/"); - - 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/"); - - 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(); - } - - public bool GetMute(Channel channel, Mix mix) - { - JsonDocument volumeSettings = new Fetcher().Provide("volumeSettings/streamer/"); - - 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(); - } - - public void SetVolume(double volume, Channel channel) - { - string vol = volume.ToString("0.00", CultureInfo.InvariantCulture); - new Fetcher().Put("volumeSettings/classic/" + channel.ToDictKey(ChannelMapChoice.HttpDict) + "/Volume/" + vol); - } - - public void SetVolume(double volume, Channel channel, Mix mix) - { - string vol = volume.ToString("0.00", CultureInfo.InvariantCulture); - new Fetcher().Put("volumeSettings/streamer/" + mix.ToDictKey() + "/" + channel.ToDictKey(ChannelMapChoice.HttpDict) + "/volume/" + vol); - } - - public void SetMute(bool mute, Channel channel) - { - new Fetcher().Put("volumeSettings/classic/" + channel.ToDictKey(ChannelMapChoice.HttpDict) + "/Mute/" + mute); - } - - public void SetMute(bool mute, Channel channel, Mix mix) - { - new Fetcher().Put("volumeSettings/streamer/" + mix.ToDictKey() + "/" + channel.ToDictKey(ChannelMapChoice.HttpDict) + "/isMuted/" + mute); - } -} \ 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/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/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/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/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..df012fa 100644 --- a/SteelSeriesAPI/SteelSeriesAPI.csproj +++ b/SteelSeriesAPI/SteelSeriesAPI.csproj @@ -1,40 +1,20 @@  + net8.0;net10.0 enable enable - net8.0;net9.0;net7.0 - 1.1.0 + SteelSeriesAPI + + + 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) - https://github.com/DataNext27/SteelSeries-NET-API - https://github.com/DataNext27/SteelSeries-NET-API.git - git - Copyright DataNext; all rights reserved + Unofficial .NET library to control SteelSeries GG (Sonar) MIT - true - True - true - icon-24.png - README.md - steelseries; sonar; gg; api; lib; library + https://github.com/DataNext27/SteelSeries-NET-API + true - - - 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/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 From 166010a06e40229dbe41700983ff3b6ebcab0926 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Sat, 8 Aug 2026 01:40:54 +0200 Subject: [PATCH 02/26] New discovery method --- SteelSeriesAPI.Tests/ServerDiscoveryTests.cs | 73 ++++++++++++ .../SteelSeriesAPI.Tests.csproj | 10 +- SteelSeriesAPI.Tests/UnitTest1.cs | 15 --- SteelSeriesAPI/Core/JsonExtensions.cs | 26 ++++ SteelSeriesAPI/Core/ServerDiscovery.cs | 107 +++++++++++++++++ SteelSeriesAPI/Core/SonarExceptions.cs | 67 +++++++++++ SteelSeriesAPI/Core/SonarHttpClient.cs | 111 ++++++++++++++++++ 7 files changed, 388 insertions(+), 21 deletions(-) create mode 100644 SteelSeriesAPI.Tests/ServerDiscoveryTests.cs delete mode 100644 SteelSeriesAPI.Tests/UnitTest1.cs create mode 100644 SteelSeriesAPI/Core/JsonExtensions.cs create mode 100644 SteelSeriesAPI/Core/ServerDiscovery.cs create mode 100644 SteelSeriesAPI/Core/SonarExceptions.cs create mode 100644 SteelSeriesAPI/Core/SonarHttpClient.cs 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/SteelSeriesAPI.Tests.csproj b/SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj index f6dc532..170597c 100644 --- a/SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj +++ b/SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj @@ -2,7 +2,6 @@ net10.0 - latest enable enable false @@ -10,14 +9,13 @@ - - - - + + + - + diff --git a/SteelSeriesAPI.Tests/UnitTest1.cs b/SteelSeriesAPI.Tests/UnitTest1.cs deleted file mode 100644 index 3ab3b9f..0000000 --- a/SteelSeriesAPI.Tests/UnitTest1.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace SteelSeriesAPI.Tests; - -public class Tests -{ - [SetUp] - public void Setup() - { - } - - [Test] - public void Test1() - { - Assert.Pass(); - } -} \ No newline at end of file diff --git a/SteelSeriesAPI/Core/JsonExtensions.cs b/SteelSeriesAPI/Core/JsonExtensions.cs new file mode 100644 index 0000000..67c4aec --- /dev/null +++ b/SteelSeriesAPI/Core/JsonExtensions.cs @@ -0,0 +1,26 @@ +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; + } +} \ No newline at end of file diff --git a/SteelSeriesAPI/Core/ServerDiscovery.cs b/SteelSeriesAPI/Core/ServerDiscovery.cs new file mode 100644 index 0000000..0c31bd6 --- /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 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..4dfc73d --- /dev/null +++ b/SteelSeriesAPI/Core/SonarExceptions.cs @@ -0,0 +1,67 @@ +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) { } +} + +/// 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; + } +} \ No newline at end of file diff --git a/SteelSeriesAPI/Core/SonarHttpClient.cs b/SteelSeriesAPI/Core/SonarHttpClient.cs new file mode 100644 index 0000000..fa134f9 --- /dev/null +++ b/SteelSeriesAPI/Core/SonarHttpClient.cs @@ -0,0 +1,111 @@ +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 class SonarHttpClient : IDisposable +{ + 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) + { + 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) + { + 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) + { + // Protocol-level failure: the server received the request and rejected it. + // Rediscovery would not help; surface the error with as much context as possible. + string body = await response.Content.ReadAsStringAsync(ct); + 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(); + } + } + + private void InvalidateAddress() => _baseAddress = null; + + /// Releases the underlying HTTP resources. + public void Dispose() + { + _http.Dispose(); + _discoveryLock.Dispose(); + GC.SuppressFinalize(this); + } +} \ No newline at end of file From f6a29a7deabf865e620064229095dcbf7a7016ac Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Sat, 8 Aug 2026 01:44:10 +0200 Subject: [PATCH 03/26] Add SonarClient (Replace SonarBridge) + new VolumeSettingsManager --- SteelSeriesAPI/Sonar/Enums/Channel.cs | 57 +++++++++++++ SteelSeriesAPI/Sonar/Enums/Mix.cs | 42 ++++++++++ .../Sonar/Managers/IVolumeSettingsManager.cs | 32 ++++++++ .../Sonar/Managers/VolumeSettingsManager.cs | 80 +++++++++++++++++++ SteelSeriesAPI/Sonar/Models/VolumeSettings.cs | 6 ++ SteelSeriesAPI/Sonar/SonarClient.cs | 41 ++++++++++ SteelSeriesAPI/Sonar/SonarRoutes.cs | 38 +++++++++ SteelSeriesAPI/SteelSeriesAPI.csproj | 8 ++ 8 files changed, 304 insertions(+) create mode 100644 SteelSeriesAPI/Sonar/Enums/Channel.cs create mode 100644 SteelSeriesAPI/Sonar/Enums/Mix.cs create mode 100644 SteelSeriesAPI/Sonar/Managers/IVolumeSettingsManager.cs create mode 100644 SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs create mode 100644 SteelSeriesAPI/Sonar/Models/VolumeSettings.cs create mode 100644 SteelSeriesAPI/Sonar/SonarClient.cs create mode 100644 SteelSeriesAPI/Sonar/SonarRoutes.cs diff --git a/SteelSeriesAPI/Sonar/Enums/Channel.cs b/SteelSeriesAPI/Sonar/Enums/Channel.cs new file mode 100644 index 0000000..9360fb8 --- /dev/null +++ b/SteelSeriesAPI/Sonar/Enums/Channel.cs @@ -0,0 +1,57 @@ +namespace SteelSeriesAPI.Sonar.Enums; + +/// A Sonar virtual audio channel. +public enum Channel +{ + /// 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 JsonKeys = new() + { + { Channel.Master, "masters" }, + { Channel.Game, "game" }, + { Channel.Chat, "chatRender" }, + { Channel.Media, "media" }, + { Channel.Aux, "aux" }, + { Channel.Mic, "chatCapture" } + }; + + private static readonly Dictionary RouteKeys = new() + { + { Channel.Master, "Master" }, + { Channel.Game, "game" }, + { Channel.Chat, "chatRender" }, + { Channel.Media, "media" }, + { Channel.Aux, "aux" }, + { Channel.Mic, "chatCapture" } + }; + + /// 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]; + + /// 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; + } +} \ No newline at end of file diff --git a/SteelSeriesAPI/Sonar/Enums/Mix.cs b/SteelSeriesAPI/Sonar/Enums/Mix.cs new file mode 100644 index 0000000..6205ca7 --- /dev/null +++ b/SteelSeriesAPI/Sonar/Enums/Mix.cs @@ -0,0 +1,42 @@ +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 +{ + /// 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 +{ + // 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" } + }; + + /// 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 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/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/VolumeSettingsManager.cs b/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs new file mode 100644 index 0000000..b592122 --- /dev/null +++ b/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs @@ -0,0 +1,80 @@ +using System.Text.Json; +using SteelSeriesAPI.Core; +using SteelSeriesAPI.Sonar.Enums; +using SteelSeriesAPI.Sonar.Models; + +namespace SteelSeriesAPI.Sonar.Managers; + +/// +internal sealed class VolumeSettingsManager : IVolumeSettingsManager +{ + private readonly SonarHttpClient _client; + + internal VolumeSettingsManager(SonarHttpClient client) => _client = client; + + /// + public async Task GetAsync(Channel channel, CancellationToken ct = default) + { + using var doc = await _client.GetAsync(SonarRoutes.ClassicVolumes, ct); + + // 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"); + + return ParseSetting(node); + } + + /// + public async Task GetAsync(Channel channel, Mix mix, CancellationToken ct = default) + { + using var doc = await _client.GetAsync(SonarRoutes.StreamerVolumes, ct); + + 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 Task SetVolumeAsync(Channel channel, double volume, CancellationToken ct = default) + { + ValidateVolume(volume); + return _client.PutAsync(SonarRoutes.SetClassicVolume(channel, volume), ct); + } + + /// + public Task SetVolumeAsync(Channel channel, Mix mix, double volume, CancellationToken ct = default) + { + ValidateVolume(volume); + return _client.PutAsync(SonarRoutes.SetStreamerVolume(mix, channel, volume), ct); + } + + /// + public Task SetMuteAsync(Channel channel, bool muted, CancellationToken ct = default) => + _client.PutAsync(SonarRoutes.SetClassicMute(channel, muted), ct); + + /// + public Task SetMuteAsync(Channel channel, Mix mix, bool muted, CancellationToken ct = default) => + _client.PutAsync(SonarRoutes.SetStreamerMute(mix, channel, muted), ct); + + 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.TryGetProperty("muted", out var m) && + m.ValueKind == JsonValueKind.True; + + return new VolumeSetting(volume, muted); + } + + private static void ValidateVolume(double volume) + { + if (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/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/SonarClient.cs b/SteelSeriesAPI/Sonar/SonarClient.cs new file mode 100644 index 0000000..f930f9c --- /dev/null +++ b/SteelSeriesAPI/Sonar/SonarClient.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using Microsoft.Extensions.Logging; +using SteelSeriesAPI.Core; +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; + + /// Controls the volume and mute state of Sonar channels. + public IVolumeSettingsManager VolumeSettings { 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); + + VolumeSettings = new VolumeSettingsManager(_httpClient); + } + + /// + public void Dispose() => _httpClient.Dispose(); + + /// + /// 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); + +} \ No newline at end of file diff --git a/SteelSeriesAPI/Sonar/SonarRoutes.cs b/SteelSeriesAPI/Sonar/SonarRoutes.cs new file mode 100644 index 0000000..f510fce --- /dev/null +++ b/SteelSeriesAPI/Sonar/SonarRoutes.cs @@ -0,0 +1,38 @@ +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 +{ + /// Volume/mute state of all channels in classic mode. + internal const string ClassicVolumes = "volumeSettings/classic/"; + + /// Volume/mute state of all channels/mixes in streamer mode. + internal const string StreamerVolumes = "volumeSettings/streamer/"; + + // 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)}"; + + 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/SteelSeriesAPI.csproj b/SteelSeriesAPI/SteelSeriesAPI.csproj index df012fa..05c3125 100644 --- a/SteelSeriesAPI/SteelSeriesAPI.csproj +++ b/SteelSeriesAPI/SteelSeriesAPI.csproj @@ -17,4 +17,12 @@ true + + + + + + + +
From d59aecb67a1a5973c0884581173b7652ef2244de Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Sat, 8 Aug 2026 01:50:51 +0200 Subject: [PATCH 04/26] Sample for testing (getting fixtures now) --- SteelSeriesAPI.Sample/Program.cs | 44 +++++++++++++++++-- .../SteelSeriesAPI.Sample.csproj | 4 ++ 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/SteelSeriesAPI.Sample/Program.cs b/SteelSeriesAPI.Sample/Program.cs index d7463e9..1736619 100644 --- a/SteelSeriesAPI.Sample/Program.cs +++ b/SteelSeriesAPI.Sample/Program.cs @@ -1,9 +1,45 @@ -namespace SteelSeriesAPI.Sample; +using Microsoft.Extensions.Logging; +using SteelSeriesAPI.Core; +using SteelSeriesAPI.Sonar; +using SteelSeriesAPI.Sonar.Enums; -class Program +namespace SteelSeriesAPI.Sample; + +internal static class Program { - static void Main(string[] args) + private static async Task Main() { - Console.WriteLine("Hello, World!"); + using var loggerFactory = LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Debug)); + var logger = loggerFactory.CreateLogger("Sample"); + + var discovery = new ServerDiscovery(logger); + using var client = new SonarHttpClient(discovery, logger); + + using var sonar = new SonarClient(logger); + + try + { + await sonar.VolumeSettings.SetVolumeAsync(Channel.Game, 0.5); + Console.WriteLine("Classic set: OK (hypothesis rejected!)"); + } + catch (SonarRequestException e) + { + Console.WriteLine($"Classic set rejected: HTTP {e.StatusCode}, body: '{e.ResponseBody}'"); + } + +// 2. Streamer-route write should succeed + await sonar.VolumeSettings.SetVolumeAsync(Channel.Game, Mix.Personal, 0.42); + var check = await sonar.VolumeSettings.GetAsync(Channel.Game, Mix.Personal); + Console.WriteLine($"Streamer set check: {check}"); + +// 3. Raw dumps for the test fixtures + using var classic = await client.GetAsync("volumeSettings/classic/", default); + Console.WriteLine(classic.RootElement); + using var streamer = await client.GetAsync("volumeSettings/streamer/", default); + Console.WriteLine(streamer.RootElement); + +// 4. Bonus: what does the mode route say? (route from your V1) + using var mode = await client.GetAsync("mode/", default); + Console.WriteLine($"Mode: {mode.RootElement}"); } } \ No newline at end of file diff --git a/SteelSeriesAPI.Sample/SteelSeriesAPI.Sample.csproj b/SteelSeriesAPI.Sample/SteelSeriesAPI.Sample.csproj index 50f9bba..a270f6f 100644 --- a/SteelSeriesAPI.Sample/SteelSeriesAPI.Sample.csproj +++ b/SteelSeriesAPI.Sample/SteelSeriesAPI.Sample.csproj @@ -11,4 +11,8 @@ + + + + From d39174a314cad075be2f33eef09b4fa10f17cd7e Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Sat, 8 Aug 2026 02:30:06 +0200 Subject: [PATCH 05/26] Add Mode support & Change http transport + tests --- SteelSeriesAPI.Sample/Program.cs | 65 +++++++----- SteelSeriesAPI.Tests/FakeTransport.cs | 28 ++++++ SteelSeriesAPI.Tests/ModeManagerTests.cs | 41 ++++++++ .../VolumeSettingsManagerTests.cs | 99 +++++++++++++++++++ SteelSeriesAPI/Core/ISonarTransport.cs | 17 ++++ SteelSeriesAPI/Core/SonarExceptions.cs | 13 +++ SteelSeriesAPI/Core/SonarHttpClient.cs | 8 +- SteelSeriesAPI/Sonar/Enums/Mode.cs | 27 +++++ SteelSeriesAPI/Sonar/Managers/IModeManager.cs | 20 ++++ SteelSeriesAPI/Sonar/Managers/ModeManager.cs | 43 ++++++++ .../Sonar/Managers/VolumeSettingsManager.cs | 16 +-- SteelSeriesAPI/Sonar/SonarClient.cs | 11 ++- SteelSeriesAPI/Sonar/SonarRoutes.cs | 5 + 13 files changed, 351 insertions(+), 42 deletions(-) create mode 100644 SteelSeriesAPI.Tests/FakeTransport.cs create mode 100644 SteelSeriesAPI.Tests/ModeManagerTests.cs create mode 100644 SteelSeriesAPI.Tests/VolumeSettingsManagerTests.cs create mode 100644 SteelSeriesAPI/Core/ISonarTransport.cs create mode 100644 SteelSeriesAPI/Sonar/Enums/Mode.cs create mode 100644 SteelSeriesAPI/Sonar/Managers/IModeManager.cs create mode 100644 SteelSeriesAPI/Sonar/Managers/ModeManager.cs diff --git a/SteelSeriesAPI.Sample/Program.cs b/SteelSeriesAPI.Sample/Program.cs index 1736619..b6acb69 100644 --- a/SteelSeriesAPI.Sample/Program.cs +++ b/SteelSeriesAPI.Sample/Program.cs @@ -1,5 +1,5 @@ -using Microsoft.Extensions.Logging; -using SteelSeriesAPI.Core; +using System.Diagnostics; +using Microsoft.Extensions.Logging; using SteelSeriesAPI.Sonar; using SteelSeriesAPI.Sonar.Enums; @@ -12,34 +12,45 @@ private static async Task Main() using var loggerFactory = LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Debug)); var logger = loggerFactory.CreateLogger("Sample"); - var discovery = new ServerDiscovery(logger); - using var client = new SonarHttpClient(discovery, logger); - using var sonar = new SonarClient(logger); - try - { - await sonar.VolumeSettings.SetVolumeAsync(Channel.Game, 0.5); - Console.WriteLine("Classic set: OK (hypothesis rejected!)"); - } - catch (SonarRequestException e) + // --- 1. ModeManager validation: read, switch, time the confirmation, switch back --- + var initialMode = await sonar.Mode.GetAsync(); + Console.WriteLine($"Current mode: {initialMode}"); + + var target = initialMode == Mode.Classic ? Mode.Streamer : Mode.Classic; + var sw = Stopwatch.StartNew(); + await sonar.Mode.SetAsync(target); + sw.Stop(); + Console.WriteLine($"Switched to {target}, confirmed in {sw.ElapsedMilliseconds} ms"); + + sw.Restart(); + await sonar.Mode.SetAsync(initialMode); + sw.Stop(); + Console.WriteLine($"Switched back to {initialMode}, confirmed in {sw.ElapsedMilliseconds} ms"); + + // --- 2. Route exploration sweep: V1 routes, do they still exist and what do they return? --- + string[] candidateRoutes = + [ + "chatMix", + "configs", + "audioDevices", + "classicRedirections", + "streamRedirections" + ]; + + foreach (string route in candidateRoutes) { - Console.WriteLine($"Classic set rejected: HTTP {e.StatusCode}, body: '{e.ResponseBody}'"); + Console.WriteLine($"\n=== GET {route} ==="); + try + { + using var doc = await sonar.GetRawAsync(route); + Console.WriteLine(doc.RootElement); + } + catch (Exception e) + { + Console.WriteLine($"FAILED: {e.GetType().Name} - {e.Message}"); + } } - -// 2. Streamer-route write should succeed - await sonar.VolumeSettings.SetVolumeAsync(Channel.Game, Mix.Personal, 0.42); - var check = await sonar.VolumeSettings.GetAsync(Channel.Game, Mix.Personal); - Console.WriteLine($"Streamer set check: {check}"); - -// 3. Raw dumps for the test fixtures - using var classic = await client.GetAsync("volumeSettings/classic/", default); - Console.WriteLine(classic.RootElement); - using var streamer = await client.GetAsync("volumeSettings/streamer/", default); - Console.WriteLine(streamer.RootElement); - -// 4. Bonus: what does the mode route say? (route from your V1) - using var mode = await client.GetAsync("mode/", default); - Console.WriteLine($"Mode: {mode.RootElement}"); } } \ 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/VolumeSettingsManagerTests.cs b/SteelSeriesAPI.Tests/VolumeSettingsManagerTests.cs new file mode 100644 index 0000000..3d76730 --- /dev/null +++ b/SteelSeriesAPI.Tests/VolumeSettingsManagerTests.cs @@ -0,0 +1,99 @@ +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)); + } + + [Fact] + public async Task SetVolumeAsync_OutOfRange_Throws() + { + var manager = new VolumeSettingsManager(new FakeTransport()); + + await Assert.ThrowsAsync( + () => manager.SetVolumeAsync(Channel.Game, 1.5)); + } + + [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/SonarExceptions.cs b/SteelSeriesAPI/Core/SonarExceptions.cs index 4dfc73d..2a42721 100644 --- a/SteelSeriesAPI/Core/SonarExceptions.cs +++ b/SteelSeriesAPI/Core/SonarExceptions.cs @@ -64,4 +64,17 @@ public SonarRequestException(string route, int statusCode, string? 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 index fa134f9..2f8a861 100644 --- a/SteelSeriesAPI/Core/SonarHttpClient.cs +++ b/SteelSeriesAPI/Core/SonarHttpClient.cs @@ -8,7 +8,7 @@ namespace SteelSeriesAPI.Core; /// Resilient HTTP client for the Sonar web server. /// Caches the server address and transparently rediscovers it when GG restarts. /// -public class SonarHttpClient : IDisposable +public class SonarHttpClient : IDisposable, ISonarTransport { private readonly HttpClient _http; private readonly ServerDiscovery _discovery; @@ -74,9 +74,11 @@ private async Task SendAsync( if (!response.IsSuccessStatusCode) { - // Protocol-level failure: the server received the request and rejected it. - // Rediscovery would not help; surface the error with as much context as possible. 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); } diff --git a/SteelSeriesAPI/Sonar/Enums/Mode.cs b/SteelSeriesAPI/Sonar/Enums/Mode.cs new file mode 100644 index 0000000..d7888d0 --- /dev/null +++ b/SteelSeriesAPI/Sonar/Enums/Mode.cs @@ -0,0 +1,27 @@ +namespace SteelSeriesAPI.Sonar.Enums; + +/// The operating mode of the Sonar mixer. +public enum Mode +{ + /// Classic mode: a single output mix with one volume per channel. + Classic, + + /// Streamer mode: two independent output mixes (personal and stream). + Streamer +} + +/// Mapping helpers between values and Sonar API identifiers. +public static class ModeExtensions +{ + /// Gets the identifier used for this mode by the Sonar API. + public static string ToApiValue(this Mode mode) => + mode == Mode.Streamer ? "stream" : "classic"; + + /// Resolves a Sonar API identifier back to a , or null if unknown. + public static Mode? FromApiValue(string value) => value switch + { + "classic" => Mode.Classic, + "stream" => Mode.Streamer, + _ => null + }; +} \ 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/ModeManager.cs b/SteelSeriesAPI/Sonar/Managers/ModeManager.cs new file mode 100644 index 0000000..8074e0b --- /dev/null +++ b/SteelSeriesAPI/Sonar/Managers/ModeManager.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using SteelSeriesAPI.Core; +using SteelSeriesAPI.Sonar.Enums; + +namespace SteelSeriesAPI.Sonar.Managers; + +/// +internal sealed class ModeManager : IModeManager +{ + private readonly ISonarTransport _transport; + + internal ModeManager(ISonarTransport transport) => _transport = transport; + + /// + public async Task GetAsync(CancellationToken ct = default) + { + 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 async Task SetAsync(Mode mode, CancellationToken ct = default) + { + await _transport.PutAsync(SonarRoutes.SetMode(mode), ct); + + // Mode switching is not instantaneous server-side (the V1 library used a + // blind 100ms sleep here). Poll until Sonar confirms, with a bounded budget. + for (int attempt = 0; attempt < 20; attempt++) + { + if (await GetAsync(ct) == mode) return; + await Task.Delay(50, ct); + } + + throw new SonarResponseException( + $"Sonar did not confirm the switch to mode '{mode}' within 1 second."); + } +} \ No newline at end of file diff --git a/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs b/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs index b592122..e2a6774 100644 --- a/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs +++ b/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs @@ -8,14 +8,14 @@ namespace SteelSeriesAPI.Sonar.Managers; /// internal sealed class VolumeSettingsManager : IVolumeSettingsManager { - private readonly SonarHttpClient _client; + private readonly ISonarTransport _transport; - internal VolumeSettingsManager(SonarHttpClient client) => _client = client; + internal VolumeSettingsManager(ISonarTransport transport) => _transport = transport; /// public async Task GetAsync(Channel channel, CancellationToken ct = default) { - using var doc = await _client.GetAsync(SonarRoutes.ClassicVolumes, ct); + using var doc = await _transport.GetAsync(SonarRoutes.ClassicVolumes, ct); // Master lives under "masters", other channels under "devices/{key}". JsonElement node = channel == Channel.Master @@ -28,7 +28,7 @@ public async Task GetAsync(Channel channel, CancellationToken ct /// public async Task GetAsync(Channel channel, Mix mix, CancellationToken ct = default) { - using var doc = await _client.GetAsync(SonarRoutes.StreamerVolumes, ct); + using var doc = await _transport.GetAsync(SonarRoutes.StreamerVolumes, ct); JsonElement node = channel == Channel.Master ? doc.RootElement.Dig("masters", "stream", mix.ToJsonKey()) @@ -41,23 +41,23 @@ public async Task GetAsync(Channel channel, Mix mix, Cancellation public Task SetVolumeAsync(Channel channel, double volume, CancellationToken ct = default) { ValidateVolume(volume); - return _client.PutAsync(SonarRoutes.SetClassicVolume(channel, volume), ct); + return _transport.PutAsync(SonarRoutes.SetClassicVolume(channel, volume), ct); } /// public Task SetVolumeAsync(Channel channel, Mix mix, double volume, CancellationToken ct = default) { ValidateVolume(volume); - return _client.PutAsync(SonarRoutes.SetStreamerVolume(mix, channel, volume), ct); + return _transport.PutAsync(SonarRoutes.SetStreamerVolume(mix, channel, volume), ct); } /// public Task SetMuteAsync(Channel channel, bool muted, CancellationToken ct = default) => - _client.PutAsync(SonarRoutes.SetClassicMute(channel, muted), ct); + _transport.PutAsync(SonarRoutes.SetClassicMute(channel, muted), ct); /// public Task SetMuteAsync(Channel channel, Mix mix, bool muted, CancellationToken ct = default) => - _client.PutAsync(SonarRoutes.SetStreamerMute(mix, channel, muted), ct); + _transport.PutAsync(SonarRoutes.SetStreamerMute(mix, channel, muted), ct); private static VolumeSetting ParseSetting(JsonElement node) { diff --git a/SteelSeriesAPI/Sonar/SonarClient.cs b/SteelSeriesAPI/Sonar/SonarClient.cs index f930f9c..50882de 100644 --- a/SteelSeriesAPI/Sonar/SonarClient.cs +++ b/SteelSeriesAPI/Sonar/SonarClient.cs @@ -12,6 +12,9 @@ namespace SteelSeriesAPI.Sonar; public sealed class SonarClient : IDisposable { private readonly SonarHttpClient _httpClient; + + /// Reads and switches the Sonar mixer mode. + public IModeManager Mode { get; } /// Controls the volume and mute state of Sonar channels. public IVolumeSettingsManager VolumeSettings { get; } @@ -23,11 +26,9 @@ public SonarClient(ILogger? logger = null) var discovery = new ServerDiscovery(logger); _httpClient = new SonarHttpClient(discovery, logger); + Mode = new ModeManager(_httpClient); VolumeSettings = new VolumeSettingsManager(_httpClient); } - - /// - public void Dispose() => _httpClient.Dispose(); /// /// Sends a GET request to an arbitrary Sonar route and returns the raw JSON response. @@ -37,5 +38,7 @@ public SonarClient(ILogger? logger = null) /// A token to cancel the operation. public Task GetRawAsync(string route, CancellationToken ct = default) => _httpClient.GetAsync(route, ct); - + + /// + public void Dispose() => _httpClient.Dispose(); } \ No newline at end of file diff --git a/SteelSeriesAPI/Sonar/SonarRoutes.cs b/SteelSeriesAPI/Sonar/SonarRoutes.cs index f510fce..0cb8533 100644 --- a/SteelSeriesAPI/Sonar/SonarRoutes.cs +++ b/SteelSeriesAPI/Sonar/SonarRoutes.cs @@ -9,6 +9,9 @@ namespace SteelSeriesAPI.Sonar; /// 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/"; @@ -19,6 +22,8 @@ internal static class SonarRoutes // in classic routes, "volume"/"isMuted" lowercase in streamer routes). // Verified against GG on 2026-08-04. + internal static string SetMode(Mode mode) => $"mode/{mode.ToApiValue()}"; + internal static string SetClassicVolume(Channel channel, double volume) => $"volumeSettings/classic/{channel.ToRouteKey()}/Volume/{Format(volume)}"; From 825e0a2123b4c279c1a8464e3c1d385442732339 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Sat, 8 Aug 2026 04:15:19 +0200 Subject: [PATCH 06/26] Fix mode interval detection --- SteelSeriesAPI/Sonar/Managers/ModeManager.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/SteelSeriesAPI/Sonar/Managers/ModeManager.cs b/SteelSeriesAPI/Sonar/Managers/ModeManager.cs index 8074e0b..4ecef34 100644 --- a/SteelSeriesAPI/Sonar/Managers/ModeManager.cs +++ b/SteelSeriesAPI/Sonar/Managers/ModeManager.cs @@ -29,15 +29,15 @@ public async Task SetAsync(Mode mode, CancellationToken ct = default) { await _transport.PutAsync(SonarRoutes.SetMode(mode), ct); - // Mode switching is not instantaneous server-side (the V1 library used a - // blind 100ms sleep here). Poll until Sonar confirms, with a bounded budget. - for (int attempt = 0; attempt < 20; attempt++) + // 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(50, ct); + await Task.Delay(100, ct); } throw new SonarResponseException( - $"Sonar did not confirm the switch to mode '{mode}' within 1 second."); + $"Sonar did not confirm the switch to mode '{mode}' within 5 seconds."); } } \ No newline at end of file From b3cd6c38828b0cd35fd83c6304bc80a57087a7d3 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Sat, 8 Aug 2026 04:15:34 +0200 Subject: [PATCH 07/26] Add github Ci --- .github/workflows/ci.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2ef7781 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +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: 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 From 1b9857a545504bc9276703005656ab309ba595ff Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Sat, 8 Aug 2026 05:20:57 +0200 Subject: [PATCH 08/26] Add Api Explorer Project to help discover routes --- SteelSeries-NET-API.sln | 6 + SteelSeriesAPI.Explorer/Program.cs | 108 ++++++++++++++++++ .../SteelSeriesAPI.Explorer.csproj | 14 +++ SteelSeriesAPI/Core/SonarHttpClient.cs | 8 ++ SteelSeriesAPI/Sonar/SonarClient.cs | 14 +++ 5 files changed, 150 insertions(+) create mode 100644 SteelSeriesAPI.Explorer/Program.cs create mode 100644 SteelSeriesAPI.Explorer/SteelSeriesAPI.Explorer.csproj diff --git a/SteelSeries-NET-API.sln b/SteelSeries-NET-API.sln index f0374bb..f099c3d 100644 --- a/SteelSeries-NET-API.sln +++ b/SteelSeries-NET-API.sln @@ -6,6 +6,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SteelSeriesAPI.Sample", "St EndProject 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 Debug|Any CPU = Debug|Any CPU @@ -24,5 +26,9 @@ Global {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..801fce1 --- /dev/null +++ b/SteelSeriesAPI.Explorer/Program.cs @@ -0,0 +1,108 @@ +using System.Text.Json; +using SteelSeriesAPI.Core; +using SteelSeriesAPI.Sonar; + +namespace SteelSeriesAPI.Explorer; + +/// Interactive exploration tool for the Sonar API. Not shipped with the library. +internal static class Program +{ + private static readonly string[] KnownGetRoutes = + [ + "mode", + "volumeSettings/classic/", + "volumeSettings/streamer/", + "audioDevices", + "classicRedirections", + "streamRedirections", + "configs" + ]; + + private static async Task Main() + { + using var sonar = new SonarClient(); + Console.WriteLine($"Sonar server: {await sonar.GetServerAddressAsync()}"); + Console.WriteLine("Sonar API Explorer - commands: get | put | probe ... | dump | quit"); + + while (true) + { + Console.Write("\n> "); + string[] input = (Console.ReadLine() ?? "").Trim().Split(' ', StringSplitOptions.RemoveEmptyEntries); + if (input.Length == 0) continue; + + try + { + switch (input[0].ToLowerInvariant()) + { + case "quit" or "q": + return; + + case "get" when input.Length == 2: + using (var doc = await sonar.GetRawAsync(input[1])) + Console.WriteLine(Pretty(doc)); + 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,-40} -> {await Probe(sonar, route)}"); + break; + + case "dump": + await Dump(sonar); + break; + + default: + Console.WriteLine("Unknown command."); + break; + } + } + catch (Exception e) + { + Console.WriteLine($"{e.GetType().Name}: {e.Message}"); + } + } + } + + /// 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("dumps", DateTime.Now.ToString("yyyy-MM-dd_HHmm")); + Directory.CreateDirectory(dir); + + foreach (string route in KnownGetRoutes) + { + string file = Path.Combine(dir, route.Trim('/').Replace('/', '_') + ".json"); + try + { + using var doc = await sonar.GetRawAsync(route); + await File.WriteAllTextAsync(file, Pretty(doc)); + Console.WriteLine($" {route,-30} -> {file}"); + } + catch (Exception e) + { + Console.WriteLine($" {route,-30} -> FAILED: {e.Message}"); + } + } + } + + private static string Pretty(JsonDocument doc) => + JsonSerializer.Serialize(doc.RootElement, 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/Core/SonarHttpClient.cs b/SteelSeriesAPI/Core/SonarHttpClient.cs index 2f8a861..fd067df 100644 --- a/SteelSeriesAPI/Core/SonarHttpClient.cs +++ b/SteelSeriesAPI/Core/SonarHttpClient.cs @@ -100,6 +100,14 @@ private async Task GetBaseAddressAsync(CancellationToken ct) _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); private void InvalidateAddress() => _baseAddress = null; diff --git a/SteelSeriesAPI/Sonar/SonarClient.cs b/SteelSeriesAPI/Sonar/SonarClient.cs index 50882de..c312981 100644 --- a/SteelSeriesAPI/Sonar/SonarClient.cs +++ b/SteelSeriesAPI/Sonar/SonarClient.cs @@ -38,6 +38,20 @@ public SonarClient(ILogger? logger = null) /// 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() => _httpClient.Dispose(); From 7e4d3467bb5f106f5ebfea860a30ddd9befd4f4d Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Sat, 8 Aug 2026 05:35:57 +0200 Subject: [PATCH 09/26] Add ChatMix support + tests --- SteelSeriesAPI.Tests/ChatMixManagerTests.cs | 78 +++++++++++++++++++ .../VolumeSettingsManagerTests.cs | 13 ++-- .../Sonar/Managers/ChatMixManager.cs | 40 ++++++++++ .../Sonar/Managers/IChatMixManager.cs | 17 ++++ .../Sonar/Managers/VolumeSettingsManager.cs | 2 +- SteelSeriesAPI/Sonar/Models/ChatMixSetting.cs | 11 +++ SteelSeriesAPI/Sonar/SonarClient.cs | 4 + SteelSeriesAPI/Sonar/SonarRoutes.cs | 7 ++ 8 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 SteelSeriesAPI.Tests/ChatMixManagerTests.cs create mode 100644 SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs create mode 100644 SteelSeriesAPI/Sonar/Managers/IChatMixManager.cs create mode 100644 SteelSeriesAPI/Sonar/Models/ChatMixSetting.cs 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/VolumeSettingsManagerTests.cs b/SteelSeriesAPI.Tests/VolumeSettingsManagerTests.cs index 3d76730..87d551e 100644 --- a/SteelSeriesAPI.Tests/VolumeSettingsManagerTests.cs +++ b/SteelSeriesAPI.Tests/VolumeSettingsManagerTests.cs @@ -77,13 +77,16 @@ public async Task SetMuteAsync_BuildsLowercaseBooleanRoute() Assert.Equal("volumeSettings/classic/chatRender/Mute/true", Assert.Single(transport.PutRoutes)); } - [Fact] - public async Task SetVolumeAsync_OutOfRange_Throws() + [Theory] + [InlineData(1.5)] + [InlineData(double.NaN)] + public async Task SetVolumeAsync_OutOfRange_Throws(double invalid) { - var manager = new VolumeSettingsManager(new FakeTransport()); + var transport = new FakeTransport(); + var manager = new VolumeSettingsManager(transport); - await Assert.ThrowsAsync( - () => manager.SetVolumeAsync(Channel.Game, 1.5)); + await Assert.ThrowsAsync(() => manager.SetVolumeAsync(Channel.Game, invalid)); + Assert.Empty(transport.PutRoutes); // validation must happen BEFORE any HTTP call } [Fact] diff --git a/SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs b/SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs new file mode 100644 index 0000000..cfad07a --- /dev/null +++ b/SteelSeriesAPI/Sonar/Managers/ChatMixManager.cs @@ -0,0 +1,40 @@ +using System.Text.Json; +using SteelSeriesAPI.Core; +using SteelSeriesAPI.Sonar.Models; + +namespace SteelSeriesAPI.Sonar.Managers; + +/// +internal sealed class ChatMixManager : IChatMixManager +{ + private readonly ISonarTransport _transport; + + internal ChatMixManager(ISonarTransport transport) => _transport = transport; + + /// + public async Task GetAsync(CancellationToken ct = default) + { + 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 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."); + + return _transport.PutAsync(SonarRoutes.SetChatMix(balance), ct); + } +} \ 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/VolumeSettingsManager.cs b/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs index e2a6774..5448069 100644 --- a/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs +++ b/SteelSeriesAPI/Sonar/Managers/VolumeSettingsManager.cs @@ -73,7 +73,7 @@ private static VolumeSetting ParseSetting(JsonElement node) private static void ValidateVolume(double volume) { - if (volume is < 0.0 or > 1.0) + 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."); } 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/SonarClient.cs b/SteelSeriesAPI/Sonar/SonarClient.cs index c312981..a2b4f54 100644 --- a/SteelSeriesAPI/Sonar/SonarClient.cs +++ b/SteelSeriesAPI/Sonar/SonarClient.cs @@ -18,6 +18,9 @@ public sealed class SonarClient : IDisposable /// Controls the volume and mute state of Sonar channels. public IVolumeSettingsManager VolumeSettings { get; } + + /// Controls the ChatMix balance + public IChatMixManager ChatMix { get; } /// Creates a new Sonar client. /// Optional logger for diagnostics. When null, the library stays silent. @@ -28,6 +31,7 @@ public SonarClient(ILogger? logger = null) Mode = new ModeManager(_httpClient); VolumeSettings = new VolumeSettingsManager(_httpClient); + ChatMix = new ChatMixManager(_httpClient); } /// diff --git a/SteelSeriesAPI/Sonar/SonarRoutes.cs b/SteelSeriesAPI/Sonar/SonarRoutes.cs index 0cb8533..7187b99 100644 --- a/SteelSeriesAPI/Sonar/SonarRoutes.cs +++ b/SteelSeriesAPI/Sonar/SonarRoutes.cs @@ -17,6 +17,10 @@ internal static class SonarRoutes /// Volume/mute state of all channels/mixes in streamer mode. 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"; // Note: the Sonar API is inconsistent by design ("Volume"/"Mute" capitalized // in classic routes, "volume"/"isMuted" lowercase in streamer routes). @@ -36,6 +40,9 @@ internal static string SetStreamerVolume(Mix mix, Channel channel, double 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)}"; + private static string Format(double value) => value.ToString("0.00", CultureInfo.InvariantCulture); From 615863e14c844af4f13cf11040de41736b27e9c3 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Sun, 16 Aug 2026 22:44:23 +0200 Subject: [PATCH 10/26] feat(events): add SonarEventListener with WebSocket stream and mode-aware volume polling - Real-time events from Sonar's /sock WebSocket (chatmix, snapshots, invalidations) - Automatic reconnection with backoff; connection doubles as GG liveness detection - Opt-in polling with mode-aware diffing for volume/mute changes (not broadcast by Sonar) - Typed event records with Previous/New state and convenience accessors - Explorer: ws command; Sample: full state dump + event bench --- SteelSeriesAPI.Explorer/Program.cs | 59 ++- SteelSeriesAPI.Sample/Program.cs | 140 +++++-- .../SonarEventListenerTests.cs | 234 +++++++++++ SteelSeriesAPI/Core/SonarHttpClient.cs | 2 +- SteelSeriesAPI/Sonar/Events/ModeChange.cs | 8 + .../Sonar/Events/SonarEventListener.cs | 382 ++++++++++++++++++ .../Sonar/Events/SonarEventNames.cs | 24 ++ SteelSeriesAPI/Sonar/Events/VolumeChange.cs | 36 ++ SteelSeriesAPI/Sonar/Events/VolumeSnapshot.cs | 19 + SteelSeriesAPI/Sonar/SonarClient.cs | 12 +- SteelSeriesAPI/Sonar/SonarRoutes.cs | 6 +- 11 files changed, 881 insertions(+), 41 deletions(-) create mode 100644 SteelSeriesAPI.Tests/SonarEventListenerTests.cs create mode 100644 SteelSeriesAPI/Sonar/Events/ModeChange.cs create mode 100644 SteelSeriesAPI/Sonar/Events/SonarEventListener.cs create mode 100644 SteelSeriesAPI/Sonar/Events/SonarEventNames.cs create mode 100644 SteelSeriesAPI/Sonar/Events/VolumeChange.cs create mode 100644 SteelSeriesAPI/Sonar/Events/VolumeSnapshot.cs diff --git a/SteelSeriesAPI.Explorer/Program.cs b/SteelSeriesAPI.Explorer/Program.cs index 801fce1..b4de9d1 100644 --- a/SteelSeriesAPI.Explorer/Program.cs +++ b/SteelSeriesAPI.Explorer/Program.cs @@ -1,4 +1,6 @@ -using System.Text.Json; +using System.Net.WebSockets; +using System.Text; +using System.Text.Json; using SteelSeriesAPI.Core; using SteelSeriesAPI.Sonar; @@ -22,7 +24,7 @@ private static async Task Main() { using var sonar = new SonarClient(); Console.WriteLine($"Sonar server: {await sonar.GetServerAddressAsync()}"); - Console.WriteLine("Sonar API Explorer - commands: get | put | probe ... | dump | quit"); + Console.WriteLine("Sonar API Explorer - commands: get | put | probe ... | dump | ws | quit"); while (true) { @@ -55,6 +57,13 @@ private static async Task Main() case "dump": await Dump(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."); @@ -102,6 +111,52 @@ private static async Task Dump(SonarClient sonar) } } } + + /// 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]; + var message = new MemoryStream(); + + try + { + while (ws.State == WebSocketState.Open) + { + var result = await ws.ReceiveAsync(buffer, cts.Token); + if (result.MessageType == WebSocketMessageType.Close) break; + + // A logical message may span several frames: accumulate until EndOfMessage + 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."); + } + } private static string Pretty(JsonDocument doc) => JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true }); diff --git a/SteelSeriesAPI.Sample/Program.cs b/SteelSeriesAPI.Sample/Program.cs index b6acb69..6e0b853 100644 --- a/SteelSeriesAPI.Sample/Program.cs +++ b/SteelSeriesAPI.Sample/Program.cs @@ -1,56 +1,124 @@ -using System.Diagnostics; -using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging; +using SteelSeriesAPI.Core; using SteelSeriesAPI.Sonar; using SteelSeriesAPI.Sonar.Enums; namespace SteelSeriesAPI.Sample; +/// +/// 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 { private static async Task Main() { - using var loggerFactory = LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Debug)); + // Set to LogLevel.Debug to see discovery, reconnections and polling internals + using var loggerFactory = LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Information)); var logger = loggerFactory.CreateLogger("Sample"); using var sonar = new SonarClient(logger); - // --- 1. ModeManager validation: read, switch, time the confirmation, switch back --- - var initialMode = await sonar.Mode.GetAsync(); - Console.WriteLine($"Current mode: {initialMode}"); - - var target = initialMode == Mode.Classic ? Mode.Streamer : Mode.Classic; - var sw = Stopwatch.StartNew(); - await sonar.Mode.SetAsync(target); - sw.Stop(); - Console.WriteLine($"Switched to {target}, confirmed in {sw.ElapsedMilliseconds} ms"); - - sw.Restart(); - await sonar.Mode.SetAsync(initialMode); - sw.Stop(); - Console.WriteLine($"Switched back to {initialMode}, confirmed in {sw.ElapsedMilliseconds} ms"); - - // --- 2. Route exploration sweep: V1 routes, do they still exist and what do they return? --- - string[] candidateRoutes = - [ - "chatMix", - "configs", - "audioDevices", - "classicRedirections", - "streamRedirections" - ]; - - foreach (string route in candidateRoutes) + try { - Console.WriteLine($"\n=== GET {route} ==="); - 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; + } + + SubscribeToEvents(sonar); + + sonar.Events.VolumePollingInterval = TimeSpan.FromMilliseconds(500); + sonar.Events.Start(); + + 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!"); + } + + /// Reads and prints the current mode, volumes, and chat mix using the typed managers. + private static async Task PrintCurrentStateAsync(SonarClient sonar) + { + Console.WriteLine("=== Current Sonar state ==="); + + // --- Mode --- + Mode mode = await sonar.Mode.GetAsync(); + Console.WriteLine($"Mode: {mode}"); + + // --- Volumes (query the channels relevant to 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) { - using var doc = await sonar.GetRawAsync(route); - Console.WriteLine(doc.RootElement); + var setting = await sonar.VolumeSettings.GetAsync(channel); + Console.WriteLine($" {channel,-6} volume: {setting.Volume,6:P0} muted: {setting.Muted}"); } - catch (Exception e) + } + else + { + foreach (Channel channel in channels) { - Console.WriteLine($"FAILED: {e.GetType().Name} - {e.Message}"); + 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})"); + } + + /// Subscribes to every event the library exposes, printing each occurrence. + private static void SubscribeToEvents(SonarClient sonar) + { + // --- 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 changes (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})"); + + // --- Invalidations (Sonar says "something changed" without details) --- + sonar.Events.RedirectionsInvalidated += (_, _) => + Console.WriteLine("[Redirections] changed (no details from Sonar)"); + + sonar.Events.SelectedConfigChanged += (_, _) => + Console.WriteLine("[Config] selected config changed"); + + // --- 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.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/Core/SonarHttpClient.cs b/SteelSeriesAPI/Core/SonarHttpClient.cs index fd067df..875604a 100644 --- a/SteelSeriesAPI/Core/SonarHttpClient.cs +++ b/SteelSeriesAPI/Core/SonarHttpClient.cs @@ -109,7 +109,7 @@ private async Task GetBaseAddressAsync(CancellationToken ct) public Task GetServerAddressAsync(CancellationToken ct = default) => GetBaseAddressAsync(ct); - private void InvalidateAddress() => _baseAddress = null; + internal void InvalidateAddress() => _baseAddress = null; /// Releases the underlying HTTP resources. public void Dispose() 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/SonarEventListener.cs b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs new file mode 100644 index 0000000..b8b7cd4 --- /dev/null +++ b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs @@ -0,0 +1,382 @@ +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.Enums; +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 a background thread. Volume changes made from the Sonar UI sliders are +/// NOT broadcast by Sonar (observed 2026-08-08): only fires on +/// connection and after major state changes such as a mode switch. +/// +public sealed class SonarEventListener : IDisposable +{ + private const string SocketPath = "/sock"; + + private readonly SonarHttpClient _httpClient; + private readonly ILogger _logger; + private CancellationTokenSource? _cts; + private Task? _runLoop; + + /// 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 Sonar pushes a full volume snapshot (on connection and after major changes). + public event EventHandler? VolumeDataReceived; + + /// Raised when redirections changed. Sonar sends no details: re-query if needed. + public event EventHandler? RedirectionsInvalidated; + + /// Raised when the selected config changed. Re-query the configs route if needed. + public event EventHandler? SelectedConfigChanged; + + /// Raised for any Sonar event not yet mapped to a typed event. + public event EventHandler? UnknownEventReceived; + + /// + /// When set before , the listener also polls the volume state at this + /// interval and raises on differences. Sonar does not broadcast + /// volume slider changes over its WebSocket (observed 2026-08-08), so polling is the only + /// admin-free way to detect them. Null (the default) disables polling. + /// + public TimeSpan? VolumePollingInterval { get; set; } + + /// Raised when polling detects a mixer mode change. Requires . + public event EventHandler? ModeChanged; + + /// Raised when polling detects a volume or mute change. Requires . + public event EventHandler? VolumeChanged; + + private Task? _pollLoop; + + internal SonarEventListener(SonarHttpClient httpClient, ILogger? logger = null) + { + _httpClient = httpClient; + _logger = logger ?? NullLogger.Instance; + } + + /// Starts listening in the background. Safe to call once; use to stop. + public void Start() + { + if (_runLoop is not null) + throw new InvalidOperationException("The event listener is already running."); + + _cts = new CancellationTokenSource(); + _runLoop = Task.Run(() => RunAsync(_cts.Token)); + + if (VolumePollingInterval is { } interval) + _pollLoop = Task.Run(() => RunPollingAsync(interval, _cts.Token)); + } + + /// Stops listening and waits for the background loop 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)); + 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: + RaiseSafely(() => RedirectionsInvalidated?.Invoke(this, EventArgs.Empty)); + break; + + case SonarEventNames.SelectedConfigUpdated: + RaiseSafely(() => SelectedConfigChanged?.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.ValueKind == JsonValueKind.Object && + data.TryGetProperty("state", out var s) && + s.ValueKind == JsonValueKind.String + ? s.GetString() : null; + + return new ChatMixSetting(balance, state); + } + + /// 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.TryGetProperty("muted", out var m) && + m.ValueKind == JsonValueKind.True; + return new VolumeSetting(volume, muted); + } + + /// + /// 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. + /// + 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; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } + catch (Exception ex) + { + _logger.LogDebug(ex, "Volume 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; + } + + /// Stops the listener without waiting. Prefer for a graceful stop. + public void Dispose() + { + _cts?.Cancel(); + _cts?.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..af409f9 --- /dev/null +++ b/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs @@ -0,0 +1,24 @@ +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"; + + // 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, + // SONAR_EVENT_ROUTING_DATA, SONAR_EVENT_STREAM_MONITORING_LOCK_STATUS_UPDATE +} \ 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/SonarClient.cs b/SteelSeriesAPI/Sonar/SonarClient.cs index a2b4f54..7a6b789 100644 --- a/SteelSeriesAPI/Sonar/SonarClient.cs +++ b/SteelSeriesAPI/Sonar/SonarClient.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Microsoft.Extensions.Logging; using SteelSeriesAPI.Core; +using SteelSeriesAPI.Sonar.Events; using SteelSeriesAPI.Sonar.Managers; namespace SteelSeriesAPI.Sonar; @@ -13,6 +14,9 @@ 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; } @@ -28,6 +32,8 @@ 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); @@ -58,5 +64,9 @@ public Task GetServerAddressAsync(CancellationToken ct = default) => _httpClient.GetServerAddressAsync(ct); /// - public void Dispose() => _httpClient.Dispose(); + public void Dispose() + { + Events.Dispose(); + _httpClient.Dispose(); + } } \ No newline at end of file diff --git a/SteelSeriesAPI/Sonar/SonarRoutes.cs b/SteelSeriesAPI/Sonar/SonarRoutes.cs index 7187b99..bf3618a 100644 --- a/SteelSeriesAPI/Sonar/SonarRoutes.cs +++ b/SteelSeriesAPI/Sonar/SonarRoutes.cs @@ -15,7 +15,11 @@ internal static class SonarRoutes /// Volume/mute state of all channels in classic mode. internal const string ClassicVolumes = "volumeSettings/classic/"; - /// Volume/mute state of all channels/mixes in streamer mode. + /// + /// 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). From f5b80c3e7722640d5681869b920164514681d0c3 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Sun, 23 Aug 2026 05:54:08 +0200 Subject: [PATCH 11/26] Added redirections management Also renamed VolumePollingInterval to PollingInterval --- SteelSeriesAPI.Sample/Program.cs | 14 +- .../RedirectionsManagerTests.cs | 153 +++++++++++++++ SteelSeriesAPI/Sonar/Enums/Channel.cs | 29 +++ .../Sonar/Events/RedirectionChanges.cs | 39 ++++ .../Sonar/Events/SonarEventListener.cs | 177 +++++++++++++++++- .../Sonar/Events/SonarEventNames.cs | 3 +- .../Sonar/Managers/IRedirectionsManager.cs | 34 ++++ .../Sonar/Managers/RedirectionsManager.cs | 149 +++++++++++++++ SteelSeriesAPI/Sonar/Models/Redirections.cs | 31 +++ SteelSeriesAPI/Sonar/SonarClient.cs | 4 + SteelSeriesAPI/Sonar/SonarRoutes.cs | 23 +++ 11 files changed, 644 insertions(+), 12 deletions(-) create mode 100644 SteelSeriesAPI.Tests/RedirectionsManagerTests.cs create mode 100644 SteelSeriesAPI/Sonar/Events/RedirectionChanges.cs create mode 100644 SteelSeriesAPI/Sonar/Managers/IRedirectionsManager.cs create mode 100644 SteelSeriesAPI/Sonar/Managers/RedirectionsManager.cs create mode 100644 SteelSeriesAPI/Sonar/Models/Redirections.cs diff --git a/SteelSeriesAPI.Sample/Program.cs b/SteelSeriesAPI.Sample/Program.cs index 6e0b853..e6c5f93 100644 --- a/SteelSeriesAPI.Sample/Program.cs +++ b/SteelSeriesAPI.Sample/Program.cs @@ -14,7 +14,7 @@ internal static class Program private static async Task Main() { // Set to LogLevel.Debug to see discovery, reconnections and polling internals - using var loggerFactory = LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Information)); + using var loggerFactory = LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Debug)); var logger = loggerFactory.CreateLogger("Sample"); using var sonar = new SonarClient(logger); @@ -32,7 +32,7 @@ private static async Task Main() SubscribeToEvents(sonar); - sonar.Events.VolumePollingInterval = TimeSpan.FromMilliseconds(500); + sonar.Events.PollingInterval = TimeSpan.FromMilliseconds(500); sonar.Events.Start(); Console.WriteLine(); @@ -109,7 +109,15 @@ private static void SubscribeToEvents(SonarClient sonar) // --- Invalidations (Sonar says "something changed" without details) --- sonar.Events.RedirectionsInvalidated += (_, _) => - Console.WriteLine("[Redirections] changed (no details from Sonar)"); + Console.WriteLine("[Invalidated] redirection invalidation received from Sonar"); + 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.SelectedConfigChanged += (_, _) => Console.WriteLine("[Config] selected config changed"); 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/Sonar/Enums/Channel.cs b/SteelSeriesAPI/Sonar/Enums/Channel.cs index 9360fb8..1c25ed7 100644 --- a/SteelSeriesAPI/Sonar/Enums/Channel.cs +++ b/SteelSeriesAPI/Sonar/Enums/Channel.cs @@ -39,12 +39,32 @@ public static class ChannelExtensions { Channel.Aux, "aux" }, { Channel.Mic, "chatCapture" } }; + + // 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() + { + { 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]; + + /// 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)); /// Resolves a Sonar JSON key back to a , or null if unknown. public static Channel? FromJsonKey(string key) @@ -54,4 +74,13 @@ public static class ChannelExtensions 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/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/SonarEventListener.cs b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs index b8b7cd4..9e77085 100644 --- a/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs +++ b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs @@ -46,30 +46,58 @@ public sealed class SonarEventListener : IDisposable /// Raised when the selected config changed. Re-query the configs route if needed. public event EventHandler? SelectedConfigChanged; + + /// 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; /// Raised for any Sonar event not yet mapped to a typed event. public event EventHandler? UnknownEventReceived; /// - /// When set before , the listener also polls the volume state at this - /// interval and raises on differences. Sonar does not broadcast - /// volume slider changes over its WebSocket (observed 2026-08-08), so polling is the only - /// admin-free way to detect them. Null (the default) disables polling. + /// 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, and redirection states (device routing, + /// mix toggles, stream monitoring). 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, devices, audio sessions...) will be raised. /// - public TimeSpan? VolumePollingInterval { get; set; } + public TimeSpan? PollingInterval { get; set; } - /// Raised when polling detects a mixer mode change. Requires . + /// Raised when polling detects a mixer mode change. Requires . public event EventHandler? ModeChanged; - /// Raised when polling detects a volume or mute change. Requires . + /// Raised when polling detects a volume or mute change. Requires . public event EventHandler? VolumeChanged; private Task? _pollLoop; + + private readonly RedirectionsManager _redirections; + private RedirectionsSnapshot? _redirectionsBaseline; + private int _redirectionRefreshVersion; + private CancellationToken _lifetime; + + /// The full redirection state used as a diffing baseline. + internal sealed record RedirectionsSnapshot( + IReadOnlyList Classic, + StreamRedirections Stream, + bool MonitoringEnabled); internal SonarEventListener(SonarHttpClient httpClient, ILogger? logger = null) { _httpClient = httpClient; _logger = logger ?? NullLogger.Instance; + + _redirections = new RedirectionsManager(httpClient); } /// Starts listening in the background. Safe to call once; use to stop. @@ -79,9 +107,10 @@ public void Start() throw new InvalidOperationException("The event listener is already running."); _cts = new CancellationTokenSource(); + _lifetime = _cts.Token; _runLoop = Task.Run(() => RunAsync(_cts.Token)); - if (VolumePollingInterval is { } interval) + if (PollingInterval is { } interval) _pollLoop = Task.Run(() => RunPollingAsync(interval, _cts.Token)); } @@ -122,6 +151,11 @@ private async Task RunAsync(CancellationToken ct) wasConnected = true; RaiseSafely(() => Connected?.Invoke(this, EventArgs.Empty)); + + // Seed the redirections baseline right away, so the very first user change + // after startup produces granular events instead of just creating the baseline. + ScheduleRedirectionRefresh(); + await ReceiveLoopAsync(ws, ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) @@ -194,7 +228,9 @@ private void Dispatch(string json) break; case SonarEventNames.RedirectionStatusUpdate: + case SonarEventNames.StreamMonitoringLockStatusUpdate: RaiseSafely(() => RedirectionsInvalidated?.Invoke(this, EventArgs.Empty)); + ScheduleRedirectionRefresh(); break; case SonarEventNames.SelectedConfigUpdated: @@ -293,6 +329,88 @@ private static VolumeSetting ParseSetting(JsonElement node) return new VolumeSetting(volume, muted); } + private readonly SemaphoreSlim _redirectionsRefreshLock = new(1, 1); + + /// + /// Schedules a redirection refresh 250ms from now. Invalidations arrive in bursts: + /// each call supersedes the previous one, so only the last of a burst actually fetches. + /// + private void ScheduleRedirectionRefresh() + { + int version = Interlocked.Increment(ref _redirectionRefreshVersion); + CancellationToken ct = _lifetime; + _logger.LogDebug("Redirection refresh #{Version} scheduled", version); + + _ = Task.Run(async () => + { + try + { + await Task.Delay(250, ct); + if (version != _redirectionRefreshVersion) + { + _logger.LogDebug("Redirection refresh #{Version} superseded", version); + return; + } + + await RefreshRedirectionsAsync(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, "Redirection refresh #{Version} failed", version); + } + }, ct); + } + + /// Fetches the full redirection state, diffs it against the baseline, and raises granular events. + private async Task RefreshRedirectionsAsync(CancellationToken ct) + { + await _redirectionsRefreshLock.WaitAsync(ct); + try + { + 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; + } + finally + { + _redirectionsRefreshLock.Release(); + } + } + /// /// 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 @@ -336,6 +454,11 @@ private async Task RunPollingAsync(TimeSpan interval, CancellationToken ct) baseline = snapshot; baselineMode = mode; + + // Redirections: UI-initiated toggles are not broadcast by Sonar (same rule as + // volume sliders), so we refresh them on the polling cadence too. The lock + // makes this safe alongside invalidation-triggered refreshes. + await RefreshRedirectionsAsync(ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) @@ -372,11 +495,49 @@ internal static IEnumerable Diff(VolumeSnapshot previous, VolumeSn static bool Changed(VolumeSetting? previous, VolumeSetting? current) => previous is not null && current is not null && previous != current; } + + /// 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); + } /// Stops the listener without waiting. Prefer for a graceful stop. public void Dispose() { _cts?.Cancel(); _cts?.Dispose(); + _redirectionsRefreshLock.Dispose(); } } \ No newline at end of file diff --git a/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs b/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs index af409f9..f5d805a 100644 --- a/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs +++ b/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs @@ -15,10 +15,11 @@ internal static class SonarEventNames 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"; // 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, - // SONAR_EVENT_ROUTING_DATA, SONAR_EVENT_STREAM_MONITORING_LOCK_STATUS_UPDATE + // SONAR_EVENT_ROUTING_DATA } \ 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/RedirectionsManager.cs b/SteelSeriesAPI/Sonar/Managers/RedirectionsManager.cs new file mode 100644 index 0000000..2bacaed --- /dev/null +++ b/SteelSeriesAPI/Sonar/Managers/RedirectionsManager.cs @@ -0,0 +1,149 @@ +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, + GetString(entry, "deviceId") ?? "", + GetBool(entry, "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 = GetString(entry, "streamRedirectionId"); + string deviceId = GetString(entry, "deviceId") ?? ""; + bool isRunning = GetBool(entry, "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 = GetString(role, "role") is { } r + ? ChannelExtensions.FromJsonKey(r) + : null; + if (channel is null) continue; + + enabled[channel.Value] = GetBool(role, "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); + } + + private static string? GetString(JsonElement obj, string name) => + obj.ValueKind == JsonValueKind.Object && + obj.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String + ? p.GetString() : null; + + private static bool GetBool(JsonElement obj, string name) => + obj.ValueKind == JsonValueKind.Object && + obj.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.True; +} \ 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/SonarClient.cs b/SteelSeriesAPI/Sonar/SonarClient.cs index 7a6b789..b58dcdf 100644 --- a/SteelSeriesAPI/Sonar/SonarClient.cs +++ b/SteelSeriesAPI/Sonar/SonarClient.cs @@ -25,6 +25,9 @@ public sealed class SonarClient : IDisposable /// Controls the ChatMix balance public IChatMixManager ChatMix { get; } + + /// Controls the differents Redirections. + public IRedirectionsManager Redirections { get; } /// Creates a new Sonar client. /// Optional logger for diagnostics. When null, the library stays silent. @@ -38,6 +41,7 @@ public SonarClient(ILogger? logger = null) Mode = new ModeManager(_httpClient); VolumeSettings = new VolumeSettingsManager(_httpClient); ChatMix = new ChatMixManager(_httpClient); + Redirections = new RedirectionsManager(_httpClient); } /// diff --git a/SteelSeriesAPI/Sonar/SonarRoutes.cs b/SteelSeriesAPI/Sonar/SonarRoutes.cs index bf3618a..47fff2a 100644 --- a/SteelSeriesAPI/Sonar/SonarRoutes.cs +++ b/SteelSeriesAPI/Sonar/SonarRoutes.cs @@ -25,6 +25,17 @@ internal static class SonarRoutes /// 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"; + // Note: the Sonar API is inconsistent by design ("Volume"/"Mute" capitalized // in classic routes, "volume"/"isMuted" lowercase in streamer routes). @@ -46,6 +57,18 @@ internal static string SetStreamerMute(Mix mix, Channel channel, 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)}"; private static string Format(double value) => value.ToString("0.00", CultureInfo.InvariantCulture); From b0ff883b6990819c3ffb3f419ce664bcc4e0ea21 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Tue, 25 Aug 2026 20:34:54 +0200 Subject: [PATCH 12/26] Add Config controls --- SteelSeriesAPI.Sample/Program.cs | 7 +- SteelSeriesAPI.Tests/ConfigManagerTests.cs | 172 ++++++++++++++++++ SteelSeriesAPI/Core/JsonExtensions.cs | 20 ++ .../Sonar/Events/ConfigSelectionChange.cs | 14 ++ .../Sonar/Events/SonarEventListener.cs | 94 ++++++++-- .../Sonar/Managers/ConfigManager.cs | 81 +++++++++ .../Sonar/Managers/IConfigManager.cs | 31 ++++ .../Sonar/Managers/RedirectionsManager.cs | 23 +-- SteelSeriesAPI/Sonar/Models/SonarConfig.cs | 14 ++ SteelSeriesAPI/Sonar/SonarClient.cs | 6 +- SteelSeriesAPI/Sonar/SonarRoutes.cs | 10 + 11 files changed, 438 insertions(+), 34 deletions(-) create mode 100644 SteelSeriesAPI.Tests/ConfigManagerTests.cs create mode 100644 SteelSeriesAPI/Sonar/Events/ConfigSelectionChange.cs create mode 100644 SteelSeriesAPI/Sonar/Managers/ConfigManager.cs create mode 100644 SteelSeriesAPI/Sonar/Managers/IConfigManager.cs create mode 100644 SteelSeriesAPI/Sonar/Models/SonarConfig.cs diff --git a/SteelSeriesAPI.Sample/Program.cs b/SteelSeriesAPI.Sample/Program.cs index e6c5f93..2668904 100644 --- a/SteelSeriesAPI.Sample/Program.cs +++ b/SteelSeriesAPI.Sample/Program.cs @@ -34,7 +34,7 @@ private static async Task Main() sonar.Events.PollingInterval = TimeSpan.FromMilliseconds(500); sonar.Events.Start(); - + Console.WriteLine(); Console.WriteLine("=== Listening to Sonar events - interact with the Sonar UI, press Enter to stop ==="); Console.WriteLine(); @@ -119,8 +119,11 @@ private static void SubscribeToEvents(SonarClient sonar) sonar.Events.StreamMonitoringChanged += (_, e) => Console.WriteLine($"[Monitoring] {(e.IsEnabled ? "hearing what the audience hears" : "back to personal mix")}"); - sonar.Events.SelectedConfigChanged += (_, _) => + sonar.Events.ConfigsInvalidated += (_, _) => Console.WriteLine("[Config] selected config changed"); + + sonar.Events.ConfigSelectionChanged += (_, e) => + Console.WriteLine($"[Config] {e.Channel}: {e.PreviousConfig?.Name ?? "?"} -> {e.NewConfigName}"); // --- Low-level / diagnostics --- sonar.Events.VolumeDataReceived += (_, e) => 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/Core/JsonExtensions.cs b/SteelSeriesAPI/Core/JsonExtensions.cs index 67c4aec..d09b360 100644 --- a/SteelSeriesAPI/Core/JsonExtensions.cs +++ b/SteelSeriesAPI/Core/JsonExtensions.cs @@ -23,4 +23,24 @@ internal static JsonElement Dig(this JsonElement element, params string[] path) } 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/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/SonarEventListener.cs b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs index 9e77085..325bf9d 100644 --- a/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs +++ b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs @@ -44,20 +44,12 @@ public sealed class SonarEventListener : IDisposable /// Raised when redirections changed. Sonar sends no details: re-query if needed. public event EventHandler? RedirectionsInvalidated; - /// Raised when the selected config changed. Re-query the configs route if needed. - public event EventHandler? SelectedConfigChanged; - - /// 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; + /// + /// 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 for any Sonar event not yet mapped to a typed event. public event EventHandler? UnknownEventReceived; @@ -78,6 +70,21 @@ public sealed class SonarEventListener : IDisposable /// Raised when polling detects a volume or mute change. Requires . public event EventHandler? VolumeChanged; + + /// 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; + + /// Raised when the selected config of a channel changes. + public event EventHandler? ConfigSelectionChanged; private Task? _pollLoop; @@ -91,6 +98,11 @@ internal sealed record RedirectionsSnapshot( IReadOnlyList Classic, StreamRedirections Stream, bool MonitoringEnabled); + + private readonly ConfigManager _configs; + private IReadOnlyDictionary? _selectedConfigsBaseline; + private int _configRefreshVersion; + private readonly SemaphoreSlim _configsRefreshLock = new(1, 1); internal SonarEventListener(SonarHttpClient httpClient, ILogger? logger = null) { @@ -98,6 +110,7 @@ internal SonarEventListener(SonarHttpClient httpClient, ILogger? logger = null) _logger = logger ?? NullLogger.Instance; _redirections = new RedirectionsManager(httpClient); + _configs = new ConfigManager(httpClient); } /// Starts listening in the background. Safe to call once; use to stop. @@ -124,6 +137,7 @@ public async Task StopAsync() catch (OperationCanceledException) { /* expected */ } _cts.Dispose(); + _configsRefreshLock.Dispose(); _cts = null; _runLoop = null; _pollLoop = null; @@ -155,6 +169,7 @@ private async Task RunAsync(CancellationToken ct) // Seed the redirections baseline right away, so the very first user change // after startup produces granular events instead of just creating the baseline. ScheduleRedirectionRefresh(); + ScheduleConfigRefresh(); await ReceiveLoopAsync(ws, ct); } @@ -234,7 +249,8 @@ private void Dispatch(string json) break; case SonarEventNames.SelectedConfigUpdated: - RaiseSafely(() => SelectedConfigChanged?.Invoke(this, EventArgs.Empty)); + RaiseSafely(() => ConfigsInvalidated?.Invoke(this, EventArgs.Empty)); + ScheduleConfigRefresh(); break; default: @@ -411,6 +427,53 @@ await _redirections.GetStreamRedirectionsAsync(ct), } } + private void ScheduleConfigRefresh() + { + int version = Interlocked.Increment(ref _configRefreshVersion); + CancellationToken ct = _lifetime; + + _ = Task.Run(async () => + { + try + { + await Task.Delay(250, ct); + if (version != _configRefreshVersion) return; + await RefreshSelectedConfigsAsync(ct); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) { } + catch (Exception ex) + { + _logger.LogWarning(ex, "Config selection refresh failed"); + } + }, ct); + } + + private async Task RefreshSelectedConfigsAsync(CancellationToken ct) + { + await _configsRefreshLock.WaitAsync(ct); + try + { + 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; + } + finally + { + _configsRefreshLock.Release(); + } + } + /// /// 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 @@ -459,6 +522,7 @@ private async Task RunPollingAsync(TimeSpan interval, CancellationToken ct) // volume sliders), so we refresh them on the polling cadence too. The lock // makes this safe alongside invalidation-triggered refreshes. await RefreshRedirectionsAsync(ct); + await RefreshSelectedConfigsAsync(ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) 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/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/RedirectionsManager.cs b/SteelSeriesAPI/Sonar/Managers/RedirectionsManager.cs index 2bacaed..f398f0d 100644 --- a/SteelSeriesAPI/Sonar/Managers/RedirectionsManager.cs +++ b/SteelSeriesAPI/Sonar/Managers/RedirectionsManager.cs @@ -83,8 +83,8 @@ internal static IReadOnlyList ParseClassicRedirections(JsonE result.Add(new ClassicRedirection( channel.Value, - GetString(entry, "deviceId") ?? "", - GetBool(entry, "isRunning"))); + entry.GetStringOrNull("deviceId") ?? "", + entry.GetBoolOrFalse("isRunning"))); } return result; @@ -103,9 +103,9 @@ internal static StreamRedirections ParseStreamRedirections(JsonElement root) { if (entry.ValueKind != JsonValueKind.Object) continue; - string? id = GetString(entry, "streamRedirectionId"); - string deviceId = GetString(entry, "deviceId") ?? ""; - bool isRunning = GetBool(entry, "isRunning"); + string? id = entry.GetStringOrNull("streamRedirectionId"); + string deviceId = entry.GetStringOrNull("deviceId") ?? ""; + bool isRunning = entry.GetBoolOrFalse("isRunning"); if (string.Equals(id, "mic", StringComparison.OrdinalIgnoreCase)) { @@ -121,12 +121,12 @@ internal static StreamRedirections ParseStreamRedirections(JsonElement root) { foreach (var role in status.EnumerateArray()) { - Channel? channel = GetString(role, "role") is { } r + Channel? channel = role.GetStringOrNull("role") is { } r ? ChannelExtensions.FromJsonKey(r) : null; if (channel is null) continue; - enabled[channel.Value] = GetBool(role, "isEnabled"); + enabled[channel.Value] = role.GetBoolOrFalse("isEnabled"); } } @@ -137,13 +137,4 @@ internal static StreamRedirections ParseStreamRedirections(JsonElement root) return new StreamRedirections(personal, stream, mic); } - - private static string? GetString(JsonElement obj, string name) => - obj.ValueKind == JsonValueKind.Object && - obj.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.String - ? p.GetString() : null; - - private static bool GetBool(JsonElement obj, string name) => - obj.ValueKind == JsonValueKind.Object && - obj.TryGetProperty(name, out var p) && p.ValueKind == JsonValueKind.True; } \ 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/SonarClient.cs b/SteelSeriesAPI/Sonar/SonarClient.cs index b58dcdf..717b3e2 100644 --- a/SteelSeriesAPI/Sonar/SonarClient.cs +++ b/SteelSeriesAPI/Sonar/SonarClient.cs @@ -26,8 +26,11 @@ public sealed class SonarClient : IDisposable /// Controls the ChatMix balance public IChatMixManager ChatMix { get; } - /// Controls the differents Redirections. + /// Controls the different Redirections. public IRedirectionsManager Redirections { get; } + + /// Retrieve and define Sonar audio configurations + public IConfigManager Configs { get; } /// Creates a new Sonar client. /// Optional logger for diagnostics. When null, the library stays silent. @@ -42,6 +45,7 @@ public SonarClient(ILogger? logger = null) VolumeSettings = new VolumeSettingsManager(_httpClient); ChatMix = new ChatMixManager(_httpClient); Redirections = new RedirectionsManager(_httpClient); + Configs = new ConfigManager(_httpClient); } /// diff --git a/SteelSeriesAPI/Sonar/SonarRoutes.cs b/SteelSeriesAPI/Sonar/SonarRoutes.cs index 47fff2a..3152600 100644 --- a/SteelSeriesAPI/Sonar/SonarRoutes.cs +++ b/SteelSeriesAPI/Sonar/SonarRoutes.cs @@ -35,6 +35,12 @@ internal static class SonarRoutes /// Whether stream monitoring ("hear what the audience hears") is enabled. Bare JSON boolean. internal const string StreamMonitoringEnabled = "streamRedirections/isStreamMonitoringEnabled"; + + /// 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"; // Note: the Sonar API is inconsistent by design ("Volume"/"Mute" capitalized @@ -69,6 +75,10 @@ internal static string SetMixChannelEnabled(Mix mix, Channel channel, bool enabl internal static string SetStreamMonitoringEnabled(bool enabled) => $"streamRedirections/isStreamMonitoringEnabled/{Bool(enabled)}"; + + /// Selects a config by id. Route verified against the V1 library; re-verify on first use. + internal static string SelectConfig(string configId) => + $"configs/{Uri.EscapeDataString(configId)}/select"; private static string Format(double value) => value.ToString("0.00", CultureInfo.InvariantCulture); From 08c6ff07744180326b5c42c53a2f1f997fa6a1c2 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Tue, 25 Aug 2026 20:47:57 +0200 Subject: [PATCH 13/26] update Sample --- SteelSeriesAPI.Sample/Program.cs | 67 +++++++++++++++++++++++++------- 1 file changed, 53 insertions(+), 14 deletions(-) diff --git a/SteelSeriesAPI.Sample/Program.cs b/SteelSeriesAPI.Sample/Program.cs index 2668904..b7b9e77 100644 --- a/SteelSeriesAPI.Sample/Program.cs +++ b/SteelSeriesAPI.Sample/Program.cs @@ -2,6 +2,7 @@ using SteelSeriesAPI.Core; using SteelSeriesAPI.Sonar; using SteelSeriesAPI.Sonar.Enums; +using SteelSeriesAPI.Sonar.Models; namespace SteelSeriesAPI.Sample; @@ -13,8 +14,8 @@ internal static class Program { private static async Task Main() { - // Set to LogLevel.Debug to see discovery, reconnections and polling internals - using var loggerFactory = LoggerFactory.Create(b => b.AddConsole().SetMinimumLevel(LogLevel.Debug)); + // 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"); using var sonar = new SonarClient(logger); @@ -34,7 +35,7 @@ private static async Task Main() sonar.Events.PollingInterval = TimeSpan.FromMilliseconds(500); sonar.Events.Start(); - + Console.WriteLine(); Console.WriteLine("=== Listening to Sonar events - interact with the Sonar UI, press Enter to stop ==="); Console.WriteLine(); @@ -44,7 +45,7 @@ private static async Task Main() Console.WriteLine("Stopped. Bye!"); } - /// Reads and prints the current mode, volumes, and chat mix using the typed managers. + /// Reads and prints the current Sonar state using every typed manager. private static async Task PrintCurrentStateAsync(SonarClient sonar) { Console.WriteLine("=== Current Sonar state ==="); @@ -53,7 +54,7 @@ private static async Task PrintCurrentStateAsync(SonarClient sonar) Mode mode = await sonar.Mode.GetAsync(); Console.WriteLine($"Mode: {mode}"); - // --- Volumes (query the channels relevant to the current mode) --- + // --- 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) @@ -79,6 +80,40 @@ private static async Task PrintCurrentStateAsync(SonarClient sonar) // --- 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} -> {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 -> {mic.DeviceId} (running: {mic.IsRunning})"); + + bool monitoring = await sonar.Redirections.GetStreamMonitoringEnabledAsync(); + Console.WriteLine($"Stream monitoring (hear the audience mix): {monitoring}"); + } + + static 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 -> {mix.DeviceId} (running: {mix.IsRunning}, enabled: [{enabledChannels}])"); + } } /// Subscribes to every event the library exposes, printing each occurrence. @@ -91,7 +126,7 @@ private static void SubscribeToEvents(SonarClient sonar) sonar.Events.Disconnected += (_, _) => Console.WriteLine(">>> Disconnected from Sonar (GG closed? will keep retrying)"); - // --- Granular changes (most consumers should use these) --- + // --- Granular, data-carrying events (most consumers should use these) --- sonar.Events.VolumeChanged += (_, e) => { string mix = e.Mix?.ToString() ?? "Classic"; @@ -107,24 +142,28 @@ private static void SubscribeToEvents(SonarClient sonar) sonar.Events.ChatMixChanged += (_, e) => Console.WriteLine($"[ChatMix] balance {e.Balance:+0.00;-0.00;0.00} (state: {e.State})"); - // --- Invalidations (Sonar says "something changed" without details) --- - sonar.Events.RedirectionsInvalidated += (_, _) => - Console.WriteLine("[Invalidated] redirection invalidation received from Sonar"); 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.ConfigsInvalidated += (_, _) => - Console.WriteLine("[Config] selected config changed"); - - sonar.Events.ConfigSelectionChanged += (_, e) => + sonar.Events.ConfigSelectionChanged += (_, e) => Console.WriteLine($"[Config] {e.Channel}: {e.PreviousConfig?.Name ?? "?"} -> {e.NewConfigName}"); + // --- 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)"); + // --- Low-level / diagnostics --- sonar.Events.VolumeDataReceived += (_, e) => Console.WriteLine($"[Snapshot] full volume state received ({e.Channels.Count} channels)"); @@ -132,4 +171,4 @@ private static void SubscribeToEvents(SonarClient sonar) sonar.Events.UnknownEventReceived += (_, e) => Console.WriteLine($"[Unknown] {e.EventName}"); } -} +} \ No newline at end of file From 5404bc000d0d5a5713065d7a79f1159f08ba91be Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Tue, 25 Aug 2026 20:58:58 +0200 Subject: [PATCH 14/26] Added Audio Device manager --- SteelSeriesAPI.Sample/Program.cs | 13 +- .../AudioDeviceManagerTests.cs | 145 ++++++++++++++++++ SteelSeriesAPI/Sonar/Enums/AudioDataFlow.cs | 10 ++ .../Sonar/Managers/AudioDeviceManager.cs | 66 ++++++++ .../Sonar/Managers/IAudioDeviceManager.cs | 22 +++ SteelSeriesAPI/Sonar/Models/AudioDevice.cs | 19 +++ SteelSeriesAPI/Sonar/SonarClient.cs | 6 +- SteelSeriesAPI/Sonar/SonarRoutes.cs | 3 + 8 files changed, 279 insertions(+), 5 deletions(-) create mode 100644 SteelSeriesAPI.Tests/AudioDeviceManagerTests.cs create mode 100644 SteelSeriesAPI/Sonar/Enums/AudioDataFlow.cs create mode 100644 SteelSeriesAPI/Sonar/Managers/AudioDeviceManager.cs create mode 100644 SteelSeriesAPI/Sonar/Managers/IAudioDeviceManager.cs create mode 100644 SteelSeriesAPI/Sonar/Models/AudioDevice.cs diff --git a/SteelSeriesAPI.Sample/Program.cs b/SteelSeriesAPI.Sample/Program.cs index b7b9e77..020b47a 100644 --- a/SteelSeriesAPI.Sample/Program.cs +++ b/SteelSeriesAPI.Sample/Program.cs @@ -87,11 +87,16 @@ private static async Task PrintCurrentStateAsync(SonarClient sonar) foreach ((Channel channel, var config) in selected.OrderBy(p => p.Key)) Console.WriteLine($" {channel,-6} -> {config.Name}{(config.IsPreset ? " (preset)" : "")}"); + // --- 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); + // --- Classic redirections --- var classicRedirections = await sonar.Redirections.GetClassicRedirectionsAsync(); Console.WriteLine("Classic redirections:"); foreach (var redirection in classicRedirections) - Console.WriteLine($" {redirection.Channel,-6} -> {redirection.DeviceId} (running: {redirection.IsRunning})"); + Console.WriteLine($" {redirection.Channel,-6} -> {NameOf(redirection.DeviceId)} (running: {redirection.IsRunning})"); // --- Streamer-mode redirections (meaningful values in streamer mode only) --- if (mode == Mode.Streamer) @@ -101,18 +106,18 @@ private static async Task PrintCurrentStateAsync(SonarClient sonar) PrintMix(streamRedirections.Personal); PrintMix(streamRedirections.Stream); if (streamRedirections.Mic is { } mic) - Console.WriteLine($" Mic passthrough -> {mic.DeviceId} (running: {mic.IsRunning})"); + 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}"); } - static void PrintMix(MixRedirection? mix) + 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 -> {mix.DeviceId} (running: {mix.IsRunning}, enabled: [{enabledChannels}])"); + Console.WriteLine($" {mix.Mix,-8} mix -> {NameOf(mix.DeviceId)} (running: {mix.IsRunning}, enabled: [{enabledChannels}])"); } } 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/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/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/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/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/SonarClient.cs b/SteelSeriesAPI/Sonar/SonarClient.cs index 717b3e2..508ed3c 100644 --- a/SteelSeriesAPI/Sonar/SonarClient.cs +++ b/SteelSeriesAPI/Sonar/SonarClient.cs @@ -28,7 +28,10 @@ public sealed class SonarClient : IDisposable /// Controls the different Redirections. public IRedirectionsManager Redirections { get; } - + + /// Retrieve audio devices + public IAudioDeviceManager Devices { get; } + /// Retrieve and define Sonar audio configurations public IConfigManager Configs { get; } @@ -45,6 +48,7 @@ public SonarClient(ILogger? logger = null) VolumeSettings = new VolumeSettingsManager(_httpClient); ChatMix = new ChatMixManager(_httpClient); Redirections = new RedirectionsManager(_httpClient); + Devices = new AudioDeviceManager(_httpClient); Configs = new ConfigManager(_httpClient); } diff --git a/SteelSeriesAPI/Sonar/SonarRoutes.cs b/SteelSeriesAPI/Sonar/SonarRoutes.cs index 3152600..e52603b 100644 --- a/SteelSeriesAPI/Sonar/SonarRoutes.cs +++ b/SteelSeriesAPI/Sonar/SonarRoutes.cs @@ -36,6 +36,9 @@ internal static class SonarRoutes /// 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"; From 47f2f41a35c9b18cec0b2516acb5eb72d45eb0e9 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Tue, 25 Aug 2026 21:47:07 +0200 Subject: [PATCH 15/26] Sonar Apps routing --- SteelSeriesAPI.Sample/Program.cs | 28 ++++ .../AppRoutingManagerTests.cs | 153 ++++++++++++++++++ .../Sonar/Events/SonarEventListener.cs | 33 ++++ .../Sonar/Events/SonarEventNames.cs | 1 + .../Sonar/Managers/AppRoutingManager.cs | 102 ++++++++++++ .../Sonar/Managers/IAppRoutingManager.cs | 29 ++++ SteelSeriesAPI/Sonar/Models/AppRouting.cs | 33 ++++ SteelSeriesAPI/Sonar/SonarClient.cs | 6 +- SteelSeriesAPI/Sonar/SonarRoutes.cs | 8 + 9 files changed, 392 insertions(+), 1 deletion(-) create mode 100644 SteelSeriesAPI.Tests/AppRoutingManagerTests.cs create mode 100644 SteelSeriesAPI/Sonar/Managers/AppRoutingManager.cs create mode 100644 SteelSeriesAPI/Sonar/Managers/IAppRoutingManager.cs create mode 100644 SteelSeriesAPI/Sonar/Models/AppRouting.cs diff --git a/SteelSeriesAPI.Sample/Program.cs b/SteelSeriesAPI.Sample/Program.cs index 020b47a..4752e9d 100644 --- a/SteelSeriesAPI.Sample/Program.cs +++ b/SteelSeriesAPI.Sample/Program.cs @@ -119,6 +119,17 @@ void PrintMix(MixRedirection? mix) mix.EnabledChannels.Where(p => p.Value).Select(p => p.Key)); Console.WriteLine($" {mix.Mix,-8} mix -> {NameOf(mix.DeviceId)} (running: {mix.IsRunning}, enabled: [{enabledChannels}])"); } + + Console.WriteLine("Audio Routing:"); + var routings = await sonar.AppRouting.GetRoutingsAsync(); + foreach (var device in routings) + { + Console.WriteLine($" {NameOf(device.DeviceId),-6}:"); + foreach (var session in device.Sessions) + { + Console.WriteLine($" {session.DisplayName,-2} ({session.ProcessId, -4}) -> {session.State}"); + } + } } /// Subscribes to every event the library exposes, printing each occurrence. @@ -161,6 +172,20 @@ private static void SubscribeToEvents(SonarClient sonar) 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 += (_, _) => @@ -169,6 +194,9 @@ private static void SubscribeToEvents(SonarClient sonar) sonar.Events.ConfigsInvalidated += (_, _) => Console.WriteLine(" (raw: configs invalidated)"); + // sonar.Events.RoutingInvalidated += (_, _) => + // Console.WriteLine(" (raw: routing invalidated)"); + // --- Low-level / diagnostics --- sonar.Events.VolumeDataReceived += (_, e) => Console.WriteLine($"[Snapshot] full volume state received ({e.Channels.Count} channels)"); 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/Sonar/Events/SonarEventListener.cs b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs index 325bf9d..392a96c 100644 --- a/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs +++ b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs @@ -50,6 +50,25 @@ public sealed class SonarEventListener : IDisposable /// the affected channel and both configs. /// public event EventHandler? ConfigsInvalidated; + + /// + /// 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; @@ -252,6 +271,20 @@ private void Dispatch(string json) RaiseSafely(() => ConfigsInvalidated?.Invoke(this, EventArgs.Empty)); ScheduleConfigRefresh(); 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, diff --git a/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs b/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs index f5d805a..9033101 100644 --- a/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs +++ b/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs @@ -16,6 +16,7 @@ internal static class SonarEventNames 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, 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/IAppRoutingManager.cs b/SteelSeriesAPI/Sonar/Managers/IAppRoutingManager.cs new file mode 100644 index 0000000..cd3ba2a --- /dev/null +++ b/SteelSeriesAPI/Sonar/Managers/IAppRoutingManager.cs @@ -0,0 +1,29 @@ +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/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/SonarClient.cs b/SteelSeriesAPI/Sonar/SonarClient.cs index 508ed3c..cc0b18a 100644 --- a/SteelSeriesAPI/Sonar/SonarClient.cs +++ b/SteelSeriesAPI/Sonar/SonarClient.cs @@ -34,7 +34,10 @@ public sealed class SonarClient : IDisposable /// Retrieve and define Sonar audio configurations public IConfigManager Configs { get; } - + + /// Manage routed apps + public IAppRoutingManager AppRouting { get; } + /// Creates a new Sonar client. /// Optional logger for diagnostics. When null, the library stays silent. public SonarClient(ILogger? logger = null) @@ -50,6 +53,7 @@ public SonarClient(ILogger? logger = null) Redirections = new RedirectionsManager(_httpClient); Devices = new AudioDeviceManager(_httpClient); Configs = new ConfigManager(_httpClient); + AppRouting = new AppRoutingManager(_httpClient); } /// diff --git a/SteelSeriesAPI/Sonar/SonarRoutes.cs b/SteelSeriesAPI/Sonar/SonarRoutes.cs index e52603b..aaae291 100644 --- a/SteelSeriesAPI/Sonar/SonarRoutes.cs +++ b/SteelSeriesAPI/Sonar/SonarRoutes.cs @@ -44,6 +44,10 @@ internal static class SonarRoutes /// 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"; + // Note: the Sonar API is inconsistent by design ("Volume"/"Mute" capitalized @@ -82,6 +86,10 @@ internal static string SetStreamMonitoringEnabled(bool enabled) => /// Selects a config by id. Route verified against the V1 library; re-verify on first use. 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); From dc4e6bc6e35c1a8b6dde4011f0622147561ba972 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Tue, 25 Aug 2026 22:04:36 +0200 Subject: [PATCH 16/26] some fixes --- SteelSeriesAPI/Core/SonarExceptions.cs | 4 ++-- SteelSeriesAPI/Core/SonarHttpClient.cs | 5 +++-- SteelSeriesAPI/Sonar/Events/SonarEventListener.cs | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/SteelSeriesAPI/Core/SonarExceptions.cs b/SteelSeriesAPI/Core/SonarExceptions.cs index 2a42721..243e511 100644 --- a/SteelSeriesAPI/Core/SonarExceptions.cs +++ b/SteelSeriesAPI/Core/SonarExceptions.cs @@ -31,9 +31,9 @@ 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. + /// The underlying exception, if any. public DiscoveryException(string message, Exception? inner = null) - : base(message) { } + : base(message, inner) { } } /// The Sonar API responded with an unexpected JSON structure. diff --git a/SteelSeriesAPI/Core/SonarHttpClient.cs b/SteelSeriesAPI/Core/SonarHttpClient.cs index 875604a..f3134e3 100644 --- a/SteelSeriesAPI/Core/SonarHttpClient.cs +++ b/SteelSeriesAPI/Core/SonarHttpClient.cs @@ -32,17 +32,18 @@ public SonarHttpClient(ServerDiscovery discovery, ILogger? logger = null) /// A token to cancel the operation. public async Task GetAsync(string route, CancellationToken ct = default) { - var response = await SendAsync(HttpMethod.Get, route, ct); + 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) { - await SendAsync(HttpMethod.Put, route, ct); + using var _ = await SendAsync(HttpMethod.Put, route, ct); } private async Task SendAsync( diff --git a/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs index 392a96c..6971bf3 100644 --- a/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs +++ b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs @@ -156,7 +156,6 @@ public async Task StopAsync() catch (OperationCanceledException) { /* expected */ } _cts.Dispose(); - _configsRefreshLock.Dispose(); _cts = null; _runLoop = null; _pollLoop = null; @@ -636,5 +635,6 @@ public void Dispose() _cts?.Cancel(); _cts?.Dispose(); _redirectionsRefreshLock.Dispose(); + _configsRefreshLock.Dispose(); } } \ No newline at end of file From 9ae5bba0991f7247c4eaa3e27fae86261afc547c Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Thu, 27 Aug 2026 22:27:18 +0200 Subject: [PATCH 17/26] setup net8 UT --- .github/workflows/ci.yml | 2 +- SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2ef7781..22eefc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: 10.0.x + dotnet-version: ['10.0.x', '8.0.x'] - name: Restore run: dotnet restore diff --git a/SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj b/SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj index 170597c..78fdb46 100644 --- a/SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj +++ b/SteelSeriesAPI.Tests/SteelSeriesAPI.Tests.csproj @@ -1,7 +1,7 @@  - net10.0 + net8.0;net10.0 enable enable false From 3e9181faafdf905478daef4e1da70762a2171a5c Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Thu, 27 Aug 2026 22:30:03 +0200 Subject: [PATCH 18/26] fix ci --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22eefc6..1513cd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,9 @@ jobs: - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: ['10.0.x', '8.0.x'] + dotnet-version: | + 8.0.x + 10.0.x - name: Restore run: dotnet restore From 9866586a730b044329aa8218f8cd7ec3c2a09d97 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Fri, 28 Aug 2026 02:48:10 +0200 Subject: [PATCH 19/26] Clean Listener code --- .../Events/Listener/DebouncedRefresher.cs | 75 +++ .../Listener/SonarEventListener.Configs.cs | 42 ++ .../SonarEventListener.Redirections.cs | 113 +++++ .../Listener/SonarEventListener.Volumes.cs | 166 +++++++ .../Sonar/Events/SonarEventListener.cs | 452 +++--------------- 5 files changed, 450 insertions(+), 398 deletions(-) create mode 100644 SteelSeriesAPI/Sonar/Events/Listener/DebouncedRefresher.cs create mode 100644 SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Configs.cs create mode 100644 SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Redirections.cs create mode 100644 SteelSeriesAPI/Sonar/Events/Listener/SonarEventListener.Volumes.cs 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/SonarEventListener.cs b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs index 6971bf3..62e5ec5 100644 --- a/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs +++ b/SteelSeriesAPI/Sonar/Events/SonarEventListener.cs @@ -4,7 +4,6 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using SteelSeriesAPI.Core; -using SteelSeriesAPI.Sonar.Enums; using SteelSeriesAPI.Sonar.Managers; using SteelSeriesAPI.Sonar.Models; @@ -16,18 +15,41 @@ namespace SteelSeriesAPI.Sonar.Events; /// On every (re)connection, Sonar pushes its full current state, so subscribers resynchronize for free. /// /// -/// Events are raised from a background thread. Volume changes made from the Sonar UI sliders are -/// NOT broadcast by Sonar (observed 2026-08-08): only fires on -/// connection and after major state changes such as a mode switch. +/// 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 class SonarEventListener : IDisposable +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; @@ -38,19 +60,6 @@ public sealed class SonarEventListener : IDisposable /// Raised when Sonar broadcasts a chat mix change (slider, hardware wheel...). public event EventHandler? ChatMixChanged; - /// Raised when Sonar pushes a full volume snapshot (on connection and after major changes). - public event EventHandler? VolumeDataReceived; - - /// Raised when redirections changed. Sonar sends no details: re-query if needed. - public event EventHandler? RedirectionsInvalidated; - - /// - /// 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 an application audio session appears on a device /// (app started playing, or was routed to this channel). @@ -72,81 +81,41 @@ public sealed class SonarEventListener : IDisposable /// Raised for any Sonar event not yet mapped to a typed event. public event EventHandler? UnknownEventReceived; - - /// - /// 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, and redirection states (device routing, - /// mix toggles, stream monitoring). 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, devices, audio sessions...) will be raised. - /// - public TimeSpan? PollingInterval { get; set; } - - /// Raised when polling detects a mixer mode change. Requires . - public event EventHandler? ModeChanged; - - /// Raised when polling detects a volume or mute change. Requires . - public event EventHandler? VolumeChanged; - - /// 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; - - /// Raised when the selected config of a channel changes. - public event EventHandler? ConfigSelectionChanged; - - private Task? _pollLoop; - - private readonly RedirectionsManager _redirections; - private RedirectionsSnapshot? _redirectionsBaseline; - private int _redirectionRefreshVersion; - private CancellationToken _lifetime; - - /// The full redirection state used as a diffing baseline. - internal sealed record RedirectionsSnapshot( - IReadOnlyList Classic, - StreamRedirections Stream, - bool MonitoringEnabled); - - private readonly ConfigManager _configs; - private IReadOnlyDictionary? _selectedConfigsBaseline; - private int _configRefreshVersion; - private readonly SemaphoreSlim _configsRefreshLock = new(1, 1); 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. Safe to call once; use to stop. + /// + /// 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 loop to complete. + /// Stops listening and waits for the background loops to complete. public async Task StopAsync() { if (_cts is null || _runLoop is null) return; @@ -165,9 +134,8 @@ public async Task StopAsync() private async Task RunAsync(CancellationToken ct) { TimeSpan backoff = TimeSpan.FromSeconds(1); - bool wasConnected = false; - + while (!ct.IsCancellationRequested) { try @@ -180,15 +148,15 @@ private async Task RunAsync(CancellationToken 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 redirections baseline right away, so the very first user change + + // Seed the diffing baselines right away, so the very first user change // after startup produces granular events instead of just creating the baseline. - ScheduleRedirectionRefresh(); - ScheduleConfigRefresh(); - + _redirectionsRefresher.Schedule(ct); + _configsRefresher.Schedule(ct); + await ReceiveLoopAsync(ws, ct); } catch (OperationCanceledException) when (ct.IsCancellationRequested) @@ -263,14 +231,14 @@ private void Dispatch(string json) case SonarEventNames.RedirectionStatusUpdate: case SonarEventNames.StreamMonitoringLockStatusUpdate: RaiseSafely(() => RedirectionsInvalidated?.Invoke(this, EventArgs.Empty)); - ScheduleRedirectionRefresh(); + _redirectionsRefresher.Schedule(_lifetime); break; case SonarEventNames.SelectedConfigUpdated: RaiseSafely(() => ConfigsInvalidated?.Invoke(this, EventArgs.Empty)); - ScheduleConfigRefresh(); + _configsRefresher.Schedule(_lifetime); break; - + case SonarEventNames.AudioSessionOpened: if (AppRoutingManager.ParseRouting(data) is { } opened) RaiseSafely(() => AudioSessionOpened?.Invoke(this, opened)); @@ -312,329 +280,17 @@ internal static ChatMixSetting ParseChatMix(JsonElement data) b.ValueKind == JsonValueKind.Number ? b.GetDouble() : 0.0; - string? state = data.ValueKind == JsonValueKind.Object && - data.TryGetProperty("state", out var s) && - s.ValueKind == JsonValueKind.String - ? s.GetString() : null; + string? state = data.GetStringOrNull("state"); return new ChatMixSetting(balance, state); } - /// 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.TryGetProperty("muted", out var m) && - m.ValueKind == JsonValueKind.True; - return new VolumeSetting(volume, muted); - } - - private readonly SemaphoreSlim _redirectionsRefreshLock = new(1, 1); - - /// - /// Schedules a redirection refresh 250ms from now. Invalidations arrive in bursts: - /// each call supersedes the previous one, so only the last of a burst actually fetches. - /// - private void ScheduleRedirectionRefresh() - { - int version = Interlocked.Increment(ref _redirectionRefreshVersion); - CancellationToken ct = _lifetime; - _logger.LogDebug("Redirection refresh #{Version} scheduled", version); - - _ = Task.Run(async () => - { - try - { - await Task.Delay(250, ct); - if (version != _redirectionRefreshVersion) - { - _logger.LogDebug("Redirection refresh #{Version} superseded", version); - return; - } - - await RefreshRedirectionsAsync(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, "Redirection refresh #{Version} failed", version); - } - }, ct); - } - - /// Fetches the full redirection state, diffs it against the baseline, and raises granular events. - private async Task RefreshRedirectionsAsync(CancellationToken ct) - { - await _redirectionsRefreshLock.WaitAsync(ct); - try - { - 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; - } - finally - { - _redirectionsRefreshLock.Release(); - } - } - - private void ScheduleConfigRefresh() - { - int version = Interlocked.Increment(ref _configRefreshVersion); - CancellationToken ct = _lifetime; - - _ = Task.Run(async () => - { - try - { - await Task.Delay(250, ct); - if (version != _configRefreshVersion) return; - await RefreshSelectedConfigsAsync(ct); - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) { } - catch (Exception ex) - { - _logger.LogWarning(ex, "Config selection refresh failed"); - } - }, ct); - } - - private async Task RefreshSelectedConfigsAsync(CancellationToken ct) - { - await _configsRefreshLock.WaitAsync(ct); - try - { - 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; - } - finally - { - _configsRefreshLock.Release(); - } - } - - /// - /// 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. - /// - 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; - - // Redirections: UI-initiated toggles are not broadcast by Sonar (same rule as - // volume sliders), so we refresh them on the polling cadence too. The lock - // makes this safe alongside invalidation-triggered refreshes. - await RefreshRedirectionsAsync(ct); - await RefreshSelectedConfigsAsync(ct); - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } - catch (Exception ex) - { - _logger.LogDebug(ex, "Volume 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; - } - - /// 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); - } - /// Stops the listener without waiting. Prefer for a graceful stop. public void Dispose() { _cts?.Cancel(); _cts?.Dispose(); - _redirectionsRefreshLock.Dispose(); - _configsRefreshLock.Dispose(); + _redirectionsRefresher.Dispose(); + _configsRefresher.Dispose(); } } \ No newline at end of file From 09af37644e0cf923db3b30ec7b9de49d0d149e7e Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Fri, 28 Aug 2026 03:08:47 +0200 Subject: [PATCH 20/26] Some code cleaning --- SteelSeriesAPI.Explorer/Program.cs | 3 +++ SteelSeriesAPI/Core/ServerDiscovery.cs | 2 +- SteelSeriesAPI/Core/SonarHttpClient.cs | 3 +-- .../Sonar/Events/SonarEventNames.cs | 3 +-- SteelSeriesAPI/Sonar/SonarClient.cs | 18 ++++++------- SteelSeriesAPI/Sonar/SonarRoutes.cs | 26 +++++++++---------- 6 files changed, 27 insertions(+), 28 deletions(-) diff --git a/SteelSeriesAPI.Explorer/Program.cs b/SteelSeriesAPI.Explorer/Program.cs index b4de9d1..f335cbc 100644 --- a/SteelSeriesAPI.Explorer/Program.cs +++ b/SteelSeriesAPI.Explorer/Program.cs @@ -14,9 +14,12 @@ internal static class Program "mode", "volumeSettings/classic/", "volumeSettings/streamer/", + "v1/chatMix", "audioDevices", "classicRedirections", "streamRedirections", + "streamRedirections/isStreamMonitoringEnabled", + "AudioDeviceRouting", "configs" ]; diff --git a/SteelSeriesAPI/Core/ServerDiscovery.cs b/SteelSeriesAPI/Core/ServerDiscovery.cs index 0c31bd6..85c6083 100644 --- a/SteelSeriesAPI/Core/ServerDiscovery.cs +++ b/SteelSeriesAPI/Core/ServerDiscovery.cs @@ -8,7 +8,7 @@ namespace SteelSeriesAPI.Core; /// Discovers the Sonar web server address by reading coreProps.json /// and querying the GG /subApps endpoint. /// -public class ServerDiscovery +public sealed class ServerDiscovery { private readonly HttpClient _ggClient; private readonly string _corePropsPath; diff --git a/SteelSeriesAPI/Core/SonarHttpClient.cs b/SteelSeriesAPI/Core/SonarHttpClient.cs index f3134e3..d0747d0 100644 --- a/SteelSeriesAPI/Core/SonarHttpClient.cs +++ b/SteelSeriesAPI/Core/SonarHttpClient.cs @@ -8,7 +8,7 @@ namespace SteelSeriesAPI.Core; /// Resilient HTTP client for the Sonar web server. /// Caches the server address and transparently rediscovers it when GG restarts. /// -public class SonarHttpClient : IDisposable, ISonarTransport +public sealed class SonarHttpClient : IDisposable, ISonarTransport { private readonly HttpClient _http; private readonly ServerDiscovery _discovery; @@ -117,6 +117,5 @@ public void Dispose() { _http.Dispose(); _discoveryLock.Dispose(); - GC.SuppressFinalize(this); } } \ No newline at end of file diff --git a/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs b/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs index 9033101..4f21fb5 100644 --- a/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs +++ b/SteelSeriesAPI/Sonar/Events/SonarEventNames.cs @@ -21,6 +21,5 @@ internal static class SonarEventNames // 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, - // SONAR_EVENT_ROUTING_DATA + // SONAR_EVENT_PLAYER_STOPPED, SONAR_EVENT_QUICKSET_*, SONAR_EVENT_RECORDING_STOPPED } \ No newline at end of file diff --git a/SteelSeriesAPI/Sonar/SonarClient.cs b/SteelSeriesAPI/Sonar/SonarClient.cs index cc0b18a..74a7bed 100644 --- a/SteelSeriesAPI/Sonar/SonarClient.cs +++ b/SteelSeriesAPI/Sonar/SonarClient.cs @@ -16,26 +16,26 @@ public sealed class SonarClient : IDisposable /// 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; } - - /// Controls the ChatMix balance + + /// Reads and controls the chat mix (game/chat balance). public IChatMixManager ChatMix { get; } - - /// Controls the different Redirections. + + /// Controls audio redirections: device routing, mix toggles, and stream monitoring. public IRedirectionsManager Redirections { get; } - /// Retrieve audio devices + /// Lists the audio devices known to Sonar, physical and virtual. public IAudioDeviceManager Devices { get; } - /// Retrieve and define Sonar audio configurations + /// Lists and selects Sonar audio configs (presets). public IConfigManager Configs { get; } - - /// Manage routed apps + + /// Reads and controls which Sonar channel each application's audio is routed to. public IAppRoutingManager AppRouting { get; } /// Creates a new Sonar client. diff --git a/SteelSeriesAPI/Sonar/SonarRoutes.cs b/SteelSeriesAPI/Sonar/SonarRoutes.cs index aaae291..4f7ed21 100644 --- a/SteelSeriesAPI/Sonar/SonarRoutes.cs +++ b/SteelSeriesAPI/Sonar/SonarRoutes.cs @@ -10,8 +10,8 @@ namespace SteelSeriesAPI.Sonar; internal static class SonarRoutes { /// Current mixer mode, returned as a bare JSON string ("classic" or "stream"). - internal const string GetMode = "mode/"; - + internal const string GetMode = "mode"; + /// Volume/mute state of all channels in classic mode. internal const string ClassicVolumes = "volumeSettings/classic/"; @@ -21,11 +21,11 @@ internal static class SonarRoutes /// 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"; @@ -35,27 +35,25 @@ internal static class SonarRoutes /// 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 SetMode(Mode mode) => $"mode/{mode.ToApiValue()}"; - internal static string SetClassicVolume(Channel channel, double volume) => $"volumeSettings/classic/{channel.ToRouteKey()}/Volume/{Format(volume)}"; @@ -70,7 +68,7 @@ internal static string SetStreamerMute(Mix mix, Channel channel, 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)}"; @@ -82,11 +80,11 @@ internal static string SetMixChannelEnabled(Mix mix, Channel channel, bool enabl internal static string SetStreamMonitoringEnabled(bool enabled) => $"streamRedirections/isStreamMonitoringEnabled/{Bool(enabled)}"; - - /// Selects a config by id. Route verified against the V1 library; re-verify on first use. + + /// 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}"; From 1b77709ea38551876ce9c3b99fb5e0d1efcb49b9 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Fri, 28 Aug 2026 03:18:27 +0200 Subject: [PATCH 21/26] Update Explorer to test API easily + add references files for the tests --- .gitignore | 3 + SteelSeriesAPI.Explorer/Program.cs | 401 +++++++-- .../AudioDeviceRouting.shape.json | 19 + .../reference-shapes/audioDevices.shape.json | 13 + .../classicRedirections.shape.json | 7 + .../reference-shapes/configs.shape.json | 767 ++++++++++++++++++ .../reference-shapes/mode.shape.json | 1 + .../streamRedirections.shape.json | 19 + ...tions_isStreamMonitoringEnabled.shape.json | 1 + .../reference-shapes/v1_chatMix.shape.json | 5 + .../volumeSettings_classic.shape.json | 46 ++ .../volumeSettings_streamer.shape.json | 100 +++ 12 files changed, 1331 insertions(+), 51 deletions(-) create mode 100644 SteelSeriesAPI.Explorer/reference-shapes/AudioDeviceRouting.shape.json create mode 100644 SteelSeriesAPI.Explorer/reference-shapes/audioDevices.shape.json create mode 100644 SteelSeriesAPI.Explorer/reference-shapes/classicRedirections.shape.json create mode 100644 SteelSeriesAPI.Explorer/reference-shapes/configs.shape.json create mode 100644 SteelSeriesAPI.Explorer/reference-shapes/mode.shape.json create mode 100644 SteelSeriesAPI.Explorer/reference-shapes/streamRedirections.shape.json create mode 100644 SteelSeriesAPI.Explorer/reference-shapes/streamRedirections_isStreamMonitoringEnabled.shape.json create mode 100644 SteelSeriesAPI.Explorer/reference-shapes/v1_chatMix.shape.json create mode 100644 SteelSeriesAPI.Explorer/reference-shapes/volumeSettings_classic.shape.json create mode 100644 SteelSeriesAPI.Explorer/reference-shapes/volumeSettings_streamer.shape.json diff --git a/.gitignore b/.gitignore index 0808c4a..7a2e8d9 100644 --- a/.gitignore +++ b/.gitignore @@ -480,3 +480,6 @@ $RECYCLE.BIN/ # Vim temporary swap files *.swp + +# API Check References +*.shape.json.actual \ No newline at end of file diff --git a/SteelSeriesAPI.Explorer/Program.cs b/SteelSeriesAPI.Explorer/Program.cs index f335cbc..2f5f922 100644 --- a/SteelSeriesAPI.Explorer/Program.cs +++ b/SteelSeriesAPI.Explorer/Program.cs @@ -3,10 +3,14 @@ using System.Text.Json; using SteelSeriesAPI.Core; using SteelSeriesAPI.Sonar; +using SteelSeriesAPI.Sonar.Enums; namespace SteelSeriesAPI.Explorer; -/// Interactive exploration tool for the Sonar API. Not shipped with the library. +/// +/// 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 = @@ -23,63 +27,339 @@ internal static class Program "configs" ]; - private static async Task Main() + 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("Sonar API Explorer - commands: get | put | probe ... | dump | ws | quit"); + 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; - try + 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) { - switch (input[0].ToLowerInvariant()) + string file = Path.Combine(ReferenceDir, SafeName(route) + ".shape.json"); + try { - case "quit" or "q": - return; - - case "get" when input.Length == 2: - using (var doc = await sonar.GetRawAsync(input[1])) - Console.WriteLine(Pretty(doc)); - 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,-40} -> {await Probe(sonar, route)}"); - break; - - case "dump": - await Dump(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; + 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($"{e.GetType().Name}: {e.Message}"); + 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) { @@ -96,25 +376,25 @@ private static async Task Probe(SonarClient sonar, string route) /// 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("dumps", DateTime.Now.ToString("yyyy-MM-dd_HHmm")); + 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, route.Trim('/').Replace('/', '_') + ".json"); + string file = Path.Combine(dir, SafeName(route) + ".json"); try { using var doc = await sonar.GetRawAsync(route); - await File.WriteAllTextAsync(file, Pretty(doc)); - Console.WriteLine($" {route,-30} -> {file}"); + await File.WriteAllTextAsync(file, Pretty(doc.RootElement)); + Console.WriteLine($" {route,-45} -> {file}"); } catch (Exception e) { - Console.WriteLine($" {route,-30} -> FAILED: {e.Message}"); + 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) { @@ -125,20 +405,20 @@ private static async Task ListenWebSocketAsync(SonarClient sonar, string path, s 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]; - var message = new MemoryStream(); + using var message = new MemoryStream(); try { @@ -147,7 +427,6 @@ await ws.SendAsync(Encoding.UTF8.GetBytes(initialMessage), var result = await ws.ReceiveAsync(buffer, cts.Token); if (result.MessageType == WebSocketMessageType.Close) break; - // A logical message may span several frames: accumulate until EndOfMessage message.Write(buffer, 0, result.Count); if (!result.EndOfMessage) continue; @@ -161,6 +440,26 @@ await ws.SendAsync(Encoding.UTF8.GetBytes(initialMessage), } } - private static string Pretty(JsonDocument doc) => - JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true }); + // ---------------------------------------------------------------- + // 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/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 From 17eec3ccc6834bffb78b202ea44f586618240e06 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Fri, 28 Aug 2026 03:33:12 +0200 Subject: [PATCH 22/26] Update Sample --- SteelSeriesAPI.Sample/Program.cs | 44 +++++++++++++++++--------------- 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/SteelSeriesAPI.Sample/Program.cs b/SteelSeriesAPI.Sample/Program.cs index 4752e9d..bfd0fcc 100644 --- a/SteelSeriesAPI.Sample/Program.cs +++ b/SteelSeriesAPI.Sample/Program.cs @@ -54,6 +54,11 @@ private static async Task PrintCurrentStateAsync(SonarClient sonar) 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]; @@ -87,11 +92,6 @@ private static async Task PrintCurrentStateAsync(SonarClient sonar) foreach ((Channel channel, var config) in selected.OrderBy(p => p.Key)) Console.WriteLine($" {channel,-6} -> {config.Name}{(config.IsPreset ? " (preset)" : "")}"); - // --- 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); - // --- Classic redirections --- var classicRedirections = await sonar.Redirections.GetClassicRedirectionsAsync(); Console.WriteLine("Classic redirections:"); @@ -110,26 +110,30 @@ private static async Task PrintCurrentStateAsync(SonarClient sonar) 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}])"); + 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}])"); + } } - Console.WriteLine("Audio Routing:"); + // --- App routing: which applications play on which channel --- var routings = await sonar.AppRouting.GetRoutingsAsync(); - foreach (var device in routings) + 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)) { - Console.WriteLine($" {NameOf(device.DeviceId),-6}:"); - foreach (var session in device.Sessions) + foreach (var session in routing.Sessions.Where(s => !s.IsSystemSound && s.IsActive)) { - Console.WriteLine($" {session.DisplayName,-2} ({session.ProcessId, -4}) -> {session.State}"); + Console.WriteLine($" {routing.Channel,-6} -> {session.DisplayName} (pid {session.ProcessId})"); + anySession = true; } } + if (!anySession) + Console.WriteLine(" (no application is currently playing audio)"); } /// Subscribes to every event the library exposes, printing each occurrence. @@ -172,7 +176,7 @@ private static void SubscribeToEvents(SonarClient sonar) 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); @@ -194,8 +198,8 @@ private static void SubscribeToEvents(SonarClient sonar) sonar.Events.ConfigsInvalidated += (_, _) => Console.WriteLine(" (raw: configs invalidated)"); - // sonar.Events.RoutingInvalidated += (_, _) => - // Console.WriteLine(" (raw: routing invalidated)"); + sonar.Events.RoutingInvalidated += (_, _) => + Console.WriteLine(" (raw: app routing invalidated)"); // --- Low-level / diagnostics --- sonar.Events.VolumeDataReceived += (_, e) => From 74df12940d62d54886ca3c70cc95e846c881adc2 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Fri, 28 Aug 2026 03:33:49 +0200 Subject: [PATCH 23/26] Add some documentation for devs --- docs/ARCHITECTURE.md | 132 +++++++++++++++++++++++++++++++++++++++++++ docs/CONTRIBUTING.md | 90 +++++++++++++++++++++++++++++ 2 files changed, 222 insertions(+) create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CONTRIBUTING.md 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 From ea1727eaf93b20b090b1ef2b8ea3ccbfa606f1ff Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Fri, 28 Aug 2026 04:02:59 +0200 Subject: [PATCH 24/26] Fix csref --- SteelSeriesAPI/Sonar/Managers/IAppRoutingManager.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/SteelSeriesAPI/Sonar/Managers/IAppRoutingManager.cs b/SteelSeriesAPI/Sonar/Managers/IAppRoutingManager.cs index cd3ba2a..6122aa0 100644 --- a/SteelSeriesAPI/Sonar/Managers/IAppRoutingManager.cs +++ b/SteelSeriesAPI/Sonar/Managers/IAppRoutingManager.cs @@ -1,4 +1,5 @@ -using SteelSeriesAPI.Sonar.Enums; +using SteelSeriesAPI.Core; +using SteelSeriesAPI.Sonar.Enums; using SteelSeriesAPI.Sonar.Models; namespace SteelSeriesAPI.Sonar.Managers; From 3794fa9ed345d61112a62ba262101134110c30b1 Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Fri, 28 Aug 2026 04:29:02 +0200 Subject: [PATCH 25/26] README + Nuget packaging --- README.md | 135 ++++++++++++++++++++------- SteelSeriesAPI/SteelSeriesAPI.csproj | 27 +++++- 2 files changed, 126 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 79beca7..2b8c9e9 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,9 @@ # SteelSeries-NET-API -[![GitHub Downloads](https://img.shields.io/github/downloads/DataNext27/SteelSeries-NET-API/total?style=for-the-badge&color=6fca00)](https://github.com/DataNext27/SteelSeries-NET-API/releases/) [![NuGet Downloads](https://img.shields.io/nuget/dt/Steelseries-NET-API?style=for-the-badge&label=Nuget%20Downloads&color=%23004880)](https://www.nuget.org/packages/Steelseries-NET-API) -[![GitHub Version](https://img.shields.io/github/v/tag/DataNext27/SteelSeries-NET-API?style=for-the-badge&label=Version)](https://github.com/DataNext27/SteelSeries-NET-API/releases/latest/) +[![NuGet Version](https://img.shields.io/nuget/vpre/Steelseries-NET-API?style=for-the-badge&label=Version)](https://www.nuget.org/packages/Steelseries-NET-API) [![GitHub License](https://img.shields.io/github/license/DataNext27/SteelSeries-NET-API?style=for-the-badge&color=red)](https://github.com/DataNext27/SteelSeries-NET-API/blob/main/LICENSE) -[![.NET Version](https://img.shields.io/badge/.NET-9.0-512cd4?style=for-the-badge)](https://dotnet.microsoft.com/fr-fr/download/dotnet/9.0) -[![.NET Version](https://img.shields.io/badge/.NET-8.0-512cd4?style=for-the-badge)](https://dotnet.microsoft.com/fr-fr/download/dotnet/8.0) -[![.NET Version](https://img.shields.io/badge/.NET-7.0-512cd4?style=for-the-badge)](https://dotnet.microsoft.com/fr-fr/download/dotnet/7.0) +[![.NET](https://img.shields.io/badge/.NET-8.0_%7C_10.0-512cd4?style=for-the-badge)](https://dotnet.microsoft.com/download) [![Ko-fi](https://img.shields.io/badge/Support_me_on-Ko--fi-FF6433?style=for-the-badge&logo=ko-fi)](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 + +- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) - layers, event mechanisms, and the + hard-earned lessons about the Sonar API +- [CONTRIBUTING.md](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) made by DataNext \ No newline at end of file +## Projects using this library +- [TouchPortal SteelSeries GG Plugin](https://github.com/DataNext27/TouchPortal_SteelSeriesGG) by DataNext \ No newline at end of file diff --git a/SteelSeriesAPI/SteelSeriesAPI.csproj b/SteelSeriesAPI/SteelSeriesAPI.csproj index 05c3125..a247f46 100644 --- a/SteelSeriesAPI/SteelSeriesAPI.csproj +++ b/SteelSeriesAPI/SteelSeriesAPI.csproj @@ -5,18 +5,39 @@ enable enable SteelSeriesAPI + true + $(WarningsAsErrors);CS1573;CS1574;CS1591 - + Steelseries-NET-API 2.0.0-alpha.1 DataNext - Unofficial .NET library to control SteelSeries GG (Sonar) + 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 + README.md + icon-24.png + See https://github.com/DataNext27/SteelSeries-NET-API/releases + Copyright (c) DataNext + + + https://github.com/DataNext27/SteelSeries-NET-API - true + git + true + true + true + snupkg + + + + + From cfe0269d8896b7865ec4220ff12fcead85c39a4d Mon Sep 17 00:00:00 2001 From: DataNext27 Date: Fri, 28 Aug 2026 04:32:10 +0200 Subject: [PATCH 26/26] Updated README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2b8c9e9..3689010 100644 --- a/README.md +++ b/README.md @@ -111,9 +111,9 @@ skipped, never fatal), and ships tooling to catch SteelSeries-side changes early ## For contributors -- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) - layers, event mechanisms, and the +- [ARCHITECTURE.md](docs/ARCHITECTURE.md) - layers, event mechanisms, and the hard-earned lessons about the Sonar API -- [CONTRIBUTING.md](CONTRIBUTING.md) - setup, the manager mold, and the +- [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`