diff --git a/WirelessMicSuiteServer/IWirelessMicReceiver.cs b/WirelessMicSuiteServer/IWirelessMicReceiver.cs
index 6036fb1..7a18b1c 100644
--- a/WirelessMicSuiteServer/IWirelessMicReceiver.cs
+++ b/WirelessMicSuiteServer/IWirelessMicReceiver.cs
@@ -88,7 +88,7 @@ public interface IWirelessMicReceiver : IDisposable, INotifyPropertyChanged
///
/// The MAC address of the wireless receiver.
///
- public abstract MACAddress? MACAddres { get; }
+ public abstract MACAddress? MACAddress { get; }
}
public interface IWirelessMic : INotifyPropertyChanged
@@ -102,6 +102,7 @@ public interface IWirelessMic : INotifyPropertyChanged
///
public abstract ConcurrentQueue? MeterData { get; }
public abstract MeteringData? LastMeterData { get; }
+ public abstract RFScanData RFScanData { get; }
///
/// A unique identifier for this wireless transmitter. Note that this is associated with the
@@ -149,6 +150,14 @@ public interface IWirelessMic : INotifyPropertyChanged
//public void Unsubscribe();
public void StartMetering(int periodMS);
public void StopMetering();
+
+ ///
+ /// Starts scanning the RF spectrum using this receiver. Audio is muted while this task is executing.
+ ///
+ /// The frequency range to scan, must be within the frequency range supported by the receiver.
+ /// If supported, the step size in Hz between each RF sample.
+ /// A data structure containing the results of the scan.
+ public Task StartRFScan(FrequencyRange range, ulong stepSize);
}
///
@@ -190,7 +199,7 @@ public struct WirelessReceiverData(IWirelessMicReceiver other) : IWirelessMicRec
public IPv4Address? Subnet { get; set; } = other.Subnet;
public IPv4Address? Gateway { get; set; } = other.Gateway;
public IPMode? IPMode { get; set; } = other.IPMode;
- public MACAddress? MACAddres { get; init; } = other.MACAddres;
+ public MACAddress? MACAddress { get; init; } = other.MACAddress;
public event PropertyChangedEventHandler? PropertyChanged;
@@ -205,6 +214,7 @@ public struct WirelessMicData(IWirelessMic other) : IWirelessMic
{
[JsonIgnore] public IWirelessMicReceiver Receiver { get; init; } = other.Receiver;
[JsonIgnore] public ConcurrentQueue? MeterData { get; init; } = other.MeterData;
+ [JsonIgnore] public RFScanData RFScanData { get; init; } = other.RFScanData;
[JsonInclude] public MeteringData? LastMeterData { get; init; } = other.LastMeterData;
[JsonInclude] public readonly uint ReceiverID => Receiver.UID;
@@ -226,12 +236,70 @@ public void StartMetering(int periodMS)
throw new NotImplementedException();
}
+ public Task StartRFScan(FrequencyRange range, ulong stepSize)
+ {
+ throw new NotImplementedException();
+ }
+
public void StopMetering()
{
throw new NotImplementedException();
}
}
+///
+/// The data resulting from an RF spectrum scan.
+///
+public struct RFScanData
+{
+ ///
+ /// The recorded RF level samples at each sampled frequency.
+ ///
+ public List Samples { get; internal set; }
+ ///
+ /// The frequency range covered by the scan.
+ ///
+ public FrequencyRange FrequencyRange { get; internal set; }
+ ///
+ /// The distance between RF samples in Hz.
+ ///
+ public ulong StepSize { get; internal set; }
+ ///
+ /// The percentage completion of the RF scan operation.
+ ///
+ public float Progress { get; internal set; }
+ ///
+ /// The current state of the RF scan operation.
+ ///
+ public Status State { get; internal set; }
+
+ ///
+ /// An RF spectrum sample.
+ ///
+ /// The frequency of the sample in Hz.
+ /// The strength of the sample in dBm.
+ public readonly struct Sample(ulong freq, float strength)
+ {
+ ///
+ /// The frequency of the sample in Hz.
+ ///
+ public readonly ulong Frequency { get; init; } = freq;
+ ///
+ /// The strength of the sample in dBm.
+ ///
+ public readonly float Strength { get; init; } = strength;
+ }
+
+ [JsonConverter(typeof(JsonStringEnumConverter))]
+ public enum Status
+ {
+ Started,
+ Running,
+ Completed,
+ Failure
+ }
+}
+
///
/// A frequency range in Hz.
///
@@ -242,11 +310,11 @@ public struct FrequencyRange(ulong startFreq, ulong endFreq)
///
/// The lower bound of the tunable frequency range in Hz.
///
- [JsonInclude] public ulong StartFrequency { get; set; } = startFreq;
+ public ulong StartFrequency { get; set; } = startFreq;
///
/// The upper bound of the tunable frequency range in Hz.
///
- [JsonInclude] public ulong EndFrequency { get; set; } = endFreq;
+ public ulong EndFrequency { get; set; } = endFreq;
}
[JsonConverter(typeof(JsonStringEnumConverter))]
diff --git a/WirelessMicSuiteServer/JsonStringConverter.cs b/WirelessMicSuiteServer/JsonStringConverter.cs
index 24b186c..ccba8b6 100644
--- a/WirelessMicSuiteServer/JsonStringConverter.cs
+++ b/WirelessMicSuiteServer/JsonStringConverter.cs
@@ -1,10 +1,4 @@
-using static System.ComponentModel.TypeConverter;
-using System.Collections;
-using System.ComponentModel.Design.Serialization;
-using System.ComponentModel;
-using System.Diagnostics.CodeAnalysis;
-using System.Globalization;
-using System.Reflection;
+using System.Reflection;
using System.Text.Json.Serialization;
using System.Text.Json;
using System;
diff --git a/WirelessMicSuiteServer/Program.cs b/WirelessMicSuiteServer/Program.cs
index 0d38126..16606ad 100644
--- a/WirelessMicSuiteServer/Program.cs
+++ b/WirelessMicSuiteServer/Program.cs
@@ -27,9 +27,10 @@ public static void Main(string[] args)
WirelessMicManager micManager = new([
new ShureUHFRManager() { PollingPeriodMS = meterInterval }
]);
+ WebSocketAPIManager wsAPIManager = new(micManager, meterInterval);
if (cli.ParsedArgs.ContainsKey("--meters"))
Task.Run(() => MeterTask(micManager));
- StartWebServer(args, micManager);
+ StartWebServer(args, micManager, wsAPIManager);
}
private static CommandLineOptions CreateCommandLineArgs(string[] args)
@@ -40,7 +41,7 @@ private static CommandLineOptions CreateCommandLineArgs(string[] args)
], args);
}
- private static void StartWebServer(string[] args, WirelessMicManager micManager)
+ private static void StartWebServer(string[] args, WirelessMicManager micManager, WebSocketAPIManager wsAPIManager)
{
var builder = WebApplication.CreateBuilder(args);
//builder.Logging.ClearProviders();
@@ -66,7 +67,14 @@ private static void StartWebServer(string[] args, WirelessMicManager micManager)
app.UseAuthorization();
- WebAPI.AddWebRoots(app, micManager);
+ var webSocketOptions = new WebSocketOptions
+ {
+ KeepAliveInterval = TimeSpan.FromMinutes(2)
+ };
+
+ app.UseWebSockets(webSocketOptions);
+
+ WebAPI.AddWebRoots(app, micManager, wsAPIManager);
app.Run();
}
diff --git a/WirelessMicSuiteServer/ShureUHFR/ShureUHFRManager.cs b/WirelessMicSuiteServer/ShureUHFR/ShureUHFRManager.cs
index 760f855..47d2823 100644
--- a/WirelessMicSuiteServer/ShureUHFR/ShureUHFRManager.cs
+++ b/WirelessMicSuiteServer/ShureUHFR/ShureUHFRManager.cs
@@ -167,6 +167,7 @@ private void TXTask()
while (!txPipe.TryDequeue(out msg))
txAvailableSem.Wait(1000);
socket.SendTo(msg.Buffer, msg.endPoint);
+ msg.Dispose();
/*var msg = await txPipe.Reader.ReadAsync(cancellationToken);
foreach (var part in msg.Buffer)
await socket.SendAsync(part);*/
@@ -209,7 +210,7 @@ private void PingTask()
internal void SendMessage(ByteMessage message)
{
if (message.endPoint.Address == IPAddress.Any)
- throw new ArgumentException();
+ throw new ArgumentException($"Specified message endpoint is IPAddress.Any, this is probably not intentional...");
txPipe.Enqueue(message);
txAvailableSem.Release();
}
@@ -244,7 +245,9 @@ enum ShureCommandType
REPORT,
SAMPLE,
NOTE,
- NOTED
+ NOTED,
+ SCAN,
+ RFLEVEL
}
public readonly record struct ShureSNetHeader
diff --git a/WirelessMicSuiteServer/ShureUHFR/ShureUHFRReceiver.cs b/WirelessMicSuiteServer/ShureUHFR/ShureUHFRReceiver.cs
index 51a33d4..63dfd3b 100644
--- a/WirelessMicSuiteServer/ShureUHFR/ShureUHFRReceiver.cs
+++ b/WirelessMicSuiteServer/ShureUHFR/ShureUHFRReceiver.cs
@@ -80,7 +80,7 @@ public IPMode? IPMode
}
}
}
- public MACAddress? MACAddres => macAddress;
+ public MACAddress? MACAddress => macAddress;
public event PropertyChangedEventHandler? PropertyChanged;
@@ -202,6 +202,16 @@ public void Receive(ReadOnlySpan msg, ShureSNetHeader header)
// This is just an acknowledgement of the UPDATE command, we can safely ignore it...
return;
}
+ else if (msg.StartsWith("RFLEVEL"))
+ {
+ type = ShureCommandType.RFLEVEL;
+ msg = msg[7..];
+ }
+ else if (msg.StartsWith("SCAN"))
+ {
+ type = ShureCommandType.SCAN;
+ msg = msg[4..];
+ }
else
{
Log($"Unknown command type '{msg.ToString().Split(' ')[0]}'", LogSeverity.Warning);
@@ -221,9 +231,19 @@ public void Receive(ReadOnlySpan msg, ShureSNetHeader header)
msg = msg[noteEnd..];
}
+ ReadOnlySpan cmd = msg;
+ if (type == ShureCommandType.SCAN)
+ {
+ // Next is the command itself
+ msg = msg[1..];
+ int scmdEnd = msg.IndexOf(' ');
+ cmd = scmdEnd == -1 ? msg : msg[..scmdEnd];
+ msg = msg[scmdEnd..];
+ }
+
// This is followed by 1 or 2 for the receiver number
int receiver;
- if (msg.Length < 3)
+ if (msg.Length < 2)
{
Log($"Incomplete message, missing receiver number: '{fullMsg}'", LogSeverity.Warning);
return;
@@ -242,12 +262,17 @@ public void Receive(ReadOnlySpan msg, ShureSNetHeader header)
{
receiver = -1;
}
- msg = msg[1..];
+ if (msg.Length > 0)
+ msg = msg[1..];
+
+ if (type != ShureCommandType.SCAN)
+ {
+ // Next is the command itself
+ int cmdEnd = msg.IndexOf(' ');
+ cmd = cmdEnd == -1 ? msg : msg[..cmdEnd];
+ msg = msg[(cmdEnd + 1)..];
+ }
- // Next is the command itself
- int cmdEnd = msg.IndexOf(' ');
- var cmd = cmdEnd == -1 ? msg : msg[..cmdEnd];
- msg = msg[(cmdEnd+1)..];
if (receiver == -1)
ParseCommand(type, cmd, msg, fullMsg);
else
@@ -319,7 +344,7 @@ private void ParseCommand(ShureCommandType type, ReadOnlySpan cmd, ReadOnl
try
{
macAddress = new MACAddress(args);
- OnPropertyChanged(nameof(MACAddres));
+ OnPropertyChanged(nameof(MACAddress));
} catch
{
CommandError(fullMsg, "Expected a MAC address in the form aa:bb:cc:dd:ee:ff!");
diff --git a/WirelessMicSuiteServer/ShureUHFR/ShureWirelessMic.cs b/WirelessMicSuiteServer/ShureUHFR/ShureWirelessMic.cs
index c75cfbf..5ce8fd0 100644
--- a/WirelessMicSuiteServer/ShureUHFR/ShureWirelessMic.cs
+++ b/WirelessMicSuiteServer/ShureUHFR/ShureWirelessMic.cs
@@ -9,6 +9,10 @@ public class ShureWirelessMic : IWirelessMic
private readonly ShureUHFRReceiver receiver;
private readonly int receiverNo;
private readonly ConcurrentQueue meterData;
+ private readonly ConcurrentQueue<(string cmd, string args)> scanCommands;
+ private readonly SemaphoreSlim scanCommandsSem;
+ private RFScanData rfScanData;
+ private Task? rfScanInProgress;
private MeteringData? lastMeterData;
private readonly uint uid;
@@ -25,6 +29,7 @@ public class ShureWirelessMic : IWirelessMic
public IWirelessMicReceiver Receiver => receiver;
public ConcurrentQueue MeterData => meterData;
public MeteringData? LastMeterData => lastMeterData;
+ public RFScanData RFScanData => rfScanData;
public uint UID => uid;
public string? Name
@@ -33,8 +38,8 @@ public string? Name
set
{
if (value != null && value.Length < 12)
- SetAsync("CHAN_NAME", value);
- }
+ SetAsync("CHAN_NAME", value.Replace(' ', '_'));
+ }
}
public int? Gain
{
@@ -107,6 +112,9 @@ public ShureWirelessMic(ShureUHFRReceiver receiver, uint uid, int receiverNo)
this.uid = uid;
this.receiverNo = receiverNo;
meterData = [];
+ rfScanInProgress = null;
+ scanCommands = [];
+ scanCommandsSem = new(1);
SendStartupCommands();
}
@@ -163,6 +171,17 @@ internal void ParseCommand(ShureCommandType type, ReadOnlySpan cmd, ReadOn
ParseSampleCommand(args, fullMsg);
return;
}
+ else if (type == ShureCommandType.SCAN)
+ {
+ scanCommands.Enqueue((cmd.ToString(), args.ToString()));
+ scanCommandsSem.Release();
+ return;
+ }
+ else if(type == ShureCommandType.RFLEVEL)
+ {
+ ParseRFLevelCommand(cmd, args, fullMsg);
+ return;
+ }
else if (type != ShureCommandType.REPORT && type != ShureCommandType.NOTE)
{
CommandError(fullMsg, $"Unexpected command type '{type}'.");
@@ -171,7 +190,7 @@ internal void ParseCommand(ShureCommandType type, ReadOnlySpan cmd, ReadOn
switch (cmd)
{
case "CHAN_NAME":
- name = args.ToString();
+ name = args.ToString().Replace('_', ' ');
OnPropertyChanged(nameof(Name));
break;
case "MUTE":
@@ -199,9 +218,9 @@ internal void ParseCommand(ShureCommandType type, ReadOnlySpan cmd, ReadOn
break;
case "TX_GAIN":
case "TX_IR_GAIN":
- if (int.TryParse(args, out int ngain) && ngain is >= -10 and <= 20)
+ if (int.TryParse(args, out int ngain) && ngain is >= 0 and <= 30)
{
- gain = ngain;
+ gain = ngain - 10;
OnPropertyChanged(nameof(Gain));
}
else if (args.SequenceEqual("UNKNOWN"))
@@ -215,6 +234,7 @@ internal void ParseCommand(ShureCommandType type, ReadOnlySpan cmd, ReadOn
case "SQUELCH":
if (int.TryParse(args, out int nsquelch))
{
+ // In the range 0-20 -> -10-10
//outputGain = -ngain;
//OnPropertyChanged(nameof(OutputGain));
}
@@ -351,6 +371,39 @@ private void ParseSampleCommand(ReadOnlySpan args, ReadOnlySpan full
lastMeterData = meter;
}
+ private void ParseRFLevelCommand(ReadOnlySpan nargs, ReadOnlySpan args, ReadOnlySpan fullMsg)
+ {
+ // "* RFLEVEL n 10 578000 100 578025 100 578050 100 578075 100 578100 100 578125 100 578150 100 578175 100 578200 100 578225 100 *"
+ // * RFLEVEL n numSamples [freq level]... *
+ // level: is in - dBm
+ Span splits = stackalloc Range[32];
+ int nSplits = args.Split(splits, ' ');
+ splits = splits[..nSplits];
+
+ if (!byte.TryParse(nargs, out byte n))
+ {
+ CommandError(fullMsg, "Couldn't parse number of RF level samples.");
+ return;
+ }
+ if (nSplits != n * 2)
+ {
+ CommandError(fullMsg, "Unexpected number of RF level samples.");
+ return;
+ }
+
+ for (int i = 0; i < n*2; i+=2)
+ {
+ if (!uint.TryParse(args[splits[i]], out uint freq) || !uint.TryParse(args[splits[i+1]], out uint rf))
+ {
+ CommandError(fullMsg, $"Couldn't parse RF level sample {i/2}.");
+ return;
+ }
+ rfScanData.Samples.Add(new(freq*1000, -(float)rf));
+ }
+
+ rfScanData.Progress = rfScanData.Samples.Count / (float)((rfScanData.FrequencyRange.EndFrequency - rfScanData.FrequencyRange.StartFrequency) / rfScanData.StepSize + 1);
+ }
+
private void CommandError(ReadOnlySpan str, string? details = null)
{
if (details != null)
@@ -362,4 +415,73 @@ private void CommandError(ReadOnlySpan str, string? details = null)
Log($"Error while parsing command '{str}'", LogSeverity.Warning);
}
}
+
+ public Task StartRFScan(FrequencyRange range, ulong stepSize)
+ {
+ if (rfScanInProgress != null && !rfScanInProgress.IsCompleted)
+ return rfScanInProgress;
+
+ var startTime = DateTime.UtcNow;
+ var timeout = TimeSpan.FromSeconds(180);
+ return rfScanInProgress = Task.Run(() =>
+ {
+ rfScanData.State = RFScanData.Status.Running;
+ rfScanData.Progress = 0;
+ rfScanData.FrequencyRange = range;
+ rfScanData.StepSize = stepSize;
+ rfScanData.Samples = [];
+
+ receiver.Send($"* METER {receiverNo} ALL 400 *");
+ receiver.Send($"* SCAN RESERVE {receiverNo} xyz *");
+ // Wait for "* SCAN RESERVED n xyz *"
+ // Wait for "* SCAN RESERVE n ACK xyz *"
+ while (DateTime.UtcNow - startTime < timeout)
+ {
+ (string cmd, string args) cmd;
+ while (!scanCommands.TryDequeue(out cmd))
+ scanCommandsSem.Wait(200);
+ if (cmd.cmd == "RESERVE" && cmd.args == "ACK xyz")
+ break;
+ }
+ // Default step size is 25KHz
+ receiver.Send($"* SCAN RANGE {receiverNo} {stepSize/1000} {range.StartFrequency/1000} {range.EndFrequency/1000} *");
+ // Wait for "* SCAN STARTED n *"
+ // Wait for "* RFLEVEL n 10 578000 100 578025 100 578050 100 578075 100 578100 100 578125 100 578150 100 578175 100 578200 100 578225 100 *"
+ // * RFLEVEL n numSamples [freq level]... *
+ // level: is in - dBm
+ // Wait for "* SCAN DONE n *"
+ while (DateTime.UtcNow - startTime < timeout)
+ {
+ (string cmd, string args) cmd;
+ while (!scanCommands.TryDequeue(out cmd))
+ scanCommandsSem.Wait(200);
+ if (cmd.cmd == "DONE")
+ break;
+ }
+ receiver.Send($"* SCAN RELEASE {receiverNo} *");
+
+ // Wait for "* SCAN RELEASED n *"
+ // Wait for "* SCAN IDLE n *"
+ while (DateTime.UtcNow - startTime < timeout)
+ {
+ (string cmd, string args) cmd;
+ while (!scanCommands.TryDequeue(out cmd))
+ {
+ receiver.Send($"* SCAN RELEASE {receiverNo} *");
+ scanCommandsSem.Wait(200);
+ }
+ if (cmd.cmd == "RELEASED")
+ break;
+ }
+ scanCommands.Clear();
+ for (int i = 0; i < scanCommandsSem.CurrentCount; i++)
+ scanCommandsSem.Wait(1);
+ // Start metering again
+ SendStartupCommands();
+
+ rfScanData.State = RFScanData.Status.Completed;
+
+ return rfScanData;
+ });
+ }
}
diff --git a/WirelessMicSuiteServer/WebAPI.cs b/WirelessMicSuiteServer/WebAPI.cs
index 12ec9a8..855fbdd 100644
--- a/WirelessMicSuiteServer/WebAPI.cs
+++ b/WirelessMicSuiteServer/WebAPI.cs
@@ -37,10 +37,11 @@ public static void AddSwaggerGen(IServiceCollection services)
c.MapType(() => new OpenApiSchema { Type = "string" });
c.MapType(() => new OpenApiSchema { Type = "string" });
+ //c.MapType(() => new OpenApiSchema { });
});
}
- public static void AddWebRoots(WebApplication app, WirelessMicManager micManager)
+ public static void AddWebRoots(WebApplication app, WirelessMicManager micManager, WebSocketAPIManager wsAPIManager)
{
#region Getters
app.MapGet("/getWirelessReceivers", (HttpContext ctx) =>
@@ -124,14 +125,40 @@ public static void AddWebRoots(WebApplication app, WirelessMicManager micManager
}).WithName("GetMicMeterAscii")
//.WithGroupName("Getters")
.WithOpenApi();
+
+ app.MapGet("/rfScan/{uid}", (HttpContext ctx, uint uid, ulong? minFreq, ulong? maxFreq, ulong? stepSize = 25000) =>
+ {
+ SetAPIHeaderOptions(ctx);
+ var mic = micManager.TryGetWirelessMic(uid);
+ if (mic == null)
+ return (RFScanData?)null;
+
+ var scan = mic.RFScanData;
+ if (scan.State == RFScanData.Status.Running)
+ {
+ var scanCpy = scan;
+ scanCpy.Samples = [];
+ return scanCpy;
+ }
+ if (minFreq != null && maxFreq != null && stepSize != null)
+ {
+ mic.StartRFScan(new FrequencyRange(minFreq.Value, maxFreq.Value), stepSize.Value);
+ scan.FrequencyRange = new FrequencyRange(minFreq.Value, maxFreq.Value);
+ scan.StepSize = stepSize.Value;
+ scan.Samples = [];
+ scan.Progress = 0;
+ scan.State = RFScanData.Status.Started;
+ }
+
+ return scan;
+ }).WithName("RFScan")
+ //.WithGroupName("Getters")
+ .WithOpenApi();
#endregion
#region Setters
var receiverProps = typeof(IWirelessMicReceiver).GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.SetProperty);
- Dictionary receiverSetters = new(
- receiverProps.Select(x => new KeyValuePair(x.Name, x))
- .Concat(receiverProps.Select(x => new KeyValuePair(CamelCase(x.Name), x)))
- );
+ Dictionary receiverSetters = new(receiverProps.Select(x => new KeyValuePair(x.Name.ToLowerInvariant(), x)));
app.MapGet("/setWirelessMicReceiver/{uid}/{param}/{value}", (uint uid, string param, string value, HttpContext ctx) =>
{
SetAPIHeaderOptions(ctx);
@@ -139,7 +166,7 @@ public static void AddWebRoots(WebApplication app, WirelessMicManager micManager
if (mic == null)
return new APIResult(false, $"Couldn't find wireless mic with UID 0x{uid:X}!");
- if (!receiverSetters.TryGetValue(param, out var prop))
+ if (!receiverSetters.TryGetValue(param.ToLowerInvariant(), out var prop))
return new APIResult(false, $"Property '{param}' does not exist!");
try
@@ -159,10 +186,7 @@ public static void AddWebRoots(WebApplication app, WirelessMicManager micManager
.WithOpenApi();
var micProps = typeof(IWirelessMic).GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.SetProperty);
- Dictionary micSetters = new(
- micProps.Select(x => new KeyValuePair(x.Name, x))
- .Concat(micProps.Select(x => new KeyValuePair(CamelCase(x.Name), x)))
- );
+ Dictionary micSetters = new(micProps.Select(x => new KeyValuePair(x.Name.ToLowerInvariant(), x)));
app.MapGet("/setWirelessMic/{uid}/{param}/{value}", (uint uid, string param, string value, HttpContext ctx) =>
{
SetAPIHeaderOptions(ctx);
@@ -170,7 +194,7 @@ public static void AddWebRoots(WebApplication app, WirelessMicManager micManager
if (mic == null)
return new APIResult(false, $"Couldn't find wireless mic with UID 0x{uid:X}!");
- if (!micSetters.TryGetValue(param, out var prop))
+ if (!micSetters.TryGetValue(param.ToLowerInvariant(), out var prop))
return new APIResult(false, $"Property '{param}' does not exist!");
try
@@ -188,6 +212,25 @@ public static void AddWebRoots(WebApplication app, WirelessMicManager micManager
//.WithGroupName("Setters")
.WithOpenApi();
#endregion
+
+ app.Map("/ws", async (HttpContext ctx) =>
+ {
+ if (!ctx.WebSockets.IsWebSocketRequest)
+ {
+ ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
+ return;
+ }
+
+ var ws = await ctx.WebSockets.AcceptWebSocketAsync();
+ var socketFinishedTcs = new TaskCompletionSource();
+
+ using WebSocketAPI wsAPI = new(ws, socketFinishedTcs, wsAPIManager);
+
+ await socketFinishedTcs.Task;
+ }).WithName("OpenWebSocket")
+ .WithDescription("Opens a new WebSocket for receiving property change notifications and metering data.")
+ //.WithGroupName("Setters")
+ .WithOpenApi();
}
private static void SetAPIHeaderOptions(HttpContext ctx)
diff --git a/WirelessMicSuiteServer/WebSocketAPI.cs b/WirelessMicSuiteServer/WebSocketAPI.cs
new file mode 100644
index 0000000..c189cc8
--- /dev/null
+++ b/WirelessMicSuiteServer/WebSocketAPI.cs
@@ -0,0 +1,335 @@
+using System.Collections.Concurrent;
+using System.Collections.Specialized;
+using System.ComponentModel;
+using System.Diagnostics.CodeAnalysis;
+using System.Net;
+using System.Net.WebSockets;
+using System.Reflection;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Timers;
+using Timer = System.Timers.Timer;
+
+namespace WirelessMicSuiteServer;
+
+public class WebSocketAPI : IDisposable
+{
+ private const int MaxReceiveSize = 0x10000;
+
+ private readonly WebSocket socket;
+ private readonly Task rxTask;
+ private readonly Task txTask;
+ private readonly CancellationToken cancellationToken;
+ private readonly CancellationTokenSource cancellationTokenSource;
+ private readonly Decoder decoder;
+ private readonly Encoder encoder;
+ private readonly byte[] buffer;
+ private readonly char[] charBuffer;
+ private readonly ConcurrentQueue txPipe;
+ private readonly SemaphoreSlim txAvailableSem;
+ private readonly TaskCompletionSource taskCompletion;
+ private readonly WebSocketAPIManager manager;
+
+ public WebSocketAPI(WebSocket webSocket, TaskCompletionSource tcs, WebSocketAPIManager manager)
+ {
+ socket = webSocket;
+ cancellationTokenSource = new ();
+ cancellationToken = cancellationTokenSource.Token;
+ txPipe = new();
+ txAvailableSem = new(0);
+ taskCompletion = tcs;
+
+ Encoding encoding = Encoding.ASCII;
+ buffer = new byte[MaxReceiveSize];
+ charBuffer = new char[encoding.GetMaxCharCount(buffer.Length)];
+ decoder = encoding.GetDecoder();
+ encoder = encoding.GetEncoder();
+
+ Log($"Opened WebSocket API ...");
+
+ rxTask = Task.Run(RxTask);
+ txTask = Task.Run(TxTask);
+
+ this.manager = manager;
+ manager.RegisterWebSocket(this);
+ }
+
+ private readonly ILogger logger = Program.LoggerFac.CreateLogger();
+ public void Log(string? message, LogSeverity severity = LogSeverity.Info)
+ {
+ logger.Log(message, severity);
+ }
+
+ public void Dispose()
+ {
+ manager.UnregisterWebSocket(this);
+ cancellationTokenSource.Cancel();
+ Task.WaitAll([rxTask, txTask], 1000);
+ rxTask.Dispose();
+ txTask.Dispose();
+ cancellationTokenSource.Dispose();
+ taskCompletion.SetResult();
+ }
+
+ private async void RxTask()
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ var res = await socket.ReceiveAsync(new ArraySegment(buffer), cancellationToken);
+ if (!res.EndOfMessage)
+ {
+ Log($"Received incomplete WebSocket message! Ignoring...", LogSeverity.Warning);
+ continue;
+ }
+ if (res.CloseStatus != null)
+ {
+ cancellationTokenSource.Cancel();
+ break;
+ }
+ if (res.MessageType != WebSocketMessageType.Text)
+ {
+ Log($"Unpexpected WebSocket message type '{res.MessageType}'! Ignoring...", LogSeverity.Warning);
+ continue;
+ }
+
+ int charsRead = decoder.GetChars(buffer, 0, res.Count, charBuffer, 0);
+ var str = charBuffer.AsMemory()[..charsRead];
+ Log($"Received: '{str}'", LogSeverity.Debug);
+ }
+ }
+
+ private void TxTask()
+ {
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ byte[] msg;
+ while (!txPipe.TryDequeue(out msg!))
+ txAvailableSem.Wait(1000);
+ socket.SendAsync(msg, WebSocketMessageType.Text, true, cancellationToken)
+ .Wait();
+ }
+ }
+
+ internal void SendMessage(byte[] message)
+ {
+ // I want to be able to send the same byte[] message to multiple clients, so tracking when they've each
+ // finished using the byte[] is difficult, hence we'll rely on the GC. Maybe this could be improved in
+ // the future, profiling needed.
+ txPipe.Enqueue(message);
+ txAvailableSem.Release();
+ }
+}
+
+public class WebSocketAPIManager : IDisposable
+{
+ private readonly Dictionary<(Type type, string propName), (PropertyInfo prop, string normalizedName)> propCache;
+ private readonly WirelessMicManager micManager;
+ private readonly List clients;
+ private readonly Timer meteringTimer;
+ private bool isSendingMeteringMsg;
+
+ public WebSocketAPIManager(WirelessMicManager micManager, int meterInterval)
+ {
+ this.micManager = micManager;
+ clients = [];
+ propCache = [];
+ BuildPropCache();
+
+ ((INotifyCollectionChanged)micManager.Receivers).CollectionChanged += (o, e) =>
+ {
+ switch (e.Action)
+ {
+ case NotifyCollectionChangedAction.Add:
+ if (e.NewItems != null)
+ foreach (IWirelessMicReceiver obj in e.NewItems)
+ RegisterPropertyChangeHandler(obj);
+ break;
+ case NotifyCollectionChangedAction.Remove:
+ if (e.OldItems != null)
+ foreach (IWirelessMicReceiver obj in e.OldItems)
+ UnregisterPropertyChangeHandler(obj);
+ break;
+ case NotifyCollectionChangedAction.Replace:
+ if (e.OldItems != null && e.NewItems != null && e.OldItems.Count == e.NewItems.Count)
+ for (int i = 0; i < e.OldItems.Count; i++)
+ {
+ UnregisterPropertyChangeHandler((IWirelessMicReceiver)e.OldItems[i]!);
+ RegisterPropertyChangeHandler((IWirelessMicReceiver)e.NewItems[i]!);
+ }
+ break;
+ case NotifyCollectionChangedAction.Move:
+ break;
+ case NotifyCollectionChangedAction.Reset:
+ throw new NotSupportedException();
+ default:
+ throw new InvalidOperationException();
+ }
+ };
+
+ isSendingMeteringMsg = false;
+ meteringTimer = new(TimeSpan.FromMilliseconds(meterInterval))
+ {
+ AutoReset = true,
+ Enabled = true,
+ };
+ meteringTimer.Elapsed += MeteringTimer_Elapsed;
+ meteringTimer.Start();
+ }
+
+ private void MeteringTimer_Elapsed(object? sender, ElapsedEventArgs e)
+ {
+ if (isSendingMeteringMsg)
+ return;
+ isSendingMeteringMsg = true;
+ try
+ {
+ var meterData = micManager.WirelessMics.Where(x => x.LastMeterData != null)
+ .Select(x => new MeterInfoNotification(x.UID, x.LastMeterData!.Value));
+ var json = JsonSerializer.SerializeToUtf8Bytes(meterData);
+
+ foreach (var client in clients)
+ client.SendMessage(json);
+ }
+ finally
+ {
+ isSendingMeteringMsg = false;
+ }
+ }
+
+ private void RegisterPropertyChangeHandler(IWirelessMicReceiver receiver)
+ {
+ receiver.PropertyChanged += OnPropertyChanged;
+ foreach (var mic in receiver.WirelessMics)
+ mic.PropertyChanged += OnPropertyChanged;
+ }
+
+ private void UnregisterPropertyChangeHandler(IWirelessMicReceiver receiver)
+ {
+ receiver.PropertyChanged -= OnPropertyChanged;
+ foreach (var mic in receiver.WirelessMics)
+ mic.PropertyChanged -= OnPropertyChanged;
+ }
+
+ private void OnPropertyChanged(object? target, PropertyChangedEventArgs args)
+ {
+ if (args.PropertyName == null || target == null)
+ return;
+
+ uint uid = 0;
+ (PropertyInfo prop, string name) cached;
+ if (target is IWirelessMic mic)
+ {
+ if (!propCache.TryGetValue((typeof(IWirelessMic), args.PropertyName), out cached!))
+ return;
+ uid = mic.UID;
+
+ }
+ else if (target is IWirelessMicReceiver rec)
+ {
+ if (!propCache.TryGetValue((typeof(IWirelessMicReceiver), args.PropertyName), out cached!))
+ return;
+ uid = rec.UID;
+ }
+ else
+ return;
+
+ object? val = cached.prop.GetValue(target);
+
+ var propNotif = new PropertyChangeNotification(cached.name, val, uid);
+ var json = JsonSerializer.SerializeToUtf8Bytes(propNotif);
+
+ foreach (var client in clients)
+ client.SendMessage(json);
+ }
+
+ private void BuildPropCache()
+ {
+ var recProps = typeof(IWirelessMicReceiver).GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.GetProperty);
+ var micProps = typeof(IWirelessMic).GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.GetProperty);
+
+ foreach (var prop in recProps)
+ {
+ if (!CheckProp(prop, out var normalizedName))
+ continue;
+
+ propCache.Add((typeof(IWirelessMicReceiver), prop.Name), (prop, normalizedName));
+ }
+
+ foreach (var prop in micProps)
+ {
+ if (!CheckProp(prop, out var normalizedName))
+ continue;
+
+ propCache.Add((typeof(IWirelessMic), prop.Name), (prop, normalizedName));
+ }
+
+ static bool CheckProp(PropertyInfo prop, [NotNullWhen(true)] out string? normalizedName)
+ {
+ normalizedName = null;
+ if (prop.GetCustomAttribute() != null)
+ return false;
+ if (prop.GetCustomAttribute() != null)
+ return false;
+ normalizedName = JsonNamingPolicy.CamelCase.ConvertName(prop.Name);
+ return true;
+ }
+ }
+
+ public void RegisterWebSocket(WebSocketAPI webSocket)
+ {
+ lock (clients)
+ {
+ clients.Add(webSocket);
+ }
+ }
+
+ public void UnregisterWebSocket(WebSocketAPI webSocket)
+ {
+ lock (clients)
+ {
+ clients.Remove(webSocket);
+ }
+ }
+
+ public void Dispose()
+ {
+ throw new NotImplementedException();
+ }
+}
+
+///
+/// Marks the annotated field to NOT send property change notifications to the WebSocketAPI.
+///
+[AttributeUsage(AttributeTargets.Property, AllowMultiple = false, Inherited = true)]
+public sealed class NotificationIgnoreAttribute : Attribute { }
+
+[Serializable]
+public readonly struct PropertyChangeNotification(string propName, object? value, uint uid)
+{
+ ///
+ /// The name of the property that was updated.
+ ///
+ public readonly string PropertyName { get; init; } = propName;
+ ///
+ /// The new value of the property.
+ ///
+ public readonly object? Value { get; init; } = value;
+ ///
+ /// The UID of the object (microphone or receiver) which was updated.
+ ///
+ public readonly uint UID { get; init; } = uid;
+}
+
+[Serializable]
+public readonly struct MeterInfoNotification(uint uid, MeteringData metering)
+{
+ ///
+ /// The UID of the microphone this data belongs too.
+ ///
+ public readonly uint UID { get; init; } = uid;
+ ///
+ /// The metered values.
+ ///
+ public readonly MeteringData MeteringData { get; init; } = metering;
+}
diff --git a/WirelessMicSuiteServer/WirelessMicManager.cs b/WirelessMicSuiteServer/WirelessMicManager.cs
index 653f5de..38bcfa7 100644
--- a/WirelessMicSuiteServer/WirelessMicManager.cs
+++ b/WirelessMicSuiteServer/WirelessMicManager.cs
@@ -1,4 +1,5 @@
using System.Collections;
+using System.Collections.ObjectModel;
using System.Net;
namespace WirelessMicSuiteServer;
@@ -6,14 +7,55 @@ namespace WirelessMicSuiteServer;
public class WirelessMicManager : IDisposable
{
private readonly List receiverManagers;
+ private readonly ObservableCollection receivers;
- public IEnumerable Receivers => receiverManagers.SelectMany(x=>x.Receivers);
+ //public IEnumerable Receivers => receiverManagers.SelectMany(x=>x.Receivers);
+ //public ObservableCollection Receivers { get; init; }
+ public ReadOnlyObservableCollection Receivers { get; init; }
// TODO: There's a race condition where if receivers get added or removed while this is being enumerated an exception is thrown.
public IEnumerable WirelessMics => new WirelessMicEnumerator(Receivers);
public WirelessMicManager(IEnumerable? receiverManagers)
{
this.receiverManagers = receiverManagers?.ToList() ?? [];
+ receivers = [];
+ Receivers = new ReadOnlyObservableCollection(receivers);
+ foreach (var rm in this.receiverManagers)
+ {
+ // Attempt to synchronise the observable collections
+ rm.Receivers.CollectionChanged += (o, e) =>
+ {
+ switch (e.Action)
+ {
+ case System.Collections.Specialized.NotifyCollectionChangedAction.Add:
+ if (e.NewItems != null)
+ foreach (IWirelessMicReceiver obj in e.NewItems)
+ receivers.Add(obj);
+ break;
+ case System.Collections.Specialized.NotifyCollectionChangedAction.Remove:
+ if (e.OldItems != null)
+ foreach (IWirelessMicReceiver obj in e.OldItems)
+ receivers.Remove(obj);
+ break;
+ case System.Collections.Specialized.NotifyCollectionChangedAction.Replace:
+ if (e.OldItems != null && e.NewItems != null && e.OldItems.Count == e.NewItems.Count)
+ for (int i = 0; i < e.OldItems.Count; i++)
+ {
+ receivers.Remove((IWirelessMicReceiver)e.OldItems[i]!);
+ receivers.Add((IWirelessMicReceiver)e.NewItems[i]!);
+ }
+ break;
+ case System.Collections.Specialized.NotifyCollectionChangedAction.Move:
+ break;
+ case System.Collections.Specialized.NotifyCollectionChangedAction.Reset:
+ throw new NotSupportedException();
+ default:
+ throw new InvalidOperationException();
+ }
+ };
+ }
+ foreach(var r in this.receiverManagers.SelectMany(x => x.Receivers))
+ receivers.Add(r);
}
private readonly ILogger logger = Program.LoggerFac.CreateLogger();
diff --git a/WirelessMicSuiteServer/WirelessMicSuiteServer.csproj b/WirelessMicSuiteServer/WirelessMicSuiteServer.csproj
index cc06228..642119b 100644
--- a/WirelessMicSuiteServer/WirelessMicSuiteServer.csproj
+++ b/WirelessMicSuiteServer/WirelessMicSuiteServer.csproj
@@ -1,11 +1,11 @@
-
+
net8.0
enable
enable
Wireless Mic Suite Server
- 1.0.1
+ 1.1.2
Thomas Mathieson
Copyright Thomas Mathieson 2024
https://github.com/space928/WirelessMicSuiteServer