- geviscope-bridge: GeViScope SDK REST wrapper (:7720) - gcore-bridge: G-Core SDK REST wrapper (:7721) - geviserver-bridge: GeViServer REST wrapper (:7710) - copilot-coordinator: WebSocket coordination hub (:8090) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
194 lines
6.9 KiB
C#
194 lines
6.9 KiB
C#
using CopilotCoordinator.Models;
|
|
|
|
namespace CopilotCoordinator.Services;
|
|
|
|
/// <summary>
|
|
/// Sequence execution engine. Ported from legacy SequenceService.cs.
|
|
/// Runs camera rotation sequences by sending CrossSwitch commands to bridges.
|
|
/// </summary>
|
|
public class SequenceRunner
|
|
{
|
|
private readonly Dictionary<int, (CancellationTokenSource Cts, int SequenceId, DateTime StartedAt)> _running = new();
|
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
|
private readonly WsBroadcaster _broadcaster;
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
private readonly IConfiguration _config;
|
|
private readonly ILogger<SequenceRunner> _logger;
|
|
|
|
// Sequence definitions loaded from config (in production, loaded from JSON file)
|
|
private List<SequenceDefinition> _sequences = new();
|
|
private List<SequenceCategory> _categories = new();
|
|
|
|
public SequenceRunner(WsBroadcaster broadcaster, IHttpClientFactory httpClientFactory,
|
|
IConfiguration config, ILogger<SequenceRunner> logger)
|
|
{
|
|
_broadcaster = broadcaster;
|
|
_httpClientFactory = httpClientFactory;
|
|
_config = config;
|
|
_logger = logger;
|
|
}
|
|
|
|
public void LoadSequences(List<SequenceDefinition> sequences, List<SequenceCategory> categories)
|
|
{
|
|
_sequences = sequences;
|
|
_categories = categories;
|
|
_logger.LogInformation("Loaded {Count} sequences in {CatCount} categories", sequences.Count, categories.Count);
|
|
}
|
|
|
|
public IReadOnlyList<SequenceDefinition> GetSequences(int? categoryId = null)
|
|
{
|
|
return categoryId.HasValue
|
|
? _sequences.Where(s => s.CategoryId == categoryId.Value).ToList()
|
|
: _sequences;
|
|
}
|
|
|
|
public IReadOnlyList<SequenceCategory> GetCategories() => _categories;
|
|
|
|
public async Task<RunningSequence?> Start(int viewerId, int sequenceId)
|
|
{
|
|
var sequence = _sequences.FirstOrDefault(s => s.Id == sequenceId);
|
|
if (sequence == null)
|
|
{
|
|
_logger.LogError("Sequence {SequenceId} not found", sequenceId);
|
|
return null;
|
|
}
|
|
|
|
await _lock.WaitAsync();
|
|
try
|
|
{
|
|
// Stop existing sequence on this viewer
|
|
if (_running.TryGetValue(viewerId, out var existing))
|
|
{
|
|
existing.Cts.Cancel();
|
|
_running.Remove(viewerId);
|
|
}
|
|
|
|
var cts = new CancellationTokenSource();
|
|
var startedAt = DateTime.UtcNow;
|
|
_running[viewerId] = (cts, sequenceId, startedAt);
|
|
|
|
// Start the sequence task
|
|
_ = Task.Run(() => RunSequenceLoop(viewerId, sequence, cts.Token), cts.Token);
|
|
|
|
_logger.LogInformation("Started sequence {SequenceId} on viewer {ViewerId}", sequenceId, viewerId);
|
|
|
|
var state = new RunningSequence(viewerId, sequenceId, startedAt, 0);
|
|
await _broadcaster.Broadcast("sequence_started", state);
|
|
return state;
|
|
}
|
|
finally { _lock.Release(); }
|
|
}
|
|
|
|
public async Task Stop(int viewerId)
|
|
{
|
|
await _lock.WaitAsync();
|
|
try
|
|
{
|
|
if (_running.TryGetValue(viewerId, out var existing))
|
|
{
|
|
existing.Cts.Cancel();
|
|
_running.Remove(viewerId);
|
|
_logger.LogInformation("Stopped sequence on viewer {ViewerId}", viewerId);
|
|
}
|
|
}
|
|
finally { _lock.Release(); }
|
|
|
|
await _broadcaster.Broadcast("sequence_stopped", new { viewerId });
|
|
}
|
|
|
|
public async Task<IReadOnlyList<RunningSequence>> GetRunning()
|
|
{
|
|
await _lock.WaitAsync();
|
|
try
|
|
{
|
|
return _running.Select(kvp => new RunningSequence(
|
|
kvp.Key, kvp.Value.SequenceId, kvp.Value.StartedAt, 0
|
|
)).ToList();
|
|
}
|
|
finally { _lock.Release(); }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Core sequence loop. Ported from legacy SequencePeriodTask.
|
|
/// Cycles through cameras, sending CrossSwitch to the appropriate bridge.
|
|
/// </summary>
|
|
private async Task RunSequenceLoop(int viewerId, SequenceDefinition sequence, CancellationToken ct)
|
|
{
|
|
_logger.LogDebug("Sequence loop started: viewer {ViewerId}, sequence {SequenceId}", viewerId, sequence.Id);
|
|
|
|
try
|
|
{
|
|
while (!ct.IsCancellationRequested)
|
|
{
|
|
for (int i = 0; i < sequence.Cameras.Count && !ct.IsCancellationRequested; i++)
|
|
{
|
|
var camera = sequence.Cameras[i];
|
|
|
|
// Send CrossSwitch via bridge
|
|
await SendCrossSwitch(viewerId, camera);
|
|
_logger.LogDebug("Sequence {SeqId} viewer {ViewerId}: switched to camera {Camera}",
|
|
sequence.Id, viewerId, camera);
|
|
|
|
await Task.Delay(TimeSpan.FromSeconds(sequence.IntervalSeconds), ct);
|
|
}
|
|
}
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
_logger.LogInformation("Sequence loop cancelled: viewer {ViewerId}", viewerId);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Sequence loop error: viewer {ViewerId}", viewerId);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Send CrossSwitch command to the appropriate bridge.
|
|
/// Determines bridge URL from camera ID ranges in configuration.
|
|
/// </summary>
|
|
private async Task SendCrossSwitch(int viewerId, int camera)
|
|
{
|
|
try
|
|
{
|
|
// Determine which bridge handles this camera based on configured ranges
|
|
var bridgeUrl = ResolveBridgeUrl(camera);
|
|
if (bridgeUrl == null)
|
|
{
|
|
_logger.LogWarning("No bridge found for camera {Camera}", camera);
|
|
return;
|
|
}
|
|
|
|
var client = _httpClientFactory.CreateClient();
|
|
client.Timeout = TimeSpan.FromSeconds(5);
|
|
var response = await client.PostAsJsonAsync($"{bridgeUrl}/viewer/connect-live",
|
|
new { Viewer = viewerId, Channel = camera });
|
|
|
|
if (!response.IsSuccessStatusCode)
|
|
{
|
|
_logger.LogWarning("CrossSwitch failed: viewer {Viewer} camera {Camera} status {Status}",
|
|
viewerId, camera, response.StatusCode);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "CrossSwitch error: viewer {ViewerId} camera {Camera}", viewerId, camera);
|
|
}
|
|
}
|
|
|
|
private string? ResolveBridgeUrl(int camera)
|
|
{
|
|
// Read bridge mappings from config: "Bridges" section with camera ranges
|
|
var bridges = _config.GetSection("Bridges").GetChildren();
|
|
foreach (var bridge in bridges)
|
|
{
|
|
var start = bridge.GetValue<int>("CameraRangeStart");
|
|
var end = bridge.GetValue<int>("CameraRangeEnd");
|
|
if (camera >= start && camera <= end)
|
|
return bridge.GetValue<string>("Url");
|
|
}
|
|
// Fallback: first bridge
|
|
return bridges.FirstOrDefault()?.GetValue<string>("Url");
|
|
}
|
|
}
|