diff --git a/Winleafs.Api/Endpoints/EffectsEndpoint.cs b/Winleafs.Api/Endpoints/EffectsEndpoint.cs index 86870ecb..4e6ba77e 100644 --- a/Winleafs.Api/Endpoints/EffectsEndpoint.cs +++ b/Winleafs.Api/Endpoints/EffectsEndpoint.cs @@ -55,7 +55,6 @@ public void SetSelectedEffect(string effectName) SendRequest(BaseUrl, Method.PUT, body: new { select = effectName}); } - /// public Task GetEffectDetailsAsync(string effectName) { @@ -78,6 +77,18 @@ public Effect GetEffectDetails(string effectName) return SendRequest(BaseUrl, Method.PUT, CreateWriteEffectCommand(effectName)); } + /// + public void WriteCustomEffectCommand(CustomEffectCommand customEffectCommand) + { + SendRequest(BaseUrl, Method.PUT, body: CreateWriteAnimationCommand(customEffectCommand)); + } + + /// + public async Task WriteCustomEffectCommandAsync(CustomEffectCommand customEffectCommand) + { + await SendRequestAsync(BaseUrl, Method.PUT, body: CreateWriteAnimationCommand(customEffectCommand)); + } + private static object CreateWriteEffectCommand(string effectName) { return new @@ -89,5 +100,13 @@ private static object CreateWriteEffectCommand(string effectName) } }; } + + private static object CreateWriteAnimationCommand(CustomEffectCommand customAnimationCommand) + { + return new + { + write = customAnimationCommand + }; + } } } diff --git a/Winleafs.Api/Endpoints/Interfaces/IEffectsEndpoint.cs b/Winleafs.Api/Endpoints/Interfaces/IEffectsEndpoint.cs index 92e636f7..64acfd49 100644 --- a/Winleafs.Api/Endpoints/Interfaces/IEffectsEndpoint.cs +++ b/Winleafs.Api/Endpoints/Interfaces/IEffectsEndpoint.cs @@ -44,5 +44,15 @@ public interface IEffectsEndpoint /// Effect GetEffectDetails(string effectName); + + /// + /// Send a command for a custom effect. + /// + /// The custom effect command to be sent. + /// The details about the effect. + Task WriteCustomEffectCommandAsync(CustomEffectCommand customEffectCommand); + + /// + void WriteCustomEffectCommand(CustomEffectCommand customEffectCommand); } } diff --git a/Winleafs.Api/Endpoints/NanoleafEndpoint.cs b/Winleafs.Api/Endpoints/NanoleafEndpoint.cs index 95c95224..10d1480e 100644 --- a/Winleafs.Api/Endpoints/NanoleafEndpoint.cs +++ b/Winleafs.Api/Endpoints/NanoleafEndpoint.cs @@ -4,6 +4,7 @@ using Newtonsoft.Json; using NLog; using RestSharp; +using RestSharp.Serializers.NewtonsoftJson; namespace Winleafs.Api.Endpoints { @@ -60,6 +61,8 @@ protected async Task SendRequestAsync(string endpoint, Method method, Type returnType = null, object body = null, bool disableLogging = false) { var restClient = new RestClient(Client.BaseUri); + restClient.UseNewtonsoftJson(); + var request = new RestRequest(GetUrlForRequest(endpoint), method) { Timeout = Timeout @@ -97,6 +100,8 @@ protected async Task SendRequestAsync(string endpoint, Method method, protected object SendRequest(string endpoint, Method method, Type returnType = null, object body = null, bool disableLogging = false) { var restClient = new RestClient(Client.BaseUri); + restClient.UseNewtonsoftJson(); + var request = new RestRequest(GetUrlForRequest(endpoint), method) { Timeout = Timeout //Set timeout to 2 seconds diff --git a/Winleafs.Api/Winleafs.Api.csproj b/Winleafs.Api/Winleafs.Api.csproj index b0edef75..99708fad 100644 --- a/Winleafs.Api/Winleafs.Api.csproj +++ b/Winleafs.Api/Winleafs.Api.csproj @@ -12,6 +12,7 @@ + diff --git a/Winleafs.Models/Models/Device.cs b/Winleafs.Models/Models/Device.cs index c5c99377..1bdecd46 100644 --- a/Winleafs.Models/Models/Device.cs +++ b/Winleafs.Models/Models/Device.cs @@ -39,6 +39,8 @@ public class Device public PercentageProfile PercentageProfile { get;set;} + public CustomEffect CustomEffect { get; set; } + public ScreenMirrorAlgorithm ScreenMirrorAlgorithm { get; set; } public ScreenMirrorFlip ScreenMirrorFlip { get; set; } diff --git a/Winleafs.Models/Models/Effects/CustomEffectCommand.cs b/Winleafs.Models/Models/Effects/CustomEffectCommand.cs new file mode 100644 index 00000000..6ab9b763 --- /dev/null +++ b/Winleafs.Models/Models/Effects/CustomEffectCommand.cs @@ -0,0 +1,59 @@ +using Newtonsoft.Json; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Winleafs.Models.Models.Effects +{ + public class CustomEffectCommand + { + /// + /// The command can be either be add, update or display: + /// add - will create a new custom effect, or update one if it has the same animName + /// update - will update an existing effect with the same animName + /// display - will show the effect on the device but will not save it + /// + [JsonProperty("command")] + public string Command { get; set; } + + /// + /// The version of the JSON schema. + /// + [JsonProperty("version")] + public string Version { get; } = "1.0"; + + /// + /// The name of the custom effect + /// + [JsonProperty("animName")] + public string AnimName { get; set; } + + /// + /// The type of animation, can be either custom or static. + /// + [JsonProperty("animType")] + public string AnimType { get; set; } + + /// + /// A data structure that specifies the speed at which frames transition from coor to color. + /// See 3.2.6.1 Custom effect at https://forum.nanoleaf.me/docs/openapi for more information. + /// + [JsonProperty("animData")] + public string AnimData { get; set; } + + /// + /// Determines whether the custom effect will repeat. + /// + [JsonProperty("loop")] + public bool Loop { get; set; } + + /// + /// This has no functional use for the custom effect, it just sets + /// the colors to be displayed in apps that list the effect. + /// + [JsonProperty("palette")] + public IList Palette { get; set; } = new List(); + } +} diff --git a/Winleafs.Models/Models/Effects/Pallete.cs b/Winleafs.Models/Models/Effects/Palette.cs similarity index 100% rename from Winleafs.Models/Models/Effects/Pallete.cs rename to Winleafs.Models/Models/Effects/Palette.cs diff --git a/Winleafs.Models/Models/Layouts/CustomEffect.cs b/Winleafs.Models/Models/Layouts/CustomEffect.cs new file mode 100644 index 00000000..63029b74 --- /dev/null +++ b/Winleafs.Models/Models/Layouts/CustomEffect.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; + +namespace Winleafs.Models.Models.Layouts +{ + /// + /// A user created Custom Effect which specifies a static pattern or animation + /// + public class CustomEffect + { + /// + /// True if this is just one Frame to set each panel's color + /// + public bool IsStatic { get; set; } + + /// + /// True if the animation is played repeatedly + /// + public bool IsLoop { get; set; } + + /// + /// A series of frames each specifying the color for each panel + /// + public IList Frames { get; set; } + + public CustomEffect() + { + Frames = new List(); + } + } +} diff --git a/Winleafs.Models/Models/Layouts/Frame.cs b/Winleafs.Models/Models/Layouts/Frame.cs new file mode 100644 index 00000000..e574cc1f --- /dev/null +++ b/Winleafs.Models/Models/Layouts/Frame.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; + +namespace Winleafs.Models.Models.Layouts +{ + public class Frame + { + public IDictionary PanelColors { get; set; } + + public Frame() + { + PanelColors = new Dictionary(); + } + } +} diff --git a/Winleafs.Models/Models/Layouts/FrameListItem.cs b/Winleafs.Models/Models/Layouts/FrameListItem.cs new file mode 100644 index 00000000..fa006e58 --- /dev/null +++ b/Winleafs.Models/Models/Layouts/FrameListItem.cs @@ -0,0 +1,17 @@ +using Winleafs.Models.Models.Layouts; + +namespace Winleafs.Models.Models.Layouts +{ + public class FrameListItem + { + public FrameListItem(Frame frame, string name) + { + Frame = frame; + Name = name; + } + + public string Name { get; set; } + public Frame Frame { get; set; } + } +} + diff --git a/Winleafs.Wpf/Api/Effects/CustomEffectCommandBuilder.cs b/Winleafs.Wpf/Api/Effects/CustomEffectCommandBuilder.cs new file mode 100644 index 00000000..74fb6f65 --- /dev/null +++ b/Winleafs.Wpf/Api/Effects/CustomEffectCommandBuilder.cs @@ -0,0 +1,137 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Winleafs.Models.Models.Effects; +using Winleafs.Models.Models.Layouts; +using Winleafs.Wpf.Helpers; + +namespace Winleafs.Wpf.Api.Effects +{ + /// + /// Used to build a CustomEffectCommand to send to the API + /// + public class CustomEffectCommandBuilder + { + private readonly CustomEffect _customEffect; + private int _transitionTime; + + public CustomEffectCommandBuilder(CustomEffect customEffect) + { + _customEffect = customEffect; + } + + public CustomEffectCommand BuildAddCommand(float transitionSecs, string name) + { + //If a name has been passed the custom effect will be added to the device + var customEffectCommand = new CustomEffectCommand(); + customEffectCommand.Command = "add"; + customEffectCommand.AnimName = name; + + //Convert seconds to tenths of a second, the time unit used by commands + _transitionTime = (int)Math.Floor(transitionSecs * 10); + + BuildBody(customEffectCommand); + + return customEffectCommand; + } + + public CustomEffectCommand BuildDisplayCommand(float transitionSecs) + { + //If no name has been passed the custom effect will just be dispayed on the device + var customEffectCommand = new CustomEffectCommand(); + customEffectCommand.Command = "display"; + + //Convert seconds to tenths of a second, the time unit used by commands + _transitionTime = (int)Math.Floor(transitionSecs * 10); + + BuildBody(customEffectCommand); + + return customEffectCommand; + } + + private void BuildBody(CustomEffectCommand customEffectCommand) + { + if (_customEffect.IsStatic) + { + customEffectCommand.AnimType = "static"; + } + else + { + customEffectCommand.AnimType = "custom"; + customEffectCommand.Loop = _customEffect.IsLoop; + } + + var animData = new StringBuilder(); + animData.Append(_customEffect.Frames[0].PanelColors.Count); + + foreach (var panelId in _customEffect.Frames[0].PanelColors.Keys) + { + animData.Append(BuildPanelAnimData(panelId)); + } + + customEffectCommand.AnimData = animData.ToString(); + + //Set pallete so used colurs are displayed in apps (docs say max of 20) + var rgbs = _customEffect.Frames.SelectMany(f => f.PanelColors.Values).Distinct().Take(20); + foreach(var rgb in rgbs) + { + customEffectCommand.Palette.Add(ColorFormatConverter.ToPalette(rgb)); + } + } + + private string BuildPanelAnimData(int panelId) + { + //Example taken from docs. This method returns one of the panel rows + //(semicolons and newlines added for clarity only): + //numPanels; + //panelId0; numFrames0; RGBWT01; RGBWT02; ... RGBWT0n(0); + //panelId1; numFrames1; RGBWT11; RGBWT12; ... RGBWT1n(1); ... ... + //panelIdN; numFramesN; RGBWTN1; RGBWTN2; ... RGBWTNn(N); + //RGB=color, W=0, Txx is transition time is tenths of a second + + var sb = new StringBuilder(); + uint? prevRgb = null; + + var totalFrames = 0; + var sameColorFrameCount = 0; + + foreach (var rgb in _customEffect.Frames.Select(f => f.PanelColors[panelId])) + { + if (prevRgb == null || rgb != prevRgb.Value) + { + if (prevRgb != null && sameColorFrameCount > 0) + { + sb.AppendFormat(" {0} {1} {2} 0 {3}", + (prevRgb >> 16) & 255, // Get the value for red by shifting 2 bytes right + (prevRgb >> 8) & 255, // Get the value for green by shifting one byte right + prevRgb & 255, // Get the value for blue by taking the lowest byte + sameColorFrameCount * _transitionTime); + + totalFrames++; + } + + sb.AppendFormat(" {0} {1} {2} 0 {3}", + (rgb >> 16) & 255, + (rgb >> 8) & 255, + rgb & 255, + _transitionTime); + + sameColorFrameCount = 0; + totalFrames++; + } + else + { + sameColorFrameCount++; + } + + prevRgb = rgb; + } + + //Prepend panelId numframes + return string.Format(" {0} {1}{2}", panelId, totalFrames, sb); + } + + } +} diff --git a/Winleafs.Wpf/Api/Effects/CustomEffectsCollection.cs b/Winleafs.Wpf/Api/Effects/CustomEffectsCollection.cs index 20ea3bff..a5b89147 100644 --- a/Winleafs.Wpf/Api/Effects/CustomEffectsCollection.cs +++ b/Winleafs.Wpf/Api/Effects/CustomEffectsCollection.cs @@ -22,7 +22,7 @@ public CustomEffectsCollection(Orchestrator orchestrator) var customColorEffects = UserSettings.Settings.CustomEffects; - if (customColorEffects != null && customColorEffects.Any()) + if (customColorEffects != null && customColorEffects.Count > 0) { foreach (var customColorEffect in customColorEffects) { diff --git a/Winleafs.Wpf/Api/Orchestrator.cs b/Winleafs.Wpf/Api/Orchestrator.cs index b20357a8..1d65db8e 100644 --- a/Winleafs.Wpf/Api/Orchestrator.cs +++ b/Winleafs.Wpf/Api/Orchestrator.cs @@ -5,6 +5,7 @@ using Winleafs.Api; using Winleafs.Models.Enums; using Winleafs.Models.Models; +using Winleafs.Models.Models.Effects; using Winleafs.Wpf.Api.Effects; using Winleafs.Wpf.Api.Events; using Winleafs.Wpf.Api.Layouts; @@ -131,10 +132,49 @@ public async Task ActivateEffect(string effectName, int brightness) } } - /// - /// Returns the list of custom effects for the device - /// - public List GetCustomEffects() + /// + /// Executes custom effect command + /// Activates an effect by name and brightness. This can be a custom effect (e.g. screen mirror) or a effect available on the Nanoleaf device + /// First deactivates any custom effects before enabling the new effect + /// + public async Task ExecuteCustomEffectCommand(CustomEffectCommand customEffectCommand) + { + if (customEffectCommand.Command == "display") + { + _logger.Info($"Orchestrator is executing a custom effect display command for device {Device.IPAddress}"); + } + else + { + _logger.Info($"Orchestrator is executing a custom effect command for device {Device.IPAddress} to {customEffectCommand.Command} the effect called {customEffectCommand.AnimName}"); + } + + try + { + var client = NanoleafClient.GetClientForDevice(Device); + + //This code is currently commented out as it doesn't seem necessary (at least for Shapes and firmware v6.12) + //DO NOT change the order of disabling effects, then setting brightness and then enabling effects + //if (_customEffects.HasActiveEffects(effectName)) + //{ + // await _customEffects.DeactivateAllEffects(); + //} + //await client.StateEndpoint.SetBrightnessAsync(brightness); + + await client.EffectsEndpoint.WriteCustomEffectCommandAsync(customEffectCommand); + + //Finally, trigger effect changed callback + TriggerEffectChangedCallbacks(); + } + catch (Exception e) + { + _logger.Error(e, $"Executing custom effect command for device {Device.Name}"); + } + } + + /// + /// Returns the list of custom effects for the device + /// + public List GetCustomEffects() { return _customEffects.GetCustomEffects(); } diff --git a/Winleafs.Wpf/Helpers/HSBToRGBConverter.cs b/Winleafs.Wpf/Helpers/ColorFormatConverter.cs similarity index 60% rename from Winleafs.Wpf/Helpers/HSBToRGBConverter.cs rename to Winleafs.Wpf/Helpers/ColorFormatConverter.cs index 409e3f52..2664ba09 100644 --- a/Winleafs.Wpf/Helpers/HSBToRGBConverter.cs +++ b/Winleafs.Wpf/Helpers/ColorFormatConverter.cs @@ -1,24 +1,25 @@ using System; using System.Drawing; +using Winleafs.Models.Models.Effects; namespace Winleafs.Wpf.Helpers { - public static class HsbToRgbConverter + public static class ColorFormatConverter { - public static System.Windows.Media.Color ConvertToMediaColor(float hue, float saturation, float brightness) + public static System.Windows.Media.Color ToMediaColor(float hue, float saturation, float brightness) { - var color = Convert(hue, saturation, brightness); + var color = ToDrawingColor(hue, saturation, brightness); return System.Windows.Media.Color.FromArgb(color.A, color.R, color.G, color.B); } /// - /// Converts HSB values to RGB Color object + /// Converts HSB values to Drawing Color object /// /// Hue between 0 and 360 /// Saturation between 0 and 100 /// Brightness between 0 and 100 /// Source: http://www.splinter.com.au/converting-hsv-to-rgb-colour-using-c/ - public static Color Convert(float hue, float saturation, float brightness) + public static Color ToDrawingColor(float hue, float saturation, float brightness) { //Normalize input saturation = saturation / 100; @@ -105,5 +106,81 @@ private static int Clamp(int i) if (i > 255) return 255; return i; } - } + + public static uint ToRgb(System.Windows.Media.Color color) + { + var rgb = (uint)(color.R << 16); + rgb += (uint)(color.G << 8); + rgb += (uint)color.B; + + return rgb; + } + + public static System.Windows.Media.Color ToMediaColor(uint argb) + { + var blue = (byte)(argb & 255); // mask the lowest byte to get blue + var green = (byte)((argb >> 8) & 255); // shift 1 byte right then mask it to get green + var red = (byte)((argb >> 16) & 255); // shift 2 bytes right then mask it to get red + + return System.Windows.Media.Color.FromArgb(255, red, green, blue); + } + + public static Palette ToPalette(uint Rgb) + { + double delta; + double min; + double hue = 0.0; + double saturation; + double brightness; + + var mediaColor = ToMediaColor(Rgb); + min = Math.Min(Math.Min(mediaColor.R, mediaColor.G), mediaColor.B); + brightness = Math.Max(Math.Max(mediaColor.R, mediaColor.G), mediaColor.B); + delta = brightness - min; + + if (brightness == 0.0) + { + saturation = 0; + } + else + { + saturation = delta / brightness; + } + + if (saturation == 0) + { + hue = 0.0; + } + + else + { + if (mediaColor.R == brightness) + { + hue = (mediaColor.G - mediaColor.B) / delta; + } + else if (mediaColor.G == brightness) + { + hue = 2 + ((mediaColor.B - mediaColor.R) / delta); + } + else if (mediaColor.B == brightness) + { + hue = 4 + (mediaColor.R - mediaColor.G) / delta; + } + + hue *= 60; + + if (hue < 0.0) + { + hue += 360; + } + } + + return new Palette + { + Hue = (int)Math.Floor(hue), + Saturation = (int)Math.Floor(saturation), + Brightness = (int)Math.Floor(brightness / 255) + }; + } + } } diff --git a/Winleafs.Wpf/Views/Effects/EffectComboBox.xaml.cs b/Winleafs.Wpf/Views/Effects/EffectComboBox.xaml.cs index 53a17244..b1bc343a 100644 --- a/Winleafs.Wpf/Views/Effects/EffectComboBox.xaml.cs +++ b/Winleafs.Wpf/Views/Effects/EffectComboBox.xaml.cs @@ -129,7 +129,7 @@ private void BuildEffects(IEnumerable customEffects, IEnumerable< { EffectName = effectWithPalette.Name, Width = (int)Width, - Colors = effectWithPalette.Palette.Select(palette => HsbToRgbConverter.ConvertToMediaColor(palette.Hue, palette.Saturation, palette.Brightness)), + Colors = effectWithPalette.Palette.Select(palette => Helpers.ColorFormatConverter.ToMediaColor(palette.Hue, palette.Saturation, palette.Brightness)), EffectType = effectWithPalette.EffectType }); } diff --git a/Winleafs.Wpf/Views/Effects/EffectComboBoxItem.xaml.cs b/Winleafs.Wpf/Views/Effects/EffectComboBoxItem.xaml.cs index 48ab63d3..9e73ad45 100644 --- a/Winleafs.Wpf/Views/Effects/EffectComboBoxItem.xaml.cs +++ b/Winleafs.Wpf/Views/Effects/EffectComboBoxItem.xaml.cs @@ -22,6 +22,11 @@ private void DrawColoredBorder(EffectComboBoxItemViewModel dataContext) //Remove duplicate colors, palettes from Nanoleaf can contain the same color multiple times var colors = dataContext.Colors.Distinct(); + if (!colors.Any()) + { + return; + } + var borderParts = new int[colors.Count()]; //Divide the border into equal sized parts diff --git a/Winleafs.Wpf/Views/Layout/CreateEffectWindow.xaml b/Winleafs.Wpf/Views/Layout/CreateEffectWindow.xaml new file mode 100644 index 00000000..f4d0a6b6 --- /dev/null +++ b/Winleafs.Wpf/Views/Layout/CreateEffectWindow.xaml @@ -0,0 +1,95 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Winleafs.Wpf/Views/MainWindows/MainWindow.xaml b/Winleafs.Wpf/Views/MainWindows/MainWindow.xaml index 42c9a701..93770b6d 100644 --- a/Winleafs.Wpf/Views/MainWindows/MainWindow.xaml +++ b/Winleafs.Wpf/Views/MainWindows/MainWindow.xaml @@ -27,7 +27,13 @@ - +