From ac34b9c2e3fe41d02aa130bf001549083b62e7ab Mon Sep 17 00:00:00 2001 From: Cau Date: Tue, 15 Sep 2026 21:33:30 -0300 Subject: [PATCH] fix(panel): Galahad II LCD brightness - LcdSetting mode, apply immediately - LianLiAioHandshake.ApplyBrightness now sends the reference driver's LcdSetting mode (0x04) instead of application mode (0x01), which is what the panel actually expects for a brightness-only control write. - A brightness change now applies immediately (IBrightnessPanelTransport gets ApplyBrightness(), wired through StreamedPanelCoordinator and the panel-device PATCH route) instead of waiting for the frame-path's periodic recheck. Tested on real Galahad II LCD hardware: brightness applies instantly, panel renders pushed frames correctly with Nexus Control on. --- src/Panel/Streams/IStreamedPanelTransport.cs | 8 +++- src/Panel/Streams/JpegPanelStreamTransport.cs | 26 +++++++--- src/Panel/Streams/StreamedPanelCoordinator.cs | 15 ++++++ .../JpegPanels/LianLiAioHandshake.cs | 8 +++- src/Routes/PanelRoutes.cs | 4 +- .../JpegPanels/JpegPanelHubTests.cs | 4 +- .../JpegPanels/LianLiAioHandshakeTests.cs | 30 ++++++++++-- .../Streams/StreamedPanelCoordinatorTests.cs | 47 +++++++++++++++++++ 8 files changed, 126 insertions(+), 16 deletions(-) diff --git a/src/Panel/Streams/IStreamedPanelTransport.cs b/src/Panel/Streams/IStreamedPanelTransport.cs index 6553bfb79..7fffa19f7 100644 --- a/src/Panel/Streams/IStreamedPanelTransport.cs +++ b/src/Panel/Streams/IStreamedPanelTransport.cs @@ -32,7 +32,7 @@ public interface IStreamedPanelDiscovery /// /// Transport that may be able to dim its panel. Implementing it is not the capability test - /// whether the attached model has a backlight command is, and an implementation that finds -/// it does not ignores the binding. +/// it does not ignore the binding. /// public interface IBrightnessPanelTransport { @@ -40,9 +40,13 @@ public interface IBrightnessPanelTransport /// Supplies the panel record's backlight percent, or null when the record has none and /// the panel keeps whatever it powered up with. Polled while frames flow: unlike /// orientation this is a device command rather than a frame filter, so the transport - /// writes only when the polled value changes. + /// writes only when the polled value changes. A caller may also force an immediate apply + /// after a settings edit, even when no frames are currently flowing. /// void BindBrightness(Func source); + + /// Applies the currently bound backlight value immediately. + void ApplyBrightness(); } /// diff --git a/src/Panel/Streams/JpegPanelStreamTransport.cs b/src/Panel/Streams/JpegPanelStreamTransport.cs index 16c25b4fc..8fcd0d327 100644 --- a/src/Panel/Streams/JpegPanelStreamTransport.cs +++ b/src/Panel/Streams/JpegPanelStreamTransport.cs @@ -34,6 +34,7 @@ public sealed class JpegPanelStreamTransport : IStreamedPanelTransport, IOrienta private int _brightnessApplied = -1; private long _brightnessNextReadMs; private bool _brightnessFaultLogged; + private readonly object _brightnessLock = new(); private readonly byte[] _turned; @@ -63,17 +64,26 @@ public void BindBrightness(Func source) _brightnessNextReadMs = 0; } - /// Runs on the frame path, the only thread that owns this transport. - private void ApplyBrightness() + /// Applies a changed backlight from either the frame or settings path. + public void ApplyBrightness() + { + lock (_brightnessLock) + { + _brightnessNextReadMs = 0; + TryApplyBrightnessLocked(); + } + } + + private bool TryApplyBrightnessLocked() { if (_brightness is null) { - return; + return false; } long nowMs = Environment.TickCount64; if (nowMs < _brightnessNextReadMs) { - return; + return false; } _brightnessNextReadMs = nowMs + BrightnessTtlMs; int? wanted; @@ -85,18 +95,20 @@ private void ApplyBrightness() _brightnessFaultLogged = true; ServiceLog.Warn($"[{_hub.Model.HandlerId}] backlight source threw: {ex.GetType().Name}: {ex.Message}"); } - return; + return false; } // No record value means the panel keeps what it powered up with. if (wanted is not int percent || percent == _brightnessApplied) { - return; + return true; } if (_hub.SetBrightness(percent)) { _brightnessApplied = percent; ServiceLog.Info($"[{_hub.Model.HandlerId}] backlight {percent}%"); + return true; } + return false; } public string Serial { get; } @@ -136,7 +148,7 @@ public void Write(ReadOnlySpan payload) private void PushFrame() { - ApplyBrightness(); + lock (_brightnessLock) { TryApplyBrightnessLocked(); } ReadOnlySpan jpeg; try { diff --git a/src/Panel/Streams/StreamedPanelCoordinator.cs b/src/Panel/Streams/StreamedPanelCoordinator.cs index fba6453ad..6ce22f97b 100644 --- a/src/Panel/Streams/StreamedPanelCoordinator.cs +++ b/src/Panel/Streams/StreamedPanelCoordinator.cs @@ -194,6 +194,21 @@ public HashSet LivePanelDeviceIds() return ids; } + /// Applies a persisted panel backlight change without waiting for a frame. + public void ApplyBrightness(string panelDeviceId) + { + lock (_lock) + { + foreach (var ds in _bySerial.Values) + { + if (ds.Session.Closed || !string.Equals(ds.Session.PanelDeviceId, panelDeviceId, StringComparison.Ordinal)) + continue; + (ds.Transport as IBrightnessPanelTransport)?.ApplyBrightness(); + return; + } + } + } + /// Suppresses assignments while a focus mode asks for rendering to stop; the overlay closes its render hosts on the empty list and rebuilds them when it returns. public void SetRenderingPaused(bool paused) { diff --git a/src/Peripherals/JpegPanels/LianLiAioHandshake.cs b/src/Peripherals/JpegPanels/LianLiAioHandshake.cs index b58bd60cc..e8d824ef2 100644 --- a/src/Peripherals/JpegPanels/LianLiAioHandshake.cs +++ b/src/Peripherals/JpegPanels/LianLiAioHandshake.cs @@ -37,6 +37,12 @@ public sealed class LianLiAioHandshake : IJpegPanelHandshake, IJpegPanelBrightne private const byte ModeLocalUi = 0x00; private const byte ModeApplication = 0x01; + /// + /// Family-wide LcdSetting mode used for brightness-only control packets (reference + /// driver: not Galahad-specific, HydroShift takes it too). + /// + internal const byte LcdSettingMode = 0x04; + /// Backlight a panel runs at until the user sets one. public const byte DefaultBrightness = 100; @@ -116,7 +122,7 @@ public bool BeforeFrame(IHidDevice device, int reportLength, long nowMs) public bool ApplyBrightness(IHidDevice device, int reportLength) { var report = Buffer(reportLength); - if (!SendLcdControl(device, report, ModeApplication, _brightness)) + if (!SendLcdControl(device, report, LcdSettingMode, _brightness)) { return false; } diff --git a/src/Routes/PanelRoutes.cs b/src/Routes/PanelRoutes.cs index 1ec2a5dda..252662f1e 100644 --- a/src/Routes/PanelRoutes.cs +++ b/src/Routes/PanelRoutes.cs @@ -321,7 +321,7 @@ public static void MapPanelEndpoints(this WebApplication app) return Results.Json(record, AppJsonContext.Default.PanelDeviceRecord); }).AllowPanel(); - app.MapPost("/panel/devices/{id}", (string id, PanelDevicePatch body, HttpContext ctx, PanelDeviceRegistry registry, MultiplexHub hub, TokenService tokens) => + app.MapPost("/panel/devices/{id}", (string id, PanelDevicePatch body, HttpContext ctx, PanelDeviceRegistry registry, MultiplexHub hub, TokenService tokens, Nexus.Service.Panel.Streams.StreamedPanelCoordinator streams) => { // A panel session may rearrange or remove the deck keys it has, but // authoring a key that opens a file, sends a key chord, types text or @@ -341,6 +341,8 @@ public static void MapPanelEndpoints(this WebApplication app) // for the active-profile flush of OTHER fields; we don't need the // explicit dirty pulse here, and keeping it implied PanelDevices // was profile-scoped, which is the bug this change fixes. + if (body.LcdBrightness.HasValue) + streams.ApplyBrightness(updated.Id); BroadcastDeviceChanged(hub, id); return Results.Json(updated, AppJsonContext.Default.PanelDeviceRecord); }).AllowPanel(); diff --git a/tests/Nexus.Service.Tests/JpegPanels/JpegPanelHubTests.cs b/tests/Nexus.Service.Tests/JpegPanels/JpegPanelHubTests.cs index 62ad03cee..52f8897e5 100644 --- a/tests/Nexus.Service.Tests/JpegPanels/JpegPanelHubTests.cs +++ b/tests/Nexus.Service.Tests/JpegPanels/JpegPanelHubTests.cs @@ -140,7 +140,7 @@ private static JpegPanelModel Dimmable() => JpegPanelModel.GalahadIiLcd with { Handshake = new LianLiAioHandshake("test-lcd", 24) }; [Fact] - public void SetBrightness_reaches_an_attached_panel_in_application_mode() + public void SetBrightness_reaches_an_attached_panel_in_lcd_setting_mode() { using var hub = new JpegPanelHub(Dimmable()); var device = new RecordingHidDevice(); @@ -151,7 +151,7 @@ public void SetBrightness_reaches_an_attached_panel_in_application_mode() var control = Assert.Single(device.Writes); Assert.Equal(0x0C, control[1]); - Assert.Equal(0x01, control[11]); + Assert.Equal(LianLiAioHandshake.LcdSettingMode, control[11]); Assert.Equal(35, control[12]); } diff --git a/tests/Nexus.Service.Tests/JpegPanels/LianLiAioHandshakeTests.cs b/tests/Nexus.Service.Tests/JpegPanels/LianLiAioHandshakeTests.cs index ea30ee87d..8dea8ebd6 100644 --- a/tests/Nexus.Service.Tests/JpegPanels/LianLiAioHandshakeTests.cs +++ b/tests/Nexus.Service.Tests/JpegPanels/LianLiAioHandshakeTests.cs @@ -54,7 +54,29 @@ public void The_control_packet_matches_the_reference_driver_byte_for_byte() } [Fact] - public void A_brightness_change_rides_application_mode_so_the_glass_stays_claimed() + public void Galahad_brightness_packet_uses_the_hardware_verified_settings_mode() + { + var handshake = new LianLiAioHandshake("lianli-galahad2-lcd", 24); + var device = new ScriptedHidDevice(); + + Assert.True(handshake.OnAttach(device, ReportLength)); + device.Writes.Clear(); + handshake.SetBrightness(35); + Assert.True(handshake.ApplyBrightness(device, ReportLength)); + + var control = Assert.Single(device.Writes); + Assert.Equal( + new byte[] + { + 0x02, 0x0C, 0x00, 0x00, 0x00, 0x08, 0x00, 0x00, 0x00, 0x00, 0x08, + 0x04, 35, 0x00, 0x00, 0x00, 0x00, 0x00, 24, + }, + control[..19]); + Assert.All(control[19..], b => Assert.Equal(0, b)); + } + + [Fact] + public void A_brightness_change_uses_the_family_wide_settings_mode() { var handshake = new LianLiAioHandshake("lianli-hydroshift-lcd", 24); var device = new ScriptedHidDevice(); @@ -66,8 +88,10 @@ public void A_brightness_change_rides_application_mode_so_the_glass_stays_claime var control = Assert.Single(device.Writes); Assert.Equal(0x0C, control[1]); - // Application, not the settings mode: the other documented mode hands the glass back. - Assert.Equal(new byte[] { 0x01, 35, 0x00, 0x00, 0x00, 0x00, 0x00, 24 }, control[11..19]); + // LcdSetting (0x04), the reference driver's brightness-only mode for the whole + // family - not application mode, which OnAttach uses to take the glass in the + // first place. + Assert.Equal(new byte[] { 0x04, 35, 0x00, 0x00, 0x00, 0x00, 0x00, 24 }, control[11..19]); } [Fact] diff --git a/tests/Nexus.Service.Tests/Panel/Streams/StreamedPanelCoordinatorTests.cs b/tests/Nexus.Service.Tests/Panel/Streams/StreamedPanelCoordinatorTests.cs index 87fa43d4d..d588d23e3 100644 --- a/tests/Nexus.Service.Tests/Panel/Streams/StreamedPanelCoordinatorTests.cs +++ b/tests/Nexus.Service.Tests/Panel/Streams/StreamedPanelCoordinatorTests.cs @@ -4,6 +4,7 @@ using System.Linq; using Microsoft.AspNetCore.Http; using Nexus.Service.Devices; +using Nexus.Service.Models.Panel; using Nexus.Service.Panel; using Nexus.Service.Panel.Streams; using Nexus.Service.Persistence; @@ -88,17 +89,46 @@ public void Write(ReadOnlySpan annexBAccessUnit) { } public void Dispose() { IsOpen = false; } } + private sealed class FakeBrightnessTransport : IStreamedPanelTransport, IBrightnessPanelTransport + { + public bool IsOpen { get; private set; } + public string Serial { get; } + public int ApplyCalls { get; private set; } + public int? LastApplied { get; private set; } + private Func? _source; + + public FakeBrightnessTransport(string serial) { Serial = serial; } + public void Open() => IsOpen = true; + public void StartPlayer() { } + public void Write(ReadOnlySpan annexBAccessUnit) { } + public void Dispose() => IsOpen = false; + public void BindBrightness(Func source) => _source = source; + public void ApplyBrightness() + { + ApplyCalls++; + LastApplied = _source?.Invoke(); + } + } + private sealed class FakeDiscovery : IStreamedPanelDiscovery { public string HandlerId => "fake-panel"; public List Devices { get; } = new(); public List Transports { get; } = new(); + public List BrightnessTransports { get; } = new(); + public bool UseBrightnessTransport { get; set; } public bool FailOpen { get; set; } public IReadOnlyList Discover() => Devices.ToList(); public IStreamedPanelTransport CreateTransport(StreamedPanelDeviceInfo info) { + if (UseBrightnessTransport) + { + var brightness = new FakeBrightnessTransport(info.Serial); + BrightnessTransports.Add(brightness); + return brightness; + } var t = new FakeTransport(info.Serial) { FailOpen = FailOpen }; Transports.Add(t); return t; @@ -238,4 +268,21 @@ public void Unknown_session_id_does_not_bind() var coordinator = Coordinator(); Assert.Null(coordinator.TryBindIngest("nope", new DefaultHttpContext())); } + + [Fact] + public void Brightness_change_can_apply_without_an_incoming_frame() + { + _discovery.UseBrightnessTransport = true; + _discovery.Devices.Add(Device()); + var coordinator = Coordinator(); + coordinator.TickOnce(); + var assignment = Assert.Single(coordinator.GetAssignments().Assignments); + + _registry.Patch(assignment.PanelDeviceId, new PanelDevicePatch { LcdBrightness = 35 }); + + coordinator.ApplyBrightness(assignment.PanelDeviceId); + var transport = Assert.Single(_discovery.BrightnessTransports); + Assert.Equal(1, transport.ApplyCalls); + Assert.Equal(35, transport.LastApplied); + } }