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; } }