diff --git a/.gitignore b/.gitignore index 0ddc313..db7c003 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ -bin/ -obj/ -*.user* -*.nu* -.* -._* +bin/ +obj/ +*.user* +*.nu* +.* +._* diff --git a/AmiClient.Helpers.cs b/AmiClient.Helpers.cs new file mode 100644 index 0000000..0905b13 --- /dev/null +++ b/AmiClient.Helpers.cs @@ -0,0 +1,109 @@ +/* Copyright © 2019 Alex Forster. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Ami +{ + using System; + using System.Diagnostics; + using System.IO; + using System.Text; + using System.Collections.Generic; + using System.Collections.Concurrent; + using System.Threading; + using System.Threading.Tasks; + using System.Linq; + using System.Reactive.Linq; + using System.Security.Cryptography; + + public sealed partial class AmiClient + { + public async Task Login(String username, String secret, Boolean md5 = true) + { + if(username == null) + { + throw new ArgumentNullException(nameof(username)); + } + + if(secret == null) + { + throw new ArgumentNullException(nameof(secret)); + } + + AmiMessage request, response; + + if(md5) + { + request = new AmiMessage + { + { "Action", "Challenge" }, + { "AuthType", "MD5" }, + }; + + response = await this.Publish(request); + + if(!(response["Response"] ?? String.Empty).Equals("Success", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + var answer = MD5.Create().ComputeHash(Encoding.ASCII.GetBytes(response["Challenge"] + secret)); + + var key = ""; + + for(var i = 0; i < answer.Length; i++) + { + key += answer[i].ToString("x2"); + } + + request = new AmiMessage + { + { "Action", "Login" }, + { "AuthType", "MD5" }, + { "Username", username }, + { "Key", key }, + }; + + response = await this.Publish(request); + } + else + { + request = new AmiMessage + { + { "Action", "Login" }, + { "Username", username }, + { "Secret", secret }, + }; + + response = await this.Publish(request); + } + + return (response["Response"] ?? String.Empty).Equals("Success", StringComparison.OrdinalIgnoreCase); + } + + public async Task Logoff() + { + AmiMessage request, response; + + request = new AmiMessage + { + { "Action", "Logoff" }, + }; + + response = await this.Publish(request); + + return (response["Response"] ?? String.Empty).Equals("Goodbye", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/AmiClient.Observable.cs b/AmiClient.Observable.cs index e2f9225..a42266d 100644 --- a/AmiClient.Observable.cs +++ b/AmiClient.Observable.cs @@ -1,106 +1,107 @@ -/* Copyright © 2018 Alex Forster. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -namespace Ami -{ - using System; - using System.Diagnostics; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using System.Collections.Generic; - using System.Collections.Concurrent; - using System.Linq; - using System.IO; - using System.Security.Cryptography; - - public sealed partial class AmiClient : IDisposable, IObservable - { - private readonly ConcurrentDictionary, Subscription> observers = - new ConcurrentDictionary, Subscription>(); - - private void Dispatch(AmiMessage message) - { - foreach(var observer in this.observers.Keys) - { - observer.OnNext(message); - } - } - - private void Dispatch(Exception exception) - { - foreach(var observer in this.observers.Keys) - { - observer.OnError(exception); - - this.Unsubscribe(observer); - } - } - - public IDisposable Subscribe(IObserver observer) - { - if(observer == null) - { - throw new ArgumentNullException(nameof(observer)); - } - - var subscription = new Subscription(this, observer); - - this.observers.TryAdd(observer, subscription); - - return subscription; - } - - public void Unsubscribe(IObserver observer) - { - if(observer == null) - { - throw new ArgumentNullException(nameof(observer)); - } - - this.observers.TryRemove(observer, out _); - } - - public void Dispose() - { - foreach(var observer in this.observers.Keys) - { - observer.OnCompleted(); - - this.Unsubscribe(observer); - } - - this.processing = false; - } - - public sealed class Subscription : IDisposable - { - private readonly AmiClient client; - - private readonly IObserver observer; - - internal Subscription(AmiClient client, IObserver observer) - { - this.client = client; - this.observer = observer; - } - - public void Dispose() - { - this.client.Unsubscribe(this.observer); - } - } - } -} +/* Copyright © 2019 Alex Forster. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Ami +{ + using System; + using System.Diagnostics; + using System.IO; + using System.Text; + using System.Collections.Generic; + using System.Collections.Concurrent; + using System.Threading; + using System.Threading.Tasks; + using System.Linq; + using System.Reactive.Linq; + using System.Security.Cryptography; + + public sealed partial class AmiClient : IDisposable, IObservable + { + private readonly ConcurrentDictionary, Subscription> observers = + new ConcurrentDictionary, Subscription>(); + + private void Dispatch(AmiMessage message) + { + foreach(var observer in this.observers.Keys) + { + observer.OnNext(message); + } + } + + private void Dispatch(Exception exception) + { + foreach(var observer in this.observers.Keys) + { + observer.OnError(exception); + + this.Unsubscribe(observer); + } + } + + public IDisposable Subscribe(IObserver observer) + { + if(observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + var subscription = new Subscription(this, observer); + + Debug.Assert(this.observers.TryAdd(observer, subscription)); + + return subscription; + } + + public void Unsubscribe(IObserver observer) + { + if(observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + this.observers.TryRemove(observer, out _); + } + + public void Dispose() + { + foreach(var observer in this.observers.Keys) + { + observer.OnCompleted(); + + this.Unsubscribe(observer); + } + + this.processing = false; + } + + private sealed class Subscription : IDisposable + { + private readonly AmiClient client; + + private readonly IObserver observer; + + internal Subscription(AmiClient client, IObserver observer) + { + this.client = client; + this.observer = observer; + } + + public void Dispose() + { + this.client.Unsubscribe(this.observer); + } + } + } +} diff --git a/AmiClient.Worker.cs b/AmiClient.Worker.cs deleted file mode 100644 index 822dd08..0000000 --- a/AmiClient.Worker.cs +++ /dev/null @@ -1,148 +0,0 @@ -/* Copyright © 2018 Alex Forster. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -namespace Ami -{ - using System; - using System.Diagnostics; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using System.Collections.Generic; - using System.Collections.Concurrent; - using System.Linq; - using System.IO; - using System.Security.Cryptography; - - public sealed partial class AmiClient - { - public sealed class DataEventArgs : EventArgs - { - public readonly String Data; - - internal DataEventArgs(String data) - { - this.Data = data; - } - } - - public event EventHandler DataSent; - - public event EventHandler DataReceived; - - private readonly Object writerLock = new Object(); - - private readonly ConcurrentDictionary> inFlight = - new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); - - public async Task Publish(AmiMessage action) - { - try - { - var tcs = new TaskCompletionSource(TaskCreationOptions.AttachedToParent); - - Debug.Assert(this.inFlight.TryAdd(action["ActionID"], tcs)); - - var buffer = action.ToBytes(); - - lock(this.writerLock) this.stream.Write(buffer, 0, buffer.Length); - - this.DataSent?.Invoke(this, new DataEventArgs(action.ToString())); - - var response = await tcs.Task; - - Debug.Assert(this.inFlight.TryRemove(response["ActionID"], out _)); - - return response; - } - catch(Exception ex) - { - this.Dispatch(ex); - - Debug.Assert(this.inFlight.TryRemove(action["ActionID"], out _)); - - return null; - } - } - - private Boolean processing; - - private void WorkerMain() - { - try - { - this.processing = true; - - var reader = new StreamReader(this.stream, new UTF8Encoding(false)); - - if(this.processing && this.stream != null && this.stream.CanRead) - { - var line = reader.ReadLine(); - - this.DataReceived?.Invoke(this, new DataEventArgs(line + "\r\n")); - - if(!line.StartsWith("Asterisk Call Manager", StringComparison.OrdinalIgnoreCase)) - { - throw new Exception("this does not appear to be an Asterisk server"); - } - } - - var lines = new List(); - - while(this.processing && this.stream != null && this.stream.CanRead) - { - lines.Add(reader.ReadLine()); - - if(lines.Last() != String.Empty) - { - continue; - } - - this.DataReceived?.Invoke(this, new DataEventArgs(String.Join("\r\n", lines) + "\r\n")); - - var message = new AmiMessage(); - - foreach(var line in lines.Where(line => line != String.Empty)) - { - var kv = line.Split(new[] { ':' }, 2); - - Debug.Assert(kv.Length == 2); - - message.Add(kv[0], kv[1]); - } - - if(message["Response"] != null && this.inFlight.TryGetValue(message["ActionID"], out var tcs)) - { - Debug.Assert(tcs.TrySetResult(message)); - } - else - { - this.Dispatch(message); - } - - lines.Clear(); - } - } - catch(ThreadAbortException) - { - Thread.ResetAbort(); - } - catch(Exception ex) - { - this.Dispatch(ex); - } - } - } -} diff --git a/AmiClient.cs b/AmiClient.cs index abf4af6..010d182 100644 --- a/AmiClient.cs +++ b/AmiClient.cs @@ -1,125 +1,183 @@ -/* Copyright © 2018 Alex Forster. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -namespace Ami -{ - using System; - using System.Diagnostics; - using System.Text; - using System.Threading; - using System.Threading.Tasks; - using System.Collections.Generic; - using System.Collections.Concurrent; - using System.Linq; - using System.IO; - using System.Security.Cryptography; - - public sealed partial class AmiClient - { - private readonly Stream stream; - - private readonly Thread worker; - - public AmiClient(Stream stream) - { - this.stream = stream ?? throw new ArgumentNullException(nameof(stream)); - - Debug.Assert(stream.CanRead); - Debug.Assert(stream.CanWrite); - - this.worker = new Thread(this.WorkerMain) { IsBackground = true }; - - this.worker.Start(); - } - - public async Task Login(String username, String secret, Boolean md5 = true) - { - if(username == null) - { - throw new ArgumentNullException(nameof(username)); - } - - if(secret == null) - { - throw new ArgumentNullException(nameof(secret)); - } - - AmiMessage request, response; - - if(md5) - { - request = new AmiMessage - { - { "Action", "Challenge" }, - { "AuthType", "MD5" }, - }; - - response = await this.Publish(request); - - if(!(response["Response"] ?? String.Empty).Equals("Success", StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - var challengeResponse = MD5.Create() - .ComputeHash(Encoding.ASCII.GetBytes(response["Challenge"] + secret)); - - var key = ""; - - for(var i = 0; i < challengeResponse.Length; i++) - { - key += challengeResponse[i].ToString("x2"); - } - - request = new AmiMessage - { - { "Action", "Login" }, - { "AuthType", "MD5" }, - { "Username", username }, - { "Key", key }, - }; - - response = await this.Publish(request); - } - else - { - request = new AmiMessage - { - { "Action", "Login" }, - { "Username", username }, - { "Secret", secret }, - }; - - response = await this.Publish(request); - } - - return (response["Response"] ?? String.Empty).Equals("Success", StringComparison.OrdinalIgnoreCase); - } - - public async Task Logoff() - { - AmiMessage request, response; - - request = new AmiMessage - { - { "Action", "Logoff" }, - }; - - response = await this.Publish(request); - - return (response["Response"] ?? String.Empty).Equals("Goodbye", StringComparison.OrdinalIgnoreCase); - } - } -} +/* Copyright © 2019 Alex Forster. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Ami +{ + using System; + using System.Diagnostics; + using System.IO; + using System.Text; + using System.Collections.Generic; + using System.Collections.Concurrent; + using System.Threading; + using System.Threading.Tasks; + using System.Linq; + using System.Reactive.Linq; + using System.Security.Cryptography; + + using ByteArrayExtensions; + + public sealed partial class AmiClient + { + private readonly Stream stream; + + public AmiClient(Stream stream) + { + this.stream = stream ?? throw new ArgumentNullException(nameof(stream)); + + Debug.Assert(stream.CanRead); + Debug.Assert(stream.CanWrite); + + var lineObserver = this.ReadLines().ToObservable(); + var line = lineObserver.Take(1).Wait(); + + if(String.IsNullOrEmpty(line)) + { + throw new Exception($"this does not appear to be an Asterisk server ({line})"); + } + + this.DataReceived?.Invoke(this, new DataEventArgs(line + "\x0d\x0a")); + + if(!line.StartsWith("Asterisk Call Manager", StringComparison.OrdinalIgnoreCase)) + { + throw new Exception($"this does not appear to be an Asterisk server ({line})"); + } + + Task.Run(this.WorkerMain); + } + + public sealed class DataEventArgs : EventArgs + { + public readonly String Data; + + internal DataEventArgs(String data) + { + this.Data = data; + } + } + + public event EventHandler DataSent; + + public event EventHandler DataReceived; + + private readonly ConcurrentDictionary> inFlight = + new ConcurrentDictionary>(StringComparer.OrdinalIgnoreCase); + + public async Task Publish(AmiMessage action) + { + try + { + var tcs = new TaskCompletionSource(TaskCreationOptions.AttachedToParent); + + Debug.Assert(this.inFlight.TryAdd(action["ActionID"], tcs)); + + var buffer = action.ToBytes(); + + await this.stream.WriteAsync(buffer, 0, buffer.Length); + + this.DataSent?.Invoke(this, new DataEventArgs(action.ToString())); + + var response = await tcs.Task; + + Debug.Assert(this.inFlight.TryRemove(response["ActionID"], out _)); + + return response; + } + catch(Exception ex) + { + this.Dispatch(ex); + + Debug.Assert(this.inFlight.TryRemove(action["ActionID"], out _)); + + return null; + } + } + + private Byte[] readBuffer = new Byte[0]; + + private IEnumerable ReadLines() + { + var needle = new Byte[] { 0x0d, 0x0a }; + + while(true) + { + if(!this.readBuffer.Any()) + { + var bytes = new Byte[4096]; + var nrBytes = this.stream.Read(bytes, 0, bytes.Length); + if(nrBytes == 0) + { + break; + } + this.readBuffer = this.readBuffer.Append(bytes.Slice(0, nrBytes)); + } + while(true) + { + var crlfPos = this.readBuffer.Find(needle, 0, this.readBuffer.Length); + if(crlfPos == -1) + { + break; + } + var line = this.readBuffer.Slice(0, crlfPos); + this.readBuffer = this.readBuffer.Slice(crlfPos + needle.Length); + yield return Encoding.UTF8.GetString(line); + } + } + } + + private Boolean processing = true; + + private async Task WorkerMain() + { + try + { + var lineObserver = this.ReadLines().ToObservable(); + + while(this.processing) + { + var message = new AmiMessage(); + + await lineObserver + .TakeWhile(line => line != String.Empty) + .Do(line => + { + var kv = line.Split(new[] { ':' }, 2); + Debug.Assert(kv.Length == 2); + message.Add(kv[0], kv[1]); + }); + + this.DataReceived?.Invoke(this, new DataEventArgs(message.ToString())); + + if(message["Response"] != null && this.inFlight.TryGetValue(message["ActionID"], out var tcs)) + { + Debug.Assert(tcs.TrySetResult(message)); + } + else + { + this.Dispatch(message); + } + } + } + catch(ThreadAbortException) + { + Thread.ResetAbort(); + } + catch(Exception ex) + { + this.Dispatch(ex); + } + } + } +} diff --git a/AmiClient.csproj b/AmiClient.csproj index 735a6ff..3b8ea29 100644 --- a/AmiClient.csproj +++ b/AmiClient.csproj @@ -1,6 +1,5 @@  - netstandard2.0 false true alexforster @@ -12,21 +11,21 @@ https://github.com/alexforster/AmiClient/tree/v1.0.0 asterisk voip sip netstandard netcore AmiClient + netstandard2.0 - 7 + 7.3 full - bin\Debug\netcoreapp2.0\AmiClient.xml + + 7.1 - - - bin\Release\netcoreapp2.0\AmiClient.xml + full + + + true - - - @@ -34,4 +33,7 @@ + + + \ No newline at end of file diff --git a/AmiMessage.Serialization.cs b/AmiMessage.Serialization.cs index 47398d5..4ded60d 100644 --- a/AmiMessage.Serialization.cs +++ b/AmiMessage.Serialization.cs @@ -1,91 +1,91 @@ -/* Copyright © 2018 Alex Forster. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -namespace Ami -{ - using System; - using System.IO; - using System.Text; - using System.Collections; - using System.Collections.Generic; - using System.Linq; - - public sealed partial class AmiMessage - { - public static AmiMessage FromBytes(Byte[] bytes) - { - var result = new AmiMessage(); - - var stream = new MemoryStream(bytes); - - var reader = new StreamReader(stream, new UTF8Encoding(false)); - - for(var nrLine = 1;; nrLine++) - { - var line = reader.ReadLine(); - - if(line == null) - { - throw new ArgumentException("unterminated message", nameof(bytes)); - } - - if(line.Equals(String.Empty)) - { - break; // empty line terminates - } - - var kvp = line.Split(new[] { ':' }, 2); - - if(kvp.Length != 2) - { - throw new ArgumentException($"malformed field on line {nrLine}", nameof(bytes)); - } - - result.Add(kvp[0], kvp[1]); - } - - return result; - } - - public static AmiMessage FromString(String @string) - { - var bytes = Encoding.UTF8.GetBytes(@string); - - return AmiMessage.FromBytes(bytes); - } - - public Byte[] ToBytes() - { - var stream = new MemoryStream(); - - using(var writer = new StreamWriter(stream, new UTF8Encoding(false))) - { - foreach(var field in this.Fields) - { - writer.Write($"{field.Key}: {field.Value}\r\n"); - } - - writer.Write("\r\n"); - } - - return stream.ToArray(); - } - - public override String ToString() - { - return Encoding.UTF8.GetString(this.ToBytes()); - } - } -} +/* Copyright © 2019 Alex Forster. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Ami +{ + using System; + using System.IO; + using System.Text; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + + public sealed partial class AmiMessage + { + public static AmiMessage FromBytes(Byte[] bytes) + { + var result = new AmiMessage(); + + var stream = new MemoryStream(bytes); + + var reader = new StreamReader(stream, new UTF8Encoding(false)); + + for(var nrLine = 1;; nrLine++) + { + var line = reader.ReadLine(); + + if(line == null) + { + throw new ArgumentException("unterminated message", nameof(bytes)); + } + + if(line.Equals(String.Empty)) + { + break; // empty line terminates + } + + var kvp = line.Split(new[] { ':' }, 2); + + if(kvp.Length != 2) + { + throw new ArgumentException($"malformed field on line {nrLine}", nameof(bytes)); + } + + result.Add(kvp[0], kvp[1]); + } + + return result; + } + + public static AmiMessage FromString(String @string) + { + var bytes = Encoding.UTF8.GetBytes(@string); + + return AmiMessage.FromBytes(bytes); + } + + public Byte[] ToBytes() + { + var stream = new MemoryStream(); + + using(var writer = new StreamWriter(stream, new UTF8Encoding(false))) + { + foreach(var field in this.Fields) + { + writer.Write($"{field.Key}: {field.Value}\x0d\x0a"); + } + + writer.Write("\x0d\x0a"); + } + + return stream.ToArray(); + } + + public override String ToString() + { + return Encoding.UTF8.GetString(this.ToBytes()); + } + } +} diff --git a/AmiMessage.cs b/AmiMessage.cs index 37495f7..47c621d 100644 --- a/AmiMessage.cs +++ b/AmiMessage.cs @@ -1,102 +1,102 @@ -/* Copyright © 2018 Alex Forster. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -namespace Ami -{ - using System; - using System.IO; - using System.Text; - using System.Collections; - using System.Collections.Generic; - using System.Linq; - - public sealed partial class AmiMessage : IEnumerable> - { - public readonly List> Fields = new List>(); - - public DateTimeOffset Timestamp { get; private set; } - - public AmiMessage() - { - this.Timestamp = DateTimeOffset.Now; - } - - public String this[String key] - { - get - { - if(String.IsNullOrWhiteSpace(key)) - { - throw new ArgumentException(nameof(key)); - } - - return this.Fields - .Where(kvp => kvp.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) - .Select(el => el.Value) - .FirstOrDefault(); - } - - private set - { - if(String.IsNullOrWhiteSpace(key) || key.IndexOfAny(new[] { '\r', '\n' }) != -1) - { - throw new ArgumentException(nameof(key)); - } - - if(value == null || value.IndexOfAny(new[] { '\r', '\n' }) != -1) - { - throw new ArgumentException(nameof(value)); - } - - key = key.Trim(); - value = value.Trim(); - - if(key.Equals("ActionID", StringComparison.OrdinalIgnoreCase)) - { - // ActionIDs are overwritten so that the AmiMessage creator can override autogenerated values; - // all other keys can be added multiple times (though it is usually invalid to do so) - - this.Fields.RemoveAll(field => field.Key.Equals("ActionID", StringComparison.OrdinalIgnoreCase)); - } - - this.Fields.Add(new KeyValuePair(key, value)); - - if(key.Equals("Action", StringComparison.OrdinalIgnoreCase) && - this.Fields.Any(field => field.Key.Equals("ActionID", StringComparison.OrdinalIgnoreCase)) == false) - { - // add an autogenerated ActionID when the Action field is set - // only if an ActionID has not already been set - - this.Fields.Add(new KeyValuePair("ActionID", Guid.NewGuid().ToString("D"))); - } - } - } - - public IEnumerator> GetEnumerator() - { - return this.Fields.GetEnumerator(); - } - - IEnumerator IEnumerable.GetEnumerator() - { - return this.GetEnumerator(); - } - - public void Add(String key, String value) - { - this[key] = value; - } - } -} +/* Copyright © 2019 Alex Forster. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Ami +{ + using System; + using System.IO; + using System.Text; + using System.Collections; + using System.Collections.Generic; + using System.Linq; + + public sealed partial class AmiMessage : IEnumerable> + { + public readonly List> Fields = new List>(); + + public DateTimeOffset Timestamp { get; private set; } + + public AmiMessage() + { + this.Timestamp = DateTimeOffset.Now; + } + + public String this[String key] + { + get + { + if(String.IsNullOrWhiteSpace(key)) + { + throw new ArgumentException(nameof(key)); + } + + return this.Fields + .Where(kvp => kvp.Key.Equals(key, StringComparison.OrdinalIgnoreCase)) + .Select(el => el.Value) + .FirstOrDefault(); + } + + private set + { + if(String.IsNullOrWhiteSpace(key) || key.IndexOfAny(new[] { '\r', '\n' }) != -1) + { + throw new ArgumentException(nameof(key)); + } + + if(value == null || value.IndexOfAny(new[] { '\r', '\n' }) != -1) + { + throw new ArgumentException(nameof(value)); + } + + key = key.Trim(); + value = value.Trim(); + + if(key.Equals("ActionID", StringComparison.OrdinalIgnoreCase)) + { + // ActionIDs are overwritten so that the AmiMessage creator can override autogenerated values; + // all other keys can be added multiple times (though it is usually invalid to do so) + + this.Fields.RemoveAll(field => field.Key.Equals("ActionID", StringComparison.OrdinalIgnoreCase)); + } + + this.Fields.Add(new KeyValuePair(key, value)); + + if(key.Equals("Action", StringComparison.OrdinalIgnoreCase) && + this.Fields.Any(field => field.Key.Equals("ActionID", StringComparison.OrdinalIgnoreCase)) == false) + { + // add an autogenerated ActionID when the Action field is set + // only if an ActionID has not already been set + + this.Fields.Add(new KeyValuePair("ActionID", Guid.NewGuid().ToString("D"))); + } + } + } + + public IEnumerator> GetEnumerator() + { + return this.Fields.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return this.GetEnumerator(); + } + + public void Add(String key, String value) + { + this[key] = value; + } + } +} diff --git a/ByteArrayExtensions/ByteArrayExtensions.cs b/ByteArrayExtensions/ByteArrayExtensions.cs new file mode 100644 index 0000000..d66fcc8 --- /dev/null +++ b/ByteArrayExtensions/ByteArrayExtensions.cs @@ -0,0 +1,153 @@ +/* Copyright © 2019 Alex Forster. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +namespace Ami.ByteArrayExtensions +{ + using System; + using System.Diagnostics; + using System.Collections.Generic; + + internal static class ByteArrayExtensions + { + [DebuggerStepThrough] + public static Byte[] Prepend(this Byte[] @this, Byte[] items) + { + var result = new Byte[@this.Length + items.Length]; + + Buffer.BlockCopy(items, 0, result, 0, items.Length); + Buffer.BlockCopy(@this, 0, result, items.Length, @this.Length); + + return result; + } + + [DebuggerStepThrough] + public static Byte[] Append(this Byte[] @this, Byte[] items) + { + var result = new Byte[@this.Length + items.Length]; + + Buffer.BlockCopy(@this, 0, result, 0, @this.Length); + Buffer.BlockCopy(items, 0, result, @this.Length, items.Length); + + return result; + } + + [DebuggerStepThrough] + public static Byte[] Slice(this Byte[] @this, Int32 start) + { + var _start = (start < 0) ? (@this.Length + start) : (start); + var _end = (@this.Length); + + return @this.Slice(_start, _end); + } + + [DebuggerStepThrough] + public static Byte[] Slice(this Byte[] @this, Int32 start, Int32 end) + { + var _start = (start < 0) ? (@this.Length + start) : (start); + var _end = (end < 0) ? (@this.Length + end) : (end); + + if(_start < 0 || @this.Length < _start) + { + throw new ArgumentOutOfRangeException(nameof(start)); + } + + if(_end < _start || @this.Length < _end) + { + throw new ArgumentOutOfRangeException(nameof(end)); + } + + var result = new Byte[_end - _start]; + + Buffer.BlockCopy(@this, _start, result, 0, result.Length); + + return result; + } + + [DebuggerStepThrough] + public static Int32 Find(this Byte[] @this, Byte[] needle, Int32 start = 0) + { + var _start = (start < 0) ? (@this.Length + start) : (start); + var _end = (@this.Length); + + return @this.Find(needle, _start, _end); + } + + [DebuggerStepThrough] + public static Int32 Find(this Byte[] @this, Byte[] needle, Int32 start, Int32 end) + { + var _start = (start < 0) ? (@this.Length + start) : (start); + var _end = (end < 0) ? (@this.Length + end) : (end); + + var needlePos = 0; + + for(var i = _start; i < _end; i++) + { + if(@this[i] == needle[needlePos]) + { + if(++needlePos == needle.Length) + { + return i - needlePos + 1; + } + } + else + { + i -= needlePos; + needlePos = 0; + } + } + + return -1; + } + + [DebuggerStepThrough] + public static Int32[] FindAll(this Byte[] @this, Byte[] needle, Int32 start = 0) + { + var _start = (start < 0) ? (@this.Length + start) : (start); + var _end = (@this.Length); + + return @this.FindAll(needle, _start, _end); + } + + [DebuggerStepThrough] + public static Int32[] FindAll(this Byte[] @this, Byte[] needle, Int32 start, Int32 end) + { + var _start = (start < 0) ? (@this.Length + start) : (start); + var _end = (end < 0) ? (@this.Length + end) : (end); + + var matches = new List(); + + var needlePos = 0; + + for(var i = _start; i < _end; i++) + { + if(@this[i] == needle[needlePos]) + { + if(++needlePos == needle.Length) + { + matches.Add(i - needlePos + 1); + needlePos = 0; + } + } + else + { + i -= needlePos; + needlePos = 0; + } + } + + return matches.ToArray(); + } + } +} diff --git a/Properties/AssemblyInfo.cs b/Properties/AssemblyInfo.cs index d908f02..afe2084 100644 --- a/Properties/AssemblyInfo.cs +++ b/Properties/AssemblyInfo.cs @@ -1,29 +1,29 @@ -/* Copyright © 2017 Alex Forster. All rights reserved. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -using System; -using System.Reflection; - -[assembly: AssemblyTitle("AmiClient")] -[assembly: AssemblyProduct("AmiClient")] -[assembly: AssemblyCopyright("Copyright © 2017 Alex Forster. All rights reserved.")] - -[assembly: AssemblyVersion("1.0.0")] - -#if DEBUG -[assembly: AssemblyConfiguration("Debug")] -#else -[assembly: AssemblyConfiguration("Release")] -#endif +/* Copyright © 2019 Alex Forster. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +using System; +using System.Reflection; + +[assembly: AssemblyTitle("AmiClient")] +[assembly: AssemblyProduct("AmiClient")] +[assembly: AssemblyCopyright("Copyright © 2019 Alex Forster. All rights reserved.")] + +[assembly: AssemblyVersion("1.0.0")] + +#if DEBUG +[assembly: AssemblyConfiguration("Debug")] +#else +[assembly: AssemblyConfiguration("Release")] +#endif diff --git a/README.md b/README.md index 141936f..2144712 100644 --- a/README.md +++ b/README.md @@ -15,204 +15,156 @@ Asterisk Management Interface (AMI) client library for .NET **Note:** While it's not a dependency, you will probably want to use the `System.Reactive.Linq` package alongside this library. -### Quick start +## Quick start Here's an easy way to set up an Asterisk 13 development environment: - 1. Download a local copy of [this basic Asterisk configuration](https://github.com/asterisk/asterisk/tree/13/configs/basic-pbx) and place it in `~/Desktop/etc-asterisk` (or your preferred location) - 2. Run a local Asterisk 13 Docker container... + 1. Download a local copy of [this basic Asterisk configuration](https://github.com/asterisk/asterisk/tree/13/configs/basic-pbx) and place it in the directory `~/Desktop/etc-asterisk` (or your preferred location) + 2. Add a `manager.conf` file to your basic Asterisk configuration directory to enable the Asterisk Management Interface... - ```bash +``` +; manager.conf + +[general] +enabled = yes +bindaddr = 0.0.0.0 +port = 5038 + +[admin] +secret = amp111 +read = all +write = all +``` + + 3. Run a local Asterisk 13 Docker container... + +```bash docker run -dit --rm --privileged \ --name asterisk13-dev \ -p 5060:5060 -p 5060:5060/udp \ -p 10000-10500:10000-10500/udp \ -p 5038:5038 \ -v ~/Desktop/etc-asterisk:/etc/asterisk \ - cleardevice/docker-cert-asterisk13-ubuntu + asterisk13 ``` - 3. Use the example code at the bottom as a starting point for learning the *AmiClient* API... - -## Public API - -```csharp -public sealed class AmiMessage : IEnumerable> -{ - // creation - - public AmiMessage(); - - public DateTimeOffset Timestamp { get; } - - // deserialization - - public static AmiMessage FromBytes(Byte[] bytes); - - public static AmiMessage FromString(String @string); - - // direct field access - - public readonly List> Fields; - - // field initialization support - - public void Add(String key, String value); - - // field indexer support - - public String this[String key] { get; set; } - - // field enumeration support - - public IEnumerator> GetEnumerator(); - - IEnumerator IEnumerable.GetEnumerator(); - - // serialization - - public Byte[] ToBytes(); - - public override String ToString(); -} - -public sealed class AmiClient : IDisposable, IObservable -{ - // creation - - public AmiClient(Stream stream); - - // AMI protocol helpers - - public async Task Login(String username, String secret, Boolean md5 = true); - - public async Task Logoff(); - - // AMI protocol debugging - - public sealed class DataEventArgs : EventArgs - { - public readonly String Data; - } - - public event EventHandler DataSent; - - public event EventHandler DataReceived; - - // request/reply - - public async Task Publish(AmiMessage action); - - // IObservable - - public IDisposable Subscribe(IObserver observer); - - public void Unsubscribe(IObserver observer); - - public void Dispose(); -} -``` + 4. Use the example code below as a starting point for learning the *AmiClient* API... -## Example code +### Example code ```csharp using System; using System.Diagnostics; -using System.Linq; -using System.Threading.Tasks; using System.Net.Sockets; +using System.Threading.Tasks; using System.Reactive.Linq; using Ami; -internal static class Program +namespace Playground { - public static async Task Main(String[] args) - { - // To make testing possible, an AmiClient accepts any Stream object that is readable and writable. - // This means that the user must establish a TCP connection to the Asterisk AMI server separately. + internal static class Program + { + public static async Task Main(String[] args) + { + // To make testing possible, an AmiClient accepts any Stream object + // that is readable and writable. This means that the user is + // responsible for maintaining a TCP connection to the AMI server. - // Note: constructing an AmiClient object starts a background reader/writer thread + // It's actually pretty easy... - using(var socket = new TcpClient(hostname: "localhost", port: 5038)) - using(var client = new AmiClient(socket.GetStream())) - { - // Activity on the wire can be logged/debugged with the DataSent and DataReceived events. + using(var socket = new TcpClient(hostname: "127.0.0.1", port: 5038)) + using(var client = new AmiClient(socket.GetStream())) + { + // At this point we're connected and we've completed the AMI + // protocol handshake. + + // Activity on the wire can be observed and logged using the + // DataSent and DataReceived events... client.DataSent += (s, e) => Console.Error.Write(e.Data); client.DataReceived += (s, e) => Console.Error.Write(e.Data); - // Log in... + // First, let's authenticate using the Login() helper function... if(!await client.Login(username: "admin", secret: "amp111", md5: true)) { - Console.WriteLine("Login failed"); - return; + Console.WriteLine("Login failed"); + return; } - // Issue a PJSIPShowEndpoints command... + // Now let's issue a PJSIPShowEndpoints command... - var showEndpointsResponse = await client.Publish(new AmiMessage + var response = await client.Publish(new AmiMessage { - { "Action", "PJSIPShowEndpoints" }, - // Note: if not provided, a random ActionID is automatically generated by AmiMessage. + { "Action", "PJSIPShowEndpoints" }, }); - Debug.Assert(showEndpointsResponse["Response"] == "Success"); + // Because we didn't specify an ActionID, one was implicitly + // created for us by the Publish() method. That's how we track + // requests and responses, allowing this client to be used + // by multiple threads or tasks. - // After the PJSIPShowEndpoints command successfully executes, Asterisk will begin emitting - // EndpointList events. Each EndpointList event represents a single PJSIP endpoint, and uses - // the same ActionID as the PJSIPShowEndpoints command that caused it. + if(response["Response"] == "Success") + { + // After the PJSIPShowEndpoints command successfully executes, + // Asterisk will begin emitting EndpointList events. - // Once events have been emitted for all PJSIP endpoints, an EndpointListComplete event will - // be emitted, again using the same ActionID as the PJSIPShowEndpoints command that caused it. + // Each EndpointList event represents a single PJSIP endpoint, + // and has the same ActionID as the PJSIPShowEndpoints command + // that caused it. - // Here's how System.Reactive.Linq (Rx) lets us easily process these EndpointList events... + // Once events have been emitted for all PJSIP endpoints, + // an EndpointListComplete event will be emitted, again with + // the same ActionID as the PJSIPShowEndpoints command + // that caused it. - await client - .Where(message => message["ActionID"] == showEndpointsResponse["ActionID"]) - .TakeWhile(message => message["Event"] != "EndpointListComplete") - .Do(message => - { - Console.Out.WriteLine($"^^^ {message["ObjectName"]} ({message["DeviceState"]})\r\n"); - }); + // Using System.Reactive.Linq, all of that can be modeled with + // a simple Rx IObservable consumer... - // Log off... + await client + .Where(message => message["ActionID"] == response["ActionID"]) + .TakeWhile(message => message["Event"] != "EndpointListComplete") + .Do(message => Console.Out.WriteLine($"~~~ \"{message["ObjectName"]}\" ({message["DeviceState"]}) ~~~")); + } + + // We're done, so let's be a good client and use the Logoff() + // helper function... if(!await client.Logoff()) { - Console.WriteLine("Logoff failed"); - return; + Console.WriteLine("Logoff failed"); + return; } - } - } + } + } + } } ``` ### Example output ``` -Asterisk Call Manager/2.6.0 Action: Challenge -ActionID: c1a041b8-635d-49ee-9d0b-490c3824fb08 +ActionID: 2983f6de-0248-4697-a460-2d5249f7f7c2 AuthType: MD5 Response: Success -ActionID: c1a041b8-635d-49ee-9d0b-490c3824fb08 -Challenge: 152385155 +ActionID: 2983f6de-0248-4697-a460-2d5249f7f7c2 +Challenge: 186191623 Action: Login -ActionID: 49ab2f47-60fd-4dd9-9a40-b0e7fb715989 +ActionID: c3fac375-74e2-4185-b3bb-5159addb4c87 AuthType: MD5 Username: admin -Key: 574c088e9c21305bca95977fad139448 +Key: 81f46181e864c9501068d5db47ac0f24 Response: Success -ActionID: 49ab2f47-60fd-4dd9-9a40-b0e7fb715989 +ActionID: c3fac375-74e2-4185-b3bb-5159addb4c87 Message: Authentication accepted Action: PJSIPShowEndpoints -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 Event: FullyBooted Privilege: system,all @@ -220,25 +172,50 @@ Status: Fully Booted Event: SuccessfulAuth Privilege: security,all -Timestamp: 1510669385.568869 -EventTV: 2017-11-14T14:23:05.568+0000 +EventTV: 2019-01-19T18:54:28.841+0000 Severity: Informational Service: AMI EventVersion: 1 AccountID: admin -SessionID: 0x7fc420003c58 +SessionID: 0x7fcb00000d50 LocalAddress: IPV4/TCP/0.0.0.0/5038 -RemoteAddress: IPV4/TCP/172.17.0.1/48520 +RemoteAddress: IPV4/TCP/172.17.0.1/44538 UsingPassword: 0 -SessionTV: 2017-11-14T14:23:05.568+0000 +SessionTV: 2019-01-19T18:54:28.840+0000 Response: Success -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 EventList: start Message: A listing of Endpoints follows, presented as EndpointList events Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 +ObjectType: endpoint +ObjectName: 1107 +Transport: +Aor: 1107 +Auths: 1107 +OutboundAuths: +Contacts: +DeviceState: Unavailable +ActiveChannels: + +~~~ "1107" (Unavailable) ~~~ +Event: EndpointList +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 +ObjectType: endpoint +ObjectName: 1113 +Transport: +Aor: 1113 +Auths: 1113 +OutboundAuths: +Contacts: +DeviceState: Unavailable +ActiveChannels: + +~~~ "1113" (Unavailable) ~~~ +Event: EndpointList +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 ObjectType: endpoint ObjectName: 1106 Transport: @@ -249,10 +226,9 @@ Contacts: DeviceState: Unavailable ActiveChannels: -^^^ 1106 (Unavailable) - +~~~ "1106" (Unavailable) ~~~ Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 ObjectType: endpoint ObjectName: 1101 Transport: @@ -263,10 +239,9 @@ Contacts: DeviceState: Unavailable ActiveChannels: -^^^ 1101 (Unavailable) - +~~~ "1101" (Unavailable) ~~~ Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 ObjectType: endpoint ObjectName: 1103 Transport: @@ -277,10 +252,9 @@ Contacts: DeviceState: Unavailable ActiveChannels: -^^^ 1103 (Unavailable) - +~~~ "1103" (Unavailable) ~~~ Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 ObjectType: endpoint ObjectName: 1102 Transport: @@ -291,10 +265,9 @@ Contacts: DeviceState: Unavailable ActiveChannels: -^^^ 1102 (Unavailable) - +~~~ "1102" (Unavailable) ~~~ Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 ObjectType: endpoint ObjectName: 1114 Transport: @@ -305,10 +278,9 @@ Contacts: DeviceState: Unavailable ActiveChannels: -^^^ 1114 (Unavailable) - +~~~ "1114" (Unavailable) ~~~ Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 ObjectType: endpoint ObjectName: 1115 Transport: @@ -319,66 +291,48 @@ Contacts: DeviceState: Unavailable ActiveChannels: -^^^ 1115 (Unavailable) - +~~~ "1115" (Unavailable) ~~~ Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 -ObjectType: endpoint -ObjectName: dcs-endpoint -Transport: -Aor: dcs-aor -Auths: -OutboundAuths: dcs-auth -Contacts: dcs-aor/sip:sip.digiumcloud.net, -DeviceState: Not in use -ActiveChannels: - -^^^ dcs-endpoint (Not in use) - -Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 -Auths: 1109 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 ObjectType: endpoint ObjectName: 1109 Transport: Aor: 1109 +Auths: 1109 OutboundAuths: Contacts: DeviceState: Unavailable ActiveChannels: -^^^ 1109 (Unavailable) - +~~~ "1109" (Unavailable) ~~~ Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 ObjectType: endpoint -ObjectName: 1108 +ObjectName: 1110 Transport: -Aor: 1108 -Auths: 1108 +Aor: 1110 +Auths: 1110 OutboundAuths: Contacts: DeviceState: Unavailable ActiveChannels: -^^^ 1108 (Unavailable) - +~~~ "1110" (Unavailable) ~~~ Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 ObjectType: endpoint -ObjectName: 1110 +ObjectName: dcs-endpoint Transport: -Aor: 1110 -Auths: 1110 -OutboundAuths: -Contacts: -DeviceState: Unavailable +Aor: dcs-aor +Auths: +OutboundAuths: dcs-auth +Contacts: dcs-aor/sip:sip.digiumcloud.net, +DeviceState: Not in use ActiveChannels: -^^^ 1110 (Unavailable) - +~~~ "dcs-endpoint" (Not in use) ~~~ Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 ObjectType: endpoint ObjectName: 1111 Transport: @@ -389,52 +343,48 @@ Contacts: DeviceState: Unavailable ActiveChannels: -^^^ 1111 (Unavailable) - +~~~ "1111" (Unavailable) ~~~ Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 ObjectType: endpoint -ObjectName: 1112 +ObjectName: 1105 Transport: -Aor: 1112 -Auths: 1112 +Aor: 1105 +Auths: 1105 OutboundAuths: Contacts: DeviceState: Unavailable ActiveChannels: -^^^ 1112 (Unavailable) - +~~~ "1105" (Unavailable) ~~~ Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 ObjectType: endpoint -ObjectName: 1113 +ObjectName: 1108 Transport: -Aor: 1113 -Auths: 1113 +Aor: 1108 +Auths: 1108 OutboundAuths: Contacts: DeviceState: Unavailable ActiveChannels: -^^^ 1113 (Unavailable) - +~~~ "1108" (Unavailable) ~~~ Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 ObjectType: endpoint -ObjectName: 1105 +ObjectName: 1112 Transport: -Aor: 1105 -Auths: 1105 +Aor: 1112 +Auths: 1112 OutboundAuths: Contacts: DeviceState: Unavailable ActiveChannels: -^^^ 1105 (Unavailable) - +~~~ "1112" (Unavailable) ~~~ Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 ObjectType: endpoint ObjectName: 1104 Transport: @@ -445,31 +395,96 @@ Contacts: DeviceState: Unavailable ActiveChannels: -^^^ 1104 (Unavailable) - -Event: EndpointList -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 -ObjectType: endpoint -ObjectName: 1107 -Transport: -Aor: 1107 -Auths: 1107 -OutboundAuths: -Contacts: -DeviceState: Unavailable -ActiveChannels: - -^^^ 1107 (Unavailable) - +~~~ "1104" (Unavailable) ~~~ Event: EndpointListComplete -ActionID: 5554d3da-171f-486e-9895-11060a5dd990 +ActionID: b2c28e04-446f-483a-ac4d-51d0bb3c0ba9 EventList: Complete ListItems: 16 Action: Logoff -ActionID: 890b8d83-9bdd-43af-98aa-4b31be111d15 +ActionID: 645913f8-cb61-4b95-b7d5-371347b5db76 Response: Goodbye -ActionID: 890b8d83-9bdd-43af-98aa-4b31be111d15 +ActionID: 645913f8-cb61-4b95-b7d5-371347b5db76 Message: Thanks for all the fish. -``` \ No newline at end of file + +``` + +## Public API + +```csharp +public sealed class AmiMessage : IEnumerable> +{ + // creation + + public AmiMessage(); + + public DateTimeOffset Timestamp { get; } + + // deserialization + + public static AmiMessage FromBytes(Byte[] bytes); + + public static AmiMessage FromString(String @string); + + // direct field access + + public readonly List> Fields; + + // field initialization support + + public void Add(String key, String value); + + // field indexer support + + public String this[String key] { get; set; } + + // field enumeration support + + public IEnumerator> GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator(); + + // serialization + + public Byte[] ToBytes(); + + public override String ToString(); +} + +public sealed class AmiClient : IDisposable, IObservable +{ + // creation + + public AmiClient(Stream stream); + + // AMI protocol helpers + + public async Task Login(String username, String secret, Boolean md5 = true); + + public async Task Logoff(); + + // AMI protocol debugging + + public sealed class DataEventArgs : EventArgs + { + public readonly String Data; + } + + public event EventHandler DataSent; + + public event EventHandler DataReceived; + + // request/reply + + public async Task Publish(AmiMessage action); + + // IObservable + + public IDisposable Subscribe(IObserver observer); + + public void Unsubscribe(IObserver observer); + + public void Dispose(); +} +```