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(); + + // Test the provided action + var action = GscAction.Decode(request.Action); + results.Add(new { + action_string = request.Action, + decoded = action != null, + action_type = action?.ToString() ?? "null" + }); + + if (action != null) + { + action.Dispose(); + } + + return Results.Ok(new { + test_results = results, + note = "If decoded=true, the action can be sent via PLC" + }); +}); + +// DEBUG: Test all playback action formats +app.MapGet("/debug/test-playback-actions", () => +{ + var testActions = new[] { + // Numeric PlayMode tests + "ViewerConnect(1001, 1, 1)", + "ViewerConnect(1001, 1, 6)", + "ViewerSetPlayMode(1001, 1, 1)", + "ViewerSetPlayMode(1001, 6, 1)", + "ViewerPlayFromTime(1001, 1, 2, \"2024/01/15 14:30:00,000 GMT+01:00\")", + "ViewerJumpByTime(1001, 1, 2, 60)", + // String PlayMode tests (old format) + "ViewerConnect(1001, 1, \"play stop\")", + "ViewerSetPlayMode(1001, \"step forward\", 1)", + // Working actions for comparison + "ViewerConnectLive(1001, 1)", + "ViewerClear(1001)", + "CustomAction(1, \"test\")", + "PanLeft(1, 50)" + }; + + var results = new List(); + foreach (var actionStr in testActions) + { + var action = GscAction.Decode(actionStr); + results.Add(new { + action_string = actionStr, + decoded = action != null, + can_send = action != null + }); + if (action != null) + { + action.Dispose(); + } + } + + return Results.Ok(new { + test_results = results, + summary = $"Decoded: {results.Count(r => ((dynamic)r).decoded)} / {results.Count}" + }); +}); + +// 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 GeViScope Bridge event stream" + }); + await ws.SendAsync( + new ArraySegment(Encoding.UTF8.GetBytes(welcome)), + WebSocketMessageType.Text, + true, + CancellationToken.None + ); + + // Keep connection open and handle incoming messages (ping/pong, close) + 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("GeViScope Bridge starting on port 7720"); +Console.WriteLine("WebSocket events: ws://localhost:7720/ws/events"); +Console.WriteLine("========================================"); + +app.Run("http://localhost:7720"); + +// 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/geviserver-bridge/GeViServerBridge/GeViServerBridge.csproj b/geviserver-bridge/GeViServerBridge/GeViServerBridge.csproj new file mode 100644 index 0000000..d619af1 --- /dev/null +++ b/geviserver-bridge/GeViServerBridge/GeViServerBridge.csproj @@ -0,0 +1,23 @@ + + + + net8.0 + enable + enable + x86 + + + + + C:\GEVISOFT\GeViProcAPINET_4_0.dll + True + + + + + + Always + + + + diff --git a/geviserver-bridge/GeViServerBridge/Program.cs b/geviserver-bridge/GeViServerBridge/Program.cs new file mode 100644 index 0000000..b3b58e0 --- /dev/null +++ b/geviserver-bridge/GeViServerBridge/Program.cs @@ -0,0 +1,232 @@ +using GEUTEBRUECK.GeViSoftSDKNET.ActionsWrapper; +using GEUTEBRUECK.GeViSoftSDKNET.ActionsWrapper.ActionDispatcher; +using GEUTEBRUECK.GeViSoftSDKNET.ActionsWrapper.SystemActions; +using GEUTEBRUECK.GeViSoftSDKNET.ActionsWrapper.SwitchControlActions; + +var builder = WebApplication.CreateBuilder(args); + +// GeViServer connection state +GeViDatabase? database = null; +string? currentAddress = null; +string? currentUsername = null; +List receivedMessages = new List(); + +var app = builder.Build(); + +// Event handler for received messages +void OnDatabaseNotification(object? sender, GeViSoftDatabaseNotificationEventArgs e) +{ + var msg = $"[{DateTime.Now:HH:mm:ss}] Notification: {e.ServerNotificationType}"; + Console.WriteLine(msg); + receivedMessages.Add(msg); +} + +void OnReceivedCustomAction(object? sender, GeViAct_CustomActionEventArgs e) +{ + var msg = $"[{DateTime.Now:HH:mm:ss}] CustomAction({e.aCustomInt}, \"{e.aCustomText}\")"; + Console.WriteLine(msg); + receivedMessages.Add(msg); +} + +void OnReceivedCrossSwitch(object? sender, GeViAct_CrossSwitchEventArgs e) +{ + var msg = $"[{DateTime.Now:HH:mm:ss}] CrossSwitch({e.aVideoInput}, {e.aVideoOutput}, {e.aSwitchMode})"; + Console.WriteLine(msg); + receivedMessages.Add(msg); +} + +// Connection endpoint +app.MapPost("/connect", (ConnectRequest request) => +{ + try + { + // Create and configure database connection + database = new GeViDatabase(); + database.Create( + request.Address, + request.Username, + request.Password + ); + + // Register event handlers BEFORE connecting + database.DatabaseNotification += OnDatabaseNotification; + database.ReceivedCustomAction += OnReceivedCustomAction; + database.ReceivedCrossSwitch += OnReceivedCrossSwitch; + database.RegisterCallback(); + + // Connect to GeViServer + var connectResult = database.Connect(); + + if (connectResult == GeViConnectResult.connectOk) + { + currentAddress = request.Address; + currentUsername = request.Username; + Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] Connected to GeViServer at {request.Address}"); + + return Results.Ok(new + { + success = true, + message = "Connected to GeViServer", + address = request.Address, + username = request.Username, + connected_at = DateTime.UtcNow + }); + } + else + { + return Results.BadRequest(new + { + error = "Connection failed", + message = connectResult.ToString() + }); + } + } + catch (Exception ex) + { + return Results.BadRequest(new + { + error = "Internal Server Error", + message = ex.Message, + stack_trace = ex.StackTrace + }); + } +}); + +// Disconnect endpoint +app.MapPost("/disconnect", () => +{ + try + { + if (database != null) + { + database.Disconnect(); + database.Dispose(); + database = null; + } + + currentAddress = null; + currentUsername = null; + + return Results.Ok(new + { + success = true, + message = "Disconnected successfully" + }); + } + catch (Exception ex) + { + return Results.BadRequest(new + { + error = "Internal Server Error", + message = ex.Message + }); + } +}); + +// Status endpoint +app.MapGet("/status", () => +{ + return Results.Ok(new + { + is_connected = database != null, + address = currentAddress, + username = currentUsername + }); +}); + +// Ping endpoint +app.MapPost("/ping", () => +{ + try + { + if (database == null) + { + return Results.BadRequest(new { error = "Not connected" }); + } + + var result = database.SendPing(); + + return Results.Ok(new + { + success = result, + message = result ? "Ping successful" : "Ping failed" + }); + } + catch (Exception ex) + { + return Results.BadRequest(new + { + error = "Internal Server Error", + message = ex.Message + }); + } +}); + +// Send message endpoint +app.MapPost("/send-message", (SendMessageRequest request) => +{ + try + { + if (database == null) + { + return Results.BadRequest(new { error = "Not connected" }); + } + + Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] SENDING: {request.Message}"); + + // Send action message + database.SendMessage(request.Message); + + var logMsg = $"[{DateTime.Now:HH:mm:ss}] SENT: {request.Message}"; + receivedMessages.Add(logMsg); + + return Results.Ok(new + { + success = true, + message = "Message sent successfully", + sent_message = request.Message + }); + } + catch (Exception ex) + { + Console.WriteLine($"[{DateTime.Now:HH:mm:ss}] ERROR: {ex.Message}"); + return Results.BadRequest(new + { + error = "Internal Server Error", + message = ex.Message + }); + } +}); + +// Get message log endpoint +app.MapGet("/messages", () => +{ + return Results.Ok(new + { + count = receivedMessages.Count, + messages = receivedMessages.TakeLast(50).ToList() + }); +}); + +// Clear message log endpoint +app.MapPost("/messages/clear", () => +{ + receivedMessages.Clear(); + return Results.Ok(new { message = "Message log cleared" }); +}); + +Console.WriteLine("========================================"); +Console.WriteLine("GeViServer Bridge starting on port 7710"); +Console.WriteLine("========================================"); + +// Run on port 7710 (avoiding conflict with GeViServer DevicePort 7701) +app.Run("http://localhost:7710"); + +// Request models +record ConnectRequest( + string Address, + string Username, + string Password +); + +record SendMessageRequest(string Message); diff --git a/geviserver-bridge/GeViServerBridge/Properties/launchSettings.json b/geviserver-bridge/GeViServerBridge/Properties/launchSettings.json new file mode 100644 index 0000000..b3fa4e6 --- /dev/null +++ b/geviserver-bridge/GeViServerBridge/Properties/launchSettings.json @@ -0,0 +1,38 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:40288", + "sslPort": 44338 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5103", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7198;http://localhost:5103", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/geviserver-bridge/GeViServerBridge/appsettings.Development.json b/geviserver-bridge/GeViServerBridge/appsettings.Development.json new file mode 100644 index 0000000..ff66ba6 --- /dev/null +++ b/geviserver-bridge/GeViServerBridge/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/geviserver-bridge/GeViServerBridge/appsettings.json b/geviserver-bridge/GeViServerBridge/appsettings.json new file mode 100644 index 0000000..4d56694 --- /dev/null +++ b/geviserver-bridge/GeViServerBridge/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}