Add C# bridges and coordinator service

- geviscope-bridge: GeViScope SDK REST wrapper (:7720)
- gcore-bridge: G-Core SDK REST wrapper (:7721)
- geviserver-bridge: GeViServer REST wrapper (:7710)
- copilot-coordinator: WebSocket coordination hub (:8090)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
klas
2026-02-12 15:24:20 +01:00
parent 40143734fc
commit b2887b67db
21 changed files with 4548 additions and 0 deletions

4
.gitignore vendored
View File

@@ -38,5 +38,9 @@ test_coordinator.ps1
package.json package.json
nul nul
# .NET build outputs
**/bin/
**/obj/
# Decompiled text dumps # Decompiled text dumps
C*DEVCOPILOT_D6* C*DEVCOPILOT_D6*

View File

@@ -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);

View File

@@ -0,0 +1,24 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace CopilotCoordinator.Models;
/// <summary>
/// WebSocket message envelope for all coordinator events.
/// </summary>
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);

View File

@@ -0,0 +1,21 @@
namespace CopilotCoordinator.Models;
public record SequenceDefinition(
int Id,
string Name,
int CategoryId,
List<int> 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);

View File

@@ -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<WsBroadcaster>();
builder.Services.AddSingleton<LockManager>();
builder.Services.AddSingleton<SequenceRunner>();
builder.Services.AddHttpClient();
var app = builder.Build();
// Load sequence definitions from config file if present
var seqRunner = app.Services.GetRequiredService<SequenceRunner>();
var seqConfig = builder.Configuration.GetSection("Sequences");
if (seqConfig.Exists())
{
var sequences = seqConfig.Get<List<SequenceDefinition>>() ?? new();
var categories = builder.Configuration.GetSection("SequenceCategories").Get<List<SequenceCategory>>() ?? new();
seqRunner.LoadSequences(sequences, categories);
}
// --- Lock expiration background timer (1 second, like legacy CameraLockExpirationWorker) ---
var lockManager = app.Services.GetRequiredService<LockManager>();
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<WsBroadcaster>();
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<CameraLockPriority>(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<CameraLockPriority>(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();

View File

@@ -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"
}
}
}
}

View File

@@ -0,0 +1,287 @@
using CopilotCoordinator.Models;
namespace CopilotCoordinator.Services;
/// <summary>
/// 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).
/// </summary>
public class LockManager
{
private readonly SemaphoreSlim _locksLock = new(1, 1);
private readonly Dictionary<int, CameraLock> _locks = new();
private readonly SemaphoreSlim _requestsLock = new(1, 1);
private readonly Dictionary<int, List<(string KeyboardId, CameraLockPriority Priority, DateTime CreatedAt)>> _requests = new();
private readonly TimeSpan _expirationTimeout;
private readonly TimeSpan _warningBefore;
private readonly WsBroadcaster _broadcaster;
private readonly ILogger<LockManager> _logger;
// Track which locks have already been warned about (to avoid repeat notifications)
private readonly Dictionary<int, DateTime> _warnedLocks = new();
public LockManager(IConfiguration config, WsBroadcaster broadcaster, ILogger<LockManager> logger)
{
_expirationTimeout = config.GetValue("LockExpiration:Timeout", TimeSpan.FromMinutes(5));
_warningBefore = config.GetValue("LockExpiration:WarningBefore", TimeSpan.FromMinutes(1));
_broadcaster = broadcaster;
_logger = logger;
}
public async Task<IReadOnlyList<CameraLock>> GetAllLocks()
{
await _locksLock.WaitAsync();
try { return _locks.Values.ToList(); }
finally { _locksLock.Release(); }
}
public async Task<IReadOnlyList<int>> 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<CameraLockResult> 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(); }
}
/// <summary>
/// Called every second by the expiration timer. Checks for expired and soon-to-expire locks.
/// Ported from legacy CameraLockExpirationWorker.cs.
/// </summary>
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;
}
}

View File

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

View File

@@ -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;
/// <summary>
/// WebSocket broadcaster. Manages connected keyboard clients and sends events.
/// </summary>
public class WsBroadcaster
{
private readonly ConcurrentDictionary<string, (WebSocket Socket, string KeyboardId)> _clients = new();
private readonly ILogger<WsBroadcaster> _logger;
public WsBroadcaster(ILogger<WsBroadcaster> logger)
{
_logger = logger;
}
public IReadOnlyList<KeyboardInfo> 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 });
}
}
/// <summary>
/// Broadcast a message to all connected keyboards.
/// </summary>
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<string>();
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 _);
}
/// <summary>
/// Send a message to a specific keyboard.
/// </summary>
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);
}
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -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": []
}

View File

@@ -0,0 +1,10 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>copilot_coordinator</RootNamespace>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,80 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PlatformTarget>x64</PlatformTarget>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
<ItemGroup>
<!-- G-Core SDK .NET Wrapper DLLs -->
<Reference Include="GngExceptionsNET_SDK">
<HintPath>C:\G-Core-SDK\Bin\x64\GngExceptionsNET_SDK.dll</HintPath>
</Reference>
<Reference Include="G-ActionsNET_SDK">
<HintPath>C:\G-Core-SDK\Bin\x64\G-ActionsNET_SDK.dll</HintPath>
</Reference>
<Reference Include="GngDBINET_SDK">
<HintPath>C:\G-Core-SDK\Bin\x64\GngDBINET_SDK.dll</HintPath>
</Reference>
<Reference Include="GngMediaPlayerNET_SDK">
<HintPath>C:\G-Core-SDK\Bin\x64\GngMediaPlayerNET_SDK.dll</HintPath>
</Reference>
<Reference Include="GngActionsNET_SDK">
<HintPath>C:\G-Core-SDK\Bin\x64\GngActionsNET_SDK.dll</HintPath>
</Reference>
</ItemGroup>
<!-- Copy native DLLs to output directory -->
<ItemGroup>
<!-- Core SDK DLLs -->
<None Include="C:\G-Core-SDK\Bin\x64\GngDBI.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\G-Core-SDK\Bin\x64\GngActions.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\G-Core-SDK\Bin\x64\GngMediaPlayer.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\G-Core-SDK\Bin\x64\GngMediaProcessor.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- Compatibility DLLs -->
<None Include="C:\G-Core-SDK\Bin\x64\GscDBI.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\G-Core-SDK\Bin\x64\GscActions.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\G-Core-SDK\Bin\x64\GLIBClassLib.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- VCA DLLs -->
<None Include="C:\G-Core-SDK\Bin\x64\GNGVCA.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\G-Core-SDK\Bin\x64\GngVCAPrimitives.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- Intel Media SDK -->
<None Include="C:\G-Core-SDK\Bin\x64\libmfxsw64.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- Additional dependencies -->
<None Include="C:\G-Core-SDK\Bin\x64\GngCenterPLC.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\G-Core-SDK\Bin\x64\GngMediaProcessorResources.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,74 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PlatformTarget>x86</PlatformTarget>
<RuntimeIdentifier>win-x86</RuntimeIdentifier>
<SelfContained>false</SelfContained>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
<ItemGroup>
<!-- GeViScope SDK .NET Wrapper DLLs -->
<Reference Include="GscExceptionsNET_4_0">
<HintPath>C:\Program Files (x86)\GeViScopeSDK\BIN\GscExceptionsNET_4_0.dll</HintPath>
</Reference>
<Reference Include="GscActionsNET_4_0">
<HintPath>C:\Program Files (x86)\GeViScopeSDK\BIN\GscActionsNET_4_0.dll</HintPath>
</Reference>
<Reference Include="GscDBINET_4_0">
<HintPath>C:\Program Files (x86)\GeViScopeSDK\BIN\GscDBINET_4_0.dll</HintPath>
</Reference>
<Reference Include="GscMediaPlayerNET_4_0">
<HintPath>C:\Program Files (x86)\GeViScopeSDK\BIN\GscMediaPlayerNET_4_0.dll</HintPath>
</Reference>
</ItemGroup>
<!-- Copy native DLLs to output directory -->
<ItemGroup>
<!-- Core SDK DLLs -->
<None Include="C:\Program Files (x86)\GeViScopeSDK\BIN\GscDBI.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\Program Files (x86)\GeViScopeSDK\BIN\GscActions.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\Program Files (x86)\GeViScopeSDK\BIN\GscMediaPlayer.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\Program Files (x86)\GeViScopeSDK\BIN\GscHelper.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\Program Files (x86)\GeViScopeSDK\BIN\GscDecompressor.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<!-- MediaPlayer dependencies -->
<None Include="C:\Program Files (x86)\GeViScopeSDK\BIN\GscMediaAPI.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\Program Files (x86)\GeViScopeSDK\BIN\GscJpegEncoder.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\Program Files (x86)\GeViScopeSDK\BIN\ijl15.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\Program Files (x86)\GeViScopeSDK\BIN\MPIWIN32.DLL">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\Program Files (x86)\GeViScopeSDK\BIN\GLDModule.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\Program Files (x86)\GeViScopeSDK\BIN\MscDBI.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="C:\Program Files (x86)\GeViScopeSDK\BIN\IntVidCompressor.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PlatformTarget>x86</PlatformTarget>
</PropertyGroup>
<ItemGroup>
<Reference Include="GeViProcAPINET_4_0">
<HintPath>C:\GEVISOFT\GeViProcAPINET_4_0.dll</HintPath>
<Private>True</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<None Include="C:\GEVISOFT\GeViProcAPI.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -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<string> receivedMessages = new List<string>();
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);

View File

@@ -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"
}
}
}
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}