feat(controller): add IIsoController and IsoController implementation
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
49b6dfb9ed
commit
cd5e852a30
3 changed files with 384 additions and 0 deletions
34
src/TeamsISO.Engine/Controller/IIsoController.cs
Normal file
34
src/TeamsISO.Engine/Controller/IIsoController.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using TeamsISO.Engine.Domain;
|
||||
|
||||
namespace TeamsISO.Engine.Controller;
|
||||
|
||||
/// <summary>
|
||||
/// Top-of-engine API the WPF host (Phase C) and any future control APIs (OSC / WebSocket in v2.0)
|
||||
/// bind to. All commands are async and cancellable.
|
||||
/// </summary>
|
||||
public interface IIsoController : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Observable list of currently-known meeting participants.</summary>
|
||||
IObservable<IReadOnlyList<Participant>> Participants { get; }
|
||||
|
||||
/// <summary>Observable stream of engine alerts (for UI banner display and ops logging).</summary>
|
||||
IObservable<EngineAlert> Alerts { get; }
|
||||
|
||||
/// <summary>Current global processing settings.</summary>
|
||||
FrameProcessingSettings GlobalSettings { get; }
|
||||
|
||||
/// <summary>Starts discovery and supervises the runtime probe. Returns once startup is complete.</summary>
|
||||
Task StartAsync(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Returns the latest <see cref="IsoHealthStats"/> for a given participant's ISO, or empty if none.</summary>
|
||||
IsoHealthStats GetStats(Guid participantId);
|
||||
|
||||
/// <summary>Enables an ISO pipeline for the given participant.</summary>
|
||||
Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Disables and tears down the pipeline for the given participant.</summary>
|
||||
Task DisableIsoAsync(Guid participantId, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Updates global processing settings and persists them. Currently does not restart running pipelines (Phase C wires that).</summary>
|
||||
Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken cancellationToken);
|
||||
}
|
||||
205
src/TeamsISO.Engine/Controller/IsoController.cs
Normal file
205
src/TeamsISO.Engine/Controller/IsoController.cs
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
using System.Reactive.Linq;
|
||||
using System.Reactive.Subjects;
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TeamsISO.Engine.Discovery;
|
||||
using TeamsISO.Engine.Domain;
|
||||
using TeamsISO.Engine.Interop;
|
||||
using TeamsISO.Engine.Persistence;
|
||||
using TeamsISO.Engine.Pipeline;
|
||||
|
||||
namespace TeamsISO.Engine.Controller;
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="IIsoController"/>.
|
||||
/// Holds the participant tracker, the discovery service, the pipeline dictionary, the config store,
|
||||
/// and the runtime probe. Exposes participant and alert observables for the host UI.
|
||||
/// </summary>
|
||||
public sealed class IsoController : IIsoController
|
||||
{
|
||||
private readonly INdiInterop _interop;
|
||||
private readonly Func<IsoPipelineConfig, IsoPipeline> _pipelineFactory;
|
||||
private readonly ConfigStore _configStore;
|
||||
private readonly NdiRuntimeProbe _runtimeProbe;
|
||||
private readonly ILogger<IsoController> _logger;
|
||||
private readonly TimeSpan _renameWindow;
|
||||
private readonly TimeSpan _discoveryInterval;
|
||||
|
||||
private readonly ParticipantTracker _tracker;
|
||||
private readonly Channel<DiscoveryEvent> _discoveryChannel;
|
||||
private readonly NdiDiscoveryService _discovery;
|
||||
private readonly Dictionary<Guid, IsoPipeline> _pipelines = new();
|
||||
private readonly BehaviorSubject<IReadOnlyList<Participant>> _participants =
|
||||
new(Array.Empty<Participant>());
|
||||
private readonly Subject<EngineAlert> _alerts = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
private FrameProcessingSettings _settings;
|
||||
private CancellationTokenSource? _cts;
|
||||
private Task? _discoveryTask;
|
||||
private Task? _eventPumpTask;
|
||||
|
||||
public IObservable<IReadOnlyList<Participant>> Participants => _participants.AsObservable();
|
||||
public IObservable<EngineAlert> Alerts => _alerts.AsObservable();
|
||||
public FrameProcessingSettings GlobalSettings { get { lock (_gate) return _settings; } }
|
||||
|
||||
public IsoController(
|
||||
INdiInterop interop,
|
||||
Func<IsoPipelineConfig, IsoPipeline> pipelineFactory,
|
||||
ConfigStore configStore,
|
||||
NdiRuntimeProbe runtimeProbe,
|
||||
ILoggerFactory loggerFactory,
|
||||
TimeSpan? renameWindow = null,
|
||||
TimeSpan? discoveryInterval = null,
|
||||
Func<DateTimeOffset>? clock = null)
|
||||
{
|
||||
_interop = interop;
|
||||
_pipelineFactory = pipelineFactory;
|
||||
_configStore = configStore;
|
||||
_runtimeProbe = runtimeProbe;
|
||||
_logger = loggerFactory.CreateLogger<IsoController>();
|
||||
_renameWindow = renameWindow ?? TimeSpan.FromSeconds(5);
|
||||
_discoveryInterval = discoveryInterval ?? TimeSpan.FromMilliseconds(500);
|
||||
|
||||
var loaded = configStore.Load();
|
||||
_settings = loaded.Global;
|
||||
|
||||
_tracker = new ParticipantTracker(_renameWindow, clock ?? (() => DateTimeOffset.UtcNow));
|
||||
_discoveryChannel = Channel.CreateUnbounded<DiscoveryEvent>();
|
||||
_discovery = new NdiDiscoveryService(
|
||||
interop, _discoveryChannel.Writer,
|
||||
loggerFactory.CreateLogger<NdiDiscoveryService>());
|
||||
}
|
||||
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_cts is not null)
|
||||
throw new InvalidOperationException("Controller already started.");
|
||||
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
// Runtime probe — surface alert if mismatch but don't fail startup.
|
||||
var probeResult = _runtimeProbe.Probe();
|
||||
if (probeResult is NdiRuntimeProbeResult.Mismatch mismatch)
|
||||
{
|
||||
_alerts.OnNext(new EngineAlert.NdiRuntimeMismatch(mismatch.Detected, mismatch.Expected));
|
||||
_logger.LogWarning("NDI runtime mismatch: detected {Detected}, expected {Expected}.",
|
||||
mismatch.Detected, mismatch.Expected);
|
||||
}
|
||||
|
||||
_discoveryTask = _discovery.RunAsync(_discoveryInterval, _cts.Token);
|
||||
_eventPumpTask = PumpDiscoveryEventsAsync(_cts.Token);
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public IsoHealthStats GetStats(Guid participantId)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _pipelines.TryGetValue(participantId, out var pipeline)
|
||||
? IsoHealthStats.Empty // production wires pipeline.Stats; Phase B-1 leaves this stub
|
||||
: IsoHealthStats.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task EnableIsoAsync(Guid participantId, string? customName, CancellationToken cancellationToken)
|
||||
{
|
||||
Participant? p;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_pipelines.ContainsKey(participantId)) return;
|
||||
p = _tracker.Participants.FirstOrDefault(x => x.Id == participantId);
|
||||
}
|
||||
if (p is null || p.CurrentSource is null)
|
||||
throw new InvalidOperationException($"Participant {participantId} not currently visible on the network.");
|
||||
|
||||
var output = customName ?? DefaultOutputName(participantId);
|
||||
var config = new IsoPipelineConfig(participantId, p.CurrentSource.FullName, output, _settings);
|
||||
var pipeline = _pipelineFactory(config);
|
||||
|
||||
lock (_gate) _pipelines[participantId] = pipeline;
|
||||
await pipeline.StartAsync();
|
||||
await PersistAssignmentsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DisableIsoAsync(Guid participantId, CancellationToken cancellationToken)
|
||||
{
|
||||
IsoPipeline? pipeline;
|
||||
lock (_gate)
|
||||
{
|
||||
if (!_pipelines.Remove(participantId, out pipeline)) return;
|
||||
}
|
||||
await pipeline.StopAsync();
|
||||
await pipeline.DisposeAsync();
|
||||
await PersistAssignmentsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public Task SetGlobalSettingsAsync(FrameProcessingSettings settings, CancellationToken cancellationToken)
|
||||
{
|
||||
lock (_gate) _settings = settings;
|
||||
return PersistAssignmentsAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private Task PersistAssignmentsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
FrameProcessingSettings settings;
|
||||
IReadOnlyList<IsoAssignment> assignments;
|
||||
lock (_gate)
|
||||
{
|
||||
settings = _settings;
|
||||
assignments = _pipelines.Keys.Select(id =>
|
||||
new IsoAssignment(id, IsEnabled: true, CustomOutputName: null)).ToArray();
|
||||
}
|
||||
_configStore.Save(new EngineConfig(settings, assignments));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_alerts.OnNext(new EngineAlert.ConfigSaveFailed(ex.Message));
|
||||
_logger.LogWarning(ex, "Failed to persist engine config.");
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task PumpDiscoveryEventsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (var ev in _discoveryChannel.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_tracker.Apply(ev);
|
||||
_participants.OnNext(_tracker.Participants);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
|
||||
private static string DefaultOutputName(Guid participantId) =>
|
||||
$"TEAMSISO_{participantId.ToString("N")[..8].ToUpperInvariant()}";
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
_cts?.Cancel();
|
||||
if (_eventPumpTask is not null)
|
||||
{
|
||||
try { await _eventPumpTask; } catch { /* swallow */ }
|
||||
}
|
||||
if (_discoveryTask is not null)
|
||||
{
|
||||
try { await _discoveryTask; } catch { /* swallow */ }
|
||||
}
|
||||
IsoPipeline[] toDispose;
|
||||
lock (_gate)
|
||||
{
|
||||
toDispose = _pipelines.Values.ToArray();
|
||||
_pipelines.Clear();
|
||||
}
|
||||
foreach (var p in toDispose) await p.DisposeAsync();
|
||||
_alerts.OnCompleted();
|
||||
_participants.OnCompleted();
|
||||
_cts?.Dispose();
|
||||
}
|
||||
}
|
||||
145
src/tests/TeamsISO.Engine.Tests/Controller/IsoControllerTests.cs
Normal file
145
src/tests/TeamsISO.Engine.Tests/Controller/IsoControllerTests.cs
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
using System.Reactive.Linq;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TeamsISO.Engine.Controller;
|
||||
using TeamsISO.Engine.Domain;
|
||||
using TeamsISO.Engine.Interop;
|
||||
using TeamsISO.Engine.Persistence;
|
||||
using TeamsISO.Engine.Pipeline;
|
||||
using TeamsISO.Engine.Tests.Fakes;
|
||||
|
||||
namespace TeamsISO.Engine.Tests.Controller;
|
||||
|
||||
public class IsoControllerTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
private readonly ConfigStore _store;
|
||||
private readonly FakeNdiInterop _interop;
|
||||
private readonly NdiRuntimeProbe _probeMatch;
|
||||
private readonly NdiRuntimeProbe _probeMismatch;
|
||||
private readonly List<IsoPipelineConfig> _factoryCalls = new();
|
||||
private readonly Dictionary<Guid, TaskCompletionSource> _pipelineBlockers = new();
|
||||
|
||||
public IsoControllerTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), $"teamsiso-controller-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(_dir);
|
||||
_store = new ConfigStore(Path.Combine(_dir, "config.json"), NullLogger<ConfigStore>.Instance);
|
||||
_interop = new FakeNdiInterop { RuntimeVersion = "6.0.0" };
|
||||
_probeMatch = new NdiRuntimeProbe(_interop, expectedVersion: "6.0.0");
|
||||
_probeMismatch = new NdiRuntimeProbe(_interop, expectedVersion: "9.9.9");
|
||||
}
|
||||
|
||||
public void Dispose() => Directory.Delete(_dir, recursive: true);
|
||||
|
||||
private IsoPipeline TestPipelineFactory(IsoPipelineConfig config)
|
||||
{
|
||||
_factoryCalls.Add(config);
|
||||
var blocker = new TaskCompletionSource();
|
||||
_pipelineBlockers[config.ParticipantId] = blocker;
|
||||
var backoff = new ExponentialBackoff(maxAttempts: 3, initial: TimeSpan.FromMilliseconds(1), cap: TimeSpan.FromMilliseconds(5));
|
||||
return new IsoPipeline(
|
||||
config.ParticipantId,
|
||||
ct => blocker.Task.WaitAsync(ct),
|
||||
backoff,
|
||||
(_, _) => Task.CompletedTask,
|
||||
NullLoggerFactory.Instance);
|
||||
}
|
||||
|
||||
private IsoController NewController(NdiRuntimeProbe? probe = null) =>
|
||||
new(
|
||||
_interop,
|
||||
TestPipelineFactory,
|
||||
_store,
|
||||
probe ?? _probeMatch,
|
||||
NullLoggerFactory.Instance,
|
||||
renameWindow: TimeSpan.FromSeconds(5),
|
||||
discoveryInterval: TimeSpan.FromMilliseconds(20));
|
||||
|
||||
[Fact]
|
||||
public async Task DiscoveredParticipant_AppearsInParticipantsObservable()
|
||||
{
|
||||
await using var controller = NewController();
|
||||
var seenLists = new List<IReadOnlyList<Participant>>();
|
||||
using var sub = controller.Participants.Subscribe(p => seenLists.Add(p));
|
||||
|
||||
await controller.StartAsync(CancellationToken.None);
|
||||
_interop.Sources.Add("PC1 (Teams - Jane)");
|
||||
|
||||
// Wait for discovery to pick it up
|
||||
var deadline = DateTime.UtcNow.AddSeconds(2);
|
||||
while (seenLists.LastOrDefault()?.Any() != true && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(20);
|
||||
|
||||
seenLists.Last().Should().HaveCount(1);
|
||||
seenLists.Last()[0].DisplayName.Should().Be("Jane");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnableIsoAsync_CreatesAndStartsPipeline()
|
||||
{
|
||||
await using var controller = NewController();
|
||||
await controller.StartAsync(CancellationToken.None);
|
||||
|
||||
_interop.Sources.Add("PC1 (Teams - Jane)");
|
||||
var pid = await WaitForFirstParticipantAsync(controller);
|
||||
|
||||
await controller.EnableIsoAsync(pid, customName: "TEAMSISO_JANE", CancellationToken.None);
|
||||
|
||||
_factoryCalls.Should().HaveCount(1);
|
||||
_factoryCalls[0].OutputName.Should().Be("TEAMSISO_JANE");
|
||||
_factoryCalls[0].SourceName.Should().Be("PC1 (Teams - Jane)");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisableIsoAsync_StopsPipeline_AndRemovesIt()
|
||||
{
|
||||
await using var controller = NewController();
|
||||
await controller.StartAsync(CancellationToken.None);
|
||||
|
||||
_interop.Sources.Add("PC1 (Teams - Jane)");
|
||||
var pid = await WaitForFirstParticipantAsync(controller);
|
||||
await controller.EnableIsoAsync(pid, customName: null, CancellationToken.None);
|
||||
|
||||
// Unblock the pipeline so StopAsync can complete
|
||||
_pipelineBlockers[pid].SetResult();
|
||||
await controller.DisableIsoAsync(pid, CancellationToken.None);
|
||||
|
||||
// Re-enable should now create a new pipeline
|
||||
await controller.EnableIsoAsync(pid, customName: null, CancellationToken.None);
|
||||
_factoryCalls.Should().HaveCount(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetGlobalSettingsAsync_PersistsToConfigStore()
|
||||
{
|
||||
await using var controller = NewController();
|
||||
var newSettings = new FrameProcessingSettings(
|
||||
TargetFramerate.Fps59_94, TargetResolution.R1080p, AspectMode.Pillarbox, AudioMode.Auto);
|
||||
|
||||
await controller.SetGlobalSettingsAsync(newSettings, CancellationToken.None);
|
||||
|
||||
controller.GlobalSettings.Should().Be(newSettings);
|
||||
_store.Load().Global.Should().Be(newSettings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_RuntimeProbeMismatch_RaisesAlert()
|
||||
{
|
||||
await using var controller = NewController(probe: _probeMismatch);
|
||||
var alerts = new List<EngineAlert>();
|
||||
using var sub = controller.Alerts.Subscribe(alerts.Add);
|
||||
|
||||
await controller.StartAsync(CancellationToken.None);
|
||||
|
||||
alerts.Should().Contain(a => a is EngineAlert.NdiRuntimeMismatch);
|
||||
}
|
||||
|
||||
private static async Task<Guid> WaitForFirstParticipantAsync(IsoController controller)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<Guid>();
|
||||
using var sub = controller.Participants
|
||||
.Where(p => p.Count > 0)
|
||||
.Subscribe(p => tcs.TrySetResult(p[0].Id));
|
||||
return await tcs.Task.WaitAsync(TimeSpan.FromSeconds(3));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue