Skip to content

Commit

Permalink
#2: PowerMateVolume: receive reliable notifications when the computer…
Browse files Browse the repository at this point in the history
… resumes from standby, so device settings can be reset [add documentation and unit tests, remove log message dialogs]
  • Loading branch information
Aldaviva committed Aug 1, 2023
1 parent 74af5bb commit 9bf7212
Show file tree
Hide file tree
Showing 8 changed files with 88 additions and 6 deletions.
1 change: 1 addition & 0 deletions PowerMate/ExceptionAdjustments.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@
P:System.Array.Length get -T:System.OverflowException
M:System.IO.Stream.ReadAsync(System.Byte[],System.Int32,System.Int32,System.Threading.CancellationToken) -T:System.NotSupportedException
M:System.Math.Abs(System.Int32) -T:System.OverflowException
M:System.TimeSpan.FromMilliseconds(System.Double) -T:System.OverflowException
6 changes: 6 additions & 0 deletions PowerMate/IPowerMateClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,12 @@ public interface IPowerMateClient: IHidClient {
/// </summary>
int LightPulseSpeed { get; set; }

/// <summary>
/// <para>Reapplies all of the values you set for <see cref="LightAnimation"/>, <see cref="LightBrightness"/>, and <see cref="LightPulseSpeed"/> from this library's internal state to the PowerMate device.</para>
/// <para>This is useful because the PowerMate loses these values when the computer it is attached to enters and leaves standby mode. After resuming from standby, the PowerMate device will load its default settings, erasing your changes. To fix this and restore the values you set before standby, call this method when the computer resumes.</para>
/// <para>To detect when a Windows computer resumes from standby, it is more reliable to read from the Windows Event Log than to subscribe to the <c>Microsoft.Win32.SystemEvents.PowerModeChanged</c> event, which occasionally does not fire an event when resuming from standby. For an example Event Log listener implementation, see <see href="https://github.com/Aldaviva/PowerMate/blob/74af5bb2daad6cbc0e07b823b1378cab172175c1/PowerMateVolume/StandbyEventEmitter.cs"/>.</para>
/// </summary>
/// <returns></returns>
bool SetAllFeaturesIfStale();

}
4 changes: 3 additions & 1 deletion 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.1</Version>
<Version>1.1.0</Version>
<Authors>Ben Hutchison</Authors>
<Company>Ben Hutchison</Company>
<PackageId>PowerMate</PackageId>
Expand All @@ -15,6 +15,7 @@
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<PackageTags>griffin powermate hid rotary-encoder</PackageTags>
<PackageIcon>icon.jpg</PackageIcon>
<PackageReadmeFile>Readme.md</PackageReadmeFile>

<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
Expand Down Expand Up @@ -42,6 +43,7 @@

<ItemGroup>
<None Include="icon.jpg" Pack="true" PackagePath="\" />
<None Include="..\Readme.md" Pack="true" PackagePath="\" />
</ItemGroup>

<PropertyGroup Condition="'$(GITHUB_ACTIONS)' == 'true' or '$(Configuration)' == 'Release'">
Expand Down
5 changes: 1 addition & 4 deletions PowerMateVolume/PowerMateVolume.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,7 @@
using IStandbyListener standbyListener = new EventLogStandbyListener();
standbyListener.FatalError += (_, exception) =>
MessageBox.Show("Event log subscription is broken, continuing without resume detection: " + exception, "PowerMateVolume", MessageBoxButtons.OK, MessageBoxIcon.Error);
standbyListener.Resumed += (_, _) => MessageBox.Show(powerMate.SetAllFeaturesIfStale()
? "On resume, PowerMate had wrong settings, so PowerMateVolume reset all the features on the device."
: "On resume, PowerMate had right settings, so PowerMateVolume did not reset the features on the device.",
"PowerMateVolume", MessageBoxButtons.OK, MessageBoxIcon.Information);
standbyListener.Resumed += (_, _) => powerMate.SetAllFeaturesIfStale();

Console.WriteLine("Listening for PowerMate events");
cancellationTokenSource.Token.WaitHandle.WaitOne();
24 changes: 24 additions & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ PowerMate
- [`LightBrightness` property](#lightbrightness-property)
- [`LightAnimation` property](#lightanimation-property)
- [`LightPulseSpeed` property](#lightpulsespeed-property)
- [PowerMate light state loss on resume](#powermate-light-state-loss-on-resume)
1. [Demos](#demos)
- [Simple demo](#simple-demo)
- [Volume control](#volume-control)
Expand Down Expand Up @@ -193,6 +194,29 @@ powerMate.LightPulseSpeed = 12;
powerMate.LightAnimation = LightAnimation.Pulsing;
```

### PowerMate light state loss on resume

When the computer goes into standby mode and then resumes, the PowerMate loses all of its state and resets its settings to their default values, erasing your light control changes. There are two techniques to fix this, and you should use both of them.

#### Automatically reapply settings on stale input

Each time the PowerMate device sends an input to the computer, such as a knob turn or press, it also sends the current state of the lights. This library checks that device state and compares it to the values you set using `LightBrightness`, `LightAnimation`, and `LightPulseSpeed`. If any of them differ, it will automatically send the correct values to the PowerMate. You don't have to do anything to enable this behavior.

#### Manually reapply settings on resume

Unfortunately, the user is likely to see the incorrect light state before they send an input with the PowerMate: it will be wrong as soon as the computer resumes, and they may not need to touch the PowerMate until much later.

To fix this, your program should also wait for the computer to resume from standby, and when it does, force this library to resend all of the light control property values to the device by calling **`SetAllFeaturesIfStale()`**.

To detect when a Windows computer resumes from standby, a successful strategy is to [listen for event ID 107 from the Kernel-Power source in the System log](https://github.com/Aldaviva/PowerMate/blob/74af5bb2daad6cbc0e07b823b1378cab172175c1/PowerMateVolume/StandbyEventEmitter.cs).

```cs
using IStandbyListener standbyListener = new EventLogStandbyListener();
standbyListener.Resumed += (_, _) => powerMate.SetAllFeaturesIfStale();
```

⛔ You should not listen for [`SystemEvents.PowerModeChanged`](https://learn.microsoft.com/en-us/dotnet/api/microsoft.win32.systemevents.powermodechanged?view=windowsdesktop-7.0) events because they are unreliable and do not get sent about 5% of the time.

## Demos

### Simple demo
Expand Down
13 changes: 13 additions & 0 deletions Tests/PowerMateClientInputTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,17 @@ public void Pressed() {
actualEvent!.Value.RotationDistance.Should().Be(0);
}

[Fact]
public void ResetAllFeaturesOnStaleRead() {
PowerMateClient client = new(_deviceList) {
LightBrightness = 255
};
byte[] expected = { 0x00, 0x41, 0x01, 0x01, 0x00, 0xFF, 0x00, 0x00, 0x00 };
A.CallTo(() => _stream.SetFeature(A<byte[]>.That.IsSameSequenceAs(expected), A<int>._, A<int>._)).MustHaveHappenedOnceExactly();

Thread.Sleep(750);

A.CallTo(() => _stream.SetFeature(A<byte[]>.That.IsSameSequenceAs(expected), A<int>._, A<int>._)).MustHaveHappenedTwiceOrMore();
}

}
30 changes: 29 additions & 1 deletion Tests/PowerMateClientOutputTest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using HidSharp;
using PowerMate;

Expand Down Expand Up @@ -111,4 +112,31 @@ public void SetAnimationPulsingDuringSleepOnly() {
A.CallTo(() => _stream.SetFeature(A<byte[]>.That.IsSameSequenceAs(new byte[] { 0x00, 0x41, 0x01, 0x01, 0x00, 0x80, 0x00, 0x00, 0x00 }), 0, 9)).MustHaveHappenedTwiceExactly();
}

[Fact]
public void RetrySetFeatureOnceOnFailure() {
PowerMateClient client = new(_deviceList);
byte[] expected = { 0x00, 0x41, 0x01, 0x01, 0x00, 0xFF, 0x00, 0x00, 0x00 };
A.CallTo(() => _stream.SetFeature(A<byte[]>.That.IsSameSequenceAs(expected), A<int>._, A<int>._)).Throws(new IOException("The operation completed successfully", new Win32Exception(0))).Once()
.Then.DoesNothing();

client.LightBrightness = 255;

A.CallTo(() => _stream.SetFeature(A<byte[]>.That.IsSameSequenceAs(expected), A<int>._, A<int>._)).MustHaveHappenedTwiceExactly();
}

[Fact]
public void SetAllFeaturesIfStale() {
PowerMateClient client = new(_deviceList);
client.LightBrightness = 255;
byte[] expected = { 0x00, 0x41, 0x01, 0x01, 0x00, 0xFF, 0x00, 0x00, 0x00 };
A.CallTo(() => _stream.SetFeature(A<byte[]>.That.IsSameSequenceAs(expected), A<int>._, A<int>._)).MustHaveHappenedOnceExactly();
A.CallTo(() => _stream.GetFeature(A<byte[]>._, A<int>._, A<int>._)).Invokes((byte[] buffer, int offset, int count) => { Array.Fill(buffer, (byte) 0, offset, count); });

Thread.Sleep(750);
bool actual = client.SetAllFeaturesIfStale();

actual.Should().BeTrue();
A.CallTo(() => _stream.SetFeature(A<byte[]>.That.IsSameSequenceAs(expected), A<int>._, A<int>._)).MustHaveHappenedTwiceOrMore();
}

}
11 changes: 11 additions & 0 deletions Tests/PowerMateInputTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -172,4 +172,15 @@ public void DecodeBrightness(byte inputByte4, byte inputByte5, byte expected) {
actual.ActualLightBrightness.Should().Be(expected);
}

[Theory]
[InlineData(7)] // illegal animation
[InlineData(48)] // illegal pulse speed
public void ArgumentOutOfRange(byte inputByte5) {
#pragma warning disable CA1806 // the side effect of the constructor is an exception, which we want to test
// ReSharper disable once ObjectCreationAsStatement - the side effect of the constructor is an exception, which we want to test
Action thrower = () => new PowerMateInput(new byte[] { 0, 0, 1, 0, 0, inputByte5, 0x0a });
#pragma warning restore CA1806
thrower.Should().Throw<ArgumentOutOfRangeException>();
}

}

0 comments on commit 9bf7212

Please sign in to comment.