Skip to content

Commit

Permalink
✨ Add a custom tool to explore StreamDeck bitmap colors.
Browse files Browse the repository at this point in the history
  • Loading branch information
hexawyz committed Feb 9, 2025
1 parent 7611bac commit 027f558
Show file tree
Hide file tree
Showing 14 changed files with 557 additions and 3 deletions.
21 changes: 21 additions & 0 deletions Exo.sln
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DeviceTools.Usb", "src\Devi
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Exo.Memory", "src\Exo\Core\Exo.Memory\Exo.Memory.csproj", "{6866CC56-DDAC-4511-AA90-FD77E05DB15F}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tools", "Tools", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "StreamDeckPlayground", "src\Tools\StreamDeckPlayground\StreamDeckPlayground.csproj", "{1870E316-3853-0EE2-E6C3-98BAD951A288}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -1299,6 +1303,22 @@ Global
{6866CC56-DDAC-4511-AA90-FD77E05DB15F}.Release|x64.Build.0 = Release|Any CPU
{6866CC56-DDAC-4511-AA90-FD77E05DB15F}.Release|x86.ActiveCfg = Release|Any CPU
{6866CC56-DDAC-4511-AA90-FD77E05DB15F}.Release|x86.Build.0 = Release|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Debug|arm64.ActiveCfg = Debug|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Debug|arm64.Build.0 = Debug|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Debug|x64.ActiveCfg = Debug|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Debug|x64.Build.0 = Debug|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Debug|x86.ActiveCfg = Debug|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Debug|x86.Build.0 = Debug|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Release|Any CPU.Build.0 = Release|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Release|arm64.ActiveCfg = Release|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Release|arm64.Build.0 = Release|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Release|x64.ActiveCfg = Release|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Release|x64.Build.0 = Release|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Release|x86.ActiveCfg = Release|Any CPU
{1870E316-3853-0EE2-E6C3-98BAD951A288}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand Down Expand Up @@ -1377,6 +1397,7 @@ Global
{9D1822E8-F2BF-473B-84ED-B16E206B429F} = {59B8EF45-EA19-477D-B57C-5783B7B1C5CF}
{53087D24-A134-4744-9136-1E24E14C2A2A} = {59B8EF45-EA19-477D-B57C-5783B7B1C5CF}
{6866CC56-DDAC-4511-AA90-FD77E05DB15F} = {062C0665-616A-4FB4-B0DA-2EEBE7FDA7B9}
{1870E316-3853-0EE2-E6C3-98BAD951A288} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {E9FEED7F-27EC-4720-9CAB-C2E6FDD8556D}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System.Buffers.Binary;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text;
using DeviceTools.HumanInterfaceDevices;
Expand All @@ -8,7 +6,7 @@ namespace Exo.Devices.Elgato.StreamDeck;

// This implementation is for Stream Deck XL. Multiple subclasses are needed to provide features for other device versions.
// TODO: Create(ushort productId) returning the correct underlying implementation with correctly initialized parameters.
internal class StreamDeckDevice : IAsyncDisposable
public sealed class StreamDeckDevice : IAsyncDisposable
{
// https://www.reddit.com/r/elgato/comments/jagj6p/stream_deck_update_49_screensaver_sleep_action/
// > The recommended screensaver dimensions are:
Expand Down
12 changes: 12 additions & 0 deletions src/Tools/StreamDeckPlayground/App.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<Application x:Class="StreamDeckPlayground.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:StreamDeckPlayground"
StartupUri="MainWindow.xaml"
ThemeMode="System">
<Application.Resources>
<BooleanToVisibilityConverter x:Key="BooleanToVisibilityConverter" />
<local:NullabilityToVisibilityConverter x:Key="NullabilityToVisibilityConverter" />
<local:ColorToBrushConverter x:Key="ColorToBrushConverter" />
</Application.Resources>
</Application>
7 changes: 7 additions & 0 deletions src/Tools/StreamDeckPlayground/App.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
using System.Windows;

namespace StreamDeckPlayground;

public partial class App : Application
{
}
10 changes: 10 additions & 0 deletions src/Tools/StreamDeckPlayground/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
using System.Windows;

[assembly: ThemeInfo(
ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
//(used if a resource is not found in the page,
// or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
//(used if a resource is not found in the page,
// app, or any theme specific resource dictionaries)
)]
11 changes: 11 additions & 0 deletions src/Tools/StreamDeckPlayground/ColorToBrushConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;

namespace StreamDeckPlayground;

public sealed class ColorToBrushConverter : IValueConverter
{
public object? Convert(object value, Type targetType, object parameter, CultureInfo culture) => value is Color color ? new SolidColorBrush(color) : null;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException();
}
59 changes: 59 additions & 0 deletions src/Tools/StreamDeckPlayground/DesignMainViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
using System.Collections.ObjectModel;
using System.Windows.Media;

namespace StreamDeckPlayground;

internal sealed class DesignMainViewModel
{
public sealed class StreamDeckDevice
{
public string DeviceName { get; }
public string SerialNumber { get; }
public string FirmwareVersion { get; }
public int ButtonColumnCount { get; }
public int ButtonRowCount { get; }
public int ButtonImageWidth { get; }
public int ButtonImageHeight { get; }
public int ScreensaverImageWidth { get; }
public int ScreensaverImageHeight { get; }
public ReadOnlyCollection<StreamDeckButton> Buttons { get; }
public StreamDeckButton? SelectedButton { get; set; }

public StreamDeckDevice(string deviceName, string serialNumber, string firmwareVersion, int buttonColumnCount, int buttonRowCount, int buttonImageWidth, int buttonImageHeight, int screensaverImageWidth, int screensaverImageHeight)
{
DeviceName = deviceName;
SerialNumber = serialNumber;
FirmwareVersion = firmwareVersion;
ButtonColumnCount = buttonColumnCount;
ButtonRowCount = buttonRowCount;
ButtonImageWidth = buttonImageWidth;
ButtonImageHeight = buttonImageHeight;
ScreensaverImageWidth = screensaverImageWidth;
ScreensaverImageHeight = screensaverImageHeight;
Buttons = Array.AsReadOnly(Enumerable.Range(0, buttonColumnCount * buttonRowCount).Select(i => new StreamDeckButton()).ToArray());
SelectedButton = Buttons[2];
}
}

public sealed class StreamDeckButton
{
public int Width => 96;
public int Height => 96;

public byte Red { get; set; } = 55;
public byte Green { get; set; } = 207;
public byte Blue { get; set; } = 29;
public Color Color => Color.FromArgb(255, Red, Green, Blue);
public string HtmlColorCode => $"#{Red:X2}{Green:X2}{Blue:X2}";
}

public ReadOnlyObservableCollection<StreamDeckDevice> Devices { get; }
public StreamDeckDevice? SelectedDevice { get; set; }

public DesignMainViewModel()
{
var device = new StreamDeckDevice(@"\\HID\Whatever", "SN0123456789", "42.42.42", 8, 4, 96, 96, 1024, 600);
Devices = new([device]);
SelectedDevice = device;
}
}
65 changes: 65 additions & 0 deletions src/Tools/StreamDeckPlayground/MainViewModel.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
using System.Collections.ObjectModel;
using DeviceTools;
using Exo.Ui;

namespace StreamDeckPlayground;

public sealed class MainViewModel : BindableObject, IAsyncDisposable
{
private readonly ObservableCollection<StreamDeckViewModel> _devices;
private readonly ReadOnlyObservableCollection<StreamDeckViewModel> _readOnlyDevices;
private StreamDeckViewModel? _selectedDevice;
private readonly Task _watchDevicesTask;
private CancellationTokenSource? _cancellationTokenSource;

public MainViewModel()
{
_devices = new();
_readOnlyDevices = new(_devices);
_cancellationTokenSource = new();
_watchDevicesTask = WatchDevicesAsync(_cancellationTokenSource.Token);
}

public async ValueTask DisposeAsync()
{
if (Interlocked.Exchange(ref _cancellationTokenSource, null) is { } cts)
{
cts.Cancel();
await _watchDevicesTask;
cts.Dispose();
}
}

public StreamDeckViewModel? SelectedDevice
{
get => _selectedDevice;
set => SetValue(ref _selectedDevice, value);
}

public async Task WatchDevicesAsync(CancellationToken cancellationToken)
{
try
{
foreach
(
var device in await DeviceQuery.FindAllAsync
(
DeviceObjectKind.DeviceInterface,
Properties.System.Devices.InterfaceClassGuid == DeviceInterfaceClassGuids.Hid &
Properties.System.DeviceInterface.Hid.VendorId == 0x0FD9 &
Properties.System.DeviceInterface.Hid.ProductId == 0x006C &
Properties.System.Devices.InterfaceEnabled == true,
cancellationToken
)
)
{
_devices.Add(await StreamDeckViewModel.CreateAsync(device.Id, 0x006C, cancellationToken));
}
}
catch (OperationCanceledException)
{
}
}

public ReadOnlyObservableCollection<StreamDeckViewModel> Devices => _readOnlyDevices;
}
130 changes: 130 additions & 0 deletions src/Tools/StreamDeckPlayground/MainWindow.xaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<Window x:Class="StreamDeckPlayground.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:local="clr-namespace:StreamDeckPlayground"
mc:Ignorable="d"
d:DataContext="{d:DesignInstance Type=local:DesignMainViewModel, IsDesignTimeCreatable=True}"
Title="StreamDeck Playground" Height="550" Width="800">
<Window.DataContext>
<local:MainViewModel />
</Window.DataContext>
<Grid Margin="6">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Text="Device" Margin="0,6,0,6" VerticalAlignment="Center" />
<ComboBox Grid.Column="1" ItemsSource="{Binding Devices}" SelectedItem="{Binding SelectedDevice, Mode=TwoWay}" Margin="0,6,0,6" VerticalAlignment="Center">
<ComboBox.ItemTemplate>
<DataTemplate DataType="local:StreamDeckViewModel">
<TextBlock Text="{Binding DeviceName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</Grid>
<ScrollViewer Grid.Row="1">
<Grid DataContext="{Binding SelectedDevice, Mode=OneWay}" Visibility="{Binding Converter={StaticResource NullabilityToVisibilityConverter}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Expander Header="Device Information" IsExpanded="True" Margin="0,6,0,6">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="200" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="Serial Number" Margin="0,6,0,6" VerticalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding SerialNumber}" Margin="0,6,0,6" VerticalAlignment="Center" />
<TextBlock Grid.Row="1" Text="Firmware Version" Margin="0,6,0,6" VerticalAlignment="Center" />
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding FirmwareVersion}" Margin="0,6,0,6" VerticalAlignment="Center" />
<TextBlock Grid.Row="2" Text="Grid Size" Margin="0,6,0,6" VerticalAlignment="Center" />
<TextBlock Grid.Row="2" Grid.Column="1" Margin="0,6,0,6" VerticalAlignment="Center"><Run Text="{Binding ButtonColumnCount, Mode=OneWay}" /><Run Text="x" /><Run Text="{Binding ButtonRowCount, Mode=OneWay}" /></TextBlock>
<TextBlock Grid.Row="3" Text="Button Size" Margin="0,6,0,6" VerticalAlignment="Center" />
<TextBlock Grid.Row="3" Grid.Column="1" Margin="0,6,0,6" VerticalAlignment="Center"><Run Text="{Binding ButtonImageWidth, Mode=OneWay}" /><Run Text="x" /><Run Text="{Binding ButtonImageHeight, Mode=OneWay}" /></TextBlock>
<TextBlock Grid.Row="4" Text="Screen Size" Margin="0,6,0,6" VerticalAlignment="Center" />
<TextBlock Grid.Row="4" Grid.Column="1" Margin="0,6,0,6" VerticalAlignment="Center"><Run Text="{Binding ScreensaverImageWidth, Mode=OneWay}" /><Run Text="x" /><Run Text="{Binding ScreensaverImageHeight, Mode=OneWay}" /></TextBlock>
</Grid>
</Expander>
<Expander Grid.Row="1" Header="Buttons" Margin="0,6,0,6" IsExpanded="True">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ListBox
ItemsSource="{Binding Buttons}"
SelectedItem="{Binding SelectedButton, Mode=TwoWay}"
HorizontalAlignment="Center"
HorizontalContentAlignment="Center">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem" BasedOn="{StaticResource {x:Type ListBoxItem}}">
<Setter Property="Margin" Value="0" />
<Setter Property="Padding" Value="2" />
<Setter Property="Width" Value="52" />
<Setter Property="Height" Value="52" />
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<Rectangle HorizontalAlignment="Center" VerticalAlignment="Center" Width="48" Height="48" Fill="{Binding Color, Converter={StaticResource ColorToBrushConverter}}" />
</DataTemplate>
</ListBox.ItemTemplate>
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="{Binding ButtonColumnCount}" Rows="{Binding ButtonRowCount}" HorizontalAlignment="Center" VerticalAlignment="Center" />
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
</ListBox>
<Grid Grid.Row="1" DataContext="{Binding SelectedButton}" Visibility="{Binding Converter={StaticResource NullabilityToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="100" />
<ColumnDefinition Width="370" />
</Grid.ColumnDefinitions>
<StackPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<Rectangle Fill="{Binding Color, Converter={StaticResource ColorToBrushConverter}}" Width="48" Height="48" />
<TextBlock Text="{Binding HtmlColorCode}" />
</StackPanel>
<Grid Grid.Column="1">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="50" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="30" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Margin="0,6,0,6" VerticalAlignment="Center" Text="Red" />
<Slider Grid.Row="0" Grid.Column="1" Margin="0,6,0,6" VerticalAlignment="Center" Minimum="0" Maximum="255" Value="{Binding Red, Mode=TwoWay}" />
<TextBlock Grid.Row="0" Grid.Column="2" Margin="0,6,0,6" HorizontalAlignment="Right" VerticalAlignment="Center" Text="{Binding Red, Mode=OneWay}" />
<TextBlock Grid.Row="1" Margin="0,6,0,6" VerticalAlignment="Center" Text="Green" />
<Slider Grid.Row="1" Grid.Column="1" Margin="0,6,0,6" VerticalAlignment="Center" Minimum="0" Maximum="255" Value="{Binding Green, Mode=TwoWay}" />
<TextBlock Grid.Row="1" Grid.Column="2" Margin="0,6,0,6" HorizontalAlignment="Right" VerticalAlignment="Center" Text="{Binding Green, Mode=OneWay}" />
<TextBlock Grid.Row="2" Margin="0,6,0,6" VerticalAlignment="Center" Text="Blue" />
<Slider Grid.Row="2" Grid.Column="1" Margin="0,6,0,6" VerticalAlignment="Center" Minimum="0" Maximum="255" Value="{Binding Blue, Mode=TwoWay}" />
<TextBlock Grid.Row="2" Grid.Column="2" Margin="0,6,0,6" HorizontalAlignment="Right" VerticalAlignment="Center" Text="{Binding Blue, Mode=OneWay}" />
</Grid>
</Grid>
</Grid>
</Expander>
</Grid>
</ScrollViewer>
</Grid>
</Window>
22 changes: 22 additions & 0 deletions src/Tools/StreamDeckPlayground/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;

namespace StreamDeckPlayground;
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
}
14 changes: 14 additions & 0 deletions src/Tools/StreamDeckPlayground/NullabilityToVisibilityConverter.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.Globalization;
using System.Windows;
using System.Windows.Data;

namespace StreamDeckPlayground;

public sealed class NullabilityToVisibilityConverter : IValueConverter
{
private static readonly object Visible = Visibility.Visible;
private static readonly object Collapsed = Visibility.Collapsed;

public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value is not null ? Visible : Collapsed;
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) => throw new NotSupportedException();
}
Loading

0 comments on commit 027f558

Please sign in to comment.