commit 308ba282fbc14a293d1ea9c48dcd21001c30ed31 Author: BlubbFish Date: Mon Aug 3 22:34:16 2026 +0200 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b58eb25 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.vs +OpenMacroBoard.SDK/bin +OpenMacroBoard.SDK/obj +StreamDeckSharp/bin +StreamDeckSharp/obj \ No newline at end of file diff --git a/OpenMacroBoard.SDK/ButtonPressEffectAdapter.cs b/OpenMacroBoard.SDK/ButtonPressEffectAdapter.cs new file mode 100644 index 0000000..5c1f22a --- /dev/null +++ b/OpenMacroBoard.SDK/ButtonPressEffectAdapter.cs @@ -0,0 +1,118 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System; +using System.Collections.Generic; + +namespace OpenMacroBoard.SDK { + /// + /// Macro board adapter that implements a software button press effect. + /// + /// + /// This vaguely mimics the perspective of a real button being pushed + /// and provides better feedback to the user that a button push was registered. + /// + public class ButtonPressEffectAdapter : MacroBoardAdapter { + private readonly Dictionary mostRecentKeyBitmaps = new(); + private readonly Dictionary keyPressedState = new(); + + /// + /// Initializes a new instance of the class. + /// + /// The this effect should be applied to. + public ButtonPressEffectAdapter(IMacroBoard macroBoard) + : this(macroBoard, null) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The board that is wrapped with the button press effect. + /// The configuration that should be used. If null the default configuration will be used. + public ButtonPressEffectAdapter(IMacroBoard macroBoard, ButtonPressEffectConfig config) : base(macroBoard) { + this.ButtonPressed += this.ButtonPressEffectAdapter_ButtonPressed; + this.ButtonReleased += this.ButtonPressEffectAdapter_ButtonReleased; + //KeyStateChanged += SoftwareButtonFeature_KeyStateChanged; + Config = config ?? new ButtonPressEffectConfig(); + } + + private void ButtonPressEffectAdapter_ButtonReleased(Object sender, KeyPressEvent e) { + this.keyPressedState[e.Key] = false; + this.UpdateKeyBitmap(e.Key); + } + + private void ButtonPressEffectAdapter_ButtonPressed(Object sender, KeyPressEvent e) { + this.keyPressedState[e.Key] = true; + this.UpdateKeyBitmap(e.Key); + } + + /// + /// The configuration that controls the behavior of the button press effect feature. + /// + public ButtonPressEffectConfig Config { + get; + } + + /// + public override void SetButtonImage(int keyId, KeyBitmap bitmapData) { + mostRecentKeyBitmaps[keyId] = bitmapData; + UpdateKeyBitmap(keyId); + } + + private void UpdateKeyBitmap(int keyId) { + var bitmap = GetBitmapForKey(keyId); + + if(bitmap != null) { + base.SetButtonImage(keyId, bitmap); + } + } + + private bool IsKeyPressed(int keyId) { + if(keyPressedState.TryGetValue(keyId, out var keyPressed)) { + return keyPressed; + } + + return false; + } + + private KeyBitmap GetBitmapForKey(int keyId) { + if(!mostRecentKeyBitmaps.TryGetValue(keyId, out var bitmap)) { + return null; + } + + return IsKeyPressed(keyId) ? ResizeBitmap(bitmap) : bitmap; + } + + private KeyBitmap ResizeBitmap(KeyBitmap keyBitmap) { + var bitmapDataAccess = (IKeyBitmapDataAccess)keyBitmap; + + if(bitmapDataAccess.IsEmpty) { + return KeyBitmap.Black; + } + + var targetWidth = (int)Math.Round(Config.Scale * keyBitmap.Width); + var targetHeight = (int)Math.Round(Config.Scale * keyBitmap.Height); + + var smallerImage = bitmapDataAccess.ToImage(); + smallerImage.Mutate(x => x.Resize(targetWidth, targetHeight)); + + var offsetLeft = (int)Math.Round((keyBitmap.Width - targetWidth) * Config.OriginX); + var offsetTop = (int)Math.Round((keyBitmap.Height - targetHeight) * Config.OriginY); + + var color = Color.FromPixel(new Rgb24( + Config.BackgroundColor.R, + Config.BackgroundColor.G, + Config.BackgroundColor.B) + ); + + var newImage = new Image(keyBitmap.Width, keyBitmap.Height); + + newImage.Mutate(x => { + x.BackgroundColor(color); + x.DrawImage(smallerImage, new Point(offsetLeft, offsetTop), 1); + }); + + return KeyBitmap.Create.FromImageSharpImage(newImage); + } + } +} diff --git a/OpenMacroBoard.SDK/ButtonPressEffectConfig.cs b/OpenMacroBoard.SDK/ButtonPressEffectConfig.cs new file mode 100644 index 0000000..176f76b --- /dev/null +++ b/OpenMacroBoard.SDK/ButtonPressEffectConfig.cs @@ -0,0 +1,35 @@ +using System.Diagnostics.CodeAnalysis; + +namespace OpenMacroBoard.SDK +{ + /// + /// Configuration for + /// + [ExcludeFromCodeCoverage] + public class ButtonPressEffectConfig + { + /// + /// Gets or sets a factor that determines how much the images gets smaller or even bigger when pressed. + /// + /// + /// It's basically a scale factor, if you want the image to be half the size use 0.5. One means no change + /// and values larger than one make the image bigger when pressed. + /// + public double Scale { get; set; } = 0.8; + + /// + /// Gets or sets the relative x coordinate of the origin. + /// + public double OriginX { get; set; } = 0.5; + + /// + /// Gets or sets the relative y coordinate of the origin. + /// + public double OriginY { get; set; } = 0.5; + + /// + /// The background color that is used when the button is shrunk. + /// + public OmbColor BackgroundColor { get; set; } = OmbColor.Black; + } +} diff --git a/OpenMacroBoard.SDK/ConditionalDisposable{T}.cs b/OpenMacroBoard.SDK/ConditionalDisposable{T}.cs new file mode 100644 index 0000000..02f8cac --- /dev/null +++ b/OpenMacroBoard.SDK/ConditionalDisposable{T}.cs @@ -0,0 +1,50 @@ +using System; + +namespace OpenMacroBoard.SDK +{ + /// + /// A conditional disposable wrapper. + /// + /// + /// This class is used in situations where the wrapped element is either borrowed (in which case + /// it shouldn't be disposed) or owned (in which case it should be disposed) and abstracts that + /// away from the consumer. The consumer has to make sure to call dispose once they are finished + /// and this wrapped decides whether the wrapped element is in fact disposed or not. + /// + /// Disposable type. + public sealed class ConditionalDisposable : IDisposable + where T : IDisposable + { + /// + /// Initializes a new instance of the class. + /// + /// The wrapped item. + /// A flag that determines if the item will be disposed. + public ConditionalDisposable(T item, bool disposeItem) + { + Item = item; + DisposeItem = disposeItem; + } + + /// + /// Gets the underlying wrapped item. + /// + public T Item { get; } + + /// + /// Get a value that determines whether the item will be disposed or not. + /// + public bool DisposeItem { get; } + + /// + public void Dispose() + { + if (!DisposeItem) + { + return; + } + + Item?.Dispose(); + } + } +} diff --git a/OpenMacroBoard.SDK/ConnectionEventArgs.cs b/OpenMacroBoard.SDK/ConnectionEventArgs.cs new file mode 100644 index 0000000..7dbcdc8 --- /dev/null +++ b/OpenMacroBoard.SDK/ConnectionEventArgs.cs @@ -0,0 +1,23 @@ +using System; + +namespace OpenMacroBoard.SDK +{ + /// + /// Is used for events that communicate connection changes. + /// + public class ConnectionEventArgs : EventArgs + { + /// + /// Initializes a new instance of the class. + /// + public ConnectionEventArgs(bool newConnectionState) + { + NewConnectionState = newConnectionState; + } + + /// + /// The new connection state. + /// + public bool NewConnectionState { get; } + } +} diff --git a/OpenMacroBoard.SDK/DeviceConnectionChangedEventArgs.cs b/OpenMacroBoard.SDK/DeviceConnectionChangedEventArgs.cs new file mode 100644 index 0000000..cabbbaa --- /dev/null +++ b/OpenMacroBoard.SDK/DeviceConnectionChangedEventArgs.cs @@ -0,0 +1,32 @@ +using System; + +namespace OpenMacroBoard.SDK +{ + /// + /// An event argument that reports a connection status change for a particular device. + /// + public class DeviceConnectionChangedEventArgs : EventArgs + { + /// + /// Initializes a new instance of the class. + /// + /// A device reference. + /// The current connection state. + public DeviceConnectionChangedEventArgs(IDeviceReference deviceReference, bool connected) + { + DeviceReference = deviceReference ?? throw new ArgumentNullException(nameof(deviceReference)); + Connected = connected; + } + + /// + /// Gets a handle to the device that changed. + /// + public IDeviceReference DeviceReference { get; } + + /// + /// Gets a value that indicates the connection state change. True if the device got connected, + /// false if the device got disconnected. + /// + public bool Connected { get; } + } +} diff --git a/OpenMacroBoard.SDK/DeviceContext.cs b/OpenMacroBoard.SDK/DeviceContext.cs new file mode 100644 index 0000000..b807041 --- /dev/null +++ b/OpenMacroBoard.SDK/DeviceContext.cs @@ -0,0 +1,19 @@ +using OpenMacroBoard.SDK.Internals; + +namespace OpenMacroBoard.SDK +{ + /// + /// A collection of released methods. + /// + public static class DeviceContext + { + /// + /// Creates a new device context (without any listeners). + /// + /// A new device context. + public static IDeviceContext Create() + { + return new DeviceContextInternal(); + } + } +} diff --git a/OpenMacroBoard.SDK/DeviceContextExtensions.cs b/OpenMacroBoard.SDK/DeviceContextExtensions.cs new file mode 100644 index 0000000..a7a0111 --- /dev/null +++ b/OpenMacroBoard.SDK/DeviceContextExtensions.cs @@ -0,0 +1,125 @@ +using OpenMacroBoard.SDK.Internals; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +#nullable enable + +namespace OpenMacroBoard.SDK +{ + /// + /// A collection of extensions for . + /// + public static class DeviceContextExtensions + { + /// + /// Wait for and open the first detected in this context. + /// + /// + /// + /// If there are multiple devices connected it's undefined which one you will get. + /// If you want to specify a filter to get a specific device use + /// . + /// + /// + public static Task OpenAsync( + this IDeviceContext context, + CancellationToken cancellationToken = default + ) + { + return context.OpenAsync(_ => true, cancellationToken); + } + + /// + /// Wait for and open the first matching in this context. + /// + public static async Task OpenAsync( + this IDeviceContext context, + Func selector, + CancellationToken cancellationToken = default + ) + { + return (await context.GetDeviceReferenceAsync(selector, cancellationToken)).Open(); + } + + /// + /// Wait for and return the first matching . + /// + public static async Task GetDeviceReferenceAsync( + this IDeviceContext context, + Func selector, + CancellationToken cancellationToken = default + ) + { + // check if there already is a matching device. + var devRef = context.KnownDevices.FirstOrDefault(selector); + + if (devRef is not null) + { + return devRef; + } + + // if not, register event handler + // and wait for a matching device to show up + + var eventSync = new object(); + var waitForDeviceBlocker = new TaskCompletionSource(); + + IDeviceReference? foundDevice = null; + + void ProcessReport(DeviceStateReport report) + { + lock (eventSync!) + { + if (foundDevice != null) + { + // ignore events if we've already found a device. + return; + } + + if (report.Connected && selector(report.DeviceReference)) + { + foundDevice = report.DeviceReference; + waitForDeviceBlocker!.TrySetResult(0); + } + } + } + + IDisposable subscription = null!; + CancellationTokenRegistration registration = default; + + try + { + registration = cancellationToken.Register(() => waitForDeviceBlocker.TrySetCanceled()); + var deviceReportObserver = new DeviceStateObserver(ProcessReport); + subscription = context.DeviceStateReports.Subscribe(deviceReportObserver); + + await waitForDeviceBlocker.Task; + } + finally + { +#if NET8_0_OR_GREATER + await registration.DisposeAsync(); +#else + registration.Dispose(); +#endif + subscription.Dispose(); + } + +#pragma warning disable S2583 // Conditionally executed code should be reachable + if (foundDevice is null) +#pragma warning restore S2583 + { + // We don't document that exception because it shouldn't happen, + // and we don't expect the consumer to handle this exception type. + +#pragma warning disable RCS1140 // Add exception to documentation comment. + throw new InvalidOperationException("Couldn't locate device with matching criteria. This is a bug, please file an issue."); +#pragma warning restore RCS1140 + } + + return foundDevice; + } + } +} diff --git a/OpenMacroBoard.SDK/DeviceListenerBase.cs b/OpenMacroBoard.SDK/DeviceListenerBase.cs new file mode 100644 index 0000000..d44d1d0 --- /dev/null +++ b/OpenMacroBoard.SDK/DeviceListenerBase.cs @@ -0,0 +1,131 @@ +using OpenMacroBoard.SDK.Internals; +using System; +using System.Collections.Generic; + +namespace OpenMacroBoard.SDK +{ + /// + /// A device listener base to simplify device listener implementations. + /// + public abstract class DeviceListenerBase : IObservable + { + private readonly object sync = new(); + + private readonly List subscriptions = new(); + private readonly List knownDevices; + + /// + /// Initializes a new instance of the class. + /// + protected DeviceListenerBase() + { + knownDevices = new(); + KnownDevices = knownDevices.AsReadOnly(); + } + + /// + /// Gets a list of the currently known (at least seen once) devices. + /// + public IReadOnlyList KnownDevices { get; } + + /// + /// Subscribes an observer that will be notified when a device state changes. + /// + /// Returns a disposable subscription. + public IDisposable Subscribe(IObserver observer) + { + lock (sync) + { + // send currently known values + foreach (var device in knownDevices) + { + observer.OnNext(new DeviceStateReport(device.DeviceReference, device.Connected, true)); + } + + // setup subscription for updates + var sub = new Subscription(this, observer); + subscriptions.Add(sub); + return sub; + } + } + + /// + /// Updates a device state. If the state has changed compared to the previous state all subscribed + /// observers will be notified. + /// + /// A referenced device which has changed. + /// The current connection state of the device. + protected void Update(IDeviceReference deviceReference, bool connected) + { + lock (sync) + { + var foundIndex = knownDevices.FindIndex(d => d.DeviceReference.Equals(deviceReference)); + var isNew = foundIndex < 0; + + if (isNew) + { + knownDevices.Add(new KnownDeviceInternal(deviceReference, connected)); + foundIndex = knownDevices.Count - 1; + } + + var knownDevice = knownDevices[foundIndex]; + knownDevice.Connected = connected; + + foreach (var subscription in subscriptions) + { + subscription.SendUpdates(); + } + } + } + + private sealed class Subscription : IDisposable + { + private readonly DeviceListenerBase parent; + private readonly IObserver observer; + + /// + /// Contains the state the subscriber knows about. + /// This is used to calculate new updates. + /// + private readonly List subscriberState = new(); + + public Subscription(DeviceListenerBase parent, IObserver observer) + { + this.parent = parent ?? throw new ArgumentNullException(nameof(parent)); + this.observer = observer ?? throw new ArgumentNullException(nameof(observer)); + } + + public void SendUpdates() + { + // send updates for existing devices + for (int i = 0; i < subscriberState.Count; i++) + { + var device = parent.knownDevices[i]; + + if (device.Connected != subscriberState[i]) + { + // report new connection state + observer.OnNext(new DeviceStateReport(device.DeviceReference, device.Connected, false)); + subscriberState[i] = device.Connected; + } + } + + // add and send updates for new (to this subscriber) devices. + for (int i = subscriberState.Count; i < parent.knownDevices.Count; i++) + { + var device = parent.knownDevices[i]; + subscriberState.Add(device.Connected); + observer.OnNext(new DeviceStateReport(device.DeviceReference, device.Connected, true)); + } + } + + public void Dispose() + { + lock (parent.sync) + { + parent.subscriptions.Remove(this); + } + } + } + } +} diff --git a/OpenMacroBoard.SDK/DeviceStateReport.cs b/OpenMacroBoard.SDK/DeviceStateReport.cs new file mode 100644 index 0000000..8bdf7db --- /dev/null +++ b/OpenMacroBoard.SDK/DeviceStateReport.cs @@ -0,0 +1,38 @@ +using System; + +namespace OpenMacroBoard.SDK +{ + /// + /// A device state report. + /// + public class DeviceStateReport + { + /// + /// Initializes a new instance of the class. + /// + /// The device. + /// The connection state. + /// Info about if the device is new or not. + public DeviceStateReport(IDeviceReference deviceReference, bool connected, bool newDevice) + { + DeviceReference = deviceReference ?? throw new ArgumentNullException(nameof(deviceReference)); + Connected = connected; + NewDevice = newDevice; + } + + /// + /// Gets the device reference. + /// + public IDeviceReference DeviceReference { get; } + + /// + /// Gets the connection state. + /// + public bool Connected { get; } + + /// + /// Gets the info if this device is new or not. + /// + public bool NewDevice { get; } + } +} diff --git a/OpenMacroBoard.SDK/DisconnectReplayAdapter.cs b/OpenMacroBoard.SDK/DisconnectReplayAdapter.cs new file mode 100644 index 0000000..f64f43c --- /dev/null +++ b/OpenMacroBoard.SDK/DisconnectReplayAdapter.cs @@ -0,0 +1,54 @@ +using System.Collections.Generic; + +namespace OpenMacroBoard.SDK +{ + /// + /// A adapter that replays brightness and key bitmaps if a device is disconnected. + /// + public class DisconnectReplayAdapter : MacroBoardAdapter + { + private readonly Dictionary mostRecentKeyBitmaps = new(); + private byte? mostRecentBrightness = null; + + /// + /// Initializes a new instance of the class. + /// + public DisconnectReplayAdapter(IMacroBoard macroBoard) + : base(macroBoard) + { + ConnectionStateChanged += ReplayEventsForConnectionStateChange; + } + + /// + public override void SetBrightness(byte percent) + { + mostRecentBrightness = percent; + base.SetBrightness(percent); + } + + /// + public override void SetButtonImage(int keyId, KeyBitmap bitmapData) + { + mostRecentKeyBitmaps[keyId] = bitmapData; + base.SetButtonImage(keyId, bitmapData); + } + + private void ReplayEventsForConnectionStateChange(object sender, ConnectionEventArgs e) + { + if (e.NewConnectionState) + { + // devices connected again: replay last known values + + if (mostRecentBrightness is not null) + { + base.SetBrightness(mostRecentBrightness.Value); + } + + foreach (var bmp in mostRecentKeyBitmaps) + { + base.SetButtonImage(bmp.Key, bmp.Value); + } + } + } + } +} diff --git a/OpenMacroBoard.SDK/DrawFullScreenExtensions.cs b/OpenMacroBoard.SDK/DrawFullScreenExtensions.cs new file mode 100644 index 0000000..249359c --- /dev/null +++ b/OpenMacroBoard.SDK/DrawFullScreenExtensions.cs @@ -0,0 +1,123 @@ +using OpenMacroBoard.SDK.Internals; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System; + +namespace OpenMacroBoard.SDK +{ + /// + /// Extension method to generate fullscreen images on s. + /// + public static class DrawFullScreenExtensions + { + /// + /// Draw a given image as fullscreen (spanning over all keys) + /// + /// The board the image should be drawn to. + /// The image that should be drawn. + /// The resize mode that should be used to fit the image. + /// The provided board or bitmap is null. + public static void DrawFullScreenBitmap + ( + this IMacroBoard board, + Image image, + ResizeMode resizeMode = ResizeMode.BoxPad + ) + { + if (board is null) + { + throw new ArgumentNullException(nameof(board)); + } + + if (image is null) + { + throw new ArgumentNullException(nameof(image)); + } + + byte[] imgData = null; + + using (var ctx = ResizeToFullStreamDeckImage(image, board.Keys.Area.Size, resizeMode)) + { + imgData = ctx.Item.ToBgr24PixelArray(); + } + + for (var i = 0; i < board.Keys.Count; i++) + { + var img = GetKeyImageFromFull(board.Keys[i], imgData, board.Keys.Area.Size); + board.SetButtonImage(i, img); + } + } + + private static ConditionalDisposable> ResizeToFullStreamDeckImage + ( + Image image, + OmbSize newSize, + ResizeMode resizeMode + ) + { + return ConstrainedContext.For( + image, + x => + { + if (x is not Image bgr24) + { + return null; + } + + if (x.Width != newSize.Width || x.Height != newSize.Height) + { + return null; + } + + return bgr24; + }, + _ => + { + var scaled = new Image(image.Width, image.Height); + + var resizeOptions = new ResizeOptions() + { + Mode = resizeMode, + Size = new(newSize.Width, newSize.Height), + Sampler = KnownResamplers.Welch, + }; + + scaled.Mutate(x => + { + x.DrawImage(image, 1); + x.Resize(resizeOptions); + }); + + return scaled; + } + ); + } + + private static KeyBitmap GetKeyImageFromFull + ( + OmbRectangle keyPos, + byte[] fullImageData, + OmbSize fullImageSize + ) + { + var keyImgData = new byte[keyPos.Width * keyPos.Height * 3]; + var stride = 3 * fullImageSize.Width; + + for (var y = 0; y < keyPos.Height; y++) + { + for (var x = 0; x < keyPos.Width; x++) + { + var p = (keyPos.Top + y) * stride + (keyPos.Left + x) * 3; + var kPos = (y * keyPos.Width + x) * 3; + + keyImgData[kPos + 0] = fullImageData[p + 0]; + keyImgData[kPos + 1] = fullImageData[p + 1]; + keyImgData[kPos + 2] = fullImageData[p + 2]; + } + } + + return KeyBitmap.Create.FromBgr24Array(keyPos.Width, keyPos.Height, keyImgData); + } + } +} diff --git a/OpenMacroBoard.SDK/GraphicPrimitives/OmbColor.NamedColors.cs b/OpenMacroBoard.SDK/GraphicPrimitives/OmbColor.NamedColors.cs new file mode 100644 index 0000000..9a6c3a9 --- /dev/null +++ b/OpenMacroBoard.SDK/GraphicPrimitives/OmbColor.NamedColors.cs @@ -0,0 +1,749 @@ +namespace OpenMacroBoard.SDK +{ + /// + /// Contains static named color values. + /// + /// + public readonly partial struct OmbColor + { + /// + /// Represents a matching the W3C definition that has an hex value of #F0F8FF. + /// + public static readonly OmbColor AliceBlue = FromRgb(240, 248, 255); + + /// + /// Represents a matching the W3C definition that has an hex value of #FAEBD7. + /// + public static readonly OmbColor AntiqueWhite = FromRgb(250, 235, 215); + + /// + /// Represents a matching the W3C definition that has an hex value of #00FFFF. + /// + public static readonly OmbColor Aqua = FromRgb(0, 255, 255); + + /// + /// Represents a matching the W3C definition that has an hex value of #7FFFD4. + /// + public static readonly OmbColor Aquamarine = FromRgb(127, 255, 212); + + /// + /// Represents a matching the W3C definition that has an hex value of #F0FFFF. + /// + public static readonly OmbColor Azure = FromRgb(240, 255, 255); + + /// + /// Represents a matching the W3C definition that has an hex value of #F5F5DC. + /// + public static readonly OmbColor Beige = FromRgb(245, 245, 220); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFE4C4. + /// + public static readonly OmbColor Bisque = FromRgb(255, 228, 196); + + /// + /// Represents a matching the W3C definition that has an hex value of #000000. + /// + public static readonly OmbColor Black = FromRgb(0, 0, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFEBCD. + /// + public static readonly OmbColor BlanchedAlmond = FromRgb(255, 235, 205); + + /// + /// Represents a matching the W3C definition that has an hex value of #0000FF. + /// + public static readonly OmbColor Blue = FromRgb(0, 0, 255); + + /// + /// Represents a matching the W3C definition that has an hex value of #8A2BE2. + /// + public static readonly OmbColor BlueViolet = FromRgb(138, 43, 226); + + /// + /// Represents a matching the W3C definition that has an hex value of #A52A2A. + /// + public static readonly OmbColor Brown = FromRgb(165, 42, 42); + + /// + /// Represents a matching the W3C definition that has an hex value of #DEB887. + /// + public static readonly OmbColor BurlyWood = FromRgb(222, 184, 135); + + /// + /// Represents a matching the W3C definition that has an hex value of #5F9EA0. + /// + public static readonly OmbColor CadetBlue = FromRgb(95, 158, 160); + + /// + /// Represents a matching the W3C definition that has an hex value of #7FFF00. + /// + public static readonly OmbColor Chartreuse = FromRgb(127, 255, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #D2691E. + /// + public static readonly OmbColor Chocolate = FromRgb(210, 105, 30); + + /// + /// Represents a matching the W3C definition that has an hex value of #FF7F50. + /// + public static readonly OmbColor Coral = FromRgb(255, 127, 80); + + /// + /// Represents a matching the W3C definition that has an hex value of #6495ED. + /// + public static readonly OmbColor CornflowerBlue = FromRgb(100, 149, 237); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFF8DC. + /// + public static readonly OmbColor Cornsilk = FromRgb(255, 248, 220); + + /// + /// Represents a matching the W3C definition that has an hex value of #DC143C. + /// + public static readonly OmbColor Crimson = FromRgb(220, 20, 60); + + /// + /// Represents a matching the W3C definition that has an hex value of #00FFFF. + /// + public static readonly OmbColor Cyan = Aqua; + + /// + /// Represents a matching the W3C definition that has an hex value of #00008B. + /// + public static readonly OmbColor DarkBlue = FromRgb(0, 0, 139); + + /// + /// Represents a matching the W3C definition that has an hex value of #008B8B. + /// + public static readonly OmbColor DarkCyan = FromRgb(0, 139, 139); + + /// + /// Represents a matching the W3C definition that has an hex value of #B8860B. + /// + public static readonly OmbColor DarkGoldenrod = FromRgb(184, 134, 11); + + /// + /// Represents a matching the W3C definition that has an hex value of #A9A9A9. + /// + public static readonly OmbColor DarkGray = FromRgb(169, 169, 169); + + /// + /// Represents a matching the W3C definition that has an hex value of #006400. + /// + public static readonly OmbColor DarkGreen = FromRgb(0, 100, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #A9A9A9. + /// + public static readonly OmbColor DarkGrey = DarkGray; + + /// + /// Represents a matching the W3C definition that has an hex value of #BDB76B. + /// + public static readonly OmbColor DarkKhaki = FromRgb(189, 183, 107); + + /// + /// Represents a matching the W3C definition that has an hex value of #8B008B. + /// + public static readonly OmbColor DarkMagenta = FromRgb(139, 0, 139); + + /// + /// Represents a matching the W3C definition that has an hex value of #556B2F. + /// + public static readonly OmbColor DarkOliveGreen = FromRgb(85, 107, 47); + + /// + /// Represents a matching the W3C definition that has an hex value of #FF8C00. + /// + public static readonly OmbColor DarkOrange = FromRgb(255, 140, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #9932CC. + /// + public static readonly OmbColor DarkOrchid = FromRgb(153, 50, 204); + + /// + /// Represents a matching the W3C definition that has an hex value of #8B0000. + /// + public static readonly OmbColor DarkRed = FromRgb(139, 0, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #E9967A. + /// + public static readonly OmbColor DarkSalmon = FromRgb(233, 150, 122); + + /// + /// Represents a matching the W3C definition that has an hex value of #8FBC8F. + /// + public static readonly OmbColor DarkSeaGreen = FromRgb(143, 188, 143); + + /// + /// Represents a matching the W3C definition that has an hex value of #483D8B. + /// + public static readonly OmbColor DarkSlateBlue = FromRgb(72, 61, 139); + + /// + /// Represents a matching the W3C definition that has an hex value of #2F4F4F. + /// + public static readonly OmbColor DarkSlateGray = FromRgb(47, 79, 79); + + /// + /// Represents a matching the W3C definition that has an hex value of #2F4F4F. + /// + public static readonly OmbColor DarkSlateGrey = DarkSlateGray; + + /// + /// Represents a matching the W3C definition that has an hex value of #00CED1. + /// + public static readonly OmbColor DarkTurquoise = FromRgb(0, 206, 209); + + /// + /// Represents a matching the W3C definition that has an hex value of #9400D3. + /// + public static readonly OmbColor DarkViolet = FromRgb(148, 0, 211); + + /// + /// Represents a matching the W3C definition that has an hex value of #FF1493. + /// + public static readonly OmbColor DeepPink = FromRgb(255, 20, 147); + + /// + /// Represents a matching the W3C definition that has an hex value of #00BFFF. + /// + public static readonly OmbColor DeepSkyBlue = FromRgb(0, 191, 255); + + /// + /// Represents a matching the W3C definition that has an hex value of #696969. + /// + public static readonly OmbColor DimGray = FromRgb(105, 105, 105); + + /// + /// Represents a matching the W3C definition that has an hex value of #696969. + /// + public static readonly OmbColor DimGrey = DimGray; + + /// + /// Represents a matching the W3C definition that has an hex value of #1E90FF. + /// + public static readonly OmbColor DodgerBlue = FromRgb(30, 144, 255); + + /// + /// Represents a matching the W3C definition that has an hex value of #B22222. + /// + public static readonly OmbColor Firebrick = FromRgb(178, 34, 34); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFFAF0. + /// + public static readonly OmbColor FloralWhite = FromRgb(255, 250, 240); + + /// + /// Represents a matching the W3C definition that has an hex value of #228B22. + /// + public static readonly OmbColor ForestGreen = FromRgb(34, 139, 34); + + /// + /// Represents a matching the W3C definition that has an hex value of #FF00FF. + /// + public static readonly OmbColor Fuchsia = FromRgb(255, 0, 255); + + /// + /// Represents a matching the W3C definition that has an hex value of #DCDCDC. + /// + public static readonly OmbColor Gainsboro = FromRgb(220, 220, 220); + + /// + /// Represents a matching the W3C definition that has an hex value of #F8F8FF. + /// + public static readonly OmbColor GhostWhite = FromRgb(248, 248, 255); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFD700. + /// + public static readonly OmbColor Gold = FromRgb(255, 215, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #DAA520. + /// + public static readonly OmbColor Goldenrod = FromRgb(218, 165, 32); + + /// + /// Represents a matching the W3C definition that has an hex value of #808080. + /// + public static readonly OmbColor Gray = FromRgb(128, 128, 128); + + /// + /// Represents a matching the W3C definition that has an hex value of #008000. + /// + public static readonly OmbColor Green = FromRgb(0, 128, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #ADFF2F. + /// + public static readonly OmbColor GreenYellow = FromRgb(173, 255, 47); + + /// + /// Represents a matching the W3C definition that has an hex value of #808080. + /// + public static readonly OmbColor Grey = Gray; + + /// + /// Represents a matching the W3C definition that has an hex value of #F0FFF0. + /// + public static readonly OmbColor Honeydew = FromRgb(240, 255, 240); + + /// + /// Represents a matching the W3C definition that has an hex value of #FF69B4. + /// + public static readonly OmbColor HotPink = FromRgb(255, 105, 180); + + /// + /// Represents a matching the W3C definition that has an hex value of #CD5C5C. + /// + public static readonly OmbColor IndianRed = FromRgb(205, 92, 92); + + /// + /// Represents a matching the W3C definition that has an hex value of #4B0082. + /// + public static readonly OmbColor Indigo = FromRgb(75, 0, 130); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFFFF0. + /// + public static readonly OmbColor Ivory = FromRgb(255, 255, 240); + + /// + /// Represents a matching the W3C definition that has an hex value of #F0E68C. + /// + public static readonly OmbColor Khaki = FromRgb(240, 230, 140); + + /// + /// Represents a matching the W3C definition that has an hex value of #E6E6FA. + /// + public static readonly OmbColor Lavender = FromRgb(230, 230, 250); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFF0F5. + /// + public static readonly OmbColor LavenderBlush = FromRgb(255, 240, 245); + + /// + /// Represents a matching the W3C definition that has an hex value of #7CFC00. + /// + public static readonly OmbColor LawnGreen = FromRgb(124, 252, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFFACD. + /// + public static readonly OmbColor LemonChiffon = FromRgb(255, 250, 205); + + /// + /// Represents a matching the W3C definition that has an hex value of #ADD8E6. + /// + public static readonly OmbColor LightBlue = FromRgb(173, 216, 230); + + /// + /// Represents a matching the W3C definition that has an hex value of #F08080. + /// + public static readonly OmbColor LightCoral = FromRgb(240, 128, 128); + + /// + /// Represents a matching the W3C definition that has an hex value of #E0FFFF. + /// + public static readonly OmbColor LightCyan = FromRgb(224, 255, 255); + + /// + /// Represents a matching the W3C definition that has an hex value of #FAFAD2. + /// + public static readonly OmbColor LightGoldenrodYellow = FromRgb(250, 250, 210); + + /// + /// Represents a matching the W3C definition that has an hex value of #D3D3D3. + /// + public static readonly OmbColor LightGray = FromRgb(211, 211, 211); + + /// + /// Represents a matching the W3C definition that has an hex value of #90EE90. + /// + public static readonly OmbColor LightGreen = FromRgb(144, 238, 144); + + /// + /// Represents a matching the W3C definition that has an hex value of #D3D3D3. + /// + public static readonly OmbColor LightGrey = LightGray; + + /// + /// Represents a matching the W3C definition that has an hex value of #FFB6C1. + /// + public static readonly OmbColor LightPink = FromRgb(255, 182, 193); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFA07A. + /// + public static readonly OmbColor LightSalmon = FromRgb(255, 160, 122); + + /// + /// Represents a matching the W3C definition that has an hex value of #20B2AA. + /// + public static readonly OmbColor LightSeaGreen = FromRgb(32, 178, 170); + + /// + /// Represents a matching the W3C definition that has an hex value of #87CEFA. + /// + public static readonly OmbColor LightSkyBlue = FromRgb(135, 206, 250); + + /// + /// Represents a matching the W3C definition that has an hex value of #778899. + /// + public static readonly OmbColor LightSlateGray = FromRgb(119, 136, 153); + + /// + /// Represents a matching the W3C definition that has an hex value of #778899. + /// + public static readonly OmbColor LightSlateGrey = LightSlateGray; + + /// + /// Represents a matching the W3C definition that has an hex value of #B0C4DE. + /// + public static readonly OmbColor LightSteelBlue = FromRgb(176, 196, 222); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFFFE0. + /// + public static readonly OmbColor LightYellow = FromRgb(255, 255, 224); + + /// + /// Represents a matching the W3C definition that has an hex value of #00FF00. + /// + public static readonly OmbColor Lime = FromRgb(0, 255, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #32CD32. + /// + public static readonly OmbColor LimeGreen = FromRgb(50, 205, 50); + + /// + /// Represents a matching the W3C definition that has an hex value of #FAF0E6. + /// + public static readonly OmbColor Linen = FromRgb(250, 240, 230); + + /// + /// Represents a matching the W3C definition that has an hex value of #FF00FF. + /// + public static readonly OmbColor Magenta = Fuchsia; + + /// + /// Represents a matching the W3C definition that has an hex value of #800000. + /// + public static readonly OmbColor Maroon = FromRgb(128, 0, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #66CDAA. + /// + public static readonly OmbColor MediumAquamarine = FromRgb(102, 205, 170); + + /// + /// Represents a matching the W3C definition that has an hex value of #0000CD. + /// + public static readonly OmbColor MediumBlue = FromRgb(0, 0, 205); + + /// + /// Represents a matching the W3C definition that has an hex value of #BA55D3. + /// + public static readonly OmbColor MediumOrchid = FromRgb(186, 85, 211); + + /// + /// Represents a matching the W3C definition that has an hex value of #9370DB. + /// + public static readonly OmbColor MediumPurple = FromRgb(147, 112, 219); + + /// + /// Represents a matching the W3C definition that has an hex value of #3CB371. + /// + public static readonly OmbColor MediumSeaGreen = FromRgb(60, 179, 113); + + /// + /// Represents a matching the W3C definition that has an hex value of #7B68EE. + /// + public static readonly OmbColor MediumSlateBlue = FromRgb(123, 104, 238); + + /// + /// Represents a matching the W3C definition that has an hex value of #00FA9A. + /// + public static readonly OmbColor MediumSpringGreen = FromRgb(0, 250, 154); + + /// + /// Represents a matching the W3C definition that has an hex value of #48D1CC. + /// + public static readonly OmbColor MediumTurquoise = FromRgb(72, 209, 204); + + /// + /// Represents a matching the W3C definition that has an hex value of #C71585. + /// + public static readonly OmbColor MediumVioletRed = FromRgb(199, 21, 133); + + /// + /// Represents a matching the W3C definition that has an hex value of #191970. + /// + public static readonly OmbColor MidnightBlue = FromRgb(25, 25, 112); + + /// + /// Represents a matching the W3C definition that has an hex value of #F5FFFA. + /// + public static readonly OmbColor MintCream = FromRgb(245, 255, 250); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFE4E1. + /// + public static readonly OmbColor MistyRose = FromRgb(255, 228, 225); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFE4B5. + /// + public static readonly OmbColor Moccasin = FromRgb(255, 228, 181); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFDEAD. + /// + public static readonly OmbColor NavajoWhite = FromRgb(255, 222, 173); + + /// + /// Represents a matching the W3C definition that has an hex value of #000080. + /// + public static readonly OmbColor Navy = FromRgb(0, 0, 128); + + /// + /// Represents a matching the W3C definition that has an hex value of #FDF5E6. + /// + public static readonly OmbColor OldLace = FromRgb(253, 245, 230); + + /// + /// Represents a matching the W3C definition that has an hex value of #808000. + /// + public static readonly OmbColor Olive = FromRgb(128, 128, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #6B8E23. + /// + public static readonly OmbColor OliveDrab = FromRgb(107, 142, 35); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFA500. + /// + public static readonly OmbColor Orange = FromRgb(255, 165, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #FF4500. + /// + public static readonly OmbColor OrangeRed = FromRgb(255, 69, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #DA70D6. + /// + public static readonly OmbColor Orchid = FromRgb(218, 112, 214); + + /// + /// Represents a matching the W3C definition that has an hex value of #EEE8AA. + /// + public static readonly OmbColor PaleGoldenrod = FromRgb(238, 232, 170); + + /// + /// Represents a matching the W3C definition that has an hex value of #98FB98. + /// + public static readonly OmbColor PaleGreen = FromRgb(152, 251, 152); + + /// + /// Represents a matching the W3C definition that has an hex value of #AFEEEE. + /// + public static readonly OmbColor PaleTurquoise = FromRgb(175, 238, 238); + + /// + /// Represents a matching the W3C definition that has an hex value of #DB7093. + /// + public static readonly OmbColor PaleVioletRed = FromRgb(219, 112, 147); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFEFD5. + /// + public static readonly OmbColor PapayaWhip = FromRgb(255, 239, 213); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFDAB9. + /// + public static readonly OmbColor PeachPuff = FromRgb(255, 218, 185); + + /// + /// Represents a matching the W3C definition that has an hex value of #CD853F. + /// + public static readonly OmbColor Peru = FromRgb(205, 133, 63); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFC0CB. + /// + public static readonly OmbColor Pink = FromRgb(255, 192, 203); + + /// + /// Represents a matching the W3C definition that has an hex value of #DDA0DD. + /// + public static readonly OmbColor Plum = FromRgb(221, 160, 221); + + /// + /// Represents a matching the W3C definition that has an hex value of #B0E0E6. + /// + public static readonly OmbColor PowderBlue = FromRgb(176, 224, 230); + + /// + /// Represents a matching the W3C definition that has an hex value of #800080. + /// + public static readonly OmbColor Purple = FromRgb(128, 0, 128); + + /// + /// Represents a matching the W3C definition that has an hex value of #663399. + /// + public static readonly OmbColor RebeccaPurple = FromRgb(102, 51, 153); + + /// + /// Represents a matching the W3C definition that has an hex value of #FF0000. + /// + public static readonly OmbColor Red = FromRgb(255, 0, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #BC8F8F. + /// + public static readonly OmbColor RosyBrown = FromRgb(188, 143, 143); + + /// + /// Represents a matching the W3C definition that has an hex value of #4169E1. + /// + public static readonly OmbColor RoyalBlue = FromRgb(65, 105, 225); + + /// + /// Represents a matching the W3C definition that has an hex value of #8B4513. + /// + public static readonly OmbColor SaddleBrown = FromRgb(139, 69, 19); + + /// + /// Represents a matching the W3C definition that has an hex value of #FA8072. + /// + public static readonly OmbColor Salmon = FromRgb(250, 128, 114); + + /// + /// Represents a matching the W3C definition that has an hex value of #F4A460. + /// + public static readonly OmbColor SandyBrown = FromRgb(244, 164, 96); + + /// + /// Represents a matching the W3C definition that has an hex value of #2E8B57. + /// + public static readonly OmbColor SeaGreen = FromRgb(46, 139, 87); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFF5EE. + /// + public static readonly OmbColor SeaShell = FromRgb(255, 245, 238); + + /// + /// Represents a matching the W3C definition that has an hex value of #A0522D. + /// + public static readonly OmbColor Sienna = FromRgb(160, 82, 45); + + /// + /// Represents a matching the W3C definition that has an hex value of #C0C0C0. + /// + public static readonly OmbColor Silver = FromRgb(192, 192, 192); + + /// + /// Represents a matching the W3C definition that has an hex value of #87CEEB. + /// + public static readonly OmbColor SkyBlue = FromRgb(135, 206, 235); + + /// + /// Represents a matching the W3C definition that has an hex value of #6A5ACD. + /// + public static readonly OmbColor SlateBlue = FromRgb(106, 90, 205); + + /// + /// Represents a matching the W3C definition that has an hex value of #708090. + /// + public static readonly OmbColor SlateGray = FromRgb(112, 128, 144); + + /// + /// Represents a matching the W3C definition that has an hex value of #708090. + /// + public static readonly OmbColor SlateGrey = SlateGray; + + /// + /// Represents a matching the W3C definition that has an hex value of #FFFAFA. + /// + public static readonly OmbColor Snow = FromRgb(255, 250, 250); + + /// + /// Represents a matching the W3C definition that has an hex value of #00FF7F. + /// + public static readonly OmbColor SpringGreen = FromRgb(0, 255, 127); + + /// + /// Represents a matching the W3C definition that has an hex value of #4682B4. + /// + public static readonly OmbColor SteelBlue = FromRgb(70, 130, 180); + + /// + /// Represents a matching the W3C definition that has an hex value of #D2B48C. + /// + public static readonly OmbColor Tan = FromRgb(210, 180, 140); + + /// + /// Represents a matching the W3C definition that has an hex value of #008080. + /// + public static readonly OmbColor Teal = FromRgb(0, 128, 128); + + /// + /// Represents a matching the W3C definition that has an hex value of #D8BFD8. + /// + public static readonly OmbColor Thistle = FromRgb(216, 191, 216); + + /// + /// Represents a matching the W3C definition that has an hex value of #FF6347. + /// + public static readonly OmbColor Tomato = FromRgb(255, 99, 71); + + /// + /// Represents a matching the W3C definition that has an hex value of #40E0D0. + /// + public static readonly OmbColor Turquoise = FromRgb(64, 224, 208); + + /// + /// Represents a matching the W3C definition that has an hex value of #EE82EE. + /// + public static readonly OmbColor Violet = FromRgb(238, 130, 238); + + /// + /// Represents a matching the W3C definition that has an hex value of #F5DEB3. + /// + public static readonly OmbColor Wheat = FromRgb(245, 222, 179); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFFFFF. + /// + public static readonly OmbColor White = FromRgb(255, 255, 255); + + /// + /// Represents a matching the W3C definition that has an hex value of #F5F5F5. + /// + public static readonly OmbColor WhiteSmoke = FromRgb(245, 245, 245); + + /// + /// Represents a matching the W3C definition that has an hex value of #FFFF00. + /// + public static readonly OmbColor Yellow = FromRgb(255, 255, 0); + + /// + /// Represents a matching the W3C definition that has an hex value of #9ACD32. + /// + public static readonly OmbColor YellowGreen = FromRgb(154, 205, 50); + } +} diff --git a/OpenMacroBoard.SDK/GraphicPrimitives/OmbColor.cs b/OpenMacroBoard.SDK/GraphicPrimitives/OmbColor.cs new file mode 100644 index 0000000..fe3180d --- /dev/null +++ b/OpenMacroBoard.SDK/GraphicPrimitives/OmbColor.cs @@ -0,0 +1,99 @@ +using System; + +namespace OpenMacroBoard.SDK +{ + /// + /// Represents a color value. + /// + public readonly partial struct OmbColor : IEquatable + { + private OmbColor(byte r, byte g, byte b) + { + R = r; + G = g; + B = b; + } + + /// + /// The red part of the color. + /// + public byte R { get; } + + /// + /// The green part of the color. + /// + public byte G { get; } + + /// + /// The blue part of the color. + /// + public byte B { get; } + + /// + /// Checks whether two structures are equal. + /// + /// The left hand operand. + /// The right hand operand. + /// + /// True if the parameter is equal to the parameter; + /// otherwise, false. + /// + public static bool operator ==(OmbColor left, OmbColor right) + { + return left.Equals(right); + } + + /// + /// Checks whether two structures are equal. + /// + /// The left hand operand. + /// The right hand operand. + /// + /// True if the parameter is not equal to the parameter; + /// otherwise, false. + /// + public static bool operator !=(OmbColor left, OmbColor right) + { + return !left.Equals(right); + } + + /// + /// Creates a from RGB bytes. + /// + /// The red component (0-255). + /// The green component (0-255). + /// The blue component (0-255). + /// The . + public static OmbColor FromRgb(byte r, byte g, byte b) + { + return new OmbColor(r, g, b); + } + + /// + public override string ToString() + { + return $"#{R:X2}{G:X2}{B:X2}"; + } + + /// + public bool Equals(OmbColor other) + { + return + R == other.R && + G == other.G && + B == other.B; + } + + /// + public override bool Equals(object obj) + { + return obj is OmbColor other && Equals(other); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(R, G, B); + } + } +} diff --git a/OpenMacroBoard.SDK/GraphicPrimitives/OmbPoint.cs b/OpenMacroBoard.SDK/GraphicPrimitives/OmbPoint.cs new file mode 100644 index 0000000..0ee3143 --- /dev/null +++ b/OpenMacroBoard.SDK/GraphicPrimitives/OmbPoint.cs @@ -0,0 +1,139 @@ +using System; +using System.Runtime.CompilerServices; + +namespace OpenMacroBoard.SDK +{ + /// + /// Represents an ordered pair of integer x- and y-coordinates that defines a point in + /// a two-dimensional plane. + /// + public readonly struct OmbPoint : IEquatable + { + /// + /// Represents a that has X and Y values set to zero. + /// + public static readonly OmbPoint Empty = default; + + /// + /// Initializes a new instance of the struct. + /// + /// The horizontal position of the point. + /// The vertical position of the point. + public OmbPoint(int x, int y) + : this() + { + X = x; + Y = y; + } + + /// + /// Initializes a new instance of the struct from the given . + /// + /// The size. + public OmbPoint(OmbSize size) + { + X = size.Width; + Y = size.Height; + } + + /// + /// Gets or sets the x-coordinate of this . + /// + public int X { get; } + + /// + /// Gets or sets the y-coordinate of this . + /// + public int Y { get; } + + /// + /// Gets a value indicating whether this is empty. + /// + public bool IsEmpty => Equals(Empty); + + /// + /// Creates a with the coordinates of the specified . + /// + /// The point. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static explicit operator OmbSize(OmbPoint point) + { + return new OmbSize(point.X, point.Y); + } + + /// + /// Divides by a producing . + /// + /// Dividend of type . + /// Divisor of type . + /// Result of type . + public static OmbPoint operator /(OmbPoint left, int right) + { + return new OmbPoint(left.X / right, left.Y / right); + } + + /// + /// Compares two objects for equality. + /// + /// The on the left side of the operand. + /// The on the right side of the operand. + /// + /// True if the current left is equal to the parameter; otherwise, false. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool operator ==(OmbPoint left, OmbPoint right) + { + return left.Equals(right); + } + + /// + /// Compares two objects for inequality. + /// + /// The on the left side of the operand. + /// The on the right side of the operand. + /// + /// True if the current left is unequal to the parameter; otherwise, false. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool operator !=(OmbPoint left, OmbPoint right) + { + return !left.Equals(right); + } + + /// + /// Deconstructs this point into two integers. + /// + /// The out value for X. + /// The out value for Y. + public void Deconstruct(out int x, out int y) + { + x = X; + y = Y; + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(X, Y); + } + + /// + public override string ToString() + { + return $"Point [ X={X}, Y={Y} ]"; + } + + /// + public override bool Equals(object obj) + { + return obj is OmbPoint other && Equals(other); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Equals(OmbPoint other) + { + return X.Equals(other.X) && Y.Equals(other.Y); + } + } +} diff --git a/OpenMacroBoard.SDK/GraphicPrimitives/OmbRectangle.cs b/OpenMacroBoard.SDK/GraphicPrimitives/OmbRectangle.cs new file mode 100644 index 0000000..395f343 --- /dev/null +++ b/OpenMacroBoard.SDK/GraphicPrimitives/OmbRectangle.cs @@ -0,0 +1,199 @@ +using System; +using System.Runtime.CompilerServices; + +namespace OpenMacroBoard.SDK +{ + /// + /// Stores a set of four integers that represent the location and size of a rectangle. + /// + public readonly struct OmbRectangle : IEquatable + { + /// + /// Represents a that has X, Y, Width, and Height values set to zero. + /// + public static readonly OmbRectangle Empty = default; + + /// + /// Initializes a new instance of the struct. + /// + /// The horizontal position of the rectangle. + /// The vertical position of the rectangle. + /// The width of the rectangle. + /// The height of the rectangle. + public OmbRectangle(int x, int y, int width, int height) + { + X = x; + Y = y; + Width = width; + Height = height; + } + + /// + /// Initializes a new instance of the struct. + /// + /// + /// The which specifies the rectangles point in a two-dimensional plane. + /// + /// + /// The which specifies the rectangles height and width. + /// + public OmbRectangle(OmbPoint point, OmbSize size) + { + X = point.X; + Y = point.Y; + Width = size.Width; + Height = size.Height; + } + + /// + /// Gets or sets the x-coordinate of this . + /// + public int X { get; } + + /// + /// Gets or sets the y-coordinate of this . + /// + public int Y { get; } + + /// + /// Gets or sets the width of this . + /// + public int Width { get; } + + /// + /// Gets or sets the height of this . + /// + public int Height { get; } + + /// + /// Gets or sets the coordinates of the upper-left corner of the rectangular region represented by this . + /// + public OmbPoint Location + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => new(X, Y); + } + + /// + /// Gets or sets the size of this . + /// + public OmbSize Size + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => new(Width, Height); + } + + /// + /// Gets a value indicating whether this is empty. + /// + public bool IsEmpty => Equals(Empty); + + /// + /// Gets the y-coordinate of the top edge of this . + /// + public int Top => Y; + + /// + /// Gets the x-coordinate of the right edge of this . + /// + public int Right + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => unchecked(X + Width); + } + + /// + /// Gets the y-coordinate of the bottom edge of this . + /// + public int Bottom + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get => unchecked(Y + Height); + } + + /// + /// Gets the x-coordinate of the left edge of this . + /// + public int Left => X; + + /// + /// Compares two objects for equality. + /// + /// The on the left side of the operand. + /// The on the right side of the operand. + /// + /// True if the current left is equal to the parameter; otherwise, false. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool operator ==(OmbRectangle left, OmbRectangle right) + { + return left.Equals(right); + } + + /// + /// Compares two objects for inequality. + /// + /// The on the left side of the operand. + /// The on the right side of the operand. + /// + /// True if the current left is unequal to the parameter; otherwise, false. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool operator !=(OmbRectangle left, OmbRectangle right) + { + return !left.Equals(right); + } + + /// + /// Creates a rectangle from left, top, right, bottom. + /// + public static OmbRectangle FromLTRB(int left, int top, int right, int bottom) + { + return new OmbRectangle(left, top, unchecked(right - left), unchecked(bottom - top)); + } + + /// + /// Deconstructs this rectangle into four integers. + /// + /// The out value for X. + /// The out value for Y. + /// The out value for the width. + /// The out value for the height. + public void Deconstruct(out int x, out int y, out int width, out int height) + { + x = X; + y = Y; + width = Width; + height = Height; + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Equals(OmbRectangle other) + { + return + X.Equals(other.X) && + Y.Equals(other.Y) && + Width.Equals(other.Width) && + Height.Equals(other.Height); + } + + /// + public override bool Equals(object obj) + { + return obj is OmbRectangle other && Equals(other); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(X, Y, Width, Height); + } + + /// + public override string ToString() + { + return $"Rectangle [ X={X}, Y={Y}, Width={Width}, Height={Height} ]"; + } + } +} diff --git a/OpenMacroBoard.SDK/GraphicPrimitives/OmbSize.cs b/OpenMacroBoard.SDK/GraphicPrimitives/OmbSize.cs new file mode 100644 index 0000000..7c6a1fd --- /dev/null +++ b/OpenMacroBoard.SDK/GraphicPrimitives/OmbSize.cs @@ -0,0 +1,156 @@ +using System; +using System.Runtime.CompilerServices; + +namespace OpenMacroBoard.SDK +{ + /// + /// Stores an ordered pair of integers, which specify a height and width. + /// + public readonly struct OmbSize : IEquatable + { + /// + /// Represents a that has Width and Height values set to zero. + /// + public static readonly OmbSize Empty = default; + + /// + /// Initializes a new instance of the struct. + /// + /// The width and height of the size. + public OmbSize(int value) + : this() + { + Width = value; + Height = value; + } + + /// + /// Initializes a new instance of the struct. + /// + /// The width of the size. + /// The height of the size. + public OmbSize(int width, int height) + { + Width = width; + Height = height; + } + + /// + /// Initializes a new instance of the struct. + /// + /// The size. + public OmbSize(OmbSize size) + : this() + { + Width = size.Width; + Height = size.Height; + } + + /// + /// Initializes a new instance of the struct from the given . + /// + /// The point. + public OmbSize(OmbPoint point) + { + Width = point.X; + Height = point.Y; + } + + /// + /// Gets or sets the width of this . + /// + public int Width { get; } + + /// + /// Gets or sets the height of this . + /// + public int Height { get; } + + /// + /// Gets a value indicating whether this is empty. + /// + public bool IsEmpty => Equals(Empty); + + /// + /// Converts the given into a . + /// + /// The size. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static explicit operator OmbPoint(OmbSize size) + { + return new OmbPoint(size.Width, size.Height); + } + + /// + /// Compares two objects for equality. + /// + /// + /// The on the left side of the operand. + /// + /// + /// The on the right side of the operand. + /// + /// + /// True if the current left is equal to the parameter; otherwise, false. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool operator ==(OmbSize left, OmbSize right) + { + return left.Equals(right); + } + + /// + /// Compares two objects for inequality. + /// + /// + /// The on the left side of the operand. + /// + /// + /// The on the right side of the operand. + /// + /// + /// True if the current left is unequal to the parameter; otherwise, false. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static bool operator !=(OmbSize left, OmbSize right) + { + return !left.Equals(right); + } + + /// + /// Deconstructs this size into two integers. + /// + /// The out value for the width. + /// The out value for the height. + public void Deconstruct(out int width, out int height) + { + width = Width; + height = Height; + } + + /// + public override int GetHashCode() + { + return HashCode.Combine(Width, Height); + } + + /// + public override string ToString() + { + return $"Size [ Width={Width}, Height={Height} ]"; + } + + /// + public override bool Equals(object obj) + { + return obj is OmbSize other && Equals(other); + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool Equals(OmbSize other) + { + return Width.Equals(other.Width) && Height.Equals(other.Height); + } + } +} diff --git a/OpenMacroBoard.SDK/GraphicPrimitives/README.md b/OpenMacroBoard.SDK/GraphicPrimitives/README.md new file mode 100644 index 0000000..d6ba271 --- /dev/null +++ b/OpenMacroBoard.SDK/GraphicPrimitives/README.md @@ -0,0 +1,5 @@ +To make the API a bit more agnostic regarding the internal image processing library +we provide our graphic primitives (like Size, Rectangle etc.) + +Those types are intentionally very simple and don't provide a lot of functionallity. +Some things are inspired on other libraries primitives like ImageSharp, SkiaSharp or System.Drawing diff --git a/OpenMacroBoard.SDK/GridKeyLayout.cs b/OpenMacroBoard.SDK/GridKeyLayout.cs new file mode 100644 index 0000000..314d3e6 --- /dev/null +++ b/OpenMacroBoard.SDK/GridKeyLayout.cs @@ -0,0 +1,96 @@ +using OpenMacroBoard.SDK.Helper; +using System; +using System.Collections; +using System.Collections.Generic; + +namespace OpenMacroBoard.SDK +{ + /// + /// Represents a grid-like keyboard layout for macro boards. + /// + public class GridKeyLayout : IKeyLayout + { + /// + /// Initializes a new instance of the class. + /// + /// Number of keys in the x-coordinate (horizontal) + /// Number of keys in the y-coordinate (vertical) + /// Square key size (pixels) + /// Distance between keys (pixels) + public GridKeyLayout(int countX, int countY, int keySize, int gapSize) + { +#pragma warning disable SA1503, IDE0011 // Braces should not be omitted + if (countX <= 0) throw new ArgumentOutOfRangeException(nameof(countX)); + if (countY <= 0) throw new ArgumentOutOfRangeException(nameof(countY)); + if (keySize <= 0) throw new ArgumentOutOfRangeException(nameof(keySize)); + if (gapSize <= 0) throw new ArgumentOutOfRangeException(nameof(gapSize)); +#pragma warning restore SA1503, IDE0011 + + CountX = countX; + CountY = countY; + KeySize = keySize; + GapSize = gapSize; + Count = countX * countY; + + Area = this.GetFullArea(); + } + + /// + /// Gets the number of keys on this layout. + /// + public int Count { get; } + + /// + public int KeySize { get; } + + /// + public int GapSize { get; } + + /// + public OmbRectangle Area { get; } + + /// + public int CountX { get; } + + /// + public int CountY { get; } + + /// + /// Gets the dimensions of the key with a given . + /// + /// The index of the key. + /// The dimensions of the requested key. + /// Is thrown if the is out of range. + public OmbRectangle this[int keyIndex] + { + get + { + if (keyIndex < 0 || keyIndex >= Count) + { + throw new ArgumentOutOfRangeException(nameof(keyIndex)); + } + + // split id into x and y component + var y = keyIndex / CountX; + var x = keyIndex % CountX; + + var fullSize = KeySize + GapSize; + return new OmbRectangle(fullSize * x, fullSize * y, KeySize, KeySize); + } + } + + /// + public IEnumerator GetEnumerator() + { + for (int i = 0; i < Count; i++) + { + yield return this[i]; + } + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} diff --git a/OpenMacroBoard.SDK/IDeviceContext.cs b/OpenMacroBoard.SDK/IDeviceContext.cs new file mode 100644 index 0000000..fc40ebd --- /dev/null +++ b/OpenMacroBoard.SDK/IDeviceContext.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; + +namespace OpenMacroBoard.SDK +{ + /// + /// Represents a context that tracks devices and their connection state using device listeners. + /// + /// + /// This is basically the entry point to opening a . Once constructed + /// you can add device listeners for various providers and the context will collect all devices + /// (even from different providers) in one place. + /// + public interface IDeviceContext : IDisposable + { + /// + /// Gets the observable that reports all device state changes, for example like + /// new devices or connection state changes. + /// + /// + /// Once subscribed, reports are generated to reflect the current state of known devices and + /// their connection state. This does not mean that all original reports are replayed, but only + /// the most recent events, that are needed to rebuild the currently known state for all known devices. + /// + IObservable DeviceStateReports { get; } + + /// + /// A list of known devices. + /// + /// + /// The order of the list is consistent and new devices are always added at the end. + /// + IReadOnlyList KnownDevices { get; } + + /// + /// Registers a new device listener for that context. + /// + /// + /// Registered listeners can't be unsubscribed individually. + /// All listeners will unsubscribed when the context is disposed. + /// + /// Returns the original instance to allow for fluent API calls. + IDeviceContext AddListener(IObservable deviceListener); + + /// + /// Registers a new device listener for that context. + /// + /// + /// + /// Registered listeners can't be unsubscribed individually. + /// All listeners will unsubscribed when the context is disposed. + /// + /// + /// When is set to true and is + /// it will be disposed when the context is disposed. + /// + /// + /// Returns the original instance to allow for fluent API calls. + IDeviceContext AddListener(IObservable deviceListener, bool disposeWithContext); + + /// + /// Registers a new device listener for that context. + /// + /// A with a parameterless constructor. + /// + /// Registered listeners can't be unsubscribed individually. + /// All listeners will unsubscribed when the context is disposed. + /// + /// Returns the original instance to allow for fluent API calls. + IDeviceContext AddListener() + where TListener : IObservable, new(); + } +} diff --git a/OpenMacroBoard.SDK/IDeviceReference.cs b/OpenMacroBoard.SDK/IDeviceReference.cs new file mode 100644 index 0000000..c03af44 --- /dev/null +++ b/OpenMacroBoard.SDK/IDeviceReference.cs @@ -0,0 +1,29 @@ +namespace OpenMacroBoard.SDK +{ + /// + /// A handle that can be used to open an instance. + /// + public interface IDeviceReference + { + /// + /// A user friendly display name. + /// + /// + /// The device name is not part of the equality for a device reference handle. This means, that + /// there can be two s with different names which are still + /// considered to be equal, because they refer to the same device. + /// + string DeviceName { get; set; } + + /// + /// Gets the key layout for the referenced device. + /// + IKeyLayout Keys { get; } + + /// + /// Connects to a macro board and returns an instance + /// which can be used to interact with the board. + /// + IMacroBoard Open(); + } +} diff --git a/OpenMacroBoard.SDK/IKeyBitmapDataAccess.cs b/OpenMacroBoard.SDK/IKeyBitmapDataAccess.cs new file mode 100644 index 0000000..6c9a70f --- /dev/null +++ b/OpenMacroBoard.SDK/IKeyBitmapDataAccess.cs @@ -0,0 +1,30 @@ +using System; + +namespace OpenMacroBoard.SDK +{ + /// + /// An interface that allows you to access the underlying data of s. + /// + public interface IKeyBitmapDataAccess + { + /// + /// Gets the width of the bitmap. + /// + int Width { get; } + + /// + /// Gets the height of the bitmap. + /// + int Height { get; } + + /// + /// Gets a value indicating whether the underlying byte array is null. + /// + bool IsEmpty { get; } + + /// + /// Gets the underlying image data in unaligned Bgr24 format (stride = width * 3). + /// + ReadOnlySpan GetData(); + } +} diff --git a/OpenMacroBoard.SDK/IKeyBitmapFactory.cs b/OpenMacroBoard.SDK/IKeyBitmapFactory.cs new file mode 100644 index 0000000..0a3d289 --- /dev/null +++ b/OpenMacroBoard.SDK/IKeyBitmapFactory.cs @@ -0,0 +1,14 @@ +namespace OpenMacroBoard.SDK +{ + /// + /// Interface for factory extensions + /// + /// + /// This interface is intentionally empty + /// It is used to implement factory extensions + /// + /// + public interface IKeyBitmapFactory + { + } +} diff --git a/OpenMacroBoard.SDK/IKeyLayout.cs b/OpenMacroBoard.SDK/IKeyLayout.cs new file mode 100644 index 0000000..f381530 --- /dev/null +++ b/OpenMacroBoard.SDK/IKeyLayout.cs @@ -0,0 +1,61 @@ +using System.Collections.Generic; + +namespace OpenMacroBoard.SDK +{ + /// + /// Describes the key layout of an . + /// + public interface IKeyLayout : IReadOnlyList + { + /// + /// Gets the image size of the keys that are supported. + /// + /// + /// + /// In the rare case that the underlying board doesn't have square keys + /// the value will be the size that most keys use, an average or a guess. + /// If you need more accurate sizes you have the enumerate the key rectangles of + /// this collection. + /// + /// + int KeySize { get; } + + /// + /// Gets the smallest rectangle area that fits all keys. + /// + OmbRectangle Area { get; } + + /// + /// Gets the number of keys in the horizontal direction. + /// + /// + /// In the rare case that the underlying board doesn't have a rectangular key layout + /// this value might be an estimate or even wrong. It's not even guaranteed that + /// times will be equal to Count + /// but all implementations should make sure that the product is at least + /// not greater than Count and is at least 1. + /// + int CountX { get; } + + /// + /// Gets the number of keys in the vertical direction. + /// + /// + /// In the rare case that the underlying board doesn't have a rectangular key layout + /// this value might be an estimate or even wrong. It's not even guaranteed that + /// times will be equal to Count + /// but all implementations should make sure that the product is at least + /// not greater than Count and is at least 1. + /// + int CountY { get; } + + /// + /// Gets the gap between the keys. + /// + /// + /// In the rare case that the underlying board doesn't have + /// a rectangular key layout this value might be an estimate, made up or wrong. + /// + int GapSize { get; } + } +} diff --git a/OpenMacroBoard.SDK/IKnownDevice.cs b/OpenMacroBoard.SDK/IKnownDevice.cs new file mode 100644 index 0000000..5f971f2 --- /dev/null +++ b/OpenMacroBoard.SDK/IKnownDevice.cs @@ -0,0 +1,15 @@ +namespace OpenMacroBoard.SDK +{ + /// + /// A managed by a + /// with additional meta data (like the connected state). + /// + public interface IKnownDevice : IDeviceReference + { + /// + /// Gets a value that indicates the current connection state. + /// True if the device is currently connected, false if the device is disconnected. + /// + bool Connected { get; } + } +} diff --git a/OpenMacroBoard.SDK/IMacroBoard.cs b/OpenMacroBoard.SDK/IMacroBoard.cs new file mode 100644 index 0000000..e54c50d --- /dev/null +++ b/OpenMacroBoard.SDK/IMacroBoard.cs @@ -0,0 +1,140 @@ +using System; + +namespace OpenMacroBoard.SDK { + /// + /// An interface that allows you to interact with (LCD) macro boards + /// + public interface IMacroBoard : IDisposable { + // + // Is raised when a key is pressed + // + //event EventHandler KeyStateChanged; + + /// + /// Is raised when the MarcoBoard is being disconnected or connected + /// + event EventHandler ConnectionStateChanged; + + /// + /// Is raised when a key is pressed + /// + event EventHandler ButtonPressed; + + /// + /// Is raised when a key is released + /// + event EventHandler ButtonReleased; + + /// + /// Is raised when the Touchbar is tapped + /// + event EventHandler TouchbarTouched; + + /// + /// Is raised when the Touchbar is pressed (long tap) + /// + event EventHandler TouchbarPressed; + + /// + /// Is raised when the finger is fliped over the Touchbar + /// + event EventHandler TouchFlipEvent; + + /// + /// Is raised when a Rotary Encoder is pressed + /// + event EventHandler EncoderPressed; + + /// + /// Is raised when a Rotary Encoder is released + /// + event EventHandler EncoderReleased; + + /// + /// Is raised when a Rotary Encoder is turned + /// + event EventHandler EncoderTurn; + + /// + /// Informations about the keys and their position + /// + IKeyLayout Keys { + get; + } + + /// + /// Gets a value indicating whether the MarcoBoard is connected. + /// + Boolean IsConnected { + get; + } + + /// + /// Sets the brightness for this + /// + /// Brightness in percent (0 - 100) + /// + /// + /// The brightness on the device is controlled with PWM (https://en.wikipedia.org/wiki/Pulse-width_modulation). + /// This results in a non-linear correlation between set percentage and perceived brightness. + /// + /// + /// In a nutshell: changing from 10 - 30 results in a bigger change than 80 - 100 (barely visible change) + /// This effect should be compensated outside this library + /// + /// + void SetBrightness(Byte percent); + + /// + /// Uploads an image for a specific button. + /// + /// Specifies which key the image will be applied on + /// Bitmap. The key will be painted black if this value is null. + void SetButtonImage(Int32 keyId, KeyBitmap bitmapData); + + /// + /// Uploads an image for the complete LCD. + /// + /// Bitmap. The key will be painted black if this value is null. + void SetFullScreenImage(KeyBitmap bitmapData); + + /// + /// Uploads a full image for the touchscreen window strip. + /// + /// Bitmap. The key will be painted black if this value is null. + void SetWindowImage(KeyBitmap bitmapData); + + /// + /// Uploads an image into a rectangular region of the touchscreen window. + /// + /// X-coordinate + /// Y-coordinate + /// Image width + /// Image height + /// Bitmap. The key will be painted black if this value is null. + void SetPartialWindowImage(Int32 x_pos, Int32 y_pos, KeyBitmap bitmapData); + + /// + /// Shows the standby logo (full-screen) + /// + void ShowLogo(); + + /// + /// Gets the firmware version. + /// + /// + /// Returns the firmware version + /// or if the device doesn't have a firmware. + /// + String GetFirmwareVersion(); + + /// + /// Gets the serial number. + /// + /// + /// Returns the serial number + /// or if the device doesn't have a serial number. + /// + String GetSerialNumber(); + } +} diff --git a/OpenMacroBoard.SDK/ImageSharpExtensions.cs b/OpenMacroBoard.SDK/ImageSharpExtensions.cs new file mode 100644 index 0000000..06ca1c3 --- /dev/null +++ b/OpenMacroBoard.SDK/ImageSharpExtensions.cs @@ -0,0 +1,88 @@ +using OpenMacroBoard.SDK.Internals; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System; + +namespace OpenMacroBoard.SDK { + /// + /// A collection of extensions for interop with ImageSharp. + /// + public static class ImageSharpExtensions { + /// + /// Converts an Bgr24 image to a byte array containing raw pixel data. + /// + public static byte[] ToBgr24PixelArray(this Image image) { + using var ctx = image.WithBgr24(); + + var data = new byte[image.Width * image.Height * 3]; + ctx.Item.CopyPixelDataTo(data); + return data; + } + + /// + /// Copies an as Bgr24 into a given span. + /// + public static void ToBgr24PixelArray(this Image image, Span targetPixelData) { + using var ctx = image.WithBgr24(); + ctx.Item.CopyPixelDataTo(targetPixelData); + } + + /// + /// Converts an to a byte array containing raw pixel data. + /// + public static byte[] ToBgr24PixelArray(this Image image) { + using var ctx = image.WithBgr24(); + return ctx.Item.ToBgr24PixelArray(); + } + + /// + /// Clones a given image into a + /// with correct alpha blending and a given background color. + /// + public static Image CloneAlphaBlendedBgr24(this Image image, OmbColor backgroundColor) { + var clonedBgr24 = new Image(image.Width, image.Height); + + var color = Color.FromPixel(new Rgb24( + backgroundColor.R, + backgroundColor.G, + backgroundColor.B + )); + + clonedBgr24.Mutate(x => { + x.BackgroundColor(color); + x.DrawImage(image, 1); + }); + + return clonedBgr24; + } + + /// + /// Clones a given image into a + /// with correct alpha blending and black background. + /// + public static Image CloneAlphaBlendedBgr24(this Image image) { + var clonedBgr24 = new Image(image.Width, image.Height); + clonedBgr24.Mutate(x => x.DrawImage(image, 1)); + return clonedBgr24; + } + + /// + /// Creates a context with an . + /// + /// + /// + /// If the image already is an this image will be wrapped inside the + /// . If the image is a different pixel format it will be cloned + /// and transformed into an . + /// + /// + /// Calling on the returned value will never dispose the original + /// but only the implicitly cloned value if any. + /// + /// + public static ConditionalDisposable> WithBgr24(this Image image) { + return ConstrainedContext.For(image, x => x as Image, x => x.CloneAlphaBlendedBgr24()); + } + } +} diff --git a/OpenMacroBoard.SDK/Internals/ConstrainedContext.cs b/OpenMacroBoard.SDK/Internals/ConstrainedContext.cs new file mode 100644 index 0000000..c074cff --- /dev/null +++ b/OpenMacroBoard.SDK/Internals/ConstrainedContext.cs @@ -0,0 +1,38 @@ +using System; + +namespace OpenMacroBoard.SDK.Internals +{ + internal static class ConstrainedContext + { + /// + /// Creates a context which might depend on the provided item or a clone of that item. + /// + /// + /// This is useful in situation where you only want to do an expensive operation + /// for types when needed. The + /// makes sure that elements that depend on the parent (borrow, no copy) will not be disposed + /// but cloned (owned copies) will be disposed. + /// + /// Input type. + /// Output type. + public static ConditionalDisposable For( + TInput item, + Func borrow, + Func ownedCopy + ) + where TInput : class, IDisposable + where TOutput : class, IDisposable + { + var borrowedResult = borrow(item); + + if (borrowedResult is not null) + { + return new ConditionalDisposable(borrowedResult, false); + } + + var ownedResult = ownedCopy(item); + + return new ConditionalDisposable(ownedResult, true); + } + } +} diff --git a/OpenMacroBoard.SDK/Internals/DeviceContextInternal.cs b/OpenMacroBoard.SDK/Internals/DeviceContextInternal.cs new file mode 100644 index 0000000..03b22a3 --- /dev/null +++ b/OpenMacroBoard.SDK/Internals/DeviceContextInternal.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; + +namespace OpenMacroBoard.SDK.Internals +{ + internal sealed class DeviceContextInternal : IDeviceContext + { + private readonly MergedDeviceListener mergedDeviceListener = new(); + private readonly List disposeWithContext = new(); + private readonly List knownDevices = new(); + + public DeviceContextInternal() + { + KnownDevices = knownDevices.AsReadOnly(); + + DeviceStateReports = mergedDeviceListener; + KnownDevices = mergedDeviceListener.KnownDevices; + } + + public IReadOnlyList KnownDevices { get; } + public IObservable DeviceStateReports { get; } + + public void Dispose() + { + foreach (var disposable in disposeWithContext) + { + disposable.Dispose(); + } + } + + public IDeviceContext AddListener(IObservable deviceListener) + { + return AddListener(deviceListener, true); + } + + public IDeviceContext AddListener(IObservable deviceListener, bool disposeWithContext) + { + var subscription = deviceListener.Subscribe(mergedDeviceListener); + this.disposeWithContext.Add(subscription); + + if (disposeWithContext && deviceListener is IDisposable disposableListener) + { + this.disposeWithContext.Add(disposableListener); + } + + return this; + } + + public IDeviceContext AddListener() + where TListener : IObservable, new() + { + var listener = new TListener(); + return AddListener(listener, true); + } + } +} diff --git a/OpenMacroBoard.SDK/Internals/DeviceStateObserver.cs b/OpenMacroBoard.SDK/Internals/DeviceStateObserver.cs new file mode 100644 index 0000000..7feea76 --- /dev/null +++ b/OpenMacroBoard.SDK/Internals/DeviceStateObserver.cs @@ -0,0 +1,34 @@ +using System; + +#nullable enable + +namespace OpenMacroBoard.SDK.Internals +{ + /// + /// Create an observer from an action. + /// + internal class DeviceStateObserver : IObserver + { + private readonly Action eventHandler; + + public DeviceStateObserver(Action eventHandler) + { + this.eventHandler = eventHandler ?? throw new ArgumentNullException(nameof(eventHandler)); + } + + public void OnCompleted() + { + // intentionally left empty. + } + + public void OnError(Exception error) + { + // intentionally left empty. + } + + public void OnNext(DeviceStateReport value) + { + eventHandler(value); + } + } +} diff --git a/OpenMacroBoard.SDK/Internals/KnownDeviceInternal.cs b/OpenMacroBoard.SDK/Internals/KnownDeviceInternal.cs new file mode 100644 index 0000000..42ab965 --- /dev/null +++ b/OpenMacroBoard.SDK/Internals/KnownDeviceInternal.cs @@ -0,0 +1,38 @@ +using System; + +namespace OpenMacroBoard.SDK.Internals +{ + internal sealed class KnownDeviceInternal : IKnownDevice + { + public KnownDeviceInternal(IDeviceReference deviceReference, bool connected) + { + DeviceReference = deviceReference ?? throw new ArgumentNullException(nameof(deviceReference)); + Connected = connected; + } + + public bool Connected { get; set; } + public IDeviceReference DeviceReference { get; } + public IKeyLayout Keys => DeviceReference.Keys; + + public string DeviceName + { + get => DeviceReference.DeviceName; + set => DeviceReference.DeviceName = value; + } + + public override bool Equals(object obj) + { + return DeviceReference.Equals(obj); + } + + public override int GetHashCode() + { + return DeviceReference.GetHashCode(); + } + + public IMacroBoard Open() + { + return DeviceReference.Open(); + } + } +} diff --git a/OpenMacroBoard.SDK/Internals/MergedDeviceListener.cs b/OpenMacroBoard.SDK/Internals/MergedDeviceListener.cs new file mode 100644 index 0000000..5e58d66 --- /dev/null +++ b/OpenMacroBoard.SDK/Internals/MergedDeviceListener.cs @@ -0,0 +1,26 @@ +using System; + +namespace OpenMacroBoard.SDK.Internals +{ + internal sealed class MergedDeviceListener : + DeviceListenerBase, + IObserver + { + public void OnCompleted() + { + // Intentionally empty. + // When a device listener completes (which it shouldn't), we don't care. + } + + public void OnError(Exception error) + { + // Intentionally empty. + // When a device listener reports an error (which it shouldn't), we don't care. + } + + public void OnNext(DeviceStateReport value) + { + Update(value.DeviceReference, value.Connected); + } + } +} diff --git a/OpenMacroBoard.SDK/KeyBitmap.cs b/OpenMacroBoard.SDK/KeyBitmap.cs new file mode 100644 index 0000000..5b3961b --- /dev/null +++ b/OpenMacroBoard.SDK/KeyBitmap.cs @@ -0,0 +1,139 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace OpenMacroBoard.SDK { + /// + /// Represents a bitmap that can be used as key images + /// + public sealed class KeyBitmap : IEquatable, IKeyBitmapDataAccess { + /// + /// Byte order is B-G-R, and pixels are stored left-to-right and top-to-bottom + /// + private readonly Byte[] rawBitmapData; + + private Int32? cachedHashCode = null; + + internal KeyBitmap(Int32 width, Int32 height, Byte[] bitmapData) { + this.Width = width; + this.Height = height; + this.rawBitmapData = bitmapData ?? throw new ArgumentNullException(nameof(bitmapData)); + } + + /// + /// This property can be used to create new KeyBitmaps + /// + /// + /// This property just serves as an anchor point for extension methods + /// to create new objects + /// + public static IKeyBitmapFactory Create { + get; + } + + /// + /// Solid black bitmap + /// + /// + /// If you need a black bitmap (for example to clear keys) use this property for better performance (in theory ^^) + /// + public static KeyBitmap Black { get; } = new(1, 1, []); + + /// + /// Gets the width of the bitmap. + /// + public Int32 Width { + get; + } + + /// + /// Gets the height of the bitmap. + /// + public Int32 Height { + get; + } + + Boolean IKeyBitmapDataAccess.IsEmpty => this.rawBitmapData.Length == 0; + + /// + /// The == operator + /// + public static Boolean operator ==(KeyBitmap a, KeyBitmap b) => Equals(a, b); + + /// + /// The != operator + /// + public static Boolean operator !=(KeyBitmap a, KeyBitmap b) => !Equals(a, b); + + /// + /// Compares the content of two given s + /// + /// KeyBitmap a + /// KeyBitmap b + /// Returns true of the s are equal and false otherwise. + public static Boolean Equals(KeyBitmap a, KeyBitmap b) => + ReferenceEquals(a, b) || ( + a is not null && + b is not null && + a.Width == b.Width && + a.Height == b.Height && ( + ReferenceEquals(a.rawBitmapData, b.rawBitmapData) || ( + a.rawBitmapData is not null && + b.rawBitmapData is not null && + a.rawBitmapData.SequenceEqual(b.rawBitmapData) + ) + ) + ); + + /// + /// Compares the content of this to another KeyBitmap + /// + /// The other + /// True if both bitmaps are equals and false otherwise. + public Boolean Equals(KeyBitmap other) => Equals(this, other); + + /// + /// Compares the content of this to another object + /// + /// The other object + /// Return true if the other object is a and equal to this one. Returns false otherwise. + public override Boolean Equals(Object obj) => Equals(this, obj as KeyBitmap); + + /// + /// Get the hash code for this object. + /// + /// The hash code + public override Int32 GetHashCode() => this.cachedHashCode ??= this.CalculateObjectHash(); + + /// + ReadOnlySpan IKeyBitmapDataAccess.GetData() => new(this.rawBitmapData); + + private Int32 CalculateObjectHash() { + const Int32 initalValue = 17; + const Int32 primeFactor = 23; + const Int32 imageSampleSize = 1000; + + unchecked { + Int32 hash = initalValue; + hash = (hash * primeFactor) + this.Width; + hash = (hash * primeFactor) + this.Height; + + if(this.rawBitmapData.Length == 0) { + return hash; + } + + Int32 stepSize = 1; + if(this.rawBitmapData.Length > imageSampleSize) { + stepSize = this.rawBitmapData.Length / imageSampleSize; + } + + for(Int32 i = 0; i < this.rawBitmapData.Length; i += stepSize) { + hash *= 23; + hash += this.rawBitmapData[i]; + } + + return hash; + } + } + } +} diff --git a/OpenMacroBoard.SDK/KeyBitmapBasicFactoryExtensions.cs b/OpenMacroBoard.SDK/KeyBitmapBasicFactoryExtensions.cs new file mode 100644 index 0000000..0187392 --- /dev/null +++ b/OpenMacroBoard.SDK/KeyBitmapBasicFactoryExtensions.cs @@ -0,0 +1,249 @@ +using SixLabors.ImageSharp; +using System; +using System.IO; +using System.Threading.Tasks; + +namespace OpenMacroBoard.SDK { + /// + /// Collection of factory extension methods for s. + /// + /// + /// You typically don't want to invoke the static methods directly, but instead use them + /// as extension methods by calling .XYZ(); + /// + public static class KeyBitmapBasicFactoryExtensions { + /// + /// Creates a new object. + /// + /// The builder that is used to create the + /// width of the bitmap + /// height of the bitmap + /// raw bitmap data (Bgr24) + /// + /// Either or are smaller than one. + /// + /// + /// Provided and doesn't match the + /// expected array length of . + /// + public static KeyBitmap FromBgr24Array( + this IKeyBitmapFactory keyFactory, + int width, + int height, + ReadOnlySpan bitmapDataBgr24 + ) { + if(width < 1) { + throw new ArgumentOutOfRangeException(nameof(width)); + } + + if(height < 1) { + throw new ArgumentOutOfRangeException(nameof(height)); + } + + var expectedLength = width * height * 3; + + if(bitmapDataBgr24.Length != expectedLength) { + throw new ArgumentException($"{nameof(bitmapDataBgr24)}.Length does not match it's expected size ({nameof(width)} x {nameof(height)} x 3)", nameof(bitmapDataBgr24)); + } + + var data = new byte[expectedLength]; + bitmapDataBgr24.CopyTo(data); + + return new KeyBitmap(width, height, data); + } + + /// + /// Creates a new object. + /// + /// The builder that is used to create the + /// width of the bitmap + /// height of the bitmap + /// raw bitmap data (Bgra32). The alpha channel will be ignored. + /// + /// Either or are smaller than one. + /// + /// + /// Provided and doesn't match the + /// expected array length of . + /// + public static KeyBitmap FromBgra32Array( + this IKeyBitmapFactory keyFactory, + int width, + int height, + ReadOnlySpan bitmapDataBgra32 + ) { + if(width < 1) { + throw new ArgumentOutOfRangeException(nameof(width)); + } + + if(height < 1) { + throw new ArgumentOutOfRangeException(nameof(height)); + } + + var pixelCount = width * height; + var expectedLength = pixelCount * 4; + + if(bitmapDataBgra32.Length != expectedLength) { + throw new ArgumentException($"{nameof(bitmapDataBgra32)}.Length does not match it's expected size ({nameof(width)} x {nameof(height)} x 4)", nameof(bitmapDataBgra32)); + } + + var data = new byte[expectedLength]; + + for(int i = 0; i < pixelCount; i++) { + var i3 = i * 3; + var i4 = i * 4; + + // Copy BGR and ignore alpha channel + data[i3 + 0] = bitmapDataBgra32[i4 + 0]; + data[i3 + 1] = bitmapDataBgra32[i4 + 1]; + data[i3 + 2] = bitmapDataBgra32[i4 + 2]; + } + + return new KeyBitmap(width, height, data); + } + + /// + /// Creates a new object. + /// + /// The builder that is used to create the + /// width of the bitmap + /// height of the bitmap + /// raw bitmap data (Bgra32). The alpha channel will be ignored. + /// + /// Either or are smaller than one. + /// + /// + /// Provided and doesn't match the + /// expected array length of . + /// + public static KeyBitmap FromRgba32Array( + this IKeyBitmapFactory keyFactory, + int width, + int height, + ReadOnlySpan bitmapDataRgba32 + ) { + if(width < 1) { + throw new ArgumentOutOfRangeException(nameof(width)); + } + + if(height < 1) { + throw new ArgumentOutOfRangeException(nameof(height)); + } + + var pixelCount = width * height; + var expectedLength = pixelCount * 4; + + if(bitmapDataRgba32.Length != expectedLength) { + throw new ArgumentException($"{nameof(bitmapDataRgba32)}.Length does not match it's expected size ({nameof(width)} x {nameof(height)} x 4)", nameof(bitmapDataRgba32)); + } + + var data = new byte[expectedLength]; + + for(int i = 0; i < pixelCount; i++) { + var i3 = i * 3; + var i4 = i * 4; + + // Copy BGR and ignore alpha channel + data[i3 + 2] = bitmapDataRgba32[i4 + 0]; + data[i3 + 1] = bitmapDataRgba32[i4 + 1]; + data[i3 + 0] = bitmapDataRgba32[i4 + 2]; + } + + return new KeyBitmap(width, height, data); + } + + /// + /// Creates a new object. + /// + /// The builder that is used to create the + /// width of the bitmap + /// height of the bitmap + /// + /// Either or are smaller than one. + /// + public static KeyBitmap Empty( + this IKeyBitmapFactory keyFactory, + int width, + int height + ) { + if(width < 1) { + throw new ArgumentOutOfRangeException(nameof(width)); + } + + if(height < 1) { + throw new ArgumentOutOfRangeException(nameof(height)); + } + + return new KeyBitmap(width, height, Array.Empty()); + } + + /// + /// Creates a single color (single pixel) with a given color. + /// + /// The builder that is used to create the + /// Red channel. + /// Green channel. + /// Blue channel. + public static KeyBitmap FromRgb(this IKeyBitmapFactory keyFactory, byte r, byte g, byte b) { + // If everything is 0 (black) take a shortcut ;-) + if(r == 0 && g == 0 && b == 0) { + return KeyBitmap.Black; + } + + var buffer = new byte[3] { b, g, r }; + return KeyBitmap.Create.FromBgr24Array(1, 1, buffer); + } + + /// + /// Creates a single color (single pixel) with a given color. + /// + /// The builder that is used to create the + /// The color. + public static KeyBitmap FromColor(this IKeyBitmapFactory keyFactory, OmbColor color) { + return keyFactory.FromRgb(color.R, color.G, color.B); + } + + /// + /// Create a bitmap from an encoded given image stream. + /// + public static KeyBitmap FromStream(this IKeyBitmapFactory builder, Stream bitmapStream) { + return builder.FromImageSharpImage(Image.Load(bitmapStream)); + } + + /// + /// Create a bitmap from an encoded given image stream. + /// + public static async Task FromStreamAsync(this IKeyBitmapFactory builder, Stream bitmapStream) { + return builder.FromImageSharpImage(await Image.LoadAsync(bitmapStream)); + } + + /// + /// Create a bitmap from an encoded given image file. + /// + public static KeyBitmap FromFile(this IKeyBitmapFactory builder, string bitmapFile) { + return builder.FromImageSharpImage(Image.Load(bitmapFile)); + } + + /// + /// Create a bitmap from an encoded given image file. + /// + public static async Task FromFileAsync(this IKeyBitmapFactory builder, string bitmapFile) { + return builder.FromImageSharpImage(await Image.LoadAsync(bitmapFile)); + } + + /// + /// Creates a from a given . + /// + /// The provided bitmap is null. + /// The pixel format of the image is not supported. + public static KeyBitmap FromImageSharpImage(this IKeyBitmapFactory keyFactory, Image image) { + if(image is null) { + throw new ArgumentNullException(nameof(image)); + } + + using var ctx = image.WithBgr24(); + var pixelData = ctx.Item.ToBgr24PixelArray(); + return KeyBitmap.Create.FromBgr24Array(image.Width, image.Height, pixelData); + } + } +} diff --git a/OpenMacroBoard.SDK/KeyBitmapDataAccessExtensions.cs b/OpenMacroBoard.SDK/KeyBitmapDataAccessExtensions.cs new file mode 100644 index 0000000..047738f --- /dev/null +++ b/OpenMacroBoard.SDK/KeyBitmapDataAccessExtensions.cs @@ -0,0 +1,24 @@ +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; + +namespace OpenMacroBoard.SDK +{ + /// + /// Extension methods for . + /// + public static class KeyBitmapDataAccessExtensions + { + /// + /// Creates a new for this . + /// + /// + /// + /// Keep in mind that this operation allocates and creates a copy under the hood. + /// + /// + public static Image ToImage(this IKeyBitmapDataAccess dataAccess) + { + return Image.LoadPixelData(dataAccess.GetData(), dataAccess.Width, dataAccess.Height); + } + } +} diff --git a/OpenMacroBoard.SDK/KeyEventArgs.cs b/OpenMacroBoard.SDK/KeyEventArgs.cs new file mode 100644 index 0000000..0e7eeb8 --- /dev/null +++ b/OpenMacroBoard.SDK/KeyEventArgs.cs @@ -0,0 +1,107 @@ +using System; + +namespace OpenMacroBoard.SDK { + /// + /// An event argument that is used to communicate key state changes. + /// + public class KeyEventArgs : EventArgs { + /// + /// Initializes a new instance of the class. + /// + /// The index of the key that was pressed or released. + /// A flag that determines if the key was pressed or released. + public KeyEventArgs(Int32 key, Boolean isDown) { + this.Key = key; + this.IsDown = isDown; + } + + /// + /// The index of the key that was pressed or released. + /// + public Int32 Key { + get; + } + + /// + /// A flag that determines if the key was pressed or released. + /// + public Boolean IsDown { + get; + } + } + + public class KeyPressEvent : EventArgs { + public KeyPressEvent(Int32 key) => this.Key = key; + + public Int32 Key { + get; + } + + public override String ToString() => "Key: " + this.Key; + } + + public class TouchTipEvent : EventArgs { + public TouchTipEvent(UInt16 x, UInt16 y) { + this.XCoord = x; + this.YCoord = y; + } + + public UInt16 XCoord { + get; + } + public UInt16 YCoord { + get; + } + public override String ToString() => "X-Coord: " + this.XCoord + ", Y-Coord: " + this.YCoord; + } + + public class TouchFlipEvent : EventArgs { + public TouchFlipEvent(UInt16 fx, UInt16 fy, UInt16 tx, UInt16 ty) { + this.FromXCoord = fx; + this.FromYCoord = fy; + this.ToXCoord = tx; + this.ToYCoord = ty; + } + + public UInt16 FromXCoord { + get; + } + public UInt16 FromYCoord { + get; + } + public UInt16 ToXCoord { + get; + } + public UInt16 ToYCoord { + get; + } + + public override String ToString() => "From X-Coord: " + this.FromXCoord + ", Y-Coord: " + this.FromYCoord+ ", To X-Coord: " + this.ToXCoord + ", Y-Coord: " + this.ToYCoord; + } + + public class KnobPressEvent : EventArgs { + public KnobPressEvent(Int32 key) => this.Encoder = key; + + public Int32 Encoder { + get; + } + + public override String ToString() => "Encoder: " + this.Encoder; + } + + public class KnobTurnEvent : EventArgs { + public KnobTurnEvent(Int32 key, SByte steps) { + this.Encoder = key; + this.Steps = steps; + } + + public Int32 Encoder { + get; + } + public SByte Steps { + get; + } + + public override String ToString() => "Encoder: " + this.Encoder + ", Steps: " + this.Steps; + } +} diff --git a/OpenMacroBoard.SDK/KeyLayoutAreaHelperExtensions.cs b/OpenMacroBoard.SDK/KeyLayoutAreaHelperExtensions.cs new file mode 100644 index 0000000..d5500a9 --- /dev/null +++ b/OpenMacroBoard.SDK/KeyLayoutAreaHelperExtensions.cs @@ -0,0 +1,33 @@ +using System; + +namespace OpenMacroBoard.SDK.Helper +{ + /// + /// Extensions from s. + /// + public static class KeyLayoutAreaHelperExtensions + { + /// + /// Calculates a which spans all keys from a given . + /// + /// The key layout this area is calculated for. + /// Returns a rectangle that spans all keys. + public static OmbRectangle GetFullArea(this IKeyLayout keyLayout) + { + var minX = 0; + var minY = 0; + var maxX = 0; + var maxY = 0; + + foreach (var keyRect in keyLayout) + { + minX = Math.Min(minX, keyRect.Left); + minY = Math.Min(minY, keyRect.Top); + maxX = Math.Max(maxX, keyRect.Right); + maxY = Math.Max(maxY, keyRect.Bottom); + } + + return new OmbRectangle(minX, minY, maxX - minX, maxY - minY); + } + } +} diff --git a/OpenMacroBoard.SDK/MacroBoardAdapter.cs b/OpenMacroBoard.SDK/MacroBoardAdapter.cs new file mode 100644 index 0000000..68b4590 --- /dev/null +++ b/OpenMacroBoard.SDK/MacroBoardAdapter.cs @@ -0,0 +1,144 @@ +using System; +using System.Reflection.PortableExecutable; + +namespace OpenMacroBoard.SDK { + /// + /// Wraps an and allows for hooks to implement "middle-ware" features. + /// + public abstract class MacroBoardAdapter : IMacroBoard { + private readonly IMacroBoard macroBoard; + private readonly Boolean leaveOpen; + + private Boolean disposed = false; + + /// + /// Initializes a new instance of the class. + /// + /// + /// When this instance is disposed, the underlying board is disposed as well. + /// + /// The macroBoard that is wrapped. + protected MacroBoardAdapter(IMacroBoard macroBoard) : this(macroBoard, false) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The macroBoard that is wrapped. + /// When true, the underlying macroBoard will not be disposed with this instance. + protected MacroBoardAdapter(IMacroBoard macroBoard, Boolean leaveOpen) { + this.macroBoard = macroBoard ?? throw new ArgumentNullException(nameof(macroBoard)); + this.leaveOpen = leaveOpen; + + macroBoard.ConnectionStateChanged += this.OnConnectionStateChanged; + //macroBoard.KeyStateChanged += this.OnKeyStateChanged; + macroBoard.ButtonPressed += this.MacroBoard_ButtonPressed; + macroBoard.ButtonReleased += this.MacroBoard_ButtonReleased; + macroBoard.TouchbarTouched += this.MacroBoard_TouchbarTouched; + macroBoard.TouchbarPressed += this.MacroBoard_TouchbarPressed; + macroBoard.TouchFlipEvent += this.MacroBoard_TouchFlipEvent; + macroBoard.EncoderPressed += this.MacroBoard_EncoderPressed; + macroBoard.EncoderReleased += this.MacroBoard_EncoderReleased; + macroBoard.EncoderTurn += this.MacroBoard_EncoderTurn; + } + + + + /// + /// Finalizes an instance of the class. + /// + ~MacroBoardAdapter() { + this.Dispose(false); + } + + /// + //public event EventHandler KeyStateChanged; + + public event EventHandler ButtonPressed; + public event EventHandler ButtonReleased; + public event EventHandler TouchbarTouched; + public event EventHandler TouchbarPressed; + public event EventHandler TouchFlipEvent; + public event EventHandler EncoderPressed; + public event EventHandler EncoderReleased; + public event EventHandler EncoderTurn; + + /// + public event EventHandler ConnectionStateChanged; + + /// + public virtual IKeyLayout Keys => this.macroBoard.Keys; + + /// + public virtual Boolean IsConnected => this.macroBoard.IsConnected; + + /// + public void Dispose() { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + /// + public virtual void SetBrightness(Byte percent) => this.macroBoard.SetBrightness(percent); + + /// + public virtual void SetButtonImage(Int32 keyId, KeyBitmap bitmapData) => this.macroBoard.SetButtonImage(keyId, bitmapData); + + /// + public void SetFullScreenImage(KeyBitmap bitmapData) => throw new NotImplementedException(); + + /// + public virtual void SetWindowImage(KeyBitmap bitmapData) => throw new NotImplementedException(); + + /// + public virtual void SetPartialWindowImage(Int32 x_pos, Int32 y_pos, KeyBitmap bitmapData) => throw new NotImplementedException(); + + /// + public virtual void ShowLogo() => this.macroBoard.ShowLogo(); + + /// + public String GetFirmwareVersion() => this.macroBoard.GetFirmwareVersion(); + + /// + public String GetSerialNumber() => this.macroBoard.GetFirmwareVersion(); + + // + // Virtual KeyStateChanged event handler. + // + // The sender of the original event. + // The event arguments. + //protected virtual void OnKeyStateChanged(Object sender, KeyEventArgs e) => KeyStateChanged?.Invoke(this, e); + + private void MacroBoard_EncoderTurn(Object sender, KnobTurnEvent e) => this.EncoderTurn?.Invoke(this, e); + private void MacroBoard_EncoderReleased(Object sender, KnobPressEvent e) => this.EncoderReleased?.Invoke(this, e); + private void MacroBoard_EncoderPressed(Object sender, KnobPressEvent e) => this.EncoderPressed?.Invoke(this, e); + private void MacroBoard_TouchFlipEvent(Object sender, TouchFlipEvent e) => this.TouchFlipEvent?.Invoke(this, e); + private void MacroBoard_TouchbarPressed(Object sender, TouchTipEvent e) => this.TouchbarPressed?.Invoke(this, e); + private void MacroBoard_TouchbarTouched(Object sender, TouchTipEvent e) => this.TouchbarTouched?.Invoke(this, e); + private void MacroBoard_ButtonReleased(Object sender, KeyPressEvent e) => this.ButtonReleased?.Invoke(this, e); + private void MacroBoard_ButtonPressed(Object sender, KeyPressEvent e) => this.ButtonPressed?.Invoke(this, e); + + /// + /// Virtual ConnectionStateChanged event handler. + /// + /// The sender of the original event. + /// The event arguments. + protected virtual void OnConnectionStateChanged(Object sender, ConnectionEventArgs e) => ConnectionStateChanged?.Invoke(this, e); + + /// + /// Protected implementation of Dispose pattern. + /// + /// True when called from and false when called from the finalizer. + protected virtual void Dispose(Boolean disposing) { + if(this.disposed) { + return; + } + + if(disposing && !this.leaveOpen) { + this.macroBoard?.Dispose(); + } + + this.disposed = true; + } + } +} diff --git a/OpenMacroBoard.SDK/MacroBoardFeatureExtensions.cs b/OpenMacroBoard.SDK/MacroBoardFeatureExtensions.cs new file mode 100644 index 0000000..7a7b98b --- /dev/null +++ b/OpenMacroBoard.SDK/MacroBoardFeatureExtensions.cs @@ -0,0 +1,47 @@ +using System; + +namespace OpenMacroBoard.SDK +{ + /// + /// Extensions for enrichment ;-) + /// + public static class MacroBoardFeatureExtensions + { + /// + /// Wraps an with an button press effect adapter. + /// + /// The board that should be wrapped. + /// The configuration that should be used. Changes to the configuration later also takes effect. + /// Returns a new board that implements the button press effect. + /// The provided board is null. + public static IMacroBoard WithButtonPressEffect(this IMacroBoard macroBoard, ButtonPressEffectConfig config = null) + { + if (macroBoard is null) + { + throw new ArgumentNullException(nameof(macroBoard)); + } + + return new ButtonPressEffectAdapter(macroBoard, config); + } + + /// + /// Wraps an with a disconnect replay adapter. + /// + /// The board that should be wrapped. + /// Returns a new board that implements the replay feature. + /// + /// This adapter makes sure, that if a device is disconnected that previously set properties like + /// images and brightness are replayed if the device is connected again. + /// + /// The provided board is null. + public static IMacroBoard WithDisconnectReplay(this IMacroBoard macroBoard) + { + if (macroBoard is null) + { + throw new ArgumentNullException(nameof(macroBoard)); + } + + return new DisconnectReplayAdapter(macroBoard); + } + } +} diff --git a/OpenMacroBoard.SDK/OpenMacroBoard.SDK.csproj b/OpenMacroBoard.SDK/OpenMacroBoard.SDK.csproj new file mode 100644 index 0000000..9a87189 --- /dev/null +++ b/OpenMacroBoard.SDK/OpenMacroBoard.SDK.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + + + + + 7.0.0 + OpenMacroBoard.SDK + Abstraction for macro boards (with LCD keys) + https://github.com/OpenMacroBoard/OpenMacroBoard.SDK + + + + + + + + + + diff --git a/OpenMacroBoard.SDK/SetKeyExtensions.cs b/OpenMacroBoard.SDK/SetKeyExtensions.cs new file mode 100644 index 0000000..a5567a5 --- /dev/null +++ b/OpenMacroBoard.SDK/SetKeyExtensions.cs @@ -0,0 +1,49 @@ +using System; + +namespace OpenMacroBoard.SDK +{ + /// + /// A bunch of extensions to clear all keys, or set a single to all keys. + /// + public static class SetKeyExtensions + { + /// + /// Sets a background image for all keys + /// + /// The provided board is null. + public static void SetKeyBitmap(this IMacroBoard board, KeyBitmap bitmap) + { + if (board is null) + { + throw new ArgumentNullException(nameof(board)); + } + + for (var i = 0; i < board.Keys.Count; i++) + { + board.SetButtonImage(i, bitmap); + } + } + + /// + /// Sets background to black for a given key + /// + /// The provided board is null. + public static void ClearKey(this IMacroBoard board, int keyId) + { + if (board is null) + { + throw new ArgumentNullException(nameof(board)); + } + + board.SetButtonImage(keyId, KeyBitmap.Black); + } + + /// + /// Sets background to black for all given keys + /// + public static void ClearKeys(this IMacroBoard board) + { + board.SetKeyBitmap(KeyBitmap.Black); + } + } +} diff --git a/OpenMacroBoard.SDK/Utils/LinqExtensions.cs b/OpenMacroBoard.SDK/Utils/LinqExtensions.cs new file mode 100644 index 0000000..71f5ba9 --- /dev/null +++ b/OpenMacroBoard.SDK/Utils/LinqExtensions.cs @@ -0,0 +1,42 @@ +using System; +using System.Collections.Generic; + +namespace OpenMacroBoard.SDK.Utils +{ + /// + /// Some LINQ extensions we use internally. + /// + public static class LinqExtensions + { + /// + /// + /// A useful combination of LINQs Select() and Where(). + /// Prevents invalid object states between those two calls by combining them. + /// + /// + /// If the returns (true, value) the value will be yielded. + /// If the returns (false, value) it will not be present in + /// the ouput enumerble. + /// + /// + /// Input type. + /// Output type. + /// Incomming source enumerable + /// Filter/Selector + public static IEnumerable SelectWhere( + this IEnumerable source, + Func selector + ) + { + foreach (var item in source) + { + var (success, output) = selector(item); + + if (success) + { + yield return output; + } + } + } + } +} diff --git a/OpenMacroBoard.SDK/icon.png b/OpenMacroBoard.SDK/icon.png new file mode 100644 index 0000000..8979d21 Binary files /dev/null and b/OpenMacroBoard.SDK/icon.png differ diff --git a/StreamDeckSharp.sln b/StreamDeckSharp.sln new file mode 100644 index 0000000..82909fc --- /dev/null +++ b/StreamDeckSharp.sln @@ -0,0 +1,40 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.5.11716.220 stable +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StreamDeckSharp", "StreamDeckSharp\StreamDeckSharp.csproj", "{1C90BA5B-09A9-75A5-5FC4-59C154B641D1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OpenMacroBoard.SDK", "OpenMacroBoard.SDK\OpenMacroBoard.SDK.csproj", "{3EF6B61C-551F-335B-8E30-F53E832F037A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ImageSharp", "..\ImageSharp\ImageSharp\ImageSharp.csproj", "{144E1462-962C-E752-C3F9-2ECD13D95F6E}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {1C90BA5B-09A9-75A5-5FC4-59C154B641D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {1C90BA5B-09A9-75A5-5FC4-59C154B641D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {1C90BA5B-09A9-75A5-5FC4-59C154B641D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {1C90BA5B-09A9-75A5-5FC4-59C154B641D1}.Release|Any CPU.Build.0 = Release|Any CPU + {3EF6B61C-551F-335B-8E30-F53E832F037A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3EF6B61C-551F-335B-8E30-F53E832F037A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3EF6B61C-551F-335B-8E30-F53E832F037A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3EF6B61C-551F-335B-8E30-F53E832F037A}.Release|Any CPU.Build.0 = Release|Any CPU + {144E1462-962C-E752-C3F9-2ECD13D95F6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {144E1462-962C-E752-C3F9-2ECD13D95F6E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {144E1462-962C-E752-C3F9-2ECD13D95F6E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {144E1462-962C-E752-C3F9-2ECD13D95F6E}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {897E8FA3-2A42-46E7-B2A3-4213DA35864D} + EndGlobalSection + GlobalSection(SharedMSBuildProjectFiles) = preSolution + ..\ImageSharp\SharedInfrastructure.projitems*{144e1462-962c-e752-c3f9-2ecd13d95f6e}*SharedItemsImports = 5 + EndGlobalSection +EndGlobal diff --git a/StreamDeckSharp/AssemblyInfo.cs b/StreamDeckSharp/AssemblyInfo.cs new file mode 100644 index 0000000..353fbcc --- /dev/null +++ b/StreamDeckSharp/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("StreamDeckSharp.Tests")] diff --git a/StreamDeckSharp/Exceptions/StreamDeckException.cs b/StreamDeckSharp/Exceptions/StreamDeckException.cs new file mode 100644 index 0000000..cd3efa8 --- /dev/null +++ b/StreamDeckSharp/Exceptions/StreamDeckException.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace StreamDeckSharp.Exceptions { + /// + /// Base class for all StreamDeckSharp Exceptions + /// + [Serializable] + [ExcludeFromCodeCoverage] + public abstract class StreamDeckException : Exception { + /// + /// Initializes a new instance of the class. + /// + protected StreamDeckException() { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + protected StreamDeckException(String message) + : base(message) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + /// + /// The exception that is the cause of the current exception, or a null reference + /// if no inner exception is specified. + /// + protected StreamDeckException(String message, Exception innerException) + : base(message, innerException) { + } + } +} diff --git a/StreamDeckSharp/Exceptions/StreamDeckNotFoundException.cs b/StreamDeckSharp/Exceptions/StreamDeckNotFoundException.cs new file mode 100644 index 0000000..3ff5335 --- /dev/null +++ b/StreamDeckSharp/Exceptions/StreamDeckNotFoundException.cs @@ -0,0 +1,39 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace StreamDeckSharp.Exceptions { + /// + /// Is thrown if no device could be found + /// + [Serializable] + [ExcludeFromCodeCoverage] + public class StreamDeckNotFoundException + : StreamDeckException { + /// + /// Initializes a new instance of the class. + /// + internal StreamDeckNotFoundException() + : base("Stream Deck not found.") { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + internal StreamDeckNotFoundException(String message) + : base(message) { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + /// + /// The exception that is the cause of the current exception, or a null reference + /// if no inner exception is specified. + /// + internal StreamDeckNotFoundException(String message, Exception innerException) + : base(message, innerException) { + } + } +} diff --git a/StreamDeckSharp/Hardware.cs b/StreamDeckSharp/Hardware.cs new file mode 100644 index 0000000..b480aa2 --- /dev/null +++ b/StreamDeckSharp/Hardware.cs @@ -0,0 +1,215 @@ +using OpenMacroBoard.SDK; +using StreamDeckSharp.Internals; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Linq; + +using static StreamDeckSharp.UsbConstants; + +#pragma warning disable AV1710 // Member name includes the name of its containing type + +namespace StreamDeckSharp { + /// + /// Details about different StreamDeck Hardware + /// + public static class Hardware { + private static readonly ConcurrentDictionary RegisteredHardware = new(); + + static Hardware() { + // .-------------. + // | Stream Deck | + // '-------------' + + GridKeyLayout streamDeckKeys = new GridKeyLayout(5, 3, 72, 30); + + StreamDeck = + RegisterNewHardwareInternal( + "Stream Deck", + streamDeckKeys, + new HidComDriverStreamDeck() { + // Limit of 3'200'000 bytes/s (~3.0 MiB/s) + // because without that limit glitches will happen on fast writes. + BytesPerSecondLimit = 3_200_000, + }, + ElgatoUsbId(0x0060) + ); + + GridKeyLayout streamDeckKeysNew = new GridKeyLayout(5, 3, 72, 32); + + StreamDeckMK2 = + RegisterNewHardwareInternal( + "Stream Deck MK.2", + streamDeckKeysNew, + new HidComDriverStreamDeckJpeg(72, 0, 0, 480, 272) { + // Limit of 1'500'000 bytes/s (~1.5 MB/s), + // because ImageGlitchTest.Rainbow has glitches with higher speeds + BytesPerSecondLimit = 1_500_000, + }, + ElgatoUsbId(0x0080) + ); + + StreamDeckRev2 = + RegisterNewHardwareInternal( + "Stream Deck Rev2", + streamDeckKeysNew, + new HidComDriverStreamDeckJpeg(72, 0, 0, 480, 272) { + // Limit of 3'200'000 bytes/s (~3.0 MiB/s) just to be safe, + // because I don't own a StreamDeck Rev2 to test it. + BytesPerSecondLimit = 3_200_000, + }, + ElgatoUsbId(0x006d) + ); + + // .----------------. + // | Stream Deck XL | + // '----------------' + + StreamDeckXL = + RegisterNewHardwareInternal( + "Stream Deck XL", + new GridKeyLayout(8, 4, 96, 38), + new HidComDriverStreamDeckJpeg(96, 0, 0, 1024, 600), + ElgatoUsbId(0x006c), + ElgatoUsbId(0x008f), + ElgatoUsbId(0x00ba) + ); + + // .------------------. + // | Stream Deck Mini | + // '------------------' + + StreamDeckMini = + RegisterNewHardwareInternal( + "Stream Deck Mini", + new GridKeyLayout(3, 2, 80, 32), + new HidComDriverStreamDeckMini(80), + ElgatoUsbId(0x0063), + ElgatoUsbId(0x0090) + ); + + // .------------------. + // | Stream Deck XL + | + // '------------------' + + StreamDeckXLPlus = + RegisterNewHardwareInternal( + "Stream Deck XL +", + new GridKeyLayout(9, 4, 112, 32), + new HidComDriverStreamDeckJpeg(112, 1200, 100, 1280, 800), + ElgatoUsbId(0x00c6) + ); + } + + /// + /// Details about the classic Stream Deck + /// + public static IUsbHidHardware StreamDeck { + get; + } + + /// + /// Details about the updated Stream Deck MK.2 + /// + public static IUsbHidHardware StreamDeckMK2 { + get; + } + + /// + /// Details about the classic Stream Deck Rev 2 + /// + public static IUsbHidHardware StreamDeckRev2 { + get; + } + + /// + /// Details about the Stream Deck XL + /// + public static IUsbHidHardware StreamDeckXL { + get; + } + + /// + /// Details about the Stream Deck Mini + /// + public static IUsbHidHardware StreamDeckMini { + get; + } + + /// + /// Details about the Stream Deck XL + + /// + public static IUsbHidHardware StreamDeckXLPlus { + get; + } + + /// + /// This method registers a new (currently unknown to this library) hardware driver. + /// + /// + /// + /// This method can be used if a new stream deck hardware is released to the market and + /// the library currently doesn't have support for that new device. In the past a new device + /// was often pretty similar to an existing device so with this method a tech-savvy person + /// can register that new device. + /// + /// + /// This feature is a bit "low-level", just take a look at the source code + /// if you are not sure what to do. + /// + /// + /// The USB vendor and product ID. + /// A human readable name of the device. + /// The key layout of the device. + /// The code that is used to communicate to the device. + /// + /// Returns a description of the device that can be used to open that device with + /// or + /// . + /// + public static IUsbHidHardware RegisterNewHardware( + UsbVendorProductPair usbId, + string deviceName, + GridKeyLayout keyLayout, + IStreamDeckHidComDriver driver + ) { + return RegisterNewHardwareInternal( + deviceName, + keyLayout, + driver, + usbId + ); + } + + internal static UsbHardwareIdAndDriver RegisterNewHardwareInternal( + string deviceName, + GridKeyLayout keyLayout, + IStreamDeckHidComDriver driver, + params UsbVendorProductPair[] usbIds + ) { + UsbHardwareIdAndDriver internalReference = new UsbHardwareIdAndDriver( + usbIds.ToList(), + deviceName, + keyLayout, + driver + ); + + foreach(UsbVendorProductPair id in usbIds) { + RegisteredHardware.AddOrUpdate(id, internalReference, (_, _) => internalReference); + } + + return internalReference; + } + + internal static IEnumerable GetInternalStreamDeckHardwareInfos() { + return RegisteredHardware.Values.Distinct().ToList(); + } + + internal static UsbHardwareIdAndDriver GetInternalHardwareInfos(UsbVendorProductPair usbId) { + if(RegisteredHardware.TryGetValue(usbId, out UsbHardwareIdAndDriver hardwareInfo)) { + return hardwareInfo; + } + + return null; + } + } +} diff --git a/StreamDeckSharp/IHardware.cs b/StreamDeckSharp/IHardware.cs new file mode 100644 index 0000000..834cff4 --- /dev/null +++ b/StreamDeckSharp/IHardware.cs @@ -0,0 +1,22 @@ +using OpenMacroBoard.SDK; + +namespace StreamDeckSharp { + /// + /// A compact collection of hardware specific information about a device. + /// + public interface IHardware { + /// + /// Key layout information + /// + GridKeyLayout Keys { + get; + } + + /// + /// Name of the device + /// + string DeviceName { + get; + } + } +} diff --git a/StreamDeckSharp/IUsbHidHardware.cs b/StreamDeckSharp/IUsbHidHardware.cs new file mode 100644 index 0000000..70e19ea --- /dev/null +++ b/StreamDeckSharp/IUsbHidHardware.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace StreamDeckSharp { + /// + /// USB HID specific hardware information + /// + public interface IUsbHidHardware : IHardware { + /// + /// Unique identifier for USB device. Vendor and product ID pair. + /// + IReadOnlyList UsbIds { + get; + } + } +} diff --git a/StreamDeckSharp/Internals/BasicHidClient.cs b/StreamDeckSharp/Internals/BasicHidClient.cs new file mode 100644 index 0000000..6279d92 --- /dev/null +++ b/StreamDeckSharp/Internals/BasicHidClient.cs @@ -0,0 +1,219 @@ +using OpenMacroBoard.SDK; +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading; + +namespace StreamDeckSharp.Internals { + internal class BasicHidClient : IMacroBoard { + private readonly Byte[] keyStates; + private readonly Lock disposeLock = new(); + + private Dictionary buttonstates = []; + private Dictionary encoderstates = []; + + public BasicHidClient(IStreamDeckHid deckHid, IKeyLayout keys, IStreamDeckHidComDriver hidComDriver) { + this.DeckHid = deckHid; + this.Keys = keys; + + deckHid.ConnectionStateChanged += (_, e) => ConnectionStateChanged?.Invoke(this, e); + deckHid.ReportReceived += this.DeckHid_ReportReceived; + + this.HidComDriver = hidComDriver; + this.Buffer = new Byte[deckHid.OutputReportLength]; + this.keyStates = new Byte[this.Keys.Count]; + } + + //public event EventHandler KeyStateChanged; + + public event EventHandler ConnectionStateChanged; + public event EventHandler ButtonPressed; + public event EventHandler ButtonReleased; + public event EventHandler TouchbarTouched; + public event EventHandler TouchbarPressed; + public event EventHandler TouchFlipEvent; + public event EventHandler EncoderPressed; + public event EventHandler EncoderReleased; + public event EventHandler EncoderTurn; + + public IKeyLayout Keys { + get; + } + public Boolean IsDisposed { + get; private set; + } + public Boolean IsConnected => this.DeckHid.IsConnected; + + protected IStreamDeckHid DeckHid { + get; + } + protected IStreamDeckHidComDriver HidComDriver { + get; + } + protected Byte[] Buffer { + get; + } + + public void Dispose() { + this.Dispose(true); + GC.SuppressFinalize(this); + } + + public String GetFirmwareVersion() => this.ReadFeatureString(this.HidComDriver.FirmwareVersionFeatureId, this.HidComDriver.FirmwareVersionReportSkip); + + public String GetSerialNumber() => this.ReadFeatureString(this.HidComDriver.SerialNumberFeatureId, this.HidComDriver.SerialNumberReportSkip); + + public void SetBrightness(Byte percent) { + this.ThrowIfAlreadyDisposed(); + _ = this.DeckHid.WriteFeature(this.HidComDriver.GetBrightnessMessage(percent)); + } + + public virtual void SetButtonImage(Int32 keyId, KeyBitmap bitmapData) { + keyId = this.HidComDriver.ExtKeyIdToHardwareKeyId(keyId); + Byte[] payload = this.HidComDriver.GenerateButtonImage(bitmapData); + IEnumerable reports = OutputReportSplitter.ButtonImage(payload, this.Buffer, this.HidComDriver.ReportSize, this.HidComDriver.HeaderSizeButton, keyId, this.HidComDriver.PrepareHeaderOutButtonImage); + foreach(Byte[] report in reports) { + _ = this.DeckHid.WriteReport(report); + } + } + + public virtual void SetFullScreenImage(KeyBitmap bitmapData) { + Byte[] payload = this.HidComDriver.GenerateFullScreenImage(bitmapData); + + IEnumerable reports = OutputReportSplitter.FullScreenImage(payload, this.Buffer, this.HidComDriver.ReportSize, this.HidComDriver.HeaderSizeFullScreenImage, this.HidComDriver.PrepareHeaderOutFullScreenImage); + + foreach(Byte[] report in reports) { + _ = this.DeckHid.WriteReport(report); + } + } + + public virtual void SetWindowImage(KeyBitmap bitmapData) { + Byte[] payload = this.HidComDriver.GenerateWindowImage(bitmapData); + + IEnumerable reports = OutputReportSplitter.WindowImage(payload, this.Buffer, this.HidComDriver.ReportSize, this.HidComDriver.HeaderSizeWindowImage, this.HidComDriver.PrepareHeaderOutWindowImage); + + foreach(Byte[] report in reports) { + _ = this.DeckHid.WriteReport(report); + } + } + + public virtual void SetPartialWindowImage(Int32 x_pos, Int32 y_pos, KeyBitmap bitmapData) { + Byte[] payload = this.HidComDriver.GeneratePartialWindowImage(bitmapData); + + IEnumerable reports = OutputReportSplitter.PartialWindowImage(payload, this.Buffer, this.HidComDriver.ReportSize, this.HidComDriver.HeaderSizePartialWindowImage, x_pos, y_pos, bitmapData.Width, bitmapData.Height, this.HidComDriver.PrepareHeaderOutPartialWindowImage); + + foreach(Byte[] report in reports) { + _ = this.DeckHid.WriteReport(report); + } + } + + public void ShowLogo() { + this.ThrowIfAlreadyDisposed(); + this.ShowLogoWithoutDisposeVerification(); + } + + protected virtual void Shutdown() { + } + + protected virtual void Dispose(Boolean disposing) { + if(disposing) { + lock(this.disposeLock) { + if(this.IsDisposed) { + return; + } + + this.IsDisposed = true; + } + + this.Shutdown(); + + // Sleep to let the stream deck catch up. + // Without this Sleep() the stream deck might set a key image after the logo was shown. + // I've no idea why it's sometimes executed out of order even though the write is synchronized. + Thread.Sleep(50); + + this.ShowLogoWithoutDisposeVerification(); + + this.DeckHid.Dispose(); + } + } + + protected void ThrowIfAlreadyDisposed() => ObjectDisposedException.ThrowIf(this.IsDisposed, nameof(BasicHidClient)); + + private String ReadFeatureString(Byte featureId, Int32 skipBytes) => + !this.DeckHid.ReadFeatureData(featureId, out Byte[] featureData) + ? null + : Encoding.UTF8.GetString(featureData, skipBytes, featureData.Length - skipBytes).Trim('\0'); + + private void DeckHid_ReportReceived(Object sender, IReportsIn e) { + if(e is ButtonPressEvent bp) { + foreach(KeyValuePair item in bp.ButtonStates) { + if(!this.buttonstates.ContainsKey(item.Key)) { + this.buttonstates.Add(item.Key, item.Value); + if(item.Value) { + this.ButtonPressed?.Invoke(this, new KeyPressEvent(item.Key)); + } + } else { + if(item.Value && !this.buttonstates[item.Key]) { + this.buttonstates[item.Key] = item.Value; + this.ButtonPressed?.Invoke(this, new KeyPressEvent(item.Key)); + } else if(!item.Value && this.buttonstates[item.Key]) { + this.buttonstates[item.Key] = item.Value; + this.ButtonReleased?.Invoke(this, new KeyPressEvent(item.Key)); + } + } + } + } + if(e is TouchTapEvent tt) { + this.TouchbarTouched?.Invoke(this, new TouchTipEvent(tt.XCoord, tt.YCoord)); + } + if(e is TouchPressEvent tp) { + this.TouchbarPressed?.Invoke(this, new TouchTipEvent(tp.XCoord, tp.YCoord)); + } + if(e is TouchFlickEvent tf) { + this.TouchFlipEvent?.Invoke(this, new TouchFlipEvent(tf.XCoordStart, tf.YCoordStart, tf.XCoordStop, tf.YCoordStop)); + } + if(e is EncoderPressEvent ep) { + foreach(KeyValuePair item in ep.EncoderStates) { + if(!this.encoderstates.ContainsKey(item.Key)) { + this.encoderstates.Add(item.Key, item.Value); + if(item.Value) { + this.EncoderPressed?.Invoke(this, new KnobPressEvent(item.Key)); + } + } else { + if(item.Value && !this.encoderstates[item.Key]) { + this.encoderstates[item.Key] = item.Value; + this.EncoderPressed?.Invoke(this, new KnobPressEvent(item.Key)); + } else if(!item.Value && this.encoderstates[item.Key]) { + this.encoderstates[item.Key] = item.Value; + this.EncoderReleased?.Invoke(this, new KnobPressEvent(item.Key)); + } + } + } + } + if(e is EncoderRotateEvent er) { + foreach(KeyValuePair item in er.EncoderStates) { + if(item.Value != 0) { + this.EncoderTurn?.Invoke(this, new KnobTurnEvent(item.Key, item.Value)); + } + } + } + //Console.WriteLine(e); + } + + /*private void ProcessKeys(Byte[] newStates) { + for(Int32 i = 0; i < this.keyStates.Length; i++) { + Int32 newStatePos = i + this.HidComDriver.KeyReportOffset; + + if(this.keyStates[i] != newStates[newStatePos]) { + Int32 externalKeyId = this.HidComDriver.HardwareKeyIdToExtKeyId(i); + KeyStateChanged?.Invoke(this, new KeyEventArgs(externalKeyId, newStates[newStatePos] != 0)); + this.keyStates[i] = newStates[newStatePos]; + } + } + }*/ + + private void ShowLogoWithoutDisposeVerification() => this.DeckHid.WriteFeature(this.HidComDriver.GetLogoMessage()); + + } +} diff --git a/StreamDeckSharp/Internals/CachedHidClient.cs b/StreamDeckSharp/Internals/CachedHidClient.cs new file mode 100644 index 0000000..953c746 --- /dev/null +++ b/StreamDeckSharp/Internals/CachedHidClient.cs @@ -0,0 +1,113 @@ +using OpenMacroBoard.SDK; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using System; + +namespace StreamDeckSharp.Internals { + internal class CachedHidClient : BasicHidClient { + private readonly Task writerTask; + private readonly ConcurrentBufferedQueue imageQueue; + private readonly ConditionalWeakTable cacheKeyBitmaps = []; + + private interface OutpurReportType {} + private class ButtonImage : OutpurReportType { + public Int32 KeyId { + get; set; + } + } + private class FullScreenImage : OutpurReportType { + } + private class WindowImage : OutpurReportType { + } + private class PartialWindowImage : OutpurReportType { + public Int32 XCoord { + get; set; + } + public Int32 YCoord { + get; set; + } + public Int32 Width { + get; set; + } + public Int32 Height { + get; set; + } + } + + public CachedHidClient(IStreamDeckHid deckHid, IKeyLayout keys, IStreamDeckHidComDriver hidComDriver) : base(deckHid, keys, hidComDriver) { + this.imageQueue = new ConcurrentBufferedQueue(); + this.writerTask = this.StartBitmapWriterTask(); + } + + public override void SetButtonImage(Int32 keyId, KeyBitmap bitmapData) { + this.ThrowIfAlreadyDisposed(); + this.imageQueue.Add(new ButtonImage { KeyId = this.HidComDriver.ExtKeyIdToHardwareKeyId(keyId) }, this.cacheKeyBitmaps.GetValue(bitmapData, this.HidComDriver.GenerateButtonImage)); + } + + public override void SetFullScreenImage(KeyBitmap bitmapData) { + this.ThrowIfAlreadyDisposed(); + this.imageQueue.Add(new FullScreenImage(), this.cacheKeyBitmaps.GetValue(bitmapData, this.HidComDriver.GenerateFullScreenImage)); + } + + public override void SetWindowImage(KeyBitmap bitmapData) { + this.ThrowIfAlreadyDisposed(); + this.imageQueue.Add(new WindowImage(), this.cacheKeyBitmaps.GetValue(bitmapData, this.HidComDriver.GenerateWindowImage)); + } + + public override void SetPartialWindowImage(Int32 x_pos, Int32 y_pos, KeyBitmap bitmapData) { + ArgumentOutOfRangeException.ThrowIfNotEqual(y_pos, 0); + KeyBitmap scaledbitmap = this.HidComDriver.AdjustImageSize(bitmapData, x_pos, y_pos); + this.ThrowIfAlreadyDisposed(); + this.imageQueue.Add(new PartialWindowImage { XCoord = x_pos, YCoord = y_pos, Width = scaledbitmap.Width, Height = scaledbitmap.Height }, this.cacheKeyBitmaps.GetValue(scaledbitmap, this.HidComDriver.GeneratePartialWindowImage)); + } + + protected override void Shutdown() { + this.imageQueue.CompleteAdding(); + this.writerTask.Wait(); + } + + protected override void Dispose(Boolean disposing) { + base.Dispose(disposing); + this.imageQueue.Dispose(); + } + + private Task StartBitmapWriterTask() { + void BackgroundAction() { + while(true) { + (Boolean success, OutpurReportType type, Byte[] payload) = this.imageQueue.Take(); + + if(!success) { + // image queue completed + break; + } + IEnumerable reports = []; + + if(type is ButtonImage b) { + reports = OutputReportSplitter.ButtonImage(payload, this.Buffer, this.HidComDriver.ReportSize, this.HidComDriver.HeaderSizeButton, b.KeyId, this.HidComDriver.PrepareHeaderOutButtonImage); + } + if(type is FullScreenImage) { + reports = OutputReportSplitter.FullScreenImage(payload, this.Buffer, this.HidComDriver.ReportSize, this.HidComDriver.HeaderSizeFullScreenImage, this.HidComDriver.PrepareHeaderOutFullScreenImage); + } + if(type is WindowImage) { + reports = OutputReportSplitter.WindowImage(payload, this.Buffer, this.HidComDriver.ReportSize, this.HidComDriver.HeaderSizeFullScreenImage, this.HidComDriver.PrepareHeaderOutWindowImage); + } + if(type is PartialWindowImage p) { + reports = OutputReportSplitter.PartialWindowImage(payload, this.Buffer, this.HidComDriver.ReportSize, this.HidComDriver.HeaderSizePartialWindowImage, p.XCoord, p.YCoord, p.Width, p.Height, this.HidComDriver.PrepareHeaderOutPartialWindowImage); + } + foreach(Byte[] report in reports) { + _ = this.DeckHid.WriteReport(report); + } + } + } + + return Task.Factory.StartNew( + BackgroundAction, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default + ); + } + } +} \ No newline at end of file diff --git a/StreamDeckSharp/Internals/ConcurrentBufferedQueue.cs b/StreamDeckSharp/Internals/ConcurrentBufferedQueue.cs new file mode 100644 index 0000000..2efd03e --- /dev/null +++ b/StreamDeckSharp/Internals/ConcurrentBufferedQueue.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.Threading; + +namespace StreamDeckSharp.Internals +{ + internal sealed class ConcurrentBufferedQueue : IDisposable + { + private readonly object sync = new(); + + private readonly Dictionary valueBuffer = new(); + private readonly Queue queue = new(); + + private volatile bool isAddingCompleted; + private volatile bool disposed; + + public int Count => this.queue.Count; + + public bool IsAddingCompleted + { + get + { + this.ThrowIfDisposed(); + return this.isAddingCompleted; + } + } + + public bool IsCompleted + { + get + { + lock (this.sync) + { + this.ThrowIfDisposed(); + return this.isAddingCompleted && this.Count == 0; + } + } + } + + public void Add(TKey key, TValue value) + { + lock (this.sync) + { + this.ThrowIfDisposed(); + + if (this.isAddingCompleted) + { + throw new InvalidOperationException("Adding was already marked as completed."); + } + + try + { + this.valueBuffer[key] = value; + + if (!this.queue.Contains(key)) + { + this.queue.Enqueue(key); + } + } + finally + { + Monitor.PulseAll(this.sync); + } + } + } + + public (bool Success, TKey Key, TValue Value) Take() + { + lock (this.sync) + { + while (this.queue.Count < 1) + { + this.ThrowIfDisposed(); + + if (this.isAddingCompleted) + { + return (false, default, default); + } + + Monitor.Wait(this.sync); + } + + this.ThrowIfDisposed(); + + TKey key = this.queue.Dequeue(); + TValue value = this.valueBuffer[key]; + this.valueBuffer.Remove(key); + + return (true, key, value); + } + } + + public void CompleteAdding() + { + lock (this.sync) + { + if (this.isAddingCompleted) + { + return; + } + + this.isAddingCompleted = true; + Monitor.PulseAll(this.sync); + } + } + + public void Dispose() + { + lock (this.sync) + { + if (this.disposed) + { + return; + } + + this.disposed = true; + + if (!this.isAddingCompleted) + { + this.CompleteAdding(); + } + } + } + + private void ThrowIfDisposed() + { + if (this.disposed) + { + throw new ObjectDisposedException(nameof(ConcurrentBufferedQueue)); + } + } + } +} diff --git a/StreamDeckSharp/Internals/DeviceListExtensions.cs b/StreamDeckSharp/Internals/DeviceListExtensions.cs new file mode 100644 index 0000000..5a914b6 --- /dev/null +++ b/StreamDeckSharp/Internals/DeviceListExtensions.cs @@ -0,0 +1,62 @@ +using HidSharp; +using OpenMacroBoard.SDK.Utils; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace StreamDeckSharp.Internals +{ + internal static class DeviceListExtensions + { + public static IEnumerable GetStreamDecks( + this DeviceList deviceList, + params IUsbHidHardware[] hardware + ) + { + if (deviceList is null) + { + throw new ArgumentNullException(nameof(deviceList)); + } + + var matchAllKnowDevices = hardware is null || hardware.Length < 1; + + (bool Success, UsbHardwareIdAndDriver Hardware) MatchingHardware(HidDevice d) + { + UsbHardwareIdAndDriver hwDetails = d.GetHardwareInformation(); + + if (hwDetails is null) + { + return (false, default); + } + + if (matchAllKnowDevices) + { + return (true, hwDetails); + } + + UsbVendorProductPair deviceUsbKey = new UsbVendorProductPair(d.VendorID, d.ProductID); + var hardwareMatches = hardware.Any(h => h.UsbIds.Contains(deviceUsbKey)); + + return ( + hardwareMatches, + hardwareMatches ? hwDetails : default + ); + } + + return deviceList + .GetHidDevices() + .SelectWhere(device => + { + (Boolean success, UsbHardwareIdAndDriver hardware) = MatchingHardware(device); + var value = success ? new { HardwareInfo = hardware, Device = device } : default; + + return (success, value); + }) + .Select(i => new StreamDeckDeviceReference( + i.Device.DevicePath, + i.HardwareInfo.DeviceName, + i.HardwareInfo.Keys + )); + } + } +} diff --git a/StreamDeckSharp/Internals/HidComDriverStreamDeck.cs b/StreamDeckSharp/Internals/HidComDriverStreamDeck.cs new file mode 100644 index 0000000..091e070 --- /dev/null +++ b/StreamDeckSharp/Internals/HidComDriverStreamDeck.cs @@ -0,0 +1,153 @@ +using OpenMacroBoard.SDK; +using System; + +namespace StreamDeckSharp.Internals { + /// + /// HID Stream Deck communication driver for the classical Stream Deck. + /// + public sealed class HidComDriverStreamDeck : IStreamDeckHidComDriver { + private const Int32 ImgWidth = 72; + private const Int32 ColorChannels = 3; + + private static readonly Byte[] BmpHeader = new Byte[] + { + 0x42, 0x4d, 0xf6, 0x3c, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00, + 0x00, 0x00, 0x48, 0x00, 0x00, 0x00, 0x48, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00, + 0x00, 0x00, 0xc0, 0x3c, 0x00, 0x00, 0xc4, 0x0e, + 0x00, 0x00, 0xc4, 0x0e, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + + /// + public Int32 HeaderSizeButton => 16; + + /// + public Int32 HeaderSizeFullScreenImage => throw new NotImplementedException(); + + /// + public Int32 HeaderSizeWindowImage => throw new NotImplementedException(); + + /// + public Int32 HeaderSizePartialWindowImage => throw new NotImplementedException(); + + /// + public Int32 ReportSize => 7819; + + /// + public Int32 ExpectedFeatureReportLength => 17; + + /// + public Int32 ExpectedOutputReportLength => 8191; + + /// + public Int32 ExpectedInputReportLength => 17; + + /// + public Int32 KeyReportOffset => 1; + + /// + public Byte FirmwareVersionFeatureId => 4; + + /// + public Byte SerialNumberFeatureId => 3; + + /// + public Int32 FirmwareVersionReportSkip => 5; + + /// + public Int32 SerialNumberReportSkip => 5; + + /// + public Double BytesPerSecondLimit { get; set; } = Double.PositiveInfinity; + + + + /// + public Byte[] GenerateButtonImage(KeyBitmap keyBitmap) { + ReadOnlySpan rawData = keyBitmap.GetScaledVersion(ImgWidth, ImgWidth); + + Byte[] bmp = new Byte[ImgWidth * ImgWidth * 3 + BmpHeader.Length]; + Array.Copy(BmpHeader, 0, bmp, 0, BmpHeader.Length); + + if(rawData.Length != 0) { + for(Int32 y = 0; y < ImgWidth; y++) { + for(Int32 x = 0; x < ImgWidth; x++) { + Int32 src = (y * ImgWidth + x) * ColorChannels; + Int32 tar = (y * ImgWidth + (ImgWidth - 1 - x)) * ColorChannels + BmpHeader.Length; + + bmp[tar + 0] = rawData[src + 0]; + bmp[tar + 1] = rawData[src + 1]; + bmp[tar + 2] = rawData[src + 2]; + } + } + } + + return bmp; + } + + /// + public Byte[] GenerateFullScreenImage(KeyBitmap keyBitmap) => throw new NotImplementedException(); + + /// + public Byte[] GenerateWindowImage(KeyBitmap keyBitmap) => throw new NotImplementedException(); + + /// + public Byte[] GeneratePartialWindowImage(KeyBitmap keyBitmap) => throw new NotImplementedException(); + + /// + public KeyBitmap AdjustImageSize(KeyBitmap bitmapData, Int32 x_pos, Int32 y_pos) => throw new NotImplementedException(); + + /// + public Int32 ExtKeyIdToHardwareKeyId(Int32 extKeyId) { + return FlipIdsHorizontal(extKeyId); + } + + /// + public Int32 HardwareKeyIdToExtKeyId(Int32 hardwareKeyId) { + return FlipIdsHorizontal(hardwareKeyId); + } + + /// + public void PrepareHeaderOutButtonImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Int32 keyId, Boolean isLast) { + data[0] = 2; // Report ID ? + data[1] = 1; // ? + data[2] = (Byte)(pageNumber + 1); + data[4] = (Byte)(isLast ? 1 : 0); + data[5] = (Byte)(keyId + 1); + } + + public void PrepareHeaderOutFullScreenImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Boolean isLast) => throw new NotImplementedException(); + public void PrepareHeaderOutWindowImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Boolean isLast) => throw new NotImplementedException(); + public void PrepareHeaderOutPartialWindowImage(Byte[] data, Int32 x_pos, Int32 y_pos, Int32 width, Int32 height, Int32 pageNumber, Int32 payloadLength, Boolean isLast) => throw new NotImplementedException(); + + /// + public Byte[] GetBrightnessMessage(Byte percent) { + if(percent > 100) { + throw new ArgumentOutOfRangeException(nameof(percent)); + } + + Byte[] buffer = new Byte[] + { + 0x05, 0x55, 0xaa, 0xd1, 0x01, 0x64, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, + }; + + buffer[5] = percent; + return buffer; + } + + /// + public Byte[] GetLogoMessage() { + return new Byte[] { 0x0B, 0x63 }; + } + + /// + private static Int32 FlipIdsHorizontal(Int32 keyId) { + Int32 diff = ((keyId % 5) - 2) * -2; + return keyId + diff; + } + } +} diff --git a/StreamDeckSharp/Internals/HidComDriverStreamDeckJpeg.cs b/StreamDeckSharp/Internals/HidComDriverStreamDeckJpeg.cs new file mode 100644 index 0000000..5e5b20c --- /dev/null +++ b/StreamDeckSharp/Internals/HidComDriverStreamDeckJpeg.cs @@ -0,0 +1,263 @@ +using OpenMacroBoard.SDK; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System; +using System.IO; + +namespace StreamDeckSharp.Internals { + /// + /// HID Stream Deck communication driver for JPEG based devices. + /// + public sealed class HidComDriverStreamDeckJpeg : IStreamDeckHidComDriver { + private readonly JpegEncoder jpgEncoder; + + private Byte[] cachedNullImage = null; + + /// + /// Initializes a new instance of the class. + /// + /// The size of the button images in pixels. + /// /// Thrown if the is smaller than one. + public HidComDriverStreamDeckJpeg(Int32 buttonSize, Int32 touchWidth, Int32 touchHeight, Int32 fullscreenWidth, Int32 fullscreenHeight) { + ArgumentOutOfRangeException.ThrowIfLessThan(buttonSize, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(touchWidth, 0); + ArgumentOutOfRangeException.ThrowIfLessThan(touchHeight, 0); + ArgumentOutOfRangeException.ThrowIfLessThan(fullscreenWidth, 1); + ArgumentOutOfRangeException.ThrowIfLessThan(fullscreenHeight, 1); + + this.jpgEncoder = new JpegEncoder() { + Quality = 100, + }; + + this.ButtonSize = buttonSize; + this.TouchWidth = touchWidth; + this.TouchHeight = touchHeight; + this.FullscreenWidth = fullscreenWidth; + this.FullscreenHeight = fullscreenHeight; + } + + public Int32 ButtonSize { + get; + } + + public Int32 TouchWidth { + get; + } + + public Int32 TouchHeight { + get; + } + + public Int32 FullscreenWidth { + get; + } + + public Int32 FullscreenHeight { + get; + } + + /// + public Int32 HeaderSizeButton => 0x08; + + /// + public Int32 HeaderSizeFullScreenImage => 0x08; + + /// + public Int32 HeaderSizeWindowImage => 0x08; + + /// + public Int32 HeaderSizePartialWindowImage => 0x10; + + /// + public Int32 ReportSize => 1024; + + /// + public Int32 ExpectedFeatureReportLength => 32; + + /// + public Int32 ExpectedOutputReportLength => 1024; + + /// + public Int32 ExpectedInputReportLength => 512; + + /// + public Int32 KeyReportOffset => 4; + + /// + public Byte FirmwareVersionFeatureId => 5; + + /// + public Byte SerialNumberFeatureId => 6; + + /// + public Int32 FirmwareVersionReportSkip => 6; + + /// + public Int32 SerialNumberReportSkip => 2; + + /// + public Double BytesPerSecondLimit { get; set; } = Double.PositiveInfinity; + + /// + public Int32 ExtKeyIdToHardwareKeyId(Int32 extKeyId) => extKeyId; + + /// + public Byte[] GenerateButtonImage(KeyBitmap keyBitmap) { + ReadOnlySpan rawData = keyBitmap.GetScaledVersion(this.ButtonSize, this.ButtonSize); + return rawData.Length == 0 ? this.GetNullImage(this.ButtonSize, this.ButtonSize) : this.EncodeImageToJpg(rawData, this.ButtonSize, this.ButtonSize); + } + + public Byte[] GenerateFullScreenImage(KeyBitmap keyBitmap) { + ReadOnlySpan rawData = keyBitmap.GetScaledVersion(this.FullscreenWidth, this.FullscreenHeight); + return rawData.Length == 0 ? this.GetNullImage(this.FullscreenWidth, this.FullscreenHeight) : this.EncodeImageToJpg(rawData, this.FullscreenWidth, this.FullscreenHeight); + } + + /// + public Byte[] GenerateWindowImage(KeyBitmap keyBitmap) { + ReadOnlySpan rawData = keyBitmap.GetScaledVersion(this.TouchWidth, this.TouchHeight); + return rawData.Length == 0 ? this.GetNullImage(this.TouchWidth, this.TouchHeight) : this.EncodeImageToJpg(rawData, this.TouchWidth, this.TouchHeight); + } + + /// + public Byte[] GeneratePartialWindowImage(KeyBitmap keyBitmap) => this.EncodeImageToJpg(((IKeyBitmapDataAccess)keyBitmap).GetData(), keyBitmap.Width, keyBitmap.Height); + + /// + public Int32 HardwareKeyIdToExtKeyId(Int32 hardwareKeyId) => hardwareKeyId; + + /// + public void PrepareHeaderOutButtonImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Int32 keyId, Boolean isLast) { + data[0] = 0x02; + data[1] = 0x07; + data[2] = (Byte)keyId; + data[3] = (Byte)(isLast ? 1 : 0); + data[4] = (Byte)(payloadLength & 0xff); + data[5] = (Byte)(payloadLength >> 8); + data[6] = (Byte)(pageNumber & 0xff); + data[7] = (Byte)(pageNumber >> 8); + } + + /// + public void PrepareHeaderOutFullScreenImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Boolean isLast) { + data[0] = 0x02; + data[1] = 0x08; + data[2] = 0x00; + data[3] = (Byte)(isLast ? 1 : 0); + data[4] = (Byte)(payloadLength & 0xff); + data[5] = (Byte)(payloadLength >> 8); + data[6] = (Byte)(pageNumber & 0xff); + data[7] = (Byte)(pageNumber >> 8); + } + + /// + public void PrepareHeaderOutWindowImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Boolean isLast) { + data[0] = 0x02; + data[1] = 0x0B; + data[2] = 0x00; + data[3] = (Byte)(isLast ? 1 : 0); + data[4] = (Byte)(payloadLength & 255); + data[5] = (Byte)(payloadLength >> 8); + data[6] = (Byte)(pageNumber & 0xff); + data[7] = (Byte)(pageNumber >> 8); + } + + /// + public void PrepareHeaderOutPartialWindowImage(Byte[] data, Int32 x_pos, Int32 y_pos, Int32 width, Int32 height, Int32 pageNumber, Int32 payloadLength, Boolean isLast) { + data[0] = 0x02; + data[1] = 0x0c; + data[2] = (Byte)(x_pos & 0xff); //2 xpos low byte + data[3] = (Byte)(x_pos >> 8); //3 xpos high byte + data[4] = (Byte)(y_pos & 0xff); //4 ypos low byte + data[5] = (Byte)(y_pos >> 8); //5 ypos high byte + data[6] = (Byte)(width & 0xff); //6 width low byte // 120 + data[7] = (Byte)(width >> 8); //7 width high byte + data[8] = (Byte)(height & 0xff); //8 height low byte // 120 + data[9] = (Byte)(height >> 8); //9 height high byte + data[10] = (Byte)(isLast ? 1 : 0); + data[11] = (Byte)(pageNumber & 0xff); + data[12] = (Byte)(pageNumber >> 8); + data[13] = (Byte)(payloadLength & 0xff); + data[14] = (Byte)(payloadLength >> 8); + data[15] = 0x00; + } + + /// + public Byte[] GetBrightnessMessage(Byte percent) { + if(percent > 100) { + throw new ArgumentOutOfRangeException(nameof(percent)); + } + + Byte[] buffer = + [ + 0x03, 0x08, 0x64, 0x23, 0xB8, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0xA5, 0x49, 0xCD, 0x02, 0xFE, 0x7F, 0x00, 0x00, + ]; + + buffer[2] = percent; + buffer[3] = 0x23; // 0x23, sometimes 0x27 + + return buffer; + } + + /// + public KeyBitmap AdjustImageSize(KeyBitmap bitmapData, Int32 x_pos, Int32 y_pos) { + if(x_pos >= this.TouchWidth || y_pos >= this.TouchHeight || x_pos < 0 || y_pos < 0) { + throw new ArgumentException("x or y coordinate is bigger than screen or negative"); + } + Int32 width = bitmapData.Width; + if(x_pos + bitmapData.Width > this.TouchWidth) { + width = bitmapData.Width - x_pos + bitmapData.Width - this.TouchWidth; + } + /*Int32 height = bitmapData.Height; + if(y_pos + bitmapData.Height > this.TouchHeight) { + height = bitmapData.Height - y_pos + bitmapData.Height - this.TouchHeight; + }*/ //NEEDS TO BE TOUCHSIZE IN HEIGHT! + Int32 height = this.TouchHeight; + + + if(bitmapData.Width == width && bitmapData.Height == height) { + // if it is already the size we need just return the underlying data + return bitmapData; + } + + IKeyBitmapDataAccess keyDataAccess = bitmapData; + using Image image = keyDataAccess.ToImage(); + image.Mutate(x => x.Resize(width, height)); + return KeyBitmap.Create.FromImageSharpImage(image); + } + + /// + public Byte[] GetLogoMessage() => [0x03, 0x02]; + + private Byte[] GetNullImage(Int32 width, Int32 height) { + if(this.cachedNullImage is null) { + ReadOnlySpan rawNullImg = KeyBitmap.Create.FromBgr24Array(1, 1, [0, 0, 0]).GetScaledVersion(width, height); + this.cachedNullImage = this.EncodeImageToJpg(rawNullImg, width, height); + } + + return this.cachedNullImage; + } + + private Byte[] EncodeImageToJpg(ReadOnlySpan bgr24, Int32 width, Int32 height) { + + using Image image = Image.LoadPixelData(bgr24, width, height); + + image.Mutate(x => x.Rotate(-90)); + + using MemoryStream memStream = new(); + image.SaveAsJpeg(memStream, this.jpgEncoder); + + //Byte[] buffer = memStream.ToArray(); + + //Console.WriteLine(BitConverter.ToString(buffer).Replace("-", "").Substring(0, 100)); + //return buffer; + + return memStream.ToArray(); + } + + + } +} diff --git a/StreamDeckSharp/Internals/HidComDriverStreamDeckMini.cs b/StreamDeckSharp/Internals/HidComDriverStreamDeckMini.cs new file mode 100644 index 0000000..ae4c1c1 --- /dev/null +++ b/StreamDeckSharp/Internals/HidComDriverStreamDeckMini.cs @@ -0,0 +1,163 @@ +using OpenMacroBoard.SDK; +using System; + +namespace StreamDeckSharp.Internals { + /// + /// HID Stream Deck communication driver for the Stream Deck Mini. + /// + public sealed class HidComDriverStreamDeckMini : IStreamDeckHidComDriver { + private const Int32 ColorChannels = 3; + + private static readonly Byte[] BmpHeader = new Byte[] + { + 0x42, 0x4d, 0x36, 0x4b, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x36, 0x00, 0x00, 0x00, 0x28, 0x00, + 0x00, 0x00, 0x50, 0x00, 0x00, 0x00, 0x50, 0x00, + 0x00, 0x00, 0x01, 0x00, 0x18, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x4b, 0x00, 0x00, 0xc4, 0x0e, + 0x00, 0x00, 0xc4, 0x0e, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + }; + + private readonly Int32 imgSize; + + /// + /// Initializes a new instance of the class. + /// + /// The size of the button images in pixels. + /// Thrown if the is smaller than one. + public HidComDriverStreamDeckMini(Int32 imgSize) { + if(imgSize < 1) { + throw new ArgumentOutOfRangeException(nameof(imgSize)); + } + + this.imgSize = imgSize; + } + + /// + public Int32 HeaderSizeButton => 16; + + /// + public Int32 HeaderSizeFullScreenImage => throw new NotImplementedException(); + + /// + public Int32 HeaderSizeWindowImage => throw new NotImplementedException(); + + /// + public Int32 HeaderSizePartialWindowImage => throw new NotImplementedException(); + + /// + public Int32 ReportSize => 1024; + + /// + public Int32 ExpectedFeatureReportLength => 17; + + /// + public Int32 ExpectedOutputReportLength => 1024; + + /// + public Int32 ExpectedInputReportLength => 17; + + /// + public Int32 KeyReportOffset => 1; + + /// + public Byte FirmwareVersionFeatureId => 4; + + /// + public Byte SerialNumberFeatureId => 3; + + /// + public Int32 FirmwareVersionReportSkip => 5; + + /// + public Int32 SerialNumberReportSkip => 5; + + /// + public Double BytesPerSecondLimit => Double.PositiveInfinity; + + + + /// + public Byte[] GenerateButtonImage(KeyBitmap keyBitmap) { + ReadOnlySpan rawData = keyBitmap.GetScaledVersion(this.imgSize, this.imgSize); + Byte[] bmp = new Byte[this.imgSize * this.imgSize * ColorChannels + BmpHeader.Length]; + + Array.Copy(BmpHeader, 0, bmp, 0, BmpHeader.Length); + + if(rawData.Length != 0) { + for(Int32 y = 0; y < this.imgSize; y++) { + for(Int32 x = 0; x < this.imgSize; x++) { + Int32 src = (y * this.imgSize + x) * ColorChannels; + Int32 tar = ((this.imgSize - x - 1) * this.imgSize + y) * ColorChannels + BmpHeader.Length; + + bmp[tar + 0] = rawData[src + 0]; + bmp[tar + 1] = rawData[src + 1]; + bmp[tar + 2] = rawData[src + 2]; + } + } + } + + return bmp; + } + + /// + public Byte[] GenerateFullScreenImage(KeyBitmap keyBitmap) => throw new NotImplementedException(); + + /// + public Byte[] GenerateWindowImage(KeyBitmap keyBitmap) => throw new NotImplementedException(); + + /// + public Byte[] GeneratePartialWindowImage(KeyBitmap keyBitmap) => throw new NotImplementedException(); + + /// + public KeyBitmap AdjustImageSize(KeyBitmap bitmapData, Int32 x_pos, Int32 y_pos) => throw new NotImplementedException(); + + /// + public Int32 ExtKeyIdToHardwareKeyId(Int32 extKeyId) { + return extKeyId; + } + + /// + public Int32 HardwareKeyIdToExtKeyId(Int32 hardwareKeyId) { + return hardwareKeyId; + } + + /// + public void PrepareHeaderOutButtonImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Int32 keyId, Boolean isLast) { + data[0] = 2; // Report ID ? + data[1] = 1; // ? + data[2] = (Byte)pageNumber; + data[4] = (Byte)(isLast ? 1 : 0); + data[5] = (Byte)(keyId + 1); + } + + public void PrepareHeaderOutFullScreenImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Boolean isLast) => throw new NotImplementedException(); + + public void PrepareHeaderOutWindowImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Boolean isLast) => throw new NotImplementedException(); + + public void PrepareHeaderOutPartialWindowImage(Byte[] data, Int32 x_pos, Int32 y_pos, Int32 width, Int32 height, Int32 pageNumber, Int32 payloadLength, Boolean isLast) => throw new NotImplementedException(); + + /// + public Byte[] GetBrightnessMessage(Byte percent) { + if(percent > 100) { + throw new ArgumentOutOfRangeException(nameof(percent)); + } + + Byte[] buffer = new Byte[] + { + 0x05, 0x55, 0xaa, 0xd1, 0x01, 0x64, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, + }; + + buffer[5] = percent; + return buffer; + } + + /// + public Byte[] GetLogoMessage() { + return new Byte[] { 0x0B, 0x63 }; + } + } +} diff --git a/StreamDeckSharp/Internals/HidDeviceExtensions.cs b/StreamDeckSharp/Internals/HidDeviceExtensions.cs new file mode 100644 index 0000000..2122eaa --- /dev/null +++ b/StreamDeckSharp/Internals/HidDeviceExtensions.cs @@ -0,0 +1,7 @@ +using HidSharp; + +namespace StreamDeckSharp.Internals { + internal static class HidDeviceExtensions { + public static UsbHardwareIdAndDriver GetHardwareInformation(this HidDevice hid) => Hardware.GetInternalHardwareInfos(new(hid.VendorID, hid.ProductID)); + } +} diff --git a/StreamDeckSharp/Internals/IStreamDeckHid.cs b/StreamDeckSharp/Internals/IStreamDeckHid.cs new file mode 100644 index 0000000..a3bce9c --- /dev/null +++ b/StreamDeckSharp/Internals/IStreamDeckHid.cs @@ -0,0 +1,20 @@ +using OpenMacroBoard.SDK; +using System; + +namespace StreamDeckSharp.Internals { + internal interface IStreamDeckHid : IDisposable { + event EventHandler ReportReceived; + event EventHandler ConnectionStateChanged; + + Boolean IsConnected { + get; + } + Int32 OutputReportLength { + get; + } + + Boolean WriteFeature(Byte[] featureData); + Boolean WriteReport(Byte[] reportData); + Boolean ReadFeatureData(Byte id, out Byte[] data); + } +} diff --git a/StreamDeckSharp/Internals/IStreamDeckHidComDriver.cs b/StreamDeckSharp/Internals/IStreamDeckHidComDriver.cs new file mode 100644 index 0000000..910c579 --- /dev/null +++ b/StreamDeckSharp/Internals/IStreamDeckHidComDriver.cs @@ -0,0 +1,223 @@ +using OpenMacroBoard.SDK; +using System; + +namespace StreamDeckSharp.Internals { + /// + /// Interface that describes the StreamDeck HID communication. + /// + /// + /// + /// Unless there are new stream deck versions you likely don't have to deal with this interface + /// or implementations of it directly as a consumer of the library. Because this interface is + /// low level, some members may not have a good documentation or any at all. + /// + /// Implementations must be thread-safe. + /// + public interface IStreamDeckHidComDriver { + /// + /// Gets the header size for a specific button. + /// + Int32 HeaderSizeButton { + get; + } + + /// + /// Gets the header size for the complete LCD. + /// + Int32 HeaderSizeFullScreenImage { + get; + } + + /// + /// Gets the header size for the touchscreen window strip. + /// + Int32 HeaderSizeWindowImage { + get; + } + + /// + /// Gets the header size for a rectangular region of the touchscreen window. + /// + Int32 HeaderSizePartialWindowImage { + get; + } + + /// + /// Gets the report size. + /// + Int32 ReportSize { + get; + } + + /// + /// Gets the feature report length for the device. + /// + /// + /// This is asserted (in debug mode). + /// + Int32 ExpectedFeatureReportLength { + get; + } + + /// + /// Gets the output report length for the device. + /// + /// + /// This is asserted (in debug mode). + /// + Int32 ExpectedOutputReportLength { + get; + } + + /// + /// Gets the input report length for the device. + /// + /// + /// This is asserted (in debug mode). + /// + Int32 ExpectedInputReportLength { + get; + } + + /// + /// Gets the offset of the key information inside the key state report. + /// + Int32 KeyReportOffset { + get; + } + + /// + /// The ID of the feature that identifies the firmware version. + /// + Byte FirmwareVersionFeatureId { + get; + } + + /// + /// Number of bytes to skip before the firmware version string starts. + /// + /// + /// For details see property documentation of . + /// + Int32 FirmwareVersionReportSkip { + get; + } + + /// + /// The ID of the feature that identifies the serial number. + /// + Byte SerialNumberFeatureId { + get; + } + + /// + /// Number of bytes to skip before the serial number string starts. + /// + /// + /// For some reason some string reports have some "weird" data prefixed. + /// I guess they are some binary encoded details or headers - no idea. + /// This property can be tweaked so the resulting string doesn't contain + /// strange Unicode characters. + /// + Int32 SerialNumberReportSkip { + get; + } + + /// + /// Limits the USB transfer speed. + /// + /// + /// Some stream decks produce artifacts and glitches when data comes in to fast. + /// I'm not sure if this happens because of this library or because of a bug in + /// the stream deck's firmware but currently the work-around is to limit the transfer rate. + /// This value has to be determined experimentally. + /// + Double BytesPerSecondLimit { + get; + } + + /// + /// Generate they payload for a given . + /// + Byte[] GenerateButtonImage(KeyBitmap keyBitmap); + + /// + /// Generate they payload for a given . + /// + Byte[] GenerateFullScreenImage(KeyBitmap keyBitmap); + + /// + /// Generate they payload for a given for the Touchstripe. + /// + Byte[] GenerateWindowImage(KeyBitmap keyBitmap); + + /// + /// Generate they payload for a given for the Touchstripe. + /// + Byte[] GeneratePartialWindowImage(KeyBitmap keyBitmap); + + + /// + /// Adjust Picture Size to fit on Screen + /// + /// Picture to check or resized + /// Pixel from Top + /// Pixel from Left + /// + KeyBitmap AdjustImageSize(KeyBitmap bitmapData, Int32 x_pos, Int32 y_pos); + + /// + /// This is used to convert between keyId conventions + /// + /// + /// The original stream deck has a pretty weird way of enumerating keys. + /// Index 0 starts right top and they are enumerated right to left, + /// and top to bottom. Most developers would expect it to be left-to-right + /// instead of right-to-left, so we change that ;-) + /// + Int32 ExtKeyIdToHardwareKeyId(Int32 extKeyId); + + /// + /// This is used to convert between keyId conventions + /// + Int32 HardwareKeyIdToExtKeyId(Int32 hardwareKeyId); + + /// + /// Before the report is sent to the stream deck (human interface device) this is called to + /// prepare meta information and details in the report header. This depends on the target device + /// and has to be reverse engineered with a USB traffic analyzer. + /// + void PrepareHeaderOutButtonImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Int32 keyId, Boolean isLast); + + /// + /// Before the report is sent to the stream deck (human interface device) this is called to + /// prepare meta information and details in the report header. This depends on the target device + /// and has to be reverse engineered with a USB traffic analyzer. + /// + void PrepareHeaderOutFullScreenImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Boolean isLast); + + /// + /// Before the report is sent to the stream deck (human interface device) this is called to + /// prepare meta information and details in the report header. This depends on the target device + /// and has to be reverse engineered with a USB traffic analyzer. + /// + void PrepareHeaderOutWindowImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Boolean isLast); + + /// + /// Before the report is sent to the stream deck (human interface device) this is called to + /// prepare meta information and details in the report header. This depends on the target device + /// and has to be reverse engineered with a USB traffic analyzer. + /// + void PrepareHeaderOutPartialWindowImage(Byte[] data, Int32 x_pos, Int32 y_pos, Int32 width, Int32 height, Int32 pageNumber, Int32 payloadLength, Boolean isLast); + + /// + /// Generates a message to set a given brightness. + /// + Byte[] GetBrightnessMessage(Byte percent); + + /// + /// Generates a message to show the vendor logo. + /// + Byte[] GetLogoMessage(); + } +} diff --git a/StreamDeckSharp/Internals/KeyBitmapExtensions.cs b/StreamDeckSharp/Internals/KeyBitmapExtensions.cs new file mode 100644 index 0000000..cdc6d80 --- /dev/null +++ b/StreamDeckSharp/Internals/KeyBitmapExtensions.cs @@ -0,0 +1,31 @@ +using OpenMacroBoard.SDK; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using System; + +namespace StreamDeckSharp.Internals { + internal static class KeyBitmapExtensions { + public static ReadOnlySpan GetScaledVersion(this KeyBitmap keyBitmap, Int32 width, Int32 height) { + IKeyBitmapDataAccess keyDataAccess = keyBitmap; + + if(keyDataAccess.IsEmpty) { + // default span is of length 0 (the caller has to check this special case) + return default; + } + + if(keyBitmap.Width == width && keyBitmap.Height == height) { + // if it is already the size we need just return the underlying data + return keyDataAccess.GetData(); + } + + using Image image = keyDataAccess.ToImage(); + + image.Mutate(x => x.Resize(width, height)); + + Byte[] scaledPixelData = image.ToBgr24PixelArray(); + + return new ReadOnlySpan(scaledPixelData); + } + } +} diff --git a/StreamDeckSharp/Internals/OutputReportSplitter.cs b/StreamDeckSharp/Internals/OutputReportSplitter.cs new file mode 100644 index 0000000..826f733 --- /dev/null +++ b/StreamDeckSharp/Internals/OutputReportSplitter.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; + +namespace StreamDeckSharp.Internals { + internal static class OutputReportSplitter { + public delegate void PrepareHeaderOutButtonImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Int32 keyId, Boolean isLast); + public delegate void PrepareHeaderOutFullScreenImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Boolean isLast); + public delegate void PrepareHeaderOutWindowImage(Byte[] data, Int32 pageNumber, Int32 payloadLength, Boolean isLast); + public delegate void PrepareHeaderOutPartialWindowImage(Byte[] data, Int32 x_pos, Int32 y_pos, Int32 width, Int32 height, Int32 pageNumber, Int32 payloadLength, Boolean isLast); + + public static IEnumerable ButtonImage(Byte[] data, Byte[] buffer, Int32 bufferLength, Int32 headerSize, Int32 keyId, PrepareHeaderOutButtonImage prepareData) { + Int32 maxPayloadLength = bufferLength - headerSize; + + Int32 remainingBytes = data.Length; + Int32 bytesSent = 0; + + for(Int32 splitNumber = 0; remainingBytes > 0; splitNumber++) { + Boolean isLast = remainingBytes <= maxPayloadLength; + Int32 bytesToSend = Math.Min(remainingBytes, maxPayloadLength); + + Array.Copy(data, bytesSent, buffer, headerSize, bytesToSend); + prepareData(buffer, splitNumber, bytesToSend, keyId, isLast); + + //String x = BitConverter.ToString(buffer).Replace("-", ""); + + yield return buffer; + + bytesSent += bytesToSend; + remainingBytes -= bytesToSend; + } + } + + public static IEnumerable FullScreenImage(Byte[] data, Byte[] buffer, Int32 bufferLength, Int32 headerSize, PrepareHeaderOutFullScreenImage prepareData) { + Int32 maxPayloadLength = bufferLength - headerSize; + + Int32 remainingBytes = data.Length; + Int32 bytesSent = 0; + + for(Int32 splitNumber = 0; remainingBytes > 0; splitNumber++) { + Boolean isLast = remainingBytes <= maxPayloadLength; + Int32 bytesToSend = Math.Min(remainingBytes, maxPayloadLength); + + Array.Copy(data, bytesSent, buffer, headerSize, bytesToSend); + prepareData(buffer, splitNumber, bytesToSend, isLast); + yield return buffer; + + bytesSent += bytesToSend; + remainingBytes -= bytesToSend; + } + } + + public static IEnumerable WindowImage(Byte[] data, Byte[] buffer, Int32 bufferLength, Int32 headerSize, PrepareHeaderOutWindowImage prepareData) { + Int32 maxPayloadLength = bufferLength - headerSize; + + Int32 remainingBytes = data.Length; + Int32 bytesSent = 0; + + for(Int32 splitNumber = 0; remainingBytes > 0; splitNumber++) { + Boolean isLast = remainingBytes <= maxPayloadLength; + Int32 bytesToSend = Math.Min(remainingBytes, maxPayloadLength); + + Array.Copy(data, bytesSent, buffer, headerSize, bytesToSend); + prepareData(buffer, splitNumber, bytesToSend, isLast); + yield return buffer; + + bytesSent += bytesToSend; + remainingBytes -= bytesToSend; + } + } + + public static IEnumerable PartialWindowImage(Byte[] data, Byte[] buffer, Int32 bufferLength, Int32 headerSize, Int32 x_pos, Int32 y_pos, Int32 width, Int32 height, PrepareHeaderOutPartialWindowImage prepareData) { + Int32 maxPayloadLength = bufferLength - headerSize; + + Int32 remainingBytes = data.Length; + Int32 bytesSent = 0; + + for(Int32 splitNumber = 0; remainingBytes > 0; splitNumber++) { + Boolean isLast = remainingBytes <= maxPayloadLength; + Int32 bytesToSend = Math.Min(remainingBytes, maxPayloadLength); + Array.Clear(buffer); + Array.Copy(data, bytesSent, buffer, headerSize, bytesToSend); + prepareData(buffer, x_pos, y_pos, width, height, splitNumber, bytesToSend, isLast); + + //String x = BitConverter.ToString(buffer).Replace("-", ""); + + yield return buffer; + + bytesSent += bytesToSend; + remainingBytes -= bytesToSend; + } + } + } +} diff --git a/StreamDeckSharp/Internals/ReportReceivedEventArgs.cs b/StreamDeckSharp/Internals/ReportReceivedEventArgs.cs new file mode 100644 index 0000000..cc3e391 --- /dev/null +++ b/StreamDeckSharp/Internals/ReportReceivedEventArgs.cs @@ -0,0 +1,132 @@ +using System; +using System.Collections.Generic; + +namespace StreamDeckSharp.Internals { + interface IReportsIn { + + } + + public class ButtonPressEvent : EventArgs, IReportsIn { + private const Int32 DATA_OFFSET = 4; + public Byte ButtonsCount { + get; + } + public Dictionary ButtonStates { + get; + } + public ButtonPressEvent(Byte[] data) { + this.ButtonsCount = data[2]; + if(this.ButtonsCount + DATA_OFFSET <= data.Length) { + this.ButtonStates = []; + for(Int32 i = 0; i < this.ButtonsCount; i++) { + this.ButtonStates.Add(i, data[i + DATA_OFFSET] == 0x01); + } + } + } + public override String ToString() => "Button Presed: [" + String.Join(", ", this.ButtonStates) + "]"; + } + + public class TouchTapEvent : EventArgs, IReportsIn { + public Byte Fingers { + get; + } + public UInt16 XCoord { + get; + } + public UInt16 YCoord { + get; + } + public TouchTapEvent(Byte[] data) { + this.Fingers = data[5]; + this.XCoord = (UInt16)BitConverter.ToInt16(data, 6); + this.YCoord = (UInt16)BitConverter.ToInt16(data, 8); + } + public override String ToString() => "Touch Tapped on x: " + this.XCoord + ", y: " + this.YCoord; + } + + public class TouchPressEvent : EventArgs, IReportsIn { + public Byte Fingers { + get; + } + public UInt16 XCoord { + get; + } + public UInt16 YCoord { + get; + } + public TouchPressEvent(Byte[] data) { + this.Fingers = data[5]; + this.XCoord = (UInt16)BitConverter.ToInt16(data, 6); + this.YCoord = (UInt16)BitConverter.ToInt16(data, 8); + } + public override String ToString() => "Touch Pressed on x: " + this.XCoord + ", y: " + this.YCoord; + } + + public class TouchFlickEvent : EventArgs, IReportsIn { + public Byte Fingers { + get; + } + public UInt16 XCoordStart { + get; + } + public UInt16 YCoordStart { + get; + } + public UInt16 XCoordStop { + get; + } + public UInt16 YCoordStop { + get; + } + + public TouchFlickEvent(Byte[] data) { + this.Fingers = data[5]; + this.XCoordStart = (UInt16)BitConverter.ToInt16(data, 6); + this.YCoordStart = (UInt16)BitConverter.ToInt16(data, 8); + this.XCoordStop = (UInt16)BitConverter.ToInt16(data, 10); + this.YCoordStop = (UInt16)BitConverter.ToInt16(data, 12); + } + public override String ToString() => "Touch Swiped from x: " + this.XCoordStart + ", y: " + this.YCoordStart + " to x: " + this.XCoordStop + ", y: " + this.YCoordStop; + } + + public class EncoderPressEvent : EventArgs, IReportsIn { + private const Int32 DATA_OFFSET = 5; + public Byte EncoderCount { + get; + } + public Dictionary EncoderStates { + get; + } + public EncoderPressEvent(Byte[] data) { + this.EncoderCount = (Byte)(data[2] - 1); + if(this.EncoderCount + DATA_OFFSET <= data.Length) { + this.EncoderStates = []; + for(Int32 i = 0; i < this.EncoderCount; i++) { + this.EncoderStates.Add(i, data[i + DATA_OFFSET] == 0x01); + } + } + } + public override String ToString() => "Encoder Presed: [" + String.Join(", ", this.EncoderStates) + "]"; + } + + public class EncoderRotateEvent : EventArgs, IReportsIn { + private const Int32 DATA_OFFSET = 5; + public Byte EncoderCount { + get; + } + public Dictionary EncoderStates { + get; + } + public EncoderRotateEvent(Byte[] data) { + this.EncoderCount = (Byte)(data[2] - 1); + if(this.EncoderCount + DATA_OFFSET <= data.Length) { + this.EncoderStates = []; + for(Int32 i = 0; i < this.EncoderCount; i++) { + this.EncoderStates.Add(i, (SByte)data[i + DATA_OFFSET]); + } + } + } + public override String ToString() => "Encoder Turned Steps: [" + String.Join(", ", this.EncoderStates) + "]"; + } +} + diff --git a/StreamDeckSharp/Internals/StreamDeckHidWrapper.cs b/StreamDeckSharp/Internals/StreamDeckHidWrapper.cs new file mode 100644 index 0000000..772daf1 --- /dev/null +++ b/StreamDeckSharp/Internals/StreamDeckHidWrapper.cs @@ -0,0 +1,298 @@ +using HidSharp; +using OpenMacroBoard.SDK; +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; + +namespace StreamDeckSharp.Internals { + internal sealed class StreamDeckHidWrapper : IStreamDeckHid { + private readonly Object hidStreamLock = new(); + private readonly String devicePath; + + /// + /// Used to throttle write speed. + /// + /// + /// + /// Based on a hand full of speed measurements, it looks like that (at least) + /// the classical stream deck (hardware revision 1) can't keep up with full USB 2.0 speed. + /// + /// + /// For the other devices this limit is also active but probably not relevant, + /// because in practice the speed is slower, because all other devices use + /// JPEG instead of BMP and the Hid.Write probably also blocks as long as the device is busy. + /// + /// + /// The limit was determined by the following measurements with a classical stream deck: + /// + /// write speed -> time between glitches
+ /// 3.90 MiB/s -> 1.7s
+ /// 3.68 MiB/s -> 3.7s
+ /// 3.60 MiB/s -> 7.6s
+ ///
+ /// + /// Based on the assumption, that the stream deck has a maximum speed at which data is processed, + /// the following formular can be used: + /// + /// + /// Measured speed ............ s
+ /// Time between glitches ..... t
+ /// Internal speed ............ x (to be calculated)
+ /// Hardware buffer size ...... b (will be eliminated when solving for x)
+ ///
+ /// (s - x) * t = b + /// (s1 - x) * t1 = (s2 - x) * t2 + /// + /// When solved for x and evaluated with all the measured pairs, the calculated internal speed + /// of the classical stream deck seems to be (almost exactly?) 3.50 MiB/s - A few tests indeed + /// showed that limiting the speed below that value seems to prevent glitches. + /// + /// + /// So long story short we set a limit of 3'200'000 bytes/s (~3.0 MiB/s) + /// for all devices that can't keep up or I haven't had the chance to test on a + /// particular Elgato Device (for example I don't own a StreamDeck Rev2) and for + /// other devices that work as expected we set (unlimited). + /// + ///
+ private readonly Throttle throttle; + + private readonly IStreamDeckHidComDriver hardwareInfo; + private HidStream dStream; + private Byte[] readReportBuffer; + + public StreamDeckHidWrapper(HidDevice device, IStreamDeckHidComDriver hardwareInfo) { + if(device is null) { + throw new ArgumentNullException(nameof(device)); + } + + this.hardwareInfo = hardwareInfo ?? throw new ArgumentNullException(nameof(hardwareInfo)); + + if(hardwareInfo.BytesPerSecondLimit < Double.PositiveInfinity) { + this.throttle = new() { + BytesPerSecondLimit = hardwareInfo.BytesPerSecondLimit + }; + } + + this.devicePath = device.DevicePath; + DeviceList.Local.Changed += this.Local_Changed; + + this.InitializeDeviceSettings(device); + this.OpenConnection(device); + } + + public event EventHandler ConnectionStateChanged; + public event EventHandler ReportReceived; + + public Int32 OutputReportLength { + get; private set; + } + public Int32 FeatureReportLength { + get; private set; + } + + public Boolean IsConnected => this.dStream != null; + + public void Dispose() { + this.DisposeConnection(); + } + + public Boolean ReadFeatureData(Byte id, out Byte[] data) { + data = new Byte[this.FeatureReportLength]; + data[0] = id; + + HidStream targetStream = this.dStream; + + if(targetStream is null) { + return false; + } + + try { + lock(this.hidStreamLock) { + this.throttle?.MeasureAndBlock(data.Length); + targetStream.GetFeature(data); + return true; + } + } catch(Exception ex) when(ex is TimeoutException or IOException) { + this.DisposeConnection(); + return false; + } + } + + public Boolean WriteFeature(Byte[] featureData) { + if(featureData.Length != this.FeatureReportLength) { + var resizedData = new Byte[this.FeatureReportLength]; + var minLen = Math.Min(this.FeatureReportLength, featureData.Length); + Array.Copy(featureData, 0, resizedData, 0, minLen); + featureData = resizedData; + } + + HidStream targetStream = this.dStream; + + if(targetStream is null) { + return false; + } + + try { + lock(this.hidStreamLock) { + this.throttle?.MeasureAndBlock(featureData.Length); + targetStream.SetFeature(featureData); + } + + return true; + } catch(Exception ex) when(IsConnectionError(ex)) { + this.DisposeConnection(); + return false; + } + } + + public Boolean WriteReport(Byte[] reportData) { + HidStream targetStream = this.dStream; + + if(targetStream is null) { + return false; + } + + try { + lock(this.hidStreamLock) { + this.throttle?.MeasureAndBlock(reportData.Length); + targetStream.Write(reportData); + } + + return true; + } catch(Exception ex) when(IsConnectionError(ex)) { + this.DisposeConnection(); + return false; + } + } + + private static Boolean IsConnectionError(Exception ex) { + if(ex is TimeoutException) { + return true; + } + + if(ex is IOException) { + return true; + } + + if(ex is ObjectDisposedException) { + return true; + } + + return false; + } + + private void OpenConnection(HidDevice device) { + if(device == null) { + return; + } + + if(this.dStream != null) { + return; + } + + if(device.TryOpen(out HidStream stream)) { + stream.ReadTimeout = Timeout.Infinite; + this.dStream = stream; + this.BeginWaitRead(stream); + ConnectionStateChanged?.Invoke(this, new ConnectionEventArgs(true)); + } + } + + private void Local_Changed(Object sender, DeviceListChangedEventArgs e) { + this.RefreshConnection(); + } + + private void InitializeDeviceSettings(HidDevice device) { + var inputReportLength = device.GetMaxInputReportLength(); + this.OutputReportLength = device.GetMaxOutputReportLength(); + this.FeatureReportLength = device.GetMaxFeatureReportLength(); + + Debug.Assert( + this.OutputReportLength == this.hardwareInfo.ExpectedOutputReportLength, + $"Output report length unexpected. Found: {this.OutputReportLength}. Expected: {this.hardwareInfo.ExpectedOutputReportLength}" + ); + + Debug.Assert( + this.FeatureReportLength == this.hardwareInfo.ExpectedFeatureReportLength, + $"Feature report length unexpected. Found: {this.FeatureReportLength}. Expected: {this.hardwareInfo.ExpectedFeatureReportLength}" + ); + + Debug.Assert( + inputReportLength == this.hardwareInfo.ExpectedInputReportLength, + $"Input report length unexpected. Found: {inputReportLength}. Expected: {this.hardwareInfo.ExpectedInputReportLength}" + ); + + this.readReportBuffer = new Byte[this.OutputReportLength]; + } + + private void RefreshConnection() { + HidDevice device = DeviceList.Local + .GetHidDevices() + .FirstOrDefault(d => d.DevicePath == this.devicePath); + + var deviceFound = device != null; + var deviceActive = this.dStream != null; + + if(deviceFound == deviceActive) { + return; + } + + if(!deviceFound) { + this.DisposeConnection(); + } else { + this.OpenConnection(device); + } + } + + private void DisposeConnection() { + HidStream dStreamRefCopy = this.dStream; + this.dStream = null; + + if(dStreamRefCopy is null) { + return; + } + + dStreamRefCopy.Dispose(); + ConnectionStateChanged?.Invoke(this, new ConnectionEventArgs(false)); + } + + private void BeginWaitRead(HidStream stream) { + stream.BeginRead(this.readReportBuffer, 0, this.readReportBuffer.Length, new AsyncCallback(this.ReadReportCallback), stream); + } + + private void ReadReportCallback(IAsyncResult ar) { + HidStream stream = (HidStream)ar.AsyncState; + + try { + if(this.dStream == null) { + // connection already disposed + return; + } + + Int32 res = stream.EndRead(ar); + Byte[] data = new Byte[res]; + Array.Copy(this.readReportBuffer, 0, data, 0, res); + if(res > 4 && data[0] == 0x01 && data[1] == 0x00) { + this.ReportReceived?.Invoke(this, new ButtonPressEvent(data)); + } else if(res > 8 && data[0] == 0x01 && data[1] == 0x02 && data[4] == 0x01) { + this.ReportReceived?.Invoke(this, new TouchTapEvent(data)); + } else if(res > 8 && data[0] == 0x01 && data[1] == 0x02 && data[4] == 0x02) { + this.ReportReceived?.Invoke(this, new TouchPressEvent(data)); + } else if(res > 12 && data[0] == 0x01 && data[1] == 0x02 && data[4] == 0x03) { + this.ReportReceived?.Invoke(this, new TouchFlickEvent(data)); + } else if(res > 5 && data[0] == 0x01 && data[1] == 0x03 && data[4] == 0x00) { + this.ReportReceived?.Invoke(this, new EncoderPressEvent(data)); + } else if(res > 5 && data[0] == 0x01 && data[1] == 0x03 && data[4] == 0x01) { + this.ReportReceived?.Invoke(this, new EncoderRotateEvent(data)); + } + } catch(Exception ex) when(IsConnectionError(ex)) { + this.DisposeConnection(); + return; + } + + this.BeginWaitRead(stream); + } + } +} diff --git a/StreamDeckSharp/Internals/Throttle.cs b/StreamDeckSharp/Internals/Throttle.cs new file mode 100644 index 0000000..6a3478d --- /dev/null +++ b/StreamDeckSharp/Internals/Throttle.cs @@ -0,0 +1,45 @@ +using System; +using System.Diagnostics; +using System.Threading; + +#pragma warning disable AV1710 // Member name includes the name of its containing type + +namespace StreamDeckSharp.Internals +{ + internal class Throttle + { + private readonly Stopwatch stopwatch = Stopwatch.StartNew(); + private long sumBytesInWindow = 0; + private int sleepCount = 0; + + public double BytesPerSecondLimit { get; set; } = double.PositiveInfinity; + public int ByteCountBeforeThrottle { get; set; } = 16_000; + + public void MeasureAndBlock(int bytes) + { + this.sumBytesInWindow += bytes; + + var elapsedSeconds = this.stopwatch.Elapsed.TotalSeconds; + var estimatedSeconds = this.sumBytesInWindow / this.BytesPerSecondLimit; + + if (this.sumBytesInWindow > this.ByteCountBeforeThrottle && elapsedSeconds < estimatedSeconds) + { + var delta = Math.Max(1, (int)((estimatedSeconds - elapsedSeconds) * 1000)); + Thread.Sleep(delta); + this.sleepCount++; + } + + if (elapsedSeconds >= 1) + { + if (this.sleepCount > 1) + { + Debug.WriteLine($"[Throttle] {this.sumBytesInWindow / elapsedSeconds}"); + } + + this.stopwatch.Restart(); + this.sumBytesInWindow = 0; + this.sleepCount = 0; + } + } + } +} diff --git a/StreamDeckSharp/Internals/UsbHardwareIdAndDriver.cs b/StreamDeckSharp/Internals/UsbHardwareIdAndDriver.cs new file mode 100644 index 0000000..05f7121 --- /dev/null +++ b/StreamDeckSharp/Internals/UsbHardwareIdAndDriver.cs @@ -0,0 +1,28 @@ +using OpenMacroBoard.SDK; +using System.Collections.Generic; + +#pragma warning disable AV1000 // Type name contains the word 'and', which suggests it has multiple purposes + +namespace StreamDeckSharp.Internals +{ + internal sealed class UsbHardwareIdAndDriver : IUsbHidHardware + { + public UsbHardwareIdAndDriver( + IReadOnlyList usbIds, + string deviceName, + GridKeyLayout keys, + IStreamDeckHidComDriver driver + ) + { + this.UsbIds = usbIds; + this.DeviceName = deviceName; + this.Keys = keys; + this.Driver = driver; + } + + public IReadOnlyList UsbIds { get; } + public string DeviceName { get; } + public GridKeyLayout Keys { get; } + public IStreamDeckHidComDriver Driver { get; } + } +} diff --git a/StreamDeckSharp/StreamDeck.cs b/StreamDeckSharp/StreamDeck.cs new file mode 100644 index 0000000..00858e4 --- /dev/null +++ b/StreamDeckSharp/StreamDeck.cs @@ -0,0 +1,67 @@ +using HidSharp; +using OpenMacroBoard.SDK; +using StreamDeckSharp.Exceptions; +using StreamDeckSharp.Internals; +using System.Collections.Generic; +using System.Linq; +using System; + +namespace StreamDeckSharp { + /// + /// This is a factory class to create IStreamDeck References + /// + public static class StreamDeck { + /// + /// Enumerates connected Stream Decks and returns the first one. + /// + /// Thrown if no Stream Deck is found + public static IMacroBoard OpenDevice(params IUsbHidHardware[] hardware) { + return OpenDevice(true, hardware); + } + + /// + /// Enumerates connected Stream Decks and returns the first one. + /// + /// Thrown if no Stream Deck is found + public static IMacroBoard OpenDevice(Boolean useWriteCache, params IUsbHidHardware[] hardware) { + StreamDeckDeviceReference dev = EnumerateDevices(hardware).FirstOrDefault() ?? throw new StreamDeckNotFoundException(); + return dev.Open(); + } + + /// + /// Enumerates connected Stream Decks and returns the first one. + /// + /// Thrown if no Stream Deck is found + public static IMacroBoard OpenDevice(String devicePath) { + return OpenDevice(devicePath, true); + } + + /// + /// Get the Stream Deck with a given . + /// + /// Thrown if no Stream Deck is found + public static IMacroBoard OpenDevice(String devicePath, Boolean useWriteCache) { + HidDevice dev = DeviceList.Local.GetHidDevices().First(d => d.DevicePath == devicePath); + return FromHid(dev ?? throw new StreamDeckNotFoundException(), useWriteCache); + } + + /// + /// Enumerate Elgato Stream Deck Devices that match a given type. + /// + /// If no types or null is passed as argument, all known types are found + public static IEnumerable EnumerateDevices(params IUsbHidHardware[] hardware) { + return DeviceList.Local.GetStreamDecks(hardware); + } + + internal static IMacroBoard FromHid(HidDevice device, Boolean cached) { + UsbHardwareIdAndDriver hwInfo = device.GetHardwareInformation(); + StreamDeckHidWrapper hidWrapper = new StreamDeckHidWrapper(device, hwInfo.Driver); + + if(cached) { + return new CachedHidClient(hidWrapper, hwInfo.Keys, hwInfo.Driver); + } + + return new BasicHidClient(hidWrapper, hwInfo.Keys, hwInfo.Driver); + } + } +} diff --git a/StreamDeckSharp/StreamDeckDeviceReference.cs b/StreamDeckSharp/StreamDeckDeviceReference.cs new file mode 100644 index 0000000..8238d26 --- /dev/null +++ b/StreamDeckSharp/StreamDeckDeviceReference.cs @@ -0,0 +1,83 @@ +using OpenMacroBoard.SDK; +using System; + +namespace StreamDeckSharp +{ + /// + /// A device reference pointing to a stream deck. + /// + public sealed class StreamDeckDeviceReference : IDeviceReference + { + internal StreamDeckDeviceReference( + String devicePath, + String deviceName, + GridKeyLayout keyLayout + ) + { + this.DevicePath = devicePath; + this.DeviceName = deviceName; + this.Keys = keyLayout; + } + + /// + /// Gets the OSes unique identifier for human interface device. + /// + public String DevicePath { get; } + + /// + public String DeviceName { get; set; } + + /// + public IKeyLayout Keys { get; } + + /// + public override String ToString() + { + return this.DeviceName; + } + + /// + public IMacroBoard Open(Boolean useWriteCache) + { + return StreamDeck.OpenDevice(this.DevicePath, useWriteCache); + } + + /// + public IMacroBoard Open() + { + return StreamDeck.OpenDevice(this.DevicePath); + } + + /// + public override Int32 GetHashCode() + { + return this.DevicePath.GetHashCode(); + } + + /// + public override Boolean Equals(Object obj) + { + if (obj is null) + { + return false; + } + + if (ReferenceEquals(this, obj)) + { + return true; + } + + if (obj is not StreamDeckDeviceReference other) + { + return false; + } + + if (other.DevicePath != this.DevicePath) + { + return false; + } + + return true; + } + } +} diff --git a/StreamDeckSharp/StreamDeckListener.cs b/StreamDeckSharp/StreamDeckListener.cs new file mode 100644 index 0000000..ac034c6 --- /dev/null +++ b/StreamDeckSharp/StreamDeckListener.cs @@ -0,0 +1,143 @@ +using System; +using HidSharp; +using OpenMacroBoard.SDK; +using StreamDeckSharp.Internals; +using System.Collections.Generic; +using System.Linq; + +namespace StreamDeckSharp { + /// + /// A listener for stream deck devices. + /// + public sealed class StreamDeckListener : + IDisposable, + IObservable { + private readonly Object sync = new(); + private readonly List knownDevices = new(); + private readonly List subscriptions = new(); + private readonly Dictionary knownDeviceLookup = new(); + + private Boolean disposed = true; + + /// + /// Initializes a new instance of the class. + /// + public StreamDeckListener() { + // register event handler before we load the entire list + // so we don't miss stream decks connecting between the calls. + DeviceList.Local.Changed += this.DeviceListChanged; + + // initial force load + this.ProcessDelta(); + } + + /// + public IDisposable Subscribe(IObserver observer) { + Subscription subscription = new Subscription(this, observer); + this.subscriptions.Add(subscription); + subscription.SendUpdates(); + return subscription; + } + + /// + public void Dispose() { + if(this.disposed) { + return; + } + + this.disposed = true; + DeviceList.Local.Changed -= this.DeviceListChanged; + } + + private void ProcessDelta() { + lock(this.sync) { + // because the HidDevice event doesn't tell us what changed + // we calculate the difference ourselves. + Dictionary currentDevices = DeviceList.Local + .GetStreamDecks() + .ToDictionary(s => s.DevicePath, s => s); + + // update connection states of known devices + foreach(DeviceState knownDevice in this.knownDevices) { + knownDevice.Connected = currentDevices.ContainsKey(knownDevice.DeviceReference.DevicePath); + } + + // add new devices + foreach(KeyValuePair currentDevice in currentDevices) { + if(this.knownDeviceLookup.ContainsKey(currentDevice.Key)) { + // skip because this one is already known + continue; + } + + this.knownDeviceLookup.Add(currentDevice.Key, currentDevice.Value); + this.knownDevices.Add(new DeviceState(currentDevice.Value, true)); + } + + // send updates to all subscribers + foreach(Subscription subscription in this.subscriptions) { + subscription.SendUpdates(); + } + } + } + + private void DeviceListChanged(Object sender, DeviceListChangedEventArgs e) { + this.ProcessDelta(); + } + + private sealed class DeviceState { + public DeviceState(StreamDeckDeviceReference deviceReference, Boolean connected) { + this.DeviceReference = deviceReference ?? throw new ArgumentNullException(nameof(deviceReference)); + this.Connected = connected; + } + + public StreamDeckDeviceReference DeviceReference { + get; + } + public Boolean Connected { + get; set; + } + } + + private sealed class Subscription : IDisposable { + private readonly StreamDeckListener parent; + private readonly IObserver observer; + + /// + /// Contains the state the subscriber knows about. + /// This is used to calculate new updates. + /// + private readonly List subscriberState = new(); + + public Subscription(StreamDeckListener parent, IObserver observer) { + this.parent = parent ?? throw new ArgumentNullException(nameof(parent)); + this.observer = observer ?? throw new ArgumentNullException(nameof(observer)); + } + + public void SendUpdates() { + // send updates for existing devices + for(Int32 i = 0; i < this.subscriberState.Count; i++) { + DeviceState device = this.parent.knownDevices[i]; + + if(device.Connected != this.subscriberState[i]) { + // report new connection state + this.observer.OnNext(new DeviceStateReport(device.DeviceReference, device.Connected, false)); + this.subscriberState[i] = device.Connected; + } + } + + // add and send updates for new (to this subscriber) devices. + for(Int32 i = this.subscriberState.Count; i < this.parent.knownDevices.Count; i++) { + DeviceState device = this.parent.knownDevices[i]; + this.subscriberState.Add(device.Connected); + this.observer.OnNext(new DeviceStateReport(device.DeviceReference, device.Connected, true)); + } + } + + public void Dispose() { + lock(this.parent.sync) { + this.parent.subscriptions.Remove(this); + } + } + } + } +} diff --git a/StreamDeckSharp/StreamDeckSharp.csproj b/StreamDeckSharp/StreamDeckSharp.csproj new file mode 100644 index 0000000..f84cd91 --- /dev/null +++ b/StreamDeckSharp/StreamDeckSharp.csproj @@ -0,0 +1,17 @@ + + + + net10.0 + + + + + + + + + + + + + diff --git a/StreamDeckSharp/StreamDeckSharp.nuspec b/StreamDeckSharp/StreamDeckSharp.nuspec new file mode 100644 index 0000000..2cfa980 --- /dev/null +++ b/StreamDeckSharp/StreamDeckSharp.nuspec @@ -0,0 +1,32 @@ + + + + StreamDeckSharp + $version$ + StreamDeckSharp + Christian Franzl + MIT + + https://github.com/OpenMacroBoard/StreamDeckSharp + https://raw.githubusercontent.com/OpenMacroBoard/StreamDeckSharp/master/doc/icon64.png + icon.png + false + A simple .NET interface for the StreamDeck HID + streamdeck elgato stream deck open macro board openmacroboard streamdeckmini + + + + + + + + + + + + + + + + + diff --git a/StreamDeckSharp/UsbConstants.cs b/StreamDeckSharp/UsbConstants.cs new file mode 100644 index 0000000..f0225b3 --- /dev/null +++ b/StreamDeckSharp/UsbConstants.cs @@ -0,0 +1,31 @@ +using System; + +namespace StreamDeckSharp +{ + /// + /// A collection of Stream Deck USB related constants. + /// + public static class UsbConstants + { + /// + /// Helper function to create a for Elgato devices + /// (with the vendor id ). + /// + /// USB product id. + public static UsbVendorProductPair ElgatoUsbId(int productId) + { + return new UsbVendorProductPair(VendorIds.ElgatoSystemsGmbH, productId); + } + + /// + /// Known (Stream Deck related) USB Vendor IDs. + /// + public static class VendorIds + { + /// + /// The USB Vendor ID for Elgato Systems GmbH. + /// + public const Int32 ElgatoSystemsGmbH = 0x0fd9; + } + } +} diff --git a/StreamDeckSharp/UsbVendorProductPair.cs b/StreamDeckSharp/UsbVendorProductPair.cs new file mode 100644 index 0000000..048b24b --- /dev/null +++ b/StreamDeckSharp/UsbVendorProductPair.cs @@ -0,0 +1,80 @@ +using System; + +namespace StreamDeckSharp +{ + /// + /// Fully quallified USB product identifier. Includes the USB Vendor ID and the USB Product ID. + /// + public readonly struct UsbVendorProductPair : IEquatable + { + /// + /// Initializes a new instance of the struct. + /// + public UsbVendorProductPair(Int32 vendorId, Int32 productId) + { + this.UsbVendorId = vendorId; + this.UsbProductId = productId; + } + + /// + /// USB vendor id + /// + public Int32 UsbVendorId { get; } + + /// + /// USB product id + /// + public Int32 UsbProductId { get; } + + /// + /// The == operator. Calls internally. + /// + public static Boolean operator ==(UsbVendorProductPair a, UsbVendorProductPair b) + { + return Equals(a, b); + } + + /// + /// The == operator. Calls internally + /// and inverts the result. + /// + public static Boolean operator !=(UsbVendorProductPair a, UsbVendorProductPair b) + { + return !Equals(a, b); + } + + /// + /// Indicates whether the two givel objects is equal. + /// + /// First object. + /// Second object. + /// true if the two objects are equal; otherwise, false. + public static Boolean Equals(UsbVendorProductPair a, UsbVendorProductPair b) + { + return a.UsbVendorId == b.UsbVendorId && a.UsbProductId == b.UsbProductId; + } + + /// + public Boolean Equals(UsbVendorProductPair other) + { + return Equals(this, other); + } + + /// + public override Boolean Equals(Object obj) + { + if (obj is not UsbVendorProductPair other) + { + return false; + } + + return Equals(this, other); + } + + /// + public override Int32 GetHashCode() + { + return this.UsbVendorId.GetHashCode() ^ this.UsbProductId.GetHashCode(); + } + } +} diff --git a/StreamDeckSharp/icon.png b/StreamDeckSharp/icon.png new file mode 100644 index 0000000..8979d21 Binary files /dev/null and b/StreamDeckSharp/icon.png differ