diff --git a/.gitignore b/.gitignore
index d66e033..4f9efa1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,5 +38,9 @@ test_coordinator.ps1
package.json
nul
+# .NET build outputs
+**/bin/
+**/obj/
+
# Decompiled text dumps
C*DEVCOPILOT_D6*
diff --git a/copilot-coordinator/Models/CameraLock.cs b/copilot-coordinator/Models/CameraLock.cs
new file mode 100644
index 0000000..8da311a
--- /dev/null
+++ b/copilot-coordinator/Models/CameraLock.cs
@@ -0,0 +1,36 @@
+namespace CopilotCoordinator.Models;
+
+public record CameraLock(
+ int CameraId,
+ CameraLockPriority Priority,
+ string OwnerName,
+ DateTime OwnedSince,
+ DateTime ExpiresAt
+);
+
+public enum CameraLockPriority { None, High, Low }
+
+public enum CameraLockNotificationType
+{
+ Acquired,
+ TakenOver,
+ ConfirmTakeOver,
+ Confirmed,
+ Rejected,
+ ExpireSoon,
+ Unlocked
+}
+
+public record CameraLockResult(bool Acquired, CameraLock? Lock);
+
+public record CameraLockNotification(
+ CameraLockNotificationType Type,
+ int CameraId,
+ string CopilotName
+);
+
+public record LockRequest(int CameraId, string KeyboardId, string Priority = "low");
+public record UnlockRequest(int CameraId, string KeyboardId);
+public record TakeoverRequest(int CameraId, string KeyboardId, string Priority = "low");
+public record TakeoverConfirmRequest(int CameraId, string KeyboardId, bool Confirm);
+public record ResetExpirationRequest(int CameraId, string KeyboardId);
diff --git a/copilot-coordinator/Models/Messages.cs b/copilot-coordinator/Models/Messages.cs
new file mode 100644
index 0000000..470e7d4
--- /dev/null
+++ b/copilot-coordinator/Models/Messages.cs
@@ -0,0 +1,24 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace CopilotCoordinator.Models;
+
+///
+/// WebSocket message envelope for all coordinator events.
+///
+public class WsMessage
+{
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = "";
+
+ [JsonPropertyName("data")]
+ public JsonElement? Data { get; set; }
+
+ public static string Serialize(string type, object? data = null)
+ {
+ var msg = new { type, data };
+ return JsonSerializer.Serialize(msg);
+ }
+}
+
+public record KeyboardInfo(string Id, string? Name, DateTime ConnectedAt);
diff --git a/copilot-coordinator/Models/Sequence.cs b/copilot-coordinator/Models/Sequence.cs
new file mode 100644
index 0000000..b6c4836
--- /dev/null
+++ b/copilot-coordinator/Models/Sequence.cs
@@ -0,0 +1,21 @@
+namespace CopilotCoordinator.Models;
+
+public record SequenceDefinition(
+ int Id,
+ string Name,
+ int CategoryId,
+ List Cameras,
+ int IntervalSeconds
+);
+
+public record SequenceCategory(int Id, string Name);
+
+public record RunningSequence(
+ int ViewerId,
+ int SequenceId,
+ DateTime StartedAt,
+ int CurrentCameraIndex
+);
+
+public record SequenceStartRequest(int ViewerId, int SequenceId);
+public record SequenceStopRequest(int ViewerId);
diff --git a/copilot-coordinator/Program.cs b/copilot-coordinator/Program.cs
new file mode 100644
index 0000000..2313c84
--- /dev/null
+++ b/copilot-coordinator/Program.cs
@@ -0,0 +1,159 @@
+using System.Net.WebSockets;
+using CopilotCoordinator.Models;
+using CopilotCoordinator.Services;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Configure port from config (default 8090)
+var port = builder.Configuration.GetValue("Port", 8090);
+builder.WebHost.UseUrls($"http://0.0.0.0:{port}");
+
+// Register services
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+builder.Services.AddHttpClient();
+
+var app = builder.Build();
+
+// Load sequence definitions from config file if present
+var seqRunner = app.Services.GetRequiredService();
+var seqConfig = builder.Configuration.GetSection("Sequences");
+if (seqConfig.Exists())
+{
+ var sequences = seqConfig.Get>() ?? new();
+ var categories = builder.Configuration.GetSection("SequenceCategories").Get>() ?? new();
+ seqRunner.LoadSequences(sequences, categories);
+}
+
+// --- Lock expiration background timer (1 second, like legacy CameraLockExpirationWorker) ---
+var lockManager = app.Services.GetRequiredService();
+var expirationTimer = new PeriodicTimer(TimeSpan.FromSeconds(1));
+_ = Task.Run(async () =>
+{
+ while (await expirationTimer.WaitForNextTickAsync())
+ {
+ try { await lockManager.CheckExpirations(); }
+ catch (Exception ex)
+ {
+ app.Logger.LogError(ex, "Lock expiration check failed");
+ }
+ }
+});
+
+// --- WebSocket endpoint ---
+app.UseWebSockets();
+app.Map("/ws", async (HttpContext ctx) =>
+{
+ if (!ctx.WebSockets.IsWebSocketRequest)
+ {
+ ctx.Response.StatusCode = 400;
+ return;
+ }
+
+ var keyboardId = ctx.Request.Query["keyboard"].FirstOrDefault() ?? "unknown";
+ var ws = await ctx.WebSockets.AcceptWebSocketAsync();
+ var clientId = Guid.NewGuid().ToString();
+ var broadcaster = ctx.RequestServices.GetRequiredService();
+
+ await broadcaster.HandleConnection(ws, clientId, keyboardId);
+});
+
+// --- Health & Status ---
+app.MapGet("/health", () => Results.Ok(new { status = "ok", timestamp = DateTime.UtcNow }));
+
+app.MapGet("/status", async (LockManager locks, SequenceRunner sequences, WsBroadcaster ws) =>
+{
+ var allLocks = await locks.GetAllLocks();
+ var running = await sequences.GetRunning();
+ var keyboards = ws.GetConnectedKeyboards();
+
+ return Results.Ok(new
+ {
+ status = "ok",
+ keyboards = keyboards.Count,
+ keyboardList = keyboards,
+ activeLocks = allLocks.Count,
+ locks = allLocks,
+ runningSequences = running.Count,
+ sequences = running
+ });
+});
+
+// --- Lock endpoints ---
+app.MapPost("/locks/try", async (LockRequest req, LockManager mgr) =>
+{
+ var priority = Enum.TryParse(req.Priority, true, out var p) ? p : CameraLockPriority.Low;
+ var result = await mgr.TryLock(req.CameraId, req.KeyboardId, priority);
+ return Results.Ok(result);
+});
+
+app.MapPost("/locks/release", async (UnlockRequest req, LockManager mgr) =>
+{
+ await mgr.Unlock(req.CameraId, req.KeyboardId);
+ return Results.Ok(new { success = true });
+});
+
+app.MapPost("/locks/takeover", async (TakeoverRequest req, LockManager mgr) =>
+{
+ var priority = Enum.TryParse(req.Priority, true, out var p) ? p : CameraLockPriority.Low;
+ await mgr.RequestTakeover(req.CameraId, req.KeyboardId, priority);
+ return Results.Ok(new { success = true });
+});
+
+app.MapPost("/locks/confirm", async (TakeoverConfirmRequest req, LockManager mgr) =>
+{
+ await mgr.ConfirmTakeover(req.CameraId, req.KeyboardId, req.Confirm);
+ return Results.Ok(new { success = true });
+});
+
+app.MapPost("/locks/reset", async (ResetExpirationRequest req, LockManager mgr) =>
+{
+ await mgr.ResetExpiration(req.CameraId, req.KeyboardId);
+ return Results.Ok(new { success = true });
+});
+
+app.MapGet("/locks", async (LockManager mgr) =>
+{
+ var locks = await mgr.GetAllLocks();
+ return Results.Ok(locks);
+});
+
+app.MapGet("/locks/{keyboardId}", async (string keyboardId, LockManager mgr) =>
+{
+ var ids = await mgr.GetLockedCameraIds(keyboardId);
+ return Results.Ok(ids);
+});
+
+// --- Sequence endpoints ---
+app.MapPost("/sequences/start", async (SequenceStartRequest req, SequenceRunner runner) =>
+{
+ var result = await runner.Start(req.ViewerId, req.SequenceId);
+ return result != null ? Results.Ok(result) : Results.NotFound(new { error = "Sequence not found" });
+});
+
+app.MapPost("/sequences/stop", async (SequenceStopRequest req, SequenceRunner runner) =>
+{
+ await runner.Stop(req.ViewerId);
+ return Results.Ok(new { success = true });
+});
+
+app.MapGet("/sequences/running", async (SequenceRunner runner) =>
+{
+ var running = await runner.GetRunning();
+ return Results.Ok(running);
+});
+
+app.MapGet("/sequences", (int? categoryId, SequenceRunner runner) =>
+{
+ var sequences = runner.GetSequences(categoryId);
+ return Results.Ok(sequences);
+});
+
+app.MapGet("/sequences/categories", (SequenceRunner runner) =>
+{
+ return Results.Ok(runner.GetCategories());
+});
+
+app.Logger.LogInformation("COPILOT Coordinator starting on port {Port}", port);
+app.Run();
diff --git a/copilot-coordinator/Properties/launchSettings.json b/copilot-coordinator/Properties/launchSettings.json
new file mode 100644
index 0000000..a0e8e0b
--- /dev/null
+++ b/copilot-coordinator/Properties/launchSettings.json
@@ -0,0 +1,29 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "iisSettings": {
+ "windowsAuthentication": false,
+ "anonymousAuthentication": true,
+ "iisExpress": {
+ "applicationUrl": "http://localhost:35036",
+ "sslPort": 0
+ }
+ },
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5200",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "IIS Express": {
+ "commandName": "IISExpress",
+ "launchBrowser": true,
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/copilot-coordinator/Services/LockManager.cs b/copilot-coordinator/Services/LockManager.cs
new file mode 100644
index 0000000..eddcbbf
--- /dev/null
+++ b/copilot-coordinator/Services/LockManager.cs
@@ -0,0 +1,287 @@
+using CopilotCoordinator.Models;
+
+namespace CopilotCoordinator.Services;
+
+///
+/// Camera lock manager. Ported from legacy CameraLocksService.cs (~363 lines).
+/// In-memory state only — locks are lost on restart (same as legacy AppServer).
+/// Locks expire after configurable timeout (default 5 minutes).
+///
+public class LockManager
+{
+ private readonly SemaphoreSlim _locksLock = new(1, 1);
+ private readonly Dictionary _locks = new();
+
+ private readonly SemaphoreSlim _requestsLock = new(1, 1);
+ private readonly Dictionary> _requests = new();
+
+ private readonly TimeSpan _expirationTimeout;
+ private readonly TimeSpan _warningBefore;
+ private readonly WsBroadcaster _broadcaster;
+ private readonly ILogger _logger;
+
+ // Track which locks have already been warned about (to avoid repeat notifications)
+ private readonly Dictionary _warnedLocks = new();
+
+ public LockManager(IConfiguration config, WsBroadcaster broadcaster, ILogger logger)
+ {
+ _expirationTimeout = config.GetValue("LockExpiration:Timeout", TimeSpan.FromMinutes(5));
+ _warningBefore = config.GetValue("LockExpiration:WarningBefore", TimeSpan.FromMinutes(1));
+ _broadcaster = broadcaster;
+ _logger = logger;
+ }
+
+ public async Task> GetAllLocks()
+ {
+ await _locksLock.WaitAsync();
+ try { return _locks.Values.ToList(); }
+ finally { _locksLock.Release(); }
+ }
+
+ public async Task> GetLockedCameraIds(string keyboardId)
+ {
+ await _locksLock.WaitAsync();
+ try
+ {
+ return _locks.Values
+ .Where(l => l.OwnerName.Equals(keyboardId, StringComparison.OrdinalIgnoreCase))
+ .Select(l => l.CameraId)
+ .ToList();
+ }
+ finally { _locksLock.Release(); }
+ }
+
+ public async Task TryLock(int cameraId, string keyboardId, CameraLockPriority priority)
+ {
+ _logger.LogInformation("TryLock camera {CameraId} by {Keyboard} priority {Priority}", cameraId, keyboardId, priority);
+
+ await _locksLock.WaitAsync();
+ try
+ {
+ if (!_locks.TryGetValue(cameraId, out var existing))
+ {
+ // Camera is free — acquire lock
+ var now = DateTime.UtcNow;
+ var newLock = new CameraLock(cameraId, priority, keyboardId, now, now + _expirationTimeout);
+ _locks[cameraId] = newLock;
+
+ _logger.LogInformation("Camera {CameraId} locked by {Keyboard}", cameraId, keyboardId);
+ await _broadcaster.Broadcast("lock_acquired", newLock);
+
+ // If there's a pending takeover request, notify the new owner
+ await NotifyPendingTakeover(cameraId);
+
+ return new CameraLockResult(true, newLock);
+ }
+
+ // Camera already locked — check if priority can take over
+ if (CanTakeOver(priority, existing.Priority))
+ {
+ var previousOwner = existing.OwnerName;
+ var now = DateTime.UtcNow;
+ var newLock = new CameraLock(cameraId, priority, keyboardId, now, now + _expirationTimeout);
+ _locks[cameraId] = newLock;
+
+ _logger.LogInformation("Camera {CameraId} taken over from {Previous} by {Keyboard}", cameraId, previousOwner, keyboardId);
+
+ // Notify previous owner
+ await _broadcaster.SendTo(previousOwner, "lock_notification", new CameraLockNotification(
+ CameraLockNotificationType.TakenOver, cameraId, keyboardId));
+
+ await _broadcaster.Broadcast("lock_acquired", newLock);
+ return new CameraLockResult(true, newLock);
+ }
+
+ // Cannot take over — return current lock info
+ _logger.LogInformation("Camera {CameraId} lock denied for {Keyboard} — held by {Owner}", cameraId, keyboardId, existing.OwnerName);
+ return new CameraLockResult(false, existing);
+ }
+ finally { _locksLock.Release(); }
+ }
+
+ public async Task Unlock(int cameraId, string keyboardId)
+ {
+ _logger.LogInformation("Unlock camera {CameraId} by {Keyboard}", cameraId, keyboardId);
+
+ await _locksLock.WaitAsync();
+ try
+ {
+ if (!_locks.TryGetValue(cameraId, out var existing))
+ {
+ _logger.LogWarning("Cannot unlock camera {CameraId} — not locked", cameraId);
+ return;
+ }
+
+ _locks.Remove(cameraId);
+ _warnedLocks.Remove(cameraId);
+
+ _logger.LogInformation("Camera {CameraId} unlocked", cameraId);
+ }
+ finally { _locksLock.Release(); }
+
+ await _broadcaster.Broadcast("lock_released", new { cameraId });
+
+ // Notify the owner
+ await _broadcaster.SendTo(keyboardId, "lock_notification", new CameraLockNotification(
+ CameraLockNotificationType.Unlocked, cameraId, keyboardId));
+ }
+
+ public async Task RequestTakeover(int cameraId, string keyboardId, CameraLockPriority priority)
+ {
+ _logger.LogInformation("Takeover requested for camera {CameraId} by {Keyboard}", cameraId, keyboardId);
+
+ string? currentOwner;
+ await _locksLock.WaitAsync();
+ try
+ {
+ if (!_locks.TryGetValue(cameraId, out var existing))
+ return;
+ currentOwner = existing.OwnerName;
+ }
+ finally { _locksLock.Release(); }
+
+ // Queue the request
+ await _requestsLock.WaitAsync();
+ bool isFirst;
+ try
+ {
+ if (!_requests.TryGetValue(cameraId, out var list))
+ {
+ list = new();
+ _requests[cameraId] = list;
+ }
+ list.Add((keyboardId, priority, DateTime.UtcNow));
+ list.Sort((a, b) => a.CreatedAt.CompareTo(b.CreatedAt));
+ isFirst = list.Count == 1;
+ }
+ finally { _requestsLock.Release(); }
+
+ // Only notify current owner if this is the first request (avoid spamming)
+ if (isFirst)
+ {
+ await _broadcaster.SendTo(currentOwner, "lock_notification", new CameraLockNotification(
+ CameraLockNotificationType.ConfirmTakeOver, cameraId, keyboardId));
+ }
+ }
+
+ public async Task ConfirmTakeover(int cameraId, string requestingKeyboardId, bool confirm)
+ {
+ _logger.LogInformation("Takeover confirm for camera {CameraId} by {Keyboard}: {Confirm}", cameraId, requestingKeyboardId, confirm);
+
+ // Remove the request
+ await _requestsLock.WaitAsync();
+ try
+ {
+ if (_requests.TryGetValue(cameraId, out var list))
+ {
+ list.RemoveAll(r => r.KeyboardId == requestingKeyboardId);
+ if (list.Count == 0) _requests.Remove(cameraId);
+ }
+ }
+ finally { _requestsLock.Release(); }
+
+ if (confirm)
+ {
+ // Unlock the current owner
+ await _locksLock.WaitAsync();
+ try
+ {
+ if (_locks.TryGetValue(cameraId, out var existing))
+ {
+ _locks.Remove(cameraId);
+ _warnedLocks.Remove(cameraId);
+ }
+ }
+ finally { _locksLock.Release(); }
+
+ await _broadcaster.Broadcast("lock_released", new { cameraId });
+ }
+
+ // Notify the requester of the result
+ await _broadcaster.SendTo(requestingKeyboardId, "lock_notification", new CameraLockNotification(
+ confirm ? CameraLockNotificationType.Confirmed : CameraLockNotificationType.Rejected,
+ cameraId, requestingKeyboardId));
+ }
+
+ public async Task ResetExpiration(int cameraId, string keyboardId)
+ {
+ await _locksLock.WaitAsync();
+ try
+ {
+ if (_locks.TryGetValue(cameraId, out var existing) &&
+ existing.OwnerName.Equals(keyboardId, StringComparison.OrdinalIgnoreCase))
+ {
+ var newExpiry = DateTime.UtcNow + _expirationTimeout;
+ _locks[cameraId] = existing with { ExpiresAt = newExpiry };
+ _warnedLocks.Remove(cameraId);
+ }
+ }
+ finally { _locksLock.Release(); }
+ }
+
+ ///
+ /// Called every second by the expiration timer. Checks for expired and soon-to-expire locks.
+ /// Ported from legacy CameraLockExpirationWorker.cs.
+ ///
+ public async Task CheckExpirations()
+ {
+ var now = DateTime.UtcNow;
+ var toUnlock = new List<(int CameraId, string OwnerName)>();
+ var toWarn = new List<(int CameraId, string OwnerName)>();
+
+ await _locksLock.WaitAsync();
+ try
+ {
+ foreach (var (id, lck) in _locks)
+ {
+ if (lck.ExpiresAt <= now)
+ {
+ toUnlock.Add((id, lck.OwnerName));
+ }
+ else if (lck.ExpiresAt <= now + _warningBefore)
+ {
+ if (!_warnedLocks.TryGetValue(id, out var prevExpiry) || prevExpiry != lck.ExpiresAt)
+ {
+ toWarn.Add((id, lck.OwnerName));
+ _warnedLocks[id] = lck.ExpiresAt;
+ }
+ }
+ }
+ }
+ finally { _locksLock.Release(); }
+
+ // Process expirations outside the lock
+ foreach (var (cameraId, owner) in toUnlock)
+ {
+ _logger.LogInformation("Lock expired for camera {CameraId}", cameraId);
+ await Unlock(cameraId, owner);
+ }
+
+ foreach (var (cameraId, owner) in toWarn)
+ {
+ await _broadcaster.SendTo(owner, "lock_notification", new CameraLockNotification(
+ CameraLockNotificationType.ExpireSoon, cameraId, owner));
+ _logger.LogInformation("Lock expiring soon for camera {CameraId}, notified {Owner}", cameraId, owner);
+ }
+ }
+
+ private async Task NotifyPendingTakeover(int cameraId)
+ {
+ await _requestsLock.WaitAsync();
+ try
+ {
+ if (_requests.TryGetValue(cameraId, out var list) && list.Count > 0)
+ {
+ var first = list[0];
+ // Will be handled by existing owner through ConfirmTakeOver flow
+ }
+ }
+ finally { _requestsLock.Release(); }
+ }
+
+ private static bool CanTakeOver(CameraLockPriority requester, CameraLockPriority holder)
+ {
+ // High priority can take over Low priority (same as legacy CameraLockPriorityExtensions.CanTakeOver)
+ return requester == CameraLockPriority.High && holder == CameraLockPriority.Low;
+ }
+}
diff --git a/copilot-coordinator/Services/SequenceRunner.cs b/copilot-coordinator/Services/SequenceRunner.cs
new file mode 100644
index 0000000..7f8a10a
--- /dev/null
+++ b/copilot-coordinator/Services/SequenceRunner.cs
@@ -0,0 +1,193 @@
+using CopilotCoordinator.Models;
+
+namespace CopilotCoordinator.Services;
+
+///
+/// Sequence execution engine. Ported from legacy SequenceService.cs.
+/// Runs camera rotation sequences by sending CrossSwitch commands to bridges.
+///
+public class SequenceRunner
+{
+ private readonly Dictionary _running = new();
+ private readonly SemaphoreSlim _lock = new(1, 1);
+ private readonly WsBroadcaster _broadcaster;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IConfiguration _config;
+ private readonly ILogger _logger;
+
+ // Sequence definitions loaded from config (in production, loaded from JSON file)
+ private List _sequences = new();
+ private List _categories = new();
+
+ public SequenceRunner(WsBroadcaster broadcaster, IHttpClientFactory httpClientFactory,
+ IConfiguration config, ILogger logger)
+ {
+ _broadcaster = broadcaster;
+ _httpClientFactory = httpClientFactory;
+ _config = config;
+ _logger = logger;
+ }
+
+ public void LoadSequences(List sequences, List categories)
+ {
+ _sequences = sequences;
+ _categories = categories;
+ _logger.LogInformation("Loaded {Count} sequences in {CatCount} categories", sequences.Count, categories.Count);
+ }
+
+ public IReadOnlyList GetSequences(int? categoryId = null)
+ {
+ return categoryId.HasValue
+ ? _sequences.Where(s => s.CategoryId == categoryId.Value).ToList()
+ : _sequences;
+ }
+
+ public IReadOnlyList GetCategories() => _categories;
+
+ public async Task 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> GetRunning()
+ {
+ await _lock.WaitAsync();
+ try
+ {
+ return _running.Select(kvp => new RunningSequence(
+ kvp.Key, kvp.Value.SequenceId, kvp.Value.StartedAt, 0
+ )).ToList();
+ }
+ finally { _lock.Release(); }
+ }
+
+ ///
+ /// Core sequence loop. Ported from legacy SequencePeriodTask.
+ /// Cycles through cameras, sending CrossSwitch to the appropriate bridge.
+ ///
+ 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);
+ }
+ }
+
+ ///
+ /// Send CrossSwitch command to the appropriate bridge.
+ /// Determines bridge URL from camera ID ranges in configuration.
+ ///
+ 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("CameraRangeStart");
+ var end = bridge.GetValue("CameraRangeEnd");
+ if (camera >= start && camera <= end)
+ return bridge.GetValue("Url");
+ }
+ // Fallback: first bridge
+ return bridges.FirstOrDefault()?.GetValue("Url");
+ }
+}
diff --git a/copilot-coordinator/Services/WsBroadcaster.cs b/copilot-coordinator/Services/WsBroadcaster.cs
new file mode 100644
index 0000000..0e160a9
--- /dev/null
+++ b/copilot-coordinator/Services/WsBroadcaster.cs
@@ -0,0 +1,122 @@
+using System.Collections.Concurrent;
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+using CopilotCoordinator.Models;
+
+namespace CopilotCoordinator.Services;
+
+///
+/// WebSocket broadcaster. Manages connected keyboard clients and sends events.
+///
+public class WsBroadcaster
+{
+ private readonly ConcurrentDictionary _clients = new();
+ private readonly ILogger _logger;
+
+ public WsBroadcaster(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public IReadOnlyList GetConnectedKeyboards()
+ {
+ return _clients.Values
+ .Select(c => new KeyboardInfo(c.KeyboardId, null, DateTime.UtcNow))
+ .ToList();
+ }
+
+ public async Task HandleConnection(WebSocket ws, string clientId, string keyboardId)
+ {
+ _clients[clientId] = (ws, keyboardId);
+ _logger.LogInformation("Keyboard {KeyboardId} connected (client {ClientId})", keyboardId, clientId);
+
+ await Broadcast("keyboard_online", new { keyboardId });
+
+ try
+ {
+ var buffer = new byte[4096];
+ while (ws.State == WebSocketState.Open)
+ {
+ var result = await ws.ReceiveAsync(buffer, CancellationToken.None);
+ if (result.MessageType == WebSocketMessageType.Close)
+ break;
+
+ // Parse incoming messages (keyboard can send commands via WebSocket too)
+ if (result.MessageType == WebSocketMessageType.Text)
+ {
+ var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
+ _logger.LogDebug("Received from {KeyboardId}: {Message}", keyboardId, message);
+ }
+ }
+ }
+ catch (WebSocketException ex)
+ {
+ _logger.LogWarning("WebSocket error for {KeyboardId}: {Message}", keyboardId, ex.Message);
+ }
+ finally
+ {
+ _clients.TryRemove(clientId, out _);
+ _logger.LogInformation("Keyboard {KeyboardId} disconnected", keyboardId);
+ await Broadcast("keyboard_offline", new { keyboardId });
+ }
+ }
+
+ ///
+ /// Broadcast a message to all connected keyboards.
+ ///
+ public async Task Broadcast(string type, object? data = null)
+ {
+ var json = WsMessage.Serialize(type, data);
+ var bytes = Encoding.UTF8.GetBytes(json);
+
+ var deadClients = new List();
+
+ foreach (var (clientId, (socket, _)) in _clients)
+ {
+ try
+ {
+ if (socket.State == WebSocketState.Open)
+ {
+ await socket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None);
+ }
+ else
+ {
+ deadClients.Add(clientId);
+ }
+ }
+ catch
+ {
+ deadClients.Add(clientId);
+ }
+ }
+
+ foreach (var id in deadClients)
+ _clients.TryRemove(id, out _);
+ }
+
+ ///
+ /// Send a message to a specific keyboard.
+ ///
+ public async Task SendTo(string keyboardId, string type, object? data = null)
+ {
+ var json = WsMessage.Serialize(type, data);
+ var bytes = Encoding.UTF8.GetBytes(json);
+
+ foreach (var (clientId, (socket, kbId)) in _clients)
+ {
+ if (kbId.Equals(keyboardId, StringComparison.OrdinalIgnoreCase) &&
+ socket.State == WebSocketState.Open)
+ {
+ try
+ {
+ await socket.SendAsync(bytes, WebSocketMessageType.Text, true, CancellationToken.None);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("Failed to send to {KeyboardId}: {Error}", keyboardId, ex.Message);
+ }
+ }
+ }
+ }
+}
diff --git a/copilot-coordinator/appsettings.Development.json b/copilot-coordinator/appsettings.Development.json
new file mode 100644
index 0000000..ff66ba6
--- /dev/null
+++ b/copilot-coordinator/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/copilot-coordinator/appsettings.json b/copilot-coordinator/appsettings.json
new file mode 100644
index 0000000..a982bd3
--- /dev/null
+++ b/copilot-coordinator/appsettings.json
@@ -0,0 +1,30 @@
+{
+ "Port": 8090,
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ },
+ "AllowedHosts": "*",
+ "LockExpiration": {
+ "Timeout": "00:05:00",
+ "WarningBefore": "00:01:00"
+ },
+ "Bridges": [
+ {
+ "Name": "GeViScope",
+ "Url": "http://localhost:7720",
+ "CameraRangeStart": 500000,
+ "CameraRangeEnd": 500999
+ },
+ {
+ "Name": "GCore",
+ "Url": "http://localhost:7721",
+ "CameraRangeStart": 501000,
+ "CameraRangeEnd": 501999
+ }
+ ],
+ "Sequences": [],
+ "SequenceCategories": []
+}
diff --git a/copilot-coordinator/copilot-coordinator.csproj b/copilot-coordinator/copilot-coordinator.csproj
new file mode 100644
index 0000000..e7415d0
--- /dev/null
+++ b/copilot-coordinator/copilot-coordinator.csproj
@@ -0,0 +1,10 @@
+
+
+
+ net8.0
+ enable
+ enable
+ copilot_coordinator
+
+
+
diff --git a/gcore-bridge/GCoreBridge/GCoreBridge.csproj b/gcore-bridge/GCoreBridge/GCoreBridge.csproj
new file mode 100644
index 0000000..fccba90
--- /dev/null
+++ b/gcore-bridge/GCoreBridge/GCoreBridge.csproj
@@ -0,0 +1,80 @@
+
+
+
+ net8.0
+ enable
+ enable
+ x64
+ win-x64
+ false
+
+
+
+
+
+
+
+
+
+ C:\G-Core-SDK\Bin\x64\GngExceptionsNET_SDK.dll
+
+
+ C:\G-Core-SDK\Bin\x64\G-ActionsNET_SDK.dll
+
+
+ C:\G-Core-SDK\Bin\x64\GngDBINET_SDK.dll
+
+
+ C:\G-Core-SDK\Bin\x64\GngMediaPlayerNET_SDK.dll
+
+
+ C:\G-Core-SDK\Bin\x64\GngActionsNET_SDK.dll
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
+ PreserveNewest
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
+
diff --git a/gcore-bridge/GCoreBridge/Program.cs b/gcore-bridge/GCoreBridge/Program.cs
new file mode 100644
index 0000000..f22f713
--- /dev/null
+++ b/gcore-bridge/GCoreBridge/Program.cs
@@ -0,0 +1,1469 @@
+// G-Core Bridge - REST API for Geutebruck G-Core Server SDK
+// Uses GEUTEBRUECK.Gng namespaces from G-Core SDK
+
+using GEUTEBRUECK.Gng.Wrapper.Exceptions;
+using GEUTEBRUECK.Gng.Wrapper.DBI;
+using GEUTEBRUECK.Gng.Wrapper.MediaPlayer;
+using GEUTEBRUECK.Gng;
+using GEUTEBRUECK.Gng.PLC;
+using GEUTEBRUECK.Gng.Actions;
+using GEUTEBRUECK.Gng.Actions.System;
+using Microsoft.OpenApi.Models;
+using System.Collections;
+using System.Collections.Concurrent;
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add Swagger services
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(c =>
+{
+ c.SwaggerDoc("v1", new OpenApiInfo
+ {
+ Title = "G-Core Bridge API",
+ Version = "1.0.0",
+ Description = "REST API bridge for Geutebruck G-Core Server SDK. Provides access to camera control, video routing, PTZ, and action/event handling. G-Core is the successor to GeViScope."
+ });
+});
+
+// G-Core connection state
+GngServer? gngServer = null;
+GngPLCWrapper? gngPLC = null;
+Dispatcher? actionDispatcher = null;
+string? currentAddress = null;
+string? currentUsername = null;
+DateTime? connectedAt = null;
+int eventCount = 0;
+List receivedMessages = new List();
+List mediaChannels = new List();
+bool sdkIntegrated = true;
+
+// WebSocket clients for event streaming
+ConcurrentDictionary wsClients = new ConcurrentDictionary();
+
+// Monitor state tracking (populated from ViewerConnected/ViewerCleared events)
+ConcurrentDictionary monitorStates = new ConcurrentDictionary();
+
+// Alarm state tracking (populated from EventStarted/EventStopped events)
+ConcurrentDictionary alarmStates = new ConcurrentDictionary();
+
+// Update monitor state from event
+void UpdateMonitorState(string actionName, int viewer, int channel = 0, int playMode = 0)
+{
+ if (actionName == "ViewerConnected" || actionName == "ViewerSelectionChanged")
+ {
+ monitorStates[viewer] = new MonitorState
+ {
+ ViewerId = viewer,
+ CurrentChannel = channel,
+ PlayMode = playMode,
+ LastUpdated = DateTime.UtcNow
+ };
+ }
+ else if (actionName == "ViewerCleared")
+ {
+ monitorStates[viewer] = new MonitorState
+ {
+ ViewerId = viewer,
+ CurrentChannel = 0,
+ PlayMode = 0,
+ LastUpdated = DateTime.UtcNow
+ };
+ }
+}
+
+// Update alarm state from event
+void UpdateAlarmState(string eventType, long eventId, string eventName, long typeId, long foreignKey)
+{
+ if (eventType == "EventStarted")
+ {
+ alarmStates[eventId] = new AlarmState
+ {
+ EventId = eventId,
+ EventName = eventName,
+ TypeId = typeId,
+ ForeignKey = foreignKey,
+ StartedAt = DateTime.UtcNow,
+ IsActive = true
+ };
+ }
+ else if (eventType == "EventStopped")
+ {
+ if (alarmStates.TryGetValue(eventId, out var alarm))
+ {
+ alarm.IsActive = false;
+ alarm.StoppedAt = DateTime.UtcNow;
+ }
+ }
+}
+
+// Broadcast event to all connected WebSocket clients
+async Task BroadcastEvent(object eventData)
+{
+ var json = JsonSerializer.Serialize(eventData);
+ var buffer = Encoding.UTF8.GetBytes(json);
+ var segment = new ArraySegment(buffer);
+
+ var deadClients = new List();
+
+ foreach (var (clientId, ws) in wsClients)
+ {
+ if (ws.State == WebSocketState.Open)
+ {
+ try
+ {
+ await ws.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None);
+ }
+ catch
+ {
+ deadClients.Add(clientId);
+ }
+ }
+ else
+ {
+ deadClients.Add(clientId);
+ }
+ }
+
+ foreach (var clientId in deadClients)
+ {
+ wsClients.TryRemove(clientId, out _);
+ }
+}
+
+// Parse action string into structured event
+object? ParseActionToEvent(string actionStr)
+{
+ var match = System.Text.RegularExpressions.Regex.Match(actionStr, @"(\w+)\(([^)]*)\)");
+ if (!match.Success) return null;
+
+ var actionName = match.Groups[1].Value;
+ var paramsStr = match.Groups[2].Value;
+ var paramValues = paramsStr.Split(',').Select(p => p.Trim().Trim('"')).ToArray();
+
+ // Update local state tracking based on action type
+ if (actionName == "ViewerConnected" && paramValues.Length >= 3)
+ {
+ int.TryParse(paramValues[0], out var viewer);
+ int.TryParse(paramValues[1], out var channel);
+ int.TryParse(paramValues[2], out var playMode);
+ UpdateMonitorState(actionName, viewer, channel, playMode);
+ }
+ else if (actionName == "ViewerCleared" && paramValues.Length >= 1)
+ {
+ int.TryParse(paramValues[0], out var viewer);
+ UpdateMonitorState(actionName, viewer);
+ }
+ else if (actionName == "ViewerSelectionChanged" && paramValues.Length >= 3)
+ {
+ int.TryParse(paramValues[0], out var viewer);
+ int.TryParse(paramValues[1], out var channel);
+ int.TryParse(paramValues[2], out var playMode);
+ UpdateMonitorState(actionName, viewer, channel, playMode);
+ }
+
+ return actionName switch
+ {
+ "ViewerConnected" when paramValues.Length >= 3 => new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = "ViewerConnected",
+ @params = new
+ {
+ Viewer = int.TryParse(paramValues[0], out var v) ? v : 0,
+ Channel = int.TryParse(paramValues[1], out var c) ? c : 0,
+ PlayMode = int.TryParse(paramValues[2], out var pm) ? pm : 0
+ }
+ },
+ "ViewerCleared" when paramValues.Length >= 1 => new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = "ViewerCleared",
+ @params = new { Viewer = int.TryParse(paramValues[0], out var v) ? v : 0 }
+ },
+ "ViewerSelectionChanged" when paramValues.Length >= 3 => new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = "ViewerSelectionChanged",
+ @params = new
+ {
+ Viewer = int.TryParse(paramValues[0], out var v) ? v : 0,
+ Channel = int.TryParse(paramValues[1], out var c) ? c : 0,
+ PlayMode = int.TryParse(paramValues[2], out var pm) ? pm : 0
+ }
+ },
+ "DigitalInput" when paramValues.Length >= 2 => new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = "DigitalInput",
+ @params = new
+ {
+ Contact = int.TryParse(paramValues[0], out var contact) ? contact : 0,
+ State = int.TryParse(paramValues[1], out var state) ? state : 0
+ }
+ },
+ "VCAlarmQueueNotification" when paramValues.Length >= 4 => new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = "VCAlarmQueueNotification",
+ @params = new
+ {
+ Viewer = int.TryParse(paramValues[0], out var v) ? v : 0,
+ Notification = int.TryParse(paramValues[1], out var n) ? n : 0,
+ AlarmID = int.TryParse(paramValues[2], out var aid) ? aid : 0,
+ TypeID = int.TryParse(paramValues[3], out var tid) ? tid : 0
+ }
+ },
+ _ => new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = actionName,
+ raw = actionStr
+ }
+ };
+}
+
+// PlayMode string to numeric value mapping (compatible with GeViScope action format)
+var playModeMap = new Dictionary(StringComparer.OrdinalIgnoreCase)
+{
+ { "unknown", 0 },
+ { "play stop", 1 },
+ { "play forward", 2 },
+ { "play backward", 3 },
+ { "fast forward", 4 },
+ { "fast backward", 5 },
+ { "step forward", 6 },
+ { "step backward", 7 },
+ { "play BOD", 8 },
+ { "play EOD", 9 },
+ { "quasi live", 10 },
+ { "live", 11 },
+ { "next event", 12 },
+ { "prev event", 13 },
+ { "peek live picture", 14 },
+ { "next detected motion", 17 },
+ { "prev detected motion", 18 }
+};
+
+// Helper function to get numeric PlayMode value
+int GetPlayModeValue(string playMode)
+{
+ if (playModeMap.TryGetValue(playMode, out int value))
+ return value;
+ if (int.TryParse(playMode, out int numValue))
+ return numValue;
+ Console.WriteLine($"Unknown PlayMode '{playMode}', defaulting to 'play stop' (1)");
+ return 1;
+}
+
+// Helper to add log message
+void AddLog(string message)
+{
+ var logMsg = $"[{DateTime.Now:HH:mm:ss}] {message}";
+ Console.WriteLine(logMsg);
+ receivedMessages.Add(logMsg);
+ // Keep only last 1000 messages
+ if (receivedMessages.Count > 1000)
+ receivedMessages.RemoveAt(0);
+}
+
+// Helper to destroy PLC
+void DestroyPLC(bool connectionLost = false)
+{
+ if (gngPLC != null)
+ {
+ try
+ {
+ if (!connectionLost)
+ {
+ gngPLC.UnsubscribeAll();
+ gngPLC.CloseCallback();
+ }
+ gngPLC.Dispose();
+ }
+ catch { }
+ gngPLC = null;
+ }
+ actionDispatcher = null;
+}
+
+// Helper to create PLC for actions
+void CreatePLC()
+{
+ if (gngServer == null) return;
+
+ try
+ {
+ DestroyPLC();
+
+ // Create PLC from server
+ gngPLC = gngServer.CreatePLC();
+
+ // Set up push callback for receiving actions/events
+ gngPLC.PLCCallback += (sender, e) =>
+ {
+ try
+ {
+ eventCount++;
+
+ if (e.PlcNotification.GetNotificationType() == GngPlcNotificationType.plcnNewActionData)
+ {
+ // Decode received action
+ byte[] actionBuf = e.PlcNotification.GetAction();
+ var receivedAction = GngAction.Create(actionBuf);
+ if (receivedAction != null)
+ {
+ var actionText = receivedAction.ActionText ?? "";
+ AddLog($"Action received: {actionText}");
+
+ // Broadcast to WebSocket clients
+ var wsEvent = ParseActionToEvent(actionText);
+ if (wsEvent != null && wsClients.Count > 0)
+ {
+ _ = BroadcastEvent(wsEvent);
+ }
+ }
+ }
+ else if (e.PlcNotification.GetNotificationType() == GngPlcNotificationType.plcnNewEventData)
+ {
+ var eventData = e.PlcNotification.GetEventData();
+ if (eventData != null)
+ {
+ var eventType = eventData.EventNotificationType switch
+ {
+ GngPlcEventNotificationType.plcenEventStarted => "started",
+ GngPlcEventNotificationType.plcenEventStopped => "stopped",
+ GngPlcEventNotificationType.plcenEventRetriggered => "retriggered",
+ _ => "unknown"
+ };
+ AddLog($"Event: {eventData.EventHeader.EventName} {eventData.EventHeader.EventID} {eventType}");
+
+ // Update local alarm state tracking
+ var actionType = eventType == "started" ? "EventStarted" : eventType == "stopped" ? "EventStopped" : "EventRetriggered";
+ UpdateAlarmState(actionType, eventData.EventHeader.EventID, eventData.EventHeader.EventName ?? "",
+ eventData.EventHeader.EventTypeID, Convert.ToInt64(eventData.EventHeader.ForeignKey));
+
+ // Broadcast event notifications to WebSocket clients
+ var wsEvent = new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = actionType,
+ @params = new
+ {
+ EventID = eventData.EventHeader.EventID,
+ EventName = eventData.EventHeader.EventName,
+ TypeID = eventData.EventHeader.EventTypeID,
+ ForeignKey = eventData.EventHeader.ForeignKey
+ }
+ };
+ if (wsClients.Count > 0)
+ {
+ _ = BroadcastEvent(wsEvent);
+ }
+ }
+ }
+ else if (e.PlcNotification.GetNotificationType() == GngPlcNotificationType.plcnPushCallbackLost)
+ {
+ AddLog("Connection lost!");
+
+ // Broadcast connection lost to WebSocket clients
+ var wsEvent = new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = "ConnectionLost",
+ @params = new { }
+ };
+ if (wsClients.Count > 0)
+ {
+ _ = BroadcastEvent(wsEvent);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ AddLog($"PLC callback error: {ex.Message}");
+ }
+ };
+
+ // Open push callback
+ gngPLC.OpenPushCallback();
+
+ // Initialize action dispatcher
+ actionDispatcher = new Dispatcher();
+
+ // Subscribe to all actions and events
+ gngPLC.SubscribeActionsAll();
+ gngPLC.SubscribeEventsAll();
+
+ AddLog("PLC created and subscribed successfully");
+ }
+ catch (Exception ex)
+ {
+ AddLog($"Failed to create PLC: {ex.Message}");
+ }
+}
+
+// Helper to load media channels
+void LoadMediaChannels()
+{
+ if (gngServer == null) return;
+
+ try
+ {
+ mediaChannels.Clear();
+ ArrayList channels = new ArrayList();
+ MediaPlayerHelperFunctions.QueryMediaChannelList(gngServer, out channels);
+
+ foreach (GngMediaChannelData mc in channels)
+ {
+ if (mc.IsActive)
+ {
+ mediaChannels.Add(new MediaChannelInfo
+ {
+ ChannelID = mc.ChannelID,
+ GlobalNumber = mc.GlobalNumber,
+ Name = mc.Name ?? $"Channel {mc.GlobalNumber}",
+ Description = mc.Desc ?? "",
+ IsActive = mc.IsActive
+ });
+ }
+ }
+ AddLog($"Loaded {mediaChannels.Count} media channels");
+ }
+ catch (Exception ex)
+ {
+ AddLog($"Failed to load media channels: {ex.Message}");
+ }
+}
+
+var app = builder.Build();
+
+// Enable Swagger
+app.UseSwagger();
+app.UseSwaggerUI();
+
+// ============================================================================
+// API ENDPOINTS
+// ============================================================================
+
+// Health check
+app.MapGet("/", () => Results.Ok(new
+{
+ service = "G-Core Bridge",
+ status = "running",
+ version = "1.0.0",
+ sdk_integrated = sdkIntegrated,
+ connected = gngServer != null
+}));
+
+// Connection endpoint
+app.MapPost("/connect", (ConnectRequest request) =>
+{
+ try
+ {
+ AddLog($"Connecting to {request.Address} as {request.Username}...");
+
+ // Disconnect if already connected
+ if (gngServer != null)
+ {
+ DestroyPLC();
+ try
+ {
+ gngServer.Disconnect(5000);
+ gngServer.Dispose();
+ }
+ catch { }
+ gngServer = null;
+ mediaChannels.Clear();
+ }
+
+ // Create new server connection
+ gngServer = new GngServer();
+
+ // Encode password
+ string encodedPassword = DBIHelperFunctions.EncodePassword(request.Password);
+
+ // Set connection parameters and connect
+ using (var connectParams = new GngServerConnectParams(request.Address, request.Username, encodedPassword))
+ {
+ gngServer.SetConnectParams(connectParams);
+ var result = gngServer.Connect();
+
+ if (result == GngServerConnectResult.connectOk)
+ {
+ currentAddress = request.Address;
+ currentUsername = request.Username;
+ connectedAt = DateTime.Now;
+
+ // Create PLC for actions
+ CreatePLC();
+
+ // Load media channels
+ LoadMediaChannels();
+
+ AddLog($"Connected to {request.Address} successfully");
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "Connected successfully",
+ address = currentAddress,
+ username = currentUsername,
+ channel_count = mediaChannels.Count,
+ connected_at = connectedAt
+ });
+ }
+ else
+ {
+ var errorMsg = result.ToString();
+ gngServer.Dispose();
+ gngServer = null;
+
+ AddLog($"Connection failed: {errorMsg}");
+
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = errorMsg,
+ result_code = result.ToString()
+ });
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ AddLog($"Connection error: {ex.Message}");
+ if (gngServer != null)
+ {
+ try { gngServer.Dispose(); } catch { }
+ gngServer = null;
+ }
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "Connection error",
+ message = ex.Message
+ });
+ }
+});
+
+// Disconnect endpoint
+app.MapPost("/disconnect", () =>
+{
+ try
+ {
+ if (gngServer != null)
+ {
+ AddLog("Disconnecting...");
+ DestroyPLC();
+ gngServer.Disconnect(5000);
+ gngServer.Dispose();
+ gngServer = null;
+ }
+
+ currentAddress = null;
+ currentUsername = null;
+ connectedAt = null;
+ mediaChannels.Clear();
+
+ AddLog("Disconnected");
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "Disconnected successfully"
+ });
+ }
+ catch (Exception ex)
+ {
+ AddLog($"Disconnect error: {ex.Message}");
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "Disconnect error",
+ message = ex.Message
+ });
+ }
+});
+
+// Status endpoint (enhanced for monitoring)
+app.MapGet("/status", () =>
+{
+ return Results.Ok(new
+ {
+ is_connected = gngServer != null,
+ address = currentAddress,
+ username = currentUsername,
+ channel_count = mediaChannels.Count,
+ connected_at = connectedAt,
+ connection_duration_sec = connectedAt.HasValue ? (int)(DateTime.UtcNow - connectedAt.Value).TotalSeconds : 0,
+ event_count = eventCount,
+ websocket_clients = wsClients.Count,
+ plc_active = gngPLC != null,
+ sdk_integrated = sdkIntegrated
+ });
+});
+
+// Health check endpoint (for load balancers)
+app.MapGet("/health", () =>
+{
+ var isHealthy = gngServer != null && gngPLC != null;
+ return isHealthy
+ ? Results.Ok(new { status = "healthy", server = "gcore-bridge" })
+ : Results.Ok(new { status = "degraded", server = "gcore-bridge", message = "Not connected to G-Core server" });
+});
+
+// Get media channels
+app.MapGet("/channels", () =>
+{
+ return Results.Ok(new
+ {
+ count = mediaChannels.Count,
+ channels = mediaChannels
+ });
+});
+
+// Refresh media channels
+app.MapPost("/channels/refresh", () =>
+{
+ LoadMediaChannels();
+ return Results.Ok(new
+ {
+ count = mediaChannels.Count,
+ channels = mediaChannels
+ });
+});
+
+// Get current monitor states (tracked from ViewerConnected/ViewerCleared events)
+app.MapGet("/monitors", () =>
+{
+ return Results.Ok(new
+ {
+ server = currentAddress,
+ count = monitorStates.Count,
+ monitors = monitorStates.Values.OrderBy(m => m.ViewerId).Select(m => new
+ {
+ viewer_id = m.ViewerId,
+ current_channel = m.CurrentChannel,
+ play_mode = m.PlayMode,
+ last_updated = m.LastUpdated
+ }),
+ note = "State is tracked from ViewerConnected/ViewerCleared events. Initial state requires event activity or server query."
+ });
+});
+
+// Get specific monitor state
+app.MapGet("/monitors/{viewerId}", (int viewerId) =>
+{
+ if (monitorStates.TryGetValue(viewerId, out var state))
+ {
+ return Results.Ok(new
+ {
+ viewer_id = state.ViewerId,
+ current_channel = state.CurrentChannel,
+ play_mode = state.PlayMode,
+ last_updated = state.LastUpdated
+ });
+ }
+ return Results.NotFound(new { error = "Monitor not found or no events received yet" });
+});
+
+// Get active alarms (tracked from EventStarted/EventStopped notifications)
+app.MapGet("/alarms/active", () =>
+{
+ var activeAlarms = alarmStates.Values.Where(a => a.IsActive).OrderBy(a => a.StartedAt);
+ return Results.Ok(new
+ {
+ server = currentAddress,
+ count = activeAlarms.Count(),
+ alarms = activeAlarms.Select(a => new
+ {
+ event_id = a.EventId,
+ event_name = a.EventName,
+ type_id = a.TypeId,
+ foreign_key = a.ForeignKey,
+ started_at = a.StartedAt
+ }),
+ note = "Alarms tracked from EventStarted/EventStopped notifications. For complete alarm state on startup, query GeViServer bridge."
+ });
+});
+
+// Get all alarms (including stopped)
+app.MapGet("/alarms", () =>
+{
+ return Results.Ok(new
+ {
+ server = currentAddress,
+ total = alarmStates.Count,
+ active = alarmStates.Values.Count(a => a.IsActive),
+ alarms = alarmStates.Values.OrderByDescending(a => a.StartedAt).Select(a => new
+ {
+ event_id = a.EventId,
+ event_name = a.EventName,
+ type_id = a.TypeId,
+ foreign_key = a.ForeignKey,
+ started_at = a.StartedAt,
+ stopped_at = a.StoppedAt,
+ is_active = a.IsActive
+ })
+ });
+});
+
+// Send action (generic)
+app.MapPost("/action", (ActionRequest request) =>
+{
+ try
+ {
+ AddLog($"Sending action: {request.Action}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "Not connected",
+ message = "Connect to a server first"
+ });
+ }
+
+ // Create action from string
+ var action = GngAction.Create(request.Action);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+
+ return Results.Ok(new
+ {
+ success = true,
+ action = request.Action
+ });
+ }
+
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "Invalid action",
+ message = "Could not decode action string"
+ });
+ }
+ catch (Exception ex)
+ {
+ AddLog($"Action error: {ex.Message}");
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "Action error",
+ message = ex.Message
+ });
+ }
+});
+
+// Send CustomAction
+app.MapPost("/custom-action", (CustomActionRequest request) =>
+{
+ try
+ {
+ AddLog($"Sending CustomAction({request.TypeId}, \"{request.Text}\")");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "Not connected"
+ });
+ }
+
+ // Create CustomAction directly
+ var customAction = new CustomAction(request.TypeId, request.Text ?? "");
+ gngPLC.SendAction(customAction);
+
+ return Results.Ok(new
+ {
+ success = true,
+ type_id = request.TypeId,
+ text = request.Text
+ });
+ }
+ catch (Exception ex)
+ {
+ AddLog($"CustomAction error: {ex.Message}");
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "CustomAction error",
+ message = ex.Message
+ });
+ }
+});
+
+// ViewerConnect - connect a viewer to a channel with play mode
+app.MapPost("/viewer/connect", (ViewerConnectRequest request) =>
+{
+ try
+ {
+ var playModeValue = GetPlayModeValue(request.PlayMode);
+ var actionStr = $"ViewerConnect({request.Viewer}, {request.Channel}, {playModeValue})";
+ AddLog($"Sending: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+
+ return Results.Ok(new
+ {
+ success = true,
+ viewer = request.Viewer,
+ channel = request.Channel,
+ play_mode = request.PlayMode
+ });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { success = false, error = ex.Message });
+ }
+});
+
+// ViewerConnectLive - connect viewer to live channel
+app.MapPost("/viewer/connect-live", (ViewerConnectLiveRequest request) =>
+{
+ try
+ {
+ var actionStr = $"ViewerConnectLive({request.Viewer}, {request.Channel})";
+ AddLog($"Sending: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+
+ return Results.Ok(new
+ {
+ success = true,
+ viewer = request.Viewer,
+ channel = request.Channel
+ });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { success = false, error = ex.Message });
+ }
+});
+
+// ViewerClear - clear viewer display
+app.MapPost("/viewer/clear", (ViewerClearRequest request) =>
+{
+ try
+ {
+ var actionStr = $"ViewerClear({request.Viewer})";
+ AddLog($"Sending: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new { success = true, viewer = request.Viewer });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { success = false, error = ex.Message });
+ }
+});
+
+// CrossSwitch compatibility endpoint
+app.MapPost("/crossswitch", (CrossSwitchRequest request) =>
+{
+ try
+ {
+ var actionStr = $"ViewerConnectLive({request.VideoOutput}, {request.VideoInput})";
+ AddLog($"CrossSwitch: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new
+ {
+ success = true,
+ viewer = request.VideoOutput,
+ channel = request.VideoInput
+ });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { success = false, error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Pan
+app.MapPost("/camera/pan", (CameraPanRequest request) =>
+{
+ try
+ {
+ var direction = request.Direction.ToLower() == "left" ? "Left" : "Right";
+ var actionStr = $"Pan{direction}({request.Camera}, {request.Speed})";
+ AddLog($"PTZ: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new { success = true, camera = request.Camera, direction, speed = request.Speed });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Tilt
+app.MapPost("/camera/tilt", (CameraTiltRequest request) =>
+{
+ try
+ {
+ var direction = request.Direction.ToLower() == "up" ? "Up" : "Down";
+ var actionStr = $"Tilt{direction}({request.Camera}, {request.Speed})";
+ AddLog($"PTZ: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new { success = true, camera = request.Camera, direction, speed = request.Speed });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Zoom
+app.MapPost("/camera/zoom", (CameraZoomRequest request) =>
+{
+ try
+ {
+ var direction = request.Direction.ToLower() == "in" ? "In" : "Out";
+ var actionStr = $"Zoom{direction}({request.Camera}, {request.Speed})";
+ AddLog($"PTZ: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new { success = true, camera = request.Camera, direction, speed = request.Speed });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Stop all movement
+app.MapPost("/camera/stop", (CameraStopRequest request) =>
+{
+ try
+ {
+ var actionStr = $"CameraStopAll({request.Camera})";
+ AddLog($"PTZ: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new { success = true, camera = request.Camera });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Stop pan
+app.MapPost("/camera/pan-stop", (CameraStopRequest request) =>
+{
+ try
+ {
+ var actionStr = $"PanStop({request.Camera})";
+ AddLog($"PTZ: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new { success = true, camera = request.Camera });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Stop tilt
+app.MapPost("/camera/tilt-stop", (CameraStopRequest request) =>
+{
+ try
+ {
+ var actionStr = $"TiltStop({request.Camera})";
+ AddLog($"PTZ: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new { success = true, camera = request.Camera });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Stop zoom
+app.MapPost("/camera/zoom-stop", (CameraStopRequest request) =>
+{
+ try
+ {
+ var actionStr = $"ZoomStop({request.Camera})";
+ AddLog($"PTZ: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new { success = true, camera = request.Camera });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Go to preset
+app.MapPost("/camera/preset", (CameraPresetRequest request) =>
+{
+ try
+ {
+ var actionStr = $"PrePosCallUp({request.Camera}, {request.Preset})";
+ AddLog($"PTZ: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new { success = true, camera = request.Camera, preset = request.Preset });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// Digital Output - Close contact
+app.MapPost("/digital-io/close", (DigitalContactRequest request) =>
+{
+ try
+ {
+ var actionStr = $"CloseDigitalOutput({request.ContactId})";
+ AddLog($"DigitalIO: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new { success = true, contact_id = request.ContactId });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// Digital Output - Open contact
+app.MapPost("/digital-io/open", (DigitalContactRequest request) =>
+{
+ try
+ {
+ var actionStr = $"OpenDigitalOutput({request.ContactId})";
+ AddLog($"DigitalIO: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new { success = true, contact_id = request.ContactId });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// ============================================================================
+// PLAYBACK CONTROL ENDPOINTS
+// ============================================================================
+
+// ViewerSetPlayMode - Set playback mode and speed
+app.MapPost("/viewer/set-play-mode", (ViewerSetPlayModeRequest request) =>
+{
+ try
+ {
+ var playModeValue = GetPlayModeValue(request.PlayMode);
+ var actionStr = $"ViewerSetPlayMode({request.Viewer}, {playModeValue}, {request.PlaySpeed})";
+ AddLog($"Playback: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new
+ {
+ success = true,
+ viewer = request.Viewer,
+ play_mode = request.PlayMode,
+ play_speed = request.PlaySpeed
+ });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { success = false, error = ex.Message });
+ }
+});
+
+// ViewerPlayFromTime - Go to specific date/time
+app.MapPost("/viewer/play-from-time", (ViewerPlayFromTimeRequest request) =>
+{
+ try
+ {
+ var playModeValue = GetPlayModeValue(request.PlayMode);
+ var actionStr = $"ViewerPlayFromTime({request.Viewer}, {request.Channel}, {playModeValue}, \"{request.Time}\")";
+ AddLog($"Playback: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new
+ {
+ success = true,
+ viewer = request.Viewer,
+ channel = request.Channel,
+ play_mode = request.PlayMode,
+ time = request.Time
+ });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { success = false, error = ex.Message });
+ }
+});
+
+// ViewerJumpByTime - Jump playback position by seconds
+app.MapPost("/viewer/jump-by-time", (ViewerJumpByTimeRequest request) =>
+{
+ try
+ {
+ var playModeValue = GetPlayModeValue(request.PlayMode);
+ var actionStr = $"ViewerJumpByTime({request.Viewer}, {request.Channel}, {playModeValue}, {request.TimeInSec})";
+ AddLog($"Playback: {actionStr}");
+
+ if (gngPLC == null)
+ {
+ return Results.BadRequest(new { success = false, error = "Not connected" });
+ }
+
+ var action = GngAction.Create(actionStr);
+ if (action != null)
+ {
+ gngPLC.SendAction(action);
+ return Results.Ok(new
+ {
+ success = true,
+ viewer = request.Viewer,
+ channel = request.Channel,
+ play_mode = request.PlayMode,
+ time_in_sec = request.TimeInSec
+ });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to decode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { success = false, error = ex.Message });
+ }
+});
+
+// Get message log
+app.MapGet("/messages", () =>
+{
+ return Results.Ok(new
+ {
+ count = receivedMessages.Count,
+ messages = receivedMessages.TakeLast(100).ToList()
+ });
+});
+
+// Clear message log
+app.MapPost("/messages/clear", () =>
+{
+ receivedMessages.Clear();
+ return Results.Ok(new { message = "Message log cleared" });
+});
+
+// Enable WebSocket support
+app.UseWebSockets();
+
+// WebSocket endpoint for event streaming
+app.Map("/ws/events", async context =>
+{
+ if (context.WebSockets.IsWebSocketRequest)
+ {
+ var ws = await context.WebSockets.AcceptWebSocketAsync();
+ var clientId = Guid.NewGuid().ToString();
+ wsClients[clientId] = ws;
+
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] WebSocket client connected: {clientId}");
+
+ // Send initial connection confirmation
+ var welcome = JsonSerializer.Serialize(new
+ {
+ type = "connected",
+ clientId = clientId,
+ server = currentAddress,
+ message = "Connected to G-Core Bridge event stream"
+ });
+ await ws.SendAsync(
+ new ArraySegment(Encoding.UTF8.GetBytes(welcome)),
+ WebSocketMessageType.Text,
+ true,
+ CancellationToken.None
+ );
+
+ // Keep connection open and handle incoming messages
+ var buffer = new byte[1024];
+ try
+ {
+ while (ws.State == WebSocketState.Open)
+ {
+ var result = await ws.ReceiveAsync(new ArraySegment(buffer), CancellationToken.None);
+
+ if (result.MessageType == WebSocketMessageType.Close)
+ {
+ await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client requested close", CancellationToken.None);
+ break;
+ }
+ }
+ }
+ catch (WebSocketException)
+ {
+ // Client disconnected
+ }
+ finally
+ {
+ wsClients.TryRemove(clientId, out _);
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] WebSocket client disconnected: {clientId}");
+ }
+ }
+ else
+ {
+ context.Response.StatusCode = 400;
+ }
+});
+
+Console.WriteLine("========================================");
+Console.WriteLine("G-Core Bridge v1.0.0");
+Console.WriteLine("========================================");
+Console.WriteLine("Starting on port 7721...");
+Console.WriteLine("Swagger UI: http://localhost:7721/swagger");
+Console.WriteLine("WebSocket events: ws://localhost:7721/ws/events");
+Console.WriteLine("========================================");
+
+app.Run("http://localhost:7721");
+
+// Request/Response Models
+record ConnectRequest(string Address, string Username, string Password);
+record ActionRequest(string Action);
+record CustomActionRequest(int TypeId, string? Text);
+record ViewerConnectRequest(int Viewer, int Channel, string PlayMode = "live");
+record ViewerConnectLiveRequest(int Viewer, int Channel);
+record ViewerClearRequest(int Viewer);
+record CrossSwitchRequest(int VideoInput, int VideoOutput, int SwitchMode = 0);
+record CameraPanRequest(int Camera, string Direction, int Speed = 50);
+record CameraTiltRequest(int Camera, string Direction, int Speed = 50);
+record CameraZoomRequest(int Camera, string Direction, int Speed = 50);
+record CameraStopRequest(int Camera);
+record CameraPresetRequest(int Camera, int Preset);
+record DigitalContactRequest(int ContactId);
+record ViewerSetPlayModeRequest(int Viewer, string PlayMode, double PlaySpeed = 1.0);
+record ViewerPlayFromTimeRequest(int Viewer, int Channel, string PlayMode, string Time);
+record ViewerJumpByTimeRequest(int Viewer, int Channel, string PlayMode, int TimeInSec);
+
+class MediaChannelInfo
+{
+ public long ChannelID { get; set; }
+ public long GlobalNumber { get; set; }
+ public string Name { get; set; } = "";
+ public string Description { get; set; } = "";
+ public bool IsActive { get; set; }
+}
+
+// Monitor state tracked from ViewerConnected/ViewerCleared events
+class MonitorState
+{
+ public int ViewerId { get; set; }
+ public int CurrentChannel { get; set; }
+ public int PlayMode { get; set; }
+ public DateTime LastUpdated { get; set; }
+}
+
+// Alarm state tracked from EventStarted/EventStopped notifications
+class AlarmState
+{
+ public long EventId { get; set; }
+ public string EventName { get; set; } = "";
+ public long TypeId { get; set; }
+ public long ForeignKey { get; set; }
+ public DateTime StartedAt { get; set; }
+ public DateTime? StoppedAt { get; set; }
+ public bool IsActive { get; set; }
+}
diff --git a/geviscope-bridge/GeViScopeBridge/GeViScopeBridge.csproj b/geviscope-bridge/GeViScopeBridge/GeViScopeBridge.csproj
new file mode 100644
index 0000000..3e88c97
--- /dev/null
+++ b/geviscope-bridge/GeViScopeBridge/GeViScopeBridge.csproj
@@ -0,0 +1,74 @@
+
+
+
+ net8.0
+ enable
+ enable
+ x86
+ win-x86
+ false
+
+
+
+
+
+
+
+
+
+ C:\Program Files (x86)\GeViScopeSDK\BIN\GscExceptionsNET_4_0.dll
+
+
+ C:\Program Files (x86)\GeViScopeSDK\BIN\GscActionsNET_4_0.dll
+
+
+ C:\Program Files (x86)\GeViScopeSDK\BIN\GscDBINET_4_0.dll
+
+
+ C:\Program Files (x86)\GeViScopeSDK\BIN\GscMediaPlayerNET_4_0.dll
+
+
+
+
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+ PreserveNewest
+
+
+
+
diff --git a/geviscope-bridge/GeViScopeBridge/Program.cs b/geviscope-bridge/GeViScopeBridge/Program.cs
new file mode 100644
index 0000000..86aa737
--- /dev/null
+++ b/geviscope-bridge/GeViScopeBridge/Program.cs
@@ -0,0 +1,1692 @@
+using GEUTEBRUECK.GeViScope.Wrapper.DBI;
+using GEUTEBRUECK.GeViScope.Wrapper.Actions;
+using GEUTEBRUECK.GeViScope.Wrapper.Actions.SystemActions;
+using GEUTEBRUECK.GeViScope.Wrapper.Actions.DigitalContactsActions;
+using GEUTEBRUECK.GeViScope.Wrapper.Actions.ActionDispatcher;
+using GEUTEBRUECK.GeViScope.Wrapper.MediaPlayer;
+using Microsoft.OpenApi.Models;
+using System.Collections;
+using System.Collections.Concurrent;
+using System.Net.WebSockets;
+using System.Text;
+using System.Text.Json;
+
+var builder = WebApplication.CreateBuilder(args);
+
+// Add CORS for web app access
+builder.Services.AddCors(options =>
+{
+ options.AddDefaultPolicy(policy =>
+ {
+ policy.AllowAnyOrigin()
+ .AllowAnyMethod()
+ .AllowAnyHeader();
+ });
+});
+
+// Add Swagger services
+builder.Services.AddEndpointsApiExplorer();
+builder.Services.AddSwaggerGen(c =>
+{
+ c.SwaggerDoc("v1", new OpenApiInfo
+ {
+ Title = "GeViScope Bridge API",
+ Version = "v1",
+ Description = "REST API bridge for Geutebruck GeViScope Camera Server SDK. Provides access to camera control, video routing, PTZ, and action/event handling."
+ });
+});
+
+// GeViScope connection state
+GscServer? gscServer = null;
+GscPLCWrapper? gscPLC = null;
+GscActionDispatcher? actionDispatcher = null;
+string? currentAddress = null;
+string? currentUsername = null;
+DateTime? connectedAt = null;
+int eventCount = 0;
+List receivedMessages = new List();
+List mediaChannels = new List();
+
+// WebSocket clients for event streaming
+ConcurrentDictionary wsClients = new ConcurrentDictionary();
+
+// Monitor state tracking (populated from ViewerConnected/ViewerCleared events)
+ConcurrentDictionary monitorStates = new ConcurrentDictionary();
+
+// Alarm state tracking (populated from EventStarted/EventStopped events)
+ConcurrentDictionary alarmStates = new ConcurrentDictionary();
+
+// Update monitor state from event
+void UpdateMonitorState(string actionName, int viewer, int channel = 0, int playMode = 0)
+{
+ if (actionName == "ViewerConnected" || actionName == "ViewerSelectionChanged")
+ {
+ monitorStates[viewer] = new MonitorState
+ {
+ ViewerId = viewer,
+ CurrentChannel = channel,
+ PlayMode = playMode,
+ LastUpdated = DateTime.UtcNow
+ };
+ }
+ else if (actionName == "ViewerCleared")
+ {
+ monitorStates[viewer] = new MonitorState
+ {
+ ViewerId = viewer,
+ CurrentChannel = 0,
+ PlayMode = 0,
+ LastUpdated = DateTime.UtcNow
+ };
+ }
+}
+
+// Update alarm state from event
+void UpdateAlarmState(string eventType, long eventId, string eventName, long typeId, long foreignKey)
+{
+ if (eventType == "EventStarted")
+ {
+ alarmStates[eventId] = new AlarmState
+ {
+ EventId = eventId,
+ EventName = eventName,
+ TypeId = typeId,
+ ForeignKey = foreignKey,
+ StartedAt = DateTime.UtcNow,
+ IsActive = true
+ };
+ }
+ else if (eventType == "EventStopped")
+ {
+ if (alarmStates.TryGetValue(eventId, out var alarm))
+ {
+ alarm.IsActive = false;
+ alarm.StoppedAt = DateTime.UtcNow;
+ }
+ }
+}
+
+// Broadcast event to all connected WebSocket clients
+async Task BroadcastEvent(object eventData)
+{
+ var json = JsonSerializer.Serialize(eventData);
+ var buffer = Encoding.UTF8.GetBytes(json);
+ var segment = new ArraySegment(buffer);
+
+ var deadClients = new List();
+
+ foreach (var (clientId, ws) in wsClients)
+ {
+ if (ws.State == WebSocketState.Open)
+ {
+ try
+ {
+ await ws.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None);
+ }
+ catch
+ {
+ deadClients.Add(clientId);
+ }
+ }
+ else
+ {
+ deadClients.Add(clientId);
+ }
+ }
+
+ // Clean up dead connections
+ foreach (var clientId in deadClients)
+ {
+ wsClients.TryRemove(clientId, out _);
+ }
+}
+
+// Parse action string into structured event (e.g., "ViewerConnected(5, 101, 11)" -> {action: "ViewerConnected", params: {...}})
+object? ParseActionToEvent(string actionStr)
+{
+ // Match pattern: ActionName(params)
+ var match = System.Text.RegularExpressions.Regex.Match(actionStr, @"(\w+)\(([^)]*)\)");
+ if (!match.Success) return null;
+
+ var actionName = match.Groups[1].Value;
+ var paramsStr = match.Groups[2].Value;
+ var paramValues = paramsStr.Split(',').Select(p => p.Trim().Trim('"')).ToArray();
+
+ // Update local state tracking based on action type
+ if (actionName == "ViewerConnected" && paramValues.Length >= 3)
+ {
+ int.TryParse(paramValues[0], out var viewer);
+ int.TryParse(paramValues[1], out var channel);
+ int.TryParse(paramValues[2], out var playMode);
+ UpdateMonitorState(actionName, viewer, channel, playMode);
+ }
+ else if (actionName == "ViewerCleared" && paramValues.Length >= 1)
+ {
+ int.TryParse(paramValues[0], out var viewer);
+ UpdateMonitorState(actionName, viewer);
+ }
+ else if (actionName == "ViewerSelectionChanged" && paramValues.Length >= 3)
+ {
+ int.TryParse(paramValues[0], out var viewer);
+ int.TryParse(paramValues[1], out var channel);
+ int.TryParse(paramValues[2], out var playMode);
+ UpdateMonitorState(actionName, viewer, channel, playMode);
+ }
+
+ return actionName switch
+ {
+ "ViewerConnected" when paramValues.Length >= 3 => new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = "ViewerConnected",
+ @params = new
+ {
+ Viewer = int.TryParse(paramValues[0], out var v) ? v : 0,
+ Channel = int.TryParse(paramValues[1], out var c) ? c : 0,
+ PlayMode = int.TryParse(paramValues[2], out var pm) ? pm : 0
+ }
+ },
+ "ViewerCleared" when paramValues.Length >= 1 => new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = "ViewerCleared",
+ @params = new
+ {
+ Viewer = int.TryParse(paramValues[0], out var v) ? v : 0
+ }
+ },
+ "ViewerSelectionChanged" when paramValues.Length >= 3 => new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = "ViewerSelectionChanged",
+ @params = new
+ {
+ Viewer = int.TryParse(paramValues[0], out var v) ? v : 0,
+ Channel = int.TryParse(paramValues[1], out var c) ? c : 0,
+ PlayMode = int.TryParse(paramValues[2], out var pm) ? pm : 0
+ }
+ },
+ "DigitalInput" when paramValues.Length >= 2 => new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = "DigitalInput",
+ @params = new
+ {
+ Contact = int.TryParse(paramValues[0], out var contact) ? contact : 0,
+ State = int.TryParse(paramValues[1], out var state) ? state : 0
+ }
+ },
+ "VCAlarmQueueNotification" when paramValues.Length >= 4 => new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = "VCAlarmQueueNotification",
+ @params = new
+ {
+ Viewer = int.TryParse(paramValues[0], out var v) ? v : 0,
+ Notification = int.TryParse(paramValues[1], out var n) ? n : 0,
+ AlarmID = int.TryParse(paramValues[2], out var aid) ? aid : 0,
+ TypeID = int.TryParse(paramValues[3], out var tid) ? tid : 0
+ }
+ },
+ _ => new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = actionName,
+ raw = actionStr
+ }
+ };
+}
+
+// PlayMode string to numeric value mapping (PlcViewerPlayMode enum)
+var playModeMap = new Dictionary(StringComparer.OrdinalIgnoreCase)
+{
+ { "unknown", 0 },
+ { "play stop", 1 },
+ { "play forward", 2 },
+ { "play backward", 3 },
+ { "fast forward", 4 },
+ { "fast backward", 5 },
+ { "step forward", 6 },
+ { "step backward", 7 },
+ { "play BOD", 8 },
+ { "play EOD", 9 },
+ { "quasi live", 10 },
+ { "live", 11 },
+ { "next event", 12 },
+ { "prev event", 13 },
+ { "peek live picture", 14 },
+ { "next detected motion", 17 },
+ { "prev detected motion", 18 }
+};
+
+// Helper function to get numeric PlayMode value
+int GetPlayModeValue(string playMode)
+{
+ if (playModeMap.TryGetValue(playMode, out int value))
+ return value;
+ // Try parsing as numeric value if not found in map
+ if (int.TryParse(playMode, out int numValue))
+ return numValue;
+ // Default to play stop (1) if unknown
+ Console.WriteLine($"Unknown PlayMode '{playMode}', defaulting to 'play stop' (1)");
+ return 1;
+}
+
+var app = builder.Build();
+
+// Enable CORS
+app.UseCors();
+
+// Action dispatcher event handlers
+void OnCustomAction(object? sender, GscAct_CustomActionEventArgs e)
+{
+ var msg = $"[{DateTime.Now:HH:mm:ss}] CustomAction({e.aInt}, \"{e.aString}\")";
+ Console.WriteLine(msg);
+ receivedMessages.Add(msg);
+}
+
+void OnDigitalInput(object? sender, GscAct_DigitalInputEventArgs e)
+{
+ var msg = $"[{DateTime.Now:HH:mm:ss}] DigitalInput(GlobalNo={e.aContact.GlobalNo})";
+ Console.WriteLine(msg);
+ receivedMessages.Add(msg);
+}
+
+// PLC callback handler
+void OnPLCCallback(object? sender, PLCCallbackEventArgs e)
+{
+ try
+ {
+ eventCount++;
+
+ if (e.PlcNotification.GetNotificationType() == GscPlcNotificationType.plcnNewActionData)
+ {
+ var action = e.PlcNotification.GetAction();
+ if (action != null && actionDispatcher != null)
+ {
+ var actionStr = action.ToString() ?? "";
+
+ if (!actionDispatcher.Dispatch(action))
+ {
+ var msg = $"[{DateTime.Now:HH:mm:ss}] Action: {actionStr}";
+ Console.WriteLine(msg);
+ receivedMessages.Add(msg);
+
+ // Broadcast to WebSocket clients
+ var eventData = ParseActionToEvent(actionStr);
+ if (eventData != null && wsClients.Count > 0)
+ {
+ _ = BroadcastEvent(eventData);
+ }
+ }
+ }
+ }
+ else if (e.PlcNotification.GetNotificationType() == GscPlcNotificationType.plcnNewEventData)
+ {
+ var eventData = e.PlcNotification.GetEventData();
+ if (eventData != null)
+ {
+ var eventType = eventData.EventNotificationType switch
+ {
+ GscPlcEventNotificationType.plcenEventStarted => "started",
+ GscPlcEventNotificationType.plcenEventStopped => "stopped",
+ GscPlcEventNotificationType.plcenEventRetriggered => "retriggered",
+ _ => "unknown"
+ };
+ var msg = $"[{DateTime.Now:HH:mm:ss}] Event: {eventData.EventHeader.EventName} {eventData.EventHeader.EventID} {eventType}";
+ Console.WriteLine(msg);
+ receivedMessages.Add(msg);
+
+ // Update local alarm state tracking
+ var actionType = eventType == "started" ? "EventStarted" : eventType == "stopped" ? "EventStopped" : "EventRetriggered";
+ UpdateAlarmState(actionType, eventData.EventHeader.EventID, eventData.EventHeader.EventName ?? "",
+ eventData.EventHeader.EventTypeID, Convert.ToInt64(eventData.EventHeader.ForeignKey));
+
+ // Broadcast event notifications to WebSocket clients
+ var wsEvent = new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = actionType,
+ @params = new
+ {
+ EventID = eventData.EventHeader.EventID,
+ EventName = eventData.EventHeader.EventName,
+ TypeID = eventData.EventHeader.EventTypeID,
+ ForeignKey = eventData.EventHeader.ForeignKey
+ }
+ };
+ if (wsClients.Count > 0)
+ {
+ _ = BroadcastEvent(wsEvent);
+ }
+ }
+ }
+ else if (e.PlcNotification.GetNotificationType() == GscPlcNotificationType.plcnPushCallbackLost)
+ {
+ var msg = $"[{DateTime.Now:HH:mm:ss}] Connection lost!";
+ Console.WriteLine(msg);
+ receivedMessages.Add(msg);
+
+ // Broadcast connection lost to WebSocket clients
+ var wsEvent = new
+ {
+ timestamp = DateTime.UtcNow.ToString("o"),
+ server = currentAddress,
+ action = "ConnectionLost",
+ @params = new { }
+ };
+ if (wsClients.Count > 0)
+ {
+ _ = BroadcastEvent(wsEvent);
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] PLC Callback error: {ex.Message}");
+ }
+}
+
+// Helper function to create PLC and register callbacks
+void CreatePLC()
+{
+ if (gscServer == null) return;
+
+ gscPLC = gscServer.CreatePLC();
+ gscPLC.PLCCallback += OnPLCCallback;
+ gscPLC.OpenPushCallback();
+
+ actionDispatcher = new GscActionDispatcher();
+ actionDispatcher.OnCustomAction += OnCustomAction;
+ actionDispatcher.OnDigitalInput += OnDigitalInput;
+
+ gscPLC.SubscribeActionsAll();
+ gscPLC.SubscribeEventsAll();
+}
+
+// Helper function to destroy PLC
+void DestroyPLC(bool connectionLost = false)
+{
+ if (gscPLC != null)
+ {
+ if (!connectionLost)
+ {
+ gscPLC.UnsubscribeAll();
+ gscPLC.CloseCallback();
+ }
+
+ if (actionDispatcher != null)
+ {
+ actionDispatcher.Dispose();
+ actionDispatcher = null;
+ }
+
+ gscPLC.Dispose();
+ gscPLC = null;
+ }
+}
+
+// Helper function to load media channels
+void LoadMediaChannels()
+{
+ mediaChannels.Clear();
+ if (gscServer == null)
+ {
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] LoadMediaChannels: gscServer is null");
+ return;
+ }
+
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] LoadMediaChannels: Starting channel query...");
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Server connected: {gscServer.IsConnected}");
+
+ try
+ {
+ var channelList = new ArrayList();
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Calling MediaPlayerHelperFunctions.QueryMediaChannelList...");
+ MediaPlayerHelperFunctions.QueryMediaChannelList(gscServer, out channelList);
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] QueryMediaChannelList returned, channelList is null: {channelList == null}");
+
+ if (channelList != null)
+ {
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Found {channelList.Count} total channels from server");
+
+ foreach (GscMediaChannelData channel in channelList)
+ {
+ // Include ALL channels (both active and inactive)
+ mediaChannels.Add(new MediaChannelInfo
+ {
+ ChannelID = channel.ChannelID,
+ GlobalNumber = channel.GlobalNumber,
+ Name = channel.Name,
+ Description = channel.Desc,
+ IsActive = channel.IsActive
+ });
+ Console.WriteLine($" - Channel {channel.ChannelID}: {channel.Name} (Active: {channel.IsActive})");
+ }
+ }
+ else
+ {
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] channelList is NULL!");
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Error loading media channels: {ex.Message}");
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Stack trace: {ex.StackTrace}");
+ }
+}
+
+// ============================================================================
+// API ENDPOINTS
+// ============================================================================
+
+// Connection endpoint
+app.MapPost("/connect", (ConnectRequest request) =>
+{
+ try
+ {
+ // Disconnect existing connection
+ if (gscServer != null)
+ {
+ DestroyPLC();
+ gscServer.Disconnect(5000);
+ gscServer.Dispose();
+ gscServer = null;
+ }
+
+ // Create new connection
+ gscServer = new GscServer();
+
+ // Encode password
+ var encodedPassword = DBIHelperFunctions.EncodePassword(request.Password);
+
+ // Set connection parameters
+ using var connectParams = new GscServerConnectParams(request.Address, request.Username, encodedPassword);
+ gscServer.SetConnectParams(connectParams);
+
+ // Connect
+ var result = gscServer.Connect();
+
+ if (result == GscServerConnectResult.connectOk)
+ {
+ currentAddress = request.Address;
+ currentUsername = request.Username;
+ connectedAt = DateTime.UtcNow;
+ eventCount = 0;
+
+ // Create PLC for actions/events
+ CreatePLC();
+
+ // Load media channels
+ LoadMediaChannels();
+
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Connected to GeViScope at {request.Address}");
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "Connected to GeViScope",
+ address = request.Address,
+ username = request.Username,
+ channelCount = mediaChannels.Count,
+ connected_at = connectedAt
+ });
+ }
+ else
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "Connection failed",
+ message = result.ToString()
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "Connection error",
+ message = ex.Message,
+ stack_trace = ex.StackTrace
+ });
+ }
+});
+
+// Disconnect endpoint
+app.MapPost("/disconnect", () =>
+{
+ try
+ {
+ DestroyPLC();
+
+ if (gscServer != null)
+ {
+ gscServer.Disconnect(5000);
+ gscServer.Dispose();
+ gscServer = null;
+ }
+
+ currentAddress = null;
+ currentUsername = null;
+ connectedAt = null;
+ eventCount = 0;
+ mediaChannels.Clear();
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "Disconnected successfully"
+ });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "Disconnect error",
+ message = ex.Message
+ });
+ }
+});
+
+// Status endpoint (enhanced for monitoring)
+app.MapGet("/status", () =>
+{
+ return Results.Ok(new
+ {
+ is_connected = gscServer != null,
+ address = currentAddress,
+ username = currentUsername,
+ channel_count = mediaChannels.Count,
+ connected_at = connectedAt,
+ connection_duration_sec = connectedAt.HasValue ? (int)(DateTime.UtcNow - connectedAt.Value).TotalSeconds : 0,
+ event_count = eventCount,
+ websocket_clients = wsClients.Count,
+ plc_active = gscPLC != null
+ });
+});
+
+// Health check endpoint (for load balancers)
+app.MapGet("/health", () =>
+{
+ var isHealthy = gscServer != null && gscPLC != null;
+ return isHealthy
+ ? Results.Ok(new { status = "healthy", server = "geviscope-bridge" })
+ : Results.Ok(new { status = "degraded", server = "geviscope-bridge", message = "Not connected to GeViScope server" });
+});
+
+// Get media channels
+app.MapGet("/channels", () =>
+{
+ if (gscServer == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ return Results.Ok(new
+ {
+ count = mediaChannels.Count,
+ channels = mediaChannels
+ });
+});
+
+// Refresh media channels
+app.MapPost("/channels/refresh", () =>
+{
+ if (gscServer == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ LoadMediaChannels();
+
+ return Results.Ok(new
+ {
+ count = mediaChannels.Count,
+ channels = mediaChannels
+ });
+});
+
+// Get current monitor states (tracked from ViewerConnected/ViewerCleared events)
+app.MapGet("/monitors", () =>
+{
+ return Results.Ok(new
+ {
+ server = currentAddress,
+ count = monitorStates.Count,
+ monitors = monitorStates.Values.OrderBy(m => m.ViewerId).Select(m => new
+ {
+ viewer_id = m.ViewerId,
+ current_channel = m.CurrentChannel,
+ play_mode = m.PlayMode,
+ last_updated = m.LastUpdated
+ }),
+ note = "State is tracked from ViewerConnected/ViewerCleared events. Initial state requires event activity or server query."
+ });
+});
+
+// Get specific monitor state
+app.MapGet("/monitors/{viewerId}", (int viewerId) =>
+{
+ if (monitorStates.TryGetValue(viewerId, out var state))
+ {
+ return Results.Ok(new
+ {
+ viewer_id = state.ViewerId,
+ current_channel = state.CurrentChannel,
+ play_mode = state.PlayMode,
+ last_updated = state.LastUpdated
+ });
+ }
+ return Results.NotFound(new { error = "Monitor not found or no events received yet" });
+});
+
+// Get active alarms (tracked from EventStarted/EventStopped notifications)
+app.MapGet("/alarms/active", () =>
+{
+ var activeAlarms = alarmStates.Values.Where(a => a.IsActive).OrderBy(a => a.StartedAt);
+ return Results.Ok(new
+ {
+ server = currentAddress,
+ count = activeAlarms.Count(),
+ alarms = activeAlarms.Select(a => new
+ {
+ event_id = a.EventId,
+ event_name = a.EventName,
+ type_id = a.TypeId,
+ foreign_key = a.ForeignKey,
+ started_at = a.StartedAt
+ }),
+ note = "Alarms tracked from EventStarted/EventStopped notifications. For complete alarm state on startup, query GeViServer bridge."
+ });
+});
+
+// Get all alarms (including stopped)
+app.MapGet("/alarms", () =>
+{
+ return Results.Ok(new
+ {
+ server = currentAddress,
+ total = alarmStates.Count,
+ active = alarmStates.Values.Count(a => a.IsActive),
+ alarms = alarmStates.Values.OrderByDescending(a => a.StartedAt).Select(a => new
+ {
+ event_id = a.EventId,
+ event_name = a.EventName,
+ type_id = a.TypeId,
+ foreign_key = a.ForeignKey,
+ started_at = a.StartedAt,
+ stopped_at = a.StoppedAt,
+ is_active = a.IsActive
+ })
+ });
+});
+
+// Send action (generic)
+app.MapPost("/action", (ActionRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Sending action: {request.Action}");
+
+ var action = GscAction.Decode(request.Action);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ var logMsg = $"[{DateTime.Now:HH:mm:ss}] SENT: {request.Action}";
+ receivedMessages.Add(logMsg);
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "Action sent",
+ action = request.Action
+ });
+ }
+ else
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "Invalid action",
+ message = "Could not decode action string"
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "Action error",
+ message = ex.Message
+ });
+ }
+});
+
+// Send CustomAction
+app.MapPost("/custom-action", (CustomActionRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ var action = new GscAct_CustomAction(request.TypeId, request.Text ?? "");
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ var logMsg = $"[{DateTime.Now:HH:mm:ss}] SENT: CustomAction({request.TypeId}, \"{request.Text}\")";
+ receivedMessages.Add(logMsg);
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "CustomAction sent",
+ type_id = request.TypeId,
+ text = request.Text
+ });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "CustomAction error",
+ message = ex.Message
+ });
+ }
+});
+
+// ViewerConnect - connect a viewer to a channel with play mode
+app.MapPost("/viewer/connect", (ViewerConnectRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ var playModeValue = GetPlayModeValue(request.PlayMode);
+ var actionStr = $"ViewerConnect({request.Viewer}, {request.Channel}, {playModeValue})";
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ var logMsg = $"[{DateTime.Now:HH:mm:ss}] SENT: {actionStr}";
+ Console.WriteLine(logMsg);
+ receivedMessages.Add(logMsg);
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "ViewerConnect sent",
+ viewer = request.Viewer,
+ channel = request.Channel,
+ play_mode = request.PlayMode
+ });
+ }
+ else
+ {
+ return Results.BadRequest(new { success = false, error = "Failed to create ViewerConnect action" });
+ }
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "ViewerConnect error",
+ message = ex.Message
+ });
+ }
+});
+
+// ViewerConnectLive - connect a viewer to a channel (GeViScope equivalent of CrossSwitch)
+app.MapPost("/viewer/connect-live", (ViewerConnectLiveRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ var actionStr = $"ViewerConnectLive({request.Viewer}, {request.Channel})";
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ var logMsg = $"[{DateTime.Now:HH:mm:ss}] SENT: {actionStr}";
+ Console.WriteLine(logMsg);
+ receivedMessages.Add(logMsg);
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "ViewerConnectLive sent",
+ viewer = request.Viewer,
+ channel = request.Channel
+ });
+ }
+ else
+ {
+ return Results.BadRequest(new { success = false, error = "Failed to create ViewerConnectLive action" });
+ }
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "ViewerConnectLive error",
+ message = ex.Message
+ });
+ }
+});
+
+// ViewerClear - clear viewer display
+app.MapPost("/viewer/clear", (ViewerClearRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ var actionStr = $"ViewerClear({request.Viewer})";
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ var logMsg = $"[{DateTime.Now:HH:mm:ss}] SENT: {actionStr}";
+ Console.WriteLine(logMsg);
+ receivedMessages.Add(logMsg);
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "ViewerClear sent",
+ viewer = request.Viewer
+ });
+ }
+ else
+ {
+ return Results.BadRequest(new { success = false, error = "Failed to create ViewerClear action" });
+ }
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "ViewerClear error",
+ message = ex.Message
+ });
+ }
+});
+
+// CrossSwitch - redirects to ViewerConnectLive for GeViScope (for backward compatibility)
+app.MapPost("/crossswitch", (CrossSwitchRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ // For GeViScope, use ViewerConnectLive instead of CrossSwitch
+ // VideoOutput = Viewer, VideoInput = Channel
+ var actionStr = $"ViewerConnectLive({request.VideoOutput}, {request.VideoInput})";
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ var logMsg = $"[{DateTime.Now:HH:mm:ss}] SENT: {actionStr} (via /crossswitch)";
+ Console.WriteLine(logMsg);
+ receivedMessages.Add(logMsg);
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "ViewerConnectLive sent (CrossSwitch mapped)",
+ viewer = request.VideoOutput,
+ channel = request.VideoInput
+ });
+ }
+ else
+ {
+ return Results.BadRequest(new { success = false, error = "Failed to create ViewerConnectLive action" });
+ }
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "CrossSwitch error",
+ message = ex.Message
+ });
+ }
+});
+
+// PTZ Camera Control - Pan
+app.MapPost("/camera/pan", (CameraPanRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ // Correct action names: PanLeft, PanRight (not CameraPanLeft/Right)
+ var direction = request.Direction.ToLower() == "left" ? "Left" : "Right";
+ var actionStr = $"Pan{direction}({request.Camera}, {request.Speed})";
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] PTZ Pan: {actionStr}");
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = $"Camera pan {direction}",
+ camera = request.Camera,
+ speed = request.Speed
+ });
+ }
+
+ return Results.BadRequest(new { error = "Failed to create pan action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Tilt
+app.MapPost("/camera/tilt", (CameraTiltRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ // Correct action names: TiltUp, TiltDown (not CameraTiltUp/Down)
+ var direction = request.Direction.ToLower() == "up" ? "Up" : "Down";
+ var actionStr = $"Tilt{direction}({request.Camera}, {request.Speed})";
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] PTZ Tilt: {actionStr}");
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = $"Camera tilt {direction}",
+ camera = request.Camera,
+ speed = request.Speed
+ });
+ }
+
+ return Results.BadRequest(new { error = "Failed to create tilt action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Zoom
+app.MapPost("/camera/zoom", (CameraZoomRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ // Correct action names: ZoomIn, ZoomOut (not CameraZoomIn/Out)
+ var direction = request.Direction.ToLower() == "in" ? "In" : "Out";
+ var actionStr = $"Zoom{direction}({request.Camera}, {request.Speed})";
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] PTZ Zoom: {actionStr}");
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = $"Camera zoom {direction}",
+ camera = request.Camera,
+ speed = request.Speed
+ });
+ }
+
+ return Results.BadRequest(new { error = "Failed to create zoom action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Stop all movement
+app.MapPost("/camera/stop", (CameraStopRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ // CameraStopAll is the correct action name
+ var actionStr = $"CameraStopAll({request.Camera})";
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] PTZ Stop: {actionStr}");
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "Camera stopped",
+ camera = request.Camera
+ });
+ }
+
+ return Results.BadRequest(new { error = "Failed to create stop action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Stop pan movement
+app.MapPost("/camera/pan-stop", (CameraStopRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ var actionStr = $"PanStop({request.Camera})";
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] PTZ PanStop: {actionStr}");
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "Pan stopped",
+ camera = request.Camera
+ });
+ }
+
+ return Results.BadRequest(new { error = "Failed to create pan stop action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Stop tilt movement
+app.MapPost("/camera/tilt-stop", (CameraStopRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ var actionStr = $"TiltStop({request.Camera})";
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] PTZ TiltStop: {actionStr}");
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "Tilt stopped",
+ camera = request.Camera
+ });
+ }
+
+ return Results.BadRequest(new { error = "Failed to create tilt stop action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Stop zoom movement
+app.MapPost("/camera/zoom-stop", (CameraStopRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ var actionStr = $"ZoomStop({request.Camera})";
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] PTZ ZoomStop: {actionStr}");
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "Zoom stopped",
+ camera = request.Camera
+ });
+ }
+
+ return Results.BadRequest(new { error = "Failed to create zoom stop action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// PTZ Camera Control - Go to preset
+app.MapPost("/camera/preset", (CameraPresetRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ // Correct action name: PrePosCallUp (not CameraGotoPreset)
+ var actionStr = $"PrePosCallUp({request.Camera}, {request.Preset})";
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] PTZ Preset: {actionStr}");
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = $"Camera going to preset {request.Preset}",
+ camera = request.Camera,
+ preset = request.Preset
+ });
+ }
+
+ return Results.BadRequest(new { error = "Failed to create preset action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// Digital Output - Close contact
+app.MapPost("/digital-io/close", (DigitalContactRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ var actionStr = $"CloseDigitalOutput({request.ContactId})";
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "Digital output closed",
+ contact_id = request.ContactId
+ });
+ }
+
+ return Results.BadRequest(new { error = "Failed to create action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// Digital Output - Open contact
+app.MapPost("/digital-io/open", (DigitalContactRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ var actionStr = $"OpenDigitalOutput({request.ContactId})";
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "Digital output opened",
+ contact_id = request.ContactId
+ });
+ }
+
+ return Results.BadRequest(new { error = "Failed to create action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new { error = ex.Message });
+ }
+});
+
+// ============================================================================
+// PLAYBACK CONTROL ENDPOINTS
+// ============================================================================
+
+// ViewerSetPlayMode - Set playback mode and speed
+// PlayMode values: "step forward", "step backward", "fast forward", "fast backward",
+// "play forward", "play backward", "play stop", "live", "play BOD", "play EOD"
+app.MapPost("/viewer/set-play-mode", (ViewerSetPlayModeRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ var playModeValue = GetPlayModeValue(request.PlayMode);
+ var actionStr = $"ViewerSetPlayMode({request.Viewer}, {playModeValue}, {request.PlaySpeed})";
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Playback: {actionStr}");
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ var logMsg = $"[{DateTime.Now:HH:mm:ss}] SENT: {actionStr}";
+ receivedMessages.Add(logMsg);
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "ViewerSetPlayMode sent",
+ viewer = request.Viewer,
+ play_mode = request.PlayMode,
+ play_speed = request.PlaySpeed
+ });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to create ViewerSetPlayMode action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "ViewerSetPlayMode error",
+ message = ex.Message
+ });
+ }
+});
+
+// ViewerPlayFromTime - Go to specific date/time
+// Time format: "2024/01/15 14:30:00,000 GMT+01:00"
+app.MapPost("/viewer/play-from-time", (ViewerPlayFromTimeRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ var playModeValue = GetPlayModeValue(request.PlayMode);
+ var actionStr = $"ViewerPlayFromTime({request.Viewer}, {request.Channel}, {playModeValue}, \"{request.Time}\")";
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Playback: {actionStr}");
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ var logMsg = $"[{DateTime.Now:HH:mm:ss}] SENT: {actionStr}";
+ receivedMessages.Add(logMsg);
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "ViewerPlayFromTime sent",
+ viewer = request.Viewer,
+ channel = request.Channel,
+ play_mode = request.PlayMode,
+ time = request.Time
+ });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to create ViewerPlayFromTime action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "ViewerPlayFromTime error",
+ message = ex.Message
+ });
+ }
+});
+
+// ViewerJumpByTime - Jump playback position by seconds
+app.MapPost("/viewer/jump-by-time", (ViewerJumpByTimeRequest request) =>
+{
+ try
+ {
+ if (gscServer == null || gscPLC == null)
+ {
+ return Results.BadRequest(new { error = "Not connected" });
+ }
+
+ var playModeValue = GetPlayModeValue(request.PlayMode);
+ var actionStr = $"ViewerJumpByTime({request.Viewer}, {request.Channel}, {playModeValue}, {request.TimeInSec})";
+ Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Playback: {actionStr}");
+ var action = GscAction.Decode(actionStr);
+ if (action != null)
+ {
+ gscPLC.SendAction(action);
+ action.Dispose();
+
+ var logMsg = $"[{DateTime.Now:HH:mm:ss}] SENT: {actionStr}";
+ receivedMessages.Add(logMsg);
+
+ return Results.Ok(new
+ {
+ success = true,
+ message = "ViewerJumpByTime sent",
+ viewer = request.Viewer,
+ channel = request.Channel,
+ play_mode = request.PlayMode,
+ time_in_sec = request.TimeInSec
+ });
+ }
+
+ return Results.BadRequest(new { success = false, error = "Failed to create ViewerJumpByTime action" });
+ }
+ catch (Exception ex)
+ {
+ return Results.BadRequest(new
+ {
+ success = false,
+ error = "ViewerJumpByTime error",
+ message = ex.Message
+ });
+ }
+});
+
+// DEBUG: Test action decoding - helps identify which actions are supported by SDK
+app.MapPost("/debug/test-action", (ActionRequest request) =>
+{
+ var results = new List