diff --git a/Arduino/MassiveKnob/MassiveKnob.ino b/Arduino/MassiveKnob/MassiveKnob.ino index 5169183..ec70437 100644 --- a/Arduino/MassiveKnob/MassiveKnob.ino +++ b/Arduino/MassiveKnob/MassiveKnob.ino @@ -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; + } +} diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/Actions/DeviceVolumeAction.cs b/Windows/MassiveKnob.Plugin.CoreAudio/Actions/DeviceVolumeAction.cs index 93829f5..db31607 100644 --- a/Windows/MassiveKnob.Plugin.CoreAudio/Actions/DeviceVolumeAction.cs +++ b/Windows/MassiveKnob.Plugin.CoreAudio/Actions/DeviceVolumeAction.cs @@ -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,45 +14,62 @@ 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(); + actionContext = context; + settings = context.GetSettings(); + ApplySettings(); } - + public void Dispose() { } - + + 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 - { - - } } } diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/CoreAudioControllerInstance.cs b/Windows/MassiveKnob.Plugin.CoreAudio/CoreAudioControllerInstance.cs new file mode 100644 index 0000000..1026c25 --- /dev/null +++ b/Windows/MassiveKnob.Plugin.CoreAudio/CoreAudioControllerInstance.cs @@ -0,0 +1,16 @@ +using System; +using AudioSwitcher.AudioApi.CoreAudio; + +namespace MassiveKnob.Plugin.CoreAudio +{ + public static class CoreAudioControllerInstance + { + private static readonly Lazy Instance = new Lazy(); + + + public static CoreAudioController Acquire() + { + return Instance.Value; + } + } +} diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/MassiveKnob.Plugin.CoreAudio.csproj b/Windows/MassiveKnob.Plugin.CoreAudio/MassiveKnob.Plugin.CoreAudio.csproj index cb62ecd..123b6c9 100644 --- a/Windows/MassiveKnob.Plugin.CoreAudio/MassiveKnob.Plugin.CoreAudio.csproj +++ b/Windows/MassiveKnob.Plugin.CoreAudio/MassiveKnob.Plugin.CoreAudio.csproj @@ -46,10 +46,20 @@ + - - DeviceVolumeActionSettings.xaml + + + DeviceVolumeActionSettingsView.xaml + + + + + + Strings.resx + True + True @@ -59,13 +69,21 @@ + + 4.0.0-alpha5 + 4.5.4 - - + + ResXFileCodeGenerator + Strings.Designer.cs + + + + Designer MSBuild:Compile diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/MassiveKnobCoreAudioPlugin.cs b/Windows/MassiveKnob.Plugin.CoreAudio/MassiveKnobCoreAudioPlugin.cs index 1e8cc5e..13c738d 100644 --- a/Windows/MassiveKnob.Plugin.CoreAudio/MassiveKnobCoreAudioPlugin.cs +++ b/Windows/MassiveKnob.Plugin.CoreAudio/MassiveKnobCoreAudioPlugin.cs @@ -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(); + }); + } } } diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/Properties/AssemblyInfo.cs b/Windows/MassiveKnob.Plugin.CoreAudio/Properties/AssemblyInfo.cs index b37ec34..7ba6cb2 100644 --- a/Windows/MassiveKnob.Plugin.CoreAudio/Properties/AssemblyInfo.cs +++ b/Windows/MassiveKnob.Plugin.CoreAudio/Properties/AssemblyInfo.cs @@ -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 diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/Settings/BaseDeviceSettings.cs b/Windows/MassiveKnob.Plugin.CoreAudio/Settings/BaseDeviceSettings.cs new file mode 100644 index 0000000..dd8cb66 --- /dev/null +++ b/Windows/MassiveKnob.Plugin.CoreAudio/Settings/BaseDeviceSettings.cs @@ -0,0 +1,9 @@ +using System; + +namespace MassiveKnob.Plugin.CoreAudio.Settings +{ + public class BaseDeviceSettings + { + public Guid? DeviceId { get; set; } + } +} diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/Settings/BaseDeviceSettingsViewModel.cs b/Windows/MassiveKnob.Plugin.CoreAudio/Settings/BaseDeviceSettingsViewModel.cs new file mode 100644 index 0000000..454bd84 --- /dev/null +++ b/Windows/MassiveKnob.Plugin.CoreAudio/Settings/BaseDeviceSettingsViewModel.cs @@ -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 playbackDevices; + private PlaybackDeviceViewModel selectedDevice; + + // ReSharper disable UnusedMember.Global - used by WPF Binding + public IList 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) + }; + } + } +} diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettings.cs b/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettings.cs new file mode 100644 index 0000000..6fa5eee --- /dev/null +++ b/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettings.cs @@ -0,0 +1,7 @@ +namespace MassiveKnob.Plugin.CoreAudio.Settings +{ + public class DeviceVolumeActionSettings : BaseDeviceSettings + { + // TODO OSD + } +} diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettings.xaml b/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettings.xaml deleted file mode 100644 index 86aa8c4..0000000 --- a/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettings.xaml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettings.xaml.cs b/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettings.xaml.cs deleted file mode 100644 index 6c1ed5b..0000000 --- a/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettings.xaml.cs +++ /dev/null @@ -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 -{ - /// - /// Interaction logic for DeviceVolumeActionSettings.xaml - /// - public partial class DeviceVolumeActionSettings : UserControl - { - public DeviceVolumeActionSettings() - { - InitializeComponent(); - } - } -} diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettingsView.xaml b/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettingsView.xaml new file mode 100644 index 0000000..b1485b0 --- /dev/null +++ b/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettingsView.xaml @@ -0,0 +1,14 @@ + + + Playback device + + + diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettingsView.xaml.cs b/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettingsView.xaml.cs new file mode 100644 index 0000000..7ecd71f --- /dev/null +++ b/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettingsView.xaml.cs @@ -0,0 +1,14 @@ +namespace MassiveKnob.Plugin.CoreAudio.Settings +{ + /// + /// Interaction logic for DeviceVolumeActionSettingsView.xaml + /// + public partial class DeviceVolumeActionSettingsView + { + public DeviceVolumeActionSettingsView(DeviceVolumeActionSettingsViewModel viewModel) + { + DataContext = viewModel; + InitializeComponent(); + } + } +} diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettingsViewModel.cs b/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettingsViewModel.cs new file mode 100644 index 0000000..7e46566 --- /dev/null +++ b/Windows/MassiveKnob.Plugin.CoreAudio/Settings/DeviceVolumeActionSettingsViewModel.cs @@ -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) + { + } + } +} diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/Strings.Designer.cs b/Windows/MassiveKnob.Plugin.CoreAudio/Strings.Designer.cs new file mode 100644 index 0000000..746832c --- /dev/null +++ b/Windows/MassiveKnob.Plugin.CoreAudio/Strings.Designer.cs @@ -0,0 +1,108 @@ +//------------------------------------------------------------------------------ +// +// 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. +// +//------------------------------------------------------------------------------ + +namespace MassiveKnob.Plugin.CoreAudio { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // 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() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [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; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to {0}. + /// + internal static string DeviceDisplayNameActive { + get { + return ResourceManager.GetString("DeviceDisplayNameActive", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} (Disabled). + /// + internal static string DeviceDisplayNameDisabled { + get { + return ResourceManager.GetString("DeviceDisplayNameDisabled", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} (Inactive). + /// + internal static string DeviceDisplayNameInactive { + get { + return ResourceManager.GetString("DeviceDisplayNameInactive", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} (Not present). + /// + internal static string DeviceDisplayNameNotPresent { + get { + return ResourceManager.GetString("DeviceDisplayNameNotPresent", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to {0} (Unplugged). + /// + internal static string DeviceDisplayNameUnplugged { + get { + return ResourceManager.GetString("DeviceDisplayNameUnplugged", resourceCulture); + } + } + } +} diff --git a/Windows/MassiveKnob.Plugin.CoreAudio/Strings.resx b/Windows/MassiveKnob.Plugin.CoreAudio/Strings.resx new file mode 100644 index 0000000..5a89d1c --- /dev/null +++ b/Windows/MassiveKnob.Plugin.CoreAudio/Strings.resx @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + {0} + + + {0} (Disabled) + + + {0} (Inactive) + + + {0} (Not present) + + + {0} (Unplugged) + + \ No newline at end of file diff --git a/Windows/MassiveKnob.Plugin.MockDevice/Devices/MockDevice.cs b/Windows/MassiveKnob.Plugin.MockDevice/Devices/MockDevice.cs index d402c4d..4112c92 100644 --- a/Windows/MassiveKnob.Plugin.MockDevice/Devices/MockDevice.cs +++ b/Windows/MassiveKnob.Plugin.MockDevice/Devices/MockDevice.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using System.Windows.Controls; using MassiveKnob.Plugin.MockDevice.Settings; @@ -9,37 +10,86 @@ namespace MassiveKnob.Plugin.MockDevice.Devices public Guid DeviceId { get; } = new Guid("e1a4977a-abf4-4c75-a17d-fd8d3a8451ff"); 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(); + + 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 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); } - } - - - private class Settings - { - // TODO interval, etc. } } } diff --git a/Windows/MassiveKnob.Plugin.MockDevice/MassiveKnob.Plugin.MockDevice.csproj b/Windows/MassiveKnob.Plugin.MockDevice/MassiveKnob.Plugin.MockDevice.csproj index d9a3c45..8a0f341 100644 --- a/Windows/MassiveKnob.Plugin.MockDevice/MassiveKnob.Plugin.MockDevice.csproj +++ b/Windows/MassiveKnob.Plugin.MockDevice/MassiveKnob.Plugin.MockDevice.csproj @@ -48,9 +48,11 @@ - - MockDeviceSettings.xaml + + MockDeviceSettingsView.xaml + + @@ -60,7 +62,7 @@ - + Designer MSBuild:Compile diff --git a/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettings.cs b/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettings.cs new file mode 100644 index 0000000..5990d4b --- /dev/null +++ b/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettings.cs @@ -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; + } +} diff --git a/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettings.xaml b/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettings.xaml deleted file mode 100644 index 15b1798..0000000 --- a/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettings.xaml +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - Number of knobs - - - Randomly change the volume - - diff --git a/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettings.xaml.cs b/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettings.xaml.cs deleted file mode 100644 index 843c196..0000000 --- a/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettings.xaml.cs +++ /dev/null @@ -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 -{ - /// - /// Interaction logic for MockDeviceSettings.xaml - /// - public partial class MockDeviceSettings : UserControl - { - public MockDeviceSettings() - { - InitializeComponent(); - } - } -} diff --git a/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettingsView.xaml b/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettingsView.xaml new file mode 100644 index 0000000..6fcf56b --- /dev/null +++ b/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettingsView.xaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + Number of analog inputs + + + Number of digital inputs + + + Random change interval (seconds) + + + diff --git a/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettingsView.xaml.cs b/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettingsView.xaml.cs new file mode 100644 index 0000000..503729c --- /dev/null +++ b/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettingsView.xaml.cs @@ -0,0 +1,15 @@ +namespace MassiveKnob.Plugin.MockDevice.Settings +{ + /// + /// Interaction logic for MockDeviceSettingsView.xaml + /// + public partial class MockDeviceSettingsView + { + public MockDeviceSettingsView(MockDeviceSettingsViewModel settingsViewModel) + { + DataContext = settingsViewModel; + + InitializeComponent(); + } + } +} diff --git a/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettingsViewModel.cs b/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettingsViewModel.cs new file mode 100644 index 0000000..54040ad --- /dev/null +++ b/Windows/MassiveKnob.Plugin.MockDevice/Settings/MockDeviceSettingsViewModel.cs @@ -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)); + } + } +} diff --git a/Windows/MassiveKnob.Plugin.SerialDevice/Devices/SerialDevice.cs b/Windows/MassiveKnob.Plugin.SerialDevice/Devices/SerialDevice.cs new file mode 100644 index 0000000..b155974 --- /dev/null +++ b/Windows/MassiveKnob.Plugin.SerialDevice/Devices/SerialDevice.cs @@ -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(); + + 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); + } + } + } +} diff --git a/Windows/MassiveKnob.Plugin.SerialDevice/MassiveKnob.Plugin.SerialDevice.csproj b/Windows/MassiveKnob.Plugin.SerialDevice/MassiveKnob.Plugin.SerialDevice.csproj new file mode 100644 index 0000000..f85d09a --- /dev/null +++ b/Windows/MassiveKnob.Plugin.SerialDevice/MassiveKnob.Plugin.SerialDevice.csproj @@ -0,0 +1,71 @@ + + + + + Debug + AnyCPU + {FC0D22D8-5F1B-4D85-8269-FA4837CDE3A2} + Library + Properties + MassiveKnob.Plugin.SerialDevice + MassiveKnob.Plugin.SerialDevice + v4.7.2 + 512 + true + + + true + full + false + $(localappdata)\MassiveKnob\Plugins\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + + + + + + + + SerialDeviceSettingsView.xaml + + + + + + + + {A1298BE4-1D23-416C-8C56-FC9264487A95} + MassiveKnob.Plugin + + + + + Designer + MSBuild:Compile + + + + \ No newline at end of file diff --git a/Windows/MassiveKnob.Plugin.SerialDevice/MassiveKnobSerialDevicePlugin.cs b/Windows/MassiveKnob.Plugin.SerialDevice/MassiveKnobSerialDevicePlugin.cs new file mode 100644 index 0000000..9e0ccaf --- /dev/null +++ b/Windows/MassiveKnob.Plugin.SerialDevice/MassiveKnobSerialDevicePlugin.cs @@ -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 "; + public string Url { get; } = "https://www.github.com/MvRens/MassiveKnob/"; + + public IEnumerable Devices { get; } = new IMassiveKnobDevice[] + { + new Devices.SerialDevice() + }; + } +} diff --git a/Windows/MassiveKnob.Plugin.SerialDevice/Properties/AssemblyInfo.cs b/Windows/MassiveKnob.Plugin.SerialDevice/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..751b137 --- /dev/null +++ b/Windows/MassiveKnob.Plugin.SerialDevice/Properties/AssemblyInfo.cs @@ -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")] diff --git a/Windows/MassiveKnob.Plugin.SerialDevice/Settings/SerialDeviceSettings.cs b/Windows/MassiveKnob.Plugin.SerialDevice/Settings/SerialDeviceSettings.cs new file mode 100644 index 0000000..3abc590 --- /dev/null +++ b/Windows/MassiveKnob.Plugin.SerialDevice/Settings/SerialDeviceSettings.cs @@ -0,0 +1,8 @@ +namespace MassiveKnob.Plugin.SerialDevice.Settings +{ + public class SerialDeviceSettings + { + public string PortName { get; set; } = null; + public int BaudRate { get; set; } = 115200; + } +} diff --git a/Windows/MassiveKnob.Plugin.SerialDevice/Settings/SerialDeviceSettingsView.xaml b/Windows/MassiveKnob.Plugin.SerialDevice/Settings/SerialDeviceSettingsView.xaml new file mode 100644 index 0000000..12150f1 --- /dev/null +++ b/Windows/MassiveKnob.Plugin.SerialDevice/Settings/SerialDeviceSettingsView.xaml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + Serial port + + + Baud rate + + + diff --git a/Windows/MassiveKnob.Plugin.SerialDevice/Settings/SerialDeviceSettingsView.xaml.cs b/Windows/MassiveKnob.Plugin.SerialDevice/Settings/SerialDeviceSettingsView.xaml.cs new file mode 100644 index 0000000..3e894f0 --- /dev/null +++ b/Windows/MassiveKnob.Plugin.SerialDevice/Settings/SerialDeviceSettingsView.xaml.cs @@ -0,0 +1,14 @@ +namespace MassiveKnob.Plugin.SerialDevice.Settings +{ + /// + /// Interaction logic for SerialDeviceSettingsView.xaml + /// + public partial class SerialDeviceSettingsView + { + public SerialDeviceSettingsView(SerialDeviceSettingsViewModel viewModel) + { + DataContext = viewModel; + InitializeComponent(); + } + } +} diff --git a/Windows/MassiveKnob.Plugin.SerialDevice/Settings/SerialDeviceSettingsViewModel.cs b/Windows/MassiveKnob.Plugin.SerialDevice/Settings/SerialDeviceSettingsViewModel.cs new file mode 100644 index 0000000..56485a1 --- /dev/null +++ b/Windows/MassiveKnob.Plugin.SerialDevice/Settings/SerialDeviceSettingsViewModel.cs @@ -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 serialPorts; + public event PropertyChangedEventHandler PropertyChanged; + + + // ReSharper disable UnusedMember.Global - used by WPF Binding + public IEnumerable 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)); + } + } +} diff --git a/Windows/MassiveKnob.Plugin.SerialDevice/Worker/SerialWorker.cs b/Windows/MassiveKnob.Plugin.SerialDevice/Worker/SerialWorker.cs new file mode 100644 index 0000000..2e02f25 --- /dev/null +++ b/Windows/MassiveKnob.Plugin.SerialDevice/Worker/SerialWorker.cs @@ -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); + } + } +} diff --git a/Windows/MassiveKnob.Plugin/IMassiveKnobAction.cs b/Windows/MassiveKnob.Plugin/IMassiveKnobAction.cs index ba3f3fc..bba4713 100644 --- a/Windows/MassiveKnob.Plugin/IMassiveKnobAction.cs +++ b/Windows/MassiveKnob.Plugin/IMassiveKnobAction.cs @@ -19,12 +19,17 @@ namespace MassiveKnob.Plugin InputDigital = 1 << 1, /// - /// Can be assigned to an output, like an LED or relay. + /// Can be assigned to an analog output. /// - OutputSignal = 1 << 2 + OutputAnalog = 1 << 2, + + /// + /// Can be assigned to a digital output, like an LED or relay. + /// + OutputDigital = 1 << 3 } - - + + /// /// Provides information about an action which can be assigned to a knob or button. /// @@ -53,7 +58,6 @@ namespace MassiveKnob.Plugin /// /// Called when an action is bound to a knob or button to create an instance of the action. /// - /// Provides an interface to the Massive Knob settings and device. Can be stored until the action instance is disposed. - IMassiveKnobActionInstance Create(IMassiveKnobActionContext context); + IMassiveKnobActionInstance Create(); } } diff --git a/Windows/MassiveKnob.Plugin/IMassiveKnobActionContext.cs b/Windows/MassiveKnob.Plugin/IMassiveKnobActionContext.cs index 9f08a67..b8154b1 100644 --- a/Windows/MassiveKnob.Plugin/IMassiveKnobActionContext.cs +++ b/Windows/MassiveKnob.Plugin/IMassiveKnobActionContext.cs @@ -4,9 +4,15 @@ public interface IMassiveKnobActionContext : IMassiveKnobContext { /// - /// 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. + /// + /// The analog value in the range of 0 to 100. + void SetAnalogOutput(byte value); + + /// + /// Sets the state of the digital output. Only valid for OutputDigital action types, will raise an exception otherwise. /// /// Whether the signal is on or off. - void SetSignal(bool on); + void SetDigitalOutput(bool on); } } diff --git a/Windows/MassiveKnob.Plugin/IMassiveKnobActionInstance.cs b/Windows/MassiveKnob.Plugin/IMassiveKnobActionInstance.cs index 3973f60..110a27f 100644 --- a/Windows/MassiveKnob.Plugin/IMassiveKnobActionInstance.cs +++ b/Windows/MassiveKnob.Plugin/IMassiveKnobActionInstance.cs @@ -9,6 +9,12 @@ namespace MassiveKnob.Plugin /// public interface IMassiveKnobActionInstance : IDisposable { + /// + /// Called right after this instance is created. + /// + /// Provides an interface to the Massive Knob settings and device. Can be stored until the action instance is disposed. + void Initialize(IMassiveKnobActionContext context); + /// /// 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. diff --git a/Windows/MassiveKnob.Plugin/IMassiveKnobAnalogAction.cs b/Windows/MassiveKnob.Plugin/IMassiveKnobAnalogAction.cs index 15cfae1..4fe88ff 100644 --- a/Windows/MassiveKnob.Plugin/IMassiveKnobAnalogAction.cs +++ b/Windows/MassiveKnob.Plugin/IMassiveKnobAnalogAction.cs @@ -8,7 +8,7 @@ namespace MassiveKnob.Plugin public interface IMassiveKnobAnalogAction : IMassiveKnobActionInstance { /// - /// Called when a knob's position changes. + /// Called when an analog input's value changes. /// /// The new value. Range is 0 to 100. ValueTask AnalogChanged(byte value); diff --git a/Windows/MassiveKnob.Plugin/IMassiveKnobDevice.cs b/Windows/MassiveKnob.Plugin/IMassiveKnobDevice.cs index f949076..3a9f6c3 100644 --- a/Windows/MassiveKnob.Plugin/IMassiveKnobDevice.cs +++ b/Windows/MassiveKnob.Plugin/IMassiveKnobDevice.cs @@ -25,7 +25,6 @@ namespace MassiveKnob.Plugin /// /// Called when the device is selected. /// - /// Provides an interface to the Massive Knob settings and device. Can be stored until the device instance is disposed. - IMassiveKnobDeviceInstance Create(IMassiveKnobContext context); + IMassiveKnobDeviceInstance Create(); } } diff --git a/Windows/MassiveKnob.Plugin/IMassiveKnobDeviceContext.cs b/Windows/MassiveKnob.Plugin/IMassiveKnobDeviceContext.cs new file mode 100644 index 0000000..c170ec6 --- /dev/null +++ b/Windows/MassiveKnob.Plugin/IMassiveKnobDeviceContext.cs @@ -0,0 +1,80 @@ +namespace MassiveKnob.Plugin +{ + /// + public interface IMassiveKnobDeviceContext : IMassiveKnobContext + { + /// + /// Call when an attempt is being made to connect to the device. If the connection is always ready + /// this call can be skipped. + /// + void Connecting(); + + /// + /// Call when the device is connected. This method must be called before AnalogChanged or DigitalChanged. + /// + /// The specs as reported by the device. + void Connected(DeviceSpecs specs); + + /// + /// Call when the connection to the device has been lost. + /// + void Disconnected(); + + /// + /// Call when an analog input's value has changed. + /// + /// The index of the analog input. Must be within bounds of the value reported in Connected. + /// The new value in the range from 0 to 100. + void AnalogChanged(int analogInputIndex, byte value); + + /// + /// Call when a digital input's state has changed. + /// + /// The index of the digital input. Must be within bounds of the value reported in Connected. + /// Whether the input is considered on or off. + void DigitalChanged(int digitalInputIndex, bool on); + } + + + /// + /// Defines the specs as reported by the device. + /// + public readonly struct DeviceSpecs + { + /// + /// The number of analog input controls supported by the device. + /// + public readonly int AnalogInputCount; + + /// + /// The number of digital input controls supported by the device. + /// + public readonly int DigitalInputCount; + + /// + /// The number of analog output controls supported by the device. + /// + public readonly int AnalogOutputCount; + + /// + /// The number of digital output controls supported by the device. + /// + public readonly int DigitalOutputCount; + + + /// + /// Defines the specs as reported by the device. + /// + /// The number of analog input controls supported by the device. + /// The number of digital input controls supported by the device. + /// The number of analog output controls supported by the device. + /// The number of digital output controls supported by the device. + public DeviceSpecs(int analogInputCount, int digitalInputCount, int analogOutputCount, int digitalOutputCount) + { + AnalogInputCount = analogInputCount; + DigitalInputCount = digitalInputCount; + AnalogOutputCount = analogOutputCount; + DigitalOutputCount = digitalOutputCount; + } + } +} diff --git a/Windows/MassiveKnob.Plugin/IMassiveKnobDeviceInstance.cs b/Windows/MassiveKnob.Plugin/IMassiveKnobDeviceInstance.cs index d8307df..1924a3e 100644 --- a/Windows/MassiveKnob.Plugin/IMassiveKnobDeviceInstance.cs +++ b/Windows/MassiveKnob.Plugin/IMassiveKnobDeviceInstance.cs @@ -8,6 +8,12 @@ namespace MassiveKnob.Plugin /// public interface IMassiveKnobDeviceInstance : IDisposable { + /// + /// Called right after this instance is created. + /// + /// Provides an interface to the Massive Knob settings and device. Can be stored until the device instance is disposed. + void Initialize(IMassiveKnobDeviceContext context); + /// /// 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. diff --git a/Windows/MassiveKnob.Plugin/IMassiveKnobHardwarePlugin.cs b/Windows/MassiveKnob.Plugin/IMassiveKnobDevicePlugin.cs similarity index 100% rename from Windows/MassiveKnob.Plugin/IMassiveKnobHardwarePlugin.cs rename to Windows/MassiveKnob.Plugin/IMassiveKnobDevicePlugin.cs diff --git a/Windows/MassiveKnob.Plugin/IMassiveKnobDigitalAction.cs b/Windows/MassiveKnob.Plugin/IMassiveKnobDigitalAction.cs index fb3992f..2d2998a 100644 --- a/Windows/MassiveKnob.Plugin/IMassiveKnobDigitalAction.cs +++ b/Windows/MassiveKnob.Plugin/IMassiveKnobDigitalAction.cs @@ -1,9 +1,16 @@ -namespace MassiveKnob.Plugin +using System.Threading.Tasks; + +namespace MassiveKnob.Plugin { /// /// Required to be implemented for Action type InputDigital. Receives an update when a knob's position changes. /// public interface IMassiveKnobDigitalAction : IMassiveKnobActionInstance { + /// + /// Called when a digital input's value changes. + /// + /// The new value. + ValueTask DigitalChanged(bool on); } } diff --git a/Windows/MassiveKnob.Plugin/MassiveKnob.Plugin.csproj b/Windows/MassiveKnob.Plugin/MassiveKnob.Plugin.csproj index 8cdc5b2..5659c17 100644 --- a/Windows/MassiveKnob.Plugin/MassiveKnob.Plugin.csproj +++ b/Windows/MassiveKnob.Plugin/MassiveKnob.Plugin.csproj @@ -44,6 +44,7 @@ + @@ -52,7 +53,7 @@ - + diff --git a/Windows/MassiveKnob.Plugin/Properties/AssemblyInfo.cs b/Windows/MassiveKnob.Plugin/Properties/AssemblyInfo.cs index dddcccd..a776320 100644 --- a/Windows/MassiveKnob.Plugin/Properties/AssemblyInfo.cs +++ b/Windows/MassiveKnob.Plugin/Properties/AssemblyInfo.cs @@ -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 diff --git a/Windows/MassiveKnob.sln b/Windows/MassiveKnob.sln index 85a3fb7..8f3c00f 100644 --- a/Windows/MassiveKnob.sln +++ b/Windows/MassiveKnob.sln @@ -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 diff --git a/Windows/MassiveKnob/Hardware/AbstractMassiveKnobHardware.cs b/Windows/MassiveKnob/Hardware/AbstractMassiveKnobHardware.cs deleted file mode 100644 index 275bf14..0000000 --- a/Windows/MassiveKnob/Hardware/AbstractMassiveKnobHardware.cs +++ /dev/null @@ -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 observers = new List(); - - - 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); - } - } - } -} diff --git a/Windows/MassiveKnob/Hardware/CoreAudioDeviceManager.cs b/Windows/MassiveKnob/Hardware/CoreAudioDeviceManager.cs deleted file mode 100644 index 05421cb..0000000 --- a/Windows/MassiveKnob/Hardware/CoreAudioDeviceManager.cs +++ /dev/null @@ -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 audioController = new Lazy(); - private List devices; - - - public void Dispose() - { - if (audioController.IsValueCreated) - audioController.Value.Dispose(); - } - - - public async Task> GetDevices() - { - return devices ?? (devices = (await audioController.Value.GetPlaybackDevicesAsync()) - .Select(device => new AudioDevice(device) as IAudioDevice) - .ToList()); - } - - - public Task 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(); - } - } -} diff --git a/Windows/MassiveKnob/Hardware/IAudioDeviceManager.cs b/Windows/MassiveKnob/Hardware/IAudioDeviceManager.cs deleted file mode 100644 index 1993e77..0000000 --- a/Windows/MassiveKnob/Hardware/IAudioDeviceManager.cs +++ /dev/null @@ -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> GetDevices(); - Task GetDeviceById(Guid deviceId); - } - - - public interface IAudioDeviceManagerFactory - { - IAudioDeviceManager Create(); - } -} diff --git a/Windows/MassiveKnob/Hardware/IMassiveKnobHardware.cs b/Windows/MassiveKnob/Hardware/IMassiveKnobHardware.cs deleted file mode 100644 index 5421915..0000000 --- a/Windows/MassiveKnob/Hardware/IMassiveKnobHardware.cs +++ /dev/null @@ -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); - } -} diff --git a/Windows/MassiveKnob/Hardware/MockMassiveKnobHardware.cs b/Windows/MassiveKnob/Hardware/MockMassiveKnobHardware.cs deleted file mode 100644 index 798bec3..0000000 --- a/Windows/MassiveKnob/Hardware/MockMassiveKnobHardware.cs +++ /dev/null @@ -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); - } - } -} diff --git a/Windows/MassiveKnob/Hardware/SerialMassiveKnobHardware.cs b/Windows/MassiveKnob/Hardware/SerialMassiveKnobHardware.cs deleted file mode 100644 index ebd6a41..0000000 --- a/Windows/MassiveKnob/Hardware/SerialMassiveKnobHardware.cs +++ /dev/null @@ -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 workerThreadCompleted = new TaskCompletionSource(); - - - 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); - } - } -} diff --git a/Windows/MassiveKnob/Helpers/ComboBoxTemplateSelector.cs b/Windows/MassiveKnob/Helpers/ComboBoxTemplateSelector.cs index adda8da..303ce8f 100644 --- a/Windows/MassiveKnob/Helpers/ComboBoxTemplateSelector.cs +++ b/Windows/MassiveKnob/Helpers/ComboBoxTemplateSelector.cs @@ -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, diff --git a/Windows/MassiveKnob/Helpers/DelegateCommand.cs b/Windows/MassiveKnob/Helpers/DelegateCommand.cs index e895714..8266c22 100644 --- a/Windows/MassiveKnob/Helpers/DelegateCommand.cs +++ b/Windows/MassiveKnob/Helpers/DelegateCommand.cs @@ -1,4 +1,5 @@ -using System; +/* +using System; using System.Windows.Input; namespace MassiveKnob.Helpers @@ -78,3 +79,4 @@ namespace MassiveKnob.Helpers } } } +*/ \ No newline at end of file diff --git a/Windows/MassiveKnob/Helpers/SerialQueue.cs b/Windows/MassiveKnob/Helpers/SerialQueue.cs new file mode 100644 index 0000000..25194bf --- /dev/null +++ b/Windows/MassiveKnob/Helpers/SerialQueue.cs @@ -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 lastTaskWeakRef = new WeakReference(null); + + public Task Enqueue(Action action) + { + return Enqueue(() => + { + action(); + return true; + }); + } + + public Task Enqueue(Func 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 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 Enqueue(Func> asyncFunction) + { + lock (locker) + { + var resultTask = lastTaskWeakRef.TryGetTarget(out var lastTask) + ? lastTask.ContinueWith(_ => asyncFunction(), TaskContinuationOptions.ExecuteSynchronously).Unwrap() + : Task.Run(asyncFunction); + + lastTaskWeakRef.SetTarget(resultTask); + + return resultTask; + } + } + } +} diff --git a/Windows/MassiveKnob/MassiveKnob.csproj b/Windows/MassiveKnob/MassiveKnob.csproj index fb0171e..6d627cd 100644 --- a/Windows/MassiveKnob/MassiveKnob.csproj +++ b/Windows/MassiveKnob/MassiveKnob.csproj @@ -58,19 +58,20 @@ - - - - - - + + + + + + InputOutputView.xaml + SettingsWindow.xaml @@ -116,6 +117,9 @@ 5.2.1 + + 5.0.0 + @@ -136,6 +140,10 @@ Designer MSBuild:Compile + + Designer + MSBuild:Compile + Designer MSBuild:Compile diff --git a/Windows/MassiveKnob/Model/IMassiveKnobOrchestrator.cs b/Windows/MassiveKnob/Model/IMassiveKnobOrchestrator.cs index a818b26..e0737a3 100644 --- a/Windows/MassiveKnob/Model/IMassiveKnobOrchestrator.cs +++ b/Windows/MassiveKnob/Model/IMassiveKnobOrchestrator.cs @@ -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 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; + } } } diff --git a/Windows/MassiveKnob/Model/IPluginManager.cs b/Windows/MassiveKnob/Model/IPluginManager.cs index 87e185d..9f6a2c4 100644 --- a/Windows/MassiveKnob/Model/IPluginManager.cs +++ b/Windows/MassiveKnob/Model/IPluginManager.cs @@ -5,8 +5,7 @@ namespace MassiveKnob.Model { public interface IPluginManager { - IEnumerable Plugins { get; } - IEnumerable GetDevicePlugins(); + IEnumerable GetActionPlugins(); } } diff --git a/Windows/MassiveKnob/Model/MassiveKnobOrchestrator.cs b/Windows/MassiveKnob/Model/MassiveKnobOrchestrator.cs index 87e2de8..2654904 100644 --- a/Windows/MassiveKnob/Model/MassiveKnobOrchestrator.cs +++ b/Windows/MassiveKnob/Model/MassiveKnobOrchestrator.cs @@ -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 activeDeviceInfoSubject = new Subject(); + private IMassiveKnobDeviceContext activeDeviceContext; + + private readonly List analogInputs = new List(); + private readonly List digitalInputs = new List(); + private readonly List analogOutputs = new List(); + private readonly List digitalOutputs = new List(); - 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 ActiveDeviceSubject => activeDeviceInfoSubject; + + + public MassiveKnobOrchestrator(IPluginManager pluginManager) + { + this.pluginManager = pluginManager; + } + + + public void Dispose() + { + activeDevice?.Instance?.Dispose(); + + void DisposeMappings(IEnumerable 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(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() ?? new T(); + } + } + + + protected void SetDeviceSettings(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(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() ?? new T(); + } + } + + + protected void SetActionSettings(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 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 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(); + 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 mapping, int newCount, List actionSettings, Action 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 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() where T : class, new() { - // TODO - return default; + return owner.GetDeviceSettings(this); + } + + + public void SetSettings(T settings) where T : class, new() + { + 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() where T : class, new() + { + return owner.GetActionSettings(this, action, index); + } + + + public void SetSettings(T settings) where T : class, new() + { + owner.SetActionSettings(this, action, index, settings); + } + + + public void SetDigitalOutput(bool on) + { + throw new NotImplementedException(); } - public void SetSettings(T settings) where T : class, new() + public void SetAnalogOutput(byte value) { - // TODO + throw new NotImplementedException(); } } } diff --git a/Windows/MassiveKnob/Model/PluginManager.cs b/Windows/MassiveKnob/Model/PluginManager.cs index 48fec68..1a2b27e 100644 --- a/Windows/MassiveKnob/Model/PluginManager.cs +++ b/Windows/MassiveKnob/Model/PluginManager.cs @@ -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 plugins = new List(); - public IEnumerable Plugins => plugins; - public IEnumerable GetDevicePlugins() { return plugins.Where(p => p is IMassiveKnobDevicePlugin).Cast(); } - - public void Load() + public IEnumerable GetActionPlugins() { + return plugins.Where(p => p is IMassiveKnobActionPlugin).Cast(); + } + + + public void Load(Action 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 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() != null); foreach (var pluginType in pluginTypes) @@ -71,9 +94,57 @@ namespace MassiveKnob.Model var pluginInstance = Activator.CreateInstance(pluginType); 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 PluginById = new Dictionary(); + public readonly Dictionary DeviceById = new Dictionary(); + public readonly Dictionary ActionById = new Dictionary(); + } } } diff --git a/Windows/MassiveKnob/Program.cs b/Windows/MassiveKnob/Program.cs index f28ec47..dadea3e 100644 --- a/Windows/MassiveKnob/Program.cs +++ b/Windows/MassiveKnob/Program.cs @@ -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. /// [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(); + container.RegisterInstance(pluginManager); + container.RegisterInstance(orchestrator); container.Register(); container.Register(); container.Register(); - var settings = await SettingsJsonSerializer.Deserialize(); - container.RegisterInstance(settings); - - var pluginManager = new PluginManager(); - pluginManager.Load(); - container.RegisterInstance(pluginManager); - var app = container.GetInstance(); app.Run(); + + orchestrator.Dispose(); + return 0; } } } diff --git a/Windows/MassiveKnob/Settings/Settings.cs b/Windows/MassiveKnob/Settings/Settings.cs index 2ac71c3..37c8b24 100644 --- a/Windows/MassiveKnob/Settings/Settings.cs +++ b/Windows/MassiveKnob/Settings/Settings.cs @@ -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 Actions { get; set; } + public List AnalogInput { get; set; } + public List DigitalInput { get; set; } + public List AnalogOutput { get; set; } + public List DigitalOutput { get; set; } + + + public void Verify() + { + if (AnalogInput == null) AnalogInput = new List(); + if (DigitalInput == null) DigitalInput = new List(); + if (AnalogOutput == null) AnalogOutput = new List(); + if (DigitalOutput == null) DigitalOutput = new List(); + } - public static Settings Default() + public Settings Clone() { return new Settings { - Device = null, - Actions = new List() + 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 + }; + } } } } diff --git a/Windows/MassiveKnob/Settings/SettingsJsonSerializer.cs b/Windows/MassiveKnob/Settings/SettingsJsonSerializer.cs index dddc346..57bfd7d 100644 --- a/Windows/MassiveKnob/Settings/SettingsJsonSerializer.cs +++ b/Windows/MassiveKnob/Settings/SettingsJsonSerializer.cs @@ -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 + { + 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 Deserialize() + public static Settings Deserialize() { return Deserialize(GetDefaultFilename()); } - public static async Task 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(json, DefaultSettings); } + + if (settings == null) + settings = new Settings(); - if (string.IsNullOrEmpty(json)) - return Settings.Default(); - - return JsonConvert.DeserializeObject(json); + settings.Verify(); + return settings; } } } diff --git a/Windows/MassiveKnob/Strings.Designer.cs b/Windows/MassiveKnob/Strings.Designer.cs index c5bf674..3611e3b 100644 --- a/Windows/MassiveKnob/Strings.Designer.cs +++ b/Windows/MassiveKnob/Strings.Designer.cs @@ -61,83 +61,11 @@ namespace MassiveKnob { } /// - /// Looks up a localized string similar to {0}. + /// Looks up a localized string similar to Not configured. /// - internal static string DeviceDisplayNameActive { + internal static string ActionNotConfigured { get { - return ResourceManager.GetString("DeviceDisplayNameActive", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to {0} (Disabled). - /// - internal static string DeviceDisplayNameDisabled { - get { - return ResourceManager.GetString("DeviceDisplayNameDisabled", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to {0} (Inactive). - /// - internal static string DeviceDisplayNameInactive { - get { - return ResourceManager.GetString("DeviceDisplayNameInactive", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to {0} (Not present). - /// - internal static string DeviceDisplayNameNotPresent { - get { - return ResourceManager.GetString("DeviceDisplayNameNotPresent", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to {0} (Unplugged). - /// - internal static string DeviceDisplayNameUnplugged { - get { - return ResourceManager.GetString("DeviceDisplayNameUnplugged", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Knob {0}. - /// - internal static string KnobIndex { - get { - return ResourceManager.GetString("KnobIndex", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Connected. - /// - internal static string StatusConnected { - get { - return ResourceManager.GetString("StatusConnected", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Connecting.... - /// - internal static string StatusConnecting { - get { - return ResourceManager.GetString("StatusConnecting", resourceCulture); - } - } - - /// - /// Looks up a localized string similar to Not connected. - /// - internal static string StatusNotConnected { - get { - return ResourceManager.GetString("StatusNotConnected", resourceCulture); + return ResourceManager.GetString("ActionNotConfigured", resourceCulture); } } } diff --git a/Windows/MassiveKnob/Strings.resx b/Windows/MassiveKnob/Strings.resx index 0576451..72ba0d9 100644 --- a/Windows/MassiveKnob/Strings.resx +++ b/Windows/MassiveKnob/Strings.resx @@ -117,31 +117,7 @@ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 - - Connected - - - Connecting... - - - Not connected - - - Knob {0} - - - {0} - - - {0} (Disabled) - - - {0} (Inactive) - - - {0} (Not present) - - - {0} (Unplugged) + + Not configured \ No newline at end of file diff --git a/Windows/MassiveKnob/Style.xaml b/Windows/MassiveKnob/Style.xaml index 443fe67..b7a4d7e 100644 --- a/Windows/MassiveKnob/Style.xaml +++ b/Windows/MassiveKnob/Style.xaml @@ -1,5 +1,9 @@  + + @@ -12,10 +16,21 @@ + + + + diff --git a/Windows/MassiveKnob/View/InputOutputView.xaml b/Windows/MassiveKnob/View/InputOutputView.xaml new file mode 100644 index 0000000..4e8d2f0 --- /dev/null +++ b/Windows/MassiveKnob/View/InputOutputView.xaml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Windows/MassiveKnob/View/InputOutputView.xaml.cs b/Windows/MassiveKnob/View/InputOutputView.xaml.cs new file mode 100644 index 0000000..5d4d6a5 --- /dev/null +++ b/Windows/MassiveKnob/View/InputOutputView.xaml.cs @@ -0,0 +1,13 @@ +namespace MassiveKnob.View +{ + /// + /// Interaction logic for InputOutputView.xaml + /// + public partial class InputOutputView + { + public InputOutputView() + { + InitializeComponent(); + } + } +} diff --git a/Windows/MassiveKnob/View/SettingsWindow.xaml b/Windows/MassiveKnob/View/SettingsWindow.xaml index 601f5e4..2ac0269 100644 --- a/Windows/MassiveKnob/View/SettingsWindow.xaml +++ b/Windows/MassiveKnob/View/SettingsWindow.xaml @@ -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}"> @@ -14,32 +18,103 @@ - + - + - + + Device - + - + + - - Controls + + + Analog inputs + + + + + + + + + + + + Digital inputs + + + + + + + + + + + + Analog outputs + + + + + + + + + + + + Digital outputs + + + + + + + + diff --git a/Windows/MassiveKnob/ViewModel/ActionViewModel.cs b/Windows/MassiveKnob/ViewModel/ActionViewModel.cs new file mode 100644 index 0000000..e443ecb --- /dev/null +++ b/Windows/MassiveKnob/ViewModel/ActionViewModel.cs @@ -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; + } + } + +} diff --git a/Windows/MassiveKnob/ViewModel/DeviceViewModel.cs b/Windows/MassiveKnob/ViewModel/DeviceViewModel.cs new file mode 100644 index 0000000..d6769ea --- /dev/null +++ b/Windows/MassiveKnob/ViewModel/DeviceViewModel.cs @@ -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; + } + } +} diff --git a/Windows/MassiveKnob/ViewModel/InputOutputViewModel.cs b/Windows/MassiveKnob/ViewModel/InputOutputViewModel.cs new file mode 100644 index 0000000..a31f5fa --- /dev/null +++ b/Windows/MassiveKnob/ViewModel/InputOutputViewModel.cs @@ -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 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)); + } + } +} diff --git a/Windows/MassiveKnob/ViewModel/SettingsViewModel.cs b/Windows/MassiveKnob/ViewModel/SettingsViewModel.cs index 4764a9b..0c4dcfb 100644 --- a/Windows/MassiveKnob/ViewModel/SettingsViewModel.cs +++ b/Windows/MassiveKnob/ViewModel/SettingsViewModel.cs @@ -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 analogInputs; + private IEnumerable digitalInputs; + private IEnumerable analogOutputs; + private IEnumerable digitalOutputs; + + + // ReSharper disable UnusedMember.Global - used by WPF Binding + public IList Devices { get; } + public IList Actions { get; } + - public IEnumerable 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 AnalogInputs + { + get => analogInputs; + set + { + analogInputs = value; + OnPropertyChanged(); + } + } + + public Visibility DigitalInputVisibility => specs.HasValue && specs.Value.DigitalInputCount > 0 + ? Visibility.Visible + : Visibility.Collapsed; + + public IEnumerable DigitalInputs + { + get => digitalInputs; + set + { + digitalInputs = value; + OnPropertyChanged(); + } + } + + public Visibility AnalogOutputVisibility => specs.HasValue && specs.Value.AnalogOutputCount > 0 + ? Visibility.Visible + : Visibility.Collapsed; + + public IEnumerable AnalogOutputs + { + get => analogOutputs; + set + { + analogOutputs = value; + OnPropertyChanged(); + } + } + + public Visibility DigitalOutputVisibility => specs.HasValue && specs.Value.DigitalOutputCount > 0 + ? Visibility.Visible + : Visibility.Collapsed; + + public IEnumerable 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 + { + 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)); } } }