diff --git a/src/DependencyInjection/NexusServiceCollectionExtensions.cs b/src/DependencyInjection/NexusServiceCollectionExtensions.cs index 79891f84b..2951f1fdd 100644 --- a/src/DependencyInjection/NexusServiceCollectionExtensions.cs +++ b/src/DependencyInjection/NexusServiceCollectionExtensions.cs @@ -1133,6 +1133,7 @@ public static IServiceCollection AddNexusDevices(this IServiceCollection service services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); @@ -1510,6 +1511,7 @@ public static IServiceCollection AddNexusPeripherals(this IServiceCollection ser #endif services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); return services; diff --git a/src/Devices/Handlers/Aw3225QfHandler.cs b/src/Devices/Handlers/Aw3225QfHandler.cs new file mode 100644 index 000000000..d63267bd2 --- /dev/null +++ b/src/Devices/Handlers/Aw3225QfHandler.cs @@ -0,0 +1,20 @@ +using System.Collections.Generic; +using Nexus.Service.Platform.Displays; + +namespace Nexus.Service.Devices.Handlers; + +/// AW3225QF monitor; its crosshair is controlled over video DDC/CI. +public sealed class Aw3225QfHandler : IDeviceHandler +{ + private readonly Aw3225QfCrosshairController _crosshair; + + public Aw3225QfHandler(Aw3225QfCrosshairController crosshair) => _crosshair = crosshair; + + public string Id => "aw3225qf"; + public string Name => "Alienware AW3225QF"; + public string Category => "display"; + public IReadOnlyList Identifiers { get; } = System.Array.Empty(); + public bool SupportsNexusControl => false; + public bool IsConnected(IReadOnlyList _) => _crosshair.FindDisplayId() is not null; + public string GetFirmwareVersion() => ""; +} diff --git a/src/Models/Displays/Aw3225QfCrosshairDtos.cs b/src/Models/Displays/Aw3225QfCrosshairDtos.cs new file mode 100644 index 000000000..df7ab4581 --- /dev/null +++ b/src/Models/Displays/Aw3225QfCrosshairDtos.cs @@ -0,0 +1,26 @@ +namespace Nexus.Service.Models.Displays; + +public sealed class Aw3225QfCrosshairConfig +{ + public int Type { get; set; } + public int Color { get; set; } = 2; + public int MaskControl { get; set; } +} + +public sealed class Aw3225QfCrosshairRequest +{ + public bool Enabled { get; set; } + public int Type { get; set; } + public int Color { get; set; } = 2; + public int MaskControl { get; set; } +} + +public sealed class Aw3225QfCrosshairStatus +{ + public bool Connected { get; set; } + public bool Enabled { get; set; } + public int ActiveEngine { get; set; } + public string DisplayId { get; set; } = ""; + public Aw3225QfCrosshairConfig Config { get; set; } = new(); + public string Error { get; set; } = ""; +} diff --git a/src/Models/Displays/DisplayDtos.cs b/src/Models/Displays/DisplayDtos.cs index a38d3bb51..538511b5a 100644 --- a/src/Models/Displays/DisplayDtos.cs +++ b/src/Models/Displays/DisplayDtos.cs @@ -8,6 +8,8 @@ public sealed class DisplayDto public string Name { get; set; } = ""; public string Manufacturer { get; set; } = ""; public string Model { get; set; } = ""; + /// Physical monitor description from dxva2 or the EDID name on Windows. + public string PhysicalDescription { get; set; } = ""; public bool IsInternal { get; set; } public bool IsDdcCapable { get; set; } /// False when the user turned brightness control off for this diff --git a/src/Persistence/NexusSettings.cs b/src/Persistence/NexusSettings.cs index 8edf09afa..f5984f5e5 100644 --- a/src/Persistence/NexusSettings.cs +++ b/src/Persistence/NexusSettings.cs @@ -1171,6 +1171,11 @@ public sealed class KrakenFirmwareLighting public sealed class DevicesSettings { + /// Last AW3225QF crosshair selection; VCP 0xED cannot be read back. + public Dictionary Aw3225QfCrosshairs { get; set; } = new(); + /// Initial development selection, migrated on the next successful write. + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + public Nexus.Service.Models.Displays.Aw3225QfCrosshairConfig? Aw3225QfCrosshair { get; set; } public List DisabledLightingDevices { get; set; } = new(); /// Stable display ids the user turned brightness control off for. Nexus issues no DDC/CI transaction to a listed display - not even the capability probe - which is the escape hatch for a monitor whose firmware hangs on DDC. NOT profile-scoped: a panel's tolerance for DDC is a property of the hardware, not of a lighting/cooling persona. public List DdcDisabledDisplays { get; set; } = new(); diff --git a/src/Platform/Displays/Aw3225QfCrosshairController.cs b/src/Platform/Displays/Aw3225QfCrosshairController.cs new file mode 100644 index 000000000..ccd9df41b --- /dev/null +++ b/src/Platform/Displays/Aw3225QfCrosshairController.cs @@ -0,0 +1,176 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Nexus.Service.Models.Displays; +using Nexus.Service.Persistence; + +namespace Nexus.Service.Platform.Displays; + +/// AW3225QF Gen1 AlienVision crosshair over DDC/CI (EC/ED). +public sealed class Aw3225QfCrosshairController +{ + private const byte EngineVcp = 0xEC; + private const byte AdjustmentVcp = 0xED; + private const int CrosshairEngine = 6; + private readonly IDisplayBrightnessProvider _displays; + private readonly IConfigStore _store; + private readonly SemaphoreSlim _gate = new(1, 1); + private readonly object _discoveryGate = new(); + private readonly Func _delay; + private readonly Func _hostSupported; + private readonly Func _clock; + private string? _cachedDisplayId; + private long? _discoveredAt; + + public Aw3225QfCrosshairController(IDisplayBrightnessProvider displays, IConfigStore store) + : this(displays, store, Task.Delay, OperatingSystem.IsWindows, () => Environment.TickCount64) { } + + internal Aw3225QfCrosshairController(IDisplayBrightnessProvider displays, IConfigStore store, + Func delay, Func hostSupported, Func clock) + { + _displays = displays; + _store = store; + _delay = delay; + _hostSupported = hostSupported; + _clock = clock; + } + + public string? FindDisplayId() + { + if (!_hostSupported()) return null; + lock (_discoveryGate) + { + var now = _clock(); + if (_discoveredAt is long at && now - at < 5000) return _cachedDisplayId; + try + { + var disabled = _store.Load().Devices.DdcDisabledDisplays; + _cachedDisplayId = _displays.Enumerate(disabled) + .FirstOrDefault(d => d.PhysicalDescription.Contains("AW3225QF", StringComparison.OrdinalIgnoreCase) + || d.Name.Contains("AW3225QF", StringComparison.OrdinalIgnoreCase))?.Id; + _discoveredAt = now; + return _cachedDisplayId; + } + catch + { + // Do not cache helper failures during login. + _discoveredAt = null; + return null; + } + } + } + + public async Task GetStatusAsync(CancellationToken ct = default) + { + await _gate.WaitAsync(ct); + try { return await ReadStatusAsync(FindDisplayId(), ct); } + finally { _gate.Release(); } + } + + public async Task ApplyAsync(Aw3225QfCrosshairRequest request, CancellationToken ct = default) + { + await _gate.WaitAsync(ct); + try + { + var id = FindDisplayId(); + if (id is null) return Unavailable("AW3225QF is not connected."); + if (IsDdcDisabled(id)) return Unavailable("DDC/CI is disabled for this display.", id); + if (request.Type is < 0 or > 11 || request.Color is < 0 or > 5 || request.MaskControl is < 0 or > 2) + return Unavailable("Invalid crosshair settings.", id); + ct.ThrowIfCancellationRequested(); + // Finish a started transaction if the browser disconnects, so a + // cancellation after EC=0 does not leave the crosshair switched off. + ct = CancellationToken.None; + + if (!request.Enabled) + { + var engine = await ReadEngineAsync(id, ct); + if (engine is null) return Unavailable("Could not read AlienVision state.", id); + // Switching off another AlienVision mode would be surprising. + if ((engine.Value & 0xF) == CrosshairEngine && !await WriteRetry(id, EngineVcp, 0, ct)) + return Unavailable("Could not turn off the crosshair.", id); + } + else + { + var packed = (CrosshairEngine << 13) | (3 << 10) + | (request.MaskControl << 8) | (request.Type << 4) | (request.Color << 1); + if (!await WriteRetry(id, EngineVcp, 0, ct)) + return Unavailable("Could not prepare AlienVision.", id); + await _delay(300, ct); + if (!await WriteRetry(id, AdjustmentVcp, packed, ct)) + return Unavailable("Could not set the crosshair.", id); + await _delay(250, ct); + if (!await WriteRetry(id, AdjustmentVcp, packed, ct)) + return Unavailable("Could not set the crosshair.", id); + await _delay(300, ct); + if (!await WriteRetry(id, EngineVcp, CrosshairEngine, ct)) + return Unavailable("Could not enable the crosshair.", id); + } + _store.Update(s => + { + s.Devices.Aw3225QfCrosshairs[id] = new Aw3225QfCrosshairConfig + { + Type = request.Type, Color = request.Color, MaskControl = request.MaskControl, + }; + s.Devices.Aw3225QfCrosshair = null; + }); + return await ReadStatusAsync(id, ct); + } + finally { _gate.Release(); } + } + + private bool IsDdcDisabled(string id) => _store.Load().Devices.DdcDisabledDisplays.Contains(id); + + private Aw3225QfCrosshairConfig ConfigFor(string id) + { + var devices = _store.Load().Devices; + var config = devices.Aw3225QfCrosshairs.TryGetValue(id, out var saved) + ? saved : devices.Aw3225QfCrosshair ?? new(); + return new() { Type = config.Type, Color = config.Color, MaskControl = config.MaskControl }; + } + + private async Task ReadStatusAsync(string? id, CancellationToken ct) + { + if (id is null) return Unavailable("AW3225QF is not connected."); + if (IsDdcDisabled(id)) return Unavailable("DDC/CI is disabled for this display.", id); + var engine = await ReadEngineAsync(id, ct); + if (engine is null) return Unavailable("Could not read AlienVision state.", id); + var active = engine.Value & 0xF; + return new Aw3225QfCrosshairStatus + { + Connected = true, DisplayId = id, ActiveEngine = active, + Enabled = active == CrosshairEngine, + Config = ConfigFor(id), + }; + } + + private async Task WriteRetry(string id, byte code, int value, CancellationToken ct) + { + for (var attempt = 0; attempt < 5; attempt++) + { + if (IsDdcDisabled(id)) return false; + if (_displays.SetVcp(id, code, value)) return true; + if (attempt < 4) await _delay(120, ct); + } + return false; + } + + private async Task ReadEngineAsync(string id, CancellationToken ct) + { + for (var attempt = 0; attempt < 3; attempt++) + { + ct.ThrowIfCancellationRequested(); + var engine = _displays.GetVcp(id, EngineVcp); + if (engine is not null) return engine.Value; + if (attempt < 2) await _delay(120, ct); + } + return null; + } + + private Aw3225QfCrosshairStatus Unavailable(string error, string? id = null) => new() + { + Error = error, Connected = id is not null, DisplayId = id ?? "", + Config = id is not null ? ConfigFor(id) : new(), + }; +} diff --git a/src/Platform/Displays/WindowsDisplayBrightnessProvider.cs b/src/Platform/Displays/WindowsDisplayBrightnessProvider.cs index 7e32c80f2..44cf38bba 100644 --- a/src/Platform/Displays/WindowsDisplayBrightnessProvider.cs +++ b/src/Platform/Displays/WindowsDisplayBrightnessProvider.cs @@ -84,8 +84,17 @@ public IReadOnlyList Enumerate(IReadOnlyCollection? excluded Name = name, Manufacturer = manufacturer, Model = model, + PhysicalDescription = manufacturer.Equals("DEL", StringComparison.OrdinalIgnoreCase) + ? GetPhysicalDescription(entry.HMonitor) : "", IsInternal = isInternal, }; + if (manufacturer.Equals("DEL", StringComparison.OrdinalIgnoreCase) + && !dto.PhysicalDescription.Contains("AW3225QF", StringComparison.OrdinalIgnoreCase)) + { + var edidName = WindowsDisplayIdentity.ReadEdidMonitorName(entry.AdapterDevice); + if (edidName.Contains("AW3225QF", StringComparison.OrdinalIgnoreCase)) + dto.PhysicalDescription = edidName; + } dto.BrightnessControl.UnsupportedReason = "Brightness control is not available for this display."; @@ -442,6 +451,18 @@ private static bool TryGetPhysicalMonitor(IntPtr hMonitor, out IntPtr phys) return phys != IntPtr.Zero; } + private static string GetPhysicalDescription(IntPtr hMonitor) + { + if (!GetNumberOfPhysicalMonitorsFromHMONITOR(hMonitor, out var count) || count == 0) return ""; + var monitors = new PHYSICAL_MONITOR[count]; + if (!GetPhysicalMonitorsFromHMONITOR(hMonitor, count, monitors)) return ""; + try { return monitors[0].szPhysicalMonitorDescription ?? ""; } + finally + { + foreach (var monitor in monitors) DestroyPhysicalMonitor(monitor.hPhysicalMonitor); + } + } + private static bool TryGetMonitorBrightness(IntPtr phys, out uint min, out uint cur, out uint max) { min = 0; cur = 0; max = 0; diff --git a/src/Platform/Displays/WindowsDisplayIdentity.cs b/src/Platform/Displays/WindowsDisplayIdentity.cs index 21967b3c6..f7c04df0b 100644 --- a/src/Platform/Displays/WindowsDisplayIdentity.cs +++ b/src/Platform/Displays/WindowsDisplayIdentity.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32; namespace Nexus.Service.Platform.Displays; @@ -99,6 +101,30 @@ internal static string ReadMonitorDeviceId(string adapterDeviceName) return monitor.DeviceID ?? ""; } + /// EDID monitor-name descriptor (0xFC), used when dxva2's description is generic. + internal static string ReadEdidMonitorName(string adapterDeviceName) + { + try + { + var deviceId = ReadMonitorDeviceId(adapterDeviceName); + var parts = deviceId.Split('#'); + if (parts.Length < 3) return ""; + var path = $@"SYSTEM\CurrentControlSet\Enum\DISPLAY\{parts[1]}\{parts[2]}\Device Parameters"; + using var key = Registry.LocalMachine.OpenSubKey(path); + if (key?.GetValue("EDID") is not byte[] edid || edid.Length < 126) return ""; + foreach (var offset in new[] { 54, 72, 90, 108 }) + { + if (edid[offset] == 0 && edid[offset + 1] == 0 && edid[offset + 2] == 0 + && edid[offset + 3] == 0xFC && edid[offset + 4] == 0) + { + return Encoding.ASCII.GetString(edid, offset + 5, 13).Trim('\0', '\n', '\r', ' '); + } + } + } + catch { /* A missing or protected EDID is not a display-enumeration failure. */ } + return ""; + } + /// /// Monitor device interface path (\\?\DISPLAY#...#{guid}) via /// EDD_GET_DEVICE_INTERFACE_NAME only, with no flags=0 fallback: callers diff --git a/src/Routes/DisplayRoutes.cs b/src/Routes/DisplayRoutes.cs index 3141b47f4..a2ef06194 100644 --- a/src/Routes/DisplayRoutes.cs +++ b/src/Routes/DisplayRoutes.cs @@ -165,6 +165,16 @@ public static void MapDisplayEndpoints(this WebApplication app) // System monitors (external DDC/CI + internal panels) app.MapGet("/displays", (DisplayBrightnessController d) => d.ListDisplays()).AllowPanel(); + app.MapGet("/devices/aw3225qf/crosshair", async (Aw3225QfCrosshairController crosshair, CancellationToken ct) => + Results.Json(await crosshair.GetStatusAsync(ct), AppJsonContext.Default.Aw3225QfCrosshairStatus)).AllowPanel(); + app.MapPost("/devices/aw3225qf/crosshair", async ( + Aw3225QfCrosshairRequest body, Aw3225QfCrosshairController crosshair, CancellationToken ct) => + { + var status = await crosshair.ApplyAsync(body, ct); + return Results.Json(status, AppJsonContext.Default.Aw3225QfCrosshairStatus, + statusCode: status.Error.Length == 0 ? 200 : 422); + }).AllowPanel(); + // OS monitor topology (positions, modes, scale) merged with panel state. app.MapGet("/displays/topology", (DisplayTopologyService topology) => topology.GetTopology()).AllowPanel(); diff --git a/src/Serialization/AppJsonContext.cs b/src/Serialization/AppJsonContext.cs index a013e0659..b2429b78e 100644 --- a/src/Serialization/AppJsonContext.cs +++ b/src/Serialization/AppJsonContext.cs @@ -309,6 +309,9 @@ namespace Nexus.Service.Serialization; // Displays (system monitors: brightness + DDC/CI VCP) [JsonSerializable(typeof(Nexus.Service.Models.Displays.DisplayDto))] +[JsonSerializable(typeof(Nexus.Service.Models.Displays.Aw3225QfCrosshairConfig))] +[JsonSerializable(typeof(Nexus.Service.Models.Displays.Aw3225QfCrosshairRequest))] +[JsonSerializable(typeof(Nexus.Service.Models.Displays.Aw3225QfCrosshairStatus))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(Nexus.Service.Models.Displays.DisplayCapabilitiesDto))] [JsonSerializable(typeof(Nexus.Service.Models.Displays.DisplayBrightnessControlDto))] diff --git a/tests/Nexus.Service.Tests/Aw3225QfCrosshairControllerTests.cs b/tests/Nexus.Service.Tests/Aw3225QfCrosshairControllerTests.cs new file mode 100644 index 000000000..02fe64d88 --- /dev/null +++ b/tests/Nexus.Service.Tests/Aw3225QfCrosshairControllerTests.cs @@ -0,0 +1,220 @@ +using Nexus.Service.Models.Displays; +using Nexus.Service.Persistence; +using Nexus.Service.Platform.Displays; + +namespace Nexus.Service.Tests; + +public sealed class Aw3225QfCrosshairControllerTests +{ + [Theory] + [InlineData(0, 2, 0, 0xCC04)] + [InlineData(5, 3, 0, 0xCC56)] + [InlineData(11, 5, 2, 0xCEBA)] + public async Task Enable_UsesGen1HeaderDoubleWriteAndDelays(int type, int color, int mask, int packed) + { + var provider = new FakeDisplays(); + var store = new MemoryStore(); + var controller = Create(provider, store); + + var result = await controller.ApplyAsync(new() { Enabled = true, Type = type, Color = color, MaskControl = mask }); + + Assert.Equal(new[] { "EC:0", "wait:300", $"ED:{packed}", "wait:250", $"ED:{packed}", "wait:300", "EC:6" }, provider.Trace); + Assert.True(result.Enabled); + Assert.Equal("", result.Error); + Assert.Equal(type, store.Load().Devices.Aw3225QfCrosshairs["monitor1"].Type); + Assert.All(provider.ReadCodes, code => Assert.Equal(0xEC, code)); + } + + [Theory] + [InlineData(-1, 2, 0)] + [InlineData(12, 2, 0)] + [InlineData(0, -1, 0)] + [InlineData(0, 6, 0)] + [InlineData(0, 2, -1)] + [InlineData(0, 2, 3)] + public async Task InvalidSettings_NeverWrite(int type, int color, int mask) + { + var provider = new FakeDisplays(); + var store = new MemoryStore(); + var result = await Create(provider, store).ApplyAsync(new() { Enabled = true, Type = type, Color = color, MaskControl = mask }); + Assert.NotEmpty(result.Error); + Assert.Empty(provider.Trace); + Assert.Empty(store.Load().Devices.Aw3225QfCrosshairs); + } + + [Fact] + public async Task FailedAdjustment_RetriesWithoutEnablingOrSaving() + { + var provider = new FakeDisplays { FailWriteCode = 0xED }; + var store = new MemoryStore(); + var result = await Create(provider, store).ApplyAsync(new() { Enabled = true, Type = 5 }); + Assert.NotEmpty(result.Error); + Assert.Equal(5, provider.Trace.Count(t => t.StartsWith("ED:"))); + Assert.DoesNotContain("EC:6", provider.Trace); + Assert.Empty(store.Load().Devices.Aw3225QfCrosshairs); + } + + [Fact] + public async Task TransientReadAndWriteFailures_Recover() + { + var provider = new FakeDisplays { FailWritesRemaining = 1, FailReadsRemaining = 1 }; + var result = await Create(provider, new()).ApplyAsync(new() { Enabled = true }); + Assert.True(result.Enabled); + Assert.Equal(2, provider.Trace.Count(t => t == "EC:0")); + Assert.Equal(2, provider.ReadCodes.Count); + } + + [Fact] + public async Task Disable_DoesNotSwitchOffAnotherAlienVisionEngine() + { + var provider = new FakeDisplays { Engine = 0x1003 }; + var result = await Create(provider, new()).ApplyAsync(new() { Enabled = false }); + Assert.False(result.Enabled); + Assert.Equal(3, result.ActiveEngine); + Assert.Empty(provider.Trace); + } + + [Fact] + public async Task DiscoveryCache_RechecksOptOutBeforeEveryOperation() + { + var provider = new FakeDisplays(); + var store = new MemoryStore(); + long now = 0; + var controller = Create(provider, store, clock: () => now); + Assert.Equal("monitor1", controller.FindDisplayId()); + Assert.Equal("monitor1", controller.FindDisplayId()); + Assert.Equal(1, provider.Enumerations); + + store.Update(s => s.Devices.DdcDisabledDisplays.Add("monitor1")); + var read = await controller.GetStatusAsync(); + var write = await controller.ApplyAsync(new() { Enabled = true }); + Assert.True(read.Connected); + Assert.NotEmpty(read.Error); + Assert.NotEmpty(write.Error); + Assert.Empty(provider.ReadCodes); + Assert.Empty(provider.Trace); + + now = 5001; + controller.FindDisplayId(); + Assert.Contains("monitor1", provider.Excluded!); + provider.Displays.Clear(); + now = 10002; + Assert.Null(controller.FindDisplayId()); + } + + [Fact] + public async Task OtherModel_AndUnsupportedHost_NeverUseDdc() + { + var provider = new FakeDisplays(); + provider.Displays[0].PhysicalDescription = "Alienware AW2725QF"; + Assert.False((await Create(provider, new()).GetStatusAsync()).Connected); + Assert.Empty(provider.ReadCodes); + var unsupported = new Aw3225QfCrosshairController(provider, new MemoryStore(), (_, _) => Task.CompletedTask, () => false, () => 0); + var enumerations = provider.Enumerations; + Assert.Null(unsupported.FindDisplayId()); + Assert.Equal(enumerations, provider.Enumerations); + } + + [Fact] + public async Task ConcurrentRequests_DoNotInterleaveTransactions() + { + var provider = new FakeDisplays(); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var firstDelay = true; + var controller = Create(provider, new(), async (ms, _) => + { + if (ms == 300 && firstDelay) + { + firstDelay = false; + entered.SetResult(); + await release.Task; + } + }); + var first = controller.ApplyAsync(new() { Enabled = true, Type = 0 }); + await entered.Task; + var second = controller.ApplyAsync(new() { Enabled = true, Type = 5, Color = 3 }); + Assert.Equal(new[] { "EC:0" }, provider.Trace); + release.SetResult(); + await Task.WhenAll(first, second); + Assert.Equal(new[] { "EC:0", "ED:52228", "ED:52228", "EC:6", "EC:0", "ED:52310", "ED:52310", "EC:6" }, provider.Trace); + } + + [Fact] + public async Task BrowserCancellation_AfterFirstWrite_StillFinishesTransaction() + { + var provider = new FakeDisplays(); + using var cts = new CancellationTokenSource(); + var controller = Create(provider, new(), (_, _) => { cts.Cancel(); return Task.CompletedTask; }); + var result = await controller.ApplyAsync(new() { Enabled = true }, cts.Token); + Assert.True(result.Enabled); + Assert.Equal("EC:6", provider.Trace.Last()); + } + + [Fact] + public async Task SavedSelection_IsScopedToDisplayAndLegacySelectionMigrates() + { + var provider = new FakeDisplays(); + var store = new MemoryStore(); + store.Load().Devices.Aw3225QfCrosshair = new() { Type = 5, Color = 3 }; + long now = 0; + var controller = Create(provider, store, clock: () => now); + Assert.Equal(5, (await controller.GetStatusAsync()).Config.Type); + await controller.ApplyAsync(new() { Enabled = true, Type = 5, Color = 3 }); + Assert.Null(store.Load().Devices.Aw3225QfCrosshair); + provider.Displays[0].Id = "monitor2"; + now = 5001; + Assert.Equal(0, (await controller.GetStatusAsync()).Config.Type); + await controller.ApplyAsync(new() { Enabled = false, Type = 1 }); + Assert.Equal(5, store.Load().Devices.Aw3225QfCrosshairs["monitor1"].Type); + Assert.Equal(1, store.Load().Devices.Aw3225QfCrosshairs["monitor2"].Type); + } + + private static Aw3225QfCrosshairController Create(FakeDisplays provider, MemoryStore store, + Func? delay = null, Func? clock = null) + => new(provider, store, delay ?? ((ms, _) => { provider.Trace.Add($"wait:{ms}"); return Task.CompletedTask; }), () => true, clock ?? (() => 0)); + + private sealed class MemoryStore : IConfigStore + { + private readonly NexusSettings _settings = new(); + public string SettingsPath => ":memory:"; + public NexusSettings Load() => _settings; + public void Update(Action mutate) { mutate(_settings); OnChanged?.Invoke(); } + public void Reload() { } + public void FlushNow() { } + public event Action? OnChanged; + } + + private sealed class FakeDisplays : IDisplayBrightnessProvider + { + public List Displays { get; } = new() { new() { Id = "monitor1", PhysicalDescription = "Alienware AW3225QF" } }; + public List Trace { get; } = new(); + public List ReadCodes { get; } = new(); + public int Engine { get; set; } = 0x1006; + public byte? FailWriteCode { get; set; } + public int FailWritesRemaining { get; set; } + public int FailReadsRemaining { get; set; } + public int Enumerations { get; private set; } + public IReadOnlyCollection? Excluded { get; private set; } + public string Hint => ""; + public IReadOnlyList Enumerate(IReadOnlyCollection? excludedIds = null) + { Enumerations++; Excluded = excludedIds; return Displays; } + public DisplayVcpDto? GetVcp(string id, byte code) + { + ReadCodes.Add(code); + if (FailReadsRemaining-- > 0) return null; + return new() { Id = id, Code = code, Value = Engine }; + } + public bool SetVcp(string id, byte code, int value) + { + Trace.Add($"{code:X2}:{value}"); + if (FailWriteCode == code || FailWritesRemaining-- > 0) return false; + if (code == 0xEC) Engine = value; + return true; + } + public int? GetBrightness(string id) => null; + public DisplayBrightnessDto SetBrightness(string id, int percent) => new(); + public DisplayBrightnessWritePolicy GetBrightnessWritePolicy(string id) => new(); + public string? FindDisplayIdByHardwareName(IReadOnlyList fragments) => null; + } +} diff --git a/tests/Nexus.Service.Tests/DependencyInjection/NexusServiceCollectionExtensionsTests.cs b/tests/Nexus.Service.Tests/DependencyInjection/NexusServiceCollectionExtensionsTests.cs index 171c8bfc1..455e4741a 100644 --- a/tests/Nexus.Service.Tests/DependencyInjection/NexusServiceCollectionExtensionsTests.cs +++ b/tests/Nexus.Service.Tests/DependencyInjection/NexusServiceCollectionExtensionsTests.cs @@ -106,14 +106,15 @@ public void Devices_resolves_manager_and_handlers() var handlers = sp.GetServices().ToArray(); // CNVS, QSeries (Q60+Q80 collapsed), Y70, Keeb, IbpKeyboard, IbpMouse, FanHub, AW5, // NP50, SmartHub, LianLi, LianLiWireless, LianLiTl, Galahad2, CorsairLink, - // CorsairLinkLcd, Strimer, Nollie, Tryx, StreamDeck, Kraken, SmbusDram (the chipset + // CorsairLinkLcd, Strimer, Nollie, Tryx, StreamDeck, Kraken, AW3225QF, SmbusDram (the chipset // bus as a device), plus one JpegPanelHandler per JpegPanelModel (Galahad II LCD, // Corsair XC7, Corsair Elite Capellix, ID-Cooling FX-LCD). // ... plus one BulkPanelHandler per bulk-pipe driver (Thermalright, Ryujin, Screen 8.8, // ZMatrices). Assert.Equal( - 22 + Nexus.Service.Peripherals.JpegPanels.JpegPanelModel.All.Length + 4, + 23 + Nexus.Service.Peripherals.JpegPanels.JpegPanelModel.All.Length + 4, handlers.Length); + Assert.Single(handlers.OfType()); } [Fact]