Skip to content

Commit

Permalink
Extract common HID logic into separate HidClient NuGet package so it …
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
Aldaviva committed Jun 11, 2023
1 parent 015e15d commit 30c7bc4
Show file tree
Hide file tree
Showing 14 changed files with 267 additions and 325 deletions.
30 changes: 3 additions & 27 deletions PowerMate/IPowerMateClient.cs
Original file line number Diff line number Diff line change
@@ -1,47 +1,23 @@
using System.ComponentModel;
using HidClient;

namespace PowerMate;

/// <summary>
/// <para>Listen for events and control the light on a connected Griffin PowerMate USB device.</para>
/// <para>To get started, construct a new instance of <see cref="PowerMateClient"/>.</para>
/// <para>Once you have constructed an instance, you can subscribe to <see cref="InputReceived"/> events to be notified when the PowerMate knob is rotated, pressed, or released.</para>
/// <para>You can also subscribe to <see cref="IsConnectedChanged"/> or <see cref="INotifyPropertyChanged.PropertyChanged"/> to be notified when it connects or disconnects from a PowerMate.</para>
/// <para>You can also subscribe to <see cref="IHidClient.IsConnectedChanged"/> or <see cref="INotifyPropertyChanged.PropertyChanged"/> to be notified when it connects or disconnects from a PowerMate.</para>
/// <para>The light is controlled by the <see cref="LightBrightness"/>, <see cref="LightAnimation"/>, and <see cref="LightPulseSpeed"/> properties.</para>
/// <para>Remember to dispose of this instance when you're done using it by calling <see cref="IDisposable.Dispose"/>, or with a <see langword="using" /> statement or declaration.</para>
/// </summary>
public interface IPowerMateClient: IDisposable, INotifyPropertyChanged {

/// <summary>
/// <para><see langword="true" /> if the client is currently connected to a PowerMate device, or <see langword="false" /> if it is disconnected, possibly because there is no PowerMate device
/// plugged into the computer.</para>
/// <para><see cref="PowerMateClient"/> 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.</para>
/// <para>If a PowerMate device is plugged in, <see cref="IsConnected"/> will already be <see langword="true" /> by the time the <see cref="PowerMateClient"/> constructor returns.</para>
/// <para>To receive notifications when this property changes, you can subscribe to the <see cref="IsConnectedChanged"/> or <see cref="INotifyPropertyChanged.PropertyChanged"/> events.</para>
/// </summary>
bool IsConnected { get; }
public interface IPowerMateClient: IHidClient {

/// <summary>
/// Fired whenever the PowerMate knob is rotated, pressed, or released.
/// </summary>
event EventHandler<PowerMateInput> InputReceived;

/// <summary>
/// <para>Fired whenever the connection state of the PowerMate changes. Not fired when constructing or disposing the <see cref="PowerMateClient"/> instance.</para>
/// <para>The event argument contains the new value of <see cref="IsConnected"/>.</para>
/// <para>This value can also be accessed at any time by reading the <see cref="IsConnected"/> property.</para>
/// <para>If you want to use data binding which expects <see cref="INotifyPropertyChanged.PropertyChanged"/> events, <see cref="IPowerMateClient"/> also implements
/// <see cref="INotifyPropertyChanged"/>, so you can use that event instead.</para>
/// </summary>
event EventHandler<bool> IsConnectedChanged;

/// <summary>
/// <see cref="SynchronizationContext"/> 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.
/// </summary>
SynchronizationContext EventSynchronizationContext { get; set; }

/// <summary>
/// <para>Get or set how bright the blue/cyan LED in the base of the PowerMate is, between <c>0</c> (off) and <c>255</c> (brightest), inclusive. When the device is first plugged in, it defaults to
/// <c>80</c>.</para>
Expand Down
4 changes: 2 additions & 2 deletions PowerMate/PowerMate.csproj
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<Version>1.0.0</Version>
<Version>1.0.1</Version>
<Authors>Ben Hutchison</Authors>
<Company>Ben Hutchison</Company>
<PackageId>PowerMate</PackageId>
Expand Down Expand Up @@ -36,7 +36,7 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="HidSharp" Version="2.1.0" />
<PackageReference Include="HidClient" Version="1.0.1" />
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" />
</ItemGroup>

Expand Down
198 changes: 40 additions & 158 deletions PowerMate/PowerMateClient.cs
Original file line number Diff line number Diff line change
@@ -1,137 +1,53 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using HidClient;
using HidSharp;

namespace PowerMate;

/// <inheritdoc />
public class PowerMateClient: IPowerMateClient {
/// <inheritdoc cref="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;

/// <inheritdoc />
public event EventHandler<bool>? IsConnectedChanged;
protected override int VendorId { get; } = 0x077d;

/// <inheritdoc />
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;

/// <inheritdoc />
public event EventHandler<PowerMateInput>? InputReceived;

/// <inheritdoc />
public SynchronizationContext EventSynchronizationContext { get; set; } = SynchronizationContext.Current ?? new SynchronizationContext();

/// <summary>
/// <para>Constructs a new instance that communicates with a PowerMate device.</para>
/// <para>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.</para>
/// <para>If multiple PowerMate devices are present, it will pick one arbitrarily and connect to it.</para>
/// <para>Once you have constructed an instance, you can subscribe to <see cref="InputReceived"/> events to be notified when the PowerMate knob is rotated, pressed, or released.</para>
/// <para>You can also subscribe to <see cref="IsConnectedChanged"/> or <see cref="INotifyPropertyChanged.PropertyChanged"/> to be notified when it connects or disconnects from a PowerMate.</para>
/// <para>The light is controlled by the <see cref="LightBrightness"/>, <see cref="LightAnimation"/>, and <see cref="LightPulseSpeed"/> properties.</para>
/// <para>Remember to dispose of this instance when you're done using it by calling <see cref="Dispose()"/>, or with a <see langword="using" /> statement or declaration.</para>
/// </summary>
public PowerMateClient(): this(DeviceList.Local) { }

internal PowerMateClient(DeviceList deviceList) {
_deviceList = deviceList;
_deviceList.Changed += onDeviceListChanged;
AttachToDevice();
}
public PowerMateClient() { }

/// <inheritdoc />
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();
}
/// <inheritdoc />
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;
/// <inheritdoc />
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();
}

/// <inheritdoc />
Expand Down Expand Up @@ -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);
}
}
}

/// <param name="pulseSpeed">in the range [0, 24]</param>
/// <returns>two big-endian bytes to send to the PowerMate to set its pulsing speed</returns>
private static byte[] EncodePulseSpeed(int pulseSpeed) {
byte[] encoded = BitConverter.GetBytes((ushort) (pulseSpeed switch {
< 8 => Math.Min(0x00e, (7 - pulseSpeed) * 2),
Expand All @@ -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);
}

/// <summary>
/// <para>Clean up managed and, optionally, unmanaged resources.</para>
/// <para>When inheriting from <see cref="PowerMateClient"/>, you should override this method, dispose of your managed resources if <paramref name="disposing"/> is <see langword="true" />, then
/// free your unmanaged resources regardless of the value of <paramref name="disposing"/>, and finally call this base <see cref="Dispose(bool)"/> implementation.</para>
/// <para>For more information, see <see url="https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose#implement-the-dispose-pattern-for-a-derived-class">Implement
/// the dispose pattern for a derived class</see>.</para>
/// </summary>
/// <param name="disposing">Should be <see langword="false" /> when called from a finalizer, and <see langword="true" /> when called from the <see cref="Dispose()"/> method. In other words, it is
/// <see langword="true" /> when deterministically called and <see langword="false" /> when non-deterministically called.</param>
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;
}
}
}

/// <summary>
/// <para>Disconnect from any connected PowerMate device and clean up managed resources.</para>
/// <para><see cref="IsConnectedChanged"/> and <see cref="INotifyPropertyChanged.PropertyChanged"/> events will not be emitted if a PowerMate is disconnected during disposal.</para>
/// <para>Subclasses of <see cref="PowerMateClient"/> should override <see cref="Dispose(bool)"/>.</para>
/// <para>For more information, see <see url="https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/unmanaged">Cleaning Up Unmanaged Resources</see> and
/// <see url="https://learn.microsoft.com/en-us/dotnet/standard/garbage-collection/implementing-dispose">Implementing a Dispose Method</see>.</para>
/// </summary>
public void Dispose() {
Dispose(true);
GC.SuppressFinalize(this);
EventSynchronizationContext.Post(_ => OnPropertyChanged(propertyName), null);
}

}

0 comments on commit 30c7bc4

Please sign in to comment.