Skip to content

Commit c505f84

Browse files
Fix LIFX/Hue per-device disable lifecycle (v4.1.37)
Per-device disable on the Mappings tab had four independent bugs uncovered while testing with a LIFX Beam: - Disable raced restore against in-flight decorator chunks. Added a per-device gate flag on the LIFX/Hue update queues and made LifxUpdateQueue's restore take _lock around both the colour and power sends, so the bulb returns cleanly to its pre-Chromatics state instead of being left half-decorator half-restored on chained Beam setups. - Re-enable left the device dark and the Mapping preview blank when testing with the startup animation. RunStartupEffects only built tagged groups for attached devices, so a device that came up disabled never got its rainbow group. AddDevice now calls SyncTaggedEffectsForDevice and surface.Update(flushLeds: true) in a single locked pass, with layer.requestUpdate=true on every layer tied to the device so processors re-paint from scratch. - Restart with a persisted-disabled LIFX bulb silently powered the bulb back on, because CaptureOriginalStateAsync's "turn on if off" branch fired before the post-Load detach. The next capture cycle then read Powered=true and poisoned _original, so subsequent disables stopped turning the bulb off — the runaway state. Capture now takes a turnOnIfOff flag; the LIFX provider passes false when the device is persisted-disabled, and gates the queue immediately to close the brief surface.Load to detach window. Hue gets the same defensive gate. - Re-enable's recapture worker ran AFTER surface.Update(true) had already painted Chromatics colours onto the bulb, so the recaptured _original.Zones reflected the rainbow rather than pre-Chromatics. Dropped the recapture entirely and replaced its auto-on side effect with a dedicated EnsurePoweredOn() that sends SetLightPower(on=true) without touching _original. Hue's Update() already sends On=true with every paint, so it needs no equivalent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 63a3466 commit c505f84

11 files changed

Lines changed: 1680 additions & 46 deletions

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
All notable changes to Chromatics are documented here.
44

5-
## 4.1.33
5+
## 4.1.37
66

77
- Added Auto-discovery for Hue bridges.
88
- Updated Hue light adoption. After pairing, Chromatics shows a picker so you can choose exactly which Hue lights it should control. Existing setups upgraded from earlier builds keep all their bulbs (one-time auto-adopt of whatever the bridge currently exposes); from then on you can deselect bulbs you don't want from the same dialog.

Chromatics/Chromatics.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<OutputType>WinExe</OutputType>
55
<TargetFramework>net10.0-windows7.0</TargetFramework>
66
<StartupObject>Chromatics.Program</StartupObject>
7-
<Version>4.1.33.0</Version>
7+
<Version>4.1.37.0</Version>
88
<Authors>Danielle Thompson</Authors>
99
<ApplicationManifest>app.manifest</ApplicationManifest>
1010
<Copyright>logicallysynced 2026</Copyright>

Chromatics/Core/RGBController.cs

Lines changed: 103 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -351,8 +351,18 @@ public static void RemoveDevice(IRGBDevice device)
351351
// last colour we sent — which is undesirable when the user
352352
// explicitly disables a single device. Run on a worker so
353353
// the UI thread doesn't block on the UDP / HTTP sends.
354+
//
355+
// SetPerDeviceDisabled is set BEFORE Task.Run kicks the
356+
// restore so any decorator data still buffered in the
357+
// queue's _currentDataSet from a TimerUpdateTrigger tick
358+
// that raced surface.Detach gets dropped. Without this
359+
// gate the restore's chunked SetExtendedColorZones
360+
// packets interleave with the late decorator chunks and
361+
// leave half a chained Beam strip stuck on the last
362+
// decorator colour.
354363
if (device is Extensions.RGB.NET.Devices.LIFX.LifxDevice lifxDev)
355364
{
365+
lifxDev.SetPerDeviceDisabled(true);
356366
System.Threading.Tasks.Task.Run(async () =>
357367
{
358368
try { await lifxDev.RestoreOriginalStateAsync(); }
@@ -361,6 +371,7 @@ public static void RemoveDevice(IRGBDevice device)
361371
}
362372
else if (device is Extensions.RGB.NET.Devices.Hue.HueDevice hueDev)
363373
{
374+
hueDev.SetPerDeviceDisabled(true);
364375
System.Threading.Tasks.Task.Run(async () =>
365376
{
366377
try { await hueDev.RestoreOriginalStateAsync(); }
@@ -401,27 +412,94 @@ public static void AddDevice(IRGBDevice device)
401412
surface.Attach(device);
402413
AttachGlobalBrightness(device);
403414

404-
// Re-capture the bulb's current state on per-device re-enable.
405-
// The user may have changed the colour / power between the
406-
// disable and re-enable (Hue / LIFX app, automation, etc.),
407-
// and they expect the next disable to restore whatever was
408-
// on the bulb just before Chromatics retook control.
415+
// Open the queue back up SYNCHRONOUSLY — order matters because
416+
// surface.Update(true) below is a single critical section that
417+
// both renders LedGroups (populating RequestedColor on every
418+
// painted LED) and dispatches a flushed device.Update for each
419+
// attached device. If the queue is still gated when that
420+
// dispatch fires, the resulting SetData would be drained by
421+
// OnUpdate and ignored by Update, leaving the bulb at its
422+
// restored "original" colour with no Chromatics paint.
409423
if (device is Extensions.RGB.NET.Devices.LIFX.LifxDevice lifxDev)
410424
{
411-
System.Threading.Tasks.Task.Run(async () =>
412-
{
413-
try { await lifxDev.CaptureOriginalStateAsync(); }
414-
catch { /* best-effort */ }
415-
});
425+
lifxDev.ResetCache();
426+
lifxDev.SetPerDeviceDisabled(false);
416427
}
417428
else if (device is Extensions.RGB.NET.Devices.Hue.HueDevice hueDev)
418429
{
419-
System.Threading.Tasks.Task.Run(async () =>
430+
hueDev.SetPerDeviceDisabled(false);
431+
}
432+
433+
// Tagged effects (startup rainbow, title-screen starfield)
434+
// build their per-device ListLedGroup at the moment the tag
435+
// is started — devices that were disabled at the time
436+
// RunStartupEffects ran have no tagged group covering their
437+
// LEDs, so Render never paints them and led.Color never
438+
// changes. Without this, re-enabling a device while the
439+
// startup rainbow is running leaves the bulb dark and the
440+
// Mapping-tab preview shows no activity. Mirrors the
441+
// behaviour of DevicesChanged.Added's "running animation,
442+
// join the rig" path for hot-plug.
443+
var deviceGuid = GetDeviceGuid(device);
444+
if (deviceGuid != Guid.Empty)
445+
SyncTaggedEffectsForDevice(deviceGuid);
446+
447+
// Mark all layers tied to this device for re-process so
448+
// their processors rebuild fresh ListLedGroups and re-paint
449+
// the LEDs from scratch on the next GameController tick.
450+
// This dirties the LEDs even when the layer's colour hasn't
451+
// changed since pre-disable — without it, a Static red
452+
// layer on a re-enabled bulb sees "RequestedColor==Color"
453+
// (Render kept painting red during the disabled period and
454+
// led.Update ran every surface tick to keep _color in
455+
// sync), IsDirty=false, no SetData call, no UDP / HTTP.
456+
foreach (var layer in MappingLayers.GetLayers().Values)
457+
{
458+
if (layer.deviceGuid == deviceGuid)
459+
layer.requestUpdate = true;
460+
}
461+
462+
// Force a full surface render + flushed device update in a
463+
// single locked pass. Render walks every attached LedGroup
464+
// and writes RequestedColor for each LED it covers; the
465+
// following device.Update(true) phase then returns every
466+
// LED with RequestedColor.A > 0 regardless of IsDirty,
467+
// so the queue receives the LED state immediately on
468+
// re-enable instead of waiting for the next decorator
469+
// tick to dirty something. Caches in LIFX's queue are
470+
// already cleared above so the per-zone diff doesn't
471+
// suppress this flush.
472+
try { surface.Update(flushLeds: true); } catch { }
473+
474+
// For LIFX, the bulb may have come up powered-off (we
475+
// honour the persisted disable state at startup with
476+
// CaptureOriginalStateAsync(turnOnIfOff: false)). The
477+
// paint frames above pre-load the per-zone HSBK on the
478+
// bulb but do not switch it on — SetExtendedColorZones
479+
// doesn't toggle power. Send an explicit SetLightPower
480+
// AFTER the flush so the bulb wakes up already showing
481+
// the freshly-painted Chromatics state instead of the
482+
// last restored frame. EnsurePoweredOn deliberately
483+
// does NOT recapture _original — we used to re-run
484+
// CaptureOriginalStateAsync here for its auto-on side
485+
// effect, but the recapture's GetExtendedColorZones
486+
// query saw the rainbow paints we'd just sent and
487+
// poisoned _original.Zones, so the next disable
488+
// restored to "rainbow" instead of pre-Chromatics.
489+
// _original from app startup is still the right thing
490+
// to restore to.
491+
if (device is Extensions.RGB.NET.Devices.LIFX.LifxDevice lifxDevPower)
492+
{
493+
System.Threading.Tasks.Task.Run(() =>
420494
{
421-
try { await hueDev.CaptureOriginalStateAsync(); }
495+
try { lifxDevPower.EnsurePoweredOn(); }
422496
catch { /* best-effort */ }
423497
});
424498
}
499+
// Hue's Update() includes On=true on every paint frame,
500+
// so the bridge powers the bulb on automatically as soon
501+
// as the surface.Update flush above lands. No equivalent
502+
// power-on call needed.
425503

426504
if (_activeDevices.ContainsKey(device))
427505
{
@@ -754,13 +832,26 @@ public static bool LoadDeviceProvider(IRGBDeviceProvider provider)
754832
// state we have to detach the disabled devices here,
755833
// AFTER Load has populated provider.Devices and put
756834
// them on the surface.
835+
//
836+
// For Hue/LIFX we ALSO set the queue's per-device
837+
// disable flag so any decorator data that managed to
838+
// get buffered during the brief surface-load window
839+
// (Load → render tick → device.Update → SetData) gets
840+
// dropped by the queue's next OnUpdate instead of
841+
// being sent to the bulb. The flag stays set until
842+
// the user re-enables the device via AddDevice, which
843+
// captures fresh state and clears the flag.
757844
foreach (var device in provider.Devices)
758845
{
759846
var guidProbe = Helpers.DeviceHelper.GenerateDeviceGuid(device.DeviceInfo.DeviceName);
760847
if (!Layers.MappingLayers.IsDeviceDisabled(guidProbe))
761848
continue;
762849
if (surface.Devices.Contains(device))
763850
surface.Detach(device);
851+
if (device is Extensions.RGB.NET.Devices.LIFX.LifxDevice lifxDev)
852+
lifxDev.SetPerDeviceDisabled(true);
853+
else if (device is Extensions.RGB.NET.Devices.Hue.HueDevice hueDev)
854+
hueDev.SetPerDeviceDisabled(true);
764855
if (_activeDevices.ContainsKey(device))
765856
_activeDevices[device] = false;
766857
else

Chromatics/Extensions/RGB.NET/Devices/Hue/HueDevice.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ public HueDevice(HueDeviceInfo deviceInfo, HueUpdateQueue updateQueue) : base(de
2020
public Task CaptureOriginalStateAsync() => _updateQueue.CaptureOriginalStateAsync();
2121
public Task RestoreOriginalStateAsync() => _updateQueue.RestoreOriginalStateAsync();
2222

23+
public void SetPerDeviceDisabled(bool disabled) => _updateQueue.SetPerDeviceDisabled(disabled);
24+
2325
// Forwarded into the update queue so SetBrightness on the bridge picks
2426
// up the per-device multiplier — uniform RGB scaling doesn't affect xy.
2527
public void SetPerDeviceBrightness(PerDeviceBrightnessCorrection correction)

Chromatics/Extensions/RGB.NET/Devices/Hue/HueRGBDeviceProvider.cs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,19 @@ private async Task<IEnumerable<IRGBDevice>> LoadDevicesAsync()
139139
var queue = new HueUpdateQueue(GetUpdateTrigger(), light, modelId, localHueApi);
140140
HueDevice device = new HueDevice(deviceInfo, queue);
141141

142+
// Bulbs persisted-disabled in the Mapping tab
143+
// get the queue gated immediately so the brief
144+
// surface.Load → post-Load detach window in
145+
// RGBController can't drain a buffered LED
146+
// frame and send an UpdateLight to the bridge
147+
// before the disable flag is set. Hue's
148+
// CaptureOriginalStateAsync is harmless here
149+
// (it only reads), but Update sends colour +
150+
// power, which would visibly turn the bulb on.
151+
var deviceGuid = Chromatics.Helpers.DeviceHelper.GenerateDeviceGuid(deviceInfo.DeviceName);
152+
if (Chromatics.Layers.MappingLayers.IsDeviceDisabled(deviceGuid))
153+
queue.SetPerDeviceDisabled(true);
154+
142155
// Snapshot the bulb's current state before the
143156
// surface starts pushing colour updates. Failures
144157
// are logged inside; we still add the device to

Chromatics/Extensions/RGB.NET/Devices/Hue/HueUpdateQueue.cs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,11 @@ public class HueUpdateQueue : UpdateQueue
3131
private readonly string _modelId;
3232
private readonly Lock _lock = new();
3333
private volatile bool _shuttingDown;
34+
// Set true while the device is disabled in the Mapping tab so any
35+
// buffered LED data that arrives via OnUpdate after surface.Detach
36+
// gets dropped instead of racing the restore-to-original UpdateAsync.
37+
// Cleared on re-enable in RGBController.AddDevice.
38+
private volatile bool _perDeviceDisable;
3439
private PerDeviceBrightnessCorrection _perDeviceBrightness;
3540

3641
// Rate-limit error logs GLOBALLY across every HueUpdateQueue
@@ -87,6 +92,8 @@ public HueUpdateQueue(IDeviceUpdateTrigger updateTrigger, Light light, string mo
8792
// already sent TurnOff, racing the bridge into the wrong final state.
8893
public void BeginShutdown() => _shuttingDown = true;
8994

95+
public void SetPerDeviceDisabled(bool disabled) => _perDeviceDisable = disabled;
96+
9097
public void SetPerDeviceBrightness(PerDeviceBrightnessCorrection correction)
9198
=> _perDeviceBrightness = correction;
9299

@@ -183,8 +190,10 @@ protected override bool Update(ReadOnlySpan<(object key, Color color)> dataSet)
183190
lock (_lock)
184191
{
185192
// Provider has begun teardown — drop the update so it can't race
186-
// ahead of (or behind) the explicit TurnOff sequence.
187-
if (_shuttingDown) return true;
193+
// ahead of (or behind) the explicit TurnOff sequence. Same
194+
// for per-device disable from the Mapping tab so a buffered
195+
// colour frame can't race the restore-to-original send.
196+
if (_shuttingDown || _perDeviceDisable) return true;
188197

189198
try
190199
{

Chromatics/Extensions/RGB.NET/Devices/LIFX/LifxDevice.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@ public LifxDevice(LifxDeviceInfo info, LifxUpdateQueue updateQueue, LifxClientDe
2121
public LifxClientDefinition Definition => _def;
2222

2323
public void BeginShutdown() => _updateQueue.BeginShutdown();
24-
public Task CaptureOriginalStateAsync() => _updateQueue.CaptureOriginalStateAsync();
24+
public Task CaptureOriginalStateAsync(bool turnOnIfOff = true) => _updateQueue.CaptureOriginalStateAsync(turnOnIfOff);
2525
public Task RestoreOriginalStateAsync() => _updateQueue.RestoreOriginalStateAsync();
2626

27+
public void SetPerDeviceDisabled(bool disabled) => _updateQueue.SetPerDeviceDisabled(disabled);
28+
public void ResetCache() => _updateQueue.ResetCache();
29+
public void EnsurePoweredOn() => _updateQueue.EnsurePoweredOn();
30+
2731
public void SetPerDeviceBrightness(PerDeviceBrightnessCorrection correction)
2832
=> _updateQueue.SetPerDeviceBrightness(correction);
2933

Chromatics/Extensions/RGB.NET/Devices/LIFX/LifxRGBDeviceProvider.cs

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -98,14 +98,28 @@ private async Task<IEnumerable<IRGBDevice>> LoadDevicesAsync()
9898
var info = new LifxDeviceInfo(def);
9999
var dev = new LifxDevice(info, queue, def);
100100

101-
// Capture original state before the surface paints anything.
102-
// This races with the trigger ramping up but
103-
// CaptureOriginalStateAsync uses a separate UDP socket so
104-
// it can't deadlock against the SetColor stream.
105-
// CaptureOriginalStateAsync also turns the bulb on if it
106-
// was off, so layers become visible immediately rather
107-
// than waiting for the user to toggle physical power.
108-
await dev.CaptureOriginalStateAsync().ConfigureAwait(false);
101+
// Devices the user disabled in the Mapping tab in a
102+
// previous session must not be touched at startup. Two
103+
// things we'd otherwise do are wrong here:
104+
// 1) CaptureOriginalStateAsync's "turn on if off"
105+
// branch would silently power the bulb back up
106+
// every launch — and the next capture cycle would
107+
// then observe Powered=true and poison _original
108+
// so subsequent disables stop turning the bulb
109+
// back off. Pass turnOnIfOff: false to skip it.
110+
// 2) The brief surface.Load → post-Load detach pass
111+
// window in RGBController could let the queue's
112+
// trigger drain a buffered LED frame and send a
113+
// paint UDP packet before the per-device disable
114+
// flag is set. Setting it here, before the device
115+
// is added to the surface, closes that window.
116+
var deviceGuid = Chromatics.Helpers.DeviceHelper.GenerateDeviceGuid(info.DeviceName);
117+
bool disabled = Chromatics.Layers.MappingLayers.IsDeviceDisabled(deviceGuid);
118+
119+
if (disabled)
120+
queue.SetPerDeviceDisabled(true);
121+
122+
await dev.CaptureOriginalStateAsync(turnOnIfOff: !disabled).ConfigureAwait(false);
109123

110124
devices.Add(dev);
111125
}

0 commit comments

Comments
 (0)