Implemented Arduino firmware

Implemented serial connection in Windows application
This commit is contained in:
Mark van Renswoude 2021-02-20 12:16:18 +01:00
parent 072e73df88
commit f15491c541
11 changed files with 553 additions and 79 deletions

View File

@ -0,0 +1,230 @@
/*
*
* Configuration
* Modify these settings according to your hardware design
*
*/
// Set this to the number of potentiometers you have connected
const byte KnobCount = 1;
// For each potentiometer, specify the port
const byte KnobPin[KnobCount] = {
// A0,
A1
};
// Minimum time between reporting changing values, reduces serial traffic
const unsigned long MinimumInterval = 50;
// Alpha value of the Exponential Moving Average (EMA) to reduce noise
const float EMAAlpha = 0.6;
// How many measurements to take at boot time to seed the EMA
const byte EMASeedCount = 5;
/*
*
* Le code
* Here be dragons.
*
*/
bool active = false;
enum OutputMode {
Binary, // Communication with the desktop application
PlainText, // Plain text, useful for the Arduino Serial Monitor
Plotter // Graph values, for the Arduino Serial Plotter
};
OutputMode outputMode = Binary;
byte volume[KnobCount];
unsigned long lastChange[KnobCount];
int analogReadValue[KnobCount];
float emaValue[KnobCount];
unsigned long currentTime;
unsigned long lastPlot;
void setup()
{
Serial.begin(115200);
// Wait for the Serial port hardware to initialise
while (!Serial) {}
// Seed the moving average
for (byte knobIndex = 0; knobIndex < KnobCount; knobIndex++)
emaValue[knobIndex] = analogRead(KnobPin[knobIndex]);
for (byte seed = 1; seed < EMASeedCount - 1; seed++)
for (byte knobIndex = 0; knobIndex < KnobCount; knobIndex++)
getVolume(knobIndex);
// Read the initial values
currentTime = millis();
for (byte knobIndex = 0; knobIndex < KnobCount; knobIndex++)
{
volume[knobIndex] = getVolume(knobIndex);
lastChange[knobIndex] = currentTime;
}
}
void loop()
{
if (Serial.available())
processMessage(Serial.read());
// Not that due to ADC checking and Serial communication, currentTime will not be
// accurate throughout the loop. But since we don't need exact timing for the interval this
// is acceptable and saves a few calls to millis.
currentTime = millis();
// Check volume knobs
byte newVolume;
for (byte knobIndex = 0; knobIndex < KnobCount; knobIndex++)
{
newVolume = getVolume(knobIndex);
if (newVolume != volume[knobIndex] && (currentTime - lastChange[knobIndex] >= MinimumInterval))
{
if (active)
// Send out new value
outputVolume(knobIndex, newVolume);
volume[knobIndex] = newVolume;
lastChange[knobIndex] = currentTime;
}
}
if (outputMode == Plotter && (currentTime - lastPlot) >= 50)
{
outputPlotter();
lastPlot = currentTime;
}
}
void processMessage(byte message)
{
switch (message)
{
case 'H': // Handshake
processHandshakeMessage();
break;
case 'Q': // Quit
processQuitMessage();
break;
}
}
void processHandshakeMessage()
{
byte buffer[2];
if (Serial.readBytes(buffer, 3) < 3)
return;
if (buffer[0] != 'M' || buffer[1] != 'K')
return;
switch (buffer[2])
{
case 'B':
outputMode = Binary;
break;
case 'P':
outputMode = PlainText;
break;
case 'G':
outputMode = Plotter;
break;
default:
return;
}
switch (outputMode)
{
case Binary:
Serial.write('H');
Serial.write(KnobCount);
break;
case PlainText:
Serial.print("Hello! I have ");
Serial.print(KnobCount);
Serial.println(" knobs.");
break;
}
active = true;
}
void processQuitMessage()
{
switch (outputMode)
{
case Binary:
case PlainText:
Serial.write('Q');
break;
}
active = false;
}
byte getVolume(byte knobIndex)
{
analogReadValue[knobIndex] = analogRead(KnobPin[knobIndex]);
emaValue[knobIndex] = (EMAAlpha * analogReadValue[knobIndex]) + ((1 - EMAAlpha) * emaValue[knobIndex]);
return map(emaValue[knobIndex], 0, 1023, 0, 100);
}
void outputVolume(byte knobIndex, byte newVolume)
{
switch (outputMode)
{
case Binary:
Serial.write('V');
Serial.write(knobIndex);
Serial.write(newVolume);
break;
case PlainText:
Serial.print("Volume #");
Serial.print(knobIndex);
Serial.print(" = ");
Serial.println(newVolume);
break;
}
}
void outputPlotter()
{
for (byte i = 0; i < KnobCount; i++)
{
if (i > 0)
Serial.print('\t');
Serial.print(analogReadValue[i]);
Serial.print('\t');
Serial.print(emaValue[i]);
Serial.print('\t');
Serial.print(volume[i]);
}
Serial.println();
}

View File

@ -6,8 +6,8 @@ Control audio devices using physical knobs.
Inspired by [an article on Prusa's blog](https://blog.prusaprinters.org/3d-print-an-oversized-media-control-volume-knob-arduino-basics_30184/), this project has a slightly different set of goals:
**Must have**
1. Control multiple audio devices, one set of physical controls per device
2. Volume is set to an absolute value (potentiometer instead of a rotary encoder)
1. Control multiple audio devices, one set of physical controls per device
2. Volume is set to an absolute value (potentiometer instead of a rotary encoder)
Because of the second requirement, a simple media keys HID device does not suffice and extra software is required on the desktop.
@ -16,6 +16,8 @@ Because of the second requirement, a simple media keys HID device does not suffi
a. by changing the Windows default output device
b. by running a VoiceMeeter macro
2. Corresponding LEDs to indicate the currently active device
3. OSD
4. API / plugins to use extra knobs and buttons for other purposes
## Developing
The hardware side uses an Arduino sketch to communicate the hardware state over the serial port.

View File

@ -185,7 +185,6 @@ namespace MassiveKnob.Forms
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterScreen;
this.Text = "Massive Knob - Settings";
this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.SettingsForm_FormClosing);
this.FormClosed += new System.Windows.Forms.FormClosedEventHandler(this.SettingsForm_FormClosed);
this.NotifyIconMenu.ResumeLayout(false);
this.CommunicationGroupbox.ResumeLayout(false);
this.CommunicationGroupbox.PerformLayout();

View File

@ -4,6 +4,8 @@ using System.IO.Ports;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using Dapplo.Windows.Devices;
using Dapplo.Windows.Devices.Enums;
using MassiveKnob.Hardware;
using MassiveKnob.Settings;
using MassiveKnob.UserControls;
@ -11,13 +13,15 @@ using Nito.AsyncEx;
namespace MassiveKnob.Forms
{
public partial class SettingsForm : Form, IMassiveKnobHardwareObserver
public partial class SettingsForm : Form, IMassiveKnobHardwareObserver, IObserver<DeviceNotificationEvent>
{
private readonly IAudioDeviceManager audioDeviceManager;
private readonly IMassiveKnobHardwareFactory massiveKnobHardwareFactory;
private readonly List<KnobDeviceControl> knobDeviceControls = new List<KnobDeviceControl>();
private bool loading = true;
private string lastConnectedPort = null;
private IDisposable deviceSubscription;
private IMassiveKnobHardware hardware;
private IAudioDevice[] devices;
private Settings.Settings settings;
@ -35,42 +39,43 @@ namespace MassiveKnob.Forms
this.massiveKnobHardwareFactory = massiveKnobHardwareFactory;
InitializeComponent();
SerialPortStatusLabel.Text = Strings.StatusNotConnected;
Task.Run(async () =>
{
await LoadSettings();
await Task.WhenAll(
LoadSerialPorts(),
LoadAudioDevices()
);
loading = false;
await Connect();
}).ContinueWith(t =>
{
if (t.IsFaulted && t.Exception != null)
SafeCall(() => throw t.Exception);
});
// Due to the form not being visible initially (see SetVisibleCore), we can't use the Load event
AsyncLoad();
}
private void SafeCall(Action action)
private async void AsyncLoad()
{
await LoadSettings();
await Task.WhenAll(
LoadSerialPorts(),
LoadAudioDevices()
);
deviceSubscription = DeviceNotification.OnNotification.Subscribe(this);
loading = false;
await Connect();
}
private void RunInUIContext(Action action)
{
if (InvokeRequired)
Invoke(action);
else
action();
}
private Task LoadSerialPorts()
{
var portNames = SerialPort.GetPortNames();
SafeCall(() =>
RunInUIContext(() =>
{
SerialPortCombobox.BeginUpdate();
try
@ -97,22 +102,19 @@ namespace MassiveKnob.Forms
private async Task LoadSettings()
{
var newSettings = await SettingsJsonSerializer.Deserialize();
SafeCall(() => SetSettings(newSettings));
RunInUIContext(() => SetSettings(newSettings));
}
private void SaveSettings()
private async Task SaveSettings()
{
if (settings == null)
return;
Task.Run(async () =>
{
using (await saveSettingsLock.LockAsync())
{
await SettingsJsonSerializer.Serialize(settings);
}
});
using (await saveSettingsLock.LockAsync())
{
await SettingsJsonSerializer.Serialize(settings);
}
}
@ -120,9 +122,8 @@ namespace MassiveKnob.Forms
{
string serialPort = null;
SafeCall(() =>
RunInUIContext(() =>
{
SerialPortStatusLabel.Text = Strings.StatusConnecting;
serialPort = (string)SerialPortCombobox.SelectedItem;
});
@ -145,7 +146,7 @@ namespace MassiveKnob.Forms
private async Task LoadAudioDevices()
{
var newDevices = await audioDeviceManager.GetDevices();
SafeCall(() => SetDevices(newDevices));
RunInUIContext(() => SetDevices(newDevices));
}
@ -158,10 +159,12 @@ namespace MassiveKnob.Forms
SerialPortCombobox.SelectedItem = value.SerialPort;
// No need to update the knob device user controls, as they are not loaded yet
// (guaranteed by the order in AsyncLoad)
settings = value;
}
private void SetDevices(IEnumerable<IAudioDevice> value)
{
devices = value.ToArray();
@ -206,13 +209,13 @@ namespace MassiveKnob.Forms
if (i < settings.Knobs.Count)
knobDeviceControl.SetDeviceId(settings.Knobs[i].DeviceId);
knobDeviceControl.OnDeviceChanged += (sender, args) =>
knobDeviceControl.OnDeviceChanged += async (sender, args) =>
{
while (settings.Knobs.Count - 1 < args.KnobIndex)
settings.Knobs.Add(new Settings.Settings.KnobSettings());
settings.Knobs[args.KnobIndex].DeviceId = args.DeviceId;
SaveSettings();
await SaveSettings();
};
knobDeviceControls.Add(knobDeviceControl);
@ -241,7 +244,13 @@ namespace MassiveKnob.Forms
{
// Prevent the form from showing at startup
if (!startupVisibleCalled)
{
startupVisibleCalled = true;
// Make sure the underlying window is still created, otherwise Close won't work
if (!IsHandleCreated)
CreateHandle();
}
else
base.SetVisibleCore(value);
}
@ -253,16 +262,43 @@ namespace MassiveKnob.Forms
}
private void Quit()
private async Task Quit()
{
Hide();
deviceSubscription?.Dispose();
foreach (var knobDeviceControl in knobDeviceControls)
knobDeviceControl.Dispose();
knobDeviceControls.Clear();
if (hardware != null)
{
hardware.DetachObserver(this);
await hardware.Disconnect();
}
audioDeviceManager?.Dispose();
closing = true;
Close();
}
public void Connecting()
{
RunInUIContext(() =>
{
SerialPortStatusLabel.Text = Strings.StatusConnecting;
});
}
public void Connected(int knobCount)
{
SafeCall(() =>
RunInUIContext(() =>
{
SerialPortStatusLabel.Text = Strings.StatusConnected;
SetKnobCount(knobCount);
@ -272,14 +308,15 @@ namespace MassiveKnob.Forms
public void Disconnected()
{
SafeCall(() =>
RunInUIContext(() =>
{
SerialPortStatusLabel.Text = Strings.StatusNotConnected;
lastConnectedPort = null;
});
}
public void VolumeChanged(int knob, int volume)
public async void VolumeChanged(int knob, int volume)
{
if (knob >= settings.Knobs.Count)
return;
@ -289,15 +326,12 @@ namespace MassiveKnob.Forms
var deviceId = settings.Knobs[knob].DeviceId.Value;
Task.Run(async () =>
using (await setVolumeLock.LockAsync())
{
using (await setVolumeLock.LockAsync())
{
var device = await audioDeviceManager.GetDeviceById(deviceId);
if (device != null)
await device.SetVolume(volume);
}
});
var device = await audioDeviceManager.GetDeviceById(deviceId);
if (device != null)
await device.SetVolume(volume);
}
}
@ -310,20 +344,6 @@ namespace MassiveKnob.Forms
e.Cancel = true;
}
private void SettingsForm_FormClosed(object sender, FormClosedEventArgs e)
{
foreach (var knobDeviceControl in knobDeviceControls)
knobDeviceControl.Dispose();
knobDeviceControls.Clear();
hardware?.DetachObserver(this);
hardware?.Disconnect().GetAwaiter().GetResult();
audioDeviceManager?.Dispose();
}
private void NotifyIcon_DoubleClick(object sender, EventArgs e)
{
@ -331,9 +351,9 @@ namespace MassiveKnob.Forms
}
private void QuitToolStripMenuItem_Click(object sender, EventArgs e)
private async void QuitToolStripMenuItem_Click(object sender, EventArgs e)
{
Quit();
await Quit();
}
@ -349,15 +369,39 @@ namespace MassiveKnob.Forms
}
private void SerialPortCombobox_SelectedIndexChanged(object sender, EventArgs e)
private async void SerialPortCombobox_SelectedIndexChanged(object sender, EventArgs e)
{
if (loading || (string)SerialPortCombobox.SelectedItem == settings.SerialPort)
var newPort = (string) SerialPortCombobox.SelectedItem;
if (loading || newPort == lastConnectedPort)
return;
settings.SerialPort = (string) SerialPortCombobox.SelectedItem;
SaveSettings();
Task.Run(Connect);
lastConnectedPort = newPort;
if (settings.SerialPort != newPort)
{
settings.SerialPort = (string) SerialPortCombobox.SelectedItem;
await SaveSettings();
}
await Connect();
}
public async void OnNext(DeviceNotificationEvent value)
{
if ((value.EventType == DeviceChangeEvent.DeviceArrival ||
value.EventType == DeviceChangeEvent.DeviceRemoveComplete) &&
value.Is(DeviceBroadcastDeviceType.DeviceInterface))
{
await LoadSerialPorts();
}
}
public void OnError(Exception error)
{
}
public void OnCompleted()
{
}
}
}

View File

@ -36,8 +36,15 @@ namespace MassiveKnob.Hardware
{
observers.Remove(observer);
}
public void Connecting()
{
foreach (var observer in observers)
observer.Connecting();
}
public void Connected(int knobCount)
{
foreach (var observer in observers)

View File

@ -4,6 +4,7 @@ namespace MassiveKnob.Hardware
{
public interface IMassiveKnobHardwareObserver
{
void Connecting();
void Connected(int knobCount);
void Disconnected();
@ -25,6 +26,6 @@ namespace MassiveKnob.Hardware
public interface IMassiveKnobHardwareFactory
{
IMassiveKnobHardware Create(string serialPort);
IMassiveKnobHardware Create(string portName);
}
}

View File

@ -48,6 +48,7 @@ namespace MassiveKnob.Hardware
}
// ReSharper disable once UnusedMember.Global - for testing purposes only
public class MockMassiveKnobHardwareFactory : IMassiveKnobHardwareFactory
{
private readonly int knobCount;
@ -62,7 +63,7 @@ namespace MassiveKnob.Hardware
}
public IMassiveKnobHardware Create(string serialPort)
public IMassiveKnobHardware Create(string portName)
{
return new MockMassiveKnobHardware(knobCount, volumeChangeInterval, maxVolume);
}

View File

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

View File

@ -65,6 +65,7 @@
<Compile Include="Hardware\IAudioDeviceManager.cs" />
<Compile Include="Hardware\IMassiveKnobHardware.cs" />
<Compile Include="Hardware\MockMassiveKnobHardware.cs" />
<Compile Include="Hardware\SerialMassiveKnobHardware.cs" />
<Compile Include="Program.cs" />
<Compile Include="Properties\AssemblyInfo.cs" />
<Compile Include="Settings\Settings.cs" />
@ -116,6 +117,12 @@
<PackageReference Include="AudioSwitcher.AudioApi.CoreAudio">
<Version>4.0.0-alpha5</Version>
</PackageReference>
<PackageReference Include="Dapplo.Windows.Devices">
<Version>0.11.24</Version>
</PackageReference>
<PackageReference Include="Dapplo.Windows.Messages">
<Version>0.11.24</Version>
</PackageReference>
<PackageReference Include="Newtonsoft.Json">
<Version>12.0.3</Version>
</PackageReference>

View File

@ -0,0 +1,2 @@
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=UI/@EntryIndexedValue">UI</s:String></wpf:ResourceDictionary>

View File

@ -35,7 +35,8 @@ namespace MassiveKnob
container.Register<IAudioDeviceManagerFactory, CoreAudioDeviceManagerFactory>();
// For testing without the hardware:
container.Register<IMassiveKnobHardwareFactory>(() => new MockMassiveKnobHardwareFactory(3, TimeSpan.FromSeconds(1), 25));
container.Register<IMassiveKnobHardwareFactory, SerialMassiveKnobHardwareFactory>();
//container.Register<IMassiveKnobHardwareFactory>(() => new MockMassiveKnobHardwareFactory(3, TimeSpan.FromSeconds(1), 25));
return container;
}