Skip to content
Merged
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
8 changes: 6 additions & 2 deletions src/Panel/Streams/IStreamedPanelTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,21 @@ public interface IStreamedPanelDiscovery
/// <summary>
/// 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.
/// </summary>
public interface IBrightnessPanelTransport
{
/// <summary>
/// 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.
/// </summary>
void BindBrightness(Func<int?> source);

/// <summary>Applies the currently bound backlight value immediately.</summary>
void ApplyBrightness();
}

/// <summary>
Expand Down
26 changes: 19 additions & 7 deletions src/Panel/Streams/JpegPanelStreamTransport.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -63,17 +64,26 @@ public void BindBrightness(Func<int?> source)
_brightnessNextReadMs = 0;
}

/// <summary>Runs on the frame path, the only thread that owns this transport.</summary>
private void ApplyBrightness()
/// <summary>Applies a changed backlight from either the frame or settings path.</summary>
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;
Expand All @@ -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; }
Expand Down Expand Up @@ -136,7 +148,7 @@ public void Write(ReadOnlySpan<byte> payload)

private void PushFrame()
{
ApplyBrightness();
lock (_brightnessLock) { TryApplyBrightnessLocked(); }
ReadOnlySpan<byte> jpeg;
try
{
Expand Down
15 changes: 15 additions & 0 deletions src/Panel/Streams/StreamedPanelCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,21 @@ public HashSet<string> LivePanelDeviceIds()
return ids;
}

/// <summary>Applies a persisted panel backlight change without waiting for a frame.</summary>
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;
}
}
}

/// <summary>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.</summary>
public void SetRenderingPaused(bool paused)
{
Expand Down
8 changes: 7 additions & 1 deletion src/Peripherals/JpegPanels/LianLiAioHandshake.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ public sealed class LianLiAioHandshake : IJpegPanelHandshake, IJpegPanelBrightne
private const byte ModeLocalUi = 0x00;
private const byte ModeApplication = 0x01;

/// <summary>
/// Family-wide LcdSetting mode used for brightness-only control packets (reference
/// driver: not Galahad-specific, HydroShift takes it too).
/// </summary>
internal const byte LcdSettingMode = 0x04;

/// <summary>Backlight a panel runs at until the user sets one.</summary>
public const byte DefaultBrightness = 100;

Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 3 additions & 1 deletion src/Routes/PanelRoutes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions tests/Nexus.Service.Tests/JpegPanels/JpegPanelHubTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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]);
}

Expand Down
30 changes: 27 additions & 3 deletions tests/Nexus.Service.Tests/JpegPanels/LianLiAioHandshakeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -88,17 +89,46 @@ public void Write(ReadOnlySpan<byte> 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<int?>? _source;

public FakeBrightnessTransport(string serial) { Serial = serial; }
public void Open() => IsOpen = true;
public void StartPlayer() { }
public void Write(ReadOnlySpan<byte> annexBAccessUnit) { }
public void Dispose() => IsOpen = false;
public void BindBrightness(Func<int?> source) => _source = source;
public void ApplyBrightness()
{
ApplyCalls++;
LastApplied = _source?.Invoke();
}
}

private sealed class FakeDiscovery : IStreamedPanelDiscovery
{
public string HandlerId => "fake-panel";
public List<StreamedPanelDeviceInfo> Devices { get; } = new();
public List<FakeTransport> Transports { get; } = new();
public List<FakeBrightnessTransport> BrightnessTransports { get; } = new();
public bool UseBrightnessTransport { get; set; }
public bool FailOpen { get; set; }

public IReadOnlyList<StreamedPanelDeviceInfo> 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;
Expand Down Expand Up @@ -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);
}
}