diff --git a/Desktop/Backend/BackendHubManager.cs b/Desktop/Backend/BackendHubManager.cs index f21e189..8a59db5 100644 --- a/Desktop/Backend/BackendHubManager.cs +++ b/Desktop/Backend/BackendHubManager.cs @@ -116,16 +116,35 @@ private Task OnShockerLogHandler(LogEventArgs logEventArgs) /// public Task Control(IEnumerable shocks, string? customName = null) { - var enabledShockers = _configManager.Config.OpenShock.Shockers - .Where(y => y.Value.Enabled && - _openShockApi.Hubs.Value.Any(x=> - x.Shockers.Any(z => z.Id == y.Key && !z.IsPaused))) - .Select(x => x.Key) - .ToHashSet(); - - var shocksToSend = shocks.Where(x => enabledShockers.Contains(x.Id)); + var shocksToSend = shocks.Where(x => CanControl(x.Id, x.Type)); return _openShockHubClient.Control(shocksToSend, customName); } + + /// + /// Whether the given shocker may be controlled with the given control type: it must be enabled in config, exist and + /// not be paused, and for shared shockers we must hold the permission matching the control type. Owned shockers are + /// not present in the shared permission map and are always allowed. + /// + private bool CanControl(Guid shockerId, ControlType type) + { + if (!_configManager.Config.OpenShock.Shockers.TryGetValue(shockerId, out var conf) || !conf.Enabled) + return false; + + if (!_openShockApi.ShockerLookup.TryGetValue(shockerId, out var location) || location.Shocker.IsPaused) + return false; + + if (!_openShockApi.SharedShockerPermissions.TryGetValue(shockerId, out var permissions)) return true; + + return type switch + { + // Stop is always permitted: it can only ever end an action, so it needs no grant of its own. + ControlType.Stop => true, + ControlType.Shock => permissions.Shock, + ControlType.Vibrate => permissions.Vibrate, + ControlType.Sound => permissions.Sound, + _ => false + }; + } } public readonly struct ShockerLogEventArgs diff --git a/Desktop/Backend/OpenShockApi.cs b/Desktop/Backend/OpenShockApi.cs index 19f3f48..d896d0d 100644 --- a/Desktop/Backend/OpenShockApi.cs +++ b/Desktop/Backend/OpenShockApi.cs @@ -1,10 +1,12 @@ using System.Collections.Concurrent; using System.Collections.Immutable; using OpenShock.Desktop.Config; +using OpenShock.Desktop.Models; using OpenShock.Desktop.Models.BaseImpl; using OpenShock.Desktop.ModuleBase.StableInterfaces; using OpenShock.Desktop.Utils; using OpenShock.SDK.CSharp; +using OpenShock.SDK.CSharp.Models; namespace OpenShock.Desktop.Backend; @@ -36,9 +38,79 @@ public void SetupApiClient() } public ObservableVariable> Hubs { get; } = new(ImmutableArray.Empty); + + /// + /// Hubs owned by other users that have shared one or more shockers with us. Kept separate from . + /// Flattened across all owners; see for the owner-grouped view used by the UI. + /// + public ObservableVariable> SharedHubs { get; } = new(ImmutableArray.Empty); + + /// + /// Shared hubs grouped by the owner that shared them, so the UI can attribute hubs to the person that owns them. + /// + public ObservableVariable> SharedOwners { get; } = + new(ImmutableArray.Empty); + + /// + /// Permissions granted to us per shared shocker. Owned shockers are not present here (they have all permissions). + /// + public IReadOnlyDictionary SharedShockerPermissions => _sharedShockerPermissions; + private volatile IReadOnlyDictionary _sharedShockerPermissions = + new Dictionary(); + + /// + /// All hubs we can control, both owned and shared. + /// + public IEnumerable AllHubs => Hubs.Value.Concat(SharedHubs.Value); + + /// + /// Lookup from shocker id to that shocker and the hub it lives on, across owned and shared hubs. Rebuilt whenever + /// a hub list is refreshed, so hot paths do not have to scan every hub per shocker - live control resolves this + /// for every shocker on every frame. + /// + public IReadOnlyDictionary ShockerLookup => _shockerLookup; + private volatile IReadOnlyDictionary _shockerLookup = + new Dictionary(); + public ConcurrentDictionary HubStates { get; } = new(); - + + /// + /// Whether owned / shared hubs have been fetched at least once since the last logout. The shocker config is only + /// pruned once both halves are known, otherwise the half that has not loaded yet would look like it no longer + /// exists and its entries (including the user's enabled choices) would be dropped. + /// + private bool _ownHubsLoaded; + private bool _sharedHubsLoaded; + + /// + /// Guards the read-modify-write of the shocker config, which is reachable from concurrent refreshes. + /// + private readonly Lock _shockerConfigLock = new(); + + /// + /// Refreshes owned and shared hubs together and syncs the shocker config once, after both are known. Prefer this + /// over calling the individual refreshes back to back, which would sync against a half-populated hub list. + /// + public async Task RefreshAllHubs() + { + var ownFetched = await FetchOwnHubs(); + var sharedFetched = await FetchSharedHubs(); + + if (ownFetched || sharedFetched) SyncShockerConfig(); + } + public async Task RefreshHubs() + { + if (await FetchOwnHubs()) SyncShockerConfig(); + } + + public async Task RefreshSharedHubs() + { + if (await FetchSharedHubs()) SyncShockerConfig(); + } + + /// Whether the hub list was updated and the shocker config needs syncing. + private async Task FetchOwnHubs() { if (Client == null) { @@ -46,40 +118,117 @@ public async Task RefreshHubs() throw new Exception("Client is not initialized!"); } var response = await Client.GetOwnShockers(); - - response.Switch(success => + + return response.Match(success => { Hubs.Value = [..success.Value.Select(x => x.ToSdkHub(this))]; - - // re-populate config with previous data if present, this also deletes any shockers that are no longer present - var shockerList = new Dictionary(); - foreach (var shocker in success.Value.SelectMany(x => x.Shockers)) - { - var enabled = true; - - if (_configManager.Config.OpenShock.Shockers.TryGetValue(shocker.Id, out var confShocker)) - { - enabled = confShocker.Enabled; - } - - shockerList.Add(shocker.Id, new OpenShockConf.ShockerConf - { - Enabled = enabled - }); - } - _configManager.Config.OpenShock.Shockers = shockerList; - _configManager.Save(); + _ownHubsLoaded = true; + RebuildShockerLookup(); + return true; }, - error => + _ => { _logger.LogError("We are not authenticated with the OpenShock API!"); // TODO: handle unauthenticated error + return false; }); } + /// Whether the shared hub list was updated and the shocker config needs syncing. + private async Task FetchSharedHubs() + { + if (Client == null) + { + _logger.LogError("Client is not initialized!"); + throw new Exception("Client is not initialized!"); + } + var response = await Client.GetSharedShockers(); + + return response.Match(success => + { + var owners = success.Value.ToSdkSharedOwners(this); + SharedOwners.Value = owners; + SharedHubs.Value = owners.SelectMany(owner => owner.Hubs).ToArray(); + + _sharedShockerPermissions = success.Value + .SelectMany(owner => owner.Devices) + .SelectMany(device => device.Shockers) + .ToDictionary(shocker => shocker.Id, shocker => shocker.Permissions); + + _sharedHubsLoaded = true; + RebuildShockerLookup(); + return true; + }, + _ => + { + _logger.LogError("We are not authenticated with the OpenShock API!"); + // TODO: handle unauthenticated error + return false; + }); + } + + private void RebuildShockerLookup() + { + var lookup = new Dictionary(); + + foreach (var hub in AllHubs) + foreach (var shocker in hub.Shockers) + lookup.TryAdd(shocker.Id, new ShockerLocation(hub, shocker)); + + _shockerLookup = lookup; + } + + /// + /// Re-populates the per-shocker config with the currently known owned and shared shockers, preserving the enabled + /// flag for shockers that were already present and dropping shockers that no longer exist. Newly discovered owned + /// shockers default to enabled; newly discovered shared shockers default to disabled so another user's device is + /// never controlled until the user explicitly opts in. + /// + /// Entries are only dropped once both the owned and the shared hub list have been fetched; until then unknown + /// entries are carried over untouched so a partially loaded state cannot discard the user's choices. + /// + private void SyncShockerConfig() + { + lock (_shockerConfigLock) + { + var existing = _configManager.Config.OpenShock.Shockers; + var shockerList = new Dictionary(); + + foreach (var shockerId in AllHubs.SelectMany(x => x.Shockers).Select(x => x.Id)) + { + if (shockerList.ContainsKey(shockerId)) continue; + + // Shared shockers are present in the permission map; owned shockers are not. + var enabled = !_sharedShockerPermissions.ContainsKey(shockerId); + if (existing.TryGetValue(shockerId, out var confShocker)) + enabled = confShocker.Enabled; + + shockerList.Add(shockerId, new OpenShockConf.ShockerConf + { + Enabled = enabled + }); + } + + if (!_ownHubsLoaded || !_sharedHubsLoaded) + { + foreach (var (shockerId, confShocker) in existing) + shockerList.TryAdd(shockerId, confShocker); + } + + _configManager.Config.OpenShock.Shockers = shockerList; + _configManager.Save(); + } + } + public void Logout() { Hubs.Value = ImmutableArray.Empty; + SharedHubs.Value = ImmutableArray.Empty; + SharedOwners.Value = ImmutableArray.Empty; + _sharedShockerPermissions = new Dictionary(); + _shockerLookup = new Dictionary(); + _ownHubsLoaded = false; + _sharedHubsLoaded = false; } - + } \ No newline at end of file diff --git a/Desktop/Models/SharedHubOwner.cs b/Desktop/Models/SharedHubOwner.cs new file mode 100644 index 0000000..0a74b9d --- /dev/null +++ b/Desktop/Models/SharedHubOwner.cs @@ -0,0 +1,15 @@ +using OpenShock.Desktop.ModuleBase.StableInterfaces; + +namespace OpenShock.Desktop.Models; + +/// +/// A user who has shared one or more of their hubs' shockers with the current user, grouped for display so the UI can +/// attribute shared hubs to the person that owns them. +/// +public sealed class SharedHubOwner +{ + public required Guid Id { get; init; } + public required string Name { get; init; } + public Uri? Image { get; init; } + public required IReadOnlyList Hubs { get; init; } +} \ No newline at end of file diff --git a/Desktop/Models/ShockerLocation.cs b/Desktop/Models/ShockerLocation.cs new file mode 100644 index 0000000..4d2e6aa --- /dev/null +++ b/Desktop/Models/ShockerLocation.cs @@ -0,0 +1,9 @@ +using OpenShock.Desktop.ModuleBase.StableInterfaces; + +namespace OpenShock.Desktop.Models; + +/// +/// A shocker together with the hub it lives on, so a shocker id can be resolved to both in one lookup instead of +/// scanning every hub. +/// +public sealed record ShockerLocation(IOpenShockHub Hub, IOpenShockShocker Shocker); diff --git a/Desktop/ModuleManager/Implementation/OpenShockData.cs b/Desktop/ModuleManager/Implementation/OpenShockData.cs index 3b5be25..f0eef0a 100644 --- a/Desktop/ModuleManager/Implementation/OpenShockData.cs +++ b/Desktop/ModuleManager/Implementation/OpenShockData.cs @@ -10,4 +10,5 @@ namespace OpenShock.Desktop.ModuleManager.Implementation; public class OpenShockData : IOpenShockData { public required IObservableVariable> Hubs { get; init; } + public required IObservableVariable> SharedHubs { get; init; } } \ No newline at end of file diff --git a/Desktop/ModuleManager/Implementation/OpenShockService.cs b/Desktop/ModuleManager/Implementation/OpenShockService.cs index 9706464..130702f 100644 --- a/Desktop/ModuleManager/Implementation/OpenShockService.cs +++ b/Desktop/ModuleManager/Implementation/OpenShockService.cs @@ -27,7 +27,8 @@ public OpenShockService(IServiceProvider serviceProvider) Control = _controlInstance = new OpenShockControl(backendHubManager, liveControlManager); Data = new OpenShockData { - Hubs = openShockApi.Hubs + Hubs = openShockApi.Hubs, + SharedHubs = openShockApi.SharedHubs }; Api = new OpenShockApiWrapper(openShockApi); Auth = new OpenShockAuth diff --git a/Desktop/Services/AuthService.cs b/Desktop/Services/AuthService.cs index 39abd8e..a57575a 100644 --- a/Desktop/Services/AuthService.cs +++ b/Desktop/Services/AuthService.cs @@ -65,7 +65,7 @@ public async Task Authenticate() await _hubClient.StartAsync(); _logger.LogInformation("Refreshing shockers"); - await _apiClient.RefreshHubs(); + await _apiClient.RefreshAllHubs(); await _liveControlManager.RefreshConnections(); diff --git a/Desktop/Services/LiveControlManager.cs b/Desktop/Services/LiveControlManager.cs index 4562c13..cb18231 100644 --- a/Desktop/Services/LiveControlManager.cs +++ b/Desktop/Services/LiveControlManager.cs @@ -1,4 +1,6 @@ -using System.Reactive.Linq; +using System.Collections.Concurrent; +using System.Reactive.Linq; +using LucHeart.WebsocketLibrary; using OpenShock.Desktop.Backend; using OpenShock.Desktop.Config; using OpenShock.Desktop.Utils; @@ -10,13 +12,35 @@ namespace OpenShock.Desktop.Services; -public sealed class LiveControlManager +public sealed class LiveControlManager : IAsyncDisposable { + /// + /// How long a live control connection is kept open after the last control frame before it is closed again. + /// Live control sockets are opened lazily on demand, so this only needs to cover gaps between bursts of activity. + /// + private static readonly TimeSpan IdleTimeout = TimeSpan.FromMinutes(5); + + /// + /// How often we sweep for idle / offline connections to close. + /// + private static readonly TimeSpan SweepInterval = TimeSpan.FromSeconds(30); + + /// + /// How long a frame that arrived before the socket finished connecting stays eligible for replay. A live control + /// frame means "right now", so one that has gone stale is dropped rather than fired late against an intent the + /// user has long since released. + /// + private static readonly TimeSpan PendingFrameMaxAge = TimeSpan.FromSeconds(2); + private readonly ILogger _logger; private readonly ConfigManager _configManager; private readonly ILoggerFactory _loggerFactory; private readonly OpenShockApi _apiClient; - private readonly SemaphoreSlim _refreshLock = new(1, maxCount: 1); + + private readonly ConcurrentDictionary _connections = new(); + private readonly Lock _connectionCreationLock = new(); + private readonly CancellationTokenSource _cts = new(); + private readonly Task _sweepTask; public LiveControlManager( ILogger logger, @@ -31,148 +55,309 @@ public LiveControlManager( _loggerFactory = loggerFactory; _apiClient = apiClient; - backendHubManager.OnHubStatusUpdated.Throttle(TimeSpan.FromMilliseconds(500)).Subscribe(HubStatusUpdated); - - hubClient.OnHubUpdate.SubscribeAsync(async _ => + // When a hub goes offline (or the hub list changes) we only ever want to close connections, never open new + // ones. Opening happens lazily when a control frame is actually sent. + backendHubManager.OnHubStatusUpdated.Throttle(TimeSpan.FromMilliseconds(500)).Subscribe(_ => PruneConnections()); + + hubClient.OnHubUpdate.SubscribeAsync(_ => { - _logger.LogDebug("Device update received, updating shockers and refreshing connections"); - await RefreshConnections(); + _logger.LogDebug("Device update received, pruning stale live control connections"); + PruneConnections(); + return Task.CompletedTask; }).AsTask().Wait(); - } - private void HubStatusUpdated(Guid? obj) - { - RefreshConnections().Wait(); + _sweepTask = OsTask.Run(() => SweepLoop(_cts.Token)); } public IAsyncMinimalEventObservable OnStateUpdated => _onStateUpdated; private readonly AsyncMinimalEvent _onStateUpdated = new(); - - public Dictionary LiveControlClients { get; } = new(); - public async Task RefreshConnections() + /// + /// Returns the currently open live control client for a hub, or null if no live control connection is active. + /// Connections are opened lazily when the hub is being controlled and closed again after an idle period. + /// + public IOpenShockLiveControlClient? GetClient(Guid hubId) => + _connections.TryGetValue(hubId, out var connection) ? connection.Client : null; + + /// + /// Control shockers with a specific intensity and control type. This also checks for enabled shockers in the config. + /// Opens the live control connection for the relevant hubs on demand. + /// + public void ControlShockers(IEnumerable shockers, byte intensity, ControlType type) { - await _refreshLock.WaitAsync(); - try + // This runs per live control frame, so resolve through the api's shocker lookup and group by hand rather than + // scanning every hub per shocker. + var lookup = _apiClient.ShockerLookup; + var shockersByHub = new Dictionary>(); + + foreach (var shockerId in shockers) { - await RefreshInternal(); + if (!IsLiveControllable(shockerId)) continue; + if (!lookup.TryGetValue(shockerId, out var location) || location.Shocker.IsPaused) continue; + + if (!shockersByHub.TryGetValue(location.Hub.Id, out var hubShockers)) + shockersByHub[location.Hub.Id] = hubShockers = []; + + hubShockers.Add(shockerId); } - finally + + foreach (var (hubId, hubShockers) in shockersByHub) { - _refreshLock.Release(); + EnsureConnection(hubId)?.SendFrames(hubShockers, intensity, type); } } - - private async Task RefreshInternal() - { - _logger.LogDebug("Refreshing live control connections"); - - // Remove devices that dont exist anymore - // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator - // Linq would be horrible for readability here - foreach (var liveControlClient in LiveControlClients) - { - // Check if the hub is online - var hub = _apiClient.Hubs.Value.FirstOrDefault(x => x.Id == liveControlClient.Key); - if (hub is not null && hub.Status.Online) continue; // If so, dont remove it - - if (LiveControlClients.Remove(liveControlClient.Key, out var removedClient)) - await removedClient.DisposeAsync(); - } - foreach (var device in _apiClient.Hubs.Value.Where(x => x.Status.Online)) + /// + /// Control all enabled and online shockers with a specific intensity and control type. + /// Opens the live control connection for the relevant hubs on demand. + /// Only ever targets owned hubs; shared hubs are never swept by a blanket "control all" and can only be + /// controlled via an explicit call that names their shocker ids. + /// + public void ControlAllShockers(byte intensity, ControlType type) + { + foreach (var hub in _apiClient.Hubs.Value.Where(x => x.Status.Online)) { - // Skip hubs that already have a live control client - if (LiveControlClients.ContainsKey(device.Id)) continue; + var shockers = hub.Shockers + .Where(x => !x.IsPaused && IsLiveControllable(x.Id)) + .Select(x => x.Id) + .ToArray(); + + if (shockers.Length == 0) continue; - _logger.LogTrace("Creating live control client for device [{DeviceId}]", device.Id); - - // Adds the new live control client to the list - await SetupLiveControlClient(device.Id); + EnsureConnection(hub.Id)?.SendFrames(shockers, intensity, type); } + } - OsTask.Run(_onStateUpdated.InvokeAsyncParallel); + /// + /// A shocker is live controllable when it is enabled in config and, for shared shockers, we have been granted the + /// live control permission. Owned shockers are not present in the shared permission map and are always allowed. + /// + private bool IsLiveControllable(Guid shockerId) + { + if (!_configManager.Config.OpenShock.Shockers.TryGetValue(shockerId, out var conf) || !conf.Enabled) + return false; + + if (_apiClient.SharedShockerPermissions.TryGetValue(shockerId, out var permissions)) + return permissions.Live; + + return true; } - private async Task SetupLiveControlClient(Guid deviceId) + /// + /// Closes connections for hubs that are no longer online or no longer exist. Used on login/logout and on hub + /// status changes. Never opens new connections. + /// + public Task RefreshConnections() => PruneConnectionsAsync(idleEviction: false); + + private LiveControlConnection? EnsureConnection(Guid hubId) { - if(_apiClient.Client == null) + if (_connections.TryGetValue(hubId, out var existing)) { - _logger.LogWarning("API client is not initialized, cannot setup live control client for device [{DeviceId}]", deviceId); - return; + existing.Touch(); + return existing; } - var client = new OpenShockLiveControlClient(deviceId, _configManager.Config.OpenShock.Token, _apiClient.Client, _loggerFactory); - LiveControlClients.Add(deviceId, client); + lock (_connectionCreationLock) + { + if (_connections.TryGetValue(hubId, out existing)) + { + existing.Touch(); + return existing; + } + + if (_apiClient.Client == null) + { + _logger.LogWarning("API client is not initialized, cannot open live control connection for hub [{HubId}]", hubId); + return null; + } + + _logger.LogDebug("Opening live control connection for hub [{HubId}]", hubId); + + var client = new OpenShockLiveControlClient(hubId, _configManager.Config.OpenShock.Token, _apiClient.Client, _loggerFactory); + var connection = new LiveControlConnection(client); + _connections[hubId] = connection; + + OsTask.Run(() => StartConnection(hubId, connection)); + + return connection; + } + } + + private async Task StartConnection(Guid hubId, LiveControlConnection connection) + { + var client = connection.Client; - // Websocket connection state await client.State.Updated.SubscribeAsync(async state => { - _logger.LogTrace("Live control client for device [{DeviceId}] status updated {Status}", - deviceId, state); + _logger.LogTrace("Live control connection for hub [{HubId}] status updated {Status}", hubId, state); + + // The socket only accepts frames once connected, so anything that arrived while it was still warming up + // was parked. Replay it now, otherwise the first press after an idle period is silently swallowed. + if (state == WebsocketConnectionState.Connected) connection.FlushPendingFrames(); + await _onStateUpdated.InvokeAsyncParallel(); }); - + await client.OnDispose.SubscribeAsync(async () => { - if (!LiveControlClients.Remove(deviceId, out var removedClient)) return; - _logger.LogDebug("This shouldnt be reached [{DeviceId}]", deviceId); - await removedClient.DisposeAsync(); // Dispose incase it was not disposed + if (_connections.TryRemove(hubId, out _)) + _logger.LogDebug("Live control connection for hub [{HubId}] disposed itself", hubId); + await _onStateUpdated.InvokeAsyncParallel(); }); client.Start(); + + // Notify listeners that a (connecting) client now exists for this hub. + await _onStateUpdated.InvokeAsyncParallel(); } - /// - /// Control shockers with a specific intensity and control type. This also checks for enabled shockers in the config. - /// - /// - /// - /// - public void ControlShockers(IEnumerable shockers, byte intensity, ControlType type) + private async Task SweepLoop(CancellationToken cancellationToken) + { + using var timer = new PeriodicTimer(SweepInterval); + try + { + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + await PruneConnectionsAsync(idleEviction: true); + } + } + catch (OperationCanceledException) + { + // Shutting down + } + } + + private void PruneConnections() => OsTask.Run(() => PruneConnectionsAsync(idleEviction: false)); + + private async Task PruneConnectionsAsync(bool idleEviction) + { + var changed = false; + + foreach (var (hubId, connection) in _connections) + { + var hub = _apiClient.AllHubs.FirstOrDefault(x => x.Id == hubId); + var online = hub?.Status.Online ?? false; + var idle = idleEviction && connection.IdleFor > IdleTimeout; + + if (online && !idle) continue; + + if (!_connections.TryRemove(hubId, out var removed)) continue; + + _logger.LogDebug("Closing live control connection for hub [{HubId}] ({Reason})", hubId, + !online ? "offline" : "idle"); + changed |= await removed.DisposeAsync(); + } + + if (changed) + await _onStateUpdated.InvokeAsyncParallel(); + } + + public async ValueTask DisposeAsync() { - var enabledShockers = shockers.Where(x => - _configManager.Config.OpenShock.Shockers.Any(y => y.Key == x && y.Value.Enabled)); - - var shockersByDevice = enabledShockers.GroupBy( - x => _apiClient.Hubs.Value.FirstOrDefault(y => y.Shockers.Any(z => z.Id == x && !z.IsPaused))?.Id); + await _cts.CancelAsync(); - foreach (var device in shockersByDevice) + // Let the sweep loop observe the cancellation before the token source goes away underneath it. + try { - if (device.Key == null) continue; - if (!LiveControlClients.TryGetValue(device.Key.Value, out var client)) continue; + await _sweepTask; + } + catch (OperationCanceledException) + { + // Shutting down + } + + _cts.Dispose(); - ControlFrame(device.Select(x => x), client, intensity, type); + foreach (var hubId in _connections.Keys) + { + if (_connections.TryRemove(hubId, out var connection)) + await connection.DisposeAsync(); } } /// - /// Control all enabled shockers with a specific intensity and control type. + /// A live control socket plus the bookkeeping around it: when it was last used (for idle eviction), the frames + /// parked while it was still connecting, and whether it has already been disposed. /// - /// - /// - public void ControlAllShockers(byte intensity, ControlType type) + private sealed class LiveControlConnection(OpenShockLiveControlClient client) { - foreach (var (deviceId, liveControlClient) in LiveControlClients) - { - var apiDevice = _apiClient.Hubs.Value - .FirstOrDefault(x => x.Id == deviceId); - if (apiDevice == null) continue; - - ControlFrame(apiDevice.Shockers - .Where(x => !x.IsPaused && - _configManager.Config.OpenShock.Shockers.Any(y => - y.Key == x.Id && y.Value.Enabled)) - .Select(x => x.Id), liveControlClient, intensity, type); + public OpenShockLiveControlClient Client { get; } = client; + + /// + /// Guards frame delivery against disposal, so a frame can never be handed to a client the sweeper is closing. + /// + private readonly Lock _lock = new(); + + /// + /// The latest frame per shocker that arrived before the socket was ready. Latest wins - live control is a + /// continuous stream, so replaying anything but the most recent intensity would be meaningless. + /// + private readonly Dictionary _pendingFrames = new(); + + private long _pendingSinceTicks; + private long _lastUsedTicks = Environment.TickCount64; + private bool _disposed; + + public void Touch() => Interlocked.Exchange(ref _lastUsedTicks, Environment.TickCount64); + + public TimeSpan IdleFor => TimeSpan.FromMilliseconds(Environment.TickCount64 - Interlocked.Read(ref _lastUsedTicks)); + + /// + /// Hands frames to the socket when it is ready, otherwise parks them for replay once it connects. + /// + public void SendFrames(IEnumerable shockers, byte intensity, ControlType type) + { + lock (_lock) + { + if (_disposed) return; + + if (Client.State.Value == WebsocketConnectionState.Connected) + { + foreach (var shocker in shockers) Client.IntakeFrame(shocker, type, intensity); + return; + } + + foreach (var shocker in shockers) _pendingFrames[shocker] = (type, intensity); + _pendingSinceTicks = Environment.TickCount64; + } } - } - - private static void ControlFrame(IEnumerable shockers, OpenShockLiveControlClient client, - byte vibrationIntensity, ControlType type) - { - foreach (var shocker in shockers) + + /// + /// Replays frames parked while the socket was connecting, unless they have gone stale. + /// + public void FlushPendingFrames() { - client.IntakeFrame(shocker, type, vibrationIntensity); + lock (_lock) + { + if (_pendingFrames.Count == 0) return; + + var age = TimeSpan.FromMilliseconds(Environment.TickCount64 - _pendingSinceTicks); + if (!_disposed && age <= PendingFrameMaxAge && + Client.State.Value == WebsocketConnectionState.Connected) + { + foreach (var (shocker, frame) in _pendingFrames) + Client.IntakeFrame(shocker, frame.Type, frame.Intensity); + } + + _pendingFrames.Clear(); + } + } + + /// + /// Marks the connection dead so no further frames reach the client, then closes the socket. + /// + /// False when another caller already claimed the disposal. + public async ValueTask DisposeAsync() + { + lock (_lock) + { + if (_disposed) return false; + _disposed = true; + _pendingFrames.Clear(); + } + + await Client.DisposeAsync(); + return true; } } -} \ No newline at end of file +} diff --git a/Desktop/Ui/Pages/Dash/Components/SharedHubsPart.razor b/Desktop/Ui/Pages/Dash/Components/SharedHubsPart.razor new file mode 100644 index 0000000..6f10fc1 --- /dev/null +++ b/Desktop/Ui/Pages/Dash/Components/SharedHubsPart.razor @@ -0,0 +1,105 @@ +@using LucHeart.WebsocketLibrary +@using OpenShock.Desktop.Models +@using OpenShock.Desktop.ModuleBase.StableInterfaces +@using OpenShock.Desktop.Ui.Utils +@using OpenShock.SDK.CSharp.Live + +
+ Shared @OnlineHubs/@TotalHubs + + + + + +
+
+ + Shared hubs +
+ @OnlineHubs/@TotalHubs online +
+ + + @foreach (var owner in Owners) + { + @if (owner.Hubs.Count == 1) + { + // A single hub is redundant with the owner, so collapse into one row showing just the owner. + var hub = owner.Hubs[0]; +
+ + @owner.Name.Truncate(20) +
+ @HubStateText(hub) +
+ } + else + { +
+ + @owner.Name.Truncate(20) +
+ @foreach (var hub in owner.Hubs) + { +
+ + @hub.Name.Truncate(22) +
+ @HubStateText(hub) +
+ } + } + } +
+
+
+
+ +@code { + /// + /// The shared hubs grouped by owner, summarized in a single aggregate indicator. + /// + [Parameter] + public required IReadOnlyList Owners { get; set; } + + /// + /// Resolves the active live control client for a hub, or null when no live control connection is open. + /// + [Parameter] + public required Func GetClient { get; set; } + + private IEnumerable AllHubs => Owners.SelectMany(o => o.Hubs); + + private int OnlineHubs => AllHubs.Count(h => h.Status.Online); + private int TotalHubs => AllHubs.Count(); + + private Color AggregateColor() + { + if (AllHubs.Any(h => GetClient(h.Id)?.State.Value == WebsocketConnectionState.Connected)) + return Color.Success; + + return OnlineHubs > 0 ? Color.Info : Color.Dark; + } + + private Color HubColor(IOpenShockHub hub) + { + var client = GetClient(hub.Id); + if (client != null) + return client.State.Value switch + { + WebsocketConnectionState.Connected => Color.Success, + WebsocketConnectionState.Connecting => Color.Warning, + WebsocketConnectionState.WaitingForReconnect => Color.Warning, + _ => Color.Error + }; + + return hub.Status.Online ? Color.Info : Color.Dark; + } + + private string HubStateText(IOpenShockHub hub) + { + var client = GetClient(hub.Id); + if (client != null) return $"Live: {client.State.Value}"; + return hub.Status.Online ? "idle" : "offline"; + } +} diff --git a/Desktop/Ui/Pages/Dash/Components/StatePart.razor b/Desktop/Ui/Pages/Dash/Components/StatePart.razor index 835bd49..cc9fbe3 100644 --- a/Desktop/Ui/Pages/Dash/Components/StatePart.razor +++ b/Desktop/Ui/Pages/Dash/Components/StatePart.razor @@ -1,13 +1,30 @@ -
+
@Text - @Client.State.Value.ToString() - @Client.Gateway
- Latency: @(Client.Latency.Value)ms + @if (Client != null) + { + Live control: @Client.State.Value.ToString() + @Client.Gateway
+ Latency: @(Client.Latency.Value)ms + } + else if (Online) + { + Online + Live control idle + } + else + { + Offline + } + @if (Shared) + { +
+ Shared with you + }
@@ -16,4 +33,4 @@ .child-div-align-center > div { align-self: center; } - \ No newline at end of file + diff --git a/Desktop/Ui/Pages/Dash/Components/StatePart.razor.cs b/Desktop/Ui/Pages/Dash/Components/StatePart.razor.cs index 2dc6ebe..0e786c6 100644 --- a/Desktop/Ui/Pages/Dash/Components/StatePart.razor.cs +++ b/Desktop/Ui/Pages/Dash/Components/StatePart.razor.cs @@ -1,47 +1,93 @@ -using System.ComponentModel; -using LucHeart.WebsocketLibrary; +using System.ComponentModel; using Microsoft.AspNetCore.Components; +using LucHeart.WebsocketLibrary; using OpenShock.SDK.CSharp.Live; -using OpenShock.SDK.CSharp.Live.LiveControlModels; using Color = MudBlazor.Color; namespace OpenShock.Desktop.Ui.Pages.Dash.Components; public partial class StatePart : ComponentBase, IAsyncDisposable { + /// + /// The active live control client for this hub, or null when no live control connection is open. + /// Because connections are opened lazily and closed when idle, this switches between null and a client over time. + /// [Parameter] [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] - public required IOpenShockLiveControlClient Client { get; set; } - + public IOpenShockLiveControlClient? Client { get; set; } + + /// + /// Whether the hub is reported online by the backend (independent of whether a live control socket is open). + /// + [Parameter] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] + public bool Online { get; set; } + [Parameter] [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] public required string Text { get; set; } - - private bool _disposed = false; - private IAsyncDisposable _stateSubscription = null!; - private IAsyncDisposable _latencySubscription = null!; - - private Color GetConnectionStateColor() => - Client.State.Value switch - { - WebsocketConnectionState.Connected => Color.Success, - WebsocketConnectionState.Connecting => Color.Warning, - WebsocketConnectionState.WaitingForReconnect => Color.Warning, - _ => Color.Error - }; - - protected override async Task OnInitializedAsync() + + /// + /// Whether this hub is shared with the current user by another owner (as opposed to owned). + /// + [Parameter] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Visible)] + public bool Shared { get; set; } + + private bool _disposed; + private IOpenShockLiveControlClient? _subscribedClient; + private IAsyncDisposable? _stateSubscription; + private IAsyncDisposable? _latencySubscription; + + private Color GetConnectionStateColor() { + if (Client != null) + return Client.State.Value switch + { + WebsocketConnectionState.Connected => Color.Success, + WebsocketConnectionState.Connecting => Color.Warning, + WebsocketConnectionState.WaitingForReconnect => Color.Warning, + _ => Color.Error + }; + + return Online ? Color.Info : Color.Dark; + } + + protected override async Task OnParametersSetAsync() + { + // The Client parameter switches between null and a client as connections open / close. Keep our subscriptions + // pointed at the current client. + if (ReferenceEquals(_subscribedClient, Client)) return; + + await UnsubscribeAsync(); + _subscribedClient = Client; + + if (Client == null) return; + _stateSubscription = await Client.State.Updated.SubscribeAsync(_ => InvokeAsync(StateHasChanged)); _latencySubscription = await Client.Latency.Updated.SubscribeAsync(_ => InvokeAsync(StateHasChanged)); } - + + private async Task UnsubscribeAsync() + { + if (_stateSubscription != null) + { + await _stateSubscription.DisposeAsync(); + _stateSubscription = null; + } + + if (_latencySubscription != null) + { + await _latencySubscription.DisposeAsync(); + _latencySubscription = null; + } + } + public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; - - await _stateSubscription.DisposeAsync(); - await _latencySubscription.DisposeAsync(); + + await UnsubscribeAsync(); } -} \ No newline at end of file +} diff --git a/Desktop/Ui/Pages/Dash/SideBar.razor b/Desktop/Ui/Pages/Dash/SideBar.razor index e987dfa..00a857f 100644 --- a/Desktop/Ui/Pages/Dash/SideBar.razor +++ b/Desktop/Ui/Pages/Dash/SideBar.razor @@ -97,21 +97,14 @@ @foreach (var device in Api.Hubs.Value) { - if (LiveControlManager.LiveControlClients.TryGetValue(device.Id, out var client)) - { - - } - else - { -
- @device.Name.Truncate(13) - - - -
- } + + } + + @if (Api.SharedOwners.Value.Count > 0) + { + }
diff --git a/Desktop/Ui/Pages/Dash/Tabs/DashboardTab.razor b/Desktop/Ui/Pages/Dash/Tabs/DashboardTab.razor index a371919..5e10d95 100644 --- a/Desktop/Ui/Pages/Dash/Tabs/DashboardTab.razor +++ b/Desktop/Ui/Pages/Dash/Tabs/DashboardTab.razor @@ -57,19 +57,14 @@ @foreach (var device in Api.Hubs.Value) { - if (LiveControlManager.LiveControlClients.TryGetValue(device.Id, out var client)) - { - - } - else - { -
- @device.Name.Truncate(13) - - - -
- } + + } + + @if (Api.SharedOwners.Value.Count > 0) + { + } diff --git a/Desktop/Ui/Pages/Dash/Tabs/ShockersTab/ShockerComponent.razor b/Desktop/Ui/Pages/Dash/Tabs/ShockersTab/ShockerComponent.razor index e0282e1..d0042d0 100644 --- a/Desktop/Ui/Pages/Dash/Tabs/ShockersTab/ShockerComponent.razor +++ b/Desktop/Ui/Pages/Dash/Tabs/ShockersTab/ShockerComponent.razor @@ -3,6 +3,7 @@ @using OpenShock.Desktop.Models.BaseImpl @using OpenShock.Desktop.ModuleBase.Models @using OpenShock.Desktop.ModuleBase.StableInterfaces +@using OpenShock.SDK.CSharp.Models @inject ConfigManager ConfigManager @inject OpenShockApi OpenShockApi @@ -15,7 +16,20 @@ @bind-Value:after="OnShockerConfigUpdate"/> - @if (!_wasChanged) + @if (Permissions is { } permissions) + { +
+ @foreach (var (icon, label, granted) in PermissionIcons(permissions)) + { + + + + } +
+ } + else if (!_wasChanged) { } -@Shocker.Name + + @Shocker.Name + @if (Permissions != null && Shocker.IsPaused) + { + + + + } + @Shocker.Id @@ -47,8 +69,23 @@ else [Parameter] public required IOpenShockShocker Shocker { get; set; } + /// + /// The permissions granted to us for this shocker when it is shared with us by another owner, or null when we own + /// the shocker ourselves. Shared shockers show their permissions instead of a pause toggle, since pausing another + /// user's shocker is not ours to do. + /// + [Parameter] public ShockerPermissions? Permissions { get; set; } + private bool _wasChanged = false; + private static (string Icon, string Label, bool Granted)[] PermissionIcons(ShockerPermissions permissions) => + [ + (Icons.Material.Filled.Bolt, "Shock", permissions.Shock), + (Icons.Material.Filled.Vibration, "Vibrate", permissions.Vibrate), + (Icons.Material.Filled.VolumeUp, "Sound", permissions.Sound), + (Icons.Material.Filled.Sensors, "Live control", permissions.Live) + ]; + private async Task PauseShocker() { if (OpenShockApi.Client == null || _wasChanged) return; @@ -56,9 +93,11 @@ else await OpenShockApi.Client.PauseShocker(Shocker.Id, !Shocker.IsPaused); } - protected override void OnInitialized() + // A refresh replaces the config's shocker dictionary wholesale, so re-resolve the entry on every parameter set + // rather than only once - otherwise we would keep writing to a detached ShockerConf that is never persisted. + protected override void OnParametersSet() { - _configShocker = ConfigManager.Config.OpenShock.Shockers[Shocker.Id]; + _configShocker = ConfigManager.Config.OpenShock.Shockers.GetValueOrDefault(Shocker.Id); } private void OnShockerConfigUpdate() diff --git a/Desktop/Ui/Pages/Dash/Tabs/ShockersTab/ShockersTab.razor b/Desktop/Ui/Pages/Dash/Tabs/ShockersTab/ShockersTab.razor index a5f3450..b576e0d 100644 --- a/Desktop/Ui/Pages/Dash/Tabs/ShockersTab/ShockersTab.razor +++ b/Desktop/Ui/Pages/Dash/Tabs/ShockersTab/ShockersTab.razor @@ -1,4 +1,5 @@ @using OpenShock.Desktop.Backend +@using OpenShock.Desktop.ModuleBase.StableInterfaces @using OpenShock.SDK.CSharp.Models @using ControlType = OpenShock.SDK.CSharp.Models.ControlType @inject OpenShockApi OpenShockApi @@ -26,7 +27,7 @@ Hover="true" Elevation="0" FixedHeader="true" - Height="calc(100vh - 180px)"> + Height="@(HasSharedShockers ? "calc(50vh - 120px)" : "calc(100vh - 180px)")"> Enabled Status @@ -39,6 +40,43 @@ +@if (HasSharedShockers) +{ +
+ + Shared with you + + Disabled by default – enable a shocker to let modules control it + +
+ + + + + Enabled + Permissions + Name + Guid + + + + @context.Key + + + + + + + +} +