Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/DependencyInjection/NexusServiceCollectionExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1133,6 +1133,7 @@ public static IServiceCollection AddNexusDevices(this IServiceCollection service
services.AddSingleton<IDeviceHandler, Nexus.Service.Devices.Handlers.SmbusDramHandler>();
services.AddSingleton<IDeviceHandler, Nexus.Service.Devices.Handlers.QSeriesHandler>();
services.AddSingleton<IDeviceHandler, Nexus.Service.Devices.Handlers.Y70Handler>();
services.AddSingleton<IDeviceHandler, Nexus.Service.Devices.Handlers.Aw3225QfHandler>();
services.AddSingleton<IDeviceHandler, Nexus.Service.Devices.Handlers.KeebHandler>();
services.AddSingleton<IDeviceHandler, Nexus.Service.Devices.Handlers.IbpKeyboardHandler>();
services.AddSingleton<IDeviceHandler, Nexus.Service.Devices.Handlers.IbpMouseHandler>();
Expand Down Expand Up @@ -1510,6 +1511,7 @@ public static IServiceCollection AddNexusPeripherals(this IServiceCollection ser
#endif
services.AddSingleton<Nexus.Service.Platform.Displays.TouchMappingGuard>();
services.AddSingleton<Nexus.Service.Platform.Displays.DisplayBrightnessController>();
services.AddSingleton<Nexus.Service.Platform.Displays.Aw3225QfCrosshairController>();
services.AddSingleton<Nexus.Service.Platform.Displays.DisplayTopologyService>();
services.AddSingleton<Nexus.Service.Platform.Displays.TouchRoutingStatus>();
return services;
Expand Down
20 changes: 20 additions & 0 deletions src/Devices/Handlers/Aw3225QfHandler.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using System.Collections.Generic;
using Nexus.Service.Platform.Displays;

namespace Nexus.Service.Devices.Handlers;

/// <summary>AW3225QF monitor; its crosshair is controlled over video DDC/CI.</summary>
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<UsbId> Identifiers { get; } = System.Array.Empty<UsbId>();
public bool SupportsNexusControl => false;
public bool IsConnected(IReadOnlyList<UsbDeviceEntry> _) => _crosshair.FindDisplayId() is not null;
public string GetFirmwareVersion() => "";
}
26 changes: 26 additions & 0 deletions src/Models/Displays/Aw3225QfCrosshairDtos.cs
Original file line number Diff line number Diff line change
@@ -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; } = "";
}
2 changes: 2 additions & 0 deletions src/Models/Displays/DisplayDtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ public sealed class DisplayDto
public string Name { get; set; } = "";
public string Manufacturer { get; set; } = "";
public string Model { get; set; } = "";
/// <summary>Physical monitor description from dxva2 or the EDID name on Windows.</summary>
public string PhysicalDescription { get; set; } = "";
public bool IsInternal { get; set; }
public bool IsDdcCapable { get; set; }
/// <summary>False when the user turned brightness control off for this
Expand Down
5 changes: 5 additions & 0 deletions src/Persistence/NexusSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1171,6 +1171,11 @@ public sealed class KrakenFirmwareLighting

public sealed class DevicesSettings
{
/// <summary>Last AW3225QF crosshair selection; VCP 0xED cannot be read back.</summary>
public Dictionary<string, Nexus.Service.Models.Displays.Aw3225QfCrosshairConfig> Aw3225QfCrosshairs { get; set; } = new();
/// <summary>Initial development selection, migrated on the next successful write.</summary>
[System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
public Nexus.Service.Models.Displays.Aw3225QfCrosshairConfig? Aw3225QfCrosshair { get; set; }
public List<string> DisabledLightingDevices { get; set; } = new();
/// <summary>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.</summary>
public List<string> DdcDisabledDisplays { get; set; } = new();
Expand Down
176 changes: 176 additions & 0 deletions src/Platform/Displays/Aw3225QfCrosshairController.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>AW3225QF Gen1 AlienVision crosshair over DDC/CI (EC/ED).</summary>
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<int, CancellationToken, Task> _delay;
private readonly Func<bool> _hostSupported;
private readonly Func<long> _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<int, CancellationToken, Task> delay, Func<bool> hostSupported, Func<long> 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<Aw3225QfCrosshairStatus> GetStatusAsync(CancellationToken ct = default)
{
await _gate.WaitAsync(ct);
try { return await ReadStatusAsync(FindDisplayId(), ct); }
finally { _gate.Release(); }
}

public async Task<Aw3225QfCrosshairStatus> 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<Aw3225QfCrosshairStatus> 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<bool> 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<int?> 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(),
};
}
21 changes: 21 additions & 0 deletions src/Platform/Displays/WindowsDisplayBrightnessProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,8 +84,17 @@ public IReadOnlyList<DisplayDto> Enumerate(IReadOnlyCollection<string>? 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.";

Expand Down Expand Up @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions src/Platform/Displays/WindowsDisplayIdentity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -99,6 +101,30 @@ internal static string ReadMonitorDeviceId(string adapterDeviceName)
return monitor.DeviceID ?? "";
}

/// <summary>EDID monitor-name descriptor (0xFC), used when dxva2's description is generic.</summary>
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 "";
}

/// <summary>
/// Monitor device interface path (\\?\DISPLAY#...#{guid}) via
/// EDD_GET_DEVICE_INTERFACE_NAME only, with no flags=0 fallback: callers
Expand Down
10 changes: 10 additions & 0 deletions src/Routes/DisplayRoutes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
3 changes: 3 additions & 0 deletions src/Serialization/AppJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Nexus.Service.Models.Displays.DisplayDto>))]
[JsonSerializable(typeof(Nexus.Service.Models.Displays.DisplayCapabilitiesDto))]
[JsonSerializable(typeof(Nexus.Service.Models.Displays.DisplayBrightnessControlDto))]
Expand Down
Loading
Loading