- 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>
288 lines
11 KiB
C#
288 lines
11 KiB
C#
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;
|
|
}
|
|
}
|