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