From 30c7bc4111ef476adf46d845db44279373c8c5f8 Mon Sep 17 00:00:00 2001 From: Ben Hutchison Date: Sun, 11 Jun 2023 05:12:39 -0700 Subject: [PATCH] Extract common HID logic into separate HidClient NuGet package so it can be used by WebScale as well. Try to fix problem where PowerMate device light state gets out of sync with the state this program most recently set, usually caused by the computer resuming from standby: when receiving a HID event (from a rotation or press), check the HID message to see if the actual light state is the same as the expected state, and if not, re-send the expected state as long (as it was not set too recently, to avoid an overlapping read/write race). Fix a crash in volume control program where it can't handle no default audio output devices on startup. --- PowerMate/IPowerMateClient.cs | 30 +--- PowerMate/PowerMate.csproj | 4 +- PowerMate/PowerMateClient.cs | 198 +++++-------------------- PowerMate/PowerMateInput.cs | 52 ++++++- PowerMateVolume/PowerMateVolume.cs | 11 +- PowerMateVolume/PowerMateVolume.csproj | 3 +- PowerMateVolume/VolumeChanger.cs | 47 +++--- PowerMateVolume/packages.lock.json | 10 +- Tests/.editorconfig | 41 +++++ Tests/PowerMateClientInputTest.cs | 104 +------------ Tests/PowerMateClientOutputTest.cs | 5 +- Tests/PowerMateInputTest.cs | 76 ++++++++++ Tests/Tests.csproj | 8 +- Tests/Usings.cs | 3 +- 14 files changed, 267 insertions(+), 325 deletions(-) create mode 100644 Tests/.editorconfig diff --git a/PowerMate/IPowerMateClient.cs b/PowerMate/IPowerMateClient.cs index 16c2ca5..d381315 100644 --- a/PowerMate/IPowerMateClient.cs +++ b/PowerMate/IPowerMateClient.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using HidClient; namespace PowerMate; @@ -6,42 +7,17 @@ namespace PowerMate; /// Listen for events and control the light on a connected Griffin PowerMate USB device. /// To get started, construct a new instance of . /// Once you have constructed an instance, you can subscribe to events to be notified when the PowerMate knob is rotated, pressed, or released. -/// You can also subscribe to or to be notified when it connects or disconnects from a PowerMate. +/// You can also subscribe to or to be notified when it connects or disconnects from a PowerMate. /// The light is controlled by the , , and properties. /// Remember to dispose of this instance when you're done using it by calling , or with a statement or declaration. /// -public interface IPowerMateClient: IDisposable, INotifyPropertyChanged { - - /// - /// if the client is currently connected to a PowerMate device, or if it is disconnected, possibly because there is no PowerMate device - /// plugged into the computer. - /// will automatically try to connect to a PowerMate device when you construct a new instance, so you don't have to call any additional methods in order to make - /// it start connecting. - /// If a PowerMate device is plugged in, will already be by the time the constructor returns. - /// To receive notifications when this property changes, you can subscribe to the or events. - /// - bool IsConnected { get; } +public interface IPowerMateClient: IHidClient { /// /// Fired whenever the PowerMate knob is rotated, pressed, or released. /// event EventHandler InputReceived; - /// - /// Fired whenever the connection state of the PowerMate changes. Not fired when constructing or disposing the instance. - /// The event argument contains the new value of . - /// This value can also be accessed at any time by reading the property. - /// If you want to use data binding which expects events, also implements - /// , so you can use that event instead. - /// - event EventHandler IsConnectedChanged; - - /// - /// on which to run event callbacks. Useful if your delegates need to update a user interface on the main thread. Callbacks run on the current thread by - /// default. - /// - SynchronizationContext EventSynchronizationContext { get; set; } - /// /// Get or set how bright the blue/cyan LED in the base of the PowerMate is, between 0 (off) and 255 (brightest), inclusive. When the device is first plugged in, it defaults to /// 80. diff --git a/PowerMate/PowerMate.csproj b/PowerMate/PowerMate.csproj index 8f81985..f0f3ce0 100644 --- a/PowerMate/PowerMate.csproj +++ b/PowerMate/PowerMate.csproj @@ -1,7 +1,7 @@ - 1.0.0 + 1.0.1 Ben Hutchison Ben Hutchison PowerMate @@ -36,7 +36,7 @@ - + diff --git a/PowerMate/PowerMateClient.cs b/PowerMate/PowerMateClient.cs index 5f1b35d..abab496 100644 --- a/PowerMate/PowerMateClient.cs +++ b/PowerMate/PowerMateClient.cs @@ -1,137 +1,53 @@ using System.ComponentModel; using System.Runtime.CompilerServices; +using HidClient; using HidSharp; namespace PowerMate; -/// -public class PowerMateClient: IPowerMateClient { +/// +public class PowerMateClient: AbstractHidClient, IPowerMateClient { - private const int PowerMateVendorId = 0x077d; - private const int PowerMateProductId = 0x0410; private const byte DefaultLightBrightness = 80; - private readonly object _hidStreamLock = new(); - - private DeviceList? _deviceList; - private CancellationTokenSource? _cancellationTokenSource; - private bool _isConnected; - private HidStream? _hidStream; - private byte _lightBrightness = DefaultLightBrightness; - private LightAnimation _lightAnimation = LightAnimation.Solid; - private int _lightPulseSpeed = 12; - /// - public event EventHandler? IsConnectedChanged; + protected override int VendorId { get; } = 0x077d; /// - public event PropertyChangedEventHandler? PropertyChanged; + protected override int ProductId { get; } = 0x0410; + + private byte _lightBrightness = DefaultLightBrightness; + private LightAnimation _lightAnimation = LightAnimation.Solid; + private int _lightPulseSpeed = 12; + private DateTime? _mostRecentFeatureSetTime; /// public event EventHandler? InputReceived; /// - public SynchronizationContext EventSynchronizationContext { get; set; } = SynchronizationContext.Current ?? new SynchronizationContext(); - - /// - /// Constructs a new instance that communicates with a PowerMate device. - /// Upon construction, the new instance will immediately attempt to connect to any PowerMate connected to your computer. If none are connected, it will wait and connect when one is plugged - /// in. If a PowerMate disconnects, it will try to reconnect whenever one is plugged in again. - /// If multiple PowerMate devices are present, it will pick one arbitrarily and connect to it. - /// Once you have constructed an instance, you can subscribe to events to be notified when the PowerMate knob is rotated, pressed, or released. - /// You can also subscribe to or to be notified when it connects or disconnects from a PowerMate. - /// The light is controlled by the , , and properties. - /// Remember to dispose of this instance when you're done using it by calling , or with a statement or declaration. - /// - public PowerMateClient(): this(DeviceList.Local) { } - - internal PowerMateClient(DeviceList deviceList) { - _deviceList = deviceList; - _deviceList.Changed += onDeviceListChanged; - AttachToDevice(); - } + public PowerMateClient() { } /// - public bool IsConnected { - get => _isConnected; - private set { - if (value != _isConnected) { - _isConnected = value; - EventSynchronizationContext.Post(_ => { - IsConnectedChanged?.Invoke(this, value); - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsConnected))); - }, null); - } - } - } + public PowerMateClient(DeviceList deviceList): base(deviceList) { } - private void onDeviceListChanged(object? sender, DeviceListChangedEventArgs e) { - AttachToDevice(); - } - - private void AttachToDevice() { - bool isNewStream = false; - lock (_hidStreamLock) { - if (_hidStream == null) { - HidDevice? newDevice = _deviceList?.GetHidDeviceOrNull(PowerMateVendorId, PowerMateProductId); - if (newDevice != null) { - _hidStream = newDevice.Open(); - isNewStream = true; - } - } - } - - if (_hidStream != null && isNewStream) { - _hidStream.Closed += ReattachToDevice; - _hidStream.ReadTimeout = Timeout.Infinite; - _cancellationTokenSource = new CancellationTokenSource(); - IsConnected = true; - LightAnimation = LightAnimation; //resend all pulsing and brightness values to device - - try { - Task.Factory.StartNew(HidReadLoop, _cancellationTokenSource.Token, TaskCreationOptions.LongRunning, TaskScheduler.Default); - } catch (TaskCanceledException) { } - } - } - - private async Task HidReadLoop() { - CancellationToken cancellationToken = _cancellationTokenSource!.Token; - - try { - byte[] readBuffer = new byte[7]; - while (!cancellationToken.IsCancellationRequested) { - int readBytes = await _hidStream!.ReadAsync(readBuffer, 0, readBuffer.Length, cancellationToken); - if (readBuffer.Length == readBytes) { - PowerMateInput powerMateInput = new(readBuffer); - EventSynchronizationContext.Post(_ => { InputReceived?.Invoke(this, powerMateInput); }, null); - } - } - } catch (IOException) { - ReattachToDevice(); - } + /// + protected override void OnConnect() { + LightAnimation = LightAnimation; //resend all pulsing and brightness values to device } - private void ReattachToDevice(object? sender = null, EventArgs? e = null) { - bool disconnected = false; - lock (_hidStreamLock) { - if (_hidStream != null) { - _hidStream.Closed -= ReattachToDevice; - _hidStream.Close(); - _hidStream.Dispose(); - _hidStream = null; - disconnected = true; - } - } - - if (disconnected) { - IsConnected = false; + /// + protected override void OnHidRead(byte[] readBuffer) { + Console.WriteLine($"Read HID bytes {string.Join(" ", readBuffer.Select(b => $"{b:x2}"))}"); //FIXME development + PowerMateInput input = new(readBuffer); + EventSynchronizationContext.Post(_ => { InputReceived?.Invoke(this, input); }, null); + + if ((LightAnimation != input.ActualLightAnimation + || (LightAnimation != LightAnimation.Pulsing && LightBrightness != input.ActualLightBrightness) + || (LightAnimation == LightAnimation.Pulsing && LightPulseSpeed != input.ActualLightPulseSpeed)) + && _mostRecentFeatureSetTime is not null && DateTime.Now - _mostRecentFeatureSetTime > TimeSpan.FromMilliseconds(500)) { + Console.WriteLine("Resetting features..."); + LightAnimation = LightAnimation; } - - try { - _cancellationTokenSource?.Cancel(); - } catch (AggregateException) { } - - AttachToDevice(); } /// @@ -193,12 +109,22 @@ public class PowerMateClient: IPowerMateClient { // ExceptionAdjustment: M:System.Array.Copy(System.Array,System.Int32,System.Array,System.Int32,System.Int32) -T:System.RankException // ExceptionAdjustment: M:System.Array.Copy(System.Array,System.Int32,System.Array,System.Int32,System.Int32) -T:System.ArrayTypeMismatchException private void SetFeature(PowerMateFeature feature, params byte[] payload) { + _mostRecentFeatureSetTime = DateTime.Now; byte[] featureData = { 0x00, 0x41, 0x01, (byte) feature, 0x00, /* payload copied here */ 0x00, 0x00, 0x00, 0x00 }; Array.Copy(payload, 0, featureData, 5, Math.Min(payload.Length, 4)); - _hidStream?.SetFeature(featureData); + try { + DeviceStream?.SetFeature(featureData); + } catch (IOException e) { + if (e.InnerException is Win32Exception { NativeErrorCode: 0 }) { + // retry once with no delay if we get a "The operation completed successfully" error + DeviceStream?.SetFeature(featureData); + } + } } + /// in the range [0, 24] + /// two big-endian bytes to send to the PowerMate to set its pulsing speed private static byte[] EncodePulseSpeed(int pulseSpeed) { byte[] encoded = BitConverter.GetBytes((ushort) (pulseSpeed switch { < 8 => Math.Min(0x00e, (7 - pulseSpeed) * 2), @@ -210,56 +136,12 @@ public class PowerMateClient: IPowerMateClient { (encoded[0], encoded[1]) = (encoded[1], encoded[0]); } + Console.WriteLine($"Encoded pulse speed {pulseSpeed} to {encoded[0]:x2} {encoded[1]:x2}"); //FIXME debugging return encoded; } private void TriggerPropertyChangedEvent([CallerMemberName] string propertyName = "") { - EventSynchronizationContext.Post(_ => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)), null); - } - - /// - /// Clean up managed and, optionally, unmanaged resources. - /// When inheriting from , you should override this method, dispose of your managed resources if is , then - /// free your unmanaged resources regardless of the value of , and finally call this base implementation. - /// For more information, see Implement - /// the dispose pattern for a derived class. - /// - /// Should be when called from a finalizer, and when called from the method. In other words, it is - /// when deterministically called and when non-deterministically called. - protected virtual void Dispose(bool disposing) { - if (disposing) { - try { - _cancellationTokenSource?.Cancel(); - _cancellationTokenSource?.Dispose(); - _cancellationTokenSource = null; - } catch (AggregateException) { } - - lock (_hidStreamLock) { - if (_hidStream != null) { - _hidStream.Closed -= ReattachToDevice; - _hidStream.Close(); - _hidStream.Dispose(); - _hidStream = null; - } - } - - if (_deviceList != null) { - _deviceList.Changed -= onDeviceListChanged; - _deviceList = null; - } - } - } - - /// - /// Disconnect from any connected PowerMate device and clean up managed resources. - /// and events will not be emitted if a PowerMate is disconnected during disposal. - /// Subclasses of should override . - /// For more information, see Cleaning Up Unmanaged Resources and - /// Implementing a Dispose Method. - /// - public void Dispose() { - Dispose(true); - GC.SuppressFinalize(this); + EventSynchronizationContext.Post(_ => OnPropertyChanged(propertyName), null); } } \ No newline at end of file diff --git a/PowerMate/PowerMateInput.cs b/PowerMate/PowerMateInput.cs index c80f95c..ac40180 100644 --- a/PowerMate/PowerMateInput.cs +++ b/PowerMate/PowerMateInput.cs @@ -3,6 +3,16 @@ /// /// Event data describing how the PowerMate knob was rotated, pressed, or released. Emitted by . /// +/// +/// This is received as a 7-byte array. +/// 0: always 0? +/// 1: 1 if knob is pushed down, 0 otherwise +/// 2: knob rotation direction and distance, or 0 if not rotated +/// 3: always 0? +/// 4: led brightness, 0-255, reflects the pulsing brightness too +/// 5: 0x10 when not pulsing, 0x21 when pulsing at speed 9, 10, or 11, 0x11 pulse speed 8 +/// 6: 0 when not pulsing, 0x1 speed 8, 0x2 speed 9, 0x4 when pulsing with speed 10, 0x6 when pulsing with speed 11, 0x8 when pulsing with speed 12 +/// public readonly struct PowerMateInput { /// @@ -27,11 +37,16 @@ /// public readonly uint RotationDistance = 0; + internal readonly byte ActualLightBrightness; + internal readonly LightAnimation ActualLightAnimation; + internal readonly int ActualLightPulseSpeed; + /// /// Parse input from raw HID bytes. /// /// 7-item byte array from the HID update - public PowerMateInput(IReadOnlyList rawData): this(rawData[1] == 0x01, GetRotationDirection(rawData), GetRotationDistance(rawData)) { } + public PowerMateInput(IReadOnlyList rawData): this(rawData[1] == 0x01, DecodeRotationDirection(rawData), DecodeRotationDistance(rawData), rawData[4], DecodeLightAnimation(rawData), + DecodeLightPulseSpeed(rawData)) { } /// /// Construct a synthetic instance, useful for testing. @@ -45,18 +60,39 @@ /// events, each with this set to 1. As you rotate it faster, updates are batched and this number increases to 2 or more. The highest value I have seen is 8.This /// is always non-negative, regardless of the rotation direction; use to determine the direction.If the knob is pressed without being rotated, this /// will be 0 and will be . - public PowerMateInput(bool isPressed, RotationDirection rotationDirection, uint rotationDistance) { - IsPressed = isPressed; - RotationDirection = rotationDirection; - RotationDistance = rotationDistance; + public PowerMateInput(bool isPressed, RotationDirection rotationDirection, uint rotationDistance): this(isPressed, rotationDirection, rotationDistance, 0, LightAnimation.Solid, 0) { } + + private PowerMateInput(bool isPressed, RotationDirection rotationDirection, uint rotationDistance, byte actualLightBrightness, LightAnimation actualLightAnimation, int actualLightPulseSpeed) { + IsPressed = isPressed; + RotationDirection = rotationDirection; + RotationDistance = rotationDistance; + ActualLightBrightness = actualLightBrightness; + ActualLightAnimation = actualLightAnimation; + ActualLightPulseSpeed = actualLightPulseSpeed; } - private static uint GetRotationDistance(IReadOnlyList rawData) => (uint) Math.Abs((int) (sbyte) rawData[2]); + private static uint DecodeRotationDistance(IReadOnlyList rawData) => (uint) Math.Abs((int) (sbyte) rawData[2]); - private static RotationDirection GetRotationDirection(IReadOnlyList rawData) => rawData[2] switch { + private static RotationDirection DecodeRotationDirection(IReadOnlyList rawData) => rawData[2] switch { > 0 and < 128 => RotationDirection.Clockwise, > 128 => RotationDirection.Counterclockwise, - 0 or _ => RotationDirection.None + _ => RotationDirection.None + }; + + /// on unknown values + private static LightAnimation DecodeLightAnimation(IReadOnlyList rawData) => (rawData[5] & 0b111) switch { + 0 => LightAnimation.Solid, + 1 => LightAnimation.Pulsing, + 4 => LightAnimation.SolidWhileAwakeAndPulsingDuringComputerStandby, + _ => throw new ArgumentOutOfRangeException($"Unknown light animation read: 0x{rawData[5]:x2}") + }; + + /// on unknown values + private static int DecodeLightPulseSpeed(IReadOnlyList rawData) => (rawData[5] >> 4) switch { + 0 => 7 - rawData[6] / 2, + 1 => 7 + rawData[6], + 2 => 8 + rawData[6] / 2, + _ => throw new ArgumentOutOfRangeException($"Unknown light pulse speed read: 0x{rawData[5]:x2} 0x{rawData[6]:x2}") }; /// diff --git a/PowerMateVolume/PowerMateVolume.cs b/PowerMateVolume/PowerMateVolume.cs index 21d242f..13c59be 100644 --- a/PowerMateVolume/PowerMateVolume.cs +++ b/PowerMateVolume/PowerMateVolume.cs @@ -8,10 +8,10 @@ volumeIncrement = 0.01f; } -using IPowerMateClient powerMateClient = new PowerMateClient(); -using IVolumeChanger volumeChanger = new VolumeChanger { VolumeIncrement = volumeIncrement }; +using IPowerMateClient powerMate = new PowerMateClient(); +using IVolumeChanger volumeChanger = new VolumeChanger { VolumeIncrement = volumeIncrement }; -powerMateClient.LightBrightness = 0; +powerMate.LightBrightness = 0; CancellationTokenSource cancellationTokenSource = new(); Console.CancelKeyPress += (_, eventArgs) => { @@ -19,7 +19,7 @@ cancellationTokenSource.Cancel(); }; -powerMateClient.InputReceived += (_, powerMateEvent) => { +powerMate.InputReceived += (_, powerMateEvent) => { switch (powerMateEvent) { case { IsPressed: true, RotationDirection: RotationDirection.None }: volumeChanger.ToggleMute(); @@ -35,10 +35,11 @@ } }; +//FIXME remove this once the light settings are being reset based on HID reads, not the computer resuming from standby SystemEvents.PowerModeChanged += (_, args) => { if (args.Mode == PowerModes.Resume) { // #1: On Jarnsaxa, waking up from sleep resets the PowerMate's light settings, so set them all again - powerMateClient.LightAnimation = powerMateClient.LightAnimation; + powerMate.LightAnimation = powerMate.LightAnimation; } }; diff --git a/PowerMateVolume/PowerMateVolume.csproj b/PowerMateVolume/PowerMateVolume.csproj index 8b4d347..7d1d3a8 100644 --- a/PowerMateVolume/PowerMateVolume.csproj +++ b/PowerMateVolume/PowerMateVolume.csproj @@ -12,13 +12,14 @@ Ben Hutchison © 2023 $(Authors) PowerMate Volume - 1.0.0 + 1.0.1 $(AssemblyTitle) $(Version) app.manifest false true powermate.ico + 1701;1702;NU1701 diff --git a/PowerMateVolume/VolumeChanger.cs b/PowerMateVolume/VolumeChanger.cs index 9a1d0ab..13be51a 100644 --- a/PowerMateVolume/VolumeChanger.cs +++ b/PowerMateVolume/VolumeChanger.cs @@ -32,8 +32,8 @@ public class VolumeChanger: IVolumeChanger { private readonly MMDeviceEnumerator _mmDeviceEnumerator = new(); - private MMDevice? _defaultAudioEndpoint; - private AudioEndpointVolume? _audioEndpointVolume; + private MMDevice? _audioOutputEndpoint; + private AudioEndpointVolume? _audioOutputVolume; private float _volumeIncrement = 0.01f; @@ -51,33 +51,40 @@ public class VolumeChanger: IVolumeChanger { public VolumeChanger() { _mmDeviceEnumerator.DefaultDeviceChanged += onDefaultDeviceChanged; - AttachToDefaultDevice(); + AttachToDevice(); } - private void AttachToDefaultDevice(MMDevice? newDefaultAudioEndpoint = null) { - DetachFromDefaultDevice(); - _defaultAudioEndpoint = newDefaultAudioEndpoint ?? _mmDeviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); - _audioEndpointVolume = AudioEndpointVolume.FromDevice(_defaultAudioEndpoint); + private void AttachToDevice(MMDevice? newAudioEndpoint = null) { + DetachFromCurrentDevice(); + try { + _audioOutputEndpoint = newAudioEndpoint ?? _mmDeviceEnumerator.GetDefaultAudioEndpoint(DataFlow.Render, Role.Multimedia); + } catch (CoreAudioAPIException) { + // program started when there were no audio output devices connected + // leave _defaultAudioEndpoint null and wait for onDefaultDeviceChanged to update it when a device is connected + return; + } + + _audioOutputVolume = AudioEndpointVolume.FromDevice(_audioOutputEndpoint); } - private void DetachFromDefaultDevice() { - _audioEndpointVolume?.Dispose(); - _audioEndpointVolume = null; - _defaultAudioEndpoint?.Dispose(); - _defaultAudioEndpoint = null; + private void DetachFromCurrentDevice() { + _audioOutputVolume?.Dispose(); + _audioOutputVolume = null; + _audioOutputEndpoint?.Dispose(); + _audioOutputEndpoint = null; } public void IncreaseVolume(int increments = 1) { - if (_audioEndpointVolume is not null && increments != 0) { - float newVolume = Math.Max(0, Math.Min(1, _audioEndpointVolume.MasterVolumeLevelScalar + VolumeIncrement * increments)); - _audioEndpointVolume.MasterVolumeLevelScalar = newVolume; - Console.WriteLine($"Set volume to {newVolume:P2}"); + if (_audioOutputVolume is not null && increments != 0) { + float newVolume = Math.Max(0, Math.Min(1, _audioOutputVolume.MasterVolumeLevelScalar + VolumeIncrement * increments)); + _audioOutputVolume.MasterVolumeLevelScalar = newVolume; + // Console.WriteLine($"Set volume to {newVolume:P2}"); } } public void ToggleMute() { - if (_audioEndpointVolume is not null) { - _audioEndpointVolume.IsMuted ^= true; + if (_audioOutputVolume is not null) { + _audioOutputVolume.IsMuted ^= true; } } @@ -86,14 +93,14 @@ public class VolumeChanger: IVolumeChanger { eventArgs.TryGetDevice(out MMDevice? newDefaultAudioEndpoint); if (newDefaultAudioEndpoint is not null) { - AttachToDefaultDevice(newDefaultAudioEndpoint); + AttachToDevice(newDefaultAudioEndpoint); } } } protected virtual void Dispose(bool disposing) { if (disposing) { - DetachFromDefaultDevice(); + DetachFromCurrentDevice(); _mmDeviceEnumerator.DefaultDeviceChanged -= onDefaultDeviceChanged; _mmDeviceEnumerator.Dispose(); } diff --git a/PowerMateVolume/packages.lock.json b/PowerMateVolume/packages.lock.json index d4e7440..fb88f80 100644 --- a/PowerMateVolume/packages.lock.json +++ b/PowerMateVolume/packages.lock.json @@ -8,6 +8,14 @@ "resolved": "1.2.1.2", "contentHash": "DoQKUcdZLQawo3mOg8CvujJD9YkvTt6NtEu7S+OCo8+ySl8eCeUw4LFyL2E1OcAkPU918HZM6sUNcvIMcXWg6g==" }, + "HidClient": { + "type": "Transitive", + "resolved": "1.0.1", + "contentHash": "VtwKEL99yG3NHdTNbJQyyrx4KfzpQEmuIgWqd57f45pHf0qOHnzFjJ4lRKjmKIkNu28QXCVhHKnfV7ORyX+Z1w==", + "dependencies": { + "HidSharp": "2.1.0" + } + }, "HidSharp": { "type": "Transitive", "resolved": "2.1.0", @@ -16,7 +24,7 @@ "powermate": { "type": "Project", "dependencies": { - "HidSharp": "[2.1.0, )" + "HidClient": "[1.0.1, )" } } }, diff --git a/Tests/.editorconfig b/Tests/.editorconfig new file mode 100644 index 0000000..7fc29bf --- /dev/null +++ b/Tests/.editorconfig @@ -0,0 +1,41 @@ +[*] + +# Exception Analyzers: Exception adjustments syntax error +# default = error +; dotnet_diagnostic.Ex0001.severity = none + +# Exception Analyzers: Exception adjustments syntax error: Symbol does not exist or identifier is invalid +# default = warning +; dotnet_diagnostic.Ex0002.severity = none + +# Exception Analyzers: Member may throw undocumented exception +# default = warning +dotnet_diagnostic.Ex0100.severity = none + +# Exception Analyzers: Member accessor may throw undocumented exception +# default = warning +dotnet_diagnostic.Ex0101.severity = none + +# Exception Analyzers: Implicit constructor may throw undocumented exception +# default = warning +dotnet_diagnostic.Ex0103.severity = none + +# Exception Analyzers: Member initializer may throw undocumented exception +# default = warning +dotnet_diagnostic.Ex0104.severity = none + +# Exception Analyzers: Delegate created from member may throw undocumented exception +# default = silent +; dotnet_diagnostic.Ex0120.severity = none + +# Exception Analyzers: Delegate created from anonymous function may throw undocumented exception +# default = silent +; dotnet_diagnostic.Ex0121.severity = none + +# Exception Analyzers: Member is documented as throwing exception not documented on member in base or interface type +# default = warning +dotnet_diagnostic.Ex0200.severity = none + +# Exception Analyzers: Member accessor is documented as throwing exception not documented on member in base or interface type +# default = warning +dotnet_diagnostic.Ex0201.severity = none \ No newline at end of file diff --git a/Tests/PowerMateClientInputTest.cs b/Tests/PowerMateClientInputTest.cs index 0419faf..fc86e9c 100644 --- a/Tests/PowerMateClientInputTest.cs +++ b/Tests/PowerMateClientInputTest.cs @@ -1,4 +1,5 @@ using HidSharp; +using PowerMate; namespace Tests; @@ -18,12 +19,13 @@ public class PowerMateClientInputTest { A.CallTo(_device).Where(call => call.Method.Name == "OpenDeviceAndRestrictAccess").WithReturnType().Returns(_stream); A.CallTo(() => _stream.ReadAsync(A._, An._, An._, A._)).ReturnsLazily(call => { - byte[] buffer = (byte[]) call.Arguments[0]!; - int offset = (int) call.Arguments[1]!; - int count = (int) call.Arguments[2]!; - byte[] fakeHidBytes = Convert.FromHexString("000100004F1000"); - Array.Copy(fakeHidBytes, 0, buffer, offset, count); - return Task.FromResult(Math.Min(count, fakeHidBytes.Length)); + byte[] buffer = (byte[]) call.Arguments[0]!; + int offset = (int) call.Arguments[1]!; + int count = (int) call.Arguments[2]!; + byte[] fakeHidBytes = Convert.FromHexString("000100004F1000"); + int occupiedCount = Math.Min(count, fakeHidBytes.Length); + Array.Copy(fakeHidBytes, 0, buffer, offset, occupiedCount); + return Task.FromResult(occupiedCount); }); } @@ -48,94 +50,4 @@ public class PowerMateClientInputTest { actualEvent!.Value.RotationDistance.Should().Be(0); } - [Fact] - public void LateAttach() { - A.CallTo(() => _deviceList.GetDevices(A._)).ReturnsNextFromSequence( - Enumerable.Empty(), - new[] { _device }); - - bool? connectedEventArg = null; - PowerMateClient client = new(_deviceList); - client.IsConnected.Should().BeFalse(); - PowerMateInput? actualEvent = null; - ManualResetEventSlim inputReceived = new(); - ManualResetEventSlim isConnectedChanged = new(); - client.InputReceived += (_, @event) => { - actualEvent = @event; - inputReceived.Set(); - }; - client.IsConnectedChanged += (_, b) => { - connectedEventArg = b; - isConnectedChanged.Set(); - }; - - _deviceList.RaiseChanged(); - - inputReceived.Wait(TestTimeout); - isConnectedChanged.Wait(TestTimeout); - - client.IsConnected.Should().BeTrue(); - connectedEventArg.HasValue.Should().BeTrue(); - connectedEventArg!.Value.Should().BeTrue(); - actualEvent.HasValue.Should().BeTrue(); - actualEvent!.Value.IsPressed.Should().BeTrue(); - actualEvent!.Value.RotationDirection.Should().Be(RotationDirection.None); - actualEvent!.Value.RotationDistance.Should().Be(0); - } - - [Fact] - public void Reconnect() { - A.CallTo(() => _stream.ReadAsync(A._, An._, An._, A._)) - .ThrowsAsync(new IOException("fake disconnected")).Once().Then - .ReturnsLazily(call => { - byte[] buffer = (byte[]) call.Arguments[0]!; - int offset = (int) call.Arguments[1]!; - int count = (int) call.Arguments[2]!; - byte[] fakeHidBytes = Convert.FromHexString("000100004F1000"); - Array.Copy(fakeHidBytes, 0, buffer, offset, count); - return Task.FromResult(Math.Min(count, fakeHidBytes.Length)); - }); - - ManualResetEventSlim eventArrived = new(); - PowerMateClient client = new(_deviceList); - PowerMateInput? actualEvent = null; - client.InputReceived += (_, @event) => { - actualEvent = @event; - eventArrived.Set(); - }; - - _deviceList.RaiseChanged(); - - eventArrived.Wait(TestTimeout); - actualEvent.HasValue.Should().BeTrue(); - actualEvent!.Value.IsPressed.Should().BeTrue(); - actualEvent!.Value.RotationDirection.Should().Be(RotationDirection.None); - actualEvent!.Value.RotationDistance.Should().Be(0); - } - - [Fact] - public void SynchronizationContext() { - ManualResetEventSlim eventArrived = new(); - SynchronizationContext synchronizationContext = A.Fake(); - A.CallTo(() => synchronizationContext.Post(A._, An._)).Invokes(() => eventArrived.Set()); - - PowerMateClient client = new(_deviceList) { EventSynchronizationContext = synchronizationContext }; - eventArrived.Wait(TestTimeout); - - A.CallTo(() => synchronizationContext.Post(A._, An._)).MustHaveHappenedOnceOrMore(); - } - - [Fact] - public void Dispose() { - PowerMateClient client = new(_deviceList); - client.Dispose(); - } - - [Fact] - public void DisposeIdempotent() { - PowerMateClient client = new(_deviceList); - client.Dispose(); - client.Dispose(); - } - } \ No newline at end of file diff --git a/Tests/PowerMateClientOutputTest.cs b/Tests/PowerMateClientOutputTest.cs index 13c9989..3a221b1 100644 --- a/Tests/PowerMateClientOutputTest.cs +++ b/Tests/PowerMateClientOutputTest.cs @@ -1,7 +1,10 @@ -using HidSharp; +using System.Diagnostics.CodeAnalysis; +using HidSharp; +using PowerMate; namespace Tests; +[SuppressMessage("Style", "IDE0017:Simplify object initialization", Justification = "explicit invocation ordering for tests")] public class PowerMateClientOutputTest { private readonly HidDevice _device = A.Fake(); diff --git a/Tests/PowerMateInputTest.cs b/Tests/PowerMateInputTest.cs index 3fa89f3..e96e9d5 100644 --- a/Tests/PowerMateInputTest.cs +++ b/Tests/PowerMateInputTest.cs @@ -1,3 +1,5 @@ +using PowerMate; + namespace Tests; public class PowerMateInputTest { @@ -96,4 +98,78 @@ public class PowerMateInputTest { new PowerMateInput(false, RotationDirection.None, 0).ToString().Should().Be("Not turning while not pressed"); } + [Theory] + [InlineData(0x01, 0x0e, 0)] + [InlineData(0x01, 0x0c, 1)] + [InlineData(0x01, 0x0a, 2)] + [InlineData(0x01, 0x08, 3)] + [InlineData(0x01, 0x06, 4)] + [InlineData(0x01, 0x04, 5)] + [InlineData(0x01, 0x02, 6)] + [InlineData(0x01, 0x01, 7)] + [InlineData(0x11, 0x01, 8)] + [InlineData(0x21, 0x02, 9)] + [InlineData(0x21, 0x04, 10)] + [InlineData(0x21, 0x06, 11)] + [InlineData(0x21, 0x08, 12)] + [InlineData(0x21, 0x0a, 13)] + [InlineData(0x21, 0x0c, 14)] + [InlineData(0x21, 0x0e, 15)] + [InlineData(0x21, 0x10, 16)] + [InlineData(0x21, 0x12, 17)] + [InlineData(0x21, 0x14, 18)] + [InlineData(0x21, 0x16, 19)] + [InlineData(0x21, 0x18, 20)] + [InlineData(0x21, 0x1a, 21)] + [InlineData(0x21, 0x1c, 22)] + [InlineData(0x21, 0x1e, 23)] + [InlineData(0x21, 0x20, 24)] + public void DecodeAlwaysPulsingSpeed(byte inputByte5, byte inputByte6, int expected) { + PowerMateInput actual = new(new byte[] { 0, 0, 1, 0, 73, inputByte5, inputByte6 }); + actual.ActualLightAnimation.Should().Be(LightAnimation.Pulsing); + actual.ActualLightPulseSpeed.Should().Be(expected); + } + + [Theory] + [InlineData(0x04, 0x0e, 0)] + [InlineData(0x04, 0x0c, 1)] + [InlineData(0x04, 0x0a, 2)] + [InlineData(0x04, 0x08, 3)] + [InlineData(0x04, 0x06, 4)] + [InlineData(0x04, 0x04, 5)] + [InlineData(0x04, 0x02, 6)] + [InlineData(0x04, 0x01, 7)] + [InlineData(0x14, 0x01, 8)] + [InlineData(0x24, 0x02, 9)] + [InlineData(0x24, 0x04, 10)] + [InlineData(0x24, 0x06, 11)] + [InlineData(0x24, 0x08, 12)] + [InlineData(0x24, 0x0a, 13)] + [InlineData(0x24, 0x0c, 14)] + [InlineData(0x24, 0x0e, 15)] + [InlineData(0x24, 0x10, 16)] + [InlineData(0x24, 0x12, 17)] + [InlineData(0x24, 0x14, 18)] + [InlineData(0x24, 0x16, 19)] + [InlineData(0x24, 0x18, 20)] + [InlineData(0x24, 0x1a, 21)] + [InlineData(0x24, 0x1c, 22)] + [InlineData(0x24, 0x1e, 23)] + [InlineData(0x24, 0x20, 24)] + public void DecodeStandbyPulsingSpeed(byte inputByte5, byte inputByte6, int expected) { + PowerMateInput actual = new(new byte[] { 0, 0, 1, 0, 73, inputByte5, inputByte6 }); + actual.ActualLightAnimation.Should().Be(LightAnimation.SolidWhileAwakeAndPulsingDuringComputerStandby); + actual.ActualLightPulseSpeed.Should().Be(expected); + } + + [Theory] + [InlineData(0x0, 0x20, 0)] + [InlineData(0x7f, 0x20, 127)] + [InlineData(0xff, 0x20, 255)] + public void DecodeBrightness(byte inputByte4, byte inputByte5, byte expected) { + PowerMateInput actual = new(new byte[] { 0, 0, 1, 0, inputByte4, inputByte5, 0x0a }); + actual.ActualLightAnimation.Should().Be(LightAnimation.Solid); + actual.ActualLightBrightness.Should().Be(expected); + } + } \ No newline at end of file diff --git a/Tests/Tests.csproj b/Tests/Tests.csproj index b0ed22d..70c94ba 100644 --- a/Tests/Tests.csproj +++ b/Tests/Tests.csproj @@ -9,15 +9,15 @@ - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive all - + runtime; build; native; contentfiles; analyzers; buildtransitive all diff --git a/Tests/Usings.cs b/Tests/Usings.cs index 905afbc..b5bb7f1 100644 --- a/Tests/Usings.cs +++ b/Tests/Usings.cs @@ -1,4 +1,3 @@ global using Xunit; global using FluentAssertions; -global using FakeItEasy; -global using PowerMate; \ No newline at end of file +global using FakeItEasy; \ No newline at end of file