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