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