diff --git a/DigiMixer/DigiMixer.AllenAndHeath.Core/AHControlClient.cs b/DigiMixer/DigiMixer.AllenAndHeath.Core/AHControlClient.cs new file mode 100644 index 00000000..21c0b1c0 --- /dev/null +++ b/DigiMixer/DigiMixer.AllenAndHeath.Core/AHControlClient.cs @@ -0,0 +1,20 @@ +using DigiMixer.Core; +using Microsoft.Extensions.Logging; + +namespace DigiMixer.AllenAndHeath.Core; + +public sealed class AHControlClient : TcpMessageProcessingControllerBase +{ + public event EventHandler? MessageReceived; + + public AHControlClient(ILogger logger, string host, int port) : base(logger, host, port, bufferSize: 65540) + { + } + + protected override Task ProcessMessage(AHRawMessage message, CancellationToken cancellationToken) + { + Logger.LogTrace("Received control message: {message}", message); + MessageReceived?.Invoke(this, message); + return Task.CompletedTask; + } +} diff --git a/DigiMixer/DigiMixer.AllenAndHeath.Core/AHMessageFormat.cs b/DigiMixer/DigiMixer.AllenAndHeath.Core/AHMessageFormat.cs new file mode 100644 index 00000000..1634bd94 --- /dev/null +++ b/DigiMixer/DigiMixer.AllenAndHeath.Core/AHMessageFormat.cs @@ -0,0 +1,16 @@ +namespace DigiMixer.AllenAndHeath.Core; + +public enum AHMessageFormat +{ + VariableLength, + + /// + /// Total of 8 bytes: the fixed-length indicator and 7 bytes of data + /// + FixedLength8, + + /// + /// Total of 9 bytes: the fixed-length indicator and 8 bytes of data + /// + FixedLength9 +} diff --git a/DigiMixer/DigiMixer.AllenAndHeath.Core/AHMeterClient.cs b/DigiMixer/DigiMixer.AllenAndHeath.Core/AHMeterClient.cs new file mode 100644 index 00000000..997af6a4 --- /dev/null +++ b/DigiMixer/DigiMixer.AllenAndHeath.Core/AHMeterClient.cs @@ -0,0 +1,49 @@ +using DigiMixer.AllenAndHeath.Core; +using DigiMixer.Core; +using Microsoft.Extensions.Logging; +using System.Buffers; +using System.Net; + +namespace AllenAndHeath.Core; + +public class AHMeterClient : UdpControllerBase, IDisposable +{ + private readonly MemoryPool SendingPool = MemoryPool.Shared; + + public ushort LocalUdpPort { get; } + public event EventHandler? MessageReceived; + + private AHMeterClient(ILogger logger, ushort localUdpPort) : base(logger, localUdpPort) + { + LocalUdpPort = localUdpPort; + } + + public AHMeterClient(ILogger logger) : this(logger, FindAvailableUdpPort()) + { + } + + public async Task SendAsync(AHRawMessage message, IPEndPoint mixerUdpEndPoint, CancellationToken cancellationToken) + { + if (Logger.IsEnabled(LogLevel.Trace)) + { + Logger.LogTrace("Sending keep-alive message"); + } + using var memoryOwner = SendingPool.Rent(message.Length); + var memory = memoryOwner.Memory[..message.Length]; + message.CopyTo(memory.Span); + await Send(memory, mixerUdpEndPoint, cancellationToken); + } + + protected override void ProcessData(ReadOnlySpan data) + { + if (AHRawMessage.TryParse(data) is not AHRawMessage message) + { + return; + } + if (Logger.IsEnabled(LogLevel.Trace)) + { + Logger.LogTrace("Received meter message: {message}", message); + } + MessageReceived?.Invoke(this, message); + } +} diff --git a/DigiMixer/DigiMixer.AllenAndHeath.Core/AHRawMessage.cs b/DigiMixer/DigiMixer.AllenAndHeath.Core/AHRawMessage.cs new file mode 100644 index 00000000..f6804295 --- /dev/null +++ b/DigiMixer/DigiMixer.AllenAndHeath.Core/AHRawMessage.cs @@ -0,0 +1,128 @@ +using DigiMixer.Core; +using System.Buffers.Binary; + +namespace DigiMixer.AllenAndHeath.Core; + +/// +/// A raw, uninterpreted (other than format and type) Allen and Heath message. +/// +public sealed class AHRawMessage : IMixerMessage +{ + private const byte VariableLengthPrefix = 0x7f; + private const byte FixedLengthPrefix = 0xf7; + + public AHMessageFormat Format { get; } + + /// + /// The message type, which is null if and only if the format is FixedLength8 or FixedLength9. + /// + public byte? Type { get; } + + private ReadOnlyMemory data; + + public ReadOnlySpan Data => data.Span; + + private AHRawMessage(AHMessageFormat format, byte? type, ReadOnlyMemory data) + { + Format = format; + Type = type; + this.data = data; + } + + public static AHRawMessage ForFixedLength(ReadOnlyMemory data) + { + var format = data.Length switch + { + 7 => AHMessageFormat.FixedLength8, + 8 => AHMessageFormat.FixedLength9, + _ => throw new ArgumentException() + }; + return new(format, null, data); + } + + public static AHRawMessage ForVariableLength(byte type, ReadOnlyMemory data) => + new(AHMessageFormat.VariableLength, type, data); + + /// + /// Length of the total message, including header. + /// + public int Length => Format switch + { + AHMessageFormat.VariableLength => Data.Length + 6, + AHMessageFormat.FixedLength8 => 8, + AHMessageFormat.FixedLength9 => 9, + _ => throw new InvalidOperationException() + }; + + public static AHRawMessage? TryParse(ReadOnlySpan data) + { + if (data.Length == 0) + { + return null; + } + return data[0] switch + { + VariableLengthPrefix => TryParseVariableLength(data.ToArray()), + FixedLengthPrefix => TryParseFixedLength(data.ToArray()), + _ => throw new ArgumentException($"Invalid data: first byte is 0x{data[0]:x2}") + }; + } + + private static AHRawMessage? TryParseVariableLength(ReadOnlyMemory data) + { + if (data.Length < 6) + { + return null; + } + byte type = data.Span[1]; + int dataLength = BinaryPrimitives.ReadInt32LittleEndian(data[2..6].Span); + if (data.Length < dataLength + 6) + { + return null; + } + return new AHRawMessage(AHMessageFormat.VariableLength, type, data[6..(dataLength + 6)].ToArray()); + } + + private static AHRawMessage? TryParseFixedLength(ReadOnlyMemory data) + { + if (data.Length < 8) + { + return null; + } + // Last of these has only been seen on the SQ... + if ((data.Span[1] == 0x12 && data.Span[3] == 0x23) || + (data.Span[1] == 0x13 && data.Span[3] == 0x16) || + (data.Span[1] == 0x1a && data.Span[2] == 0x1a && data.Span[3] == 0x26)) + { + if (data.Length < 9) + { + return null; + } + return ForFixedLength(data[1..9]); + } + + return ForFixedLength(data[1..8]); + } + + public override string ToString() => $"Type={Type}; Length={Data.Length}"; + + public void CopyTo(Span buffer) + { + switch (Format) + { + case AHMessageFormat.VariableLength: + buffer[0] = VariableLengthPrefix; + buffer[1] = Type!.Value; + BinaryPrimitives.WriteInt32LittleEndian(buffer.Slice(2, 4), Data.Length); + data.Span.CopyTo(buffer.Slice(6)); + break; + case AHMessageFormat.FixedLength8: + case AHMessageFormat.FixedLength9: + buffer[0] = FixedLengthPrefix; + data.Span.CopyTo(buffer.Slice(1)); + break; + default: + throw new InvalidOperationException(); + } + } +} diff --git a/DigiMixer/DigiMixer.AllenAndHeath.Core/DigiMixer.AllenAndHeath.Core.csproj b/DigiMixer/DigiMixer.AllenAndHeath.Core/DigiMixer.AllenAndHeath.Core.csproj new file mode 100644 index 00000000..d63e8663 --- /dev/null +++ b/DigiMixer/DigiMixer.AllenAndHeath.Core/DigiMixer.AllenAndHeath.Core.csproj @@ -0,0 +1,12 @@ + + + + net9.0 + enable + enable + + + + + + diff --git a/DigiMixer/DigiMixer.AppCore/DigiMixerConfig.cs b/DigiMixer/DigiMixer.AppCore/DigiMixerConfig.cs index a225d201..a8c5a446 100644 --- a/DigiMixer/DigiMixer.AppCore/DigiMixerConfig.cs +++ b/DigiMixer/DigiMixer.AppCore/DigiMixerConfig.cs @@ -5,6 +5,7 @@ using DigiMixer.Mackie; using DigiMixer.Osc; using DigiMixer.QuSeries; +using DigiMixer.TfSeries; using DigiMixer.UCNet; using DigiMixer.UiHttp; using Microsoft.Extensions.Logging; @@ -104,6 +105,7 @@ public IMixerApi CreateMixerApi(ILogger logger, MixerApiOptions options = null) MixerHardwareType.StudioLive => StudioLive.CreateMixerApi(logger, Address, Port ?? 53000, options), MixerHardwareType.AllenHeathCq => CqMixer.CreateMixerApi(logger, Address, Port ?? 51326, options), MixerHardwareType.YamahaDm => DmMixer.CreateMixerApi(logger, Address, Port ?? 50368, options), + MixerHardwareType.YamahaTf => TfMixer.CreateMixerApi(logger, Address, Port ?? 50368, options), MixerHardwareType.BehringerWing => WingMixer.CreateMixerApi(logger, Address, Port ?? 2222, options), _ => throw new InvalidOperationException($"Unknown mixer type: {HardwareType}") }; @@ -126,6 +128,7 @@ public enum MixerHardwareType StudioLive, AllenHeathCq, YamahaDm, + YamahaTf, BehringerWing } } diff --git a/DigiMixer/DigiMixer.Diagnostics/AnnotatedMessage.cs b/DigiMixer/DigiMixer.Diagnostics/AnnotatedMessage.cs new file mode 100644 index 00000000..27713d71 --- /dev/null +++ b/DigiMixer/DigiMixer.Diagnostics/AnnotatedMessage.cs @@ -0,0 +1,14 @@ +using DigiMixer.Core; +using System.Net; + +namespace DigiMixer.Diagnostics; + +public record AnnotatedMessage( + TMessage Message, + DateTimeOffset Timestamp, + MessageDirection Direction, + int StreamOffset, + IPAddress SourceAddress, + IPAddress DestinationAddress) where TMessage : class, IMixerMessage +{ +} diff --git a/DigiMixer/DigiMixer.Diagnostics/Hex.cs b/DigiMixer/DigiMixer.Diagnostics/Hex.cs index 93b93a62..3d7a12a7 100644 --- a/DigiMixer/DigiMixer.Diagnostics/Hex.cs +++ b/DigiMixer/DigiMixer.Diagnostics/Hex.cs @@ -1,4 +1,5 @@ using DigiMixer.Core; +using System.Data; using System.Text; namespace DigiMixer.Diagnostics; @@ -61,6 +62,20 @@ public static HexDumpLine ParseHexDumpLine(string line) return new HexDumpLine(direction, offset, data); } + /// + /// Parses hex values, ignoring any spaces. + /// + public static byte[] ParseHex(string text) + { + text = text.Replace(" ", ""); + byte[] data = new byte[text.Length / 2]; + for (int i = 0; i < data.Length; i++) + { + data[i] = Convert.ToByte(text[(i * 2)..(i * 2 + 2)], 16); + } + return data; + } + public record HexDumpLine(Direction Direction, ulong Offset, byte[] Data) { public override string ToString() => diff --git a/DigiMixer/DigiMixer.Diagnostics/IPV4Packet.cs b/DigiMixer/DigiMixer.Diagnostics/IPV4Packet.cs index 8c46ce43..7abadf15 100644 --- a/DigiMixer/DigiMixer.Diagnostics/IPV4Packet.cs +++ b/DigiMixer/DigiMixer.Diagnostics/IPV4Packet.cs @@ -25,6 +25,10 @@ private IPV4Packet(ProtocolType type, IPEndPoint source, IPEndPoint dest, byte[] this.dataOffset = offset; this.dataLength = length; Timestamp = timestamp; + if (data.Length < offset + length || offset < 0 || length < 0) + { + throw new ArgumentOutOfRangeException($"Invalid data/length/offset. Data length: {data.Length}; offset: {offset}; length: {length}; type={type}"); + } } public static IPV4Packet? TryConvert(BlockBase block) @@ -44,12 +48,12 @@ private IPV4Packet(ProtocolType type, IPEndPoint source, IPEndPoint dest, byte[] { return null; } - var ipLength = BinaryPrimitives.ReadUInt16BigEndian(dataSpan.Slice(16)); + var ipLength = BinaryPrimitives.ReadUInt16BigEndian(dataSpan[16..]); var type = (ProtocolType) data[23]; - IPAddress sourceAddress = new IPAddress(dataSpan.Slice(26, 4)); - IPAddress destAddress = new IPAddress(dataSpan.Slice(30, 4)); - int sourcePort = BinaryPrimitives.ReadUInt16BigEndian(dataSpan.Slice(34)); - int destPort = BinaryPrimitives.ReadUInt16BigEndian(dataSpan.Slice(36)); + IPAddress sourceAddress = new(dataSpan.Slice(26, 4)); + IPAddress destAddress = new(dataSpan.Slice(30, 4)); + int sourcePort = BinaryPrimitives.ReadUInt16BigEndian(dataSpan[34..]); + int destPort = BinaryPrimitives.ReadUInt16BigEndian(dataSpan[36..]); int dataOffset; int dataLength; @@ -57,12 +61,23 @@ private IPV4Packet(ProtocolType type, IPEndPoint source, IPEndPoint dest, byte[] { dataOffset = 42; dataLength = BinaryPrimitives.ReadUInt16BigEndian(dataSpan[38..]) - 8; // The header includes its own length + // Ignore fragmented packets + if (data.Length < dataOffset + dataLength) + { + return null; + } } else if (type == ProtocolType.Tcp) { int headerLength = (data[46] & 0xf0) >> 2; dataOffset = 34 + headerLength; dataLength = ipLength - (dataOffset - 14); + + // Handle TCP segmentation offload + if (ipLength == 0) + { + dataLength = data.Length - dataOffset; + } } else { diff --git a/DigiMixer/DigiMixer.Diagnostics/MessageDirection.cs b/DigiMixer/DigiMixer.Diagnostics/MessageDirection.cs new file mode 100644 index 00000000..a24a753a --- /dev/null +++ b/DigiMixer/DigiMixer.Diagnostics/MessageDirection.cs @@ -0,0 +1,7 @@ +namespace DigiMixer.Diagnostics; + +public enum MessageDirection +{ + ClientToMixer = 0, + MixerToClient = 1 +} diff --git a/DigiMixer/DigiMixer.Diagnostics/NullExtensions.cs b/DigiMixer/DigiMixer.Diagnostics/NullExtensions.cs new file mode 100644 index 00000000..c5e682ec --- /dev/null +++ b/DigiMixer/DigiMixer.Diagnostics/NullExtensions.cs @@ -0,0 +1,16 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace DigiMixer.Diagnostics; + +/// +/// Extension methods to make working with null values (and nullable reference types) simpler. +/// +public static class NullExtensions +{ + public static T OrThrow([NotNull] this T? text, [CallerArgumentExpression(nameof(text))] string? message = null) where T : class => + text ?? throw new InvalidDataException($"No value for '{message}'"); + + public static T OrThrow([NotNull] this T? text, [CallerArgumentExpression(nameof(text))] string? message = null) where T : struct => + text ?? throw new InvalidDataException($"No value for '{message}'"); +} diff --git a/DigiMixer/DigiMixer.Diagnostics/Tool.cs b/DigiMixer/DigiMixer.Diagnostics/Tool.cs index a14c226a..758355fa 100644 --- a/DigiMixer/DigiMixer.Diagnostics/Tool.cs +++ b/DigiMixer/DigiMixer.Diagnostics/Tool.cs @@ -37,7 +37,7 @@ public static async Task ExecuteFromCommandLine(string[] args, Type typeInA } return 1; } - var tool = (Tool) ctor.Invoke(args.Skip(1).ToArray()); + var tool = (Tool) ctor.Invoke([..args.Skip(1)]); return await tool.Execute(); } diff --git a/DigiMixer/DigiMixer.Diagnostics/WiresharkDump.cs b/DigiMixer/DigiMixer.Diagnostics/WiresharkDump.cs index e7af65fc..7bbdf9d8 100644 --- a/DigiMixer/DigiMixer.Diagnostics/WiresharkDump.cs +++ b/DigiMixer/DigiMixer.Diagnostics/WiresharkDump.cs @@ -1,4 +1,9 @@ -using PcapngFile; +using DigiMixer.Core; +using PcapngFile; +using System.Collections.Concurrent; +using System.Collections.Immutable; +using System.Net; +using System.Net.Sockets; namespace DigiMixer.Diagnostics; @@ -15,10 +20,57 @@ private WiresharkDump(IReadOnlyList blocks) public static WiresharkDump Load(string filename) { - using (var reader = new Reader(filename)) + using var reader = new Reader(filename); + var blocks = reader.AllBlocks.ToList(); + return new WiresharkDump(blocks); + } + + public async Task>> ProcessMessages(string mixerIpAddress, string clientIpAddress) where TMessage : class, IMixerMessage + { + // Currently we assume a single client, a single mixer, and a single stream of messages between the two. + IPAddress clientAddr = IPAddress.Parse(clientIpAddress); + IPAddress mixerAddr = IPAddress.Parse(mixerIpAddress); + + DateTime? currentTimestamp = null; + IPAddress? currentSource = null; + IPAddress? currentDestination = null; + + List> messages = []; + + int outboundOffset = 0; + int inboundOffset = 0; + var outboundProcessor = new MessageProcessor(m => AddMessage(m, ref outboundOffset), 1024 * 1024); + var inboundProcessor = new MessageProcessor(m => AddMessage(m, ref inboundOffset), 1024 * 1024); + + foreach (var packet in IPV4Packets) + { + if (packet.Type != ProtocolType.Tcp) + { + continue; + } + + currentTimestamp = packet.Timestamp; + currentSource = packet.Source.Address; + currentDestination = packet.Dest.Address; + if (packet.Source.Address.Equals(clientAddr) && packet.Dest.Address.Equals(mixerAddr)) + { + await outboundProcessor.Process(packet.Data, default); + } + else if (packet.Source.Address.Equals(mixerAddr) && packet.Dest.Address.Equals(clientAddr)) + { + await inboundProcessor.Process(packet.Data, default); + } + } + return [.. messages]; + + void AddMessage(TMessage message, ref int offset) { - var blocks = reader.AllBlocks.ToList(); - return new WiresharkDump(blocks); + var direction = currentSource.OrThrow().Equals(clientAddr) ? + MessageDirection.ClientToMixer : MessageDirection.MixerToClient; + var annotated = new AnnotatedMessage(message, currentTimestamp.OrThrow(), direction, + offset, currentSource.OrThrow(), currentDestination.OrThrow()); + messages.Add(annotated); + offset += message.Length; } } } diff --git a/DigiMixer/DigiMixer.DmSeries.Core/DmBinarySegment.cs b/DigiMixer/DigiMixer.DmSeries.Core/DmBinarySegment.cs index fdeede2f..d7900889 100644 --- a/DigiMixer/DigiMixer.DmSeries.Core/DmBinarySegment.cs +++ b/DigiMixer/DigiMixer.DmSeries.Core/DmBinarySegment.cs @@ -42,13 +42,13 @@ public static DmBinarySegment FromHex(string text) public override void WriteTo(Span buffer) { buffer[0] = (byte) Format; - BinaryPrimitives.WriteInt32BigEndian(buffer.Slice(1), data.Length); - Data.CopyTo(buffer.Slice(5)); + BinaryPrimitives.WriteInt32BigEndian(buffer[1..], data.Length); + Data.CopyTo(buffer[5..]); } public static DmBinarySegment Parse(ReadOnlySpan buffer) { - var dataLength = BinaryPrimitives.ReadInt32BigEndian(buffer.Slice(1)); + var dataLength = BinaryPrimitives.ReadInt32BigEndian(buffer[1..]); var bytes = new byte[dataLength]; buffer.Slice(5, dataLength).CopyTo(bytes); return new DmBinarySegment(bytes); diff --git a/DigiMixer/DigiMixer.DmSeries.Core/DmMessage.cs b/DigiMixer/DigiMixer.DmSeries.Core/DmMessage.cs index 3cbd614e..7a42674b 100644 --- a/DigiMixer/DigiMixer.DmSeries.Core/DmMessage.cs +++ b/DigiMixer/DigiMixer.DmSeries.Core/DmMessage.cs @@ -51,14 +51,14 @@ public DmMessage(string type, uint flags, ImmutableList segments) { throw new InvalidDataException("Expected overall container with format 0x11"); } - var containerLength = BinaryPrimitives.ReadInt32BigEndian(body.Slice(1)); + var containerLength = BinaryPrimitives.ReadInt32BigEndian(body[1..]); if (containerLength != bodyLength - 5) { throw new InvalidDataException($"Expected overall container internal length {bodyLength - 5}; was {containerLength}"); } var segments = new List(); - var flags = BinaryPrimitives.ReadUInt32BigEndian(body.Slice(5)); - var nextSegmentData = body.Slice(9); + var flags = BinaryPrimitives.ReadUInt32BigEndian(body[5..]); + var nextSegmentData = body[9..]; while (nextSegmentData.Length > 0) { var format = (DmSegmentFormat) nextSegmentData[0]; @@ -72,26 +72,26 @@ public DmMessage(string type, uint flags, ImmutableList segments) _ => throw new InvalidDataException($"Unexpected segment format {nextSegmentData[0]:x2}") }; segments.Add(segment); - nextSegmentData = nextSegmentData.Slice(segment.Length); + nextSegmentData = nextSegmentData[segment.Length..]; } - return new DmMessage(type, flags, segments.ToImmutableList()); + return new DmMessage(type, flags, [.. segments]); } public void CopyTo(Span buffer) { // Note: we assume the span is right-sized to Length. Encoding.ASCII.GetBytes(Type, buffer); - BinaryPrimitives.WriteInt32BigEndian(buffer.Slice(4), buffer.Length - 8); + BinaryPrimitives.WriteInt32BigEndian(buffer[4..], buffer.Length - 8); buffer[8] = (byte) DmSegmentFormat.Binary; - BinaryPrimitives.WriteInt32BigEndian(buffer.Slice(9), buffer.Length - 13); - BinaryPrimitives.WriteUInt32BigEndian(buffer.Slice(13), Flags); - buffer = buffer.Slice(17); + BinaryPrimitives.WriteInt32BigEndian(buffer[9..], buffer.Length - 13); + BinaryPrimitives.WriteUInt32BigEndian(buffer[13..], Flags); + buffer = buffer[17..]; foreach (var segment in Segments) { segment.WriteTo(buffer); - buffer = buffer.Slice(segment.Length); + buffer = buffer[segment.Length..]; } } - public override string ToString() => $"{Type.PadRight(4)}: Flags={Flags:x8}; Segments={Segments.Count}"; + public override string ToString() => $"{Type,-4}: Flags={Flags:x8}; Segments={Segments.Count}"; } diff --git a/DigiMixer/DigiMixer.DmSeries.Core/DmUInt32Segment.cs b/DigiMixer/DigiMixer.DmSeries.Core/DmUInt32Segment.cs index 123c3502..5263d237 100644 --- a/DigiMixer/DigiMixer.DmSeries.Core/DmUInt32Segment.cs +++ b/DigiMixer/DigiMixer.DmSeries.Core/DmUInt32Segment.cs @@ -19,21 +19,21 @@ public DmUInt32Segment(ImmutableList values) public override void WriteTo(Span buffer) { buffer[0] = (byte) Format; - BinaryPrimitives.WriteInt32BigEndian(buffer.Slice(1), Values.Count); + BinaryPrimitives.WriteInt32BigEndian(buffer[1..], Values.Count); for (int i = 0; i < Values.Count; i++) { - BinaryPrimitives.WriteUInt32BigEndian(buffer.Slice(5 + i * 4), Values[i]); + BinaryPrimitives.WriteUInt32BigEndian(buffer[(5 + i * 4)..], Values[i]); } } public static DmUInt32Segment Parse(ReadOnlySpan buffer) { - var valueCount = BinaryPrimitives.ReadInt32BigEndian(buffer.Slice(1)); + var valueCount = BinaryPrimitives.ReadInt32BigEndian(buffer[1..]); var values = new uint[valueCount]; for (int i = 0; i < valueCount; i++) { - values[i] = BinaryPrimitives.ReadUInt32BigEndian(buffer.Slice(5 + i * 4)); + values[i] = BinaryPrimitives.ReadUInt32BigEndian(buffer[(5 + i * 4)..]); } - return new DmUInt32Segment(values.ToImmutableList()); + return new DmUInt32Segment([.. values]); } } diff --git a/DigiMixer/DigiMixer.DmSeries.Tools/DmMessageExtensions.cs b/DigiMixer/DigiMixer.DmSeries.Tools/DmMessageExtensions.cs index 548eba05..03bbcc71 100644 --- a/DigiMixer/DigiMixer.DmSeries.Tools/DmMessageExtensions.cs +++ b/DigiMixer/DigiMixer.DmSeries.Tools/DmMessageExtensions.cs @@ -36,7 +36,7 @@ static string DescribeSegment(DmSegment segment) case DmBinarySegment binary: var data = binary.Data; var hexLength = Math.Min(data.Length, 16); - var hex = Formatting.ToHex(data.Slice(0, hexLength)) + (hexLength == data.Length ? "" : " [...]"); + var hex = Formatting.ToHex(data[..hexLength]) + (hexLength == data.Length ? "" : " [...]"); return $"Binary[{data.Length}]: {hex}"; case DmTextSegment text: return $"Text: '{text.Text}'"; diff --git a/DigiMixer/DigiMixer.DmSeries.Tools/MmixFullDataDiff.cs b/DigiMixer/DigiMixer.DmSeries.Tools/MmixFullDataDiff.cs index 2fb4d37c..7f313b73 100644 --- a/DigiMixer/DigiMixer.DmSeries.Tools/MmixFullDataDiff.cs +++ b/DigiMixer/DigiMixer.DmSeries.Tools/MmixFullDataDiff.cs @@ -38,7 +38,7 @@ private static void DiffSnapshot(byte[]? currentSnapshot, byte[] newSnapshot) return; } - List differences = new(); + List differences = []; for (int i = 0; i < currentSnapshot.Length; i++) { if (newSnapshot[i] != currentSnapshot[i]) diff --git a/DigiMixer/DigiMixer.DmSeries/DmMessages.cs b/DigiMixer/DigiMixer.DmSeries/DmMessages.cs index 3cf80adc..7ba51d5a 100644 --- a/DigiMixer/DigiMixer.DmSeries/DmMessages.cs +++ b/DigiMixer/DigiMixer.DmSeries/DmMessages.cs @@ -35,10 +35,10 @@ public static class Subtypes public static DmBinarySegment Empty16ByteBinarySegment { get; } = new DmBinarySegment(new byte[16]); - public static DmMessage RequestData(string type, string subtype) => new DmMessage( + public static DmMessage RequestData(string type, string subtype) => new( type, flags: 0x01010102, [new DmTextSegment(subtype), new DmBinarySegment([0x80])]); - public static DmMessage UnrequestData(string type, string subtype) => new DmMessage( + public static DmMessage UnrequestData(string type, string subtype) => new( type, flags: 0x01010102, [new DmTextSegment(subtype), new DmBinarySegment([0x00])]); internal static bool IsKeepAlive(DmMessage message) => diff --git a/DigiMixer/DigiMixer.DmSeries/DmMixer.cs b/DigiMixer/DigiMixer.DmSeries/DmMixer.cs index 6dc31ff6..cf8d1fc2 100644 --- a/DigiMixer/DigiMixer.DmSeries/DmMixer.cs +++ b/DigiMixer/DigiMixer.DmSeries/DmMixer.cs @@ -86,11 +86,11 @@ public async Task DetectConfiguration(CancellationTok { throw new InvalidOperationException("Cannot detect configuration until connected"); } - var data = await fullDataTask; + await fullDataTask; // TODO: Lots more! Including the stereo flags... we may not get everything we need here. var inputs = DmChannels.AllInputs; var outputs = DmChannels.AllOutputs; - return new MixerChannelConfiguration(inputs, outputs, new[] { StereoPair.FromLeft(ChannelId.MainOutputLeft, StereoFlags.FullyIndependent) }); + return new MixerChannelConfiguration(inputs, outputs, [StereoPair.FromLeft(ChannelId.MainOutputLeft, StereoFlags.FullyIndependent)]); } public void Dispose() diff --git a/DigiMixer/DigiMixer.DmSeries/FullChannelDataMessage.cs b/DigiMixer/DigiMixer.DmSeries/FullChannelDataMessage.cs index f67bf815..4f239108 100644 --- a/DigiMixer/DigiMixer.DmSeries/FullChannelDataMessage.cs +++ b/DigiMixer/DigiMixer.DmSeries/FullChannelDataMessage.cs @@ -16,7 +16,7 @@ internal FullChannelDataMessage(DmMessage message) data = (DmBinarySegment) message.Segments[7]; } - private int GetStartOffset(ChannelId channel) => channel switch + private static int GetStartOffset(ChannelId channel) => channel switch { { IsInput: true, Value: int ch } => (ch - 1) * 0x1cf + 0x6088, { IsMainOutput: true, Value: int ch } => (ch - ChannelId.MainOutputLeft.Value) * 0x190 + 0x959e, diff --git a/DigiMixer/DigiMixer.Hardware/DigiMixer.Hardware.csproj b/DigiMixer/DigiMixer.Hardware/DigiMixer.Hardware.csproj index e3037925..7e216ea5 100644 --- a/DigiMixer/DigiMixer.Hardware/DigiMixer.Hardware.csproj +++ b/DigiMixer/DigiMixer.Hardware/DigiMixer.Hardware.csproj @@ -28,6 +28,7 @@ + @@ -39,10 +40,14 @@ + + + + diff --git a/DigiMixer/DigiMixer.Mackie.Tools/Message.cs b/DigiMixer/DigiMixer.Mackie.Tools/Message.cs index da8b07f3..1108a4c9 100644 --- a/DigiMixer/DigiMixer.Mackie.Tools/Message.cs +++ b/DigiMixer/DigiMixer.Mackie.Tools/Message.cs @@ -6,7 +6,7 @@ namespace DigiMixer.Mackie.Tools; public partial class Message { public static Message FromMackieMessage(MackieMessage message, bool outbound, DateTimeOffset? timestamp) => - new Message + new() { Outbound = outbound, Command = (int) message.Command, diff --git a/DigiMixer/DigiMixer.SqSeries.Core/DigiMixer.SqSeries.Core.csproj b/DigiMixer/DigiMixer.SqSeries.Core/DigiMixer.SqSeries.Core.csproj new file mode 100644 index 00000000..a4886fab --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries.Core/DigiMixer.SqSeries.Core.csproj @@ -0,0 +1,12 @@ + + + + net9.0 + enable + enable + + + + + + diff --git a/DigiMixer/DigiMixer.SqSeries.Core/SqControlClient.cs b/DigiMixer/DigiMixer.SqSeries.Core/SqControlClient.cs new file mode 100644 index 00000000..1dc7411c --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries.Core/SqControlClient.cs @@ -0,0 +1,5 @@ +namespace DigiMixer.SqSeries.Core; + +public sealed class SqControlClient +{ +} diff --git a/DigiMixer/DigiMixer.SqSeries.Core/SqMessageFormat.cs b/DigiMixer/DigiMixer.SqSeries.Core/SqMessageFormat.cs new file mode 100644 index 00000000..79065ed4 --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries.Core/SqMessageFormat.cs @@ -0,0 +1,16 @@ +namespace DigiMixer.SqSeries.Core; + +public enum SqMessageFormat +{ + VariableLength, + + /// + /// Total of 8 bytes: the fixed-length indicator and 7 bytes of data + /// + FixedLength8, + + /// + /// Total of 9 bytes: the fixed-length indicator and 8 bytes of data + /// + FixedLength9 +} diff --git a/DigiMixer/DigiMixer.SqSeries.Core/SqRawMessage.cs b/DigiMixer/DigiMixer.SqSeries.Core/SqRawMessage.cs new file mode 100644 index 00000000..b4a33cb6 --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries.Core/SqRawMessage.cs @@ -0,0 +1,129 @@ +using DigiMixer.Core; +using System.Buffers.Binary; + +namespace DigiMixer.SqSeries.Core; + +/// +/// A raw, uninterpreted (other than format and type) Sq message. +/// +public sealed class AHRawMessage : IMixerMessage +{ + private const byte VariableLengthPrefix = 0x7f; + private const byte FixedLengthPrefix = 0xf7; + + public SqMessageFormat Format { get; } + + /// + /// The message type, which is null if and only if the format is VariableLengthmessages. + /// + public SqMessageType? Type { get; } + + private ReadOnlyMemory data; + + public ReadOnlySpan Data => data.Span; + + private AHRawMessage(SqMessageFormat format, SqMessageType? type, ReadOnlyMemory data) + { + Format = format; + Type = type; + this.data = data; + } + + internal static AHRawMessage ForFixedLength(ReadOnlyMemory data) + { + var format = data.Length switch + { + 7 => SqMessageFormat.FixedLength8, + 8 => SqMessageFormat.FixedLength9, + }; + return new(format, null, data); + } + + internal static AHRawMessage ForVariableLength(SqMessageType type, ReadOnlyMemory data) => + new(SqMessageFormat.VariableLength, type, data); + + /// + /// Length of the total message, including header. + /// + public int Length => Format switch + { + SqMessageFormat.VariableLength => Data.Length + 6, + SqMessageFormat.FixedLength8 => 8, + SqMessageFormat.FixedLength9 => 9, + _ => throw new InvalidOperationException() + }; + + public static AHRawMessage? TryParse(ReadOnlySpan data) + { + Console.WriteLine($"Parsing {data.Length} bytes"); + if (data.Length == 0) + { + return null; + } + return data[0] switch + { + VariableLengthPrefix => TryParseVariableLength(data.ToArray()), + FixedLengthPrefix => TryParseFixedLength(data.ToArray()), + _ => throw new ArgumentException($"Invalid data: first byte is 0x{data[0]:x2}") + }; + } + + private static AHRawMessage? TryParseVariableLength(ReadOnlyMemory data) + { + if (data.Length < 6) + { + return null; + } + SqMessageType type = (SqMessageType) data.Span[1]; + int dataLength = BinaryPrimitives.ReadInt32LittleEndian(data[2..6].Span); + if (data.Length < dataLength + 6) + { + return null; + } + return new AHRawMessage(SqMessageFormat.VariableLength, type, data[6..(dataLength + 6)].ToArray()); + } + + private static AHRawMessage? TryParseFixedLength(ReadOnlyMemory data) + { + if (data.Length < 8) + { + return null; + } + // Last of these has only been seen on the SQ... + if ((data.Span[1] == 0x12 && data.Span[3] == 0x23) || + (data.Span[1] == 0x13 && data.Span[3] == 0x16) || + (data.Span[1] == 0x1a && data.Span[2] == 0x1a && data.Span[3] == 0x26)) + { + if (data.Length < 9) + { + return null; + } + return ForFixedLength(data[1..9]); + } + + return ForFixedLength(data[1..8]); + } + + public override string ToString() => $"Type={Type}; Length={Data.Length}"; + + public void CopyTo(Span buffer) + { + switch (Format) + { + case SqMessageFormat.VariableLength: + buffer[0] = VariableLengthPrefix; + buffer[1] = (byte) Type.Value; + BinaryPrimitives.WriteInt32LittleEndian(buffer.Slice(2, 4), Data.Length); + data.Span.CopyTo(buffer.Slice(6)); + break; + case SqMessageFormat.FixedLength8: + case SqMessageFormat.FixedLength9: + buffer[0] = FixedLengthPrefix; + data.Span.CopyTo(buffer.Slice(1)); + break; + default: + throw new InvalidOperationException(); + } + + } +} diff --git a/DigiMixer/DigiMixer.SqSeries.Tools/ClientInitialMessages.cs b/DigiMixer/DigiMixer.SqSeries.Tools/ClientInitialMessages.cs new file mode 100644 index 00000000..2b620aa8 --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries.Tools/ClientInitialMessages.cs @@ -0,0 +1,42 @@ +using AllenAndHeath.Core; +using DigiMixer.AllenAndHeath.Core; +using DigiMixer.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; + +namespace DigiMixer.SqSeries.Tools; + +public class ClientInitialMessages : Tool +{ + public override async Task Execute() + { + var meterClient = new AHMeterClient(NullLogger.Instance); + var controlClient = new AHControlClient(NullLogger.Instance, "192.168.1.56", 51326); + + controlClient.MessageReceived += LogMessage; + await controlClient.Connect(default); + controlClient.Start(); + + await SendAsync(new SqUdpHandshakeMessage(meterClient.LocalUdpPort)); + await Task.Delay(500); + await SendAsync(new SqVersionRequestMessage()); + await Task.Delay(500); + await SendAsync(new SqClientInitRequestMessage()); + await Task.Delay(500); + await SendAsync(new SqSimpleRequestMessage(SqMessageType.FullDataRequest)); + await Task.Delay(500); + await SendAsync(new SqSimpleRequestMessage(SqMessageType.Type15Request)); + await Task.Delay(500); + await SendAsync(new SqSimpleRequestMessage(SqMessageType.Type17Request)); + await Task.Delay(5000); + + return 0; + + Task SendAsync (SqMessage message) => controlClient.SendAsync(message.RawMessage, default); + } + + void LogMessage(object? sender, AHRawMessage message) + { + var sqMessage = SqMessage.FromRawMessage(message); + Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff}: {sqMessage}"); + } +} diff --git a/DigiMixer/DigiMixer.SqSeries.Tools/ConvertAllWireshark.cs b/DigiMixer/DigiMixer.SqSeries.Tools/ConvertAllWireshark.cs new file mode 100644 index 00000000..c78300d3 --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries.Tools/ConvertAllWireshark.cs @@ -0,0 +1,20 @@ +using DigiMixer.Diagnostics; + +namespace DigiMixer.SqSeries.Tools; + +public class ConvertAllWireshark(string directory) : Tool +{ + public override async Task Execute() + { + foreach (var file in Directory.GetFiles(directory, "*.pcapng")) + { + var singleFileTool = new ConvertWireshark(file); + var result = await singleFileTool.Execute(); + if (result != 0) + { + return result; + } + } + return 0; + } +} diff --git a/DigiMixer/DigiMixer.SqSeries.Tools/ConvertWireshark.cs b/DigiMixer/DigiMixer.SqSeries.Tools/ConvertWireshark.cs new file mode 100644 index 00000000..8cae3f9c --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries.Tools/ConvertWireshark.cs @@ -0,0 +1,22 @@ +using DigiMixer.Diagnostics; +using DigiMixer.AllenAndHeath.Core; +namespace DigiMixer.SqSeries.Tools; + +public class ConvertWireshark(string file) : Tool +{ + public override async Task Execute() + { + var dump = WiresharkDump.Load(file); + var messages = await dump.ProcessMessages("192.168.1.56", "192.168.1.140"); + + var dir = Path.GetDirectoryName(file).OrThrow(); + var newName = Path.GetFileNameWithoutExtension(file) + " decoded.txt"; + using var writer = File.CreateText(Path.Combine(dir, newName)); + foreach (var message in messages) + { + message.DisplayStructure(writer); + } + Console.WriteLine($"Saved {newName}"); + return 0; + } +} diff --git a/DigiMixer/DigiMixer.SqSeries.Tools/DigiMixer.SqSeries.Tools.csproj b/DigiMixer/DigiMixer.SqSeries.Tools/DigiMixer.SqSeries.Tools.csproj new file mode 100644 index 00000000..39193e9f --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries.Tools/DigiMixer.SqSeries.Tools.csproj @@ -0,0 +1,15 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + diff --git a/DigiMixer/DigiMixer.SqSeries.Tools/Program.cs b/DigiMixer/DigiMixer.SqSeries.Tools/Program.cs new file mode 100644 index 00000000..dda3891e --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries.Tools/Program.cs @@ -0,0 +1,3 @@ +using DigiMixer.Diagnostics; + +await Tool.ExecuteFromCommandLine(args, typeof(Program)); diff --git a/DigiMixer/DigiMixer.SqSeries.Tools/SqRawMessageExtensions.cs b/DigiMixer/DigiMixer.SqSeries.Tools/SqRawMessageExtensions.cs new file mode 100644 index 00000000..705466c0 --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries.Tools/SqRawMessageExtensions.cs @@ -0,0 +1,15 @@ +using DigiMixer.Diagnostics; +using DigiMixer.AllenAndHeath.Core; + +namespace DigiMixer.SqSeries.Tools; + +internal static class AHRawMessageExtensions +{ + internal static void DisplayStructure(this AnnotatedMessage annotatedMessage, TextWriter writer) + { + var message = annotatedMessage.Message; + string directionIndicator = annotatedMessage.Direction == MessageDirection.ClientToMixer ? "=>" : "<="; + var sqMessage = SqMessage.FromRawMessage(message); + writer.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff}: {directionIndicator} 0x{annotatedMessage.StreamOffset:x8} {sqMessage}"); + } +} diff --git a/DigiMixer/DigiMixer.SqSeries/AssemblyInfo.cs b/DigiMixer/DigiMixer.SqSeries/AssemblyInfo.cs new file mode 100644 index 00000000..359712f2 --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DigiMixer.SqSeries.Tools")] \ No newline at end of file diff --git a/DigiMixer/DigiMixer.SqSeries/DigiMixer.SqSeries.csproj b/DigiMixer/DigiMixer.SqSeries/DigiMixer.SqSeries.csproj new file mode 100644 index 00000000..258223a1 --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries/DigiMixer.SqSeries.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/DigiMixer/DigiMixer.SqSeries/SqClientInitRequestMessage.cs b/DigiMixer/DigiMixer.SqSeries/SqClientInitRequestMessage.cs new file mode 100644 index 00000000..6307f1d0 --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries/SqClientInitRequestMessage.cs @@ -0,0 +1,19 @@ +using DigiMixer.AllenAndHeath.Core; + +namespace DigiMixer.SqSeries; + +internal class SqClientInitRequestMessage : SqMessage +{ + // We don't know what this means at the moment, but it's always 1... + public ushort ClientValue => GetUInt16(0); + + internal SqClientInitRequestMessage() : base(SqMessageType.ClientInitRequest, [2, 0]) + { + } + + internal SqClientInitRequestMessage(AHRawMessage rawMessage) : base(rawMessage) + { + } + + public override string ToString() => $"Type={Type}; ClientValue={ClientValue}"; +} diff --git a/DigiMixer/DigiMixer.SqSeries/SqClientInitResponseMessage.cs b/DigiMixer/DigiMixer.SqSeries/SqClientInitResponseMessage.cs new file mode 100644 index 00000000..0b2e57e7 --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries/SqClientInitResponseMessage.cs @@ -0,0 +1,15 @@ +using DigiMixer.AllenAndHeath.Core; + +namespace DigiMixer.SqSeries; + +public class SqClientInitResponseMessage : SqMessage +{ + // We don't know what this means at the moment, but it's always 1... + public ushort MixerValue => GetUInt16(0); + + internal SqClientInitResponseMessage(AHRawMessage rawMessage) : base(rawMessage) + { + } + + public override string ToString() => $"Type={Type}; MixerValue={MixerValue}"; +} diff --git a/DigiMixer/DigiMixer.SqSeries/SqMessage.cs b/DigiMixer/DigiMixer.SqSeries/SqMessage.cs new file mode 100644 index 00000000..76097337 --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries/SqMessage.cs @@ -0,0 +1,53 @@ +using DigiMixer.AllenAndHeath.Core; +using DigiMixer.Core; +using System.Buffers.Binary; +using System.Text; + +namespace DigiMixer.SqSeries; + +/// +/// Base class for type-specific messages. These wrap . +/// +public abstract class SqMessage(AHRawMessage rawMessage) +{ + public AHRawMessage RawMessage { get; } = rawMessage; + + public ReadOnlySpan Data => RawMessage.Data; + public SqMessageType? Type => (SqMessageType?) RawMessage.Type; + + public override string ToString() => Type is null ? $"Fixed: {Formatting.ToHex(Data)}" : $"Type={Type}; Length={Data.Length}"; + + protected SqMessage(SqMessageType type, byte[] data) : this(AHRawMessage.ForVariableLength((byte) type, data)) + { + } + + internal ushort GetUInt16(int index) => BinaryPrimitives.ReadUInt16LittleEndian(Data[index..]); + + internal string? GetString(int offset, int maxLength) + { + int length = Data.Slice(offset, maxLength).IndexOf((byte) 0); + if (length == -1) + { + length = maxLength; + } + return length == 0 ? null : Encoding.ASCII.GetString(Data.Slice(offset, length)); + } + + public static SqMessage FromRawMessage(AHRawMessage rawMessage) => (SqMessageType?) rawMessage.Type switch + { + SqMessageType.UdpHandshake => new SqUdpHandshakeMessage(rawMessage), + //SqMessageType.Regular => new SqRegularMessage(rawMessage), + //SqMessageType.KeepAlive => new SqKeepAliveMessage(rawMessage), + SqMessageType.VersionRequest => new SqVersionRequestMessage(rawMessage), + SqMessageType.VersionResponse => new SqVersionResponseMessage(rawMessage), + SqMessageType.ClientInitRequest => new SqClientInitRequestMessage(rawMessage), + SqMessageType.ClientInitResponse => new SqClientInitResponseMessage(rawMessage), + SqMessageType.FullDataRequest => new SqSimpleRequestMessage(rawMessage), + SqMessageType.Type15Request => new SqSimpleRequestMessage(rawMessage), + SqMessageType.Type17Request => new SqSimpleRequestMessage(rawMessage), + //SqMessageType.FullDataResponse => new SqFullDataResponseMessage(rawMessage), + //SqMessageType.InputMeters => new SqInputMetersMessage(rawMessage), + //SqMessageType.OutputMeters => new SqOutputMetersMessage(rawMessage), + _ => new SqUnknownMessage(rawMessage) + }; +} diff --git a/DigiMixer/DigiMixer.SqSeries/SqMessageType.cs b/DigiMixer/DigiMixer.SqSeries/SqMessageType.cs new file mode 100644 index 00000000..ca8a45d8 --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries/SqMessageType.cs @@ -0,0 +1,17 @@ +namespace DigiMixer.SqSeries; + +public enum SqMessageType : byte +{ + UdpHandshake = 0, + VersionRequest = 1, + VersionResponse = 2, + FullDataRequest = 3, + FullDataResponse = 4, + ClientInitRequest = 11, + ClientInitResponse = 12, + UsersRequest = 20, + UsersResponse = 21, + Type13Request = 13, + Type15Request = 15, + Type17Request = 17, +} diff --git a/DigiMixer/DigiMixer.SqSeries/SqMixer.cs b/DigiMixer/DigiMixer.SqSeries/SqMixer.cs new file mode 100644 index 00000000..ff597032 --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries/SqMixer.cs @@ -0,0 +1,10 @@ +using DigiMixer.Core; +using Microsoft.Extensions.Logging; + +namespace DigiMixer.SqSeries; + +public class SqMixer +{ + public static IMixerApi CreateMixerApi(ILogger logger, string host, int port = 51326, MixerApiOptions? options = null) => + throw new NotImplementedException(); +} diff --git a/DigiMixer/DigiMixer.SqSeries/SqSimpleRequestMessage.cs b/DigiMixer/DigiMixer.SqSeries/SqSimpleRequestMessage.cs new file mode 100644 index 00000000..dfbf521e --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries/SqSimpleRequestMessage.cs @@ -0,0 +1,17 @@ +using DigiMixer.AllenAndHeath.Core; + +namespace DigiMixer.SqSeries; + +/// +/// A simple request message with no additional data. +/// +public class SqSimpleRequestMessage : SqMessage +{ + internal SqSimpleRequestMessage(SqMessageType type) : base(type, Array.Empty()) + { + } + + internal SqSimpleRequestMessage(AHRawMessage rawMessage) : base(rawMessage) + { + } +} diff --git a/DigiMixer/DigiMixer.SqSeries/SqUdpHandshakeMessage.cs b/DigiMixer/DigiMixer.SqSeries/SqUdpHandshakeMessage.cs new file mode 100644 index 00000000..a4574ba3 --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries/SqUdpHandshakeMessage.cs @@ -0,0 +1,18 @@ +using DigiMixer.AllenAndHeath.Core; + +namespace DigiMixer.SqSeries; + +internal sealed class SqUdpHandshakeMessage : SqMessage +{ + public ushort UdpPort => GetUInt16(0); + + public SqUdpHandshakeMessage(ushort udpPort) : base(SqMessageType.UdpHandshake, [(byte) udpPort, (byte) (udpPort >> 8)]) + { + } + + internal SqUdpHandshakeMessage(AHRawMessage message) : base(message) + { + } + + public override string ToString() => $"Type={Type}; UdpPort={UdpPort}"; +} diff --git a/DigiMixer/DigiMixer.SqSeries/SqUnknownMessage.cs b/DigiMixer/DigiMixer.SqSeries/SqUnknownMessage.cs new file mode 100644 index 00000000..1752f72a --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries/SqUnknownMessage.cs @@ -0,0 +1,10 @@ +using DigiMixer.AllenAndHeath.Core; + +namespace DigiMixer.SqSeries; + +internal sealed class SqUnknownMessage : SqMessage +{ + internal SqUnknownMessage(AHRawMessage rawMessage) : base(rawMessage) + { + } +} diff --git a/DigiMixer/DigiMixer.SqSeries/SqVersionRequestMessage.cs b/DigiMixer/DigiMixer.SqSeries/SqVersionRequestMessage.cs new file mode 100644 index 00000000..1a7e444e --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries/SqVersionRequestMessage.cs @@ -0,0 +1,16 @@ +using DigiMixer.AllenAndHeath.Core; + +namespace DigiMixer.SqSeries; + +internal class SqVersionRequestMessage : SqMessage +{ + public SqVersionRequestMessage() : base(SqMessageType.VersionRequest, []) + { + } + + internal SqVersionRequestMessage(AHRawMessage rawMessage) : base(rawMessage) + { + } + + public override string ToString() => $"Type={Type}"; +} diff --git a/DigiMixer/DigiMixer.SqSeries/SqVersionResponseMessage.cs b/DigiMixer/DigiMixer.SqSeries/SqVersionResponseMessage.cs new file mode 100644 index 00000000..e032d5d4 --- /dev/null +++ b/DigiMixer/DigiMixer.SqSeries/SqVersionResponseMessage.cs @@ -0,0 +1,18 @@ +using DigiMixer.AllenAndHeath.Core; + +namespace DigiMixer.SqSeries; + +internal class SqVersionResponseMessage : SqMessage +{ + public string Version => $"{Data[1]}.{Data[2]}.{Data[3]} r{GetUInt16(4)}"; + + public SqVersionResponseMessage(byte[] data) : base(SqMessageType.VersionResponse, data) + { + } + + internal SqVersionResponseMessage(AHRawMessage rawMessage) : base(rawMessage) + { + } + + public override string ToString() => $"Type={Type}; Version={Version}"; +} diff --git a/DigiMixer/DigiMixer.TfSeries.Tools/ClientExperimentation.cs b/DigiMixer/DigiMixer.TfSeries.Tools/ClientExperimentation.cs new file mode 100644 index 00000000..50b2b853 --- /dev/null +++ b/DigiMixer/DigiMixer.TfSeries.Tools/ClientExperimentation.cs @@ -0,0 +1,85 @@ +using DigiMixer.Diagnostics; +using DigiMixer.Yamaha; +using DigiMixer.Yamaha.Core; +using Microsoft.Extensions.Logging.Abstractions; + +namespace DigiMixer.TfSeries.Tools; + +/// +/// Tool expected to change over time: +/// - initializes a client +/// - sends experiment-specific messages +/// - sends keepalives automatically +/// +public class ClientExperimentation : Tool +{ + private const string TfRackHost = "192.168.1.96"; + private const string Dm3Host = "192.168.1.86"; + private const int Port = 50368; + + public override async Task Execute() + { + List messages = + [ + new YamahaMessage(YamahaMessageType.MMIX, 1, RequestResponseFlag.Request, [new YamahaTextSegment("Mixing"), new YamahaBinarySegment([0x80])]), + new MonitorDataMessage(YamahaMessageType.MMIX, RequestResponseFlag.Request).RawMessage, + new SyncHashesMessage(YamahaMessageType.MMIX, "Mixing", 0x10, RequestResponseFlag.Request, new byte[16], new byte[16]).RawMessage, + ]; + + TimeSpan delay = TimeSpan.FromSeconds(1); + DecodingOptions decodingOptions = new(SkipKeepAlive: true, DecodeSchema: false, DecodeData: false, ShowAllSegments: true); + YamahaClient client = null!; + client = new(NullLogger.Instance, TfRackHost, Port, HandleMessage); + await client.Connect(default); + client.Start(); + + foreach (var message in messages) + { + await Send(message); + await Task.Delay(delay); + await SendWrapped(KeepAliveMessage.Request); + await Task.Delay(delay); + } + + while (true) + { + await Task.Delay(delay); + await SendWrapped(KeepAliveMessage.Request); + } + + async Task HandleMessage(YamahaMessage message, CancellationToken cancellationToken) + { + message.DisplayStructure("<=", decodingOptions, Console.Out); + + // Respond to any messages we get, if we can. + if (message.RequestResponse == RequestResponseFlag.Request) + { + var response = GetResponse(message); + if (response is not null) + { + await Send(response); + } + } + } + + YamahaMessage? GetResponse(YamahaMessage request) => WrappedMessage.TryParse(request) switch + { + SyncHashesMessage shm => new SyncHashesMessage(request.Type, shm.Subtype, request.Flag1, RequestResponseFlag.Request, new byte[16], new byte[16]).RawMessage, + //SyncHashesMessage { RawMessage: var raw } shm => new SyncHashesMessage(raw.Type, shm.Subtype, raw.Flag1, RequestResponseFlag.Response, shm.DataHash, new byte[16]).RawMessage, + KeepAliveMessage or MonitorDataMessage => request.AsResponse(), + _ => null + }; + + async Task Send(YamahaMessage message) + { + message.DisplayStructure("=>", decodingOptions, Console.Out); + await client.SendAsync(message, default); + } + + async Task SendWrapped(WrappedMessage message) + { + message.RawMessage.DisplayStructure("=>", decodingOptions, Console.Out); + await client.SendAsync(message, default); + } + } +} diff --git a/DigiMixer/DigiMixer.TfSeries.Tools/ClientInitialMessages.cs b/DigiMixer/DigiMixer.TfSeries.Tools/ClientInitialMessages.cs new file mode 100644 index 00000000..4257312f --- /dev/null +++ b/DigiMixer/DigiMixer.TfSeries.Tools/ClientInitialMessages.cs @@ -0,0 +1,66 @@ +using DigiMixer.Diagnostics; +using DigiMixer.Yamaha.Core; +using Microsoft.Extensions.Logging.Abstractions; + +namespace DigiMixer.TfSeries.Tools; + +public class ClientInitialMessages : Tool +{ + private const string Host = "192.168.1.96"; + private const int Port = 50368; + + private static readonly YamahaMessage Message1 = ConvertHexToMessage( + "4d 50 52 4f 00 00 00 1d 11 00 00 00 18 01 01 01", + "02 31 00 00 00 09 50 72 6f 70 65 72 74 79 00 11", + "00 00 00 01 80"); + + private static readonly YamahaMessage Message2 = ConvertHexToMessage( + "4d 50 52 4f 00 00 00 47 11 00 00 00 42 01 10 01", + "04 11 00 00 00 01 00 31 00 00 00 09 50 72 6f 70", + "65 72 74 79 00 11 00 00 00 10 3a 7c 8d 4c 85 f8", + "9f 1e aa 83 4f 96 63 0c ec 3d 11 00 00 00 10 8b", + "76 f3 98 78 64 6e 83 15 f5 81 7c 06 cc b6 91"); + + private static readonly YamahaMessage Message3 = ConvertHexToMessage( + "4d 50 52 4f 00 00 00 09 11 00 00 00 04 01 04 01 00"); + + public override async Task Execute() + { + YamahaClient client = new(NullLogger.Instance, Host, Port, HandleMessage); + await client.Connect(default); + client.Start(); + + await Send(Message1); + await Send(Message2); + await Send(Message3); + await Task.Delay(10000); + return 0; + + Task HandleMessage(YamahaMessage message, CancellationToken cancellationToken) + { + message.DisplayStructure("<=", DecodingOptions.Simple, Console.Out); + return Task.CompletedTask; + } + + async Task Send(YamahaMessage message) + { + message.DisplayStructure("=>", DecodingOptions.Simple, Console.Out); + await client.SendAsync(message, default); + } + } + + private static YamahaMessage ConvertHexToMessage(params string[] hexLines) + { + var hex = string.Join("", hexLines); + byte[] data = Hex.ParseHex(hex); + if (YamahaMessage.TryParse(data) is not YamahaMessage message) + { + throw new ArgumentException("Couldn't parse message"); + } + if (data.Length != message.Length) + { + throw new ArgumentException($"Didn't use all of the bytes ({data.Length} vs {message.Length}"); + } + return message; + } +} diff --git a/DigiMixer/DigiMixer.TfSeries.Tools/ConvertAllWireshark.cs b/DigiMixer/DigiMixer.TfSeries.Tools/ConvertAllWireshark.cs new file mode 100644 index 00000000..f83a5aae --- /dev/null +++ b/DigiMixer/DigiMixer.TfSeries.Tools/ConvertAllWireshark.cs @@ -0,0 +1,20 @@ +using DigiMixer.Diagnostics; + +namespace DigiMixer.TfSeries.Tools; + +public class ConvertAllWireshark(string directory) : Tool +{ + public override async Task Execute() + { + foreach (var file in Directory.GetFiles(directory, "*.pcapng")) + { + var singleFileTool = new ConvertWireshark(file); + var result = await singleFileTool.Execute(); + if (result != 0) + { + return result; + } + } + return 0; + } +} diff --git a/DigiMixer/DigiMixer.TfSeries.Tools/ConvertWireshark.cs b/DigiMixer/DigiMixer.TfSeries.Tools/ConvertWireshark.cs new file mode 100644 index 00000000..abc3be8f --- /dev/null +++ b/DigiMixer/DigiMixer.TfSeries.Tools/ConvertWireshark.cs @@ -0,0 +1,30 @@ +using DigiMixer.Diagnostics; +using DigiMixer.Yamaha; +using DigiMixer.Yamaha.Core; + +namespace DigiMixer.TfSeries.Tools; + +public class ConvertWireshark(string file) : Tool +{ + public override async Task Execute() + { + var dump = WiresharkDump.Load(file); + var messages = await dump.ProcessMessages("192.168.1.96", "192.168.1.140"); + + var dir = Path.GetDirectoryName(file).OrThrow(); + var newName = Path.GetFileNameWithoutExtension(file) + " decoded.txt"; + using var writer = File.CreateText(Path.Combine(dir, newName)); + var schemaDictionary = new Dictionary(StringComparer.Ordinal); + foreach (var message in messages) + { + message.DisplayStructure(DecodingOptions.Investigative, writer, schemaDictionary.GetValueOrDefault); + var schemaMessage = SectionSchemaAndDataMessage.TryParse(message.Message); + if (schemaMessage is not null) + { + schemaDictionary[schemaMessage.Subtype] = schemaMessage.Data.Schema; + } + } + Console.WriteLine($"Saved {newName}"); + return 0; + } +} diff --git a/DigiMixer/DigiMixer.TfSeries.Tools/DecodingOptions.cs b/DigiMixer/DigiMixer.TfSeries.Tools/DecodingOptions.cs new file mode 100644 index 00000000..2993e7f2 --- /dev/null +++ b/DigiMixer/DigiMixer.TfSeries.Tools/DecodingOptions.cs @@ -0,0 +1,7 @@ +namespace DigiMixer.TfSeries.Tools; + +internal record DecodingOptions(bool SkipKeepAlive, bool DecodeSchema, bool DecodeData, bool ShowAllSegments) +{ + internal static DecodingOptions Simple { get; } = new(false, false, false, false); + internal static DecodingOptions Investigative { get; } = new(false, false, false, true); +} \ No newline at end of file diff --git a/DigiMixer/DigiMixer.TfSeries.Tools/DigiMixer.TfSeries.Tools.csproj b/DigiMixer/DigiMixer.TfSeries.Tools/DigiMixer.TfSeries.Tools.csproj new file mode 100644 index 00000000..3a220ab6 --- /dev/null +++ b/DigiMixer/DigiMixer.TfSeries.Tools/DigiMixer.TfSeries.Tools.csproj @@ -0,0 +1,15 @@ + + + + Exe + net9.0 + enable + enable + + + + + + + + diff --git a/DigiMixer/DigiMixer.TfSeries.Tools/Program.cs b/DigiMixer/DigiMixer.TfSeries.Tools/Program.cs new file mode 100644 index 00000000..dda3891e --- /dev/null +++ b/DigiMixer/DigiMixer.TfSeries.Tools/Program.cs @@ -0,0 +1,3 @@ +using DigiMixer.Diagnostics; + +await Tool.ExecuteFromCommandLine(args, typeof(Program)); diff --git a/DigiMixer/DigiMixer.TfSeries.Tools/RequestFullData.cs b/DigiMixer/DigiMixer.TfSeries.Tools/RequestFullData.cs new file mode 100644 index 00000000..c4f5ee5f --- /dev/null +++ b/DigiMixer/DigiMixer.TfSeries.Tools/RequestFullData.cs @@ -0,0 +1,41 @@ +using DigiMixer.Diagnostics; +using DigiMixer.Yamaha.Core; +using Microsoft.Extensions.Logging.Abstractions; + +namespace DigiMixer.TfSeries.Tools; + +public class RequestFullData : Tool +{ + private const string Host = "192.168.1.96"; + private const int Port = 50368; + + private static readonly YamahaMessage FullData = new(YamahaMessageType.MMIX, 0x01010102, + [new YamahaTextSegment("Mixing"), YamahaBinarySegment.Empty]); + + private static readonly YamahaMessage NoHashes = new(YamahaMessageType.MMIX, 0x01100104, + [new YamahaBinarySegment([00]), new YamahaTextSegment("Mixing"), YamahaBinarySegment.Zero16, YamahaBinarySegment.Zero16]); + + public override async Task Execute() + { + YamahaClient client = new(NullLogger.Instance, Host, Port, HandleMessage); + await client.Connect(default); + client.Start(); + + await Send(FullData); + await Send(NoHashes); + await Task.Delay(10000); + return 0; + + Task HandleMessage(YamahaMessage message, CancellationToken cancellationToken) + { + message.DisplayStructure("<=", DecodingOptions.Simple,Console.Out); + return Task.CompletedTask; + } + + async Task Send(YamahaMessage message) + { + message.DisplayStructure("=>", DecodingOptions.Simple, Console.Out); + await client.SendAsync(message, default); + } + } +} diff --git a/DigiMixer/DigiMixer.TfSeries.Tools/YamahaMessageExtensions.cs b/DigiMixer/DigiMixer.TfSeries.Tools/YamahaMessageExtensions.cs new file mode 100644 index 00000000..6533c5de --- /dev/null +++ b/DigiMixer/DigiMixer.TfSeries.Tools/YamahaMessageExtensions.cs @@ -0,0 +1,165 @@ +using DigiMixer.Core; +using DigiMixer.Diagnostics; +using DigiMixer.Yamaha; +using DigiMixer.Yamaha.Core; +using System.Security.Cryptography; + +namespace DigiMixer.TfSeries.Tools; + +internal static class YamahaMessageExtensions +{ + internal static void DisplayStructure(this AnnotatedMessage annotatedMessage, DecodingOptions options, TextWriter writer, Func? schemaProvider = null) + { + var message = annotatedMessage.Message; + if (options.SkipKeepAlive && KeepAliveMessage.IsKeepAlive(message)) + { + return; + } + string directionIndicator = annotatedMessage.Direction == MessageDirection.ClientToMixer ? "=>" : "<="; + writer.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff}: {directionIndicator} 0x{annotatedMessage.StreamOffset:x8} {message}"); + DisplayBody(message, options, writer, schemaProvider); + } + + internal static void DisplayStructure(this YamahaMessage message, string directionIndicator, DecodingOptions options, TextWriter writer, Func? schemaProvider = null) + { + if (options.SkipKeepAlive && KeepAliveMessage.IsKeepAlive(message)) + { + return; + } + writer.WriteLine($"{DateTime.UtcNow:HH:mm:ss.fff}: {directionIndicator} {message}"); + DisplayBody(message, options, writer, schemaProvider); + } + + private static void DisplayBody(YamahaMessage message, DecodingOptions options, TextWriter writer, Func? schemaProvider = null) + { + var wrappedMessage = WrappedMessage.TryParse(message); + + switch (wrappedMessage) + { + case SectionSchemaAndDataMessage section: + writer.WriteLine($" SectionSchemaAndData ({section.Data.Name})"); + var hash = MD5.HashData(((YamahaBinarySegment) message.Segments[7]).Data); + writer.WriteLine($" MD5: {Formatting.ToHex(hash)}"); + if (options.DecodeSchema) + { + DescribeSchema(section.Data); + } + if (options.DecodeData) + { + DescribeData(section.Data); + } + break; + case SyncHashesMessage shm: + writer.WriteLine($" SyncHashes: {shm.Subtype}"); + break; + case KeepAliveMessage kam: + writer.WriteLine(" KeepAlive"); + break; + case SingleValueMessage svm: + writer.WriteLine($" SingleValue ({svm.SectionName}): Value={DescribeSegment(svm.ValueSegment)}"); + if (schemaProvider?.Invoke(svm.SectionName) is SchemaCol schema) + { + var property = svm.ResolveProperty(schema); + writer.WriteLine($" Property: {property.Path} (Indexes {string.Join(", ", svm.SchemaIndexes)})"); + } + break; + } + if (wrappedMessage is null || options.ShowAllSegments) + { + foreach (var segment in message.Segments) + { + writer.WriteLine($" {DescribeSegment(segment)}"); + } + } + writer.WriteLine(); + + static string DescribeSegment(YamahaSegment segment) + { + switch (segment) + { + case YamahaBinarySegment binary: + var data = binary.Data; + var hexLength = Math.Min(data.Length, 16); + var hex = Formatting.ToHex(data[..hexLength]) + (hexLength == data.Length ? "" : " [...]"); + return $"Binary[{data.Length}]: {hex}"; + case YamahaTextSegment text: + return $"Text: '{text.Text}'"; + case YamahaInt32Segment int32: + return $"Int32[*{int32.Values.Count}]: {string.Join(" ", int32.Values.Select(v => $"0x{v:x8}"))} / {string.Join(" ", int32.Values)}"; + case YamahaUInt32Segment uint32: + return $"UInt32[*{uint32.Values.Count}]: {string.Join(" ", uint32.Values.Select(v => v.ToString("x8")))}"; + case YamahaUInt16Segment uint16: + return $"UInt16[*{uint16.Values.Count}]: {string.Join(" ", uint16.Values.Select(v => v.ToString("x4")))}"; + default: + throw new InvalidOperationException("Unknown segment type"); + } + } + + void DescribeSchema(SectionSchemaAndData section) + { + writer.WriteLine($" Hash text: {section.SchemaHash}"); + writer.WriteLine($" Schema:"); + DescribeCol(section.Schema, " "); + } + + void DescribeCol(SchemaCol col, string indent) + { + writer.WriteLine($"{indent}COL: {col.Name} Offset={col.RelativeOffset}; Data length={col.DataLength}; Count={col.Count}"); + var nestedIndent = indent + " "; + foreach (var property in col.Properties) + { + writer.WriteLine($"{nestedIndent}PR: {property.Name}; Type={property.Type}; Length={property.Length}; Count={property.Count}"); + } + foreach (var nested in col.Cols) + { + DescribeCol(nested, nestedIndent); + } + } + + void DescribeData(SectionSchemaAndData section) + { + DescribeColData(section, section.Schema, "", " ", 0); + } + + void DescribeColData(SectionSchemaAndData section, SchemaCol col, string colIndex, string indent, int additionalOffset) + { + writer.WriteLine($"{indent}COL{colIndex}: {col.Name}"); + foreach (var property in col.Properties) + { + for (int i = 0; i < property.Count; i++) + { + int propertyAdditionalOffset = additionalOffset + i * property.Length; + string description = property.Type switch + { + SchemaPropertyType.Text => section.GetString(property, propertyAdditionalOffset), + SchemaPropertyType.UnsignedInteger => property.Length switch + { + 1 => section.GetUInt8(property, propertyAdditionalOffset).ToString(), + 2 => section.GetUInt16(property, propertyAdditionalOffset).ToString(), + 4 => section.GetUInt32(property, propertyAdditionalOffset).ToString(), + _ => throw new InvalidOperationException($"Unexpected length {property.Length}") + }, + SchemaPropertyType.SignedInteger => property.Length switch + { + 1 => section.GetInt8(property, propertyAdditionalOffset).ToString(), + 2 => section.GetInt16(property, propertyAdditionalOffset).ToString(), + 4 => section.GetInt32(property, propertyAdditionalOffset).ToString(), + _ => throw new InvalidOperationException($"Unexpected length {property.Length}") + }, + _ => throw new InvalidOperationException($"Unexpected property type {property.Type}") + }; + string index = property.Count == 1 ? "" : $"[{i}]"; + writer.WriteLine($"{indent} {property.Name}{index}: {description}"); + } + } + foreach (var nestedCol in col.Cols) + { + for (int i = 0; i < nestedCol.Count; i++) + { + string nestedColIndex = nestedCol.Count == 1 ? "" : $"[{i}]"; + DescribeColData(section, nestedCol, nestedColIndex, indent + " ", additionalOffset + i * nestedCol.DataLength); + } + } + } + } +} diff --git a/DigiMixer/DigiMixer.TfSeries/DigiMixer.TfSeries.csproj b/DigiMixer/DigiMixer.TfSeries/DigiMixer.TfSeries.csproj new file mode 100644 index 00000000..c1bf5098 --- /dev/null +++ b/DigiMixer/DigiMixer.TfSeries/DigiMixer.TfSeries.csproj @@ -0,0 +1,14 @@ + + + + net9.0 + enable + enable + latest + + + + + + + diff --git a/DigiMixer/DigiMixer.TfSeries/TfMessages.cs b/DigiMixer/DigiMixer.TfSeries/TfMessages.cs new file mode 100644 index 00000000..717ec08a --- /dev/null +++ b/DigiMixer/DigiMixer.TfSeries/TfMessages.cs @@ -0,0 +1,7 @@ +using DigiMixer.Yamaha.Core; + +namespace DigiMixer.TfSeries; + +public class TfMessages +{ +} diff --git a/DigiMixer/DigiMixer.TfSeries/TfMixer.cs b/DigiMixer/DigiMixer.TfSeries/TfMixer.cs new file mode 100644 index 00000000..af7130c3 --- /dev/null +++ b/DigiMixer/DigiMixer.TfSeries/TfMixer.cs @@ -0,0 +1,141 @@ +using DigiMixer.Core; +using DigiMixer.Yamaha; +using DigiMixer.Yamaha.Core; +using Microsoft.Extensions.Logging; + +namespace DigiMixer.TfSeries; + +public static class TfMixer +{ + public static IMixerApi CreateMixerApi(ILogger logger, string host, int port = 50368, MixerApiOptions? options = null) => + new TfMixerApi(logger, host, port, options); +} + +internal class TfMixerApi(ILogger logger, string host, int port, MixerApiOptions? options) : IMixerApi +{ + private YamahaClient? controlClient; + private CancellationTokenSource? cts; + private DateTimeOffset? lastKeepAliveReceived; + private readonly MixerApiOptions options = options ?? MixerApiOptions.Default; + + public TimeSpan KeepAliveInterval => throw new NotImplementedException(); + + public IFaderScale FaderScale => throw new NotImplementedException(); + + public Task CheckConnection(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public async Task Connect(CancellationToken cancellationToken) + { + Dispose(); + + cts = new CancellationTokenSource(); + //meterClient = new DmMeterClient(logger); + //meterClient.MessageReceived += HandleMeterMessage; + //meterClient.Start(); + controlClient = new YamahaClient(logger, host, port, HandleControlMessage); + await controlClient.Connect(cancellationToken); + controlClient.Start(); + + // Pretend we've seen a keep-alive message + lastKeepAliveReceived = DateTimeOffset.UtcNow; + + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, cts.Token); + + // fullDataTask = RequestFullData("MMIX", "Mixing", cancellationToken); + // await fullDataTask; + + // Request live updates for channel information. + await Send(new YamahaMessage(YamahaMessageType.MMIX, 0x01041000, []), cancellationToken); + } + + public Task DetectConfiguration(CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + public void Dispose() + { + throw new NotImplementedException(); + } + + public void RegisterReceiver(IMixerReceiver receiver) + { + throw new NotImplementedException(); + } + + public Task RequestAllData(IReadOnlyList channelIds) + { + throw new NotImplementedException(); + } + + public Task SendKeepAlive() + { + throw new NotImplementedException(); + } + + public Task SetFaderLevel(ChannelId inputId, ChannelId outputId, FaderLevel level) + { + throw new NotImplementedException(); + } + + public Task SetFaderLevel(ChannelId outputId, FaderLevel level) + { + throw new NotImplementedException(); + } + + public Task SetMuted(ChannelId channelId, bool muted) + { + throw new NotImplementedException(); + } + + private async Task HandleControlMessage(YamahaMessage message, CancellationToken cancellationToken) + { + var wrapped = WrappedMessage.TryParse(message); + + if (wrapped is KeepAliveMessage) + { + lastKeepAliveReceived = DateTimeOffset.UtcNow; + return; + } + + // Handle responses to full requests. + if (message.Header == 0x01140109 && message.Segments is + [YamahaBinarySegment _, YamahaTextSegment { Text: string subtype }, YamahaTextSegment { Text: string subtype2 }, YamahaUInt16Segment _, YamahaUInt32Segment _, + YamahaUInt32Segment _, YamahaUInt32Segment _, YamahaBinarySegment _, YamahaBinarySegment _] && + subtype == subtype2) + { + /* + var node = temporaryListeners.First; + while (node is not null) + { + var listener = node.Value; + if (listener.Type == message.Type && listener.Subtype == subtype) + { + listener.SetResult(message); + listener.Dispose(); + temporaryListeners.Remove(node); + } + node = node.Next; + } + if (message.Type == TfMessages.Types.Channels) + { + HandleFullChannelData(new FullChannelDataMessage(message)); + fullDataTask = Task.FromResult(message); + }*/ + // Acknowledge the data + await Send(new YamahaMessage(message.Type, 0x01040100, []), cancellationToken); + } + } + + private async Task Send(YamahaMessage message, CancellationToken cancellationToken) + { + if (controlClient is null) + { + throw new InvalidOperationException("Client is not connected"); + } + await controlClient.SendAsync(message, cancellationToken); + } +} \ No newline at end of file diff --git a/DigiMixer/DigiMixer.Yamaha.Core/DigiMixer.Yamaha.Core.csproj b/DigiMixer/DigiMixer.Yamaha.Core/DigiMixer.Yamaha.Core.csproj new file mode 100644 index 00000000..d63e8663 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha.Core/DigiMixer.Yamaha.Core.csproj @@ -0,0 +1,12 @@ + + + + net9.0 + enable + enable + + + + + + diff --git a/DigiMixer/DigiMixer.Yamaha.Core/RequestResponseFlag.cs b/DigiMixer/DigiMixer.Yamaha.Core/RequestResponseFlag.cs new file mode 100644 index 00000000..55c8e800 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha.Core/RequestResponseFlag.cs @@ -0,0 +1,7 @@ +namespace DigiMixer.Yamaha.Core; + +public enum RequestResponseFlag : byte +{ + Request = 0x01, + Response = 0x10 +} diff --git a/DigiMixer/DigiMixer.Yamaha.Core/YamahaBinarySegment.cs b/DigiMixer/DigiMixer.Yamaha.Core/YamahaBinarySegment.cs new file mode 100644 index 00000000..39a88612 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha.Core/YamahaBinarySegment.cs @@ -0,0 +1,57 @@ +using System.Buffers.Binary; + +namespace DigiMixer.Yamaha.Core; + +public sealed class YamahaBinarySegment : YamahaSegment +{ + public static YamahaBinarySegment Empty { get; } = new([]); + public static YamahaBinarySegment Zero16 { get; } = new(new byte[16]); + + internal override int Length => data.Length + 5; + + private readonly ReadOnlyMemory data; + public ReadOnlySpan Data => data.Span; + + private YamahaBinarySegment(byte[] data) + { + this.data = data; + } + + public YamahaBinarySegment(ReadOnlySpan data) : this(data.ToArray()) + { + } + + public static YamahaBinarySegment FromHex(string text) + { + text = text.Replace(" ", ""); + byte[] data = new byte[text.Length / 2]; + for (int i = 0; i < data.Length; i++) + { + data[i] = (byte) ((ParseNybble(text[i * 2]) << 4) + ParseNybble(text[i * 2 + 1])); + } + return new YamahaBinarySegment(data); + + static int ParseNybble(char c) => c switch + { + >= '0' and <= '9' => c - '0', + >= 'A' and <= 'F' => c - 'A' + 10, + >= 'a' and <= 'f' => c - 'a' + 10, + _ => throw new ArgumentException($"Invalid nybble '{c}'") + }; + } + + internal override void WriteTo(Span buffer) + { + buffer[0] = (byte) YamahaSegmentFormat.Binary; + BinaryPrimitives.WriteInt32BigEndian(buffer[1..], data.Length); + Data.CopyTo(buffer[5..]); + } + + public static YamahaBinarySegment Parse(ReadOnlySpan buffer) + { + var dataLength = BinaryPrimitives.ReadInt32BigEndian(buffer[1..]); + var bytes = new byte[dataLength]; + buffer.Slice(5, dataLength).CopyTo(bytes); + return new YamahaBinarySegment(bytes); + } +} diff --git a/DigiMixer/DigiMixer.Yamaha.Core/YamahaClient.cs b/DigiMixer/DigiMixer.Yamaha.Core/YamahaClient.cs new file mode 100644 index 00000000..70bae266 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha.Core/YamahaClient.cs @@ -0,0 +1,14 @@ +using DigiMixer.Core; +using Microsoft.Extensions.Logging; + +namespace DigiMixer.Yamaha.Core; + +public class YamahaClient(ILogger logger, string host, int port, Func handler) + : TcpMessageProcessingControllerBase(logger, host, port, bufferSize: 1024 * 1024) +{ + protected override async Task ProcessMessage(YamahaMessage message, CancellationToken cancellationToken) + { + Logger.LogTrace("Received message: {message}", message); + await handler(message, cancellationToken); + } +} diff --git a/DigiMixer/DigiMixer.Yamaha.Core/YamahaInt32Segment.cs b/DigiMixer/DigiMixer.Yamaha.Core/YamahaInt32Segment.cs new file mode 100644 index 00000000..6e2c27d6 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha.Core/YamahaInt32Segment.cs @@ -0,0 +1,32 @@ +using System.Buffers.Binary; +using System.Collections.Immutable; + +namespace DigiMixer.Yamaha.Core; + +public sealed class YamahaInt32Segment(ImmutableList values) : YamahaSegment +{ + internal override int Length => 5 + Values.Count * 4; + + public ImmutableList Values { get; } = values; + + internal override void WriteTo(Span buffer) + { + buffer[0] = (byte) YamahaSegmentFormat.Int32; + BinaryPrimitives.WriteInt32BigEndian(buffer[1..], Values.Count); + for (int i = 0; i < Values.Count; i++) + { + BinaryPrimitives.WriteInt32BigEndian(buffer[(5 + i * 4)..], Values[i]); + } + } + + public static YamahaInt32Segment Parse(ReadOnlySpan buffer) + { + var valueCount = BinaryPrimitives.ReadInt32BigEndian(buffer[1..]); + var values = new int[valueCount]; + for (int i = 0; i < valueCount; i++) + { + values[i] = BinaryPrimitives.ReadInt32BigEndian(buffer[(5 + i * 4)..]); + } + return new YamahaInt32Segment([.. values]); + } +} diff --git a/DigiMixer/DigiMixer.Yamaha.Core/YamahaMessage.cs b/DigiMixer/DigiMixer.Yamaha.Core/YamahaMessage.cs new file mode 100644 index 00000000..79546c4e --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha.Core/YamahaMessage.cs @@ -0,0 +1,133 @@ +using DigiMixer.Core; +using System.Buffers.Binary; +using System.Collections.Immutable; +using System.Text; + +namespace DigiMixer.Yamaha.Core; + +public class YamahaMessage : IMixerMessage +{ + /// + /// Message type, e.g. MPRO or EEVT. + /// + public YamahaMessageType Type { get; } + + /// + /// The 32-bit value between the message length and the segments. + /// + public uint Header { get; } + + public byte Flag1 => (byte) (Header >> 16); + public RequestResponseFlag RequestResponse => (RequestResponseFlag) (byte) (Header >> 8); + + public int Length => + 4 // Type + + 4 // Message length (excluding type and length) + + 1 // Binary segment + + 4 // Length of binary segment + + 4 // Header + + Segments.Sum(s => s.Length); // Nested segments + + public ImmutableList Segments { get; } + + public YamahaMessage(YamahaMessageType type, byte flag1, RequestResponseFlag requestResponse, ImmutableList segments) : + this(type, ConstructHeader(type, flag1, requestResponse, segments.Count), segments) + { + } + + private static uint ConstructHeader(YamahaMessageType type, byte flag1, RequestResponseFlag requestResponse, int segmentCount) => + (uint) ((type.HeaderByte << 24) | + (flag1 << 16) | + (((byte) requestResponse) << 8) | + ((byte) segmentCount)); + + // Potentially deprecate? + public YamahaMessage(YamahaMessageType type, uint header, ImmutableList segments) + { + Type = type; + Header = header; + Segments = segments; + if ((Header & 0xff) != segments.Count) + { + throw new ArgumentException($"Header {Header:x8} incompatible with segment count of {Segments.Count}"); + } + if (((Header >> 24) & 0xff) != type.HeaderByte) + { + throw new ArgumentException($"Header {Header:x8} incompatible with message type {Type}"); + } + if (RequestResponse != RequestResponseFlag.Request && RequestResponse != RequestResponseFlag.Response) + { + throw new ArgumentException($"Request response flag {RequestResponse} is unknown."); + } + } + + public YamahaMessage AsResponse() => new(Type, Flag1, RequestResponseFlag.Response, Segments); + + public static YamahaMessage? TryParse(ReadOnlySpan data) + { + if (data.Length < 8) + { + return null; + } + int bodyLength = BinaryPrimitives.ReadInt32BigEndian(data[4..8]); + if (data.Length < bodyLength + 8) + { + return null; + } + if (bodyLength < 0) + { + throw new InvalidDataException($"Negative body length: {bodyLength}"); + } + if (YamahaMessageType.TryParse(data) is not YamahaMessageType type) + { + throw new InvalidDataException($"Unknown message type: {Encoding.ASCII.GetString(data[0..4]).Trim()}"); + } + var body = data.Slice(8, bodyLength); + if (body[0] != (byte) YamahaSegmentFormat.Binary) + { + throw new InvalidDataException("Expected overall container with format 0x11"); + } + var containerLength = BinaryPrimitives.ReadInt32BigEndian(body[1..]); + if (containerLength != bodyLength - 5) + { + throw new InvalidDataException($"Expected overall container internal length {bodyLength - 5}; was {containerLength}"); + } + var segments = new List(); + var header = BinaryPrimitives.ReadUInt32BigEndian(body[5..]); + var nextSegmentData = body[9..]; + while (nextSegmentData.Length > 0) + { + var format = (YamahaSegmentFormat)nextSegmentData[0]; + YamahaSegment segment = format switch + { + YamahaSegmentFormat.Text => YamahaTextSegment.Parse(nextSegmentData), + YamahaSegmentFormat.Binary => YamahaBinarySegment.Parse(nextSegmentData), + YamahaSegmentFormat.Int32 => YamahaInt32Segment.Parse(nextSegmentData), + YamahaSegmentFormat.UInt32 => YamahaUInt32Segment.Parse(nextSegmentData), + YamahaSegmentFormat.UInt16 => YamahaUInt16Segment.Parse(nextSegmentData), + _ => throw new InvalidDataException($"Unexpected segment format {nextSegmentData[0]:x2}") + }; + segments.Add(segment); + nextSegmentData = nextSegmentData[segment.Length..]; + } + return new YamahaMessage(type, header, [.. segments]); + } + + public void CopyTo(Span buffer) + { + // Note: we assume the span is right-sized to Length. + Type.WriteTo(buffer); + BinaryPrimitives.WriteInt32BigEndian(buffer[4..], buffer.Length - 8); + buffer[8] = (byte) YamahaSegmentFormat.Binary; + BinaryPrimitives.WriteInt32BigEndian(buffer[9..], buffer.Length - 13); + BinaryPrimitives.WriteUInt32BigEndian(buffer[13..], Header); + buffer = buffer[17..]; + foreach (var segment in Segments) + { + segment.WriteTo(buffer); + buffer = buffer[segment.Length..]; + } + } + + public override string ToString() => $"{Type.Text,-4}: Flag1={Flag1:x2}; ReqResp={RequestResponse}; Segments={Segments.Count}"; +} diff --git a/DigiMixer/DigiMixer.Yamaha.Core/YamahaMessageType.cs b/DigiMixer/DigiMixer.Yamaha.Core/YamahaMessageType.cs new file mode 100644 index 00000000..dd26e222 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha.Core/YamahaMessageType.cs @@ -0,0 +1,74 @@ +using System.Buffers.Binary; +using System.Collections.Immutable; +using System.Text; + +namespace DigiMixer.Yamaha.Core; + +/// +/// The type of a message. +/// +public sealed class YamahaMessageType +{ + public static YamahaMessageType D000 { get; } = new("d000", 2); + public static YamahaMessageType D010 { get; } = new("d010", 2); + public static YamahaMessageType D020 { get; } = new("d020", 2); + public static YamahaMessageType D030 { get; } = new("d030", 2); + public static YamahaMessageType D040 { get; } = new("d040", 2); + public static YamahaMessageType DL_A { get; } = new("DL_A", 4); + public static YamahaMessageType DL_B { get; } = new("DL_B", 4); + public static YamahaMessageType DL_C { get; } = new("DL_C", 4); + public static YamahaMessageType DL_D { get; } = new("DL_D", 4); + public static YamahaMessageType DS_A { get; } = new("DS_A", 4); + public static YamahaMessageType DS_B { get; } = new("DS_B", 4); + public static YamahaMessageType DS_C { get; } = new("DS_C", 4); + public static YamahaMessageType DS_D { get; } = new("DS_D", 4); + public static YamahaMessageType EEVT { get; } = new("EEVT", 3); + public static YamahaMessageType MCST { get; } = new("MCST", 1); + public static YamahaMessageType MFX { get; } = new("MFX", 1); + public static YamahaMessageType MMIX { get; } = new("MMIX", 1); + public static YamahaMessageType MPRC { get; } = new("MPRC", 1); + public static YamahaMessageType MPRO { get; } = new("MPRO", 1); + public static YamahaMessageType MSCL { get; } = new("MSCL", 1); + public static YamahaMessageType MSCS { get; } = new("MSCS", 1); + public static YamahaMessageType MSTS { get; } = new("MSTS", 1); + public static YamahaMessageType MSUP { get; } = new("MSUP", 1); + public static YamahaMessageType MVOL { get; } = new("MVOL", 1); + + private static readonly ImmutableList MessageTypes = [D000, D010, D020, D030, D040, DL_A, DL_B, DL_C, DL_D, DS_A, DS_B, DS_C, DS_D, EEVT, MCST, MFX, MMIX, MPRC, MPRO, MSCL, MSCS, MSTS, MSUP, MVOL]; + + /// + /// 1-4 character textual representation, always ASCII. + /// This is used as the first four bytes of the message. + /// + public string Text { get; } + + public byte HeaderByte { get; } + + private readonly uint magicNumber; + + private YamahaMessageType(string text, byte headerByte) + { + Text = text; + var bytes = new byte[4]; + Encoding.ASCII.GetBytes(text, bytes); + magicNumber = BinaryPrimitives.ReadUInt32LittleEndian(bytes); + HeaderByte = headerByte; + } + + internal static YamahaMessageType? TryParse(ReadOnlySpan bytes) + { + var magicNumber = BinaryPrimitives.ReadUInt32LittleEndian(bytes); + foreach (var type in MessageTypes) + { + if (magicNumber == type.magicNumber) + { + return type; + } + } + return null; + } + + internal void WriteTo(Span bytes) => BinaryPrimitives.WriteUInt32LittleEndian(bytes, magicNumber); + + public override string ToString() => Text; +} diff --git a/DigiMixer/DigiMixer.Yamaha.Core/YamahaMessages.cs b/DigiMixer/DigiMixer.Yamaha.Core/YamahaMessages.cs new file mode 100644 index 00000000..6c9a1c84 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha.Core/YamahaMessages.cs @@ -0,0 +1,17 @@ +namespace DigiMixer.Yamaha.Core; + +public static class YamahaMessages +{ + public static YamahaMessage KeepAlive { get; } = new(YamahaMessageType.EEVT, + 0x03010104, + [ + new YamahaUInt32Segment([0x0000]), + new YamahaUInt32Segment([0x0000]), + new YamahaTextSegment("KeepAlive"), + new YamahaTextSegment("") + ]); + + internal static bool IsKeepAlive(YamahaMessage message) => + // We could check more than this, but why bother? + message.Type == KeepAlive.Type && (message.Header == 0x03011004 || message.Header == 0x03010104); +} diff --git a/DigiMixer/DigiMixer.Yamaha.Core/YamahaSegment.cs b/DigiMixer/DigiMixer.Yamaha.Core/YamahaSegment.cs new file mode 100644 index 00000000..375a4928 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha.Core/YamahaSegment.cs @@ -0,0 +1,18 @@ +namespace DigiMixer.Yamaha.Core; + +/// +/// A segment within a . +/// +public abstract class YamahaSegment +{ + internal YamahaSegment() + { + } + + /// + /// The length of the segment, including the format. + /// + internal abstract int Length { get; } + + internal abstract void WriteTo(Span buffer); +} diff --git a/DigiMixer/DigiMixer.Yamaha.Core/YamahaSegmentFormat.cs b/DigiMixer/DigiMixer.Yamaha.Core/YamahaSegmentFormat.cs new file mode 100644 index 00000000..f556d76e --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha.Core/YamahaSegmentFormat.cs @@ -0,0 +1,10 @@ +namespace DigiMixer.Yamaha.Core; + +public enum YamahaSegmentFormat : byte +{ + Binary = 0x11, + UInt16 = 0x12, + UInt32 = 0x14, + Int32 = 0x24, + Text = 0x31, +} diff --git a/DigiMixer/DigiMixer.Yamaha.Core/YamahaTextSegment.cs b/DigiMixer/DigiMixer.Yamaha.Core/YamahaTextSegment.cs new file mode 100644 index 00000000..bd9dd45e --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha.Core/YamahaTextSegment.cs @@ -0,0 +1,31 @@ + +using System.Buffers.Binary; +using System.Text; + +namespace DigiMixer.Yamaha.Core; + +public sealed class YamahaTextSegment(string text) : YamahaSegment +{ + /// + /// The text of the segment, not including the null terminator. + /// + public string Text { get; } = text; + + internal override int Length => Text.Length + 6; + + internal override void WriteTo(Span buffer) + { + buffer[0] = (byte)YamahaSegmentFormat.Text; + BinaryPrimitives.WriteInt32BigEndian(buffer[1..], Text.Length + 1); + Encoding.ASCII.GetBytes(Text, buffer[5..]); + buffer[5 + Text.Length] = 0; + } + + public static YamahaTextSegment Parse(ReadOnlySpan buffer) + { + var textLength = BinaryPrimitives.ReadInt32BigEndian(buffer[1..]); + // We assume the null termination. + var text = Encoding.ASCII.GetString(buffer.Slice(5, textLength - 1)); + return new YamahaTextSegment(text); + } +} diff --git a/DigiMixer/DigiMixer.Yamaha.Core/YamahaUInt16Segment.cs b/DigiMixer/DigiMixer.Yamaha.Core/YamahaUInt16Segment.cs new file mode 100644 index 00000000..52bd4f48 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha.Core/YamahaUInt16Segment.cs @@ -0,0 +1,32 @@ +using System.Buffers.Binary; +using System.Collections.Immutable; + +namespace DigiMixer.Yamaha.Core; + +public sealed class YamahaUInt16Segment(ImmutableList values) : YamahaSegment +{ + internal override int Length => 5 + Values.Count * 2; + + public ImmutableList Values { get; } = values; + + internal override void WriteTo(Span buffer) + { + buffer[0] = (byte) YamahaSegmentFormat.UInt16; + BinaryPrimitives.WriteInt32BigEndian(buffer[1..], Values.Count); + for (int i = 0; i < Values.Count; i++) + { + BinaryPrimitives.WriteUInt16BigEndian(buffer[(5 + i * 2)..], Values[i]); + } + } + + public static YamahaUInt16Segment Parse(ReadOnlySpan buffer) + { + var valueCount = BinaryPrimitives.ReadInt32BigEndian(buffer[1..]); + var values = new ushort[valueCount]; + for (int i = 0; i < valueCount; i++) + { + values[i] = BinaryPrimitives.ReadUInt16BigEndian(buffer[(5 + i * 2)..]); + } + return new YamahaUInt16Segment([.. values]); + } +} diff --git a/DigiMixer/DigiMixer.Yamaha.Core/YamahaUInt32Segment.cs b/DigiMixer/DigiMixer.Yamaha.Core/YamahaUInt32Segment.cs new file mode 100644 index 00000000..02c1681a --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha.Core/YamahaUInt32Segment.cs @@ -0,0 +1,32 @@ +using System.Buffers.Binary; +using System.Collections.Immutable; + +namespace DigiMixer.Yamaha.Core; + +public sealed class YamahaUInt32Segment(ImmutableList values) : YamahaSegment +{ + internal override int Length => 5 + Values.Count * 4; + + public ImmutableList Values { get; } = values; + + internal override void WriteTo(Span buffer) + { + buffer[0] = (byte) YamahaSegmentFormat.UInt32; + BinaryPrimitives.WriteInt32BigEndian(buffer[1..], Values.Count); + for (int i = 0; i < Values.Count; i++) + { + BinaryPrimitives.WriteUInt32BigEndian(buffer[(5 + i * 4)..], Values[i]); + } + } + + public static YamahaUInt32Segment Parse(ReadOnlySpan buffer) + { + var valueCount = BinaryPrimitives.ReadInt32BigEndian(buffer[1..]); + var values = new uint[valueCount]; + for (int i = 0; i < valueCount; i++) + { + values[i] = BinaryPrimitives.ReadUInt32BigEndian(buffer[(5 + i * 4)..]); + } + return new YamahaUInt32Segment([.. values]); + } +} diff --git a/DigiMixer/DigiMixer.Yamaha/DataSubtypes.cs b/DigiMixer/DigiMixer.Yamaha/DataSubtypes.cs new file mode 100644 index 00000000..eac260b6 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha/DataSubtypes.cs @@ -0,0 +1,9 @@ +namespace DigiMixer.Yamaha; + +public static class DataSubtypes +{ + // For MMIX + public const string Mixing = "Mixing"; + // For MPRO + public const string Property = "Property"; +} diff --git a/DigiMixer/DigiMixer.Yamaha/DigiMixer.Yamaha.csproj b/DigiMixer/DigiMixer.Yamaha/DigiMixer.Yamaha.csproj new file mode 100644 index 00000000..4fa71ce4 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha/DigiMixer.Yamaha.csproj @@ -0,0 +1,13 @@ + + + + net9.0 + enable + enable + + + + + + + diff --git a/DigiMixer/DigiMixer.Yamaha/KeepAliveMessage.cs b/DigiMixer/DigiMixer.Yamaha/KeepAliveMessage.cs new file mode 100644 index 00000000..228f5437 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha/KeepAliveMessage.cs @@ -0,0 +1,35 @@ +using DigiMixer.Yamaha.Core; + +namespace DigiMixer.Yamaha; + +public sealed class KeepAliveMessage : WrappedMessage +{ + public static KeepAliveMessage Request { get; } = new(new (YamahaMessageType.EEVT, 0x01, RequestResponseFlag.Request, + [ + new YamahaUInt32Segment([0x0000]), + new YamahaUInt32Segment([0x0000]), + new YamahaTextSegment("KeepAlive"), + new YamahaTextSegment("") + ])); + + public static KeepAliveMessage Response { get; } = new(new (YamahaMessageType.EEVT, 0x01, RequestResponseFlag.Response, + [ + new YamahaUInt32Segment([0x0000]), + new YamahaUInt32Segment([0x0000]), + new YamahaTextSegment("KeepAlive"), + new YamahaTextSegment("") + ])); + + private KeepAliveMessage(YamahaMessage rawMessage) : base(rawMessage) + { + } + + public static new KeepAliveMessage? TryParse(YamahaMessage rawMessage) => + IsKeepAlive(rawMessage) ? (rawMessage.RequestResponse == RequestResponseFlag.Request ? Request : Response) + : null; + + public static bool IsKeepAlive(YamahaMessage message) => + // We could check more than this, but why bother? + message.Type == Request.RawMessage.Type && message.Flag1 == Request.RawMessage.Flag1; + +} diff --git a/DigiMixer/DigiMixer.Yamaha/MonitorDataMessage.cs b/DigiMixer/DigiMixer.Yamaha/MonitorDataMessage.cs new file mode 100644 index 00000000..7f0f635c --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha/MonitorDataMessage.cs @@ -0,0 +1,21 @@ +using DigiMixer.Yamaha.Core; + +namespace DigiMixer.Yamaha; + +public sealed class MonitorDataMessage : WrappedMessage +{ + public MonitorDataMessage(YamahaMessageType type, RequestResponseFlag requestResponse) + : this(new(type, 0x4, requestResponse, [])) + { + } + + private MonitorDataMessage(YamahaMessage rawMessage) : base(rawMessage) + { + } + + public static new MonitorDataMessage? TryParse(YamahaMessage rawMessage) => + IsMonitorDataMessage(rawMessage) ? new(rawMessage) : null; + + private static bool IsMonitorDataMessage(YamahaMessage rawMessage) => + rawMessage.Flag1 == 0x04 && rawMessage.Segments.Count == 0; +} diff --git a/DigiMixer/DigiMixer.Yamaha/SchemaCol.cs b/DigiMixer/DigiMixer.Yamaha/SchemaCol.cs new file mode 100644 index 00000000..3298ad69 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha/SchemaCol.cs @@ -0,0 +1,82 @@ +using System.Buffers.Binary; +using System.Collections.Immutable; +using System.Text; + +namespace DigiMixer.Yamaha; + +public sealed class SchemaCol +{ + public string Name { get; } + public string Path { get; } + public ImmutableList Cols { get; } + public ImmutableList Properties { get; } + + /// + /// The number of bytes a single instance of this Col takes up. + /// + public int DataLength { get; } + + /// + /// The number of types this Col is repeated in the parent. + /// + public int Count { get; } + + /// + /// The offset of the first instance of this Col, relative to the start of the parent Col. + /// + public int RelativeOffset { get; } + + /// + /// The offset of the first instance of this Col, relative to the start of the data. + /// + public int AbsoluteOffset { get; } + + public SchemaCol? Parent { get; } + + private int SchemaLength => 48 + Properties.Count * 32 + Cols.Sum(c => c.SchemaLength); + + internal SchemaCol(SchemaCol? parent, ReadOnlySpan schema) + { + if (schema.Length < 48) + { + throw new Exception($"Invalid schema length {schema.Length} for COL: {Encoding.ASCII.GetString(schema)}"); + } + if (schema[0] != 'C' || schema[1] != 'O' || schema[2] != 'L' || schema[3] != '0') + { + throw new ArgumentException($"Unexpected schema data; expected COL, got {Encoding.ASCII.GetString(schema[0..3])}"); + } + Name = Encoding.ASCII.GetString(schema.Slice(4, 24)).TrimEnd('\0'); + Path = parent is null ? Name : $"{parent.Path}/{Name}"; + RelativeOffset = BinaryPrimitives.ReadInt32LittleEndian(schema.Slice(36, 4)); + AbsoluteOffset = (parent?.AbsoluteOffset ?? 0) + RelativeOffset; + DataLength = BinaryPrimitives.ReadInt32LittleEndian(schema.Slice(40, 4)); + Count = BinaryPrimitives.ReadInt32LittleEndian(schema.Slice(44, 4)); + + var propertiesLength = BinaryPrimitives.ReadInt32LittleEndian(schema.Slice(28, 4)); + var colsLength = BinaryPrimitives.ReadInt32LittleEndian(schema.Slice(32, 4)); + if (schema.Length < 48 + propertiesLength + colsLength) + { + throw new ArgumentException($"Invalid length for Col; total length={schema.Length}; properties length={propertiesLength}; cols length={colsLength}"); + } + + var propertiesBuilder = ImmutableList.CreateBuilder(); + int dataOffset = 0; + for (int i = 0; i < propertiesLength / 32; i++) + { + var prop = new SchemaProperty(this, dataOffset, schema.Slice(i * 32 + 48, 32)); + propertiesBuilder.Add(prop); + dataOffset += prop.Length * prop.Count; + } + Properties = propertiesBuilder.ToImmutable(); + + var colsBuilder = ImmutableList.CreateBuilder(); + int offset = 0; + while (offset < colsLength) + { + var col = new SchemaCol(this, schema[(48 + propertiesLength + offset)..]); + colsBuilder.Add(col); + offset += col.SchemaLength; + } + Cols = colsBuilder.ToImmutable(); + } +} diff --git a/DigiMixer/DigiMixer.Yamaha/SchemaProperty.cs b/DigiMixer/DigiMixer.Yamaha/SchemaProperty.cs new file mode 100644 index 00000000..908be7f3 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha/SchemaProperty.cs @@ -0,0 +1,64 @@ +using System.Buffers.Binary; +using System.Text; + +namespace DigiMixer.Yamaha; + +public sealed class SchemaProperty +{ + /// + /// The name of the property. + /// + public string Name { get; } + + /// + /// The full path to the property (via ancestors). + /// + public string Path { get; } + + /// + /// The type of the property. + /// + public SchemaPropertyType Type { get; } + + /// + /// How many times this property is repeated. + /// + public int Count { get; } + + /// + /// Number of bytes this takes up in a Col. + /// + public int Length { get; } + + public SchemaCol Parent { get; } + + /// + /// The offset of the first instance of this property, relative to the start of the Col. + /// + public int RelativeOffset { get; } + + /// + /// The offset of the first instance of this property, relative to the start of the data. + /// + public int AbsoluteOffset { get; } + + internal SchemaProperty(SchemaCol parent, int offset, ReadOnlySpan schema) + { + if (schema.Length != 32) + { + throw new Exception($"Invalid schema length {schema.Length} for property: {schema.Length}"); + } + if (schema[0] != 'P' || schema[1] != 'R' || schema[2] != ' ') + { + throw new ArgumentException($"Unexpected schema data; expected property, got {Encoding.ASCII.GetString(schema[0..3])}"); + } + Type = (SchemaPropertyType) schema[3]; + Length = BinaryPrimitives.ReadUInt16LittleEndian(schema[4..6]); + Count = BinaryPrimitives.ReadUInt16LittleEndian(schema[6..8]); + Name = Encoding.ASCII.GetString(schema.Slice(8, 24)).TrimEnd('\0'); + Path = $"{parent.Path}/{Name}"; + Parent = parent; + RelativeOffset = offset; + AbsoluteOffset = offset + parent.AbsoluteOffset; + } +} diff --git a/DigiMixer/DigiMixer.Yamaha/SchemaPropertyType.cs b/DigiMixer/DigiMixer.Yamaha/SchemaPropertyType.cs new file mode 100644 index 00000000..2049db08 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha/SchemaPropertyType.cs @@ -0,0 +1,8 @@ +namespace DigiMixer.Yamaha; + +public enum SchemaPropertyType : byte +{ + Text = 0, + SignedInteger = 1, + UnsignedInteger= 2 +} diff --git a/DigiMixer/DigiMixer.Yamaha/SectionSchemaAndData.cs b/DigiMixer/DigiMixer.Yamaha/SectionSchemaAndData.cs new file mode 100644 index 00000000..6776d88e --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha/SectionSchemaAndData.cs @@ -0,0 +1,84 @@ +using DigiMixer.Yamaha.Core; +using System.Buffers.Binary; +using System.Text; + +namespace DigiMixer.Yamaha; + +/// +/// The schema and data for a section, parsed from a +/// +public sealed class SectionSchemaAndData +{ + private const int HeaderLength = 88; + + private readonly YamahaBinarySegment segment; + private readonly int schemaLength; + + public string Name { get; } + public string SchemaHash { get; } + public SchemaCol Schema { get; } + + public string GetString(SchemaProperty property, int additionalOffset = 0) + { + var offset = GetOffset(property, additionalOffset); + return Encoding.ASCII.GetString(segment.Data.Slice(offset, property.Length)).TrimEnd('\0'); + } + + public short GetInt16(SchemaProperty property, int additionalOffset = 0) + { + var offset = GetOffset(property, additionalOffset); + return BinaryPrimitives.ReadInt16LittleEndian(segment.Data[offset..]); + } + + public int GetInt32(SchemaProperty property, int additionalOffset = 0) + { + var offset = GetOffset(property, additionalOffset); + return BinaryPrimitives.ReadInt32LittleEndian(segment.Data[offset..]); + } + + public ushort GetUInt16(SchemaProperty property, int additionalOffset = 0) + { + var offset = GetOffset(property, additionalOffset); + return BinaryPrimitives.ReadUInt16LittleEndian(segment.Data[offset..]); + } + + public uint GetUInt32(SchemaProperty property, int additionalOffset = 0) + { + var offset = GetOffset(property, additionalOffset); + return BinaryPrimitives.ReadUInt32LittleEndian(segment.Data[offset..]); + } + + public byte GetUInt8(SchemaProperty property, int additionalOffset = 0) + { + var offset = GetOffset(property, additionalOffset); + return segment.Data[offset]; + } + + public sbyte GetInt8(SchemaProperty property, int additionalOffset = 0) + { + var offset = GetOffset(property, additionalOffset); + return (sbyte) segment.Data[offset]; + } + + private int GetOffset(SchemaProperty property, int additionalOffset = 0) => HeaderLength + schemaLength + property.AbsoluteOffset + additionalOffset; + + public SectionSchemaAndData(YamahaBinarySegment segment) + { + this.segment = segment; + var segmentData = segment.Data; + + var header = segmentData[..HeaderLength]; + Name = Encoding.ASCII.GetString(header[8..44]).TrimEnd('\0'); + SchemaHash = Encoding.ASCII.GetString(header[44..76]); + schemaLength = BinaryPrimitives.ReadInt32LittleEndian(header[80..]); + var dataLength = BinaryPrimitives.ReadInt32LittleEndian(header[84..]); + + if (schemaLength + dataLength + HeaderLength != segmentData.Length) + { + throw new ArgumentException($"Invalid section data length. Segment length: {segmentData.Length}; schema length: {schemaLength}; values length: {dataLength}"); + } + + // For now, assume that there's a single root Col. + Schema = new SchemaCol(null, segmentData.Slice(HeaderLength, schemaLength)); + } +} diff --git a/DigiMixer/DigiMixer.Yamaha/SectionSchemaAndDataMessage.cs b/DigiMixer/DigiMixer.Yamaha/SectionSchemaAndDataMessage.cs new file mode 100644 index 00000000..509460b5 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha/SectionSchemaAndDataMessage.cs @@ -0,0 +1,38 @@ +using DigiMixer.Yamaha.Core; + +namespace DigiMixer.Yamaha; + +public sealed class SectionSchemaAndDataMessage : WrappedMessage +{ + public SectionSchemaAndData Data { get; } + public string Subtype => ((YamahaTextSegment) RawMessage.Segments[2]).Text; + + private SectionSchemaAndDataMessage(YamahaMessage rawMessage) : base(rawMessage) + { + Data = new((YamahaBinarySegment) RawMessage.Segments[7]); + } + + public static new SectionSchemaAndDataMessage? TryParse(YamahaMessage rawMessage) => + IsSectionSchemaAndDataMessage(rawMessage) ? new(rawMessage) : null; + + private static bool IsSectionSchemaAndDataMessage(YamahaMessage rawMessage) => + rawMessage.Flag1 == 0x14 && + rawMessage.Segments.Count == 9 && + rawMessage.Segments[0] is YamahaBinarySegment seg0 && + seg0.Data.Length == 1 && seg0.Data[0] == 0 && + rawMessage.Segments[1] is YamahaTextSegment seg1 && + rawMessage.Segments[2] is YamahaTextSegment seg2 && + seg1.Text == seg2.Text && + rawMessage.Segments[3] is YamahaUInt16Segment seg3 && + seg3.Values.Count == 1 && seg3.Values[0] == 0 && + rawMessage.Segments[4] is YamahaUInt32Segment seg4 && + seg4.Values.Count == 0 && + rawMessage.Segments[5] is YamahaUInt32Segment seg5 && + seg5.Values.Count == 0 && + rawMessage.Segments[6] is YamahaUInt32Segment seg6 && + seg6.Values.Count == 1 && seg6.Values[0] == 0x000000f0 && + rawMessage.Segments[7] is YamahaBinarySegment seg7 && + seg7.Data.Length >= 88 && + rawMessage.Segments[8] is YamahaBinarySegment seg8 && + seg8.Data.Length == 0; +} diff --git a/DigiMixer/DigiMixer.Yamaha/SingleValueMessage.cs b/DigiMixer/DigiMixer.Yamaha/SingleValueMessage.cs new file mode 100644 index 00000000..54f50f81 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha/SingleValueMessage.cs @@ -0,0 +1,88 @@ +using DigiMixer.Yamaha.Core; +using System.Collections.Immutable; +using System.Security.Principal; + +namespace DigiMixer.Yamaha; + +/// +/// Reports/requests a change to a single value. +/// +public sealed class SingleValueMessage : WrappedMessage +{ + public string SectionName => ((YamahaTextSegment) RawMessage.Segments[1]).Text; + + /// + /// A path navigating from the top of the section schema to the relevant property. + /// Each value is the index of the property/col within a . + /// + public ImmutableList SchemaPath => ((YamahaUInt32Segment) RawMessage.Segments[4]).Values; + + /// + /// The indexes along the path from the root col, e.g. indicating which channel is being represented. + /// + public ImmutableList SchemaIndexes => ((YamahaUInt32Segment) RawMessage.Segments[5]).Values; + + // We don't know if this is really a client ID yet - it always seems to be 0xa0 or 0xa1. + public uint ClientId => ((YamahaUInt32Segment) RawMessage.Segments[6]).Values[0]; + + public YamahaSegment ValueSegment => RawMessage.Segments[7]; + + public SchemaProperty ResolveProperty(SchemaCol rootCol) + { + var currentCol = rootCol; + // Everything up until the last value is a col, then there's a property. + var colCount = SchemaPath.Count - 1; + + for (int i = 0; i < colCount; i++) + { + var schemaIndex = (int) SchemaPath[i]; + var colIndex = schemaIndex - currentCol.Properties.Count; + if (colIndex < 0 || colIndex >= currentCol.Cols.Count) + { + throw new ArgumentException($"Invalid schema path: {string.Join(".", SchemaPath)} at index {i}"); + } + currentCol = currentCol.Cols[colIndex]; + if (SchemaIndexes[i] >= currentCol.Count) + { + throw new ArgumentException($"Invalid schema index {SchemaIndexes[i]} in schema path: {string.Join(".", SchemaPath)} at index {i}"); + } + } + + var propertyIndex = (int) SchemaPath[colCount]; + if (propertyIndex < 0 || propertyIndex >= currentCol.Properties.Count) + { + throw new ArgumentException($"Invalid schema path: {string.Join(".", SchemaPath)} at final index for property"); + } + var property = currentCol.Properties[propertyIndex]; + if (SchemaIndexes[colCount] >= currentCol.Count) + { + throw new ArgumentException($"Invalid schema index {SchemaIndexes[colCount]} in schema path: {string.Join(".", SchemaPath)} for property"); + } + return property; + } + + private SingleValueMessage(YamahaMessage rawMessage) : base(rawMessage) + { + } + + public static new SingleValueMessage? TryParse(YamahaMessage rawMessage) => + IsSingleValueMessage(rawMessage) ? new SingleValueMessage(rawMessage) : null; + + private static bool IsSingleValueMessage(YamahaMessage rawMessage) => + rawMessage.Segments.Count == 8 && + rawMessage.Flag1 == 0x11 && + // From MixingStation, it's a UInt16[*1] - and then the response has text + // for the fifth segment... + // rawMessage.Segments[0] is YamahaBinarySegment seg0 && + // seg0.Data.Length == 1 && seg0.Data[0] == 0 && + rawMessage.Segments[1] is YamahaTextSegment seg1 && + rawMessage.Segments[2] is YamahaTextSegment seg2 && + seg1.Text == seg2.Text && + rawMessage.Segments[3] is YamahaUInt16Segment seg3 && + seg3.Values.Count == 1 && + rawMessage.Segments[4] is YamahaUInt32Segment seg4 && + rawMessage.Segments[5] is YamahaUInt32Segment seg5 && + seg4.Values.Count == seg5.Values.Count && + seg4.Values.Count == seg3.Values[0] && + rawMessage.Segments[6] is YamahaUInt32Segment; +} diff --git a/DigiMixer/DigiMixer.Yamaha/SyncHashesMessage.cs b/DigiMixer/DigiMixer.Yamaha/SyncHashesMessage.cs new file mode 100644 index 00000000..1d3d33db --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha/SyncHashesMessage.cs @@ -0,0 +1,42 @@ +using DigiMixer.Yamaha.Core; + +namespace DigiMixer.Yamaha; + +/// +/// A message containing a subtype and two hashes: one for the schema and one for the data. +/// +public sealed class SyncHashesMessage : WrappedMessage +{ + public string Subtype => ((YamahaTextSegment) RawMessage.Segments[1]).Text; + public ReadOnlySpan SchemaHash => ((YamahaBinarySegment) RawMessage.Segments[2]).Data; + public ReadOnlySpan DataHash => ((YamahaBinarySegment) RawMessage.Segments[3]).Data; + + public SyncHashesMessage(YamahaMessageType type, string subtype, byte flag1, RequestResponseFlag requestResponse, ReadOnlySpan schemaHash, ReadOnlySpan dataHash) + : this(new YamahaMessage(type, flag1, requestResponse, + [ + new YamahaBinarySegment([0]), + new YamahaTextSegment(subtype), + new YamahaBinarySegment(schemaHash), + new YamahaBinarySegment(dataHash), + ])) + { + } + + private SyncHashesMessage(YamahaMessage rawMessage) : base(rawMessage) + { + } + + public static new SyncHashesMessage? TryParse(YamahaMessage rawMessage) => + IsSyncHashesMessage(rawMessage) ? new(rawMessage) : null; + + private static bool IsSyncHashesMessage(YamahaMessage rawMessage) => + rawMessage.Flag1 == 0x10 && + rawMessage.Segments.Count == 4 && + rawMessage.Segments[0] is YamahaBinarySegment seg0 && + seg0.Data.Length == 1 && seg0.Data[0] == 0 && + rawMessage.Segments[1] is YamahaTextSegment && + rawMessage.Segments[2] is YamahaBinarySegment seg2 && + seg2.Data.Length == 16 && + rawMessage.Segments[3] is YamahaBinarySegment seg3 && + seg3.Data.Length == 16; +} diff --git a/DigiMixer/DigiMixer.Yamaha/WrappedMessage.cs b/DigiMixer/DigiMixer.Yamaha/WrappedMessage.cs new file mode 100644 index 00000000..f7843e85 --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha/WrappedMessage.cs @@ -0,0 +1,18 @@ +using DigiMixer.Yamaha.Core; + +namespace DigiMixer.Yamaha; + +/// +/// A wrapper for an original "raw" message, but with more semantics. +/// +public abstract class WrappedMessage(YamahaMessage rawMessage) +{ + public YamahaMessage RawMessage { get; } = rawMessage; + + public static WrappedMessage? TryParse(YamahaMessage message) => + SyncHashesMessage.TryParse(message) ?? + KeepAliveMessage.TryParse(message) ?? + SectionSchemaAndDataMessage.TryParse(message) ?? + MonitorDataMessage.TryParse(message) ?? + (WrappedMessage?) SingleValueMessage.TryParse(message); +} diff --git a/DigiMixer/DigiMixer.Yamaha/YamahaClientExtensions.cs b/DigiMixer/DigiMixer.Yamaha/YamahaClientExtensions.cs new file mode 100644 index 00000000..8257000d --- /dev/null +++ b/DigiMixer/DigiMixer.Yamaha/YamahaClientExtensions.cs @@ -0,0 +1,9 @@ +using DigiMixer.Yamaha.Core; + +namespace DigiMixer.Yamaha; + +public static class YamahaClientExtensions +{ + public static Task SendAsync(this YamahaClient client, WrappedMessage message, CancellationToken cancellationToken) => + client.SendAsync(message.RawMessage, cancellationToken); +} diff --git a/DigiMixer/DigiMixer.sln b/DigiMixer/DigiMixer.sln index 94388aa3..c96994f6 100644 --- a/DigiMixer/DigiMixer.sln +++ b/DigiMixer/DigiMixer.sln @@ -35,8 +35,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DigiMixer.UCNet.Core", "Dig EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Protocols", "Protocols", "{0B03065C-EBBD-446F-9BC4-1D26FD96B439}" ProjectSection(SolutionItems) = preProject + Protocols\ah-sq.md = Protocols\ah-sq.md Protocols\behringer-wing.md = Protocols\behringer-wing.md Protocols\mackie.md = Protocols\mackie.md + Protocols\yamaha-tf.md = Protocols\yamaha-tf.md EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DigiMixer.Ssc", "DigiMixer.Ssc\DigiMixer.Ssc.csproj", "{6C3761B7-E526-4DE8-9EA8-9B3F67EDFA11}" @@ -87,6 +89,20 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DigiMixer.Hardware", "DigiM EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigiMixer.BehringerWing.WingExplorer", "DigiMixer.BehringerWing.WingExplorer\DigiMixer.BehringerWing.WingExplorer.csproj", "{20705AD5-B614-413D-9BB9-8308A958E4F4}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigiMixer.TfSeries", "DigiMixer.TfSeries\DigiMixer.TfSeries.csproj", "{603E2343-6C02-4A68-A434-BC029AB6F788}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigiMixer.TfSeries.Tools", "DigiMixer.TfSeries.Tools\DigiMixer.TfSeries.Tools.csproj", "{4A66EF70-036A-4AA3-BE57-D56645FEE3CD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigiMixer.Yamaha.Core", "DigiMixer.Yamaha.Core\DigiMixer.Yamaha.Core.csproj", "{B400F62F-7AC4-49B4-AA76-4DA358DDE8B4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigiMixer.Yamaha", "DigiMixer.Yamaha\DigiMixer.Yamaha.csproj", "{5EEC5BBA-67C7-4417-9283-53EEFB280FEB}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigiMixer.SqSeries", "DigiMixer.SqSeries\DigiMixer.SqSeries.csproj", "{25452C06-6C94-4E3F-9978-DC42CE9CAD9C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigiMixer.SqSeries.Tools", "DigiMixer.SqSeries.Tools\DigiMixer.SqSeries.Tools.csproj", "{E1E0C51C-D634-4945-A310-16438CA78797}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigiMixer.AllenAndHeath.Core", "DigiMixer.AllenAndHeath.Core\DigiMixer.AllenAndHeath.Core.csproj", "{082C4376-E6AC-45AA-B985-65E7142F171C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -407,6 +423,62 @@ Global {20705AD5-B614-413D-9BB9-8308A958E4F4}.Release|Any CPU.Build.0 = Release|Any CPU {20705AD5-B614-413D-9BB9-8308A958E4F4}.Release|x64.ActiveCfg = Release|Any CPU {20705AD5-B614-413D-9BB9-8308A958E4F4}.Release|x64.Build.0 = Release|Any CPU + {603E2343-6C02-4A68-A434-BC029AB6F788}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {603E2343-6C02-4A68-A434-BC029AB6F788}.Debug|Any CPU.Build.0 = Debug|Any CPU + {603E2343-6C02-4A68-A434-BC029AB6F788}.Debug|x64.ActiveCfg = Debug|Any CPU + {603E2343-6C02-4A68-A434-BC029AB6F788}.Debug|x64.Build.0 = Debug|Any CPU + {603E2343-6C02-4A68-A434-BC029AB6F788}.Release|Any CPU.ActiveCfg = Release|Any CPU + {603E2343-6C02-4A68-A434-BC029AB6F788}.Release|Any CPU.Build.0 = Release|Any CPU + {603E2343-6C02-4A68-A434-BC029AB6F788}.Release|x64.ActiveCfg = Release|Any CPU + {603E2343-6C02-4A68-A434-BC029AB6F788}.Release|x64.Build.0 = Release|Any CPU + {4A66EF70-036A-4AA3-BE57-D56645FEE3CD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4A66EF70-036A-4AA3-BE57-D56645FEE3CD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4A66EF70-036A-4AA3-BE57-D56645FEE3CD}.Debug|x64.ActiveCfg = Debug|Any CPU + {4A66EF70-036A-4AA3-BE57-D56645FEE3CD}.Debug|x64.Build.0 = Debug|Any CPU + {4A66EF70-036A-4AA3-BE57-D56645FEE3CD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4A66EF70-036A-4AA3-BE57-D56645FEE3CD}.Release|Any CPU.Build.0 = Release|Any CPU + {4A66EF70-036A-4AA3-BE57-D56645FEE3CD}.Release|x64.ActiveCfg = Release|Any CPU + {4A66EF70-036A-4AA3-BE57-D56645FEE3CD}.Release|x64.Build.0 = Release|Any CPU + {B400F62F-7AC4-49B4-AA76-4DA358DDE8B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B400F62F-7AC4-49B4-AA76-4DA358DDE8B4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B400F62F-7AC4-49B4-AA76-4DA358DDE8B4}.Debug|x64.ActiveCfg = Debug|Any CPU + {B400F62F-7AC4-49B4-AA76-4DA358DDE8B4}.Debug|x64.Build.0 = Debug|Any CPU + {B400F62F-7AC4-49B4-AA76-4DA358DDE8B4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B400F62F-7AC4-49B4-AA76-4DA358DDE8B4}.Release|Any CPU.Build.0 = Release|Any CPU + {B400F62F-7AC4-49B4-AA76-4DA358DDE8B4}.Release|x64.ActiveCfg = Release|Any CPU + {B400F62F-7AC4-49B4-AA76-4DA358DDE8B4}.Release|x64.Build.0 = Release|Any CPU + {5EEC5BBA-67C7-4417-9283-53EEFB280FEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5EEC5BBA-67C7-4417-9283-53EEFB280FEB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5EEC5BBA-67C7-4417-9283-53EEFB280FEB}.Debug|x64.ActiveCfg = Debug|Any CPU + {5EEC5BBA-67C7-4417-9283-53EEFB280FEB}.Debug|x64.Build.0 = Debug|Any CPU + {5EEC5BBA-67C7-4417-9283-53EEFB280FEB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5EEC5BBA-67C7-4417-9283-53EEFB280FEB}.Release|Any CPU.Build.0 = Release|Any CPU + {5EEC5BBA-67C7-4417-9283-53EEFB280FEB}.Release|x64.ActiveCfg = Release|Any CPU + {5EEC5BBA-67C7-4417-9283-53EEFB280FEB}.Release|x64.Build.0 = Release|Any CPU + {25452C06-6C94-4E3F-9978-DC42CE9CAD9C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {25452C06-6C94-4E3F-9978-DC42CE9CAD9C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {25452C06-6C94-4E3F-9978-DC42CE9CAD9C}.Debug|x64.ActiveCfg = Debug|Any CPU + {25452C06-6C94-4E3F-9978-DC42CE9CAD9C}.Debug|x64.Build.0 = Debug|Any CPU + {25452C06-6C94-4E3F-9978-DC42CE9CAD9C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {25452C06-6C94-4E3F-9978-DC42CE9CAD9C}.Release|Any CPU.Build.0 = Release|Any CPU + {25452C06-6C94-4E3F-9978-DC42CE9CAD9C}.Release|x64.ActiveCfg = Release|Any CPU + {25452C06-6C94-4E3F-9978-DC42CE9CAD9C}.Release|x64.Build.0 = Release|Any CPU + {E1E0C51C-D634-4945-A310-16438CA78797}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E1E0C51C-D634-4945-A310-16438CA78797}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E1E0C51C-D634-4945-A310-16438CA78797}.Debug|x64.ActiveCfg = Debug|Any CPU + {E1E0C51C-D634-4945-A310-16438CA78797}.Debug|x64.Build.0 = Debug|Any CPU + {E1E0C51C-D634-4945-A310-16438CA78797}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E1E0C51C-D634-4945-A310-16438CA78797}.Release|Any CPU.Build.0 = Release|Any CPU + {E1E0C51C-D634-4945-A310-16438CA78797}.Release|x64.ActiveCfg = Release|Any CPU + {E1E0C51C-D634-4945-A310-16438CA78797}.Release|x64.Build.0 = Release|Any CPU + {082C4376-E6AC-45AA-B985-65E7142F171C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {082C4376-E6AC-45AA-B985-65E7142F171C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {082C4376-E6AC-45AA-B985-65E7142F171C}.Debug|x64.ActiveCfg = Debug|Any CPU + {082C4376-E6AC-45AA-B985-65E7142F171C}.Debug|x64.Build.0 = Debug|Any CPU + {082C4376-E6AC-45AA-B985-65E7142F171C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {082C4376-E6AC-45AA-B985-65E7142F171C}.Release|Any CPU.Build.0 = Release|Any CPU + {082C4376-E6AC-45AA-B985-65E7142F171C}.Release|x64.ActiveCfg = Release|Any CPU + {082C4376-E6AC-45AA-B985-65E7142F171C}.Release|x64.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/DigiMixer/Protocols/ah-sq.md b/DigiMixer/Protocols/ah-sq.md new file mode 100644 index 00000000..0e020e72 --- /dev/null +++ b/DigiMixer/Protocols/ah-sq.md @@ -0,0 +1,2 @@ +# Allen and Heath SQ protocol + diff --git a/DigiMixer/Protocols/yamaha-tf.md b/DigiMixer/Protocols/yamaha-tf.md new file mode 100644 index 00000000..44a90d06 --- /dev/null +++ b/DigiMixer/Protocols/yamaha-tf.md @@ -0,0 +1,12 @@ +# Yamaha TF Rack protocol + +Currently looks very similar to the DM protocol... but rather than assume this, +I'll implement it separately (copying code where appropriate), then see if I +can combine the two and abstract any differences. + +## Text protocol details + +There's a text protocol which doesn't do everything we need, but might help in terms of names to look for. + +https://github.com/BrenekH/yamaha-rcp-docs/ +https://github.com/bitfocus/companion-module-yamaha-rcp/ \ No newline at end of file diff --git a/RoslynAnalyzers/.gitignore b/RoslynAnalyzers/.gitignore new file mode 100644 index 00000000..a7aeaaa1 --- /dev/null +++ b/RoslynAnalyzers/.gitignore @@ -0,0 +1 @@ +*.nupkg diff --git a/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Package/JonSkeet.RoslynAnalyzers.Package.csproj b/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Package/JonSkeet.RoslynAnalyzers.Package.csproj index 54a93bcf..df03bc95 100644 --- a/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Package/JonSkeet.RoslynAnalyzers.Package.csproj +++ b/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Package/JonSkeet.RoslynAnalyzers.Package.csproj @@ -9,7 +9,7 @@ JonSkeet.RoslynAnalyzers - 1.0.0-beta.3 + 1.0.0-beta.6 Jon Skeet Apache-2.0 https://github.com/jskeet/democode diff --git a/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Test/DangerousWithOperatorAnalyzerTest.cs b/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Test/DangerousWithOperatorAnalyzerTest.cs index 8a4026e5..b1fe9b38 100644 --- a/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Test/DangerousWithOperatorAnalyzerTest.cs +++ b/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Test/DangerousWithOperatorAnalyzerTest.cs @@ -1,5 +1,6 @@ using Microsoft.CodeAnalysis.Testing; using NUnit.Framework; +using System.Threading; using System.Threading.Tasks; using VerifyCS = JonSkeet.RoslynAnalyzers.Test.Verifiers.CSharpAnalyzerVerifier; @@ -7,30 +8,13 @@ namespace JonSkeet.RoslynAnalyzers.Test; public class DangerousWithOperatorAnalyzerTest { - [Test] - public async Task TestNoInitializers() - { - var test = @"public record Simple(int X, int Y); - - class Test - { - static void M() - { - Simple s1 = new Simple(10, 10); - Simple s2 = s1 with { X = 10 }; - } - }"; - - await VerifyCS.VerifyAnalyzerAsync(test); - } + private const string AttributeDeclaration = "[System.AttributeUsage(System.AttributeTargets.Parameter)] internal class DangerousWithTargetAttribute : System.Attribute {}\n"; + private const string OtherAttributeDeclaration = "[System.AttributeUsage(System.AttributeTargets.Parameter)] internal class OtherAttribute : System.Attribute {}\n"; [Test] - public async Task TestInitializerUsedInUnsetParameter() + public async Task NoDangerousParameters() { - var test = @"public record Simple(int X, int Y) - { - public int Z { get; } = Y * 2; - } + var test = AttributeDeclaration + @"public record Simple(int X, int Y); class Test { @@ -45,36 +29,9 @@ static void M() } [Test] - public async Task TestPropertyInitializerUsedInSetParameter() - { - var test = @"public record Simple(int X, int Y) - { - public int Z { get; } = X * 2; - } - - class Test - { - static void M() - { - Simple s1 = new Simple(10, 10); - Simple s2 = s1 with { X = 10 }; - } - }"; - - var diagnostic = new DiagnosticResult(DangerousWithOperatorAnalyzer.Rule) - .WithSpan(11, 29, 11, 47) - .WithArguments("X"); - await VerifyCS.VerifyAnalyzerAsync(test, diagnostic); - } - - [Test] - public async Task TestInitializerCallingMethodUsedInSetParameter() + public async Task DangerousParameterUnset() { - var test = @"public record Simple(int X, int Y) - { - public int Z { get; } = M(X); - private static int M(int value) => value; - } + var test = AttributeDeclaration + @"public record Simple(int X, [DangerousWithTarget] int Y); class Test { @@ -85,20 +42,13 @@ static void M() } }"; - var diagnostic = new DiagnosticResult(DangerousWithOperatorAnalyzer.Rule) - .WithSpan(12, 29, 12, 47) - .WithArguments("X"); - await VerifyCS.VerifyAnalyzerAsync(test, diagnostic); + await VerifyCS.VerifyAnalyzerAsync(test); } [Test] - public async Task TestMultipleInitializersSingleDiagnostic() + public async Task DangerousParameterSet() { - var test = @"public record Simple(int X, int Y) - { - public int Z1 { get; } = X * 2; - public int Z2 { get; } = X * 2; - } + var test = AttributeDeclaration + @"public record Simple([DangerousWithTarget] int X, int Y); class Test { @@ -110,86 +60,75 @@ static void M() }"; var diagnostic = new DiagnosticResult(DangerousWithOperatorAnalyzer.Rule) - .WithSpan(12, 29, 12, 47) + .WithSpan(9, 29, 9, 47) .WithArguments("X"); await VerifyCS.VerifyAnalyzerAsync(test, diagnostic); } [Test] - public async Task TestMultipleParametersDiagnostics() + public async Task MultipleDangerousParameters() { - var test = @"public record Simple(int X, int Y) - { - public int Z1 { get; } = X * 2; - public int Z2 { get; } = Y * 2; - } + var test = AttributeDeclaration + @"public record Simple([DangerousWithTarget] int X, [DangerousWithTarget] int Y); class Test { static void M() { Simple s1 = new Simple(10, 10); - Simple s2 = s1 with { X = 10, Y = 20 }; + Simple s2 = s1 with { X = 20, Y = 20 }; } }"; var d1 = new DiagnosticResult(DangerousWithOperatorAnalyzer.Rule) - .WithSpan(12, 29, 12, 55) + .WithSpan(9, 29, 9, 55) .WithArguments("X"); var d2 = new DiagnosticResult(DangerousWithOperatorAnalyzer.Rule) - .WithSpan(12, 29, 12, 55) + .WithSpan(9, 29, 9, 55) .WithArguments("Y"); await VerifyCS.VerifyAnalyzerAsync(test, d1, d2); } [Test] - public async Task TestFieldInitializerUsedInSetParameter() + public async Task OtherAttributesIgnored() { - var test = @"public record Simple(int X, int Y) - { - private readonly int z = X * 2; - - public int Z => z; - } + var test = OtherAttributeDeclaration + @"public record Simple([Other] int X, int Y); class Test { static void M() { Simple s1 = new Simple(10, 10); - Simple s2 = s1 with { X = 10 }; + Simple s2 = s1 with { X = 10, Y = 20 }; } }"; - - var diagnostic = new DiagnosticResult(DangerousWithOperatorAnalyzer.Rule) - .WithSpan(13, 29, 13, 47) - .WithArguments("X"); - await VerifyCS.VerifyAnalyzerAsync(test, diagnostic); + await VerifyCS.VerifyAnalyzerAsync(test); } [Test] - public async Task TestMultipleFieldInitializerUsedInSetParameter() + public async Task SeparateSourceFiles() { - var test = @"public record Simple(int X, int Y) - { - private readonly int z = Y * 2, zz = X * 2; - - public int Z => z; - public int ZZ => z; - } - - class Test + var source1 = AttributeDeclaration + @"public record Simple([DangerousWithTarget] int X, int Y);"; + var source2 = @"class Test { static void M() { Simple s1 = new Simple(10, 10); - Simple s2 = s1 with { X = 10 }; + Simple s2 = s1 with { X = 20, Y = 20 }; } }"; - var diagnostic = new DiagnosticResult(DangerousWithOperatorAnalyzer.Rule) - .WithSpan(14, 29, 14, 47) + .WithSpan("/0/Test1.cs", 6, 29, 6, 55) .WithArguments("X"); - await VerifyCS.VerifyAnalyzerAsync(test, diagnostic); + var test = new VerifyCS.Test + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80, + TestState = + { + Sources = { source1, source2 } + }, + ExpectedDiagnostics = { diagnostic } + }; + + await test.RunAsync(CancellationToken.None); } } diff --git a/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Test/DangerousWithTargetAnalyzerTest.cs b/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Test/DangerousWithTargetAnalyzerTest.cs new file mode 100644 index 00000000..08f69e99 --- /dev/null +++ b/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Test/DangerousWithTargetAnalyzerTest.cs @@ -0,0 +1,164 @@ +using Microsoft.CodeAnalysis.Testing; +using NUnit.Framework; +using System.Threading.Tasks; +using VerifyCS = JonSkeet.RoslynAnalyzers.Test.Verifiers.CSharpAnalyzerVerifier; + +namespace JonSkeet.RoslynAnalyzers.Test; + +public class DangerousWithTargetAnalyzerTest +{ + private const string AttributeDeclaration = "[System.AttributeUsage(System.AttributeTargets.Parameter)] internal class DangerousWithTargetAttribute : System.Attribute {}\n"; + private const string OtherAttributeDeclaration = "[System.AttributeUsage(System.AttributeTargets.Parameter)] internal class OtherAttribute : System.Attribute {}\n"; + + [Test] + public async Task NoParameters() + { + var test = AttributeDeclaration + @"public record Simple;"; + await VerifyCS.VerifyAnalyzerAsync(test); + } + + [Test] + public async Task NoInitializers() + { + var test = AttributeDeclaration + @"public record Simple(int X, int Y);"; + await VerifyCS.VerifyAnalyzerAsync(test); + } + + [Test] + public async Task PropertyInitializer() + { + var test = AttributeDeclaration + @"public record Simple(int X, int Y) + { + public int Z { get; } = X * 2; + }"; + + var diagnostic = new DiagnosticResult(DangerousWithTargetAnalyzer.Rule) + .WithSpan(2, 22, 2, 27) + .WithArguments("X"); + await VerifyCS.VerifyAnalyzerAsync(test, diagnostic); + } + + [Test] + public async Task InitializerCallingMethod() + { + var test = AttributeDeclaration + @"public record Simple(int X, int Y) + { + public int Z { get; } = M(X); + private static int M(int value) => value; + }"; + + var diagnostic = new DiagnosticResult(DangerousWithTargetAnalyzer.Rule) + .WithSpan(2, 22, 2, 27) + .WithArguments("X"); + await VerifyCS.VerifyAnalyzerAsync(test, diagnostic); + } + + [Test] + public async Task MultipleInitializersSingleDiagnostic() + { + var test = AttributeDeclaration + @"public record Simple(int X, int Y) + { + public int Z1 { get; } = X * 2; + public int Z2 { get; } = X * 2; + }"; + + var diagnostic = new DiagnosticResult(DangerousWithTargetAnalyzer.Rule) + .WithSpan(2, 22, 2, 27) + .WithArguments("X"); + await VerifyCS.VerifyAnalyzerAsync(test, diagnostic); + } + + [Test] + public async Task MultipleInitializerMultipleDiagnostics() + { + var test = AttributeDeclaration + @"public record Simple(int X, int Y, int Safe) + { + public int Z1 { get; } = X * 2; + public int Z2 { get; } = Y * 2; + }"; + + var d1 = new DiagnosticResult(DangerousWithTargetAnalyzer.Rule) + .WithSpan(2, 22, 2, 27) + .WithArguments("X"); + var d2 = new DiagnosticResult(DangerousWithTargetAnalyzer.Rule) + .WithSpan(2, 29, 2, 34) + .WithArguments("Y"); + await VerifyCS.VerifyAnalyzerAsync(test, d1, d2); + } + + [Test] + public async Task SingleFieldInitializer() + { + var test = AttributeDeclaration + @"public record Simple(int X, int Y) + { + private readonly int z = X * 2; + + public int Z => z; + }"; + + var diagnostic = new DiagnosticResult(DangerousWithTargetAnalyzer.Rule) + .WithSpan(2, 22, 2, 27) + .WithArguments("X"); + await VerifyCS.VerifyAnalyzerAsync(test, diagnostic); + } + + [Test] + public async Task MultipleFieldInitializers() + { + var test = AttributeDeclaration + @"public record Simple(int X, int Y, int Safe) + { + private readonly int z = Y * 2, zz = X * 2; + + public int Z => z; + public int ZZ => z; + }"; + + var d1 = new DiagnosticResult(DangerousWithTargetAnalyzer.Rule) + .WithSpan(2, 29, 2, 34) + .WithArguments("Y"); + var d2 = new DiagnosticResult(DangerousWithTargetAnalyzer.Rule) + .WithSpan(2, 22, 2, 27) + .WithArguments("X"); + await VerifyCS.VerifyAnalyzerAsync(test, d1, d2); + } + + [Test] + public async Task AttributedParameter() + { + var test = AttributeDeclaration + @"public record Simple([DangerousWithTarget] int X, int Y) + { + public int Z { get; } = X * 2; + }"; + + await VerifyCS.VerifyAnalyzerAsync(test); + } + + [Test] + public async Task OtherAttributedParameter() + { + var test = AttributeDeclaration + OtherAttributeDeclaration + @"public record Simple([Other] int X, int Y) + { + public int Z { get; } = X * 2; + }"; + + var diagnostic = new DiagnosticResult(DangerousWithTargetAnalyzer.Rule) + .WithSpan(3, 22, 3, 35) + .WithArguments("X"); + await VerifyCS.VerifyAnalyzerAsync(test, diagnostic); + } + + [Test] + public async Task MixedAttribution() + { + var test = AttributeDeclaration + @"public record Simple([DangerousWithTarget] int X, int Y) + { + public int Z { get; } = X * 2; + public int ZZ { get; } = Y * 2; + }"; + + var diagnostic = new DiagnosticResult(DangerousWithTargetAnalyzer.Rule) + .WithSpan(2, 51, 2, 56) + .WithArguments("Y"); + await VerifyCS.VerifyAnalyzerAsync(test, diagnostic); + } +} diff --git a/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Vsix/JonSkeet.RoslynAnalyzers.Vsix.csproj b/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Vsix/JonSkeet.RoslynAnalyzers.Vsix.csproj deleted file mode 100644 index d23a692e..00000000 --- a/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Vsix/JonSkeet.RoslynAnalyzers.Vsix.csproj +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - net472 - JonSkeet.RoslynAnalyzers.Vsix - JonSkeet.RoslynAnalyzers.Vsix - - - - false - false - false - false - false - false - Roslyn - - - - - - - - Program - $(DevEnvDir)devenv.exe - /rootsuffix $(VSSDKTargetPlatformRegRootSuffix) - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Vsix/source.extension.vsixmanifest b/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Vsix/source.extension.vsixmanifest deleted file mode 100644 index 9ce1efb9..00000000 --- a/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Vsix/source.extension.vsixmanifest +++ /dev/null @@ -1,24 +0,0 @@ - - - - - JonSkeet.RoslynAnalyzers - This is a sample diagnostic extension for the .NET Compiler Platform ("Roslyn"). - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers/DangerousWithOperatorAnalyzer.cs b/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers/DangerousWithOperatorAnalyzer.cs index b6123384..d556617c 100644 --- a/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers/DangerousWithOperatorAnalyzer.cs +++ b/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers/DangerousWithOperatorAnalyzer.cs @@ -2,28 +2,22 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Diagnostics; -using System.Collections.Generic; using System.Collections.Immutable; -using System.Diagnostics; using System.Linq; -using System.Reflection; -using System.Xml.Linq; namespace JonSkeet.RoslynAnalyzers; [DiagnosticAnalyzer(LanguageNames.CSharp)] public class DangerousWithOperatorAnalyzer : DiagnosticAnalyzer { - public const string DiagnosticId = "JS0001"; + public const string DiagnosticId = "JS0002"; - // You can change these strings in the Resources.resx file. If you do not want your analyzer to be localize-able, you can use regular strings for Title and MessageFormat. - // See https://github.com/dotnet/roslyn/blob/main/docs/analyzers/Localizing%20Analyzers.md for more on localization - private static readonly string Title = "With operator sets a record parameter used during initialization"; - private static readonly string MessageFormat = "Record parameter '{0}' is used during initialization"; - private static readonly string Description = "With operator sets a record parameter used during initialization."; + private const string Title = $"Record parameters annotated with [{DangerousWithTargetAnalyzer.DangerousWithTargetAttributeShortName}] should not be set using the 'with' operator."; + private const string MessageFormat = $"Record parameter '{{0}}' is annotated with [{DangerousWithTargetAnalyzer.DangerousWithTargetAttributeShortName}]"; + private const string Description = $"Record parameters are annotated with [{DangerousWithTargetAnalyzer.DangerousWithTargetAttributeShortName}] if they are dangerous to set using the 'with' operator." + + " This is usually due to computations during initialization using the parameter, which aren't performed again using the new value. Using the 'with' operator with such parameters can lead to inconsistent state."; private const string Category = "Reliability"; - // TODO: Should this actually be private? Harder to test. public static DiagnosticDescriptor Rule { get; } = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: Description); public override ImmutableArray SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } } @@ -40,61 +34,44 @@ private static void AnalyzeWithOperator(SyntaxNodeAnalysisContext context) WithExpressionSyntax syntax = (WithExpressionSyntax) context.Node; var model = context.SemanticModel; var node = context.Node; - var assignedParameters = syntax.Initializer - .Expressions - .Select(exp => model.GetSymbolInfo(((AssignmentExpressionSyntax) exp).Left).Symbol?.Name) - .ToList(); - var typeInfo = model.GetTypeInfo(syntax.Expression); - if (!typeInfo.ConvertedType.IsRecord) + + var initalizerExpressions = syntax.Initializer.Expressions; + foreach (AssignmentExpressionSyntax initializerExpression in initalizerExpressions) { - return; + var assignedParameter = model.GetSymbolInfo(initializerExpression.Left).Symbol; + // The assigned parameter refers to a property, but we need to get at the parameter declaration. + if (assignedParameter is not IPropertySymbol propertySymbol) + { + continue; + } + if (IsDangerous(propertySymbol)) + { + var diagnostic = Diagnostic.Create(Rule, context.Node.GetLocation(), assignedParameter.Name); + context.ReportDiagnostic(diagnostic); + } } - var recordMembers = typeInfo.ConvertedType.GetMembers(); - foreach (var recordMember in recordMembers) + + bool IsDangerous(IPropertySymbol propertySymbol) { - var declaringReferences = recordMember.DeclaringSyntaxReferences; - foreach (var decl in declaringReferences) + // This is awful - there must be a better way of getting the parameter symbol for a record. + // I don't even known how to tell which constructor is the primary constructor... + var containingRecord = propertySymbol.ContainingType; + var constructors = containingRecord.InstanceConstructors; + foreach (var ctor in constructors) { - var declNode = decl.GetSyntax(); - if (declNode is PropertyDeclarationSyntax prop && prop.Initializer is not null) - { - MaybeReportDiagnostic(context, assignedParameters, prop.Initializer); - } - else if (declNode is FieldDeclarationSyntax field) + foreach (var parameter in ctor.Parameters) { - foreach (var subDecl in field.Declaration.Variables) + if (parameter.Name != propertySymbol.Name) { - MaybeReportDiagnostic(context, assignedParameters, subDecl.Initializer); + continue; + } + if (DangerousWithTargetAnalyzer.HasDangerousWithTargetAttribute(parameter)) + { + return true; } } - else if (declNode is VariableDeclaratorSyntax variableDeclarator) - { - MaybeReportDiagnostic(context, assignedParameters, variableDeclarator.Initializer); - } - else - { - Debugger.Break(); - } - } - } - } - - private static void MaybeReportDiagnostic(SyntaxNodeAnalysisContext context, List assignedParameters, EqualsValueClauseSyntax initializer) - { - var dataFlow = context.SemanticModel.AnalyzeDataFlow(initializer.Value); - var readSymbols = dataFlow.ReadInside; - for (int i = 0; i < readSymbols.Length; i++) - { - var parameterIndex = assignedParameters.IndexOf(readSymbols[i].Name); - if (parameterIndex != -1) - { - // Avoid reporting the same parameter multiple times. - assignedParameters[parameterIndex] = null; - var diagnostic = Diagnostic.Create(Rule, context.Node.GetLocation(), readSymbols[i].Name); - context.ReportDiagnostic(diagnostic); - return; } + return false; } - return; } } diff --git a/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers/DangerousWithTargetAnalyzer.cs b/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers/DangerousWithTargetAnalyzer.cs new file mode 100644 index 00000000..076a455d --- /dev/null +++ b/RoslynAnalyzers/JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers/DangerousWithTargetAnalyzer.cs @@ -0,0 +1,120 @@ +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Diagnostics; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; + +namespace JonSkeet.RoslynAnalyzers; + +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public class DangerousWithTargetAnalyzer : DiagnosticAnalyzer +{ + internal const string DangerousWithTargetAttributeShortName = "DangerousWithTarget"; + internal const string DangerousWithTargetAttributeFullName = "DangerousWithTargetAttribute"; + + public const string DiagnosticId = "JS0001"; + + private static readonly string Title = $"Record parameters used during initialization should be annotated with [{DangerousWithTargetAttributeShortName}]"; + private static readonly string MessageFormat = $"Record parameter '{{0}}' is used during initialization; it should be annotated with [{DangerousWithTargetAttributeShortName}]"; + private static readonly string Description = "Record parameters used during initialization can introduce inconsistencies when set using the 'with' operator." + + $" Such parameters should be annotated with a [{DangerousWithTargetAttributeShortName}] attribute (which may be internal) to indicate that this is deliberate." + + " Setting such parameters using the 'with' operator triggers a warning via another analyzer."; + private const string Category = "Reliability"; + + public static DiagnosticDescriptor Rule { get; } = new DiagnosticDescriptor(DiagnosticId, Title, MessageFormat, Category, DiagnosticSeverity.Warning, isEnabledByDefault: true, description: Description); + + public override ImmutableArray SupportedDiagnostics { get { return ImmutableArray.Create(Rule); } } + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.RegisterSyntaxNodeAction(AnalyzeRecordDeclaration, SyntaxKind.RecordDeclaration); + context.EnableConcurrentExecution(); + } + + private static void AnalyzeRecordDeclaration(SyntaxNodeAnalysisContext context) + { + var syntax = (RecordDeclarationSyntax) context.Node; + if (syntax.ParameterList is not ParameterListSyntax parameterList) + { + return; + } + var model = context.SemanticModel; + var parameterSymbols = parameterList.Parameters.Select(p => model.GetDeclaredSymbol(p)).ToList(); + + var node = context.Node; + var declaredSymbol = model.GetDeclaredSymbol(node); + if (declaredSymbol is not ITypeSymbol typeSymbol) + { + return; + } + if (!typeSymbol.IsRecord) + { + return; + } + // Null out any parameters which already have the annotation. + for (int i = 0; i < parameterSymbols.Count; i++) + { + if (HasDangerousWithTargetAttribute(parameterSymbols[i])) + { + parameterSymbols[i] = null; + } + } + + var recordMembers = typeSymbol.GetMembers(); + + foreach (var recordMember in recordMembers) + { + var declaringReferences = recordMember.DeclaringSyntaxReferences; + foreach (var decl in declaringReferences) + { + var declNode = decl.GetSyntax(); + // TODO: Figure out a nicer way of doing this. (And if we even need all of the options here.) + if (declNode is PropertyDeclarationSyntax prop && prop.Initializer is not null) + { + MaybeReportDiagnostic(context, parameterSymbols, prop.Initializer); + } + else if (declNode is FieldDeclarationSyntax field) + { + foreach (var subDecl in field.Declaration.Variables) + { + MaybeReportDiagnostic(context, parameterSymbols, subDecl.Initializer); + } + } + else if (declNode is VariableDeclaratorSyntax variableDeclarator) + { + MaybeReportDiagnostic(context, parameterSymbols, variableDeclarator.Initializer); + } + } + } + } + + private static void MaybeReportDiagnostic(SyntaxNodeAnalysisContext context, List parameterSymbols, EqualsValueClauseSyntax initializer) + { + var dataFlow = context.SemanticModel.AnalyzeDataFlow(initializer.Value); + var readSymbols = dataFlow.ReadInside; + for (int i = 0; i < readSymbols.Length; i++) + { + if (readSymbols[i] is not IParameterSymbol readParameterSymbol) + { + continue; + } + var parameterIndex = parameterSymbols.IndexOf(readParameterSymbol); + if (parameterIndex != -1) + { + // Avoid reporting the same parameter multiple times. + parameterSymbols[parameterIndex] = null; + var firstDeclaration = readParameterSymbol.DeclaringSyntaxReferences.FirstOrDefault(); + var diagnostic = Diagnostic.Create(Rule, firstDeclaration?.GetSyntax()?.GetLocation(), readParameterSymbol.Name); + context.ReportDiagnostic(diagnostic); + return; + } + } + return; + } + + internal static bool HasDangerousWithTargetAttribute(ISymbol symbol) => + symbol.GetAttributes().Any(attr => attr.AttributeClass?.Name == DangerousWithTargetAttributeFullName); +} diff --git a/RoslynAnalyzers/RoslynAnalyzers.sln b/RoslynAnalyzers/RoslynAnalyzers.sln deleted file mode 100644 index 99bf8406..00000000 --- a/RoslynAnalyzers/RoslynAnalyzers.sln +++ /dev/null @@ -1,49 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31903.59 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JonSkeet.RoslynAnalyzers", "JonSkeet.RoslynAnalyzers\JonSkeet.RoslynAnalyzers\JonSkeet.RoslynAnalyzers.csproj", "{08A37136-34DC-4E67-BE0F-C21675DE2C7F}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JonSkeet.RoslynAnalyzers.CodeFixes", "JonSkeet.RoslynAnalyzers\JonSkeet.RoslynAnalyzers.CodeFixes\JonSkeet.RoslynAnalyzers.CodeFixes.csproj", "{463F0B5F-22CF-4B2F-9C4D-C83B4BC751EA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JonSkeet.RoslynAnalyzers.Package", "JonSkeet.RoslynAnalyzers\JonSkeet.RoslynAnalyzers.Package\JonSkeet.RoslynAnalyzers.Package.csproj", "{FCBBA14E-5CFA-4E59-B9ED-1DBB615FA741}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JonSkeet.RoslynAnalyzers.Test", "JonSkeet.RoslynAnalyzers\JonSkeet.RoslynAnalyzers.Test\JonSkeet.RoslynAnalyzers.Test.csproj", "{FA5F1645-F6CD-4DEF-99C0-84F2913875EC}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JonSkeet.RoslynAnalyzers.Vsix", "JonSkeet.RoslynAnalyzers\JonSkeet.RoslynAnalyzers.Vsix\JonSkeet.RoslynAnalyzers.Vsix.csproj", "{3D130F44-F121-45D1-AD49-A670EA70D4A1}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {08A37136-34DC-4E67-BE0F-C21675DE2C7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {08A37136-34DC-4E67-BE0F-C21675DE2C7F}.Debug|Any CPU.Build.0 = Debug|Any CPU - {08A37136-34DC-4E67-BE0F-C21675DE2C7F}.Release|Any CPU.ActiveCfg = Release|Any CPU - {08A37136-34DC-4E67-BE0F-C21675DE2C7F}.Release|Any CPU.Build.0 = Release|Any CPU - {463F0B5F-22CF-4B2F-9C4D-C83B4BC751EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {463F0B5F-22CF-4B2F-9C4D-C83B4BC751EA}.Debug|Any CPU.Build.0 = Debug|Any CPU - {463F0B5F-22CF-4B2F-9C4D-C83B4BC751EA}.Release|Any CPU.ActiveCfg = Release|Any CPU - {463F0B5F-22CF-4B2F-9C4D-C83B4BC751EA}.Release|Any CPU.Build.0 = Release|Any CPU - {FCBBA14E-5CFA-4E59-B9ED-1DBB615FA741}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FCBBA14E-5CFA-4E59-B9ED-1DBB615FA741}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FCBBA14E-5CFA-4E59-B9ED-1DBB615FA741}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FCBBA14E-5CFA-4E59-B9ED-1DBB615FA741}.Release|Any CPU.Build.0 = Release|Any CPU - {FA5F1645-F6CD-4DEF-99C0-84F2913875EC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {FA5F1645-F6CD-4DEF-99C0-84F2913875EC}.Debug|Any CPU.Build.0 = Debug|Any CPU - {FA5F1645-F6CD-4DEF-99C0-84F2913875EC}.Release|Any CPU.ActiveCfg = Release|Any CPU - {FA5F1645-F6CD-4DEF-99C0-84F2913875EC}.Release|Any CPU.Build.0 = Release|Any CPU - {3D130F44-F121-45D1-AD49-A670EA70D4A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3D130F44-F121-45D1-AD49-A670EA70D4A1}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3D130F44-F121-45D1-AD49-A670EA70D4A1}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3D130F44-F121-45D1-AD49-A670EA70D4A1}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {AE43714F-E6CF-4FF9-89E9-0F5263E6FCDB} - EndGlobalSection -EndGlobal diff --git a/RoslynAnalyzers/RoslynAnalyzers.slnx b/RoslynAnalyzers/RoslynAnalyzers.slnx new file mode 100644 index 00000000..11d29b07 --- /dev/null +++ b/RoslynAnalyzers/RoslynAnalyzers.slnx @@ -0,0 +1,6 @@ + + + + + + diff --git a/RoslynAnalyzers/build.sh b/RoslynAnalyzers/build.sh new file mode 100644 index 00000000..a156d8c1 --- /dev/null +++ b/RoslynAnalyzers/build.sh @@ -0,0 +1,4 @@ +#!/bin/sh + +dotnet build -c Release RoslynAnalyzers.slnx +dotnet pack -c Release -o $PWD JonSkeet.RoslynAnalyzers/JonSkeet.RoslynAnalyzers.Package diff --git a/WpfUtil/JonSkeet.CoreAppUtil/NullExtensions.cs b/WpfUtil/JonSkeet.CoreAppUtil/NullExtensions.cs new file mode 100644 index 00000000..2a9e3405 --- /dev/null +++ b/WpfUtil/JonSkeet.CoreAppUtil/NullExtensions.cs @@ -0,0 +1,18 @@ +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; + +namespace JonSkeet.CoreAppUtil; + +#nullable enable + +/// +/// Extension methods to make working with null values (and nullable reference types) simpler. +/// +public static class NullExtensions +{ + public static T OrThrow([NotNull] this T? text, [CallerArgumentExpression(nameof(text))] string? message = null) where T : class => + text ?? throw new InvalidDataException($"No value for '{message}'"); + + public static T OrThrow([NotNull] this T? text, [CallerArgumentExpression(nameof(text))] string? message = null) where T : struct => + text ?? throw new InvalidDataException($"No value for '{message}'"); +} diff --git a/WpfUtil/JonSkeet.CoreAppUtil/SelectableCollection.cs b/WpfUtil/JonSkeet.CoreAppUtil/SelectableCollection.cs index 980f2615..ef61b445 100644 --- a/WpfUtil/JonSkeet.CoreAppUtil/SelectableCollection.cs +++ b/WpfUtil/JonSkeet.CoreAppUtil/SelectableCollection.cs @@ -117,6 +117,28 @@ public void MaybeSelectFirst() } } + /// + /// Selects the next item, if there is one. + /// + public void MaybeSelectNext() + { + if (SelectedIndex + 1 < Count) + { + SelectedIndex++; + } + } + + /// + /// Selects the previous item, if there is one. + /// + public void MaybeSelectPrevious() + { + if (SelectedIndex > 0) + { + SelectedIndex--; + } + } + /// /// Clears any existing selection. (This is equivalent to setting /// to -1, or to null.)