diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 905de6fc..f967e608 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v6 + uses: actions/checkout@v7 - name: Setup .NET 10 uses: actions/setup-dotnet@v5 @@ -59,7 +59,7 @@ jobs: # build to absorb transient feed flakiness without needing a # manual re-run. - name: Restore - uses: nick-fields/retry@v3 + uses: nick-fields/retry@v4 with: timeout_minutes: 10 max_attempts: 3 diff --git a/.gitignore b/.gitignore index 17e78d56..443687b6 100644 --- a/.gitignore +++ b/.gitignore @@ -242,4 +242,6 @@ _Pvt_Extensions/ ModelManifest.xml *.snk -.fake \ No newline at end of file +.fake +# Local design notes and ADRs - not part of the repo +/docs/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 252e5d74..56aff85c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to Chromatics are documented here. +## 4.3.24 + +- **New:** Nanoleaf smart-light support (Beta). Covers the panel family (Shapes, Canvas, Elements, Lines, Aurora) and other controllers that speak the Nanoleaf OpenAPI. Enable it from Settings → Device Providers and pair each controller with a one-time button press; effects render across the panels at their real physical positions. Layer assignments stay with their panels if you add or remove panels from the wall later. Essentials bulbs and strips are not supported. +- Chromatics now closes faster when several smart-light brands are enabled: LIFX, Hue, and Nanoleaf devices restore their original state in parallel instead of one brand at a time. +- Fixed a startup crash on PCs where Windows App Control or Smart App Control blocks one of the bundled device libraries. Chromatics now starts with that provider disabled, logs which file was blocked, and explains how to allow it. This also covers the case where the core lighting library (RGB.NET) itself is blocked during settings load. +- Fixed a crash when opening the Hue, LIFX, or Yeelight device picker with the same bulb saved twice in settings. + +## 4.3.14 + +- Added new dynamic layers for Focus Target HP and Focus Target Castbar. +- Added new effects for Status Inflicted and Casting Success. +- Added new status effects category and items to Palette tab. +- Fixed a bug which caused the Experience Tracker layer to not work with Reaper and Sage. +- Fixed the Keybinds layer misreading hotbar actions with high action ids. +- Updated Sharlayan to latest version +- Various performance improvements and bug fixes + ## 4.2.73 - Added support for FFXIV 7.51 diff --git a/Chromatics.DecoratorHarnessUI/MainViewModel.cs b/Chromatics.DecoratorHarnessUI/MainViewModel.cs index 817d61da..57bf4efe 100644 --- a/Chromatics.DecoratorHarnessUI/MainViewModel.cs +++ b/Chromatics.DecoratorHarnessUI/MainViewModel.cs @@ -299,6 +299,18 @@ public record EffectEntry(string Name, string Category, string? BasedOn = null) new("BPMHeartbeatEffect", "Decorators"), new("BPMEqualizerEffect", "Decorators"), new("BPMSpinnerEffect", "Decorators"), + new("SnowstormDecorator", "Decorators"), + new("BPMSnowstormDecorator", "Decorators"), + new("PinwheelEffect", "Decorators"), + new("BPMPinwheelEffect", "Decorators"), + new("BlueParticlesEffect", "Decorators"), + new("BPMBlueParticlesEffect", "Decorators"), + new("CloudsEffect", "Decorators"), + new("BPMCloudsEffect", "Decorators"), + new("ReactiveKeyboardEffect", "Decorators"), + new("WireframeEffect", "Decorators"), + new("BPMWireframeEffect", "Decorators"), + new("OneShotPulseEffect", "Decorators"), ]; public static EffectEntry[] RaidPresets { get; } = @@ -415,7 +427,28 @@ public record EffectEntry(string Name, string Category, string? BasedOn = null) [ObservableProperty] private double _paramDecay = 0.5; [ObservableProperty] private double _paramWedgeDegrees = 60; + // Params for the Snowstorm / Pinwheel / Clouds / Reactive family. + [ObservableProperty] private int _paramFanCount = 4; + [ObservableProperty] private double _paramTwist = 0.5; + [ObservableProperty] private double _paramOpacity = 0.85; + [ObservableProperty] private string _paramStartKey = "Enter"; + [ObservableProperty] private string _paramSnowDir = "TopToBottom"; + [ObservableProperty] private bool _paramOneShot; + [ObservableProperty] private string _paramWireDir = "Random"; + public static string[] TextureTypes { get; } = ["Linear", "Conical"]; + public static string[] SnowDirections { get; } = + [ + "TopToBottom", "BottomToTop", "LeftToRight", "RightToLeft", + "TopLeftToBottomRight", "TopRightToBottomLeft", "BottomLeftToTopRight", "BottomRightToTopLeft", + ]; + // Mapped to LedId via "Keyboard_" + name in BuildEffect; falls back to + // the grid centre when the key is missing from the device. + public static string[] StartKeys { get; } = + [ + "Enter", "Space", "Escape", "W", "A", "S", "D", "G", "H", "NumLock", "Backspace", + ]; + public static string[] WireframeDirections { get; } = ["Random", "Vertical", "Horizontal", "Alternating"]; public static string[] DiagonalDirections { get; } = ["TopLeftToBottomRight", "TopRightToBottomLeft", "BottomLeftToTopRight", "BottomRightToTopLeft", "Random"]; public static string[] FallDirections { get; } = ["TopToBottom", "BottomToTop", "LeftToRight", "RightToLeft"]; public static string[] LaserDirections { get; } = ["Horizontal", "Vertical", "DiagonalForward", "DiagonalBackward", "RandomHV", "RandomDiagonal", "RandomAll"]; @@ -570,6 +603,15 @@ private string SlotCode(int i) [ObservableProperty] private bool _showDecay; [ObservableProperty] private bool _showWedgeDegrees; + // Visibility flags for the Snowstorm / Pinwheel / Clouds / Reactive family. + [ObservableProperty] private bool _showFanCount; + [ObservableProperty] private bool _showTwist; + [ObservableProperty] private bool _showOpacity; + [ObservableProperty] private bool _showStartKey; + [ObservableProperty] private bool _showSnowDir; + [ObservableProperty] private bool _showOneShot; + [ObservableProperty] private bool _showWireDir; + public static string[] MatrixDirections { get; } = ["Down", "Up", "Left", "Right"]; // ── Live update ────────────────────────────────────────────────────── @@ -620,6 +662,8 @@ private void UpdateParamVisibility(string name) or "BPMLaserEffect" or "BPMLaserEffect2" or "BPMCircularPulseEffect" or "BPMMatrixEffect" or "BPMChaseRandom" or "BPMThunderstrikeEffect" or "BPMHeartbeatEffect" or "BPMEqualizerEffect" or "BPMSpinnerEffect" + or "BPMSnowstormDecorator" or "BPMPinwheelEffect" or "BPMBlueParticlesEffect" + or "BPMCloudsEffect" or "BPMWireframeEffect" or "Groovy Ring (Dancing Green)" or "Rebel Ring (Sugar Riot)" or "Demolition Site (Brute Abombinator)" or "Hunter's Ring — Howling Blade (M8)" or "Hunter's Ring — Howling Blade (M8S)" @@ -629,7 +673,8 @@ private void UpdateParamVisibility(string name) // Speed-driven gradient effects ShowSpeed = name is "Interphos (Queen Eternal)" or "Wind" or "Gales" or "Sandstorms / Dust Storms" or "Umbral Wind" or "Astromagnetic Storm" - or "Umbral Static" or "Everlasting Light" or "MatrixEffect"; + or "Umbral Static" or "Everlasting Light" or "MatrixEffect" + or "SnowstormDecorator" or "PinwheelEffect"; // Interval-based effects ShowInterval = name is "StarfieldDecorator" or "FastStarfieldDecorator" @@ -652,7 +697,8 @@ private void UpdateParamVisibility(string name) or "Hunter's Ring — Howling Blade (M8)" or "Arcadia P1 Effect 1 — Intro"; ShowWaveSpeed = name is "ArenaLightShowDecorator" - or "Lovely Lovering (Honey B. Lovely)" or "Sphere of Naught (Cloud of Darkness)"; + or "Lovely Lovering (Honey B. Lovely)" or "Sphere of Naught (Cloud of Darkness)" + or "BlueParticlesEffect" or "CloudsEffect"; ShowWaveFreq = name is "ArenaLightShowDecorator" or "Lovely Lovering (Honey B. Lovely)" or "Sphere of Naught (Cloud of Darkness)" @@ -676,6 +722,8 @@ private void UpdateParamVisibility(string name) ShowNumberOfLeds = name is "StarfieldDecorator" or "FastStarfieldDecorator" or "BPMStarfieldDecorator" or "BPMFastStarfieldDecorator" + or "SnowstormDecorator" or "BPMSnowstormDecorator" + or "BlueParticlesEffect" or "BPMBlueParticlesEffect" or "Hunter's Ring — Howling Blade (M8)" or "Arcadia P1 Effect 1 — Intro"; ShowStepSpeed = name is "PulseDecorator"; @@ -701,18 +749,22 @@ private void UpdateParamVisibility(string name) ShowIntensity = name is "FireEffect"; ShowFlickerSpeed = name is "FireEffect" or "MatrixEffect" or "BPMMatrixEffect"; ShowBeamWidth = name is "LaserEffect" or "BPMLaserEffect" or "BPMLaserEffect2" - or "BPMHeartbeatEffect"; + or "BPMHeartbeatEffect" or "WireframeEffect" or "BPMWireframeEffect"; ShowPulseRadius = name is "CircularPulseEffect" or "BPMCircularPulseEffect" or "Hunter's Ring — Howling Blade (M8S)" or "Arcadia P2 (M12)"; ShowFadeWidth = name is "CircularPulseEffect" or "BPMCircularPulseEffect" or "BPMRippleDecorator" + or "ReactiveKeyboardEffect" or "OneShotPulseEffect" or "Groovy Ring (Dancing Green)" or "Demolition Site (Brute Abombinator)" or "Hunter's Ring — Howling Blade (M8S)" or "Arcadia P2 (M12)"; ShowTailLength = name is "BPMChaseDecorator" or "Rebel Ring (Sugar Riot)"; - ShowSpawnInterval = name is "LaserEffect" or "CircularPulseEffect"; - ShowRippleSpeed = name is "CircularPulseEffect"; // non-BPM only; BPM* now uses BpmSpeed + ShowSpawnInterval = name is "LaserEffect" or "CircularPulseEffect" or "WireframeEffect"; + ShowRippleSpeed = name is "CircularPulseEffect" // non-BPM only; BPM* now uses BpmSpeed + or "ReactiveKeyboardEffect" or "WireframeEffect" or "OneShotPulseEffect"; ShowLaserDir = name is "LaserEffect" or "BPMLaserEffect" or "BPMLaserEffect2"; ShowBpmSpeed = name is "BPMLaserEffect" or "BPMLaserEffect2" or "BPMCircularPulseEffect" or "BPMRippleDecorator" or "BPMMatrixEffect" or "BPMSpinnerEffect" + or "BPMSnowstormDecorator" or "BPMPinwheelEffect" or "BPMBlueParticlesEffect" + or "BPMCloudsEffect" or "BPMWireframeEffect" or "Groovy Ring (Dancing Green)" or "Demolition Site (Brute Abombinator)" or "Hunter's Ring — Howling Blade (M8S)" or "Arcadia P2 (M12)"; ShowBeatsPerCycle = ShowBpmSpeed && ParamBpmSpeed == "Custom"; @@ -726,8 +778,18 @@ private void UpdateParamVisibility(string name) ShowDecay = name is "BPMThunderstrikeEffect" or "BPMEqualizerEffect" or "Arcadia P1 Effect 2 — Post-Flash"; ShowWedgeDegrees = name is "BPMSpinnerEffect"; + // Snowstorm / Pinwheel / Clouds / Reactive family + ShowFanCount = name is "PinwheelEffect" or "BPMPinwheelEffect"; + ShowTwist = name is "PinwheelEffect" or "BPMPinwheelEffect"; + ShowOpacity = name is "CloudsEffect" or "BPMCloudsEffect"; + ShowStartKey = name is "ReactiveKeyboardEffect"; + ShowSnowDir = name is "SnowstormDecorator" or "BPMSnowstormDecorator"; + ShowOneShot = name is "ReactiveKeyboardEffect"; + ShowWireDir = name is "WireframeEffect" or "BPMWireframeEffect"; + ShowColorBase = name is not ("PulseDecorator" or "ShotFlashDecorator" - or "MoveBPMGradientDecorator" or "MoveBPMDiagonalGradientDecorator"); + or "MoveBPMGradientDecorator" or "MoveBPMDiagonalGradientDecorator" + or "WireframeEffect" or "BPMWireframeEffect"); } private void ApplyDefaults(EffectEntry effect) @@ -739,6 +801,21 @@ private void ApplyDefaults(EffectEntry effect) ParamWaveSpeed = snap.WaveSpeed; ParamWaveFreq = snap.WaveFreq; ParamFadeTime = snap.FadeTime; ParamGroupSize = snap.GroupSize; ParamBlockSize = snap.BlockSize; ParamBlocks = snap.Blocks; ParamSize = snap.Size; + ParamNumberOfLeds = snap.NumberOfLeds; ParamStepSpeed = snap.StepSpeed; + ParamSustain = snap.Sustain; ParamRelease = snap.Release; ParamRepetitions = snap.Repetitions; + ParamDirection = snap.Direction; ParamRandomise = snap.Randomise; + ParamTextureType = snap.TextureType; ParamDiagonalDir = snap.DiagonalDir; ParamFallDir = snap.FallDir; + ParamIntensity = snap.Intensity; ParamFlickerSpeed = snap.FlickerSpeed; + ParamBeamWidth = snap.BeamWidth; ParamPulseRadius = snap.PulseRadius; ParamFadeWidth = snap.FadeWidth; + ParamTailLength = snap.TailLength; ParamSpawnInterval = snap.SpawnInterval; + ParamRippleSpeed = snap.RippleSpeed; ParamLaserDir = snap.LaserDir; + ParamBpmSpeed = snap.BpmSpeed; ParamBeatsPerCycle = snap.BeatsPerCycle; + ParamSimultaneousBeams = snap.SimultaneousBeams; ParamFlickerOpacity = snap.FlickerOpacity; + ParamMatrixDir = snap.MatrixDir; ParamFadeBetween = snap.FadeBetween; + ParamAccentEvery = snap.AccentEvery; ParamDecay = snap.Decay; ParamWedgeDegrees = snap.WedgeDegrees; + ParamFanCount = snap.FanCount; ParamTwist = snap.Twist; ParamOpacity = snap.Opacity; + ParamStartKey = snap.StartKey; ParamSnowDir = snap.SnowDir; + ParamOneShot = snap.OneShot; ParamWireDir = snap.WireDir; ColorBase = snap.Base; SetColors(snap.Colors); return; @@ -1024,6 +1101,66 @@ private void ApplyDefaults(EffectEntry effect) ColorBase = AvColors.Black; SetColors(AvColors.Red, AvColors.Green, AvColors.Blue); break; + case "SnowstormDecorator": + ParamSpeed = 4; ParamNumberOfLeds = 14; ParamSnowDir = "TopToBottom"; + ColorBase = ToAv(0, 10, 30); + SetColors(AvColors.White, ToAv(180, 220, 255)); + break; + case "BPMSnowstormDecorator": + ParamBpm = 128; ParamBpmSpeed = "/4"; ParamNumberOfLeds = 14; ParamSnowDir = "TopToBottom"; + ColorBase = ToAv(0, 10, 30); + SetColors(AvColors.White, ToAv(180, 220, 255)); + break; + case "PinwheelEffect": + ParamSpeed = 90; ParamFanCount = 4; ParamTwist = 0.5; + ColorBase = AvColors.Black; + SetColors(AvColors.Red, AvColors.Yellow, AvColors.Cyan, AvColors.Magenta); + break; + case "BPMPinwheelEffect": + ParamBpm = 128; ParamBpmSpeed = "Sync"; ParamFanCount = 4; ParamTwist = 0.5; + ColorBase = AvColors.Black; + SetColors(AvColors.Red, AvColors.Yellow, AvColors.Cyan, AvColors.Magenta); + break; + case "BlueParticlesEffect": + ParamWaveSpeed = 2.0; ParamNumberOfLeds = 18; + ColorBase = ToAv(0, 0, 40); + SetColors(ToAv(0, 120, 255), ToAv(80, 200, 255), ToAv(160, 230, 255)); + break; + case "BPMBlueParticlesEffect": + ParamBpm = 128; ParamBpmSpeed = "/2"; ParamNumberOfLeds = 6; + ColorBase = ToAv(0, 0, 40); + SetColors(ToAv(0, 120, 255), ToAv(80, 200, 255), ToAv(160, 230, 255)); + break; + case "CloudsEffect": + ParamWaveSpeed = 1.5; ParamOpacity = 0.85; + ColorBase = ToAv(40, 90, 160); + SetColors(ToAv(220, 230, 240), AvColors.White); + break; + case "BPMCloudsEffect": + ParamBpm = 100; ParamBpmSpeed = "/8"; ParamOpacity = 0.85; + ColorBase = ToAv(40, 90, 160); + SetColors(ToAv(220, 230, 240), AvColors.White); + break; + case "ReactiveKeyboardEffect": + ParamStartKey = "Enter"; ParamRippleSpeed = 14.0; ParamFadeWidth = 2.0; ParamOneShot = false; + ColorBase = AvColors.Black; + SetColors(ToAv(0, 200, 255)); + break; + case "WireframeEffect": + ParamRippleSpeed = 8.0; ParamSpawnInterval = 0.5; ParamBeamWidth = 0.8; ParamWireDir = "Random"; + ColorBase = AvColors.Black; + SetColors(AvColors.White); + break; + case "BPMWireframeEffect": + ParamBpm = 128; ParamBpmSpeed = "Sync"; ParamBeamWidth = 0.8; ParamWireDir = "Random"; + ColorBase = AvColors.Black; + SetColors(AvColors.White); + break; + case "OneShotPulseEffect": + ParamRippleSpeed = 12.0; ParamFadeWidth = 2.5; + ColorBase = AvColors.Black; + SetColors(AvColors.White); + break; case "MoveBPMGradientDecorator": ParamBpm = 32; ParamDirection = true; ParamTextureType = "Linear"; SetColors(AvColors.Red, AvColors.Green, AvColors.Blue); @@ -1259,6 +1396,14 @@ private void SavePreset() ParamBpm, ParamSpeed, ParamInterval, ParamFadeSpeed, ParamDensity, ParamWaveSpeed, ParamWaveFreq, ParamFadeTime, ParamGroupSize, ParamBlockSize, ParamBlocks, ParamSize, + ParamNumberOfLeds, ParamStepSpeed, ParamSustain, ParamRelease, ParamRepetitions, + ParamDirection, ParamRandomise, ParamTextureType, ParamDiagonalDir, ParamFallDir, + ParamIntensity, ParamFlickerSpeed, ParamBeamWidth, ParamPulseRadius, ParamFadeWidth, + ParamTailLength, ParamSpawnInterval, ParamRippleSpeed, ParamLaserDir, + ParamBpmSpeed, ParamBeatsPerCycle, ParamSimultaneousBeams, ParamFlickerOpacity, + ParamMatrixDir, ParamFadeBetween, ParamAccentEvery, ParamDecay, ParamWedgeDegrees, + ParamFanCount, ParamTwist, ParamOpacity, ParamStartKey, ParamSnowDir, + ParamOneShot, ParamWireDir, ColorBase, ColorSlots.Select(s => s.Color).ToArray()); _savedParams[name] = snap; @@ -1273,6 +1418,14 @@ private record SavedParams( int Bpm, int Speed, double Interval, double FadeSpeed, double Density, double WaveSpeed, double WaveFreq, double FadeTime, int GroupSize, int BlockSize, int Blocks, int Size, + int NumberOfLeds, int StepSpeed, double Sustain, double Release, int Repetitions, + bool Direction, bool Randomise, string TextureType, string DiagonalDir, string FallDir, + double Intensity, double FlickerSpeed, double BeamWidth, double PulseRadius, double FadeWidth, + double TailLength, double SpawnInterval, double RippleSpeed, string LaserDir, + string BpmSpeed, double BeatsPerCycle, int SimultaneousBeams, double FlickerOpacity, + string MatrixDir, double FadeBetween, int AccentEvery, double Decay, double WedgeDegrees, + int FanCount, double Twist, double Opacity, string StartKey, string SnowDir, + bool OneShot, string WireDir, AvColor Base, AvColor[] Colors); // ── Code snippet generator ────────────────────────────────────────── @@ -1698,6 +1851,78 @@ private string BuildCodeSnippet(EffectEntry effect) $"var spinner = new BPMSpinnerEffect(layer, {ParamBpm}, {ResolveBeatsPerCycle()}, {ParamWedgeDegrees}, colors, surface, baseCol);\n\n" + "SetEffect(spinner, layer, runningEffects);", + "SnowstormDecorator" => + $"var baseCol = {bS};\n" + + $"var colors = {arr};\n" + + $"var snow = new SnowstormDecorator(layer, {ParamNumberOfLeds}, {ParamSpeed}, colors, surface, SnowstormDecorator.SnowDirection.{ParamSnowDir}, baseCol);\n\n" + + "SetEffect(snow, layer, runningEffects);", + + "BPMSnowstormDecorator" => + $"var baseCol = {bS};\n" + + $"var colors = {arr};\n" + + $"var snow = new BPMSnowstormDecorator(layer, {ParamBpm}, {ResolveBeatsPerCycle()}, {ParamNumberOfLeds}, colors, surface, SnowstormDecorator.SnowDirection.{ParamSnowDir}, baseCol);\n\n" + + "SetEffect(snow, layer, runningEffects);", + + "PinwheelEffect" => + $"var baseCol = {bS};\n" + + $"var colors = {arr};\n" + + $"var pinwheel = new PinwheelEffect(layer, {ParamSpeed}, {ParamFanCount}, {ParamTwist}, colors, surface, baseCol);\n\n" + + "SetEffect(pinwheel, layer, runningEffects);", + + "BPMPinwheelEffect" => + $"var baseCol = {bS};\n" + + $"var colors = {arr};\n" + + $"var pinwheel = new BPMPinwheelEffect(layer, {ParamBpm}, {ResolveBeatsPerCycle()}, {ParamFanCount}, {ParamTwist}, colors, surface, baseCol);\n\n" + + "SetEffect(pinwheel, layer, runningEffects);", + + "BlueParticlesEffect" => + $"var baseCol = {bS};\n" + + $"var colors = {arr};\n" + + $"var particles = new BlueParticlesEffect(layer, {ParamNumberOfLeds}, {ParamWaveSpeed}, colors, surface, baseCol);\n\n" + + "SetEffect(particles, layer, runningEffects);", + + "BPMBlueParticlesEffect" => + $"var baseCol = {bS};\n" + + $"var colors = {arr};\n" + + $"var particles = new BPMBlueParticlesEffect(layer, {ParamBpm}, {ResolveBeatsPerCycle()}, {ParamNumberOfLeds}, colors, surface, baseCol);\n\n" + + "SetEffect(particles, layer, runningEffects);", + + "CloudsEffect" => + $"var baseCol = {bS};\n" + + $"var colors = {arr};\n" + + $"var clouds = new CloudsEffect(layer, {ParamWaveSpeed}, {ParamOpacity}, colors, surface, baseCol);\n\n" + + "SetEffect(clouds, layer, runningEffects);", + + "BPMCloudsEffect" => + $"var baseCol = {bS};\n" + + $"var colors = {arr};\n" + + $"var clouds = new BPMCloudsEffect(layer, {ParamBpm}, {ResolveBeatsPerCycle()}, {ParamOpacity}, colors, surface, baseCol);\n\n" + + "SetEffect(clouds, layer, runningEffects);", + + "ReactiveKeyboardEffect" => + $"var baseCol = {bS};\n" + + $"var colors = {arr};\n" + + $"var reactive = new ReactiveKeyboardEffect(layer, LedId.Keyboard_{ParamStartKey}, {ParamRippleSpeed}, {ParamFadeWidth}, colors, surface, baseCol, oneShot: {(ParamOneShot ? "true" : "false")});\n" + + (ParamOneShot ? "// One-shot: poll reactive.IsFinished and remove the decorator when true.\n\n" : "\n") + + "SetEffect(reactive, layer, runningEffects);", + + "WireframeEffect" => + $"var baseCol = {bS};\n" + + $"var wireframe = new WireframeEffect(layer, {ParamRippleSpeed}, {ParamSpawnInterval}, {ParamBeamWidth}, surface, WireframeEffect.PillarMode.{ParamWireDir}, baseCol);\n\n" + + "SetEffect(wireframe, layer, runningEffects);", + + "BPMWireframeEffect" => + $"var baseCol = {bS};\n" + + $"var wireframe = new BPMWireframeEffect(layer, {ParamBpm}, {ResolveBeatsPerCycle()}, {ParamBeamWidth}, surface, WireframeEffect.PillarMode.{ParamWireDir}, baseCol);\n\n" + + "SetEffect(wireframe, layer, runningEffects);", + + "OneShotPulseEffect" => + $"var baseCol = {bS};\n" + + $"var colors = {arr};\n" + + $"var pulse = new OneShotPulseEffect(layer, {ParamRippleSpeed}, {ParamFadeWidth}, colors, surface, baseCol);\n" + + "// One-shot: poll pulse.IsFinished and remove the decorator when true.\n\n" + + "SetEffect(pulse, layer, runningEffects);", + _ => $"// Select an effect to generate code", }; @@ -2260,6 +2485,83 @@ private Action BuildEffect(EffectEntry effect, ListLedGroup group) group.AddDecorator(dec); return () => group.RemoveDecorator(dec); } + case "SnowstormDecorator": + { + var dir = Enum.Parse(ParamSnowDir); + var dec = new SnowstormDecorator(group, ParamNumberOfLeds, ParamSpeed, colors, _surface, dir, baseCol); + group.AddDecorator(dec); + return () => group.RemoveDecorator(dec); + } + case "BPMSnowstormDecorator": + { + var dir = Enum.Parse(ParamSnowDir); + var dec = new BPMSnowstormDecorator(group, ParamBpm, ResolveBeatsPerCycle(), ParamNumberOfLeds, colors, _surface, dir, baseCol); + group.AddDecorator(dec); + return () => group.RemoveDecorator(dec); + } + case "PinwheelEffect": + { + var dec = new PinwheelEffect(group, ParamSpeed, ParamFanCount, ParamTwist, colors, _surface, baseCol); + group.AddDecorator(dec); + return () => group.RemoveDecorator(dec); + } + case "BPMPinwheelEffect": + { + var dec = new BPMPinwheelEffect(group, ParamBpm, ResolveBeatsPerCycle(), ParamFanCount, ParamTwist, colors, _surface, baseCol); + group.AddDecorator(dec); + return () => group.RemoveDecorator(dec); + } + case "BlueParticlesEffect": + { + var dec = new BlueParticlesEffect(group, ParamNumberOfLeds, ParamWaveSpeed, colors, _surface, baseCol); + group.AddDecorator(dec); + return () => group.RemoveDecorator(dec); + } + case "BPMBlueParticlesEffect": + { + var dec = new BPMBlueParticlesEffect(group, ParamBpm, ResolveBeatsPerCycle(), ParamNumberOfLeds, colors, _surface, baseCol); + group.AddDecorator(dec); + return () => group.RemoveDecorator(dec); + } + case "CloudsEffect": + { + var dec = new CloudsEffect(group, ParamWaveSpeed, ParamOpacity, colors, _surface, baseCol); + group.AddDecorator(dec); + return () => group.RemoveDecorator(dec); + } + case "BPMCloudsEffect": + { + var dec = new BPMCloudsEffect(group, ParamBpm, ResolveBeatsPerCycle(), ParamOpacity, colors, _surface, baseCol); + group.AddDecorator(dec); + return () => group.RemoveDecorator(dec); + } + case "ReactiveKeyboardEffect": + { + var key = Enum.Parse("Keyboard_" + ParamStartKey); + var dec = new ReactiveKeyboardEffect(group, key, ParamRippleSpeed, ParamFadeWidth, colors, _surface, baseCol, ParamOneShot); + group.AddDecorator(dec); + return () => group.RemoveDecorator(dec); + } + case "WireframeEffect": + { + var mode = Enum.Parse(ParamWireDir); + var dec = new WireframeEffect(group, ParamRippleSpeed, ParamSpawnInterval, ParamBeamWidth, _surface, mode, baseCol); + group.AddDecorator(dec); + return () => group.RemoveDecorator(dec); + } + case "BPMWireframeEffect": + { + var mode = Enum.Parse(ParamWireDir); + var dec = new BPMWireframeEffect(group, ParamBpm, ResolveBeatsPerCycle(), ParamBeamWidth, _surface, mode, baseCol); + group.AddDecorator(dec); + return () => group.RemoveDecorator(dec); + } + case "OneShotPulseEffect": + { + var dec = new OneShotPulseEffect(group, ParamRippleSpeed, ParamFadeWidth, colors, _surface, baseCol); + group.AddDecorator(dec); + return () => group.RemoveDecorator(dec); + } default: throw new InvalidOperationException($"Unknown effect: {effect.Name}"); } diff --git a/Chromatics.DecoratorHarnessUI/MainWindow.axaml b/Chromatics.DecoratorHarnessUI/MainWindow.axaml index f6179b9f..341b3384 100644 --- a/Chromatics.DecoratorHarnessUI/MainWindow.axaml +++ b/Chromatics.DecoratorHarnessUI/MainWindow.axaml @@ -228,6 +228,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Chromatics.Tests/Chromatics.Tests.csproj b/Chromatics.Tests/Chromatics.Tests.csproj index a957dfc5..9cff5172 100644 --- a/Chromatics.Tests/Chromatics.Tests.csproj +++ b/Chromatics.Tests/Chromatics.Tests.csproj @@ -38,7 +38,7 @@ - + diff --git a/Chromatics.Tests/Extensions/Nanoleaf/NanoleafDiscoveryLoopTests.cs b/Chromatics.Tests/Extensions/Nanoleaf/NanoleafDiscoveryLoopTests.cs new file mode 100644 index 00000000..56b93dd0 --- /dev/null +++ b/Chromatics.Tests/Extensions/Nanoleaf/NanoleafDiscoveryLoopTests.cs @@ -0,0 +1,90 @@ +using Chromatics.Extensions.RGB.NET.Devices.Nanoleaf.Protocol; +using System.Runtime.ExceptionServices; + +namespace Chromatics.Tests.Extensions.Nanoleaf; + +// Pins the discovery loop's no-throw contract. The first shipped loop used +// 1-second cancellation slices, which threw OperationCanceledException every +// quiet second as control flow. The exceptions were caught, but a debugger +// with break-on-thrown enabled halts on each one - which presents to the +// developer as the app crashing seconds after discovery starts. A healthy +// sweep must not throw at all. +public class NanoleafDiscoveryLoopTests +{ + [Fact] + public async Task DiscoverAsync_QuietSweep_ThrowsNoCancellationExceptions() + { + int cancellationThrows = 0; + EventHandler recorder = (_, e) => + { + // Match the production class only: test classes in this suite + // carry "NanoleafDiscovery" in their names and throw their own + // cancellations on parallel workers, so a loose substring counts + // their noise as ours. + if (e.Exception is OperationCanceledException && + Environment.StackTrace.Contains("Protocol.NanoleafDiscovery")) + { + Interlocked.Increment(ref cancellationThrows); + } + }; + + AppDomain.CurrentDomain.FirstChanceException += recorder; + try + { + await NanoleafDiscovery.DiscoverAsync(TimeSpan.FromMilliseconds(2500)); + } + finally + { + AppDomain.CurrentDomain.FirstChanceException -= recorder; + } + + Assert.Equal(0, cancellationThrows); + } + + // A failed manual-IP probe is the expected outcome for a wrong address, + // so it must report null without throwing anything - the first version + // threw TaskCanceledException from HttpClient.Timeout as its "nothing + // there" signal. + [Fact] + public async Task Probe_UnreachableOrWrongIp_ReturnsNullWithoutThrowing() + { + int throwsInProbe = 0; + EventHandler recorder = (_, e) => + { + // "" matches only the production state machine; this + // test's own frames render as "". + if (Environment.StackTrace.Contains("")) + Interlocked.Increment(ref throwsInProbe); + }; + + AppDomain.CurrentDomain.FirstChanceException += recorder; + try + { + // Refused: loopback port with no listener answers immediately. + var refused = await NanoleafDiscovery.ProbeAsync("127.0.0.1", GetClosedTcpPort(), TimeSpan.FromSeconds(2)); + // Blackhole: TEST-NET-1 is reserved, so the connect times out. + var timedOut = await NanoleafDiscovery.ProbeAsync("192.0.2.1", 16021, TimeSpan.FromMilliseconds(500)); + // Not an IP at all. + var invalid = await NanoleafDiscovery.ProbeAsync("not-an-ip", 16021, TimeSpan.FromMilliseconds(100)); + + Assert.Null(refused); + Assert.Null(timedOut); + Assert.Null(invalid); + } + finally + { + AppDomain.CurrentDomain.FirstChanceException -= recorder; + } + + Assert.Equal(0, throwsInProbe); + } + + private static int GetClosedTcpPort() + { + var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + int port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +} diff --git a/Chromatics.Tests/Extensions/Nanoleaf/NanoleafDiscoverySocketTests.cs b/Chromatics.Tests/Extensions/Nanoleaf/NanoleafDiscoverySocketTests.cs new file mode 100644 index 00000000..8ea5a109 --- /dev/null +++ b/Chromatics.Tests/Extensions/Nanoleaf/NanoleafDiscoverySocketTests.cs @@ -0,0 +1,86 @@ +using Chromatics.Extensions.RGB.NET.Devices.Nanoleaf.Protocol; +using System.Net; +using System.Net.Sockets; + +namespace Chromatics.Tests.Extensions.Nanoleaf; + +// Pins the root cause of the discovery crash and proves the fix. +// +// Mechanism: Windows queues an ICMP "port unreachable" received for any +// earlier send as a SocketException on the socket's NEXT receive, and keeps +// rethrowing it on every receive after that. The original discovery loop +// only caught OperationCanceledException, so one ICMP reset became a +// repeated throw from DiscoverAsync - the continuous crash reported when +// testing the provider. The fix disables that reporting per-socket via the +// SIO_UDP_CONNRESET ioctl (CreateDiscoverySocket) and handles +// SocketException per-iteration as a backstop. +public class NanoleafDiscoverySocketTests +{ + private static int GetClosedUdpPort() + { + using var probe = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + return ((IPEndPoint)probe.Client.LocalEndPoint!).Port; + // disposed on return - the port is closed again + } + + // Documents the OS mechanism the bug rode on: a default-configured + // socket that sent to a closed port throws SocketException on receive. + // If this ever stops failing on a future Windows build, the ioctl is + // redundant but harmless. + [Fact] + public async Task DefaultSocket_ReceiveAfterIcmpReset_ThrowsSocketException() + { + using var udp = new UdpClient(AddressFamily.InterNetwork); + udp.Client.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + + bool gotReset = false; + for (int i = 0; i < 5 && !gotReset; i++) + { + // Fresh closed port per attempt: another process grabbing the + // probed port between attempts must not starve the test of ICMP. + var closedTarget = new IPEndPoint(IPAddress.Loopback, GetClosedUdpPort()); + await udp.SendAsync(new byte[] { 0 }, 1, closedTarget); + try + { + using var cts = new CancellationTokenSource(200); + await udp.ReceiveAsync(cts.Token); + } + catch (SocketException) { gotReset = true; } + catch (OperationCanceledException) { /* ICMP not queued yet; send again */ } + } + + Assert.True(gotReset, "Expected Windows to surface the ICMP reset as a SocketException on receive."); + } + + // The fix: the production discovery socket takes the same ICMP resets + // without throwing, and still receives real datagrams afterwards. + [Fact] + public async Task DiscoverySocket_SurvivesIcmpReset_AndStillReceives() + { + using var udp = NanoleafDiscovery.CreateDiscoverySocket(); + int ourPort = ((IPEndPoint)udp.Client.LocalEndPoint!).Port; + var closedTarget = new IPEndPoint(IPAddress.Loopback, GetClosedUdpPort()); + + // Provoke the resets. A SocketException here fails the test - that + // is the point. + for (int i = 0; i < 5; i++) + { + await udp.SendAsync(new byte[] { 0 }, 1, closedTarget); + try + { + using var cts = new CancellationTokenSource(100); + await udp.ReceiveAsync(cts.Token); + } + catch (OperationCanceledException) { /* quiet is the expected outcome */ } + } + + // The socket must still be usable for real traffic. + var payload = new byte[] { 0xAB, 0xCD }; + using var sender = new UdpClient(AddressFamily.InterNetwork); + await sender.SendAsync(payload, payload.Length, new IPEndPoint(IPAddress.Loopback, ourPort)); + + using var recvCts = new CancellationTokenSource(2000); + var result = await udp.ReceiveAsync(recvCts.Token); + Assert.Equal(payload, result.Buffer); + } +} diff --git a/Chromatics.Tests/Extensions/Nanoleaf/NanoleafPanelSlotsTests.cs b/Chromatics.Tests/Extensions/Nanoleaf/NanoleafPanelSlotsTests.cs new file mode 100644 index 00000000..77c0ff1c --- /dev/null +++ b/Chromatics.Tests/Extensions/Nanoleaf/NanoleafPanelSlotsTests.cs @@ -0,0 +1,62 @@ +using Chromatics.Extensions.RGB.NET.Devices.Nanoleaf; + +namespace Chromatics.Tests.Extensions.Nanoleaf; + +// The slot table maps panelId -> LedId.Custom slot, and layer assignments +// reference LedIds, so the resolver's one job is keeping slots stable while +// panels come and go from the wall. +public class NanoleafPanelSlotsTests +{ + [Fact] + public void FreshAdoption_AssignsSlotsInAscendingPanelIdOrder() + { + var (slots, added, missing) = NanoleafPanelSlots.Resolve(null, new[] { 30, 10, 20 }); + + Assert.Equal(new[] { 10, 20, 30 }, slots); + Assert.Equal(new[] { 10, 20, 30 }, added); + Assert.Empty(missing); + } + + [Fact] + public void RemovedPanel_KeepsItsSlot_SoLaterPanelsDoNotShift() + { + var stored = new[] { 10, 20, 30 }; + + var (slots, added, missing) = NanoleafPanelSlots.Resolve(stored, new[] { 10, 30 }); + + // Panel 20 is gone but its slot survives as a tombstone: panel 30 + // stays at slot 2 (LedId.Custom3), keeping its layer assignments. + Assert.Equal(new[] { 10, 20, 30 }, slots); + Assert.Empty(added); + Assert.Equal(new[] { 20 }, missing); + } + + [Fact] + public void AddedPanel_AppendsAtTheEnd() + { + var stored = new[] { 10, 20, 30 }; + + var (slots, added, missing) = NanoleafPanelSlots.Resolve(stored, new[] { 10, 20, 30, 5, 40 }); + + // New panels never displace existing slots, even when their + // panelIds sort below existing ones. + Assert.Equal(new[] { 10, 20, 30, 5, 40 }, slots); + Assert.Equal(new[] { 5, 40 }, added); + Assert.Empty(missing); + } + + [Fact] + public void ReturningPanel_LandsBackOnItsOldSlot() + { + var stored = new[] { 10, 20, 30 }; + + // Session with panel 20 absent - tombstone holds slot 1. + var (afterRemoval, _, _) = NanoleafPanelSlots.Resolve(stored, new[] { 10, 30 }); + // Panel 20 re-attached: no additions, no missing, same table. + var (afterReturn, added, missing) = NanoleafPanelSlots.Resolve(afterRemoval, new[] { 10, 20, 30 }); + + Assert.Equal(new[] { 10, 20, 30 }, afterReturn); + Assert.Empty(added); + Assert.Empty(missing); + } +} diff --git a/Chromatics.Tests/Extensions/Nanoleaf/NanoleafProtocolTests.cs b/Chromatics.Tests/Extensions/Nanoleaf/NanoleafProtocolTests.cs new file mode 100644 index 00000000..b4d1ac1d --- /dev/null +++ b/Chromatics.Tests/Extensions/Nanoleaf/NanoleafProtocolTests.cs @@ -0,0 +1,119 @@ +using Chromatics.Extensions.RGB.NET.Devices.Nanoleaf.Protocol; +using System.Buffers.Binary; + +namespace Chromatics.Tests.Extensions.Nanoleaf; + +public class NanoleafProtocolTests +{ + // ── Streaming frame encoding (extControl v2) ──────────────────────── + + [Fact] + public void EncodeFrame_ProducesV2Layout() + { + var panels = new List<(int, byte, byte, byte)> + { + (100, 10, 20, 30), + (101, 40, 50, 60), + }; + + var frame = NanoleafStreamProtocol.EncodeFrame(panels); + + // 2-byte count + 8 bytes per panel. + Assert.Equal(2 + 2 * 8, frame.Length); + Assert.Equal(2, BinaryPrimitives.ReadUInt16BigEndian(frame.AsSpan(0, 2))); + + // First panel: id(2) r g b w(=0) transition(2). + Assert.Equal(100, BinaryPrimitives.ReadUInt16BigEndian(frame.AsSpan(2, 2))); + Assert.Equal(10, frame[4]); + Assert.Equal(20, frame[5]); + Assert.Equal(30, frame[6]); + Assert.Equal(0, frame[7]); // white channel always 0 + Assert.Equal(NanoleafStreamProtocol.DefaultTransition, BinaryPrimitives.ReadUInt16BigEndian(frame.AsSpan(8, 2))); + + // Second panel id at the right offset. + Assert.Equal(101, BinaryPrimitives.ReadUInt16BigEndian(frame.AsSpan(10, 2))); + } + + [Fact] + public void EncodeFrame_EmptyPanelList_IsJustTheCount() + { + var frame = NanoleafStreamProtocol.EncodeFrame(new List<(int, byte, byte, byte)>()); + Assert.Equal(2, frame.Length); + Assert.Equal(0, BinaryPrimitives.ReadUInt16BigEndian(frame.AsSpan(0, 2))); + } + + // ── State parsing (layout normalisation) ──────────────────────────── + + [Fact] + public void ParseState_ReadsPowerBrightnessSceneAndPanels() + { + const string json = """ + { + "name": "Living Room", + "model": "NL42", + "firmwareVersion": "7.1.2", + "serialNo": "ABC123", + "state": { "on": { "value": true }, "brightness": { "value": 73 } }, + "effects": { "select": "Northern Lights" }, + "panelLayout": { + "globalOrientation": { "value": 0 }, + "layout": { "positionData": [ + { "panelId": 105, "x": 100, "y": 50, "o": 0, "shapeType": 7 }, + { "panelId": 101, "x": 0, "y": 0, "o": 0, "shapeType": 7 } + ] } + } + } + """; + + var state = NanoleafRestClient.ParseState(json); + + Assert.True(state.On); + Assert.Equal(73, state.Brightness); + Assert.Equal("Northern Lights", state.SelectedEffect); + Assert.Equal("NL42", state.Model); + Assert.Equal(2, state.Panels.Count); + // Panels are read in document order; the provider sorts by panelId. + Assert.Contains(state.Panels, p => p.PanelId == 105 && p.X == 100 && p.Y == 50); + Assert.Contains(state.Panels, p => p.PanelId == 101 && p.X == 0 && p.Y == 0); + } + + [Fact] + public void ParseState_MissingFields_DefaultsGracefully() + { + var state = NanoleafRestClient.ParseState("{ \"name\": \"Bare\" }"); + Assert.False(state.On); + Assert.Equal(0, state.Brightness); + Assert.Equal("", state.SelectedEffect); + Assert.Empty(state.Panels); + } + + // ── mDNS query bytes ──────────────────────────────────────────────── + + [Fact] + public void BuildPtrQuery_EncodesServiceNameAsDnsLabels() + { + var q = NanoleafDiscovery.BuildPtrQuery("_nanoleafapi._tcp.local"); + + // 12-byte header, qdcount == 1. + Assert.Equal(1, BinaryPrimitives.ReadUInt16BigEndian(q.AsSpan(4, 2))); + // First label length prefix is the length of "_nanoleafapi" (12). + Assert.Equal(12, q[12]); + // Trailing QTYPE PTR (12) and QCLASS IN (1). + Assert.Equal(12, q[^3]); + Assert.Equal(1, q[^1]); + // QU bit: responders must unicast the answer to our source port. + // Windows' own mDNS service owns port 5353, so a multicast answer + // never reaches us. + Assert.Equal(0x80, q[^2]); + } + + [Fact] + public void ContainsServiceLabel_MatchesNanoleafResponsesOnly() + { + var nanoleaf = System.Text.Encoding.ASCII.GetBytes("xx_nanoleafapi._tcp.localxx"); + var other = System.Text.Encoding.ASCII.GetBytes("xx_googlecast._tcp.localxx"); + + Assert.True(NanoleafDiscovery.ContainsServiceLabel(nanoleaf)); + Assert.False(NanoleafDiscovery.ContainsServiceLabel(other)); + } +} diff --git a/Chromatics.Tests/Extensions/Nanoleaf/NanoleafRestClientTests.cs b/Chromatics.Tests/Extensions/Nanoleaf/NanoleafRestClientTests.cs new file mode 100644 index 00000000..e79547ed --- /dev/null +++ b/Chromatics.Tests/Extensions/Nanoleaf/NanoleafRestClientTests.cs @@ -0,0 +1,147 @@ +using Chromatics.Extensions.RGB.NET.Devices.Nanoleaf.Protocol; + +namespace Chromatics.Tests.Extensions.Nanoleaf; + +// REST control-plane behaviour against the simulator: pairing window, +// streaming enter, scene restore, and the retry path under injected faults. +public class NanoleafRestClientTests +{ + [Fact] + public async Task Pair_InPairingMode_ReturnsToken() + { + using var sim = new NanoleafSimulator(); + sim.PairingMode = true; + + var token = await NanoleafRestClient.PairAsync(sim.Host, sim.HttpPort); + + Assert.Equal(sim.Token, token); + } + + [Fact] + public async Task Pair_WhenWindowClosed_ReturnsNull() + { + using var sim = new NanoleafSimulator(); + sim.PairingMode = false; + + var token = await NanoleafRestClient.PairAsync(sim.Host, sim.HttpPort); + + Assert.Null(token); + } + + [Fact] + public async Task GetState_ReadsSimulatorState() + { + using var sim = new NanoleafSimulator(); + sim.SeedHexWall(6); + sim.Brightness = 42; + sim.SelectedEffect = "Forest"; + + var rest = new NanoleafRestClient(sim.Host, sim.HttpPort, sim.Token); + var state = await rest.GetStateAsync(); + + Assert.NotNull(state); + Assert.Equal(42, state!.Brightness); + Assert.Equal("Forest", state.SelectedEffect); + Assert.Equal(6, state.Panels.Count); + } + + [Fact] + public async Task EnableStreaming_ReturnsStreamEndpoint_AndFlipsMode() + { + using var sim = new NanoleafSimulator(); + + var rest = new NanoleafRestClient(sim.Host, sim.HttpPort, sim.Token); + var info = await rest.EnableStreamingAsync(); + + Assert.NotNull(info); + Assert.Equal(sim.StreamPort, info!.Port); + Assert.True(await rest.IsStreamingAsync()); + } + + [Fact] + public async Task SelectEffect_RestoresSceneByName_AndExitsStreaming() + { + using var sim = new NanoleafSimulator(); + var rest = new NanoleafRestClient(sim.Host, sim.HttpPort, sim.Token); + + await rest.EnableStreamingAsync(); + Assert.True(sim.Streaming); + + var ok = await rest.SelectEffectAsync("Northern Lights"); + + Assert.True(ok); + Assert.False(sim.Streaming); + Assert.Equal("Northern Lights", sim.SelectedEffect); + } + + [Fact] + public async Task SetOn_RetriesThroughTransientFailures() + { + using var sim = new NanoleafSimulator { On = true }; + sim.FailNextWrites = 2; // first two PUTs 503, third succeeds + + var rest = new NanoleafRestClient(sim.Host, sim.HttpPort, sim.Token); + var ok = await rest.SetOnAsync(false); + + Assert.True(ok); + Assert.False(sim.On); + } + + [Fact] + public async Task SetOn_GivesUpAfterThreeFailures() + { + using var sim = new NanoleafSimulator { On = true }; + sim.FailNextWrites = 5; // exceeds the 3-attempt budget + + var rest = new NanoleafRestClient(sim.Host, sim.HttpPort, sim.Token); + var ok = await rest.SetOnAsync(false); + + Assert.False(ok); + Assert.True(sim.On); // never applied + } + + // An offline controller is an expected state (dead pairing rows, + // startup capture retries, watchdog mid-reboot), so every call must + // fail soft without a single exception reaching the debugger - the TCP + // pre-flight gate keeps HttpClient (which throws HttpRequestException + // for unreachable hosts) out of the picture entirely. + [Fact] + public async Task RestClient_UnreachableController_FailsSoftWithoutThrowing() + { + int throwsInClient = 0; + EventHandler recorder = (_, e) => + { + if (Environment.StackTrace.Contains("Protocol.NanoleafRestClient")) + Interlocked.Increment(ref throwsInClient); + }; + + int closedPort = GetClosedTcpPort(); + AppDomain.CurrentDomain.FirstChanceException += recorder; + try + { + var rest = new NanoleafRestClient("127.0.0.1", closedPort, "TOKEN"); + + Assert.Null(await NanoleafRestClient.PairAsync("127.0.0.1", closedPort)); + Assert.Null(await rest.GetStateAsync()); + Assert.Null(await rest.EnableStreamingAsync()); + Assert.False(await rest.IsStreamingAsync()); + Assert.False(await rest.SetOnAsync(true)); + Assert.Null(await rest.GetOnAsync()); + } + finally + { + AppDomain.CurrentDomain.FirstChanceException -= recorder; + } + + Assert.Equal(0, throwsInClient); + } + + private static int GetClosedTcpPort() + { + var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0); + listener.Start(); + int port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } +} diff --git a/Chromatics.Tests/Extensions/Nanoleaf/NanoleafSimulator.cs b/Chromatics.Tests/Extensions/Nanoleaf/NanoleafSimulator.cs new file mode 100644 index 00000000..b4f35c34 --- /dev/null +++ b/Chromatics.Tests/Extensions/Nanoleaf/NanoleafSimulator.cs @@ -0,0 +1,252 @@ +using System.Collections.Concurrent; +using System.Net; +using System.Net.Sockets; +using System.Text; +using Newtonsoft.Json.Linq; + +namespace Chromatics.Tests.Extensions.Nanoleaf; + +// A fake Nanoleaf controller for tests: an HTTP listener implementing the +// OpenAPI endpoints the provider uses, plus a UDP listener capturing +// streamed extControl frames for byte-level assertions. Binds ephemeral +// localhost ports; dispose stops both listeners. +public sealed class NanoleafSimulator : IDisposable +{ + private readonly HttpListener _http = new(); + private readonly UdpClient _udp; + private readonly CancellationTokenSource _cts = new(); + private readonly Task _httpLoop; + private readonly Task _udpLoop; + + public int HttpPort { get; } + public int StreamPort { get; } + public string Host => "127.0.0.1"; + public string Token { get; } + + // Mutable device state the tests drive and assert against. + public bool PairingMode { get; set; } + public bool On { get; set; } = true; + public int Brightness { get; set; } = 60; + public string SelectedEffect { get; set; } = "Northern Lights"; + public bool Streaming { get; private set; } + public string Model { get; set; } = "NL42"; // Shapes + public string Firmware { get; set; } = "7.1.2"; + public string SerialNo { get; set; } = "SIMSERIAL01"; + public List<(int panelId, int x, int y, int shape)> Panels { get; } = new(); + + // Fault injection: number of next REST writes to fail before succeeding. + public int FailNextWrites { get; set; } + + // Captured UDP frames (raw bytes), newest last. + public ConcurrentQueue Frames { get; } = new(); + + public NanoleafSimulator(string token = "SIMTOKEN") + { + Token = token; + + HttpPort = FreeTcpPort(); + _http.Prefixes.Add($"http://127.0.0.1:{HttpPort}/"); + _http.Start(); + + _udp = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); + StreamPort = ((IPEndPoint)_udp.Client.LocalEndPoint!).Port; + + _httpLoop = Task.Run(HttpLoopAsync); + _udpLoop = Task.Run(UdpLoopAsync); + } + + public void SeedHexWall(int count) + { + Panels.Clear(); + // Rough hex packing: alternate rows, deterministic ids. + for (int i = 0; i < count; i++) + { + int row = i / 4; + int col = i % 4; + Panels.Add((100 + i, col * 100 + (row % 2) * 50, row * 86, 7 /*hexagon*/)); + } + } + + private async Task HttpLoopAsync() + { + while (!_cts.IsCancellationRequested) + { + HttpListenerContext ctx; + try { ctx = await _http.GetContextAsync(); } + catch { break; } + + try { HandleRequest(ctx); } + catch { /* keep the listener alive */ } + } + } + + private void HandleRequest(HttpListenerContext ctx) + { + var req = ctx.Request; + var path = req.Url!.AbsolutePath; + string body = ""; + using (var r = new StreamReader(req.InputStream, Encoding.UTF8)) body = r.ReadToEnd(); + + // Pairing: POST /api/v1/new + if (path.EndsWith("/api/v1/new") && req.HttpMethod == "POST") + { + if (PairingMode) + WriteJson(ctx, 200, new JObject { ["auth_token"] = Token }); + else + ctx.Response.StatusCode = 403; + ctx.Response.Close(); + return; + } + + // Everything else is token-scoped: /api/v1/{token}/... + string tokenPrefix = $"/api/v1/{Token}"; + if (!path.StartsWith(tokenPrefix)) + { + ctx.Response.StatusCode = 401; + ctx.Response.Close(); + return; + } + string rest = path.Substring(tokenPrefix.Length); + + if (FailNextWrites > 0 && req.HttpMethod == "PUT") + { + FailNextWrites--; + ctx.Response.StatusCode = 503; + ctx.Response.Close(); + return; + } + + // GET all-state + if (rest is "" or "/" && req.HttpMethod == "GET") + { + WriteJson(ctx, 200, BuildStateJson()); + ctx.Response.Close(); + return; + } + + // GET /state/on + if (rest == "/state/on" && req.HttpMethod == "GET") + { + WriteJson(ctx, 200, new JObject { ["value"] = On }); + ctx.Response.Close(); + return; + } + + // GET /effects/select + if (rest == "/effects/select" && req.HttpMethod == "GET") + { + WriteRaw(ctx, 200, "\"" + (Streaming ? "*ExtControl*" : SelectedEffect) + "\""); + ctx.Response.Close(); + return; + } + + // PUT /effects (streaming enter OR scene select) + if (rest == "/effects" && req.HttpMethod == "PUT") + { + var o = JObject.Parse(body); + if (o["write"]?["animType"]?.ToString() == "extControl") + { + Streaming = true; + WriteJson(ctx, 200, new JObject + { + ["streamControlIpAddr"] = Host, + ["streamControlPort"] = StreamPort, + }); + } + else if (o["select"] != null) + { + SelectedEffect = o["select"]!.ToString(); + Streaming = false; + ctx.Response.StatusCode = 204; + } + else ctx.Response.StatusCode = 204; + ctx.Response.Close(); + return; + } + + // PUT /state (on / brightness) + if (rest == "/state" && req.HttpMethod == "PUT") + { + var o = JObject.Parse(body); + if (o["on"]?["value"] != null) On = o["on"]!["value"]!.Value(); + if (o["brightness"]?["value"] != null) Brightness = o["brightness"]!["value"]!.Value(); + ctx.Response.StatusCode = 204; + ctx.Response.Close(); + return; + } + + ctx.Response.StatusCode = 404; + ctx.Response.Close(); + } + + private JObject BuildStateJson() + { + var positions = new JArray(); + foreach (var (panelId, x, y, shape) in Panels) + { + positions.Add(new JObject + { + ["panelId"] = panelId, ["x"] = x, ["y"] = y, ["o"] = 0, ["shapeType"] = shape, + }); + } + + return new JObject + { + ["name"] = "Simulator", + ["model"] = Model, + ["firmwareVersion"] = Firmware, + ["serialNo"] = SerialNo, + ["state"] = new JObject + { + ["on"] = new JObject { ["value"] = On }, + ["brightness"] = new JObject { ["value"] = Brightness }, + }, + ["effects"] = new JObject { ["select"] = Streaming ? "*ExtControl*" : SelectedEffect }, + ["panelLayout"] = new JObject + { + ["globalOrientation"] = new JObject { ["value"] = 0 }, + ["layout"] = new JObject { ["positionData"] = positions }, + }, + }; + } + + private async Task UdpLoopAsync() + { + while (!_cts.IsCancellationRequested) + { + UdpReceiveResult res; + try { res = await _udp.ReceiveAsync(_cts.Token); } + catch { break; } + Frames.Enqueue(res.Buffer); + } + } + + private static void WriteJson(HttpListenerContext ctx, int status, JObject body) + => WriteRaw(ctx, status, body.ToString()); + + private static void WriteRaw(HttpListenerContext ctx, int status, string body) + { + var bytes = Encoding.UTF8.GetBytes(body); + ctx.Response.StatusCode = status; + ctx.Response.ContentType = "application/json"; + ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); + } + + private static int FreeTcpPort() + { + var l = new TcpListener(IPAddress.Loopback, 0); + l.Start(); + int port = ((IPEndPoint)l.LocalEndpoint).Port; + l.Stop(); + return port; + } + + public void Dispose() + { + _cts.Cancel(); + try { _http.Stop(); } catch { } + try { _udp.Dispose(); } catch { } + try { Task.WaitAll(new[] { _httpLoop, _udpLoop }, 500); } catch { } + _cts.Dispose(); + } +} diff --git a/Chromatics.Tests/Helpers/PaletteCompatibilityTests.cs b/Chromatics.Tests/Helpers/PaletteCompatibilityTests.cs new file mode 100644 index 00000000..442cd65d --- /dev/null +++ b/Chromatics.Tests/Helpers/PaletteCompatibilityTests.cs @@ -0,0 +1,110 @@ +using Chromatics.Models; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +namespace Chromatics.Tests.Helpers; + +public class PaletteCompatibilityTests +{ + // palette.chromatics4 written by an older build lacks every field added + // since. Deserialisation must fall back to the C# field initialisers for + // the missing members so new palette entries appear (with defaults) the + // first time the user runs a newer build. + [Fact] + public void Deserialize_FileMissingNewFields_FallsBackToInitialisers() + { + var json = JObject.FromObject(new PaletteColorModel()); + json.Remove(nameof(PaletteColorModel.CastingSuccess)); + json.Remove(nameof(PaletteColorModel.FocusTargetHpClaimed)); + json.Remove(nameof(PaletteColorModel.Doom)); + json["version"] = "2"; + + var loaded = JsonConvert.DeserializeObject(json.ToString()); + + Assert.NotNull(loaded); + Assert.NotNull(loaded.CastingSuccess); + Assert.Equal("Casting Success", loaded.CastingSuccess.Name); + Assert.Equal(System.Drawing.Color.White.ToArgb(), loaded.CastingSuccess.Color.ToArgb()); + Assert.NotNull(loaded.FocusTargetHpClaimed); + Assert.NotNull(loaded.Doom); + Assert.Equal("2", loaded.version); + } + + // A file written by a newer build can carry members this build doesn't + // know. Loading must not throw and known members must still apply - + // that's the forward-compatibility half of the palette versioning + // contract. + [Fact] + public void Deserialize_FileWithUnknownFields_IgnoresThem() + { + var json = JObject.FromObject(new PaletteColorModel()); + json["version"] = "99"; + json["SomeFutureEntry"] = JObject.FromObject(new { Name = "Future", Type = 7, Color = "Red" }); + + var loaded = JsonConvert.DeserializeObject(json.ToString()); + + Assert.NotNull(loaded); + Assert.Equal("99", loaded.version); + Assert.Equal("Bleeding", loaded.Bleed.Name); + } + + // Every StatusEffects display name doubles as the StatusNameEnglish + // match key for the Status Inflicted effect, so duplicates would make + // the colour lookup ambiguous. + [Fact] + public void StatusEffectEntries_HaveUniqueNames() + { + var palette = new PaletteColorModel(); + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var field in typeof(PaletteColorModel).GetFields()) + { + if (field.FieldType != typeof(ColorMapping)) continue; + var mapping = (ColorMapping)field.GetValue(palette)!; + if (mapping.Type != Chromatics.Enums.Palette.PaletteTypes.StatusEffects) continue; + Assert.True(names.Add(mapping.Name), $"Duplicate status palette name: {mapping.Name}"); + } + + Assert.True(names.Count > 1000, $"Expected the full detrimental catalogue, found {names.Count}"); + } + + // ColorMapping.Name is persisted, so a palette saved before a rename + // restores the old display name over the new initialiser. The load path + // corrects known renames via NormalizePaletteDisplayNames - this pins + // the 4.3.x status renames alongside the NIN Huton precedent. + [Fact] + public void NormalizePaletteDisplayNames_CorrectsRenamedStatusEntries() + { + var palette = new PaletteColorModel(); + // Simulate deserialising a pre-4.3.x palette file. + palette.Bleed.Name = "Bleed"; + palette.Infirmary.Name = "Infirmary"; + + var changed = Chromatics.Helpers.FileOperationsHelper.NormalizePaletteDisplayNames(palette); + + Assert.True(changed); + Assert.Equal("Bleeding", palette.Bleed.Name); + Assert.Equal("Infirmity", palette.Infirmary.Name); + + // Idempotent on a corrected palette: no rewrite loop on every load. + Assert.False(Chromatics.Helpers.FileOperationsHelper.NormalizePaletteDisplayNames(palette)); + } + + // A genuinely old palette carries every stale name at once. All renames + // must be corrected in a single pass - this pins against a future edit + // that returns after the first match. + [Fact] + public void NormalizePaletteDisplayNames_CorrectsAllRenamesTogether() + { + var palette = new PaletteColorModel(); + palette.JobNINHuton.Name = "NIN: Huton"; + palette.Bleed.Name = "Bleed"; + palette.Infirmary.Name = "Infirmary"; + + Assert.True(Chromatics.Helpers.FileOperationsHelper.NormalizePaletteDisplayNames(palette)); + + Assert.Equal("NIN: Kazematoi", palette.JobNINHuton.Name); + Assert.Equal("Bleeding", palette.Bleed.Name); + Assert.Equal("Infirmity", palette.Infirmary.Name); + } +} diff --git a/Chromatics/Chromatics.csproj b/Chromatics/Chromatics.csproj index 5b42a532..16e67a91 100644 --- a/Chromatics/Chromatics.csproj +++ b/Chromatics/Chromatics.csproj @@ -1,4 +1,4 @@ - + WinExe @@ -10,7 +10,7 @@ net10.0-windows10.0.19041.0 10.0.17763.0 Chromatics.Program - 4.2.73.0 + 4.3.24.0 Danielle Thompson