diff --git a/TeamsISO.Linux.slnf b/TeamsISO.Linux.slnf index 1bf2820..8b9ed27 100644 --- a/TeamsISO.Linux.slnf +++ b/TeamsISO.Linux.slnf @@ -4,6 +4,7 @@ "projects": [ "src/TeamsISO.Engine/TeamsISO.Engine.csproj", "src/TeamsISO.Engine.NdiInterop/TeamsISO.Engine.NdiInterop.csproj", + "src/TeamsISO.Console/TeamsISO.Console.csproj", "src/tests/TeamsISO.Engine.Tests/TeamsISO.Engine.Tests.csproj", "src/tests/TeamsISO.Engine.IntegrationTests/TeamsISO.Engine.IntegrationTests.csproj" ] diff --git a/TeamsISO.sln b/TeamsISO.sln index a82d73c..957216f 100644 --- a/TeamsISO.sln +++ b/TeamsISO.sln @@ -17,6 +17,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.App", "src\TeamsIS EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Engine.IntegrationTests", "src\tests\TeamsISO.Engine.IntegrationTests\TeamsISO.Engine.IntegrationTests.csproj", "{A85E331D-026E-4BDE-B89C-0CC4C95001CE}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TeamsISO.Console", "src\TeamsISO.Console\TeamsISO.Console.csproj", "{C3254998-9428-4264-A8FB-EAC9E1F9F432}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -46,6 +48,10 @@ Global {A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Debug|Any CPU.Build.0 = Debug|Any CPU {A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.ActiveCfg = Release|Any CPU {A85E331D-026E-4BDE-B89C-0CC4C95001CE}.Release|Any CPU.Build.0 = Release|Any CPU + {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3254998-9428-4264-A8FB-EAC9E1F9F432}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {F0D24EAE-9225-4DC4-B3D2-6966077287A0} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} @@ -54,5 +60,6 @@ Global {F8DBD7AB-E160-4B75-88FC-BAECDD4D44E8} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848} {80DCE039-3BBC-4D3F-B44B-51F324591C29} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} {A85E331D-026E-4BDE-B89C-0CC4C95001CE} = {DBDF4A1D-4215-42D5-B456-2CE7159DF848} + {C3254998-9428-4264-A8FB-EAC9E1F9F432} = {46E05E34-8A87-4986-87D3-FE0DE4E05F44} EndGlobalSection EndGlobal diff --git a/src/TeamsISO.Console/Program.cs b/src/TeamsISO.Console/Program.cs new file mode 100644 index 0000000..43e1363 --- /dev/null +++ b/src/TeamsISO.Console/Program.cs @@ -0,0 +1,126 @@ +using System.Reactive.Linq; +using Microsoft.Extensions.Logging; +using TeamsISO.Engine.Controller; +using TeamsISO.Engine.Domain; +using TeamsISO.Engine.Interop; +using TeamsISO.Engine.Logging; +using TeamsISO.Engine.NdiInterop; +using TeamsISO.Engine.Persistence; +using TeamsISO.Engine.Pipeline; + +namespace TeamsISO.Console; + +/// +/// Headless smoke runner. Wires up the engine end-to-end against the production NDI P/Invoke +/// interop and prints participant updates, alerts, and ISO state. Useful for first-validation +/// against a real Teams meeting before the WPF UI is alive. +/// +/// Requires Windows + the NDI Runtime installed. +/// +/// Usage: +/// teamsiso-console # discover only — print participants as they appear/leave +/// teamsiso-console --enable-all # auto-enable an ISO for every discovered participant +/// +public static class Program +{ + public static async Task Main(string[] args) + { + var enableAll = args.Contains("--enable-all", StringComparer.OrdinalIgnoreCase); + using var loggerFactory = EngineLogging.CreateConsole(LogLevel.Information); + var logger = loggerFactory.CreateLogger("TeamsISO.Console"); + + if (!OperatingSystem.IsWindows()) + { + logger.LogError("TeamsISO.Console requires Windows + the NDI Runtime. Current OS is not Windows."); + return 1; + } + + NdiInteropPInvoke interop; + try + { + interop = new NdiInteropPInvoke(loggerFactory.CreateLogger()); + } + catch (Exception ex) + { + logger.LogError(ex, "Could not initialize NDI runtime. Install the NDI Runtime from https://ndi.video/tools/ and try again."); + return 2; + } + + var configPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "TeamsISO", "config.json"); + var configStore = new ConfigStore(configPath, loggerFactory.CreateLogger()); + + var probe = new NdiRuntimeProbe(interop, NdiVersion.ExpectedRuntimeVersionPrefix); + var scaler = new ManagedNearestNeighborFrameScaler(); + + // Pipeline factory: wires up real NDI receiver/processor/sender against the production interop. + IsoPipeline PipelineFactory(IsoPipelineConfig config) + { + var clock = new PeriodicTimerFrameClock(config.Settings.FramerateHz); + return new IsoPipeline( + config, interop, scaler, clock, + ExponentialBackoff.Default, + (delay, ct) => Task.Delay(delay, ct), + loggerFactory); + } + + await using var controller = new IsoController( + interop, PipelineFactory, configStore, probe, loggerFactory); + + // Wire up event printing + var sub1 = controller.Participants + .DistinctUntilChanged() + .Subscribe(plist => + { + logger.LogInformation("Participants ({Count}):", plist.Count); + foreach (var p in plist) + logger.LogInformation(" - {Name} source={Source}", p.DisplayName, p.CurrentSource?.FullName ?? ""); + }); + var sub2 = controller.Alerts.Subscribe(a => logger.LogWarning("ALERT: {Message}", a.Message)); + + using var cts = new CancellationTokenSource(); + Console.CancelKeyPress += (_, e) => { e.Cancel = true; cts.Cancel(); }; + + await controller.StartAsync(cts.Token); + logger.LogInformation("Engine started. Default framerate: {Fps:F2} fps. Press Ctrl+C or 'q' Enter to quit.", + controller.GlobalSettings.FramerateHz); + + if (enableAll) + { + // Auto-enable any participant we see, until cancellation. + controller.Participants.Subscribe(async plist => + { + foreach (var p in plist.Where(p => p.CurrentSource is not null)) + { + try { await controller.EnableIsoAsync(p.Id, customName: null, cts.Token); } + catch (Exception ex) { logger.LogWarning(ex, "Could not enable ISO for {Name}.", p.DisplayName); } + } + }); + } + + // Read until 'q' or cancellation. + var input = Task.Run(() => + { + while (!cts.IsCancellationRequested) + { + var line = Console.ReadLine(); + if (line is null) return; + if (line.Trim().Equals("q", StringComparison.OrdinalIgnoreCase)) + { + cts.Cancel(); + return; + } + } + }); + + try { await Task.Delay(Timeout.Infinite, cts.Token); } + catch (OperationCanceledException) { /* expected on quit */ } + + sub1.Dispose(); + sub2.Dispose(); + interop.Dispose(); + logger.LogInformation("Engine stopped."); + return 0; + } +} diff --git a/src/TeamsISO.Console/TeamsISO.Console.csproj b/src/TeamsISO.Console/TeamsISO.Console.csproj new file mode 100644 index 0000000..39fca0f --- /dev/null +++ b/src/TeamsISO.Console/TeamsISO.Console.csproj @@ -0,0 +1,15 @@ + + + + + + + + + Exe + net8.0 + enable + enable + + +