1
0
mirror of synced 2024-12-17 23:13:07 +01:00

Working proof-of-concept for refactoring

Implemented orchestrator
Implemented mock device
Implemented serial device
Implemented volume action
Removed old implementation
This commit is contained in:
Mark van Renswoude 2021-02-24 09:05:11 +01:00
parent dc0e761244
commit ff1e1ca74c
72 changed files with 2657 additions and 859 deletions

View File

@ -9,8 +9,7 @@ const byte KnobCount = 1;
// For each potentiometer, specify the port
const byte KnobPin[KnobCount] = {
// A0,
A1
A2
};
// Minimum time between reporting changing values, reduces serial traffic
@ -46,6 +45,7 @@ float emaValue[KnobCount];
unsigned long currentTime;
unsigned long lastPlot;
void setup()
{
Serial.begin(115200);
@ -56,7 +56,10 @@ void setup()
// Seed the moving average
for (byte knobIndex = 0; knobIndex < KnobCount; knobIndex++)
{
pinMode(KnobPin[knobIndex], INPUT);
emaValue[knobIndex] = analogRead(KnobPin[knobIndex]);
}
for (byte seed = 1; seed < EMASeedCount - 1; seed++)
for (byte knobIndex = 0; knobIndex < KnobCount; knobIndex++)
@ -119,18 +122,28 @@ void processMessage(byte message)
case 'Q': // Quit
processQuitMessage();
break;
default:
outputError("Unknown message: " + (char)message);
break;
}
}
void processHandshakeMessage()
{
byte buffer[2];
byte buffer[3];
if (Serial.readBytes(buffer, 3) < 3)
{
outputError("Invalid handshake length");
return;
}
if (buffer[0] != 'M' || buffer[1] != 'K')
{
outputError("Invalid handshake: " + String((char)buffer[0]) + String((char)buffer[1]) + String((char)buffer[2]));
return;
}
switch (buffer[2])
{
@ -147,6 +160,8 @@ void processHandshakeMessage()
break;
default:
outputMode = PlainText;
outputError("Unknown output mode: " + String((char)buffer[2]));
return;
}
@ -185,6 +200,11 @@ void processQuitMessage()
byte getVolume(byte knobIndex)
{
analogRead(KnobPin[knobIndex]);
// Give the ADC some time to stabilize
delay(10);
analogReadValue[knobIndex] = analogRead(KnobPin[knobIndex]);
emaValue[knobIndex] = (EMAAlpha * analogReadValue[knobIndex]) + ((1 - EMAAlpha) * emaValue[knobIndex]);
@ -228,3 +248,21 @@ void outputPlotter()
Serial.println();
}
void outputError(String message)
{
switch (outputMode)
{
case Binary:
Serial.write('E');
Serial.write((byte)message.length());
Serial.print(message);
break;
case PlainText:
Serial.print("Error: ");
Serial.println(message);
break;
}
}

View File

@ -1,6 +1,8 @@
using System;
using System.Threading.Tasks;
using System.Windows.Controls;
using AudioSwitcher.AudioApi;
using MassiveKnob.Plugin.CoreAudio.Settings;
namespace MassiveKnob.Plugin.CoreAudio.Actions
{
@ -12,20 +14,24 @@ namespace MassiveKnob.Plugin.CoreAudio.Actions
public string Description { get; } = "Sets the volume for the selected device, regardless of the current default device.";
public IMassiveKnobActionInstance Create(IMassiveKnobActionContext context)
public IMassiveKnobActionInstance Create()
{
return new Instance(context);
return new Instance();
}
private class Instance : IMassiveKnobAnalogAction
{
private readonly Settings settings;
private IMassiveKnobActionContext actionContext;
private DeviceVolumeActionSettings settings;
private IDevice playbackDevice;
public Instance(IMassiveKnobContext context)
public void Initialize(IMassiveKnobActionContext context)
{
settings = context.GetSettings<Settings>();
actionContext = context;
settings = context.GetSettings<DeviceVolumeActionSettings>();
ApplySettings();
}
@ -34,23 +40,36 @@ namespace MassiveKnob.Plugin.CoreAudio.Actions
}
private void ApplySettings()
{
var coreAudioController = CoreAudioControllerInstance.Acquire();
playbackDevice = settings.DeviceId.HasValue ? coreAudioController.GetDevice(settings.DeviceId.Value) : null;
}
public UserControl CreateSettingsControl()
{
return null;
var viewModel = new DeviceVolumeActionSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
if (!viewModel.IsSettingsProperty(args.PropertyName))
return;
actionContext.SetSettings(settings);
ApplySettings();
};
return new DeviceVolumeActionSettingsView(viewModel);
}
public ValueTask AnalogChanged(byte value)
public async ValueTask AnalogChanged(byte value)
{
// TODO set volume
return default;
if (playbackDevice == null)
return;
await playbackDevice.SetVolumeAsync(value);
}
}
private class Settings
{
}
}
}

View File

@ -0,0 +1,16 @@
using System;
using AudioSwitcher.AudioApi.CoreAudio;
namespace MassiveKnob.Plugin.CoreAudio
{
public static class CoreAudioControllerInstance
{
private static readonly Lazy<CoreAudioController> Instance = new Lazy<CoreAudioController>();
public static CoreAudioController Acquire()
{
return Instance.Value;
}
}
}

View File

@ -46,10 +46,20 @@
</ItemGroup>
<ItemGroup>
<Compile Include="Actions\DeviceVolumeAction.cs" />
<Compile Include="CoreAudioControllerInstance.cs" />
<Compile Include="MassiveKnobCoreAudioPlugin.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Settings\DeviceVolumeActionSettings.xaml.cs">
<DependentUpon>DeviceVolumeActionSettings.xaml</DependentUpon>
<Compile Include="Settings\DeviceVolumeActionSettings.cs" />
<Compile Include="Settings\DeviceVolumeActionSettingsView.xaml.cs">
<DependentUpon>DeviceVolumeActionSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="Settings\BaseDeviceSettings.cs" />
<Compile Include="Settings\BaseDeviceSettingsViewModel.cs" />
<Compile Include="Settings\DeviceVolumeActionSettingsViewModel.cs" />
<Compile Include="Strings.Designer.cs">
<DependentUpon>Strings.resx</DependentUpon>
<AutoGen>True</AutoGen>
<DesignTime>True</DesignTime>
</Compile>
</ItemGroup>
<ItemGroup>
@ -59,13 +69,21 @@
</ProjectReference>
</ItemGroup>
<ItemGroup>
<PackageReference Include="AudioSwitcher.AudioApi.CoreAudio">
<Version>4.0.0-alpha5</Version>
</PackageReference>
<PackageReference Include="System.Threading.Tasks.Extensions">
<Version>4.5.4</Version>
</PackageReference>
</ItemGroup>
<ItemGroup />
<ItemGroup>
<Page Include="Settings\DeviceVolumeActionSettings.xaml">
<EmbeddedResource Include="Strings.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Strings.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<Page Include="Settings\DeviceVolumeActionSettingsView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using MassiveKnob.Plugin.CoreAudio.Actions;
namespace MassiveKnob.Plugin.CoreAudio
@ -17,5 +18,17 @@ namespace MassiveKnob.Plugin.CoreAudio
{
new DeviceVolumeAction()
};
public MassiveKnobCoreAudioPlugin()
{
// My system suffers from this issue: https://github.com/xenolightning/AudioSwitcher/issues/40
// ...which causes the first call to the CoreAudioController to take up to 10 seconds,
// so initialise it as soon as possible. Bit of a workaround, but eh.
Task.Run(() =>
{
CoreAudioControllerInstance.Acquire();
});
}
}
}

View File

@ -1,5 +1,4 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following

View File

@ -0,0 +1,9 @@
using System;
namespace MassiveKnob.Plugin.CoreAudio.Settings
{
public class BaseDeviceSettings
{
public Guid? DeviceId { get; set; }
}
}

View File

@ -0,0 +1,110 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using System.Windows;
using AudioSwitcher.AudioApi;
namespace MassiveKnob.Plugin.CoreAudio.Settings
{
public class BaseDeviceSettingsViewModel : INotifyPropertyChanged
{
private readonly BaseDeviceSettings settings;
public event PropertyChangedEventHandler PropertyChanged;
private IList<PlaybackDeviceViewModel> playbackDevices;
private PlaybackDeviceViewModel selectedDevice;
// ReSharper disable UnusedMember.Global - used by WPF Binding
public IList<PlaybackDeviceViewModel> PlaybackDevices
{
get => playbackDevices;
set
{
playbackDevices = value;
OnPropertyChanged();
}
}
public PlaybackDeviceViewModel SelectedDevice
{
get => selectedDevice;
set
{
if (value == selectedDevice)
return;
selectedDevice = value;
settings.DeviceId = selectedDevice?.Id;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
public BaseDeviceSettingsViewModel(BaseDeviceSettings settings)
{
this.settings = settings;
Task.Run(async () =>
{
var coreAudioController = CoreAudioControllerInstance.Acquire();
var devices = await coreAudioController.GetPlaybackDevicesAsync();
var deviceViewModels = devices
.OrderBy(d => d.FullName)
.Select(PlaybackDeviceViewModel.FromDevice)
.ToList();
Application.Current.Dispatcher.Invoke(() =>
{
PlaybackDevices = deviceViewModels;
SelectedDevice = deviceViewModels.SingleOrDefault(d => d.Id == settings.DeviceId);
});
});
}
public virtual bool IsSettingsProperty(string propertyName)
{
return propertyName != nameof(PlaybackDevices);
}
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
public class PlaybackDeviceViewModel
{
public Guid Id { get; private set; }
public string DisplayName { get; private set; }
public static PlaybackDeviceViewModel FromDevice(IDevice device)
{
string displayFormat;
if ((device.State & DeviceState.Disabled) != 0)
displayFormat = Strings.DeviceDisplayNameDisabled;
else if ((device.State & DeviceState.Unplugged) != 0)
displayFormat = Strings.DeviceDisplayNameUnplugged;
else if ((device.State & DeviceState.NotPresent) != 0)
displayFormat = Strings.DeviceDisplayNameNotPresent;
else if ((device.State & DeviceState.Active) == 0)
displayFormat = Strings.DeviceDisplayNameInactive;
else
displayFormat = Strings.DeviceDisplayNameActive;
return new PlaybackDeviceViewModel
{
Id = device.Id,
DisplayName = string.Format(displayFormat, device.FullName)
};
}
}
}

View File

@ -0,0 +1,7 @@
namespace MassiveKnob.Plugin.CoreAudio.Settings
{
public class DeviceVolumeActionSettings : BaseDeviceSettings
{
// TODO OSD
}
}

View File

@ -1,12 +0,0 @@
<UserControl x:Class="MassiveKnob.Plugin.CoreAudio.Settings.DeviceVolumeActionSettings"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:MassiveKnob.Plugin.CoreAudio.Settings"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800">
<Grid>
</Grid>
</UserControl>

View File

@ -1,28 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
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 MassiveKnob.Plugin.CoreAudio.Settings
{
/// <summary>
/// Interaction logic for DeviceVolumeActionSettings.xaml
/// </summary>
public partial class DeviceVolumeActionSettings : UserControl
{
public DeviceVolumeActionSettings()
{
InitializeComponent();
}
}
}

View File

@ -0,0 +1,14 @@
<UserControl x:Class="MassiveKnob.Plugin.CoreAudio.Settings.DeviceVolumeActionSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:settings="clr-namespace:MassiveKnob.Plugin.CoreAudio.Settings"
mc:Ignorable="d"
d:DesignHeight="200" d:DesignWidth="800"
d:DataContext="{d:DesignInstance settings:DeviceVolumeActionSettingsViewModel}">
<StackPanel Orientation="Vertical">
<TextBlock>Playback device</TextBlock>
<ComboBox ItemsSource="{Binding PlaybackDevices}" SelectedItem="{Binding SelectedDevice}" DisplayMemberPath="DisplayName" />
</StackPanel>
</UserControl>

View File

@ -0,0 +1,14 @@
namespace MassiveKnob.Plugin.CoreAudio.Settings
{
/// <summary>
/// Interaction logic for DeviceVolumeActionSettingsView.xaml
/// </summary>
public partial class DeviceVolumeActionSettingsView
{
public DeviceVolumeActionSettingsView(DeviceVolumeActionSettingsViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,10 @@
namespace MassiveKnob.Plugin.CoreAudio.Settings
{
public class DeviceVolumeActionSettingsViewModel : BaseDeviceSettingsViewModel
{
// ReSharper disable once SuggestBaseTypeForParameter - by design
public DeviceVolumeActionSettingsViewModel(DeviceVolumeActionSettings settings) : base(settings)
{
}
}
}

View File

@ -0,0 +1,108 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace MassiveKnob.Plugin.CoreAudio {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "16.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Strings {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Strings() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MassiveKnob.Plugin.CoreAudio.Strings", typeof(Strings).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to {0}.
/// </summary>
internal static string DeviceDisplayNameActive {
get {
return ResourceManager.GetString("DeviceDisplayNameActive", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} (Disabled).
/// </summary>
internal static string DeviceDisplayNameDisabled {
get {
return ResourceManager.GetString("DeviceDisplayNameDisabled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} (Inactive).
/// </summary>
internal static string DeviceDisplayNameInactive {
get {
return ResourceManager.GetString("DeviceDisplayNameInactive", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} (Not present).
/// </summary>
internal static string DeviceDisplayNameNotPresent {
get {
return ResourceManager.GetString("DeviceDisplayNameNotPresent", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} (Unplugged).
/// </summary>
internal static string DeviceDisplayNameUnplugged {
get {
return ResourceManager.GetString("DeviceDisplayNameUnplugged", resourceCulture);
}
}
}
}

View File

@ -0,0 +1,135 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="DeviceDisplayNameActive" xml:space="preserve">
<value>{0}</value>
</data>
<data name="DeviceDisplayNameDisabled" xml:space="preserve">
<value>{0} (Disabled)</value>
</data>
<data name="DeviceDisplayNameInactive" xml:space="preserve">
<value>{0} (Inactive)</value>
</data>
<data name="DeviceDisplayNameNotPresent" xml:space="preserve">
<value>{0} (Not present)</value>
</data>
<data name="DeviceDisplayNameUnplugged" xml:space="preserve">
<value>{0} (Unplugged)</value>
</data>
</root>

View File

@ -1,4 +1,5 @@
using System;
using System.Threading;
using System.Windows.Controls;
using MassiveKnob.Plugin.MockDevice.Settings;
@ -10,36 +11,85 @@ namespace MassiveKnob.Plugin.MockDevice.Devices
public string Name { get; } = "Mock device";
public string Description { get; } = "Emulates the actual device but does not communicate with anything.";
public IMassiveKnobDeviceInstance Create(IMassiveKnobContext context)
public IMassiveKnobDeviceInstance Create()
{
return new Instance(context);
return new Instance();
}
private class Instance : IMassiveKnobDeviceInstance
{
public Instance(IMassiveKnobContext context)
private IMassiveKnobDeviceContext deviceContext;
private MockDeviceSettings settings;
private Timer inputChangeTimer;
private int reportedAnalogInputCount;
private int reportedDigitalInputCount;
private readonly Random random = new Random();
public void Initialize(IMassiveKnobDeviceContext context)
{
// TODO read settings
deviceContext = context;
settings = deviceContext.GetSettings<MockDeviceSettings>();
ApplySettings();
}
public void Dispose()
{
inputChangeTimer?.Dispose();
}
private void ApplySettings()
{
if (settings.AnalogCount != reportedAnalogInputCount ||
settings.DigitalCount != reportedDigitalInputCount)
{
deviceContext.Connected(new DeviceSpecs(settings.AnalogCount, settings.DigitalCount, 0, 0));
reportedAnalogInputCount = settings.AnalogCount;
reportedDigitalInputCount = settings.DigitalCount;
}
var interval = TimeSpan.FromSeconds(Math.Max(settings.Interval, 1));
if (inputChangeTimer == null)
inputChangeTimer = new Timer(Tick, null, interval, interval);
else
inputChangeTimer.Change(interval, interval);
}
public UserControl CreateSettingsControl()
{
// TODO pass context
return new MockDeviceSettings();
var viewModel = new MockDeviceSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
deviceContext.SetSettings(settings);
ApplySettings();
};
return new MockDeviceSettingsView(viewModel);
}
}
private class Settings
{
// TODO interval, etc.
private void Tick(object state)
{
var totalInputCount = reportedAnalogInputCount + reportedDigitalInputCount;
if (totalInputCount == 0)
return;
var changeInput = random.Next(0, totalInputCount);
if (changeInput < reportedAnalogInputCount)
deviceContext.AnalogChanged(changeInput, (byte)random.Next(0, 101));
else
deviceContext.DigitalChanged(changeInput - reportedAnalogInputCount, random.Next(0, 2) == 1);
}
}
}
}

View File

@ -48,9 +48,11 @@
<Compile Include="Devices\MockDevice.cs" />
<Compile Include="MassiveKnobMockDevicePlugin.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Settings\MockDeviceSettings.xaml.cs">
<DependentUpon>MockDeviceSettings.xaml</DependentUpon>
<Compile Include="Settings\MockDeviceSettingsView.xaml.cs">
<DependentUpon>MockDeviceSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="Settings\MockDeviceSettings.cs" />
<Compile Include="Settings\MockDeviceSettingsViewModel.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MassiveKnob.Plugin\MassiveKnob.Plugin.csproj">
@ -60,7 +62,7 @@
</ItemGroup>
<ItemGroup />
<ItemGroup>
<Page Include="Settings\MockDeviceSettings.xaml">
<Page Include="Settings\MockDeviceSettingsView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>

View File

@ -0,0 +1,9 @@
namespace MassiveKnob.Plugin.MockDevice.Settings
{
public class MockDeviceSettings
{
public int AnalogCount { get; set; } = 3;
public int DigitalCount { get; set; } = 1;
public int Interval { get; set; } = 5;
}
}

View File

@ -1,24 +0,0 @@
<UserControl x:Class="MassiveKnob.Plugin.MockDevice.Settings.MockDeviceSettings"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:MassiveKnob.Plugin.MockDevice.Settings"
mc:Ignorable="d" d:DesignWidth="800">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Margin="4">Number of knobs</TextBlock>
<TextBox Grid.Row="0" Grid.Column="1" Margin="4" Width="150" HorizontalAlignment="Left"></TextBox>
<CheckBox Grid.Row="1" Grid.Column="1" Margin="4">Randomly change the volume</CheckBox>
</Grid>
</UserControl>

View File

@ -1,28 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
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 MassiveKnob.Plugin.MockDevice.Settings
{
/// <summary>
/// Interaction logic for MockDeviceSettings.xaml
/// </summary>
public partial class MockDeviceSettings : UserControl
{
public MockDeviceSettings()
{
InitializeComponent();
}
}
}

View File

@ -0,0 +1,30 @@
<UserControl x:Class="MassiveKnob.Plugin.MockDevice.Settings.MockDeviceSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:settings="clr-namespace:MassiveKnob.Plugin.MockDevice.Settings"
mc:Ignorable="d" d:DesignWidth="800"
d:DataContext="{d:DesignInstance Type=settings:MockDeviceSettingsViewModel}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Margin="4">Number of analog inputs</TextBlock>
<TextBox Grid.Row="0" Grid.Column="1" Margin="4" Width="150" HorizontalAlignment="Left" Text="{Binding AnalogCount}" />
<TextBlock Grid.Row="1" Grid.Column="0" Margin="4">Number of digital inputs</TextBlock>
<TextBox Grid.Row="1" Grid.Column="1" Margin="4" Width="150" HorizontalAlignment="Left" Text="{Binding DigitalCount}" />
<TextBlock Grid.Row="2" Grid.Column="0" Margin="4">Random change interval (seconds)</TextBlock>
<TextBox Grid.Row="2" Grid.Column="1" Margin="4" Width="150" HorizontalAlignment="Left" Text="{Binding Interval}" />
</Grid>
</UserControl>

View File

@ -0,0 +1,15 @@
namespace MassiveKnob.Plugin.MockDevice.Settings
{
/// <summary>
/// Interaction logic for MockDeviceSettingsView.xaml
/// </summary>
public partial class MockDeviceSettingsView
{
public MockDeviceSettingsView(MockDeviceSettingsViewModel settingsViewModel)
{
DataContext = settingsViewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,67 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace MassiveKnob.Plugin.MockDevice.Settings
{
public class MockDeviceSettingsViewModel : INotifyPropertyChanged
{
private readonly MockDeviceSettings settings;
public event PropertyChangedEventHandler PropertyChanged;
// ReSharper disable UnusedMember.Global - used by WPF Binding
public int AnalogCount
{
get => settings.AnalogCount;
set
{
if (value == settings.AnalogCount)
return;
settings.AnalogCount = value;
OnPropertyChanged();
}
}
public int DigitalCount
{
get => settings.DigitalCount;
set
{
if (value == settings.DigitalCount)
return;
settings.DigitalCount = value;
OnPropertyChanged();
}
}
public int Interval
{
get => settings.Interval;
set
{
if (value == settings.Interval)
return;
settings.Interval = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
public MockDeviceSettingsViewModel(MockDeviceSettings settings)
{
this.settings = settings;
}
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}

View File

@ -0,0 +1,64 @@
using System;
using System.Windows.Controls;
using MassiveKnob.Plugin.SerialDevice.Settings;
using MassiveKnob.Plugin.SerialDevice.Worker;
namespace MassiveKnob.Plugin.SerialDevice.Devices
{
public class SerialDevice : IMassiveKnobDevice
{
public Guid DeviceId { get; } = new Guid("65255f25-d8f6-426b-8f12-cf03c44a1bf5");
public string Name { get; } = "Serial device";
public string Description { get; } = "A Serial (USB) device which implements the Massive Knob Protocol.";
public IMassiveKnobDeviceInstance Create()
{
return new Instance();
}
private class Instance : IMassiveKnobDeviceInstance
{
private IMassiveKnobDeviceContext deviceContext;
private SerialDeviceSettings settings;
private SerialWorker worker;
public void Initialize(IMassiveKnobDeviceContext context)
{
deviceContext = context;
settings = deviceContext.GetSettings<SerialDeviceSettings>();
worker = new SerialWorker(context);
ApplySettings();
}
public void Dispose()
{
worker.Dispose();
}
private void ApplySettings()
{
worker.Connect(settings.PortName, settings.BaudRate);
}
public UserControl CreateSettingsControl()
{
var viewModel = new SerialDeviceSettingsViewModel(settings);
viewModel.PropertyChanged += (sender, args) =>
{
if (!viewModel.IsSettingsProperty(args.PropertyName))
return;
deviceContext.SetSettings(settings);
ApplySettings();
};
return new SerialDeviceSettingsView(viewModel);
}
}
}
}

View File

@ -0,0 +1,71 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props" Condition="Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')" />
<PropertyGroup>
<Configuration Condition=" '$(Configuration)' == '' ">Debug</Configuration>
<Platform Condition=" '$(Platform)' == '' ">AnyCPU</Platform>
<ProjectGuid>{FC0D22D8-5F1B-4D85-8269-FA4837CDE3A2}</ProjectGuid>
<OutputType>Library</OutputType>
<AppDesignerFolder>Properties</AppDesignerFolder>
<RootNamespace>MassiveKnob.Plugin.SerialDevice</RootNamespace>
<AssemblyName>MassiveKnob.Plugin.SerialDevice</AssemblyName>
<TargetFrameworkVersion>v4.7.2</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<Deterministic>true</Deterministic>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
<DebugType>full</DebugType>
<Optimize>false</Optimize>
<OutputPath>$(localappdata)\MassiveKnob\Plugins\</OutputPath>
<DefineConstants>DEBUG;TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
<DebugType>pdbonly</DebugType>
<Optimize>true</Optimize>
<OutputPath>bin\Release\</OutputPath>
<DefineConstants>TRACE</DefineConstants>
<ErrorReport>prompt</ErrorReport>
<WarningLevel>4</WarningLevel>
</PropertyGroup>
<ItemGroup>
<Reference Include="PresentationCore" />
<Reference Include="PresentationFramework" />
<Reference Include="System" />
<Reference Include="System.Core" />
<Reference Include="System.Xaml" />
<Reference Include="System.Xml.Linq" />
<Reference Include="System.Data.DataSetExtensions" />
<Reference Include="Microsoft.CSharp" />
<Reference Include="System.Data" />
<Reference Include="System.Net.Http" />
<Reference Include="System.Xml" />
<Reference Include="WindowsBase" />
</ItemGroup>
<ItemGroup>
<Compile Include="Devices\SerialDevice.cs" />
<Compile Include="MassiveKnobSerialDevicePlugin.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Settings\SerialDeviceSettingsView.xaml.cs">
<DependentUpon>SerialDeviceSettingsView.xaml</DependentUpon>
</Compile>
<Compile Include="Settings\SerialDeviceSettingsViewModel.cs" />
<Compile Include="Settings\SerialDeviceSettings.cs" />
<Compile Include="Worker\SerialWorker.cs" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MassiveKnob.Plugin\MassiveKnob.Plugin.csproj">
<Project>{A1298BE4-1D23-416C-8C56-FC9264487A95}</Project>
<Name>MassiveKnob.Plugin</Name>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<Page Include="Settings\SerialDeviceSettingsView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
</ItemGroup>
<Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" />
</Project>

View File

@ -0,0 +1,20 @@
using System;
using System.Collections.Generic;
namespace MassiveKnob.Plugin.SerialDevice
{
[MassiveKnobPlugin]
public class MassiveKnobSerialDevicePlugin : IMassiveKnobDevicePlugin
{
public Guid PluginId { get; } = new Guid("276475e6-5ff0-420f-82dc-8aff5e8631d5");
public string Name { get; } = "Serial Device";
public string Description { get; } = "A Serial (USB) device which implements the Massive Knob Protocol.";
public string Author { get; } = "Mark van Renswoude <mark@x2software.net>";
public string Url { get; } = "https://www.github.com/MvRens/MassiveKnob/";
public IEnumerable<IMassiveKnobDevice> Devices { get; } = new IMassiveKnobDevice[]
{
new Devices.SerialDevice()
};
}
}

View File

@ -0,0 +1,35 @@
using System.Reflection;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following
// set of attributes. Change these attribute values to modify the information
// associated with an assembly.
[assembly: AssemblyTitle("MassiveKnob.Plugin.SerialDevice")]
[assembly: AssemblyDescription("")]
[assembly: AssemblyConfiguration("")]
[assembly: AssemblyCompany("")]
[assembly: AssemblyProduct("MassiveKnob.Plugin.SerialDevice")]
[assembly: AssemblyCopyright("Copyright © 2021")]
[assembly: AssemblyTrademark("")]
[assembly: AssemblyCulture("")]
// Setting ComVisible to false makes the types in this assembly not visible
// to COM components. If you need to access a type in this assembly from
// COM, set the ComVisible attribute to true on that type.
[assembly: ComVisible(false)]
// The following GUID is for the ID of the typelib if this project is exposed to COM
[assembly: Guid("fc0d22d8-5f1b-4d85-8269-fa4837cde3a2")]
// Version information for an assembly consists of the following four values:
//
// Major Version
// Minor Version
// Build Number
// Revision
//
// You can specify all the values or you can default the Build and Revision Numbers
// by using the '*' as shown below:
// [assembly: AssemblyVersion("1.0.*")]
[assembly: AssemblyVersion("1.0.0.0")]
[assembly: AssemblyFileVersion("1.0.0.0")]

View File

@ -0,0 +1,8 @@
namespace MassiveKnob.Plugin.SerialDevice.Settings
{
public class SerialDeviceSettings
{
public string PortName { get; set; } = null;
public int BaudRate { get; set; } = 115200;
}
}

View File

@ -0,0 +1,27 @@
<UserControl x:Class="MassiveKnob.Plugin.SerialDevice.Settings.SerialDeviceSettingsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:settings="clr-namespace:MassiveKnob.Plugin.SerialDevice.Settings"
mc:Ignorable="d"
d:DesignHeight="100" d:DesignWidth="800"
d:DataContext="{d:DesignInstance settings:SerialDeviceSettingsViewModel}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Margin="4">Serial port</TextBlock>
<ComboBox Grid.Row="0" Grid.Column="1" Margin="4" Width="150" HorizontalAlignment="Left" ItemsSource="{Binding SerialPorts}" SelectedItem="{Binding PortName}" />
<TextBlock Grid.Row="1" Grid.Column="0" Margin="4">Baud rate</TextBlock>
<TextBox Grid.Row="1" Grid.Column="1" Margin="4" Width="150" HorizontalAlignment="Left" Text="{Binding BaudRate}" />
</Grid>
</UserControl>

View File

@ -0,0 +1,14 @@
namespace MassiveKnob.Plugin.SerialDevice.Settings
{
/// <summary>
/// Interaction logic for SerialDeviceSettingsView.xaml
/// </summary>
public partial class SerialDeviceSettingsView
{
public SerialDeviceSettingsView(SerialDeviceSettingsViewModel viewModel)
{
DataContext = viewModel;
InitializeComponent();
}
}
}

View File

@ -0,0 +1,77 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.IO.Ports;
using System.Runtime.CompilerServices;
namespace MassiveKnob.Plugin.SerialDevice.Settings
{
public class SerialDeviceSettingsViewModel : INotifyPropertyChanged
{
private readonly SerialDeviceSettings settings;
private IEnumerable<string> serialPorts;
public event PropertyChangedEventHandler PropertyChanged;
// ReSharper disable UnusedMember.Global - used by WPF Binding
public IEnumerable<string> SerialPorts
{
get => serialPorts;
set
{
serialPorts = value;
OnPropertyChanged();
}
}
public string PortName
{
get => settings.PortName;
set
{
if (value == settings.PortName)
return;
settings.PortName = value;
OnPropertyChanged();
}
}
public int BaudRate
{
get => settings.BaudRate;
set
{
if (value == settings.BaudRate)
return;
settings.BaudRate = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
public SerialDeviceSettingsViewModel(SerialDeviceSettings settings)
{
this.settings = settings;
serialPorts = SerialPort.GetPortNames();
// TODO subscribe to device notification to refresh list
}
public bool IsSettingsProperty(string propertyName)
{
return propertyName != nameof(SerialPorts);
}
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}

View File

@ -0,0 +1,233 @@
using System;
using System.Diagnostics;
using System.IO.Ports;
using System.Text;
using System.Threading;
namespace MassiveKnob.Plugin.SerialDevice.Worker
{
public class SerialWorker : IDisposable
{
private readonly IMassiveKnobDeviceContext context;
private readonly object workerLock = new object();
private string lastPortName;
private int lastBaudRate;
private Thread workerThread;
private CancellationTokenSource workerThreadCancellation = new CancellationTokenSource();
public SerialWorker(IMassiveKnobDeviceContext context)
{
this.context = context;
}
public void Dispose()
{
Disconnect();
}
public void Connect(string portName, int baudRate)
{
lock (workerLock)
{
if (portName == lastPortName && baudRate == lastBaudRate)
return;
lastPortName = portName;
lastBaudRate = baudRate;
Disconnect();
if (string.IsNullOrEmpty(portName) || baudRate == 0)
return;
workerThreadCancellation = new CancellationTokenSource();
workerThread = new Thread(() => RunWorker(workerThreadCancellation.Token, portName, baudRate))
{
Name = "MassiveKnobSerialDevice Worker"
};
workerThread.Start();
}
}
private void Disconnect()
{
lock (workerLock)
{
workerThreadCancellation?.Cancel();
workerThreadCancellation = null;
workerThread = null;
}
}
private void RunWorker(CancellationToken cancellationToken, string portName, int baudRate)
{
context.Connecting();
while (!cancellationToken.IsCancellationRequested)
{
SerialPort serialPort = null;
DeviceSpecs specs = default;
void SafeCloseSerialPort()
{
try
{
serialPort?.Dispose();
}
catch
{
// ignored
}
serialPort = null;
context.Connecting();
}
while (serialPort == null && !cancellationToken.IsCancellationRequested)
{
if (!TryConnect(ref serialPort, portName, baudRate, out specs))
{
SafeCloseSerialPort();
Thread.Sleep(500);
}
else
break;
}
if (cancellationToken.IsCancellationRequested)
{
SafeCloseSerialPort();
break;
}
var processingMessage = false;
Debug.Assert(serialPort != null, nameof(serialPort) + " != null");
serialPort.DataReceived += (sender, args) =>
{
if (args.EventType != SerialData.Chars || processingMessage)
return;
var senderPort = (SerialPort)sender;
processingMessage = true;
try
{
var message = (char)senderPort.ReadByte();
ProcessMessage(senderPort, message);
}
finally
{
processingMessage = false;
}
};
context.Connected(specs);
try
{
// This is where sending data to the hardware would be implemented
while (serialPort.IsOpen && !cancellationToken.IsCancellationRequested)
{
Thread.Sleep(10);
}
}
catch
{
// ignored
}
context.Disconnected();
SafeCloseSerialPort();
if (!cancellationToken.IsCancellationRequested)
Thread.Sleep(500);
}
}
private static bool TryConnect(ref SerialPort serialPort, string portName, int baudRate, out DeviceSpecs specs)
{
try
{
serialPort = new SerialPort(portName, baudRate)
{
Encoding = Encoding.ASCII,
ReadTimeout = 1000,
WriteTimeout = 1000,
DtrEnable = true // TODO make setting
};
serialPort.Open();
// Send handshake
serialPort.Write(new[] { 'H', 'M', 'K', 'B' }, 0, 4);
// Wait for reply
var response = serialPort.ReadByte();
if ((char) response == 'H')
{
var knobCount = serialPort.ReadByte();
if (knobCount > -1)
{
specs = new DeviceSpecs(knobCount, 0, 0, 0);
return true;
}
}
else
CheckForError(serialPort, (char)response);
specs = default;
return false;
}
catch
{
specs = default;
return false;
}
}
private void ProcessMessage(SerialPort serialPort, char message)
{
switch (message)
{
case 'V':
var knobIndex = (byte)serialPort.ReadByte();
var volume = (byte)serialPort.ReadByte();
if (knobIndex < 255 && volume <= 100)
context.AnalogChanged(knobIndex, volume);
break;
}
}
private static void CheckForError(SerialPort serialPort, char message)
{
if (message != 'E')
return;
var length = serialPort.ReadByte();
if (length <= 0)
return;
var buffer = new byte[length];
var bytesRead = 0;
while (bytesRead < length)
bytesRead += serialPort.Read(buffer, bytesRead, length - bytesRead);
var errorMessage = Encoding.ASCII.GetString(buffer);
Debug.Print(errorMessage);
}
}
}

View File

@ -19,9 +19,14 @@ namespace MassiveKnob.Plugin
InputDigital = 1 << 1,
/// <summary>
/// Can be assigned to an output, like an LED or relay.
/// Can be assigned to an analog output.
/// </summary>
OutputSignal = 1 << 2
OutputAnalog = 1 << 2,
/// <summary>
/// Can be assigned to a digital output, like an LED or relay.
/// </summary>
OutputDigital = 1 << 3
}
@ -53,7 +58,6 @@ namespace MassiveKnob.Plugin
/// <summary>
/// Called when an action is bound to a knob or button to create an instance of the action.
/// </summary>
/// <param name="context">Provides an interface to the Massive Knob settings and device. Can be stored until the action instance is disposed.</param>
IMassiveKnobActionInstance Create(IMassiveKnobActionContext context);
IMassiveKnobActionInstance Create();
}
}

View File

@ -4,9 +4,15 @@
public interface IMassiveKnobActionContext : IMassiveKnobContext
{
/// <summary>
/// Sets the state of the signal. Only valid for OutputSignal action types, will raise an exception otherwise.
/// Sets the state of the analog output. Only valid for OutputAnalog action types, will raise an exception otherwise.
/// </summary>
/// <param name="value">The analog value in the range of 0 to 100.</param>
void SetAnalogOutput(byte value);
/// <summary>
/// Sets the state of the digital output. Only valid for OutputDigital action types, will raise an exception otherwise.
/// </summary>
/// <param name="on">Whether the signal is on or off.</param>
void SetSignal(bool on);
void SetDigitalOutput(bool on);
}
}

View File

@ -9,6 +9,12 @@ namespace MassiveKnob.Plugin
/// </summary>
public interface IMassiveKnobActionInstance : IDisposable
{
/// <summary>
/// Called right after this instance is created.
/// </summary>
/// <param name="context">Provides an interface to the Massive Knob settings and device. Can be stored until the action instance is disposed.</param>
void Initialize(IMassiveKnobActionContext context);
/// <summary>
/// Called when an action should display it's settings. Assume the width is variable, height is
/// determined by the UserControl. Return null to indicate there are no settings for this action.

View File

@ -8,7 +8,7 @@ namespace MassiveKnob.Plugin
public interface IMassiveKnobAnalogAction : IMassiveKnobActionInstance
{
/// <summary>
/// Called when a knob's position changes.
/// Called when an analog input's value changes.
/// </summary>
/// <param name="value">The new value. Range is 0 to 100.</param>
ValueTask AnalogChanged(byte value);

View File

@ -25,7 +25,6 @@ namespace MassiveKnob.Plugin
/// <summary>
/// Called when the device is selected.
/// </summary>
/// <param name="context">Provides an interface to the Massive Knob settings and device. Can be stored until the device instance is disposed.</param>
IMassiveKnobDeviceInstance Create(IMassiveKnobContext context);
IMassiveKnobDeviceInstance Create();
}
}

View File

@ -0,0 +1,80 @@
namespace MassiveKnob.Plugin
{
/// <inheritdoc />
public interface IMassiveKnobDeviceContext : IMassiveKnobContext
{
/// <summary>
/// Call when an attempt is being made to connect to the device. If the connection is always ready
/// this call can be skipped.
/// </summary>
void Connecting();
/// <summary>
/// Call when the device is connected. This method must be called before AnalogChanged or DigitalChanged.
/// </summary>
/// <param name="specs">The specs as reported by the device.</param>
void Connected(DeviceSpecs specs);
/// <summary>
/// Call when the connection to the device has been lost.
/// </summary>
void Disconnected();
/// <summary>
/// Call when an analog input's value has changed.
/// </summary>
/// <param name="analogInputIndex">The index of the analog input. Must be within bounds of the value reported in Connected.</param>
/// <param name="value">The new value in the range from 0 to 100.</param>
void AnalogChanged(int analogInputIndex, byte value);
/// <summary>
/// Call when a digital input's state has changed.
/// </summary>
/// <param name="digitalInputIndex">The index of the digital input. Must be within bounds of the value reported in Connected.</param>
/// <param name="on">Whether the input is considered on or off.</param>
void DigitalChanged(int digitalInputIndex, bool on);
}
/// <summary>
/// Defines the specs as reported by the device.
/// </summary>
public readonly struct DeviceSpecs
{
/// <summary>
/// The number of analog input controls supported by the device.
/// </summary>
public readonly int AnalogInputCount;
/// <summary>
/// The number of digital input controls supported by the device.
/// </summary>
public readonly int DigitalInputCount;
/// <summary>
/// The number of analog output controls supported by the device.
/// </summary>
public readonly int AnalogOutputCount;
/// <summary>
/// The number of digital output controls supported by the device.
/// </summary>
public readonly int DigitalOutputCount;
/// <summary>
/// Defines the specs as reported by the device.
/// </summary>
/// <param name="analogInputCount">The number of analog input controls supported by the device.</param>
/// <param name="digitalInputCount">The number of digital input controls supported by the device.</param>
/// <param name="analogOutputCount">The number of analog output controls supported by the device.</param>
/// <param name="digitalOutputCount">The number of digital output controls supported by the device.</param>
public DeviceSpecs(int analogInputCount, int digitalInputCount, int analogOutputCount, int digitalOutputCount)
{
AnalogInputCount = analogInputCount;
DigitalInputCount = digitalInputCount;
AnalogOutputCount = analogOutputCount;
DigitalOutputCount = digitalOutputCount;
}
}
}

View File

@ -8,6 +8,12 @@ namespace MassiveKnob.Plugin
/// </summary>
public interface IMassiveKnobDeviceInstance : IDisposable
{
/// <summary>
/// Called right after this instance is created.
/// </summary>
/// <param name="context">Provides an interface to the Massive Knob settings and device. Can be stored until the device instance is disposed.</param>
void Initialize(IMassiveKnobDeviceContext context);
/// <summary>
/// Called when a device should display it's settings. Assume the width is variable, height is
/// determined by the UserControl. Return null to indicate there are no settings for this device.

View File

@ -1,9 +1,16 @@
namespace MassiveKnob.Plugin
using System.Threading.Tasks;
namespace MassiveKnob.Plugin
{
/// <summary>
/// Required to be implemented for Action type InputDigital. Receives an update when a knob's position changes.
/// </summary>
public interface IMassiveKnobDigitalAction : IMassiveKnobActionInstance
{
/// <summary>
/// Called when a digital input's value changes.
/// </summary>
/// <param name="on">The new value.</param>
ValueTask DigitalChanged(bool on);
}
}

View File

@ -44,6 +44,7 @@
<Reference Include="System.Xml" />
</ItemGroup>
<ItemGroup>
<Compile Include="IMassiveKnobDeviceContext.cs" />
<Compile Include="IMassiveKnobActionContext.cs" />
<Compile Include="IMassiveKnobActionInstance.cs" />
<Compile Include="IMassiveKnobActionPlugin.cs" />
@ -52,7 +53,7 @@
<Compile Include="IMassiveKnobAnalogAction.cs" />
<Compile Include="IMassiveKnobContext.cs" />
<Compile Include="IMassiveKnobDevice.cs" />
<Compile Include="IMassiveKnobHardwarePlugin.cs" />
<Compile Include="IMassiveKnobDevicePlugin.cs" />
<Compile Include="MassiveKnobPluginAttribute.cs" />
<Compile Include="IMassiveKnobPlugin.cs" />
<Compile Include="IMassiveKnobAction.cs" />

View File

@ -1,5 +1,4 @@
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
// General Information about an assembly is controlled through the following

View File

@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MassiveKnob.Plugin.CoreAudi
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MassiveKnob.Plugin.MockDevice", "MassiveKnob.Plugin.MockDevice\MassiveKnob.Plugin.MockDevice.csproj", "{674DE974-B134-4DB5-BFAF-7BC3D05E16DE}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MassiveKnob.Plugin.SerialDevice", "MassiveKnob.Plugin.SerialDevice\MassiveKnob.Plugin.SerialDevice.csproj", "{FC0D22D8-5F1B-4D85-8269-FA4837CDE3A2}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -33,6 +35,10 @@ Global
{674DE974-B134-4DB5-BFAF-7BC3D05E16DE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{674DE974-B134-4DB5-BFAF-7BC3D05E16DE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{674DE974-B134-4DB5-BFAF-7BC3D05E16DE}.Release|Any CPU.Build.0 = Release|Any CPU
{FC0D22D8-5F1B-4D85-8269-FA4837CDE3A2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FC0D22D8-5F1B-4D85-8269-FA4837CDE3A2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FC0D22D8-5F1B-4D85-8269-FA4837CDE3A2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FC0D22D8-5F1B-4D85-8269-FA4837CDE3A2}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE

View File

@ -1,69 +0,0 @@
using System.Collections.Generic;
using System.Threading.Tasks;
namespace MassiveKnob.Hardware
{
public abstract class AbstractMassiveKnobHardware : IMassiveKnobHardware
{
protected ObserverProxy Observers = new ObserverProxy();
public void AttachObserver(IMassiveKnobHardwareObserver observer)
{
Observers.AttachObserver(observer);
}
public void DetachObserver(IMassiveKnobHardwareObserver observer)
{
Observers.DetachObserver(observer);
}
public abstract Task TryConnect();
public abstract Task Disconnect();
public class ObserverProxy : IMassiveKnobHardwareObserver
{
private readonly List<IMassiveKnobHardwareObserver> observers = new List<IMassiveKnobHardwareObserver>();
public void AttachObserver(IMassiveKnobHardwareObserver observer)
{
observers.Add(observer);
}
public void DetachObserver(IMassiveKnobHardwareObserver observer)
{
observers.Remove(observer);
}
public void Connecting()
{
foreach (var observer in observers)
observer.Connecting();
}
public void Connected(int knobCount)
{
foreach (var observer in observers)
observer.Connected(knobCount);
}
public void Disconnected()
{
foreach (var observer in observers)
observer.Disconnected();
}
public void VolumeChanged(int knob, int volume)
{
foreach (var observer in observers)
observer.VolumeChanged(knob, volume);
}
}
}
}

View File

@ -1,82 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AudioSwitcher.AudioApi;
using AudioSwitcher.AudioApi.CoreAudio;
namespace MassiveKnob.Hardware
{
public class CoreAudioDeviceManager : IAudioDeviceManager
{
private readonly Lazy<CoreAudioController> audioController = new Lazy<CoreAudioController>();
private List<IAudioDevice> devices;
public void Dispose()
{
if (audioController.IsValueCreated)
audioController.Value.Dispose();
}
public async Task<IEnumerable<IAudioDevice>> GetDevices()
{
return devices ?? (devices = (await audioController.Value.GetPlaybackDevicesAsync())
.Select(device => new AudioDevice(device) as IAudioDevice)
.ToList());
}
public Task<IAudioDevice> GetDeviceById(Guid deviceId)
{
return Task.FromResult(devices?.FirstOrDefault(device => device.Id == deviceId));
}
private class AudioDevice : IAudioDevice
{
private readonly IDevice device;
public Guid Id { get; }
public string DisplayName { get; }
public AudioDevice(IDevice device)
{
this.device = device;
Id = device.Id;
string displayFormat;
if ((device.State & DeviceState.Disabled) != 0)
displayFormat = Strings.DeviceDisplayNameDisabled;
else if ((device.State & DeviceState.Unplugged) != 0)
displayFormat = Strings.DeviceDisplayNameUnplugged;
else if ((device.State & DeviceState.NotPresent) != 0)
displayFormat = Strings.DeviceDisplayNameNotPresent;
else if ((device.State & DeviceState.Active) == 0)
displayFormat = Strings.DeviceDisplayNameInactive;
else
displayFormat = Strings.DeviceDisplayNameActive;
DisplayName = string.Format(displayFormat, device.FullName);
}
public Task SetVolume(int volume)
{
return device.SetVolumeAsync(volume);
}
}
}
public class CoreAudioDeviceManagerFactory : IAudioDeviceManagerFactory
{
public IAudioDeviceManager Create()
{
return new CoreAudioDeviceManager();
}
}
}

View File

@ -1,27 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace MassiveKnob.Hardware
{
public interface IAudioDevice
{
Guid Id { get; }
string DisplayName { get; }
Task SetVolume(int volume);
}
public interface IAudioDeviceManager : IDisposable
{
Task<IEnumerable<IAudioDevice>> GetDevices();
Task<IAudioDevice> GetDeviceById(Guid deviceId);
}
public interface IAudioDeviceManagerFactory
{
IAudioDeviceManager Create();
}
}

View File

@ -1,31 +0,0 @@
using System.Threading.Tasks;
namespace MassiveKnob.Hardware
{
public interface IMassiveKnobHardwareObserver
{
void Connecting();
void Connected(int knobCount);
void Disconnected();
void VolumeChanged(int knob, int volume);
// void ButtonPress(int index); -- for switching the active device, TBD
}
public interface IMassiveKnobHardware
{
void AttachObserver(IMassiveKnobHardwareObserver observer);
void DetachObserver(IMassiveKnobHardwareObserver observer);
Task TryConnect();
Task Disconnect();
// Task SetActiveKnob(int knob); -- for providing LED feedback when switching the active device, TBD
}
public interface IMassiveKnobHardwareFactory
{
IMassiveKnobHardware Create(string portName);
}
}

View File

@ -1,71 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace MassiveKnob.Hardware
{
public class MockMassiveKnobHardware : AbstractMassiveKnobHardware
{
private readonly int knobCount;
private readonly TimeSpan volumeChangeInterval;
private readonly int maxVolume;
private Timer changeVolumeTimer;
private readonly Random random = new Random();
public MockMassiveKnobHardware(int knobCount, TimeSpan volumeChangeInterval, int maxVolume)
{
this.knobCount = knobCount;
this.volumeChangeInterval = volumeChangeInterval;
this.maxVolume = maxVolume;
}
public override async Task TryConnect()
{
if (changeVolumeTimer != null)
return;
await Task.Delay(2000);
Observers.Connected(knobCount);
changeVolumeTimer = new Timer(
state =>
{
Observers.VolumeChanged(random.Next(0, knobCount), random.Next(0, maxVolume));
},
null,
volumeChangeInterval,
volumeChangeInterval);
}
public override Task Disconnect()
{
changeVolumeTimer?.Dispose();
return Task.CompletedTask;
}
}
// ReSharper disable once UnusedMember.Global - for testing purposes only
public class MockMassiveKnobHardwareFactory : IMassiveKnobHardwareFactory
{
private readonly int knobCount;
private readonly TimeSpan volumeChangeInterval;
private readonly int maxVolume;
public MockMassiveKnobHardwareFactory(int knobCount, TimeSpan volumeChangeInterval, int maxVolume)
{
this.knobCount = knobCount;
this.volumeChangeInterval = volumeChangeInterval;
this.maxVolume = maxVolume;
}
public IMassiveKnobHardware Create(string portName)
{
return new MockMassiveKnobHardware(knobCount, volumeChangeInterval, maxVolume);
}
}
}

View File

@ -1,180 +0,0 @@
using System.Diagnostics;
using System.IO.Ports;
using System.Threading;
using System.Threading.Tasks;
namespace MassiveKnob.Hardware
{
public class SerialMassiveKnobHardware : AbstractMassiveKnobHardware
{
private readonly string portName;
private Thread workerThread;
private readonly CancellationTokenSource workerThreadCancellation = new CancellationTokenSource();
private readonly TaskCompletionSource<bool> workerThreadCompleted = new TaskCompletionSource<bool>();
public SerialMassiveKnobHardware(string portName)
{
this.portName = portName;
}
public override async Task TryConnect()
{
if (workerThread != null)
await Disconnect();
workerThread = new Thread(RunWorker)
{
Name = "SerialMassiveKnobHardware Worker"
};
workerThread.Start();
}
public override async Task Disconnect()
{
workerThreadCancellation.Cancel();
await workerThreadCompleted.Task;
workerThread = null;
}
private void RunWorker()
{
Observers.Connecting();
while (!workerThreadCancellation.IsCancellationRequested)
{
SerialPort serialPort = null;
void SafeCloseSerialPort()
{
try
{
serialPort?.Dispose();
}
catch
{
// ignroed
}
serialPort = null;
Observers.Disconnected();
Observers.Connecting();
}
var knobCount = 0;
while (serialPort == null && !workerThreadCancellation.IsCancellationRequested)
{
try
{
serialPort = new SerialPort(portName, 115200);
serialPort.Open();
// Send handshake
serialPort.Write(new[] { 'H', 'M', 'K', 'B' }, 0, 4);
// Wait for reply
serialPort.ReadTimeout = 1000;
var response = serialPort.ReadByte();
if ((char) response == 'H')
{
knobCount = serialPort.ReadByte();
if (knobCount > -1)
break;
}
SafeCloseSerialPort();
Thread.Sleep(500);
}
catch
{
SafeCloseSerialPort();
Thread.Sleep(500);
}
}
if (workerThreadCancellation.IsCancellationRequested)
{
SafeCloseSerialPort();
break;
}
var processingMessage = false;
Debug.Assert(serialPort != null, nameof(serialPort) + " != null");
serialPort.DataReceived += (sender, args) =>
{
if (args.EventType != SerialData.Chars || processingMessage)
return;
var senderPort = (SerialPort) sender;
processingMessage = true;
try
{
var message = (char) senderPort.ReadByte();
ProcessMessage(senderPort, message);
}
finally
{
processingMessage = false;
}
};
Observers.Connected(knobCount);
try
{
// This is where sending data to the hardware would be implemented
while (serialPort.IsOpen && !workerThreadCancellation.IsCancellationRequested)
{
Thread.Sleep(10);
}
}
catch
{
// ignored
}
Observers.Disconnected();
SafeCloseSerialPort();
if (!workerThreadCancellation.IsCancellationRequested)
Thread.Sleep(500);
}
workerThreadCompleted.TrySetResult(true);
}
private void ProcessMessage(SerialPort serialPort, char message)
{
switch (message)
{
case 'V':
var knobIndex = (byte)serialPort.ReadByte();
var volume = (byte)serialPort.ReadByte();
if (knobIndex < 255 && volume <= 100)
Observers.VolumeChanged(knobIndex, volume);
break;
}
}
}
public class SerialMassiveKnobHardwareFactory : IMassiveKnobHardwareFactory
{
public IMassiveKnobHardware Create(string portName)
{
return new SerialMassiveKnobHardware(portName);
}
}
}

View File

@ -24,7 +24,7 @@ namespace MassiveKnob.Helpers
itemToCheck = VisualTreeHelper.GetParent(itemToCheck);
// If you stopped at a ComboBoxItem, you're in the dropdown
var inDropDown = (itemToCheck is ComboBoxItem);
var inDropDown = itemToCheck is ComboBoxItem;
return inDropDown
? DropdownItemsTemplate ?? DropdownItemsTemplateSelector?.SelectTemplate(item, container)
@ -33,6 +33,7 @@ namespace MassiveKnob.Helpers
}
// ReSharper disable once UnusedMember.Global - used in XAML
public class ComboBoxTemplateSelectorExtension : MarkupExtension
{
public DataTemplate SelectedItemTemplate { get; set; }
@ -42,7 +43,7 @@ namespace MassiveKnob.Helpers
public override object ProvideValue(IServiceProvider serviceProvider)
{
return new ComboBoxTemplateSelector()
return new ComboBoxTemplateSelector
{
SelectedItemTemplate = SelectedItemTemplate,
SelectedItemTemplateSelector = SelectedItemTemplateSelector,

View File

@ -1,4 +1,5 @@
using System;
/*
using System;
using System.Windows.Input;
namespace MassiveKnob.Helpers
@ -78,3 +79,4 @@ namespace MassiveKnob.Helpers
}
}
}
*/

View File

@ -0,0 +1,65 @@
using System;
using System.Threading.Tasks;
// Original source: https://github.com/Gentlee/SerialQueue
// ReSharper disable UnusedMember.Global - public API
namespace MassiveKnob.Helpers
{
public class SerialQueue
{
private readonly object locker = new object();
private readonly WeakReference<Task> lastTaskWeakRef = new WeakReference<Task>(null);
public Task Enqueue(Action action)
{
return Enqueue(() =>
{
action();
return true;
});
}
public Task<T> Enqueue<T>(Func<T> function)
{
lock (locker)
{
var resultTask = lastTaskWeakRef.TryGetTarget(out var lastTask)
? lastTask.ContinueWith(_ => function(), TaskContinuationOptions.ExecuteSynchronously)
: Task.Run(function);
lastTaskWeakRef.SetTarget(resultTask);
return resultTask;
}
}
public Task Enqueue(Func<Task> asyncAction)
{
lock (locker)
{
var resultTask = lastTaskWeakRef.TryGetTarget(out var lastTask)
? lastTask.ContinueWith(_ => asyncAction(), TaskContinuationOptions.ExecuteSynchronously).Unwrap()
: Task.Run(asyncAction);
lastTaskWeakRef.SetTarget(resultTask);
return resultTask;
}
}
public Task<T> Enqueue<T>(Func<Task<T>> asyncFunction)
{
lock (locker)
{
var resultTask = lastTaskWeakRef.TryGetTarget(out var lastTask)
? lastTask.ContinueWith(_ => asyncFunction(), TaskContinuationOptions.ExecuteSynchronously).Unwrap()
: Task.Run(asyncFunction);
lastTaskWeakRef.SetTarget(resultTask);
return resultTask;
}
}
}
}

View File

@ -58,19 +58,20 @@
<Reference Include="WindowsBase" />
</ItemGroup>
<ItemGroup>
<Compile Include="Hardware\AbstractMassiveKnobHardware.cs" />
<Compile Include="Hardware\CoreAudioDeviceManager.cs" />
<Compile Include="Hardware\IAudioDeviceManager.cs" />
<Compile Include="Hardware\IMassiveKnobHardware.cs" />
<Compile Include="Hardware\MockMassiveKnobHardware.cs" />
<Compile Include="Hardware\SerialMassiveKnobHardware.cs" />
<Compile Include="Helpers\ComboBoxTemplateSelector.cs" />
<Compile Include="Helpers\DelegateCommand.cs" />
<Compile Include="Helpers\SerialQueue.cs" />
<Compile Include="Model\IMassiveKnobOrchestrator.cs" />
<Compile Include="Model\IPluginManager.cs" />
<Compile Include="Model\MassiveKnobOrchestrator.cs" />
<Compile Include="Model\PluginManager.cs" />
<Compile Include="ViewModel\ActionViewModel.cs" />
<Compile Include="ViewModel\DeviceViewModel.cs" />
<Compile Include="ViewModel\InputOutputViewModel.cs" />
<Compile Include="ViewModel\SettingsViewModel.cs" />
<Compile Include="View\InputOutputView.xaml.cs">
<DependentUpon>InputOutputView.xaml</DependentUpon>
</Compile>
<Compile Include="View\SettingsWindow.xaml.cs">
<DependentUpon>SettingsWindow.xaml</DependentUpon>
</Compile>
@ -116,6 +117,9 @@
<PackageReference Include="SimpleInjector">
<Version>5.2.1</Version>
</PackageReference>
<PackageReference Include="System.Reactive">
<Version>5.0.0</Version>
</PackageReference>
</ItemGroup>
<ItemGroup>
<Content Include="MainIcon.ico" />
@ -136,6 +140,10 @@
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="View\InputOutputView.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>
</Page>
<Page Include="View\SettingsWindow.xaml">
<SubType>Designer</SubType>
<Generator>MSBuild:Compile</Generator>

View File

@ -1,11 +1,44 @@
using MassiveKnob.Plugin;
using System;
using MassiveKnob.Plugin;
namespace MassiveKnob.Model
{
public interface IMassiveKnobOrchestrator
public interface IMassiveKnobOrchestrator : IDisposable
{
IMassiveKnobDeviceInstance ActiveDeviceInstance { get; }
MassiveKnobDeviceInfo ActiveDevice { get; }
IObservable<MassiveKnobDeviceInfo> ActiveDeviceSubject { get; }
IMassiveKnobDeviceInstance SetActiveDevice(IMassiveKnobDevice device);
MassiveKnobDeviceInfo SetActiveDevice(IMassiveKnobDevice device);
MassiveKnobActionInfo GetAction(MassiveKnobActionType actionType, int index);
MassiveKnobActionInfo SetAction(MassiveKnobActionType actionType, int index, IMassiveKnobAction action);
}
public class MassiveKnobDeviceInfo
{
public IMassiveKnobDevice Info { get; }
public IMassiveKnobDeviceInstance Instance { get; }
public DeviceSpecs? Specs { get; }
public MassiveKnobDeviceInfo(IMassiveKnobDevice info, IMassiveKnobDeviceInstance instance, DeviceSpecs? specs)
{
Info = info;
Instance = instance;
Specs = specs;
}
}
public class MassiveKnobActionInfo
{
public IMassiveKnobAction Info { get; }
public IMassiveKnobActionInstance Instance { get; }
public MassiveKnobActionInfo(IMassiveKnobAction info, IMassiveKnobActionInstance instance)
{
Info = info;
Instance = instance;
}
}
}

View File

@ -5,8 +5,7 @@ namespace MassiveKnob.Model
{
public interface IPluginManager
{
IEnumerable<IMassiveKnobPlugin> Plugins { get; }
IEnumerable<IMassiveKnobDevicePlugin> GetDevicePlugins();
IEnumerable<IMassiveKnobActionPlugin> GetActionPlugins();
}
}

View File

@ -1,51 +1,548 @@
using MassiveKnob.Plugin;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reactive.Subjects;
using MassiveKnob.Helpers;
using MassiveKnob.Plugin;
using MassiveKnob.Settings;
using Newtonsoft.Json.Linq;
namespace MassiveKnob.Model
{
public class MassiveKnobOrchestrator : IMassiveKnobOrchestrator
{
private readonly Settings.Settings settings;
private readonly IPluginManager pluginManager;
private readonly object settingsLock = new object();
private Settings.Settings settings;
private readonly SerialQueue flushSettingsQueue = new SerialQueue();
private MassiveKnobDeviceInfo activeDevice;
private readonly Subject<MassiveKnobDeviceInfo> activeDeviceInfoSubject = new Subject<MassiveKnobDeviceInfo>();
private IMassiveKnobDeviceContext activeDeviceContext;
private readonly List<ActionMapping> analogInputs = new List<ActionMapping>();
private readonly List<ActionMapping> digitalInputs = new List<ActionMapping>();
private readonly List<ActionMapping> analogOutputs = new List<ActionMapping>();
private readonly List<ActionMapping> digitalOutputs = new List<ActionMapping>();
public IMassiveKnobDeviceInstance ActiveDeviceInstance { get; private set; }
public MassiveKnobOrchestrator(Settings.Settings settings)
public MassiveKnobDeviceInfo ActiveDevice
{
this.settings = settings;
}
public IMassiveKnobDeviceInstance SetActiveDevice(IMassiveKnobDevice device)
{
ActiveDeviceInstance?.Dispose();
ActiveDeviceInstance = device?.Create(new Context(settings));
return ActiveDeviceInstance;
}
public class Context : IMassiveKnobContext
{
private readonly Settings.Settings settings;
public Context(Settings.Settings settings)
get => activeDevice;
private set
{
this.settings = settings;
if (value == activeDevice)
return;
activeDevice = value;
activeDeviceInfoSubject.OnNext(activeDevice);
}
}
public IObservable<MassiveKnobDeviceInfo> ActiveDeviceSubject => activeDeviceInfoSubject;
public MassiveKnobOrchestrator(IPluginManager pluginManager)
{
this.pluginManager = pluginManager;
}
public void Dispose()
{
activeDevice?.Instance?.Dispose();
void DisposeMappings(IEnumerable<ActionMapping> mappings)
{
foreach (var mapping in mappings)
mapping?.ActionInfo.Instance?.Dispose();
}
DisposeMappings(analogInputs);
DisposeMappings(digitalInputs);
DisposeMappings(analogOutputs);
DisposeMappings(digitalOutputs);
activeDeviceInfoSubject?.Dispose();
}
public void Load()
{
lock (settingsLock)
{
settings = SettingsJsonSerializer.Deserialize();
if (settings.Device == null)
return;
var allDevices = pluginManager.GetDevicePlugins().SelectMany(dp => dp.Devices);
var device = allDevices.FirstOrDefault(d => d.DeviceId == settings.Device.DeviceId);
InternalSetActiveDevice(device, false);
}
}
MassiveKnobDeviceInfo IMassiveKnobOrchestrator.ActiveDevice => activeDevice;
public MassiveKnobDeviceInfo SetActiveDevice(IMassiveKnobDevice device)
{
return InternalSetActiveDevice(device, true);
}
public MassiveKnobActionInfo GetAction(MassiveKnobActionType actionType, int index)
{
var list = GetActionMappingList(actionType);
return index >= list.Count ? null : list[index].ActionInfo;
}
public MassiveKnobActionInfo SetAction(MassiveKnobActionType actionType, int index, IMassiveKnobAction action)
{
var list = GetActionMappingList(actionType);
if (index >= list.Count)
return null;
if (list[index]?.ActionInfo.Info == action)
return list[index].ActionInfo;
list[index]?.ActionInfo.Instance?.Dispose();
lock (settingsLock)
{
var settingsList = GetActionSettingsList(actionType);
while (index >= settingsList.Count)
settingsList.Add(null);
settingsList[index] = action == null ? null : new Settings.Settings.ActionSettings
{
ActionId = action.ActionId,
Settings = null
};
}
FlushSettings();
Action initializeAfterRegistration = null;
var mapping = CreateActionMapping(action, index, (actionInstance, actionContext) =>
{
initializeAfterRegistration = () => actionInstance.Initialize(actionContext);
});
list[index] = mapping;
initializeAfterRegistration?.Invoke();
return mapping?.ActionInfo;
}
private MassiveKnobDeviceInfo InternalSetActiveDevice(IMassiveKnobDevice device, bool resetSettings)
{
if (device == ActiveDevice?.Info)
return ActiveDevice;
if (resetSettings)
{
lock (settingsLock)
{
if (device == null)
settings.Device = null;
else
{
settings.Device = new Settings.Settings.DeviceSettings
{
DeviceId = device.DeviceId,
Settings = null
};
}
}
FlushSettings();
}
ActiveDevice?.Instance.Dispose();
if (device != null)
{
var instance = device.Create();
ActiveDevice = new MassiveKnobDeviceInfo(device, instance, null);
activeDeviceContext = new DeviceContext(this, device);
instance.Initialize(activeDeviceContext);
}
else
{
ActiveDevice = null;
activeDeviceContext = null;
}
return ActiveDevice;
}
protected T GetDeviceSettings<T>(IMassiveKnobDeviceContext context) where T : class, new()
{
if (context != activeDeviceContext)
throw new InvalidOperationException("Caller must be the active device to retrieve the settings");
lock (settingsLock)
{
return settings.Device.Settings?.ToObject<T>() ?? new T();
}
}
protected void SetDeviceSettings<T>(IMassiveKnobDeviceContext context, IMassiveKnobDevice device, T deviceSettings) where T : class, new()
{
if (context != activeDeviceContext)
throw new InvalidOperationException("Caller must be the active device to update the settings");
lock (settingsLock)
{
if (settings.Device == null)
settings.Device = new Settings.Settings.DeviceSettings
{
DeviceId = device.DeviceId
};
settings.Device.Settings = JObject.FromObject(deviceSettings);
}
FlushSettings();
}
protected T GetActionSettings<T>(IMassiveKnobActionContext context, IMassiveKnobAction action, int index) where T : class, new()
{
var list = GetActionMappingList(action.ActionType);
if (index >= list.Count)
return new T();
if (list[index]?.Context != context)
throw new InvalidOperationException("Caller must be the active action to retrieve the settings");
lock (settingsLock)
{
var settingsList = GetActionSettingsList(action.ActionType);
if (index >= settingsList.Count)
return new T();
return settingsList[index].Settings?.ToObject<T>() ?? new T();
}
}
protected void SetActionSettings<T>(IMassiveKnobActionContext context, IMassiveKnobAction action, int index, T actionSettings) where T : class, new()
{
var list = GetActionMappingList(action.ActionType);
if (index >= list.Count)
return;
if (list[index].Context != context)
throw new InvalidOperationException("Caller must be the active action to retrieve the settings");
lock (settingsLock)
{
var settingsList = GetActionSettingsList(action.ActionType);
while (index >= settingsList.Count)
settingsList.Add(null);
if (settingsList[index] == null)
settingsList[index] = new Settings.Settings.ActionSettings
{
ActionId = action.ActionId
};
settingsList[index].Settings = JObject.FromObject(actionSettings);
}
FlushSettings();
}
protected void AnalogChanged(IMassiveKnobDeviceContext context, int analogInputIndex, byte value)
{
if (context != activeDeviceContext)
return;
var mapping = GetActionMappingList(MassiveKnobActionType.InputAnalog);
if (mapping == null || analogInputIndex >= mapping.Count)
return;
if (mapping[analogInputIndex].ActionInfo.Instance is IMassiveKnobAnalogAction analogAction)
analogAction.AnalogChanged(value);
}
protected void DigitalChanged(IMassiveKnobDeviceContext context, int digitalInputIndex, bool on)
{
if (context != activeDeviceContext)
return;
var mapping = GetActionMappingList(MassiveKnobActionType.InputAnalog);
if (mapping == null || digitalInputIndex >= mapping.Count)
return;
if (mapping[digitalInputIndex].ActionInfo.Instance is IMassiveKnobDigitalAction digitalAction)
digitalAction.DigitalChanged(on);
}
private List<ActionMapping> GetActionMappingList(MassiveKnobActionType actionType)
{
switch (actionType)
{
case MassiveKnobActionType.InputAnalog:
return analogInputs;
case MassiveKnobActionType.InputDigital:
return digitalInputs;
case MassiveKnobActionType.OutputAnalog:
return analogOutputs;
case MassiveKnobActionType.OutputDigital:
return digitalOutputs;
default:
throw new ArgumentOutOfRangeException(nameof(actionType), actionType, null);
}
}
private List<Settings.Settings.ActionSettings> GetActionSettingsList(MassiveKnobActionType actionType)
{
switch (actionType)
{
case MassiveKnobActionType.InputAnalog:
return settings.AnalogInput;
case MassiveKnobActionType.InputDigital:
return settings.DigitalInput;
case MassiveKnobActionType.OutputAnalog:
return settings.AnalogOutput;
case MassiveKnobActionType.OutputDigital:
return settings.DigitalOutput;
default:
throw new ArgumentOutOfRangeException(nameof(actionType), actionType, null);
}
}
private void FlushSettings()
{
Settings.Settings settingsSnapshot;
lock (settingsLock)
{
settingsSnapshot = settings.Clone();
}
flushSettingsQueue.Enqueue(async () =>
{
await SettingsJsonSerializer.Serialize(settingsSnapshot);
});
}
protected void UpdateActiveDeviceSpecs(IMassiveKnobDeviceContext context, DeviceSpecs specs)
{
if (context != activeDeviceContext)
return;
var delayedInitializeActions = new List<Action>();
void DelayedInitialize(IMassiveKnobActionInstance instance, IMassiveKnobActionContext instanceContext)
{
delayedInitializeActions.Add(() =>
{
instance.Initialize(instanceContext);
});
}
lock (settingsLock)
{
UpdateMapping(analogInputs, specs.AnalogInputCount, settings.AnalogInput, DelayedInitialize);
UpdateMapping(digitalInputs, specs.DigitalInputCount, settings.DigitalInput, DelayedInitialize);
UpdateMapping(analogOutputs, specs.AnalogOutputCount, settings.AnalogOutput, DelayedInitialize);
UpdateMapping(digitalOutputs, specs.DigitalOutputCount, settings.DigitalOutput, DelayedInitialize);
}
foreach (var delayedInitializeAction in delayedInitializeActions)
delayedInitializeAction();
ActiveDevice = new MassiveKnobDeviceInfo(
ActiveDevice.Info,
ActiveDevice.Instance,
specs);
}
private void UpdateMapping(List<ActionMapping> mapping, int newCount, List<Settings.Settings.ActionSettings> actionSettings, Action<IMassiveKnobActionInstance, IMassiveKnobActionContext> initializeOutsideLock)
{
if (mapping.Count > newCount)
{
for (var actionIndex = newCount; actionIndex < mapping.Count; actionIndex++)
mapping[actionIndex]?.ActionInfo.Instance?.Dispose();
mapping.RemoveRange(newCount, mapping.Count - newCount);
}
if (actionSettings.Count > newCount)
actionSettings.RemoveRange(newCount, actionSettings.Count - newCount);
if (mapping.Count >= newCount) return;
{
var allActions = pluginManager.GetActionPlugins().SelectMany(ap => ap.Actions).ToArray();
for (var actionIndex = mapping.Count; actionIndex < newCount; actionIndex++)
{
if (actionIndex < actionSettings.Count && actionSettings[actionIndex] != null)
{
var action = allActions.FirstOrDefault(d => d.ActionId == actionSettings[actionIndex].ActionId);
mapping.Add(CreateActionMapping(action, actionIndex, initializeOutsideLock));
}
else
mapping.Add(null);
}
}
}
private ActionMapping CreateActionMapping(IMassiveKnobAction action, int index, Action<IMassiveKnobActionInstance, IMassiveKnobActionContext> initialize)
{
if (action == null)
return null;
var instance = action.Create();
var context = new ActionContext(this, action, index);
var mapping = new ActionMapping(new MassiveKnobActionInfo(action, instance), context);
initialize(instance, context);
return mapping;
}
private class ActionMapping
{
public MassiveKnobActionInfo ActionInfo { get; }
public IMassiveKnobActionContext Context { get; }
public ActionMapping(MassiveKnobActionInfo actionInfo, IMassiveKnobActionContext context)
{
ActionInfo = actionInfo;
Context = context;
}
}
private class DeviceContext : IMassiveKnobDeviceContext
{
private readonly MassiveKnobOrchestrator owner;
private readonly IMassiveKnobDevice device;
public DeviceContext(MassiveKnobOrchestrator owner, IMassiveKnobDevice device)
{
this.owner = owner;
this.device = device;
}
public T GetSettings<T>() where T : class, new()
{
// TODO
return default;
return owner.GetDeviceSettings<T>(this);
}
public void SetSettings<T>(T settings) where T : class, new()
{
// TODO
owner.SetDeviceSettings(this, device, settings);
}
public void Connecting()
{
// TODO update status ?
}
public void Connected(DeviceSpecs specs)
{
// TODO update status ?
owner.UpdateActiveDeviceSpecs(this, specs);
}
public void Disconnected()
{
// TODO update status ?
}
public void AnalogChanged(int analogInputIndex, byte value)
{
owner.AnalogChanged(this, analogInputIndex, value);
}
public void DigitalChanged(int digitalInputIndex, bool on)
{
owner.DigitalChanged(this, digitalInputIndex, on);
}
}
private class ActionContext : IMassiveKnobActionContext
{
private readonly MassiveKnobOrchestrator owner;
private readonly IMassiveKnobAction action;
private readonly int index;
public ActionContext(MassiveKnobOrchestrator owner, IMassiveKnobAction action, int index)
{
this.owner = owner;
this.action = action;
this.index = index;
}
public T GetSettings<T>() where T : class, new()
{
return owner.GetActionSettings<T>(this, action, index);
}
public void SetSettings<T>(T settings) where T : class, new()
{
owner.SetActionSettings(this, action, index, settings);
}
public void SetDigitalOutput(bool on)
{
throw new NotImplementedException();
}
public void SetAnalogOutput(byte value)
{
throw new NotImplementedException();
}
}
}

View File

@ -7,21 +7,46 @@ using MassiveKnob.Plugin;
namespace MassiveKnob.Model
{
public class MassiveKnobPluginIdConflictException : Exception
{
public Guid ConflictingId { get; }
public string FirstAssemblyFilename { get; }
public string ConflictingAssemblyFilename { get; }
public MassiveKnobPluginIdConflictException(
Guid conflictingId,
string firstAssemblyFilename,
string conflictingAssemblyFilename)
: base($"Conflicting ID {conflictingId} was already registered by {firstAssemblyFilename}.")
{
ConflictingId = conflictingId;
FirstAssemblyFilename = firstAssemblyFilename;
ConflictingAssemblyFilename = conflictingAssemblyFilename;
}
}
public class PluginManager : IPluginManager
{
private readonly List<IMassiveKnobPlugin> plugins = new List<IMassiveKnobPlugin>();
public IEnumerable<IMassiveKnobPlugin> Plugins => plugins;
public IEnumerable<IMassiveKnobDevicePlugin> GetDevicePlugins()
{
return plugins.Where(p => p is IMassiveKnobDevicePlugin).Cast<IMassiveKnobDevicePlugin>();
}
public void Load()
public IEnumerable<IMassiveKnobActionPlugin> GetActionPlugins()
{
return plugins.Where(p => p is IMassiveKnobActionPlugin).Cast<IMassiveKnobActionPlugin>();
}
public void Load(Action<Exception, string> onException)
{
var registeredIds = new RegisteredIds();
var codeBase = Assembly.GetEntryAssembly()?.CodeBase;
if (!string.IsNullOrEmpty(codeBase))
{
@ -29,17 +54,17 @@ namespace MassiveKnob.Model
if (!string.IsNullOrEmpty(localPath))
{
var applicationPluginPath = Path.Combine(localPath, @"Plugins");
LoadPlugins(applicationPluginPath);
LoadPlugins(applicationPluginPath, registeredIds, onException);
}
}
var localPluginPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"MassiveKnob", @"Plugins");
LoadPlugins(localPluginPath);
LoadPlugins(localPluginPath, registeredIds, onException);
}
private void LoadPlugins(string path)
private void LoadPlugins(string path, RegisteredIds registeredIds, Action<Exception, string> onException)
{
if (!Directory.Exists(path))
return;
@ -51,19 +76,17 @@ namespace MassiveKnob.Model
try
{
var pluginAssembly = Assembly.LoadFrom(filename);
RegisterPlugins(pluginAssembly);
RegisterPlugins(filename, pluginAssembly, registeredIds);
}
catch (Exception e)
{
// TODO report error
// Console.WriteLine(e);
throw;
onException(e, filename);
}
}
}
private void RegisterPlugins(Assembly assembly)
private void RegisterPlugins(string filename, Assembly assembly, RegisteredIds registeredIds)
{
var pluginTypes = assembly.GetTypes().Where(t => t.GetCustomAttribute<MassiveKnobPluginAttribute>() != null);
foreach (var pluginType in pluginTypes)
@ -72,8 +95,56 @@ namespace MassiveKnob.Model
if (!(pluginInstance is IMassiveKnobPlugin))
throw new InvalidCastException($"Type {pluginType.FullName} claims to be a MassiveKnobPlugin but does not implement IMassiveKnobPlugin");
ValidateRegistration(filename, (IMassiveKnobPlugin)pluginInstance, registeredIds);
plugins.Add((IMassiveKnobPlugin)pluginInstance);
}
}
private static void ValidateRegistration(string filename, IMassiveKnobPlugin plugin, RegisteredIds registeredIds)
{
// Make sure all GUIDs are actually unique and someone has not copy/pasted a plugin without
// modifying the values. This way we can safely make that assumption in other code.
if (registeredIds.PluginById.TryGetValue(plugin.PluginId, out var conflictingPluginFilename))
throw new MassiveKnobPluginIdConflictException(plugin.PluginId, conflictingPluginFilename, filename);
registeredIds.PluginById.Add(plugin.PluginId, filename);
// ReSharper disable once ConvertIfStatementToSwitchStatement - no, a plugin can implement both interfaces
if (plugin is IMassiveKnobDevicePlugin devicePlugin)
{
foreach (var device in devicePlugin.Devices)
{
if (registeredIds.DeviceById.TryGetValue(device.DeviceId, out var conflictingDeviceFilename))
throw new MassiveKnobPluginIdConflictException(device.DeviceId, conflictingDeviceFilename, filename);
registeredIds.DeviceById.Add(device.DeviceId, filename);
}
}
// ReSharper disable once InvertIf
if (plugin is IMassiveKnobActionPlugin actionPlugin)
{
foreach (var action in actionPlugin.Actions)
{
if (registeredIds.ActionById.TryGetValue(action.ActionId, out var conflictingActionFilename))
throw new MassiveKnobPluginIdConflictException(action.ActionId, conflictingActionFilename, filename);
registeredIds.ActionById.Add(action.ActionId, filename);
// TODO check ActionType vs. implemented interfaces
}
}
}
private class RegisteredIds
{
public readonly Dictionary<Guid, string> PluginById = new Dictionary<Guid, string>();
public readonly Dictionary<Guid, string> DeviceById = new Dictionary<Guid, string>();
public readonly Dictionary<Guid, string> ActionById = new Dictionary<Guid, string>();
}
}
}

View File

@ -1,7 +1,7 @@
using System;
using System.Threading.Tasks;
using System.Text;
using System.Windows;
using MassiveKnob.Model;
using MassiveKnob.Settings;
using MassiveKnob.View;
using MassiveKnob.ViewModel;
using SimpleInjector;
@ -14,33 +14,42 @@ namespace MassiveKnob
/// The main entry point for the application.
/// </summary>
[STAThread]
public static void Main()
public static int Main()
{
MainAsync().GetAwaiter().GetResult();
}
var pluginManager = new PluginManager();
var messages = new StringBuilder();
pluginManager.Load((exception, filename) =>
{
messages.AppendLine($"{filename}: {exception.Message}");
});
if (messages.Length > 0)
{
MessageBox.Show($"Error while loading plugins:\r\n\r\n{messages}", "Massive Knob", MessageBoxButton.OK, MessageBoxImage.Error);
return 1;
}
var orchestrator = new MassiveKnobOrchestrator(pluginManager);
orchestrator.Load();
private static async Task MainAsync()
{
var container = new Container();
container.Options.EnableAutoVerification = false;
container.RegisterSingleton<IMassiveKnobOrchestrator, MassiveKnobOrchestrator>();
container.RegisterInstance<IPluginManager>(pluginManager);
container.RegisterInstance<IMassiveKnobOrchestrator>(orchestrator);
container.Register<App>();
container.Register<SettingsWindow>();
container.Register<SettingsViewModel>();
var settings = await SettingsJsonSerializer.Deserialize();
container.RegisterInstance(settings);
var pluginManager = new PluginManager();
pluginManager.Load();
container.RegisterInstance<IPluginManager>(pluginManager);
var app = container.GetInstance<App>();
app.Run();
orchestrator.Dispose();
return 0;
}
}
}

View File

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Newtonsoft.Json.Linq;
namespace MassiveKnob.Settings
@ -7,32 +8,67 @@ namespace MassiveKnob.Settings
public class Settings
{
public DeviceSettings Device { get; set; }
public List<ActionSettings> Actions { get; set; }
public List<ActionSettings> AnalogInput { get; set; }
public List<ActionSettings> DigitalInput { get; set; }
public List<ActionSettings> AnalogOutput { get; set; }
public List<ActionSettings> DigitalOutput { get; set; }
public static Settings Default()
public void Verify()
{
if (AnalogInput == null) AnalogInput = new List<ActionSettings>();
if (DigitalInput == null) DigitalInput = new List<ActionSettings>();
if (AnalogOutput == null) AnalogOutput = new List<ActionSettings>();
if (DigitalOutput == null) DigitalOutput = new List<ActionSettings>();
}
public Settings Clone()
{
return new Settings
{
Device = null,
Actions = new List<ActionSettings>()
Device = Device?.Clone(),
AnalogInput = AnalogInput.Select(a => a?.Clone()).ToList(),
DigitalInput = DigitalInput.Select(a => a?.Clone()).ToList(),
AnalogOutput = AnalogOutput.Select(a => a?.Clone()).ToList(),
DigitalOutput = DigitalOutput.Select(a => a?.Clone()).ToList()
};
}
public class DeviceSettings
{
public Guid? PluginId { get; set; }
public Guid? DeviceId { get; set; }
public JObject Settings { get; set; }
public DeviceSettings Clone()
{
return new DeviceSettings
{
DeviceId = DeviceId,
// This is safe, as the JObject itself is never manipulated, only replaced
Settings = Settings
};
}
}
public class ActionSettings
{
public Guid PluginId { get; set; }
public Guid ActionId { get; set; }
public JObject Settings { get; set; }
public ActionSettings Clone()
{
return new ActionSettings
{
ActionId = ActionId,
// This is safe, as the JObject itself is never manipulated, only replaced
Settings = Settings
};
}
}
}
}

View File

@ -1,13 +1,25 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace MassiveKnob.Settings
{
public static class SettingsJsonSerializer
{
private static readonly JsonSerializerSettings DefaultSettings = new JsonSerializerSettings
{
Formatting = Formatting.Indented,
Converters = new List<JsonConverter>
{
new StringEnumConverter()
}
};
public static string GetDefaultFilename()
{
var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"MassiveKnob");
@ -24,7 +36,7 @@ namespace MassiveKnob.Settings
public static async Task Serialize(Settings settings, string filename)
{
var json = JsonConvert.SerializeObject(settings);
var json = JsonConvert.SerializeObject(settings, DefaultSettings);
using (var stream = new FileStream(filename, FileMode.Create, FileAccess.Write, FileShare.Read, 4096, true))
using (var streamWriter = new StreamWriter(stream, Encoding.UTF8))
@ -35,28 +47,34 @@ namespace MassiveKnob.Settings
}
public static Task<Settings> Deserialize()
public static Settings Deserialize()
{
return Deserialize(GetDefaultFilename());
}
public static async Task<Settings> Deserialize(string filename)
public static Settings Deserialize(string filename)
{
if (!File.Exists(filename))
return Settings.Default();
Settings settings = null;
string json;
using (var stream = new FileStream(filename, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, true))
using (var streamReader = new StreamReader(stream, Encoding.UTF8))
if (File.Exists(filename))
{
json = await streamReader.ReadToEndAsync();
string json;
using (var stream = new FileStream(filename, FileMode.Open, FileAccess.Read, FileShare.ReadWrite, 4096, true))
using (var streamReader = new StreamReader(stream, Encoding.UTF8))
{
json = streamReader.ReadToEnd();
}
if (!string.IsNullOrEmpty(json))
settings = JsonConvert.DeserializeObject<Settings>(json, DefaultSettings);
}
if (string.IsNullOrEmpty(json))
return Settings.Default();
if (settings == null)
settings = new Settings();
return JsonConvert.DeserializeObject<Settings>(json);
settings.Verify();
return settings;
}
}
}

View File

@ -61,83 +61,11 @@ namespace MassiveKnob {
}
/// <summary>
/// Looks up a localized string similar to {0}.
/// Looks up a localized string similar to Not configured.
/// </summary>
internal static string DeviceDisplayNameActive {
internal static string ActionNotConfigured {
get {
return ResourceManager.GetString("DeviceDisplayNameActive", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} (Disabled).
/// </summary>
internal static string DeviceDisplayNameDisabled {
get {
return ResourceManager.GetString("DeviceDisplayNameDisabled", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} (Inactive).
/// </summary>
internal static string DeviceDisplayNameInactive {
get {
return ResourceManager.GetString("DeviceDisplayNameInactive", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} (Not present).
/// </summary>
internal static string DeviceDisplayNameNotPresent {
get {
return ResourceManager.GetString("DeviceDisplayNameNotPresent", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} (Unplugged).
/// </summary>
internal static string DeviceDisplayNameUnplugged {
get {
return ResourceManager.GetString("DeviceDisplayNameUnplugged", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Knob {0}.
/// </summary>
internal static string KnobIndex {
get {
return ResourceManager.GetString("KnobIndex", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Connected.
/// </summary>
internal static string StatusConnected {
get {
return ResourceManager.GetString("StatusConnected", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Connecting....
/// </summary>
internal static string StatusConnecting {
get {
return ResourceManager.GetString("StatusConnecting", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Not connected.
/// </summary>
internal static string StatusNotConnected {
get {
return ResourceManager.GetString("StatusNotConnected", resourceCulture);
return ResourceManager.GetString("ActionNotConfigured", resourceCulture);
}
}
}

View File

@ -117,31 +117,7 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="StatusConnected" xml:space="preserve">
<value>Connected</value>
</data>
<data name="StatusConnecting" xml:space="preserve">
<value>Connecting...</value>
</data>
<data name="StatusNotConnected" xml:space="preserve">
<value>Not connected</value>
</data>
<data name="KnobIndex" xml:space="preserve">
<value>Knob {0}</value>
</data>
<data name="DeviceDisplayNameActive" xml:space="preserve">
<value>{0}</value>
</data>
<data name="DeviceDisplayNameDisabled" xml:space="preserve">
<value>{0} (Disabled)</value>
</data>
<data name="DeviceDisplayNameInactive" xml:space="preserve">
<value>{0} (Inactive)</value>
</data>
<data name="DeviceDisplayNameNotPresent" xml:space="preserve">
<value>{0} (Not present)</value>
</data>
<data name="DeviceDisplayNameUnplugged" xml:space="preserve">
<value>{0} (Unplugged)</value>
<data name="ActionNotConfigured" xml:space="preserve">
<value>Not configured</value>
</data>
</root>

View File

@ -1,5 +1,9 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Style TargetType="Window" x:Key="DefaultWindow">
<Setter Property="Background" Value="#f0f0f0" />
</Style>
<Style TargetType="MenuItem" x:Key="DefaultMenuItem">
<Setter Property="FontWeight" Value="Bold" />
</Style>
@ -12,10 +16,21 @@
<Setter Property="Padding" Value="5" />
</Style>
<Style TargetType="TextBlock" x:Key="SubHeader">
<Setter Property="Background" Value="LightSlateGray" />
<Setter Property="Foreground" Value="White" />
<Setter Property="FontSize" Value="14" />
<Setter Property="Padding" Value="5" />
</Style>
<Style TargetType="StackPanel" x:Key="Content">
<Setter Property="Margin" Value="8" />
</Style>
<Style TargetType="ContentControl" x:Key="SettingsControl">
<Setter Property="Margin" Value="0,8,0,0"></Setter>
</Style>
<Style TargetType="TextBlock" x:Key="ComboBoxDescription">
<Setter Property="Foreground" Value="{x:Static SystemColors.GrayTextBrush}" />
</Style>

View File

@ -0,0 +1,44 @@
<UserControl x:Class="MassiveKnob.View.InputOutputView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:helpers="clr-namespace:MassiveKnob.Helpers"
xmlns:viewModel="clr-namespace:MassiveKnob.ViewModel"
mc:Ignorable="d"
d:DesignHeight="100" d:DesignWidth="800"
d:DataContext="{d:DesignInstance viewModel:InputOutputViewModel}">
<UserControl.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceDictionary Source="../Style.xaml"></ResourceDictionary>
</ResourceDictionary.MergedDictionaries>
<DataTemplate x:Key="ActionDropdownItem">
<StackPanel Orientation="Vertical" d:DataContext="{d:DesignInstance Type=viewModel:ActionViewModel}">
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding Description}" Style="{StaticResource ComboBoxDescription}" Visibility="{Binding DescriptionVisibility}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="ActionSelectedItem">
<TextBlock Text="{Binding Name}" d:DataContext="{d:DesignInstance Type=viewModel:ActionViewModel}" />
</DataTemplate>
</ResourceDictionary>
</UserControl.Resources>
<StackPanel Orientation="Vertical">
<TextBlock Text="{Binding DisplayName}" Style="{StaticResource SubHeader}"></TextBlock>
<StackPanel Orientation="Vertical" Style="{StaticResource Content}">
<ComboBox
ItemsSource="{Binding Actions}"
SelectedItem="{Binding SelectedAction}"
IsSynchronizedWithCurrentItem="False"
ItemTemplateSelector="{helpers:ComboBoxTemplateSelector
SelectedItemTemplate={StaticResource ActionSelectedItem},
DropdownItemsTemplate={StaticResource ActionDropdownItem}}" />
<ContentControl Focusable="False" Content="{Binding ActionSettingsControl}" Style="{StaticResource SettingsControl}" />
</StackPanel>
</StackPanel>
</UserControl>

View File

@ -0,0 +1,13 @@
namespace MassiveKnob.View
{
/// <summary>
/// Interaction logic for InputOutputView.xaml
/// </summary>
public partial class InputOutputView
{
public InputOutputView()
{
InitializeComponent();
}
}
}

View File

@ -4,9 +4,13 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:helpers="clr-namespace:MassiveKnob.Helpers"
xmlns:viewModel="clr-namespace:MassiveKnob.ViewModel"
xmlns:view="clr-namespace:MassiveKnob.View"
mc:Ignorable="d"
Title="Massive Knob - Settings" Height="555" Width="704.231"
WindowStartupLocation="CenterScreen">
WindowStartupLocation="CenterScreen"
Style="{StaticResource DefaultWindow}"
d:DataContext="{d:DesignInstance Type=viewModel:SettingsViewModel}">
<Window.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
@ -14,32 +18,103 @@
</ResourceDictionary.MergedDictionaries>
<DataTemplate x:Key="DeviceDropdownItem">
<StackPanel Orientation="Vertical">
<StackPanel Orientation="Vertical" d:DataContext="{d:DesignInstance Type=viewModel:DeviceViewModel}">
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding Description}" Style="{StaticResource ComboBoxDescription}" />
<TextBlock Text="{Binding Description}" Style="{StaticResource ComboBoxDescription}" Visibility="{Binding DescriptionVisibility}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="DeviceSelectedItem">
<TextBlock Text="{Binding Name}" />
<TextBlock Text="{Binding Name}" d:DataContext="{d:DesignInstance Type=viewModel:DeviceViewModel}" />
</DataTemplate>
</ResourceDictionary>
</Window.Resources>
<ScrollViewer>
<StackPanel Orientation="Vertical">
<!--
Device
-->
<TextBlock Style="{StaticResource Header}">Device</TextBlock>
<StackPanel Orientation="Vertical" Style="{StaticResource Content}">
<ComboBox SelectedItem="{Binding SelectedDevice}" ItemsSource="{Binding Devices}" ItemTemplateSelector="{helpers:ComboBoxTemplateSelector
SelectedItemTemplate={StaticResource DeviceSelectedItem},
DropdownItemsTemplate={StaticResource DeviceDropdownItem}}" />
<ComboBox
ItemsSource="{Binding Devices}"
SelectedItem="{Binding SelectedDevice}"
IsSynchronizedWithCurrentItem="False"
ItemTemplateSelector="{helpers:ComboBoxTemplateSelector
SelectedItemTemplate={StaticResource DeviceSelectedItem},
DropdownItemsTemplate={StaticResource DeviceDropdownItem}}" />
<ContentControl Focusable="False" Content="{Binding SettingsControl}" />
<ContentControl Focusable="False" Content="{Binding SettingsControl}" Style="{StaticResource SettingsControl}" />
</StackPanel>
<TextBlock Style="{StaticResource Header}">Controls</TextBlock>
<!--
Analog inputs
-->
<TextBlock Style="{StaticResource Header}" Visibility="{Binding AnalogInputVisibility}">Analog inputs</TextBlock>
<ItemsControl ItemsSource="{Binding AnalogInputs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<view:InputOutputView />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
Digital inputs
-->
<TextBlock Style="{StaticResource Header}" Visibility="{Binding DigitalInputVisibility}">Digital inputs</TextBlock>
<ItemsControl ItemsSource="{Binding DigitalInputs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<view:InputOutputView />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
Analog outputs
-->
<TextBlock Style="{StaticResource Header}" Visibility="{Binding AnalogOutputVisibility}">Analog outputs</TextBlock>
<ItemsControl ItemsSource="{Binding AnalogOutputs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<view:InputOutputView />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
Digital outputs
-->
<TextBlock Style="{StaticResource Header}" Visibility="{Binding DigitalOutputVisibility}">Digital outputs</TextBlock>
<ItemsControl ItemsSource="{Binding DigitalOutputs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<view:InputOutputView />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
</Window>

View File

@ -0,0 +1,29 @@
using System.Windows;
using MassiveKnob.Plugin;
namespace MassiveKnob.ViewModel
{
public class ActionViewModel
{
// ReSharper disable UnusedMember.Global - used by WPF Binding
public string Name => RepresentsNull ? Strings.ActionNotConfigured : Action.Name;
public string Description => RepresentsNull ? null : Action.Description;
public Visibility DescriptionVisibility => string.IsNullOrEmpty(Description) ? Visibility.Collapsed : Visibility.Visible;
// ReSharper restore UnusedMember.Global
public IMassiveKnobActionPlugin Plugin { get; }
public IMassiveKnobAction Action { get; }
public bool RepresentsNull => Action == null;
public ActionViewModel(IMassiveKnobActionPlugin plugin, IMassiveKnobAction action)
{
Plugin = plugin;
Action = action;
}
}
}

View File

@ -0,0 +1,25 @@
using System.Windows;
using MassiveKnob.Plugin;
namespace MassiveKnob.ViewModel
{
public class DeviceViewModel
{
// ReSharper disable UnusedMember.Global - used by WPF Binding
public string Name => Device.Name;
public string Description => Device.Description;
public Visibility DescriptionVisibility => string.IsNullOrEmpty(Description) ? Visibility.Collapsed : Visibility.Visible;
// ReSharper restore UnusedMember.Global
public IMassiveKnobDevicePlugin Plugin { get; }
public IMassiveKnobDevice Device { get; }
public DeviceViewModel(IMassiveKnobDevicePlugin plugin, IMassiveKnobDevice device)
{
Plugin = plugin;
Device = device;
}
}
}

View File

@ -0,0 +1,86 @@
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Windows.Controls;
using MassiveKnob.Model;
using MassiveKnob.Plugin;
namespace MassiveKnob.ViewModel
{
public class InputOutputViewModel : INotifyPropertyChanged
{
private readonly SettingsViewModel settingsViewModel;
private readonly IMassiveKnobOrchestrator orchestrator;
private readonly MassiveKnobActionType actionType;
private readonly int index;
private ActionViewModel selectedAction;
private UserControl actionSettingsControl;
// ReSharper disable UnusedMember.Global - used by WPF Binding
public string DisplayName => actionType == MassiveKnobActionType.OutputAnalog || actionType == MassiveKnobActionType.OutputDigital
? $"Output #{index + 1}"
: $"Input #{index + 1}";
public IList<ActionViewModel> Actions => settingsViewModel.Actions;
public ActionViewModel SelectedAction
{
get => selectedAction;
set
{
if (value == selectedAction)
return;
selectedAction = value == null || value.RepresentsNull ? null : value;
var actionInfo = orchestrator.SetAction(actionType, index, selectedAction?.Action);
OnPropertyChanged();
ActionSettingsControl = actionInfo?.Instance.CreateSettingsControl();
}
}
public UserControl ActionSettingsControl
{
get => actionSettingsControl;
set
{
if (value == actionSettingsControl)
return;
actionSettingsControl = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
public InputOutputViewModel(SettingsViewModel settingsViewModel, IMassiveKnobOrchestrator orchestrator, MassiveKnobActionType actionType, int index)
{
this.settingsViewModel = settingsViewModel;
this.orchestrator = orchestrator;
this.actionType = actionType;
this.index = index;
var actionInfo = orchestrator.GetAction(actionType, index);
selectedAction = actionInfo != null
? Actions.SingleOrDefault(a => !a.RepresentsNull && a.Action.ActionId == actionInfo.Info.ActionId)
: Actions.Single(a => a.RepresentsNull);
actionSettingsControl = actionInfo?.Instance.CreateSettingsControl();
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}

View File

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Windows;
using System.Windows.Controls;
using MassiveKnob.Model;
using MassiveKnob.Plugin;
@ -11,47 +12,42 @@ namespace MassiveKnob.ViewModel
{
public class SettingsViewModel : INotifyPropertyChanged
{
private readonly Settings.Settings settings;
private readonly IMassiveKnobOrchestrator orchestrator;
private DeviceViewModel selectedDevice;
private UserControl settingsControl;
private DeviceSpecs? specs;
private IEnumerable<InputOutputViewModel> analogInputs;
private IEnumerable<InputOutputViewModel> digitalInputs;
private IEnumerable<InputOutputViewModel> analogOutputs;
private IEnumerable<InputOutputViewModel> digitalOutputs;
// ReSharper disable UnusedMember.Global - used by WPF Binding
public IList<DeviceViewModel> Devices { get; }
public IList<ActionViewModel> Actions { get; }
public IEnumerable<DeviceViewModel> Devices { get; }
public DeviceViewModel SelectedDevice
{
get => selectedDevice;
set
{
if (value == selectedDevice)
return;
selectedDevice = value;
var deviceInstance = orchestrator.SetActiveDevice(value?.Device);
if (value == null)
settings.Device = null;
else
{
settings.Device = new Settings.Settings.DeviceSettings
{
PluginId = value.Plugin.PluginId,
DeviceId = value.Device.DeviceId,
Settings = null
};
}
var deviceInfo = orchestrator.SetActiveDevice(value?.Device);
OnPropertyChanged();
SettingsControl = deviceInstance?.CreateSettingsControl();
SettingsControl = deviceInfo?.Instance.CreateSettingsControl();
}
}
public UserControl SettingsControl
{
get => settingsControl;
set
{
if (value == settingsControl)
@ -62,20 +58,113 @@ namespace MassiveKnob.ViewModel
}
}
public SettingsViewModel(IPluginManager pluginManager, Settings.Settings settings, IMassiveKnobOrchestrator orchestrator)
public DeviceSpecs? Specs
{
get => specs;
set
{
specs = value;
OnPropertyChanged();
OnOtherPropertyChanged("AnalogInputVisibility");
OnOtherPropertyChanged("DigitalInputVisibility");
AnalogInputs = Enumerable
.Range(0, specs?.AnalogInputCount ?? 0)
.Select(i => new InputOutputViewModel(this, orchestrator, MassiveKnobActionType.InputAnalog, i));
DigitalInputs = Enumerable
.Range(0, specs?.DigitalInputCount ?? 0)
.Select(i => new InputOutputViewModel(this, orchestrator, MassiveKnobActionType.InputDigital, i));
}
}
public Visibility AnalogInputVisibility => specs.HasValue && specs.Value.AnalogInputCount > 0
? Visibility.Visible
: Visibility.Collapsed;
public IEnumerable<InputOutputViewModel> AnalogInputs
{
get => analogInputs;
set
{
analogInputs = value;
OnPropertyChanged();
}
}
public Visibility DigitalInputVisibility => specs.HasValue && specs.Value.DigitalInputCount > 0
? Visibility.Visible
: Visibility.Collapsed;
public IEnumerable<InputOutputViewModel> DigitalInputs
{
get => digitalInputs;
set
{
digitalInputs = value;
OnPropertyChanged();
}
}
public Visibility AnalogOutputVisibility => specs.HasValue && specs.Value.AnalogOutputCount > 0
? Visibility.Visible
: Visibility.Collapsed;
public IEnumerable<InputOutputViewModel> AnalogOutputs
{
get => analogOutputs;
set
{
analogOutputs = value;
OnPropertyChanged();
}
}
public Visibility DigitalOutputVisibility => specs.HasValue && specs.Value.DigitalOutputCount > 0
? Visibility.Visible
: Visibility.Collapsed;
public IEnumerable<InputOutputViewModel> DigitalOutputs
{
get => digitalOutputs;
set
{
digitalOutputs = value;
OnPropertyChanged();
}
}
// ReSharper restore UnusedMember.Global
public SettingsViewModel(IPluginManager pluginManager, IMassiveKnobOrchestrator orchestrator)
{
this.settings = settings;
this.orchestrator = orchestrator;
Devices = pluginManager.GetDevicePlugins().SelectMany(dp => dp.Devices.Select(d => new DeviceViewModel(dp, d)));
orchestrator.ActiveDeviceSubject.Subscribe(info => { Specs = info.Specs; });
if (settings.Device != null)
SelectedDevice = Devices.FirstOrDefault(d =>
d.Plugin.PluginId == settings.Device.PluginId &&
d.Device.DeviceId == settings.Device.DeviceId);
Devices = pluginManager.GetDevicePlugins()
.SelectMany(dp => dp.Devices.Select(d => new DeviceViewModel(dp, d)))
.ToList();
var allActions = new List<ActionViewModel>
{
new ActionViewModel(null, null)
};
allActions.AddRange(
pluginManager.GetActionPlugins()
.SelectMany(ap => ap.Actions.Select(a => new ActionViewModel(ap, a))));
Actions = allActions;
if (orchestrator.ActiveDevice == null)
return;
selectedDevice = Devices.Single(d => d.Device.DeviceId == orchestrator.ActiveDevice.Info.DeviceId);
SettingsControl = orchestrator.ActiveDevice.Instance.CreateSettingsControl();
Specs = orchestrator.ActiveDevice.Specs;
}
@ -86,24 +175,9 @@ namespace MassiveKnob.ViewModel
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public class DeviceViewModel
protected virtual void OnOtherPropertyChanged(string propertyName)
{
// ReSharper disable UnusedMember.Global - used by WPF Binding
public string Name => Device.Name;
public string Description => Device.Description;
// ReSharper restore UnusedMember.Global
public IMassiveKnobDevicePlugin Plugin { get; }
public IMassiveKnobDevice Device { get; }
public DeviceViewModel(IMassiveKnobDevicePlugin plugin, IMassiveKnobDevice device)
{
Plugin = plugin;
Device = device;
}
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
}